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/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/seed_pearl_workspace.py')
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 241 |
1 files changed, 178 insertions, 63 deletions
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']}") |
