diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-07 00:46:29 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-07 00:46:29 -0500 |
| commit | eef4b44b461a21fb99eedc4a70cb3f25fc536ba9 (patch) | |
| tree | 4197b2dcbbdc7febd50580745f9af4ae25549cf8 /scripts/tests/test_seed_pearl_workspace.py | |
| parent | fef03a1f4f19c45a31928989aecb4ff8a47cea4a (diff) | |
| download | pearl-eef4b44b461a21fb99eedc4a70cb3f25fc536ba9.tar.gz pearl-eef4b44b461a21fb99eedc4a70cb3f25fc536ba9.zip | |
fix(scripts): seed column types for board order, healing type drift
The seed put Triage on the unstarted type and Backlog on the backlog type. Linear's board orders columns by type category first (backlog before unstarted) and only then by position, so it grouped Icebox and Backlog together and pushed Triage to the right of Backlog. Position couldn't fix it: a state's type is immutable (workflowStateUpdate takes name, color, description, and position only), so the columns had to be retyped at the source.
To make the board read Icebox, Triage, Backlog, two of the three pre-work columns have to be the backlog type and the third the unstarted one Linear renders last. So Icebox and Triage are now backlog and Backlog is the team's unstarted state, which also satisfies Linear's rule that a team keep one.
Since type can't change in place, the reconcile gained a recreate path: a column found with the wrong type gets a fresh state of the right type, its issues moved over, and the old one archived. The phases are ordered so a new unstarted state exists before the old one is archived, and so a name is freed before another column reuses it. That lets a re-run heal a workspace seeded under the old layout, which is how I fixed the live one (Triage and Backlog recreated, 32 issues moved). Matching now prefers a type-correct state and only recreates when there's none, so the same targets build a fresh team and heal an existing one.
pearl's own grouped view needed no change — it already orders sections the way Linear's board does, by type rank then position, so the migrated types sort it correctly.
Diffstat (limited to 'scripts/tests/test_seed_pearl_workspace.py')
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 198 |
1 files changed, 146 insertions, 52 deletions
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(): |
