diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/README.org | 44 | ||||
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 241 | ||||
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 198 |
3 files changed, 351 insertions, 132 deletions
diff --git a/scripts/README.org b/scripts/README.org index 5d5ab6d..0f180c5 100644 --- a/scripts/README.org +++ b/scripts/README.org @@ -53,31 +53,41 @@ the repo. A convenient way to pass it: ** Notes +- *Column types are chosen for board order.* Linear's board orders columns by + type category first (backlog before unstarted), then by position within a + category, so position alone can't move a column across a type boundary. To get + Icebox, Triage, Backlog all left of In Progress in that order, Icebox and + Triage are the backlog type and Backlog is the unstarted state (rendered last + in the pre-work group). Backlog being the unstarted state also satisfies + Linear's rule that a team keep one. + - *Reconcile, not create-from-scratch.* Linear's =teamCreate= seeds its own default columns, and Linear dedups state names case-insensitively, so a naive - "create all six" run collides with the defaults. The script instead renames - the defaults into the six Agile columns (keeping Backlog, In Progress, Done, - Canceled, and repurposing the unstarted Todo as Triage) and creates - only the genuinely-new Icebox. - -- *It never archives.* A Linear team must keep at least one unstarted state, and - the Duplicate state is reserved, so neither can be removed by API. The script - leaves any column it doesn't claim (Duplicate, which Linear hides in the board - UI) and reports it rather than failing. - -- *Positions are set in a second pass with distinct nonzero values.* Linear + "create all six" run collides with the defaults. The script reconciles by + name: it reuses a same-named (or default-aliased) state of the right type, and + creates only what's genuinely missing. On a fresh team Icebox takes over the + default backlog "Backlog" and Backlog takes over the unstarted "Todo". + +- *Wrong-type columns are recreated.* A state's type is immutable + (=workflowStateUpdate= takes name/color/description/position only), so a column + found with the wrong type can't be fixed in place. The script creates a fresh + state with the right type, moves the old state's issues onto it, and archives + the old one. The phases are ordered so a new unstarted state exists before the + old unstarted one is archived, and so a name is freed before another column + reuses it. This is what lets a re-run heal a workspace seeded under the older + type layout. + +- *The Duplicate state is left alone.* It's reserved and Linear hides it in the + board UI; the script reports it as a leftover rather than touching it. + +- *Positions are set in a final pass with distinct nonzero values.* Linear ignores a 0.0 position and appends a freshly-created state at a high one, so - the script updates positions after the states exist. + the script sets positions after the states exist. - *Grouping isn't set at view-create time.* 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 the Linear UI. -- *Triage is the unstarted type* (it took over the default Todo). pearl's - grouped view orders sections by state type and then position, so Triage - lists after the backlog columns in pearl even though the Linear board keeps - your column order. - - *New issues default to Triage, not Backlog.* The script points the team's default new-issue state at Triage (the intake column) after the columns reconcile, so a fresh issue lands there. diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py index 4e29b76..bc5300a 100644 --- a/scripts/seed_pearl_workspace.py +++ b/scripts/seed_pearl_workspace.py @@ -3,7 +3,7 @@ Brings a workspace to: - - a "Pearl" team whose columns are the six Agile states + - a "Pearl" team whose columns are the six Agile states, in board order (Icebox, Triage, Backlog, In Progress, Done, Canceled), - a "Pearl" project inside that team, - three Custom Views: Pearl Open Issues, Pearl Icebox, Pearl Inbox. @@ -12,24 +12,28 @@ Re-running is safe: it reconciles against what's already there by name. 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 -- 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. - -Positions are set in a second pass with distinct nonzero values, because Linear +an unstarted state, the Duplicate state is reserved, and a state's `type` is +immutable -- workflowStateUpdate takes name/color/description/position only. + +That last constraint drives the column types. Linear's board orders columns by +type category first (backlog before unstarted), then by position within a +category, so position alone can't move a column across a type boundary. To get +Icebox, Triage, Backlog all left of In Progress in that order, two of them have +to be the backlog type and the third the unstarted type rendered last: + + - Icebox -> backlog + - Triage -> backlog + - Backlog -> unstarted (this is the team's required unstarted state) + +Because type can't be changed in place, a column found with the wrong type is +*recreated*: the script creates a fresh state with the right type, moves the old +state's issues onto it, and archives the old one. The phases are ordered so a +new unstarted state exists before the old unstarted one is archived, and so a +name is freed before another column reuses it. + +Positions are set in a final 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 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] """ @@ -46,16 +50,23 @@ TEAM_NAME = "Pearl" TEAM_KEY = "PEARL" PROJECT_NAME = "Pearl" -# 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 takes -# over the unstarted Todo). Positions are distinct and nonzero. +# The six Agile columns in board order. `type` is chosen so Linear's +# type-grouped board renders them in this order (see the module docstring): +# Icebox and Triage are backlog-type, Backlog is the unstarted state. `sources` +# names the Linear default a column reconciles from when no column already +# carries its own name -- on a fresh team Icebox takes over the default Backlog +# and Backlog takes over the unstarted Todo. Positions are distinct and nonzero. TARGET_STATES = [ - {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, "sources": []}, - {"name": "Triage", "type": "unstarted", "color": "#e2e2e2", "position": 2.0, + {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, + "sources": ["Backlog"]}, + {"name": "Triage", "type": "backlog", "color": "#e2e2e2", "position": 2.0, + "sources": []}, + {"name": "Backlog", "type": "unstarted", "color": "#95a2b3", "position": 3.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": "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"]}, ] @@ -80,28 +91,57 @@ def desired_views(project_id): ] -def _target_aliases(target): - return {target["name"].lower(), *(s.lower() for s in target.get("sources", []))} - - def reconcile_state_plan(existing, targets=None): - """Match each target column to an existing state, or mark it for creation. + """Decide, per target column, whether to update, recreate, or create it. 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. + {target, action, match_id}: + + - "update" -- a state already carries this column's identity with the + right type; rename/reposition it in place. match_id set. + - "recreate" -- a state matches but has the wrong type (type is immutable), + so it must be replaced. match_id is the old state's id. + - "create" -- nothing matches; make a new state. match_id is None. + + Matching prefers a type-correct candidate, so a column is only recreated when + no usable state of the right type exists. The passes: + + 1. exact name + correct type -> update + 2. source alias + correct type -> update (reuse a default, e.g. Todo -> Backlog) + 3. exact name, wrong type -> recreate (type is immutable) + + This makes the same target definitions fit both a fresh team and the live + workspace. On a fresh team Icebox reuses the backlog default "Backlog" and + Backlog reuses the unstarted "Todo" (pass 2). On the live workspace, where a + correctly-named Icebox already exists and there's no Todo, the wrong-type + Triage and Backlog fall to pass 3 and get recreated. Pure: no I/O. Each + existing state is claimed by at most one target. """ targets = targets or TARGET_STATES - plan, used = [], set() + used, result = set(), {} + + def claim(target, predicate, action): + if target["name"] in result: + return + m = next((s for s in existing if s["id"] not in used and predicate(s)), None) + if m: + used.add(m["id"]) + result[target["name"]] = (action, m["id"]) + + for target in targets: # pass 1: exact name + correct type + claim(target, lambda s, t=target: + s["name"].lower() == t["name"].lower() and s["type"] == t["type"], "update") + for target in targets: # pass 2: source alias + correct type + aliases = {s.lower() for s in target.get("sources", [])} + claim(target, lambda s, t=target, a=aliases: + s["name"].lower() in a and s["type"] == t["type"], "update") + for target in targets: # pass 3: exact name, wrong type + claim(target, lambda s, t=target: s["name"].lower() == t["name"].lower(), "recreate") + + plan = [] 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}) + action, match_id = result.get(target["name"], ("create", None)) + plan.append({"target": target, "action": action, "match_id": match_id}) return plan @@ -130,8 +170,9 @@ class LinearClient: """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. + TeamCreate, StateCreate, StateUpdate, StateArchive, StateIssues, IssueMove, + TeamUpdate, Projects, ProjectCreate, CustomViews, CustomViewCreate) so a fake + transport can route on the operation name. """ def __init__(self, api_key, transport=None): @@ -170,11 +211,42 @@ class LinearClient: "color": spec["color"], "position": spec["position"]}}) return data["workflowStateCreate"]["workflowState"] - def update_state(self, state_id, name, position): + def update_state(self, state_id, name=None, position=None): + fields = {} + if name is not None: + fields["name"] = name + if position is not None: + fields["position"] = position self.execute( "mutation StateUpdate($id: String!, $input: WorkflowStateUpdateInput!) " "{ workflowStateUpdate(id: $id, input: $input) { success } }", - {"id": state_id, "input": {"name": name, "position": position}}) + {"id": state_id, "input": fields}) + + def archive_state(self, state_id): + self.execute( + "mutation StateArchive($id: String!) " + "{ workflowStateArchive(id: $id) { success } }", {"id": state_id}) + + def issues_in_state(self, state_id): + """Return every issue id in a workflow state, paging through results.""" + ids, after = [], None + while True: + data = self.execute( + "query StateIssues($id: String!, $after: String) { workflowState(id: $id) " + "{ issues(first: 250, after: $after) " + "{ nodes { id } pageInfo { hasNextPage endCursor } } } }", + {"id": state_id, "after": after}) + conn = data["workflowState"]["issues"] + ids.extend(n["id"] for n in conn["nodes"]) + if not conn["pageInfo"]["hasNextPage"]: + return ids + after = conn["pageInfo"]["endCursor"] + + def move_issue(self, issue_id, state_id): + self.execute( + "mutation IssueMove($id: String!, $state: String!) " + "{ issueUpdate(id: $id, input: { stateId: $state }) { success } }", + {"id": issue_id, "state": state_id}) def set_default_issue_state(self, team_id, state_id): self.execute( @@ -208,7 +280,8 @@ class LinearClient: def seed(client): """Reconcile the Pearl team, columns, project, and views; idempotent. - Returns {team, project, states_created, views_created, leftover_states}. + Returns {team, project, states_created, states_recreated, issues_moved, + views_created, leftover_states, default_issue_state}. """ team = client.find_team() if team is None: @@ -220,25 +293,60 @@ def seed(client): current_default = (team.get("defaultIssueState") or {}).get("id") team_id = team["id"] - states_created = [] - resolved = {} - 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"]) - resolved[target["name"]] = match_id + plan = reconcile_state_plan(states) leftover = leftover_state_names(states) - # New issues should default to the intake column, not Backlog. + # Phase 1: free up the canonical names held by states we're about to replace, + # so the new state can be created under the real name. + for entry in plan: + if entry["action"] == "recreate": + client.update_state(entry["match_id"], name=f"{entry['target']['name']} (migrating)") + + # Phase 2: create new states -- genuinely new columns and type-healed + # replacements. A replacement's new type is set here (the only place type can + # be set), so the new unstarted Backlog exists before any archive in phase 5. + resolved, created, recreated = {}, [], [] + for entry in plan: + target = entry["target"] + if entry["action"] == "create": + resolved[target["name"]] = client.create_state(team_id, target)["id"] + created.append(target["name"]) + elif entry["action"] == "recreate": + resolved[target["name"]] = client.create_state(team_id, target)["id"] + recreated.append(target["name"]) + else: + resolved[target["name"]] = entry["match_id"] + + # Phase 3: move issues off each replaced state onto its replacement. + issues_moved = 0 + for entry in plan: + if entry["action"] == "recreate": + new_id = resolved[entry["target"]["name"]] + for issue_id in client.issues_in_state(entry["match_id"]): + client.move_issue(issue_id, new_id) + issues_moved += 1 + + # Phase 4: point the new-issue default at the intake column (before archiving + # the old default's state in phase 5). intake_id = resolved.get(INTAKE_STATE) default_set = bool(intake_id) and intake_id != current_default if default_set: client.set_default_issue_state(team_id, intake_id) + # Phase 5: archive the replaced states. A new unstarted state now exists, so + # archiving the old unstarted one is allowed. + for entry in plan: + if entry["action"] == "recreate": + client.archive_state(entry["match_id"]) + + # Phase 6: set each column's final name + position, in target order so a + # rename chain (a fresh team's Backlog -> Icebox, then Todo -> Backlog) never + # collides on a name an unprocessed column still holds. + for entry in plan: + target = entry["target"] + client.update_state(resolved[target["name"]], name=target["name"], + position=target["position"]) + project = client.find_project() or client.create_project(team_id) project_id = project["id"] @@ -249,7 +357,8 @@ def seed(client): client.create_view(team_id, spec) views_created.append(spec["name"]) - return {"team": team_id, "project": project_id, "states_created": states_created, + return {"team": team_id, "project": project_id, "states_created": created, + "states_recreated": recreated, "issues_moved": issues_moved, "views_created": views_created, "leftover_states": leftover, "default_issue_state": INTAKE_STATE if default_set else "unchanged"} @@ -272,16 +381,22 @@ def main(argv=None): print("Plan:") 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']}") + verb = entry["action"] + note = "" + if verb == "recreate": + moving = len(client.issues_in_state(entry["match_id"])) + note = f" (type change; moving {moving} issue(s))" + print(f" {verb:8} {entry['target']['name']}{note}") if states: print(f" leftover columns: {leftover_state_names(states) or 'none'}") return 0 summary = seed(client) print(f"Seeded Pearl workspace: team {summary['team']}, project {summary['project']}") - print(f" states created: {summary['states_created'] or 'none'}") - print(f" views created: {summary['views_created'] or 'none'}") + print(f" states created: {summary['states_created'] or 'none'}") + print(f" states recreated: {summary['states_recreated'] or 'none'} " + f"(moved {summary['issues_moved']} issue(s))") + print(f" views created: {summary['views_created'] or 'none'}") if summary["leftover_states"]: print(f" leftover columns (left as-is; Duplicate is reserved/hidden): " f"{summary['leftover_states']}") diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py index fdf967b..5f4147e 100644 --- a/scripts/tests/test_seed_pearl_workspace.py +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -1,17 +1,18 @@ """Tests for seed_pearl_workspace. The seeding logic splits into a pure planner (given a workspace snapshot, decide -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. +what to create, recreate, 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), 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. +the Duplicate state is reserved, and a state's type is immutable. Because the +board orders columns by type category first, the columns need specific types +(Icebox and Triage backlog, Backlog unstarted) to render in board order, and a +column found with the wrong type has to be recreated -- create with the right +type, move its issues, archive the old one. Positions are set with distinct +nonzero values, since Linear ignores a 0.0 position and appends new states high. """ import os @@ -31,11 +32,13 @@ def test_target_states_are_the_six_agile_columns_in_order(): "Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled"] -def test_target_state_types_and_distinct_nonzero_positions(): +def test_target_state_types_put_backlog_last_in_the_pre_work_group(): by_name = {s["name"]: s for s in seed.TARGET_STATES} + # Icebox and Triage are backlog-type; Backlog is the unstarted state, which + # Linear renders after the backlog columns -> board order Icebox, Triage, Backlog. assert by_name["Icebox"]["type"] == "backlog" - assert by_name["Triage"]["type"] == "unstarted" # repurposed from Todo - assert by_name["Backlog"]["type"] == "backlog" + assert by_name["Triage"]["type"] == "backlog" + assert by_name["Backlog"]["type"] == "unstarted" assert by_name["In Progress"]["type"] == "started" assert by_name["Done"]["type"] == "completed" assert by_name["Canceled"]["type"] == "canceled" @@ -45,9 +48,12 @@ def test_target_state_types_and_distinct_nonzero_positions(): assert len(set(positions)) == 6 # distinct -def test_triage_queue_sources_the_default_todo(): - tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage") - assert "Todo" in tq["sources"] +def test_sources_take_over_the_right_defaults_on_a_fresh_team(): + by_name = {s["name"]: s for s in seed.TARGET_STATES} + # On a fresh team Icebox takes over the default Backlog and Backlog takes + # over the unstarted Todo (keeping its type). + assert "Backlog" in by_name["Icebox"]["sources"] + assert "Todo" in by_name["Backlog"]["sources"] def test_view_specs_names_and_project_scope(): @@ -82,38 +88,66 @@ LINEAR_DEFAULTS = [ ] -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 <- Todo). - assert plan["Icebox"] is None - assert plan["Triage"] == "d2" # the unstarted Todo - assert plan["Backlog"] == "d1" - assert plan["In Progress"] == "d3" - assert plan["Done"] == "d4" - assert plan["Canceled"] == "d5" - - -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", "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 _entries_by_target(existing): + return {p["target"]["name"]: p for p in seed.reconcile_state_plan(existing)} + + +def test_reconcile_against_linear_defaults_reuses_defaults_and_creates_triage(): + plan = _entries_by_target(LINEAR_DEFAULTS) + # Icebox takes the default backlog Backlog; Backlog takes the unstarted Todo + # (both type-correct -> update). Triage is genuinely new. + assert plan["Icebox"]["action"] == "update" and plan["Icebox"]["match_id"] == "d1" + assert plan["Backlog"]["action"] == "update" and plan["Backlog"]["match_id"] == "d2" + assert plan["Triage"]["action"] == "create" + assert plan["In Progress"]["match_id"] == "d3" + assert plan["Done"]["match_id"] == "d4" + assert plan["Canceled"]["match_id"] == "d5" + + +def test_reconcile_exact_name_wins_over_a_source_alias(): + # A live "Backlog" must be claimed by the Backlog target, not stolen by + # Icebox's "Backlog" source alias. + existing = [{"id": "s1", "name": "Backlog", "type": "unstarted"}, + {"id": "s2", "name": "Icebox", "type": "backlog"}] + plan = _entries_by_target(existing) + assert plan["Backlog"]["match_id"] == "s1" + assert plan["Icebox"]["match_id"] == "s2" + + +def test_reconcile_marks_a_wrong_type_match_for_recreate(): + # The live workspace before the fix: Triage is unstarted, Backlog is backlog + # -- both the wrong type, so both must be recreated. + existing = [ + {"id": "s1", "name": "Icebox", "type": "backlog"}, + {"id": "s2", "name": "Triage", "type": "unstarted"}, + {"id": "s3", "name": "Backlog", "type": "backlog"}, + {"id": "s4", "name": "In Progress", "type": "started"}, + {"id": "s5", "name": "Done", "type": "completed"}, + {"id": "s6", "name": "Canceled", "type": "canceled"}, + ] + plan = _entries_by_target(existing) + assert plan["Triage"]["action"] == "recreate" and plan["Triage"]["match_id"] == "s2" + assert plan["Backlog"]["action"] == "recreate" and plan["Backlog"]["match_id"] == "s3" + assert plan["Icebox"]["action"] == "update" + + +def test_reconcile_is_idempotent_against_correctly_typed_columns(): + correct = [("Icebox", "backlog"), ("Triage", "backlog"), ("Backlog", "unstarted"), + ("In Progress", "started"), ("Done", "completed"), ("Canceled", "canceled")] + existing = [{"id": f"s{i}", "name": n, "type": t} for i, (n, t) in enumerate(correct)] + plan = _entries_by_target(existing) + assert all(e["action"] == "update" for e in plan.values()) 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" + plan = _entries_by_target(existing) + assert plan["Icebox"]["match_id"] == "s1" def test_leftover_state_names_reports_unmatched(): - # Duplicate isn't a target and stays; report it for the user. + # Duplicate isn't a target and stays; Todo is claimed (by Backlog), so only + # Duplicate is reported. assert seed.leftover_state_names(LINEAR_DEFAULTS) == ["Duplicate"] @@ -157,33 +191,93 @@ def test_seed_on_empty_workspace_reconciles_then_builds(): 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("StateCreate") == 1 # only Triage is new + assert ops.count("StateUpdate") == 6 # six columns named/positioned + assert ops.count("StateArchive") == 0 # nothing to recreate on a fresh team assert ops.count("TeamUpdate") == 1 # default new-issue state -> Triage assert ops.count("ProjectCreate") == 1 assert ops.count("CustomViewCreate") == 3 assert summary["team"] == "t1" assert summary["project"] == "p1" + assert summary["states_created"] == ["Triage"] + assert summary["states_recreated"] == [] assert summary["leftover_states"] == ["Duplicate"] assert summary["default_issue_state"] == "Triage" - # the default points at Triage (which reconciled from the unstarted Todo, d2) + # the default points at the freshly-created Triage team_update = next(v for op, v in fake.calls if op == "TeamUpdate") - assert team_update["input"]["defaultIssueStateId"] == "d2" - assert "workflowStateArchive" not in ops # never archives + assert team_update["input"]["defaultIssueStateId"] == "new1" + + +def test_seed_heals_type_drift_by_recreating_and_migrating(): + # The live workspace before the fix: Triage unstarted (the old default, + # empty), Backlog backlog (holds two issues). Both wrong type -> recreate. + live_states = [ + {"id": "s1", "name": "Icebox", "type": "backlog", "position": 1.0}, + {"id": "s2", "name": "Triage", "type": "unstarted", "position": 2.0}, + {"id": "s3", "name": "Backlog", "type": "backlog", "position": 3.0}, + {"id": "s4", "name": "In Progress", "type": "started", "position": 4.0}, + {"id": "s5", "name": "Done", "type": "completed", "position": 5.0}, + {"id": "s6", "name": "Canceled", "type": "canceled", "position": 6.0}, + {"id": "s7", "name": "Duplicate", "type": "duplicate", "position": 5.0}, + ] + new_ids = iter(["nTriage", "nBacklog"]) + + def make_state(_v): + return _ok("workflowStateCreate", {"workflowState": {"id": next(new_ids)}}) + + def state_issues(v): + nodes = [{"id": "i1"}, {"id": "i2"}] if v["id"] == "s3" else [] + return {"data": {"workflowState": {"issues": { + "nodes": nodes, "pageInfo": {"hasNextPage": False, "endCursor": None}}}}} + fake = FakeTransport({ + "Teams": {"data": {"teams": {"nodes": [ + {"id": "t1", "key": "PEARL", "name": "Pearl", + "defaultIssueState": {"id": "s2"}, + "states": {"nodes": live_states}}]}}}, + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "StateCreate": make_state, + "StateIssues": state_issues, + "IssueMove": _ok("issueUpdate", {"issue": {"id": "i"}}), + "StateArchive": _ok("workflowStateArchive", {}), + "TeamUpdate": _ok("teamUpdate", {}), + "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"}]}}}, + }) + summary = seed.seed(seed.LinearClient("key", transport=fake)) + ops = fake.ops() + assert ops.count("StateCreate") == 2 # new Triage + new Backlog + assert ops.count("StateArchive") == 2 # old Triage + old Backlog + assert ops.count("IssueMove") == 2 # the two issues off old Backlog + # every create happens before any archive, so a new unstarted state exists + # before the old unstarted one is archived + assert max(i for i, op in enumerate(ops) if op == "StateCreate") \ + < min(i for i, op in enumerate(ops) if op == "StateArchive") + assert summary["states_recreated"] == ["Triage", "Backlog"] + assert summary["issues_moved"] == 2 + # the issues land on the new Backlog, and the default moves to the new Triage + moves = [v for op, v in fake.calls if op == "IssueMove"] + assert all(m["state"] == "nBacklog" for m in moves) + team_update = next(v for op, v in fake.calls if op == "TeamUpdate") + assert team_update["input"]["defaultIssueStateId"] == "nTriage" -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", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])] + +def test_seed_is_idempotent_against_a_correctly_seeded_workspace(): + correct = [("Icebox", "backlog"), ("Triage", "backlog"), ("Backlog", "unstarted"), + ("In Progress", "started"), ("Done", "completed"), + ("Canceled", "canceled"), ("Duplicate", "duplicate")] + seeded_states = [{"id": f"s{i}", "name": n, "type": t, "position": float(i + 1)} + for i, (n, t) in enumerate(correct)] fake = FakeTransport({ # default already points at Triage (s1), so no team update fires either "Teams": {"data": {"teams": {"nodes": [ {"id": "t1", "key": "PEARL", "name": "Pearl", "defaultIssueState": {"id": "s1"}, "states": {"nodes": seeded_states}}]}}}, - "TeamStates": {"data": {"team": {"states": {"nodes": seeded_states}}}}, "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), - "TeamUpdate": _ok("teamUpdate", {}), "Projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, "CustomViews": {"data": {"customViews": {"nodes": [ {"id": "v1", "name": "Pearl Open Issues"}, @@ -192,9 +286,9 @@ def test_seed_is_idempotent_against_a_seeded_workspace(): }) 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 - assert "TeamUpdate" not in ops # default already correct + for absent in ("TeamCreate", "StateCreate", "StateArchive", "ProjectCreate", + "CustomViewCreate", "TeamUpdate"): + assert absent not in ops # nothing created, recreated, or repointed def test_client_raises_on_graphql_errors(): |
