diff options
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 251 | ||||
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 217 |
3 files changed, 471 insertions, 0 deletions
@@ -16,3 +16,6 @@ # Emacs backup files *~ + +# Local Linear API key for the seed script (never commit secrets) +apikey.txt 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 } } }", {}) |
