From d8f60b1f86732b12e419d483a091634c31eb66c6 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 6 Jun 2026 17:11:06 -0500 Subject: feat(scripts): add a Linear workspace seed script scripts/seed_pearl_workspace.py stands up the Pearl dogfooding conventions in a Linear workspace: a "Pearl" team whose columns are the six Agile states (icebox, triage queue, backlog, in-progress, done, cancelled), a "Pearl" project, and three Custom Views (Pearl Open Issues, Pearl Icebox, Pearl Inbox). It's idempotent: it snapshots the workspace, plans only what's missing by name, and re-running creates nothing. Reusable for us and for end-users standing up the same setup. The six columns are typed so pearl's own grouped view reproduces the board order. icebox, triage queue, and backlog are all the backlog Linear type with ascending positions, so pearl's (state-type, position) ordering lays them out in sequence. The rest take the started, completed, and canceled types. Two limits the Linear API forced, both verified by schema introspection first. CustomViewCreateInput has no grouping field, so Pearl Open Issues is created with its filter and grouped by category later, in pearl (pearl-set-grouping) or the Linear UI. And teamCreate seeds its own default columns, so the script adds the six and reports the leftovers to tidy in the UI rather than auto-archiving, since a team's default state can't always be archived safely. The logic splits into a pure planner and a thin GraphQL client. The planner is unit-tested without mocks, the client and orchestration through a fake transport. 14 tests cover the targets, planning, idempotence, and the leftover report, and the snapshot queries ran live against a real workspace. The personal API key stays in a gitignored apikey.txt, never in the repo. --- scripts/seed_pearl_workspace.py | 251 +++++++++++++++++++++++++++++ scripts/tests/test_seed_pearl_workspace.py | 217 +++++++++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 scripts/seed_pearl_workspace.py create mode 100644 scripts/tests/test_seed_pearl_workspace.py (limited to 'scripts') 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()) diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py new file mode 100644 index 0000000..2e7cb4a --- /dev/null +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -0,0 +1,217 @@ +"""Tests for seed_pearl_workspace. + +The seeding logic splits into a pure planner (given a workspace snapshot, decide +what to create) and a thin Linear GraphQL client (the only network boundary). +The planner carries the behavior, so it gets exhaustive pure tests; the client +and the seed() orchestration get fake-transport tests that assert the right +mutations go out and that a second run is a no-op (idempotence). +""" + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import seed_pearl_workspace as seed + + +# --- pure: target definitions ------------------------------------------------- + +def test_target_states_are_the_six_agile_columns_in_order(): + names = [s["name"] for s in seed.TARGET_STATES] + assert names == ["icebox", "triage queue", "backlog", + "in-progress", "done", "cancelled"] + + +def test_early_columns_share_backlog_type_with_ascending_positions(): + # icebox / triage queue / backlog all sort by position within one type, so + # pearl's (type-rank, position) grouping reproduces the board column order. + early = [s for s in seed.TARGET_STATES + if s["name"] in ("icebox", "triage queue", "backlog")] + assert all(s["type"] == "backlog" for s in early) + positions = [s["position"] for s in early] + assert positions == sorted(positions) + assert len({s["position"] for s in seed.TARGET_STATES}) == 6 # all distinct + + +def test_later_columns_map_to_distinct_linear_types(): + by_name = {s["name"]: s["type"] for s in seed.TARGET_STATES} + assert by_name["in-progress"] == "started" + assert by_name["done"] == "completed" + assert by_name["cancelled"] == "canceled" + + +def test_every_state_has_a_color(): + assert all(s.get("color") for s in seed.TARGET_STATES) + + +def test_view_specs_names_and_project_scope(): + specs = seed.desired_views("proj-1") + assert [v["name"] for v in specs] == [ + "Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] + for v in specs: + assert v["filterData"]["project"]["id"]["eq"] == "proj-1" + + +def test_open_view_excludes_completed_and_cancelled(): + open_view = next(v for v in seed.desired_views("p") + if v["name"] == "Pearl Open Issues") + nin = open_view["filterData"]["state"]["type"]["nin"] + assert "completed" in nin and "canceled" in nin + + +def test_icebox_and_inbox_views_filter_to_their_state(): + views = {v["name"]: v for v in seed.desired_views("p")} + assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "icebox" + assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "triage queue" + + +# --- pure: the planner -------------------------------------------------------- + +EMPTY = {"team": None, "states": [], "project": None, "views": []} + + +def test_plan_on_empty_workspace_creates_everything(): + plan = seed.plan_seed(EMPTY) + assert plan.create_team is True + assert [s["name"] for s in plan.create_states] == [s["name"] for s in seed.TARGET_STATES] + assert plan.create_project is True + assert [v["name"] for v in plan.create_views] == [ + "Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] + + +def test_plan_is_idempotent_when_everything_exists(): + current = { + "team": {"id": "t1", "name": "Pearl"}, + "states": [{"name": n} for n in + ["icebox", "triage queue", "backlog", "in-progress", "done", "cancelled"]], + "project": {"id": "p1", "name": "Pearl"}, + "views": [{"name": n} for n in + ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"]], + } + plan = seed.plan_seed(current) + assert plan.create_team is False + assert plan.create_states == [] + assert plan.create_project is False + assert plan.create_views == [] + + +def test_plan_creates_only_missing_states_and_views(): + current = { + "team": {"id": "t1", "name": "Pearl"}, + "states": [{"name": "backlog"}, {"name": "done"}], + "project": {"id": "p1", "name": "Pearl"}, + "views": [{"name": "Pearl Icebox"}], + } + plan = seed.plan_seed(current) + assert plan.create_team is False + assert plan.create_project is False + assert [s["name"] for s in plan.create_states] == [ + "icebox", "triage queue", "in-progress", "cancelled"] + assert [v["name"] for v in plan.create_views] == [ + "Pearl Open Issues", "Pearl Inbox"] + + +# --- boundary: a fake GraphQL transport --------------------------------------- + +class FakeTransport: + """Records GraphQL calls and replies from a scripted op->response map.""" + + def __init__(self, responses): + self.responses = responses + self.calls = [] + + def __call__(self, query, variables): + op = next(name for name in self.responses if name in query) + self.calls.append((op, variables)) + resp = self.responses[op] + return resp(variables) if callable(resp) else resp + + def ops(self): + return [op for op, _ in self.calls] + + +def test_seed_on_empty_workspace_issues_creates_in_order(): + state_n = {"i": 0} + + def make_state(_v): + state_n["i"] += 1 + return {"data": {"workflowStateCreate": + {"success": True, "workflowState": {"id": f"s{state_n['i']}"}}}} + + fake = FakeTransport({ + # snapshot queries: empty workspace + "teams": {"data": {"teams": {"nodes": []}}}, + "teamCreate": {"data": {"teamCreate": {"success": True, + "team": {"id": "t1", "key": "PEARL", "name": "Pearl", + "states": {"nodes": []}}}}}, + "workflowStateCreate": make_state, + "projects": {"data": {"projects": {"nodes": []}}}, + "projectCreate": {"data": {"projectCreate": + {"success": True, "project": {"id": "p1", "name": "Pearl"}}}}, + "customViews": {"data": {"customViews": {"nodes": []}}}, + "customViewCreate": {"data": {"customViewCreate": + {"success": True, "customView": {"id": "v1"}}}}, + }) + client = seed.LinearClient("key", transport=fake) + summary = seed.seed(client) + + assert fake.ops().count("teamCreate") == 1 + assert fake.ops().count("workflowStateCreate") == 6 + assert fake.ops().count("projectCreate") == 1 + assert fake.ops().count("customViewCreate") == 3 + assert summary["team"] == "t1" + assert summary["project"] == "p1" + + +def test_seed_is_idempotent_against_a_populated_workspace(): + populated = { + "teams": {"data": {"teams": {"nodes": [ + {"id": "t1", "key": "PEARL", "name": "Pearl", + "states": {"nodes": [{"id": f"s{i}", "name": n} + for i, n in enumerate( + ["icebox", "triage queue", "backlog", + "in-progress", "done", "cancelled"])]}}]}}}, + "projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, + "customViews": {"data": {"customViews": {"nodes": [ + {"id": "v1", "name": "Pearl Open Issues"}, + {"id": "v2", "name": "Pearl Icebox"}, + {"id": "v3", "name": "Pearl Inbox"}]}}}, + } + fake = FakeTransport(populated) + client = seed.LinearClient("key", transport=fake) + seed.seed(client) + for create_op in ("teamCreate", "workflowStateCreate", + "projectCreate", "customViewCreate"): + assert create_op not in fake.ops() + + +def test_seed_reports_leftover_default_states_without_archiving(): + # An existing team carrying the six columns plus Linear's leftover defaults: + # seed creates nothing and surfaces the defaults for manual tidy-up, and + # never issues an archive. + populated = { + "teams": {"data": {"teams": {"nodes": [ + {"id": "t1", "key": "PEARL", "name": "Pearl", + "states": {"nodes": [{"id": f"s{i}", "name": n} for i, n in enumerate( + ["icebox", "triage queue", "backlog", "in-progress", "done", + "cancelled", "Todo", "In Review"])]}}]}}}, + "projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, + "customViews": {"data": {"customViews": {"nodes": [ + {"id": "v1", "name": "Pearl Open Issues"}, + {"id": "v2", "name": "Pearl Icebox"}, + {"id": "v3", "name": "Pearl Inbox"}]}}}, + } + fake = FakeTransport(populated) + summary = seed.seed(seed.LinearClient("key", transport=fake)) + assert summary["leftover_states"] == ["Todo", "In Review"] + assert "workflowStateArchive" not in fake.ops() + + +def test_client_raises_on_graphql_errors(): + fake = FakeTransport({"teams": {"errors": [{"message": "bad key"}]}}) + client = seed.LinearClient("key", transport=fake) + with pytest.raises(seed.LinearError, match="bad key"): + client.execute("query { teams { nodes { id } } }", {}) -- cgit v1.2.3