diff options
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 240 | ||||
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 249 |
2 files changed, 243 insertions, 246 deletions
diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py index 0ccd42a..434a995 100644 --- a/scripts/seed_pearl_workspace.py +++ b/scripts/seed_pearl_workspace.py @@ -1,32 +1,37 @@ #!/usr/bin/env python3 """Seed a Linear workspace with Pearl's dogfooding conventions. -Creates, idempotently, via the Linear GraphQL API: +Brings a workspace to: - - a "Pearl" team whose workflow states are the six Agile columns - (icebox, triage queue, backlog, in-progress, done, cancelled), + - a "Pearl" team whose columns are the six Agile states + (Icebox, Triage Queue, Backlog, In Progress, Done, Canceled), - a "Pearl" project inside that team, - three Custom Views: Pearl Open Issues, Pearl Icebox, Pearl Inbox. -Re-running is safe: anything already present by name is left alone. +Re-running is safe: it reconciles against what's already there by name. -The six columns are typed so pearl's own grouped view reproduces the board -order. icebox / triage queue / backlog are all the "backlog" Linear type with -ascending positions, so pearl's (state-type, position) group ordering lays them -out in that sequence; in-progress / done / cancelled take the started / -completed / canceled types. +How the columns are built is shaped by what Linear actually allows (verified +live, not assumed): teamCreate seeds its own default columns, a team must keep +an unstarted state, and the Duplicate state is reserved. So this script never +archives. It renames Linear's defaults into the six Agile columns -- keeping +Backlog / In Progress / Done / Canceled, and repurposing the unstarted Todo as +Triage Queue -- and creates only the genuinely-new Icebox. Linear's reserved +Duplicate state is left alone (it's hidden in the board UI); the script reports +any other leftover column for you to tidy. -Linear's CustomViewCreateInput has no grouping field, so "Pearl Open Issues" -is created with its filter only -- group it by category in pearl with -`pearl-set-grouping` (or in Linear's UI). Linear's teamCreate also seeds its -own default workflow states; this script adds the six Agile columns but does -not remove those defaults (tidy them in the Linear UI, or see a later ---tidy-default-states pass). +Positions are set in a second pass with distinct nonzero values, because Linear +ignores a 0.0 position and appends a freshly-created state at a high one. + +Two notes on the result: + - Triage Queue is Linear's "unstarted" type, so pearl's grouped view (which + orders by state type, then position) lists it after the backlog columns, + even though the Linear board itself keeps your column order. + - CustomViewCreateInput has no grouping field, so Pearl Open Issues is created + with its filter only -- group it by category in pearl (pearl-set-grouping) + or the Linear UI. Usage: LINEAR_API_KEY=lin_api_... python3 seed_pearl_workspace.py [--dry-run] - -The key may also come from the LINEAR_API_KEY environment variable. """ import argparse @@ -34,7 +39,6 @@ import json import os import sys import urllib.request -from dataclasses import dataclass, field LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" @@ -42,60 +46,65 @@ TEAM_NAME = "Pearl" TEAM_KEY = "PEARL" PROJECT_NAME = "Pearl" -# The six Agile columns. icebox / triage queue / backlog share the "backlog" -# type with ascending positions so they order by position within one type; -# the rest take their natural Linear types. Colors are arbitrary but distinct. +# The six Agile columns. `sources` names the Linear default state a column is +# reconciled from when it isn't present by its own name yet (Triage Queue takes +# over the unstarted Todo). Positions are distinct and nonzero. TARGET_STATES = [ - {"name": "icebox", "type": "backlog", "color": "#bec2c8", "position": 0}, - {"name": "triage queue", "type": "backlog", "color": "#e2e2e2", "position": 1}, - {"name": "backlog", "type": "backlog", "color": "#95a2b3", "position": 2}, - {"name": "in-progress", "type": "started", "color": "#f2c94c", "position": 3}, - {"name": "done", "type": "completed", "color": "#0f9d58", "position": 4}, - {"name": "cancelled", "type": "canceled", "color": "#eb5757", "position": 5}, + {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, "sources": []}, + {"name": "Triage Queue", "type": "unstarted", "color": "#e2e2e2", "position": 2.0, + "sources": ["Todo"]}, + {"name": "Backlog", "type": "backlog", "color": "#95a2b3", "position": 3.0, "sources": []}, + {"name": "In Progress", "type": "started", "color": "#f2c94c", "position": 4.0, "sources": []}, + {"name": "Done", "type": "completed", "color": "#0f9d58", "position": 5.0, "sources": []}, + {"name": "Canceled", "type": "canceled", "color": "#eb5757", "position": 6.0, + "sources": ["Cancelled"]}, ] VIEW_NAMES = ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] def desired_views(project_id): - """Return the three view specs (name + IssueFilter filterData) for PROJECT_ID. - - Pearl Open Issues scopes to the project and drops completed/canceled issues; - Pearl Icebox and Pearl Inbox scope to the project and a single column. - """ + """Return the three view specs (name + IssueFilter filterData) for PROJECT_ID.""" proj = {"project": {"id": {"eq": project_id}}} return [ {"name": "Pearl Open Issues", "filterData": {**proj, "state": {"type": {"nin": ["completed", "canceled"]}}}}, {"name": "Pearl Icebox", - "filterData": {**proj, "state": {"name": {"eq": "icebox"}}}}, + "filterData": {**proj, "state": {"name": {"eq": "Icebox"}}}}, {"name": "Pearl Inbox", - "filterData": {**proj, "state": {"name": {"eq": "triage queue"}}}}, + "filterData": {**proj, "state": {"name": {"eq": "Triage Queue"}}}}, ] -@dataclass -class Plan: - create_team: bool - create_states: list = field(default_factory=list) - create_project: bool = False - create_views: list = field(default_factory=list) +def _target_aliases(target): + return {target["name"].lower(), *(s.lower() for s in target.get("sources", []))} -def plan_seed(current): - """Given a workspace snapshot CURRENT, decide what to create. +def reconcile_state_plan(existing, targets=None): + """Match each target column to an existing state, or mark it for creation. - CURRENT is {team, states, project, views}; team/project are dicts or None, - states/views are lists of dicts carrying at least a "name". Pure: no I/O. + EXISTING is a list of state dicts (id, name, type). Returns a list of + {target, match_id}: match_id is the id of the existing state a column + reconciles onto (its own name or a `sources` default, case-insensitive), or + None when the column has to be created. Pure: no I/O. Each existing state is + claimed by at most one target. """ - have_states = {s["name"] for s in current["states"]} - have_views = {v["name"] for v in current["views"]} - return Plan( - create_team=current["team"] is None, - create_states=[s for s in TARGET_STATES if s["name"] not in have_states], - create_project=current["project"] is None, - create_views=[{"name": n} for n in VIEW_NAMES if n not in have_views], - ) + targets = targets or TARGET_STATES + plan, used = [], set() + for target in targets: + aliases = _target_aliases(target) + match = next((s for s in existing + if s["id"] not in used and s["name"].lower() in aliases), None) + if match: + used.add(match["id"]) + plan.append({"target": target, "match_id": match["id"] if match else None}) + return plan + + +def leftover_state_names(existing, targets=None): + """Return names of existing states no target column claims (e.g. Duplicate).""" + claimed = {p["match_id"] for p in reconcile_state_plan(existing, targets) if p["match_id"]} + return [s["name"] for s in existing if s["id"] not in claimed] class LinearError(RuntimeError): @@ -103,8 +112,6 @@ class LinearError(RuntimeError): def _http_transport(api_key): - """Return a transport(query, variables) -> response dict over real HTTP.""" - def transport(query, variables): body = json.dumps({"query": query, "variables": variables}).encode() req = urllib.request.Request( @@ -112,12 +119,16 @@ def _http_transport(api_key): headers={"Authorization": api_key, "Content-Type": "application/json"}) with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode()) - return transport class LinearClient: - """Thin Linear GraphQL client; the only network boundary in this module.""" + """Thin Linear GraphQL client; the only network boundary in this module. + + Each method names its GraphQL operation distinctly (Teams, TeamStates, + TeamCreate, StateCreate, StateUpdate, Projects, ProjectCreate, CustomViews, + CustomViewCreate) so a fake transport can route on the operation name. + """ def __init__(self, api_key, transport=None): self._transport = transport or _http_transport(api_key) @@ -125,101 +136,107 @@ class LinearClient: def execute(self, query, variables): resp = self._transport(query, variables) if resp.get("errors"): - msgs = "; ".join(e.get("message", str(e)) for e in resp["errors"]) - raise LinearError(msgs) + raise LinearError("; ".join(e.get("message", str(e)) for e in resp["errors"])) return resp["data"] - def snapshot(self): - """Read the current Pearl team, its states, the Pearl project, and views.""" - teams = self.execute( - "query { teams { nodes { id key name " + def find_team(self): + data = self.execute( + "query Teams { teams { nodes { id key name " "states { nodes { id name type position } } } } }", {}) - team = next((t for t in teams["teams"]["nodes"] if t["name"] == TEAM_NAME), None) - states = team["states"]["nodes"] if team else [] - projects = self.execute("query { projects { nodes { id name } } }", {}) - project = next((p for p in projects["projects"]["nodes"] - if p["name"] == PROJECT_NAME), None) - views = self.execute("query { customViews { nodes { id name } } }", {}) - view_nodes = [v for v in views["customViews"]["nodes"] if v["name"] in VIEW_NAMES] - return {"team": team, "states": states, "project": project, "views": view_nodes} + return next((t for t in data["teams"]["nodes"] if t["name"] == TEAM_NAME), None) + + def team_states(self, team_id): + data = self.execute( + "query TeamStates($id: String!) { team(id: $id) " + "{ states { nodes { id name type position } } } }", {"id": team_id}) + return data["team"]["states"]["nodes"] def create_team(self): data = self.execute( - "mutation($input: TeamCreateInput!) { teamCreate(input: $input) " - "{ success team { id key name states { nodes { id name type } } } } }", + "mutation TeamCreate($input: TeamCreateInput!) { teamCreate(input: $input) " + "{ success team { id key name } } }", {"input": {"name": TEAM_NAME, "key": TEAM_KEY}}) return data["teamCreate"]["team"] def create_state(self, team_id, spec): data = self.execute( - "mutation($input: WorkflowStateCreateInput!) " + "mutation StateCreate($input: WorkflowStateCreateInput!) " "{ workflowStateCreate(input: $input) { success workflowState { id name } } }", {"input": {"teamId": team_id, "name": spec["name"], "type": spec["type"], "color": spec["color"], "position": spec["position"]}}) return data["workflowStateCreate"]["workflowState"] + def update_state(self, state_id, name, position): + self.execute( + "mutation StateUpdate($id: String!, $input: WorkflowStateUpdateInput!) " + "{ workflowStateUpdate(id: $id, input: $input) { success } }", + {"id": state_id, "input": {"name": name, "position": position}}) + + def find_project(self): + data = self.execute("query Projects { projects { nodes { id name } } }", {}) + return next((p for p in data["projects"]["nodes"] if p["name"] == PROJECT_NAME), None) + def create_project(self, team_id): data = self.execute( - "mutation($input: ProjectCreateInput!) { projectCreate(input: $input) " - "{ success project { id name } } }", + "mutation ProjectCreate($input: ProjectCreateInput!) " + "{ projectCreate(input: $input) { success project { id name } } }", {"input": {"name": PROJECT_NAME, "teamIds": [team_id]}}) return data["projectCreate"]["project"] + def existing_view_names(self): + data = self.execute("query CustomViews { customViews { nodes { id name } } }", {}) + return {v["name"] for v in data["customViews"]["nodes"]} + def create_view(self, team_id, spec): - data = self.execute( - "mutation($input: CustomViewCreateInput!) { customViewCreate(input: $input) " - "{ success customView { id name } } }", + self.execute( + "mutation CustomViewCreate($input: CustomViewCreateInput!) " + "{ customViewCreate(input: $input) { success customView { id name } } }", {"input": {"name": spec["name"], "teamId": team_id, "shared": True, "filterData": spec["filterData"]}}) - return data["customViewCreate"]["customView"] def seed(client): - """Create the Pearl team, columns, project, and views; idempotent. + """Reconcile the Pearl team, columns, project, and views; idempotent. - Returns a summary dict {team, project, states_created, views_created}. + Returns {team, project, states_created, views_created, leftover_states}. """ - current = client.snapshot() - team = current["team"] + team = client.find_team() if team is None: team = client.create_team() - current["states"] = team.get("states", {}).get("nodes", []) \ - if isinstance(team.get("states"), dict) else (team.get("states") or []) + states = client.team_states(team["id"]) # re-fetch: defaults aren't in the create reply + else: + states = team.get("states", {}).get("nodes", []) team_id = team["id"] - plan = plan_seed({**current, "team": team}) - states_created = [] - for spec in plan.create_states: - client.create_state(team_id, spec) - states_created.append(spec["name"]) - - project = current["project"] or client.create_project(team_id) + for entry in reconcile_state_plan(states): + target, match_id = entry["target"], entry["match_id"] + if match_id is None: + match_id = client.create_state(team_id, target)["id"] + states_created.append(target["name"]) + # Set name + position in one update; a freshly-created state needs the + # position pass because Linear appends it at a high position otherwise. + client.update_state(match_id, target["name"], target["position"]) + leftover = leftover_state_names(states) + + project = client.find_project() or client.create_project(team_id) project_id = project["id"] - have_views = {v["name"] for v in current["views"]} + have_views = client.existing_view_names() views_created = [] for spec in desired_views(project_id): if spec["name"] not in have_views: client.create_view(team_id, spec) views_created.append(spec["name"]) - # teamCreate seeds Linear's own default columns; report any team state that - # isn't one of the six so the user can tidy the board in the UI. Not - # auto-archived: a team's default state can't always be archived safely. - target_names = {s["name"] for s in TARGET_STATES} - leftover_states = [s["name"] for s in current["states"] - if s["name"] not in target_names] - - return {"team": team_id, "project": project_id, - "states_created": states_created, "views_created": views_created, - "leftover_states": leftover_states} + return {"team": team_id, "project": project_id, "states_created": states_created, + "views_created": views_created, "leftover_states": leftover} def main(argv=None): parser = argparse.ArgumentParser(description="Seed a Pearl Linear workspace.") parser.add_argument("--dry-run", action="store_true", - help="print the plan without creating anything") + help="print the reconcile plan without changing anything") args = parser.parse_args(argv) api_key = os.environ.get("LINEAR_API_KEY") @@ -229,12 +246,15 @@ def main(argv=None): client = LinearClient(api_key) if args.dry_run: - plan = plan_seed(client.snapshot()) + team = client.find_team() + states = team.get("states", {}).get("nodes", []) if team else [] print("Plan:") - print(f" create team: {plan.create_team}") - print(f" create states: {[s['name'] for s in plan.create_states]}") - print(f" create project: {plan.create_project}") - print(f" create views: {[v['name'] for v in plan.create_views]}") + print(f" team: {'exists' if team else 'create'}") + for entry in reconcile_state_plan(states): + verb = "update" if entry["match_id"] else "create" + print(f" {verb}: {entry['target']['name']}") + if states: + print(f" leftover columns: {leftover_state_names(states) or 'none'}") return 0 summary = seed(client) @@ -242,7 +262,7 @@ def main(argv=None): print(f" states created: {summary['states_created'] or 'none'}") print(f" views created: {summary['views_created'] or 'none'}") if summary["leftover_states"]: - print(f" tidy in Linear UI (Linear's default columns, not removed): " + print(f" leftover columns (left as-is; Duplicate is reserved/hidden): " f"{summary['leftover_states']}") return 0 diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py index 2e7cb4a..ea22230 100644 --- a/scripts/tests/test_seed_pearl_workspace.py +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -1,10 +1,17 @@ """Tests for seed_pearl_workspace. The seeding logic splits into a pure planner (given a workspace snapshot, decide -what to create) and a thin Linear GraphQL client (the only network boundary). -The planner carries the behavior, so it gets exhaustive pure tests; the client -and the seed() orchestration get fake-transport tests that assert the right -mutations go out and that a second run is a no-op (idempotence). +what to create or reconcile) and a thin Linear GraphQL client (the only network +boundary). The planner carries the behavior and gets exhaustive pure tests; the +client and seed() orchestration get fake-transport tests. + +The state reconcile is shaped by what Linear actually allows, verified live: +teamCreate seeds its own default columns, a team must keep an unstarted state, +and the Duplicate state is reserved. So the script never archives -- it renames +Linear's defaults into the six Agile columns (repurposing the unstarted Todo as +Triage Queue), creates only the genuinely-new Icebox, and leaves Duplicate +alone. Positions are set with distinct nonzero values, since Linear ignores a +0.0 position and appends new states at a high one. """ import os @@ -20,31 +27,27 @@ import seed_pearl_workspace as seed # --- pure: target definitions ------------------------------------------------- def test_target_states_are_the_six_agile_columns_in_order(): - names = [s["name"] for s in seed.TARGET_STATES] - assert names == ["icebox", "triage queue", "backlog", - "in-progress", "done", "cancelled"] - - -def test_early_columns_share_backlog_type_with_ascending_positions(): - # icebox / triage queue / backlog all sort by position within one type, so - # pearl's (type-rank, position) grouping reproduces the board column order. - early = [s for s in seed.TARGET_STATES - if s["name"] in ("icebox", "triage queue", "backlog")] - assert all(s["type"] == "backlog" for s in early) - positions = [s["position"] for s in early] + assert [s["name"] for s in seed.TARGET_STATES] == [ + "Icebox", "Triage Queue", "Backlog", "In Progress", "Done", "Canceled"] + + +def test_target_state_types_and_distinct_nonzero_positions(): + by_name = {s["name"]: s for s in seed.TARGET_STATES} + assert by_name["Icebox"]["type"] == "backlog" + assert by_name["Triage Queue"]["type"] == "unstarted" # repurposed from Todo + assert by_name["Backlog"]["type"] == "backlog" + assert by_name["In Progress"]["type"] == "started" + assert by_name["Done"]["type"] == "completed" + assert by_name["Canceled"]["type"] == "canceled" + positions = [s["position"] for s in seed.TARGET_STATES] assert positions == sorted(positions) - assert len({s["position"] for s in seed.TARGET_STATES}) == 6 # all distinct + assert all(p > 0 for p in positions) # Linear ignores a 0.0 position + assert len(set(positions)) == 6 # distinct -def test_later_columns_map_to_distinct_linear_types(): - by_name = {s["name"]: s["type"] for s in seed.TARGET_STATES} - assert by_name["in-progress"] == "started" - assert by_name["done"] == "completed" - assert by_name["cancelled"] == "canceled" - - -def test_every_state_has_a_color(): - assert all(s.get("color") for s in seed.TARGET_STATES) +def test_triage_queue_sources_the_default_todo(): + tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage Queue") + assert "Todo" in tq["sources"] def test_view_specs_names_and_project_scope(): @@ -56,68 +59,68 @@ def test_view_specs_names_and_project_scope(): def test_open_view_excludes_completed_and_cancelled(): - open_view = next(v for v in seed.desired_views("p") - if v["name"] == "Pearl Open Issues") + open_view = next(v for v in seed.desired_views("p") if v["name"] == "Pearl Open Issues") nin = open_view["filterData"]["state"]["type"]["nin"] assert "completed" in nin and "canceled" in nin -def test_icebox_and_inbox_views_filter_to_their_state(): +def test_icebox_and_inbox_views_filter_to_their_column(): views = {v["name"]: v for v in seed.desired_views("p")} - assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "icebox" - assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "triage queue" + assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "Icebox" + assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "Triage Queue" -# --- pure: the planner -------------------------------------------------------- +# --- pure: the state reconcile planner ---------------------------------------- -EMPTY = {"team": None, "states": [], "project": None, "views": []} +LINEAR_DEFAULTS = [ + {"id": "d1", "name": "Backlog", "type": "backlog"}, + {"id": "d2", "name": "Todo", "type": "unstarted"}, + {"id": "d3", "name": "In Progress", "type": "started"}, + {"id": "d4", "name": "Done", "type": "completed"}, + {"id": "d5", "name": "Canceled", "type": "canceled"}, + {"id": "d6", "name": "Duplicate", "type": "duplicate"}, +] -def test_plan_on_empty_workspace_creates_everything(): - plan = seed.plan_seed(EMPTY) - assert plan.create_team is True - assert [s["name"] for s in plan.create_states] == [s["name"] for s in seed.TARGET_STATES] - assert plan.create_project is True - assert [v["name"] for v in plan.create_views] == [ - "Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] +def _plan_by_target(existing): + return {p["target"]["name"]: p["match_id"] + for p in seed.reconcile_state_plan(existing)} + + +def test_reconcile_against_linear_defaults_renames_and_creates(): + plan = _plan_by_target(LINEAR_DEFAULTS) + # Icebox is genuinely new; the rest map onto a default (Triage Queue <- Todo). + assert plan["Icebox"] is None + assert plan["Triage Queue"] == "d2" # the unstarted Todo + assert plan["Backlog"] == "d1" + assert plan["In Progress"] == "d3" + assert plan["Done"] == "d4" + assert plan["Canceled"] == "d5" -def test_plan_is_idempotent_when_everything_exists(): - current = { - "team": {"id": "t1", "name": "Pearl"}, - "states": [{"name": n} for n in - ["icebox", "triage queue", "backlog", "in-progress", "done", "cancelled"]], - "project": {"id": "p1", "name": "Pearl"}, - "views": [{"name": n} for n in - ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"]], - } - plan = seed.plan_seed(current) - assert plan.create_team is False - assert plan.create_states == [] - assert plan.create_project is False - assert plan.create_views == [] - - -def test_plan_creates_only_missing_states_and_views(): - current = { - "team": {"id": "t1", "name": "Pearl"}, - "states": [{"name": "backlog"}, {"name": "done"}], - "project": {"id": "p1", "name": "Pearl"}, - "views": [{"name": "Pearl Icebox"}], - } - plan = seed.plan_seed(current) - assert plan.create_team is False - assert plan.create_project is False - assert [s["name"] for s in plan.create_states] == [ - "icebox", "triage queue", "in-progress", "cancelled"] - assert [v["name"] for v in plan.create_views] == [ - "Pearl Open Issues", "Pearl Inbox"] +def test_reconcile_is_idempotent_against_already_named_columns(): + existing = [{"id": f"s{i}", "name": n, "type": "x"} for i, n in enumerate( + ["Icebox", "Triage Queue", "Backlog", "In Progress", "Done", "Canceled"])] + plan = _plan_by_target(existing) + assert all(mid is not None for mid in plan.values()) # everything matches, nothing created + + +def test_reconcile_matches_case_insensitively(): + # the half-seeded board carried a lowercase "icebox" + existing = [{"id": "s1", "name": "icebox", "type": "backlog"}] + plan = _plan_by_target(existing) + assert plan["Icebox"] == "s1" + + +def test_leftover_state_names_reports_unmatched(): + # Duplicate isn't a target and stays; report it for the user. + assert seed.leftover_state_names(LINEAR_DEFAULTS) == ["Duplicate"] # --- boundary: a fake GraphQL transport --------------------------------------- class FakeTransport: - """Records GraphQL calls and replies from a scripted op->response map.""" + """Records GraphQL calls and replies from a scripted op-name -> response map.""" def __init__(self, responses): self.responses = responses @@ -133,85 +136,59 @@ class FakeTransport: return [op for op, _ in self.calls] -def test_seed_on_empty_workspace_issues_creates_in_order(): - state_n = {"i": 0} +def _ok(field, payload): + return {"data": {field: {"success": True, **payload}}} - def make_state(_v): - state_n["i"] += 1 - return {"data": {"workflowStateCreate": - {"success": True, "workflowState": {"id": f"s{state_n['i']}"}}}} +def test_seed_on_empty_workspace_reconciles_then_builds(): fake = FakeTransport({ - # snapshot queries: empty workspace - "teams": {"data": {"teams": {"nodes": []}}}, - "teamCreate": {"data": {"teamCreate": {"success": True, - "team": {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": []}}}}}, - "workflowStateCreate": make_state, - "projects": {"data": {"projects": {"nodes": []}}}, - "projectCreate": {"data": {"projectCreate": - {"success": True, "project": {"id": "p1", "name": "Pearl"}}}}, - "customViews": {"data": {"customViews": {"nodes": []}}}, - "customViewCreate": {"data": {"customViewCreate": - {"success": True, "customView": {"id": "v1"}}}}, + "Teams": {"data": {"teams": {"nodes": []}}}, + "TeamCreate": _ok("teamCreate", {"team": {"id": "t1", "key": "PEARL", "name": "Pearl"}}), + # states re-fetched after create: Linear's defaults + "TeamStates": {"data": {"team": {"states": {"nodes": LINEAR_DEFAULTS}}}}, + "StateCreate": _ok("workflowStateCreate", {"workflowState": {"id": "new1"}}), + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "Projects": {"data": {"projects": {"nodes": []}}}, + "ProjectCreate": _ok("projectCreate", {"project": {"id": "p1", "name": "Pearl"}}), + "CustomViews": {"data": {"customViews": {"nodes": []}}}, + "CustomViewCreate": _ok("customViewCreate", {"customView": {"id": "v1"}}), }) - client = seed.LinearClient("key", transport=fake) - summary = seed.seed(client) - - assert fake.ops().count("teamCreate") == 1 - assert fake.ops().count("workflowStateCreate") == 6 - assert fake.ops().count("projectCreate") == 1 - assert fake.ops().count("customViewCreate") == 3 + summary = seed.seed(seed.LinearClient("key", transport=fake)) + ops = fake.ops() + assert ops.count("TeamCreate") == 1 + assert ops.count("StateCreate") == 1 # only Icebox is new + assert ops.count("StateUpdate") == 6 # six columns positioned/renamed + assert ops.count("ProjectCreate") == 1 + assert ops.count("CustomViewCreate") == 3 assert summary["team"] == "t1" assert summary["project"] == "p1" + assert summary["leftover_states"] == ["Duplicate"] + assert "workflowStateArchive" not in ops # never archives -def test_seed_is_idempotent_against_a_populated_workspace(): - populated = { - "teams": {"data": {"teams": {"nodes": [ - {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": [{"id": f"s{i}", "name": n} - for i, n in enumerate( - ["icebox", "triage queue", "backlog", - "in-progress", "done", "cancelled"])]}}]}}}, - "projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, - "customViews": {"data": {"customViews": {"nodes": [ - {"id": "v1", "name": "Pearl Open Issues"}, - {"id": "v2", "name": "Pearl Icebox"}, - {"id": "v3", "name": "Pearl Inbox"}]}}}, - } - fake = FakeTransport(populated) - client = seed.LinearClient("key", transport=fake) - seed.seed(client) - for create_op in ("teamCreate", "workflowStateCreate", - "projectCreate", "customViewCreate"): - assert create_op not in fake.ops() - - -def test_seed_reports_leftover_default_states_without_archiving(): - # An existing team carrying the six columns plus Linear's leftover defaults: - # seed creates nothing and surfaces the defaults for manual tidy-up, and - # never issues an archive. - populated = { - "teams": {"data": {"teams": {"nodes": [ +def test_seed_is_idempotent_against_a_seeded_workspace(): + seeded_states = [{"id": f"s{i}", "name": n, "type": "x"} for i, n in enumerate( + ["Icebox", "Triage Queue", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])] + fake = FakeTransport({ + "Teams": {"data": {"teams": {"nodes": [ {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": [{"id": f"s{i}", "name": n} for i, n in enumerate( - ["icebox", "triage queue", "backlog", "in-progress", "done", - "cancelled", "Todo", "In Review"])]}}]}}}, - "projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, - "customViews": {"data": {"customViews": {"nodes": [ + "states": {"nodes": seeded_states}}]}}}, + "TeamStates": {"data": {"team": {"states": {"nodes": seeded_states}}}}, + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "Projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, + "CustomViews": {"data": {"customViews": {"nodes": [ {"id": "v1", "name": "Pearl Open Issues"}, {"id": "v2", "name": "Pearl Icebox"}, {"id": "v3", "name": "Pearl Inbox"}]}}}, - } - fake = FakeTransport(populated) - summary = seed.seed(seed.LinearClient("key", transport=fake)) - assert summary["leftover_states"] == ["Todo", "In Review"] - assert "workflowStateArchive" not in fake.ops() + }) + seed.seed(seed.LinearClient("key", transport=fake)) + ops = fake.ops() + for create_op in ("TeamCreate", "StateCreate", "ProjectCreate", "CustomViewCreate"): + assert create_op not in ops # nothing re-created def test_client_raises_on_graphql_errors(): - fake = FakeTransport({"teams": {"errors": [{"message": "bad key"}]}}) + fake = FakeTransport({"Teams": {"errors": [{"message": "bad key"}]}}) client = seed.LinearClient("key", transport=fake) with pytest.raises(seed.LinearError, match="bad key"): - client.execute("query { teams { nodes { id } } }", {}) + client.execute("query Teams { teams { nodes { id } } }", {}) |
