diff options
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']}") |
