aboutsummaryrefslogtreecommitdiff
path: root/scripts/seed_pearl_workspace.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 17:35:25 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 17:35:25 -0500
commit0eafcf90e0f671c6e05e3fd5a3b7aad9bd42d4bc (patch)
tree11bd7cfa617626ab02968c6f838568772eea7640 /scripts/seed_pearl_workspace.py
parentd8f60b1f86732b12e419d483a091634c31eb66c6 (diff)
downloadpearl-0eafcf90e0f671c6e05e3fd5a3b7aad9bd42d4bc.tar.gz
pearl-0eafcf90e0f671c6e05e3fd5a3b7aad9bd42d4bc.zip
fix(scripts): reconcile the seed against Linear's default columns
The first cut of the seed assumed an empty team and created all six columns. Run live, it died on "Cannot create a duplicate workflow state": teamCreate seeds Linear's own default columns and Linear dedups state names case-insensitively, so creating "backlog" collided with the default "Backlog". The additive model is wrong whenever the team already has states, which after teamCreate it always does. The rewrite reconciles instead. It renames Linear's defaults into the six Agile columns, keeping Backlog / In Progress / Done / Canceled and repurposing the unstarted Todo as Triage Queue, and creates only the genuinely-new Icebox. It never archives, which sidesteps two limits I hit live: a team must keep an unstarted state (so Todo can't be removed, only repurposed), and the Duplicate state is reserved. Positions are set in a second update pass with distinct nonzero values, because Linear ignores a 0.0 position and appends a new state at a high one. Leftover columns the script doesn't claim (Duplicate) are reported, not touched. The planner (reconcile_state_plan, leftover_state_names) is pure and unit-tested, including the case-insensitive match that recovers a half-seeded board. The client and seed() get fake-transport tests. 13 tests, and the dry-run ran live against the seeded workspace and came back all-update, no-create, so the model matches what Linear does. The personal key stays in the gitignored apikey.txt.
Diffstat (limited to 'scripts/seed_pearl_workspace.py')
-rw-r--r--scripts/seed_pearl_workspace.py240
1 files changed, 130 insertions, 110 deletions
diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py
index 0ccd42a..434a995 100644
--- a/scripts/seed_pearl_workspace.py
+++ b/scripts/seed_pearl_workspace.py
@@ -1,32 +1,37 @@
#!/usr/bin/env python3
"""Seed a Linear workspace with Pearl's dogfooding conventions.
-Creates, idempotently, via the Linear GraphQL API:
+Brings a workspace to:
- - a "Pearl" team whose workflow states are the six Agile columns
- (icebox, triage queue, backlog, in-progress, done, cancelled),
+ - a "Pearl" team whose columns are the six Agile states
+ (Icebox, Triage Queue, Backlog, In Progress, Done, Canceled),
- 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.
+Re-running is safe: it reconciles against what's already there by name.
-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.
+How the columns are built is shaped by what Linear actually allows (verified
+live, not assumed): teamCreate seeds its own default columns, a team must keep
+an unstarted state, and the Duplicate state is reserved. So this script never
+archives. It renames Linear's defaults into the six Agile columns -- keeping
+Backlog / In Progress / Done / Canceled, and repurposing the unstarted Todo as
+Triage Queue -- and creates only the genuinely-new Icebox. Linear's reserved
+Duplicate state is left alone (it's hidden in the board UI); the script reports
+any other leftover column for you to tidy.
-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).
+Positions are set in a second pass with distinct nonzero values, because Linear
+ignores a 0.0 position and appends a freshly-created state at a high one.
+
+Two notes on the result:
+ - Triage Queue is Linear's "unstarted" type, so pearl's grouped view (which
+ orders by state type, then position) lists it after the backlog columns,
+ even though the Linear board itself keeps your column order.
+ - CustomViewCreateInput has no grouping field, so Pearl Open Issues is created
+ with its filter only -- group it by category in pearl (pearl-set-grouping)
+ or the Linear UI.
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
@@ -34,7 +39,6 @@ import json
import os
import sys
import urllib.request
-from dataclasses import dataclass, field
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
@@ -42,60 +46,65 @@ 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.
+# The six Agile columns. `sources` names the Linear default state a column is
+# reconciled from when it isn't present by its own name yet (Triage Queue takes
+# over the unstarted Todo). Positions are distinct and nonzero.
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},
+ {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, "sources": []},
+ {"name": "Triage Queue", "type": "unstarted", "color": "#e2e2e2", "position": 2.0,
+ "sources": ["Todo"]},
+ {"name": "Backlog", "type": "backlog", "color": "#95a2b3", "position": 3.0, "sources": []},
+ {"name": "In Progress", "type": "started", "color": "#f2c94c", "position": 4.0, "sources": []},
+ {"name": "Done", "type": "completed", "color": "#0f9d58", "position": 5.0, "sources": []},
+ {"name": "Canceled", "type": "canceled", "color": "#eb5757", "position": 6.0,
+ "sources": ["Cancelled"]},
]
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.
- """
+ """Return the three view specs (name + IssueFilter filterData) for PROJECT_ID."""
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"}}}},
+ "filterData": {**proj, "state": {"name": {"eq": "Icebox"}}}},
{"name": "Pearl Inbox",
- "filterData": {**proj, "state": {"name": {"eq": "triage queue"}}}},
+ "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 _target_aliases(target):
+ return {target["name"].lower(), *(s.lower() for s in target.get("sources", []))}
-def plan_seed(current):
- """Given a workspace snapshot CURRENT, decide what to create.
+def reconcile_state_plan(existing, targets=None):
+ """Match each target column to an existing state, or mark it for creation.
- 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.
+ EXISTING is a list of state dicts (id, name, type). Returns a list of
+ {target, match_id}: match_id is the id of the existing state a column
+ reconciles onto (its own name or a `sources` default, case-insensitive), or
+ None when the column has to be created. Pure: no I/O. Each existing state is
+ claimed by at most one target.
"""
- 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],
- )
+ targets = targets or TARGET_STATES
+ plan, used = [], set()
+ for target in targets:
+ aliases = _target_aliases(target)
+ match = next((s for s in existing
+ if s["id"] not in used and s["name"].lower() in aliases), None)
+ if match:
+ used.add(match["id"])
+ plan.append({"target": target, "match_id": match["id"] if match else None})
+ return plan
+
+
+def leftover_state_names(existing, targets=None):
+ """Return names of existing states no target column claims (e.g. Duplicate)."""
+ claimed = {p["match_id"] for p in reconcile_state_plan(existing, targets) if p["match_id"]}
+ return [s["name"] for s in existing if s["id"] not in claimed]
class LinearError(RuntimeError):
@@ -103,8 +112,6 @@ class LinearError(RuntimeError):
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(
@@ -112,12 +119,16 @@ def _http_transport(api_key):
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."""
+ """Thin Linear GraphQL client; the only network boundary in this module.
+
+ Each method names its GraphQL operation distinctly (Teams, TeamStates,
+ TeamCreate, StateCreate, StateUpdate, Projects, ProjectCreate, CustomViews,
+ CustomViewCreate) so a fake transport can route on the operation name.
+ """
def __init__(self, api_key, transport=None):
self._transport = transport or _http_transport(api_key)
@@ -125,101 +136,107 @@ class LinearClient:
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)
+ raise LinearError("; ".join(e.get("message", str(e)) for e in resp["errors"]))
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 "
+ def find_team(self):
+ data = self.execute(
+ "query Teams { 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}
+ return next((t for t in data["teams"]["nodes"] if t["name"] == TEAM_NAME), None)
+
+ def team_states(self, team_id):
+ data = self.execute(
+ "query TeamStates($id: String!) { team(id: $id) "
+ "{ states { nodes { id name type position } } } }", {"id": team_id})
+ return data["team"]["states"]["nodes"]
def create_team(self):
data = self.execute(
- "mutation($input: TeamCreateInput!) { teamCreate(input: $input) "
- "{ success team { id key name states { nodes { id name type } } } } }",
+ "mutation TeamCreate($input: TeamCreateInput!) { teamCreate(input: $input) "
+ "{ success team { id key name } } }",
{"input": {"name": TEAM_NAME, "key": TEAM_KEY}})
return data["teamCreate"]["team"]
def create_state(self, team_id, spec):
data = self.execute(
- "mutation($input: WorkflowStateCreateInput!) "
+ "mutation StateCreate($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 update_state(self, state_id, name, position):
+ self.execute(
+ "mutation StateUpdate($id: String!, $input: WorkflowStateUpdateInput!) "
+ "{ workflowStateUpdate(id: $id, input: $input) { success } }",
+ {"id": state_id, "input": {"name": name, "position": position}})
+
+ def find_project(self):
+ data = self.execute("query Projects { projects { nodes { id name } } }", {})
+ return next((p for p in data["projects"]["nodes"] if p["name"] == PROJECT_NAME), None)
+
def create_project(self, team_id):
data = self.execute(
- "mutation($input: ProjectCreateInput!) { projectCreate(input: $input) "
- "{ success project { id name } } }",
+ "mutation ProjectCreate($input: ProjectCreateInput!) "
+ "{ projectCreate(input: $input) { success project { id name } } }",
{"input": {"name": PROJECT_NAME, "teamIds": [team_id]}})
return data["projectCreate"]["project"]
+ def existing_view_names(self):
+ data = self.execute("query CustomViews { customViews { nodes { id name } } }", {})
+ return {v["name"] for v in data["customViews"]["nodes"]}
+
def create_view(self, team_id, spec):
- data = self.execute(
- "mutation($input: CustomViewCreateInput!) { customViewCreate(input: $input) "
- "{ success customView { id name } } }",
+ self.execute(
+ "mutation CustomViewCreate($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.
+ """Reconcile the Pearl team, columns, project, and views; idempotent.
- Returns a summary dict {team, project, states_created, views_created}.
+ Returns {team, project, states_created, views_created, leftover_states}.
"""
- current = client.snapshot()
- team = current["team"]
+ team = client.find_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 [])
+ states = client.team_states(team["id"]) # re-fetch: defaults aren't in the create reply
+ else:
+ states = team.get("states", {}).get("nodes", [])
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)
+ for entry in reconcile_state_plan(states):
+ target, match_id = entry["target"], entry["match_id"]
+ if match_id is None:
+ match_id = client.create_state(team_id, target)["id"]
+ states_created.append(target["name"])
+ # Set name + position in one update; a freshly-created state needs the
+ # position pass because Linear appends it at a high position otherwise.
+ client.update_state(match_id, target["name"], target["position"])
+ leftover = leftover_state_names(states)
+
+ project = client.find_project() or client.create_project(team_id)
project_id = project["id"]
- have_views = {v["name"] for v in current["views"]}
+ have_views = client.existing_view_names()
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}
+ return {"team": team_id, "project": project_id, "states_created": states_created,
+ "views_created": views_created, "leftover_states": leftover}
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")
+ help="print the reconcile plan without changing anything")
args = parser.parse_args(argv)
api_key = os.environ.get("LINEAR_API_KEY")
@@ -229,12 +246,15 @@ def main(argv=None):
client = LinearClient(api_key)
if args.dry_run:
- plan = plan_seed(client.snapshot())
+ team = client.find_team()
+ states = team.get("states", {}).get("nodes", []) if team else []
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]}")
+ print(f" team: {'exists' if team else 'create'}")
+ for entry in reconcile_state_plan(states):
+ verb = "update" if entry["match_id"] else "create"
+ print(f" {verb}: {entry['target']['name']}")
+ if states:
+ print(f" leftover columns: {leftover_state_names(states) or 'none'}")
return 0
summary = seed(client)
@@ -242,7 +262,7 @@ def main(argv=None):
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): "
+ print(f" leftover columns (left as-is; Duplicate is reserved/hidden): "
f"{summary['leftover_states']}")
return 0