diff options
Diffstat (limited to 'scripts/seed_pearl_workspace.py')
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 240 |
1 files changed, 130 insertions, 110 deletions
diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py index 0ccd42a..434a995 100644 --- a/scripts/seed_pearl_workspace.py +++ b/scripts/seed_pearl_workspace.py @@ -1,32 +1,37 @@ #!/usr/bin/env python3 """Seed a Linear workspace with Pearl's dogfooding conventions. -Creates, idempotently, via the Linear GraphQL API: +Brings a workspace to: - - a "Pearl" team whose workflow states are the six Agile columns - (icebox, triage queue, backlog, in-progress, done, cancelled), + - a "Pearl" team whose columns are the six Agile states + (Icebox, Triage Queue, Backlog, In Progress, Done, Canceled), - a "Pearl" project inside that team, - three Custom Views: Pearl Open Issues, Pearl Icebox, Pearl Inbox. -Re-running is safe: anything already present by name is left alone. +Re-running is safe: it reconciles against what's already there by name. -The six columns are typed so pearl's own grouped view reproduces the board -order. icebox / triage queue / backlog are all the "backlog" Linear type with -ascending positions, so pearl's (state-type, position) group ordering lays them -out in that sequence; in-progress / done / cancelled take the started / -completed / canceled types. +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 Queue -- 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. -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 Linear's UI). Linear's teamCreate also seeds its -own default workflow states; this script adds the six Agile columns but does -not remove those defaults (tidy them in the Linear UI, or see a later ---tidy-default-states pass). +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 + 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] - -The key may also come from the LINEAR_API_KEY environment variable. """ import argparse @@ -34,7 +39,6 @@ import json import os import sys import urllib.request -from dataclasses import dataclass, field LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" @@ -42,60 +46,65 @@ TEAM_NAME = "Pearl" TEAM_KEY = "PEARL" PROJECT_NAME = "Pearl" -# The six Agile columns. icebox / triage queue / backlog share the "backlog" -# type with ascending positions so they order by position within one type; -# the rest take their natural Linear types. Colors are arbitrary but distinct. +# 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 +# over the unstarted Todo). Positions are distinct and nonzero. TARGET_STATES = [ - {"name": "icebox", "type": "backlog", "color": "#bec2c8", "position": 0}, - {"name": "triage queue", "type": "backlog", "color": "#e2e2e2", "position": 1}, - {"name": "backlog", "type": "backlog", "color": "#95a2b3", "position": 2}, - {"name": "in-progress", "type": "started", "color": "#f2c94c", "position": 3}, - {"name": "done", "type": "completed", "color": "#0f9d58", "position": 4}, - {"name": "cancelled", "type": "canceled", "color": "#eb5757", "position": 5}, + {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, "sources": []}, + {"name": "Triage Queue", "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": []}, + {"name": "Done", "type": "completed", "color": "#0f9d58", "position": 5.0, "sources": []}, + {"name": "Canceled", "type": "canceled", "color": "#eb5757", "position": 6.0, + "sources": ["Cancelled"]}, ] VIEW_NAMES = ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] def desired_views(project_id): - """Return the three view specs (name + IssueFilter filterData) for PROJECT_ID. - - Pearl Open Issues scopes to the project and drops completed/canceled issues; - Pearl Icebox and Pearl Inbox scope to the project and a single column. - """ + """Return the three view specs (name + IssueFilter filterData) for PROJECT_ID.""" proj = {"project": {"id": {"eq": project_id}}} return [ {"name": "Pearl Open Issues", "filterData": {**proj, "state": {"type": {"nin": ["completed", "canceled"]}}}}, {"name": "Pearl Icebox", - "filterData": {**proj, "state": {"name": {"eq": "icebox"}}}}, + "filterData": {**proj, "state": {"name": {"eq": "Icebox"}}}}, {"name": "Pearl Inbox", - "filterData": {**proj, "state": {"name": {"eq": "triage queue"}}}}, + "filterData": {**proj, "state": {"name": {"eq": "Triage Queue"}}}}, ] -@dataclass -class Plan: - create_team: bool - create_states: list = field(default_factory=list) - create_project: bool = False - create_views: list = field(default_factory=list) +def _target_aliases(target): + return {target["name"].lower(), *(s.lower() for s in target.get("sources", []))} -def plan_seed(current): - """Given a workspace snapshot CURRENT, decide what to create. +def reconcile_state_plan(existing, targets=None): + """Match each target column to an existing state, or mark it for creation. - CURRENT is {team, states, project, views}; team/project are dicts or None, - states/views are lists of dicts carrying at least a "name". Pure: no I/O. + 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. """ - have_states = {s["name"] for s in current["states"]} - have_views = {v["name"] for v in current["views"]} - return Plan( - create_team=current["team"] is None, - create_states=[s for s in TARGET_STATES if s["name"] not in have_states], - create_project=current["project"] is None, - create_views=[{"name": n} for n in VIEW_NAMES if n not in have_views], - ) + targets = targets or TARGET_STATES + plan, used = [], set() + 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}) + return plan + + +def leftover_state_names(existing, targets=None): + """Return names of existing states no target column claims (e.g. Duplicate).""" + claimed = {p["match_id"] for p in reconcile_state_plan(existing, targets) if p["match_id"]} + return [s["name"] for s in existing if s["id"] not in claimed] class LinearError(RuntimeError): @@ -103,8 +112,6 @@ class LinearError(RuntimeError): def _http_transport(api_key): - """Return a transport(query, variables) -> response dict over real HTTP.""" - def transport(query, variables): body = json.dumps({"query": query, "variables": variables}).encode() req = urllib.request.Request( @@ -112,12 +119,16 @@ def _http_transport(api_key): headers={"Authorization": api_key, "Content-Type": "application/json"}) with urllib.request.urlopen(req) as resp: return json.loads(resp.read().decode()) - return transport class LinearClient: - """Thin Linear GraphQL client; the only network boundary in this module.""" + """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. + """ def __init__(self, api_key, transport=None): self._transport = transport or _http_transport(api_key) @@ -125,101 +136,107 @@ class LinearClient: def execute(self, query, variables): resp = self._transport(query, variables) if resp.get("errors"): - msgs = "; ".join(e.get("message", str(e)) for e in resp["errors"]) - raise LinearError(msgs) + raise LinearError("; ".join(e.get("message", str(e)) for e in resp["errors"])) return resp["data"] - def snapshot(self): - """Read the current Pearl team, its states, the Pearl project, and views.""" - teams = self.execute( - "query { teams { nodes { id key name " + def find_team(self): + data = self.execute( + "query Teams { teams { nodes { id key name " "states { nodes { id name type position } } } } }", {}) - team = next((t for t in teams["teams"]["nodes"] if t["name"] == TEAM_NAME), None) - states = team["states"]["nodes"] if team else [] - projects = self.execute("query { projects { nodes { id name } } }", {}) - project = next((p for p in projects["projects"]["nodes"] - if p["name"] == PROJECT_NAME), None) - views = self.execute("query { customViews { nodes { id name } } }", {}) - view_nodes = [v for v in views["customViews"]["nodes"] if v["name"] in VIEW_NAMES] - return {"team": team, "states": states, "project": project, "views": view_nodes} + return next((t for t in data["teams"]["nodes"] if t["name"] == TEAM_NAME), None) + + def team_states(self, team_id): + data = self.execute( + "query TeamStates($id: String!) { team(id: $id) " + "{ states { nodes { id name type position } } } }", {"id": team_id}) + return data["team"]["states"]["nodes"] def create_team(self): data = self.execute( - "mutation($input: TeamCreateInput!) { teamCreate(input: $input) " - "{ success team { id key name states { nodes { id name type } } } } }", + "mutation TeamCreate($input: TeamCreateInput!) { teamCreate(input: $input) " + "{ success team { id key name } } }", {"input": {"name": TEAM_NAME, "key": TEAM_KEY}}) return data["teamCreate"]["team"] def create_state(self, team_id, spec): data = self.execute( - "mutation($input: WorkflowStateCreateInput!) " + "mutation StateCreate($input: WorkflowStateCreateInput!) " "{ workflowStateCreate(input: $input) { success workflowState { id name } } }", {"input": {"teamId": team_id, "name": spec["name"], "type": spec["type"], "color": spec["color"], "position": spec["position"]}}) return data["workflowStateCreate"]["workflowState"] + def update_state(self, state_id, name, position): + self.execute( + "mutation StateUpdate($id: String!, $input: WorkflowStateUpdateInput!) " + "{ workflowStateUpdate(id: $id, input: $input) { success } }", + {"id": state_id, "input": {"name": name, "position": position}}) + + 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) + def create_project(self, team_id): data = self.execute( - "mutation($input: ProjectCreateInput!) { projectCreate(input: $input) " - "{ success project { id name } } }", + "mutation ProjectCreate($input: ProjectCreateInput!) " + "{ projectCreate(input: $input) { success project { id name } } }", {"input": {"name": PROJECT_NAME, "teamIds": [team_id]}}) return data["projectCreate"]["project"] + def existing_view_names(self): + data = self.execute("query CustomViews { customViews { nodes { id name } } }", {}) + return {v["name"] for v in data["customViews"]["nodes"]} + def create_view(self, team_id, spec): - data = self.execute( - "mutation($input: CustomViewCreateInput!) { customViewCreate(input: $input) " - "{ success customView { id name } } }", + self.execute( + "mutation CustomViewCreate($input: CustomViewCreateInput!) " + "{ customViewCreate(input: $input) { success customView { id name } } }", {"input": {"name": spec["name"], "teamId": team_id, "shared": True, "filterData": spec["filterData"]}}) - return data["customViewCreate"]["customView"] def seed(client): - """Create the Pearl team, columns, project, and views; idempotent. + """Reconcile the Pearl team, columns, project, and views; idempotent. - Returns a summary dict {team, project, states_created, views_created}. + Returns {team, project, states_created, views_created, leftover_states}. """ - current = client.snapshot() - team = current["team"] + team = client.find_team() if team is None: team = client.create_team() - current["states"] = team.get("states", {}).get("nodes", []) \ - if isinstance(team.get("states"), dict) else (team.get("states") or []) + states = client.team_states(team["id"]) # re-fetch: defaults aren't in the create reply + else: + states = team.get("states", {}).get("nodes", []) team_id = team["id"] - plan = plan_seed({**current, "team": team}) - states_created = [] - for spec in plan.create_states: - client.create_state(team_id, spec) - states_created.append(spec["name"]) - - project = current["project"] or client.create_project(team_id) + 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"]) + leftover = leftover_state_names(states) + + project = client.find_project() or client.create_project(team_id) project_id = project["id"] - have_views = {v["name"] for v in current["views"]} + have_views = client.existing_view_names() views_created = [] for spec in desired_views(project_id): if spec["name"] not in have_views: client.create_view(team_id, spec) views_created.append(spec["name"]) - # teamCreate seeds Linear's own default columns; report any team state that - # isn't one of the six so the user can tidy the board in the UI. Not - # auto-archived: a team's default state can't always be archived safely. - target_names = {s["name"] for s in TARGET_STATES} - leftover_states = [s["name"] for s in current["states"] - if s["name"] not in target_names] - - return {"team": team_id, "project": project_id, - "states_created": states_created, "views_created": views_created, - "leftover_states": leftover_states} + return {"team": team_id, "project": project_id, "states_created": states_created, + "views_created": views_created, "leftover_states": leftover} def main(argv=None): parser = argparse.ArgumentParser(description="Seed a Pearl Linear workspace.") parser.add_argument("--dry-run", action="store_true", - help="print the plan without creating anything") + help="print the reconcile plan without changing anything") args = parser.parse_args(argv) api_key = os.environ.get("LINEAR_API_KEY") @@ -229,12 +246,15 @@ def main(argv=None): client = LinearClient(api_key) if args.dry_run: - plan = plan_seed(client.snapshot()) + team = client.find_team() + states = team.get("states", {}).get("nodes", []) if team else [] print("Plan:") - print(f" create team: {plan.create_team}") - print(f" create states: {[s['name'] for s in plan.create_states]}") - print(f" create project: {plan.create_project}") - print(f" create views: {[v['name'] for v in plan.create_views]}") + 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']}") + if states: + print(f" leftover columns: {leftover_state_names(states) or 'none'}") return 0 summary = seed(client) @@ -242,7 +262,7 @@ def main(argv=None): print(f" states created: {summary['states_created'] or 'none'}") print(f" views created: {summary['views_created'] or 'none'}") if summary["leftover_states"]: - print(f" tidy in Linear UI (Linear's default columns, not removed): " + print(f" leftover columns (left as-is; Duplicate is reserved/hidden): " f"{summary['leftover_states']}") return 0 |
