aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/seed_pearl_workspace.py251
-rw-r--r--scripts/tests/test_seed_pearl_workspace.py217
2 files changed, 468 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())
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 } } }", {})