diff options
Diffstat (limited to 'scripts/seed_pearl_workspace.py')
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py new file mode 100644 index 0000000..0ccd42a --- /dev/null +++ b/scripts/seed_pearl_workspace.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Seed a Linear workspace with Pearl's dogfooding conventions. + +Creates, idempotently, via the Linear GraphQL API: + + - a "Pearl" team whose workflow states are the six Agile columns + (icebox, triage queue, backlog, in-progress, done, cancelled), + - 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. + +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. + +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). + +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 +import json +import os +import sys +import urllib.request +from dataclasses import dataclass, field + +LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" + +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. +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}, +] + +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. + """ + 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"}}}}, + {"name": "Pearl Inbox", + "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 plan_seed(current): + """Given a workspace snapshot CURRENT, decide what to create. + + 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. + """ + 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], + ) + + +class LinearError(RuntimeError): + """A GraphQL request returned an errors array.""" + + +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( + LINEAR_GRAPHQL_URL, data=body, + 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.""" + + def __init__(self, api_key, transport=None): + self._transport = transport or _http_transport(api_key) + + 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) + 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 " + "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} + + def create_team(self): + data = self.execute( + "mutation($input: TeamCreateInput!) { teamCreate(input: $input) " + "{ success team { id key name states { nodes { id name type } } } } }", + {"input": {"name": TEAM_NAME, "key": TEAM_KEY}}) + return data["teamCreate"]["team"] + + def create_state(self, team_id, spec): + data = self.execute( + "mutation($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 create_project(self, team_id): + data = self.execute( + "mutation($input: ProjectCreateInput!) { projectCreate(input: $input) " + "{ success project { id name } } }", + {"input": {"name": PROJECT_NAME, "teamIds": [team_id]}}) + return data["projectCreate"]["project"] + + def create_view(self, team_id, spec): + data = self.execute( + "mutation($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. + + Returns a summary dict {team, project, states_created, views_created}. + """ + current = client.snapshot() + team = current["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 []) + 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) + project_id = project["id"] + + have_views = {v["name"] for v in current["views"]} + 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} + + +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") + args = parser.parse_args(argv) + + api_key = os.environ.get("LINEAR_API_KEY") + if not api_key: + print("LINEAR_API_KEY is not set", file=sys.stderr) + return 2 + + client = LinearClient(api_key) + if args.dry_run: + plan = plan_seed(client.snapshot()) + 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]}") + 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'}") + if summary["leftover_states"]: + print(f" tidy in Linear UI (Linear's default columns, not removed): " + f"{summary['leftover_states']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) |
