diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 18:27:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 18:27:47 -0500 |
| commit | 7bd9f49ed7cb8482226d70ffaaa3d83fde194710 (patch) | |
| tree | d3db8680e0e0091382fd0759ca5877c07c5fc45d | |
| parent | 4250bbb39ab1f01a09567ea8f202c2109a2dad30 (diff) | |
| download | pearl-7bd9f49ed7cb8482226d70ffaaa3d83fde194710.tar.gz pearl-7bd9f49ed7cb8482226d70ffaaa3d83fde194710.zip | |
fix(scripts): make Triage the intake column and the default new-issue state
Two problems showed up dogfooding the seeded board. New issues landed in Backlog, because Linear's teamCreate sets the team's default new-issue state to its own Backlog default and the seed never changed it. And the intake column read "Triage Queue" when "Triage" is the name we want.
The script now points the team's defaultIssueStateId at the intake column after the columns reconcile, so a fresh issue opens in Triage instead of Backlog. It skips the update when the default is already correct, so a re-run stays a no-op. The intake column is renamed Triage throughout: the target state, the Pearl Inbox view filter, and INTAKE_STATE. find_team reads defaultIssueState so the idempotence check has something to compare.
Both mutations were schema-introspected first: TeamUpdateInput.defaultIssueStateId and CustomViewUpdateInput.filterData. The live board was fixed the same way (rename, default-state, Inbox filter) and the dry-run against it comes back all-update, no-create. 13 tests cover the rename, the default-state set on a fresh seed, and the skip when it's already Triage.
| -rw-r--r-- | scripts/README.org | 12 | ||||
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 37 | ||||
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 28 |
3 files changed, 56 insertions, 21 deletions
diff --git a/scripts/README.org b/scripts/README.org index 5ee298e..e6bacd2 100644 --- a/scripts/README.org +++ b/scripts/README.org @@ -16,7 +16,7 @@ use it. When you add a script, add a heading here too. Brings a Linear workspace up to Pearl's dogfooding conventions in one run: -- a "Pearl" team whose columns are the six Agile states (Icebox, Triage Queue, +- a "Pearl" team whose columns are the six Agile states (Icebox, Triage, Backlog, In Progress, Done, Canceled), - a "Pearl" project inside that team, - three Custom Views: Pearl Open Issues, Pearl Icebox, Pearl Inbox. @@ -57,7 +57,7 @@ the repo. A convenient way to pass it: 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 Queue) and creates + 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 @@ -73,11 +73,15 @@ the repo. A convenient way to pass it: 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 Queue is the unstarted type* (it took over the default Todo). pearl's - grouped view orders sections by state type and then position, so Triage Queue +- *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. + * coverage-summary.el ** Purpose diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py index 434a995..4e29b76 100644 --- a/scripts/seed_pearl_workspace.py +++ b/scripts/seed_pearl_workspace.py @@ -4,7 +4,7 @@ Brings a workspace to: - a "Pearl" team whose columns are the six Agile states - (Icebox, Triage Queue, Backlog, In Progress, Done, Canceled), + (Icebox, Triage, Backlog, In Progress, Done, Canceled), - a "Pearl" project inside that team, - three Custom Views: Pearl Open Issues, Pearl Icebox, Pearl Inbox. @@ -15,7 +15,7 @@ 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 +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. @@ -23,7 +23,7 @@ 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 + - 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 @@ -47,11 +47,11 @@ 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 Queue takes +# reconciled from when it isn't present by its own name yet (Triage takes # over the unstarted Todo). Positions are distinct and nonzero. TARGET_STATES = [ {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, "sources": []}, - {"name": "Triage Queue", "type": "unstarted", "color": "#e2e2e2", "position": 2.0, + {"name": "Triage", "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": []}, @@ -60,6 +60,10 @@ TARGET_STATES = [ "sources": ["Cancelled"]}, ] +# New issues should land in the intake column, not Backlog. The team's +# defaultIssueState is pointed here after the columns reconcile. +INTAKE_STATE = "Triage" + VIEW_NAMES = ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] @@ -72,7 +76,7 @@ def desired_views(project_id): {"name": "Pearl Icebox", "filterData": {**proj, "state": {"name": {"eq": "Icebox"}}}}, {"name": "Pearl Inbox", - "filterData": {**proj, "state": {"name": {"eq": "Triage Queue"}}}}, + "filterData": {**proj, "state": {"name": {"eq": "Triage"}}}}, ] @@ -141,7 +145,7 @@ class LinearClient: def find_team(self): data = self.execute( - "query Teams { teams { nodes { id key name " + "query Teams { teams { nodes { id key name defaultIssueState { id } " "states { nodes { id name type position } } } } }", {}) return next((t for t in data["teams"]["nodes"] if t["name"] == TEAM_NAME), None) @@ -172,6 +176,12 @@ class LinearClient: "{ workflowStateUpdate(id: $id, input: $input) { success } }", {"id": state_id, "input": {"name": name, "position": position}}) + def set_default_issue_state(self, team_id, state_id): + self.execute( + "mutation TeamUpdate($id: String!, $input: TeamUpdateInput!) " + "{ teamUpdate(id: $id, input: $input) { success } }", + {"id": team_id, "input": {"defaultIssueStateId": state_id}}) + 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) @@ -204,11 +214,14 @@ def seed(client): if team is None: team = client.create_team() states = client.team_states(team["id"]) # re-fetch: defaults aren't in the create reply + current_default = None else: states = team.get("states", {}).get("nodes", []) + 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: @@ -217,8 +230,15 @@ def seed(client): # 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 leftover = leftover_state_names(states) + # New issues should default to the intake column, not Backlog. + 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) + project = client.find_project() or client.create_project(team_id) project_id = project["id"] @@ -230,7 +250,8 @@ def seed(client): views_created.append(spec["name"]) return {"team": team_id, "project": project_id, "states_created": states_created, - "views_created": views_created, "leftover_states": leftover} + "views_created": views_created, "leftover_states": leftover, + "default_issue_state": INTAKE_STATE if default_set else "unchanged"} def main(argv=None): diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py index ea22230..fdf967b 100644 --- a/scripts/tests/test_seed_pearl_workspace.py +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -9,7 +9,7 @@ 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 +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. """ @@ -28,13 +28,13 @@ import seed_pearl_workspace as seed def test_target_states_are_the_six_agile_columns_in_order(): assert [s["name"] for s in seed.TARGET_STATES] == [ - "Icebox", "Triage Queue", "Backlog", "In Progress", "Done", "Canceled"] + "Icebox", "Triage", "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["Triage"]["type"] == "unstarted" # repurposed from Todo assert by_name["Backlog"]["type"] == "backlog" assert by_name["In Progress"]["type"] == "started" assert by_name["Done"]["type"] == "completed" @@ -46,7 +46,7 @@ def test_target_state_types_and_distinct_nonzero_positions(): def test_triage_queue_sources_the_default_todo(): - tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage Queue") + tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage") assert "Todo" in tq["sources"] @@ -67,7 +67,7 @@ def test_open_view_excludes_completed_and_cancelled(): 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 Inbox"]["filterData"]["state"]["name"]["eq"] == "Triage" # --- pure: the state reconcile planner ---------------------------------------- @@ -89,9 +89,9 @@ def _plan_by_target(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). + # Icebox is genuinely new; the rest map onto a default (Triage <- Todo). assert plan["Icebox"] is None - assert plan["Triage Queue"] == "d2" # the unstarted Todo + assert plan["Triage"] == "d2" # the unstarted Todo assert plan["Backlog"] == "d1" assert plan["In Progress"] == "d3" assert plan["Done"] == "d4" @@ -100,7 +100,7 @@ def test_reconcile_against_linear_defaults_renames_and_creates(): 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"])] + ["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 @@ -148,6 +148,7 @@ def test_seed_on_empty_workspace_reconciles_then_builds(): "TeamStates": {"data": {"team": {"states": {"nodes": LINEAR_DEFAULTS}}}}, "StateCreate": _ok("workflowStateCreate", {"workflowState": {"id": "new1"}}), "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "TeamUpdate": _ok("teamUpdate", {}), "Projects": {"data": {"projects": {"nodes": []}}}, "ProjectCreate": _ok("projectCreate", {"project": {"id": "p1", "name": "Pearl"}}), "CustomViews": {"data": {"customViews": {"nodes": []}}}, @@ -158,23 +159,31 @@ def test_seed_on_empty_workspace_reconciles_then_builds(): 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("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["leftover_states"] == ["Duplicate"] + assert summary["default_issue_state"] == "Triage" + # the default points at Triage (which reconciled from the unstarted Todo, d2) + 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 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"])] + ["Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])] 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"}, @@ -185,6 +194,7 @@ def test_seed_is_idempotent_against_a_seeded_workspace(): 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 def test_client_raises_on_graphql_errors(): |
