aboutsummaryrefslogtreecommitdiff
path: root/scripts/seed_pearl_workspace.py
diff options
context:
space:
mode:
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