#!/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())