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.py241
1 files changed, 178 insertions, 63 deletions
diff --git a/scripts/seed_pearl_workspace.py b/scripts/seed_pearl_workspace.py
index 4e29b76..bc5300a 100644
--- a/scripts/seed_pearl_workspace.py
+++ b/scripts/seed_pearl_workspace.py
@@ -3,7 +3,7 @@
Brings a workspace to:
- - a "Pearl" team whose columns are the six Agile states
+ - a "Pearl" team whose columns are the six Agile states, in board order
(Icebox, Triage, Backlog, In Progress, Done, Canceled),
- a "Pearl" project inside that team,
- three Custom Views: Pearl Open Issues, Pearl Icebox, Pearl Inbox.
@@ -12,24 +12,28 @@ Re-running is safe: it reconciles against what's already there by name.
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 -- 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.
-
-Positions are set in a second pass with distinct nonzero values, because Linear
+an unstarted state, the Duplicate state is reserved, and a state's `type` is
+immutable -- workflowStateUpdate takes name/color/description/position only.
+
+That last constraint drives the column types. Linear's board orders columns by
+type category first (backlog before unstarted), then by position within a
+category, so position alone can't move a column across a type boundary. To get
+Icebox, Triage, Backlog all left of In Progress in that order, two of them have
+to be the backlog type and the third the unstarted type rendered last:
+
+ - Icebox -> backlog
+ - Triage -> backlog
+ - Backlog -> unstarted (this is the team's required unstarted state)
+
+Because type can't be changed in place, a column found with the wrong type is
+*recreated*: the script creates a fresh state with the right type, moves the old
+state's issues onto it, and archives the old one. The phases are ordered so a
+new unstarted state exists before the old unstarted one is archived, and so a
+name is freed before another column reuses it.
+
+Positions are set in a final 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 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]
"""
@@ -46,16 +50,23 @@ TEAM_NAME = "Pearl"
TEAM_KEY = "PEARL"
PROJECT_NAME = "Pearl"
-# 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 takes
-# over the unstarted Todo). Positions are distinct and nonzero.
+# The six Agile columns in board order. `type` is chosen so Linear's
+# type-grouped board renders them in this order (see the module docstring):
+# Icebox and Triage are backlog-type, Backlog is the unstarted state. `sources`
+# names the Linear default a column reconciles from when no column already
+# carries its own name -- on a fresh team Icebox takes over the default Backlog
+# and Backlog takes over the unstarted Todo. Positions are distinct and nonzero.
TARGET_STATES = [
- {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0, "sources": []},
- {"name": "Triage", "type": "unstarted", "color": "#e2e2e2", "position": 2.0,
+ {"name": "Icebox", "type": "backlog", "color": "#bec2c8", "position": 1.0,
+ "sources": ["Backlog"]},
+ {"name": "Triage", "type": "backlog", "color": "#e2e2e2", "position": 2.0,
+ "sources": []},
+ {"name": "Backlog", "type": "unstarted", "color": "#95a2b3", "position": 3.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": "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"]},
]
@@ -80,28 +91,57 @@ def desired_views(project_id):
]
-def _target_aliases(target):
- return {target["name"].lower(), *(s.lower() for s in target.get("sources", []))}
-
-
def reconcile_state_plan(existing, targets=None):
- """Match each target column to an existing state, or mark it for creation.
+ """Decide, per target column, whether to update, recreate, or create it.
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.
+ {target, action, match_id}:
+
+ - "update" -- a state already carries this column's identity with the
+ right type; rename/reposition it in place. match_id set.
+ - "recreate" -- a state matches but has the wrong type (type is immutable),
+ so it must be replaced. match_id is the old state's id.
+ - "create" -- nothing matches; make a new state. match_id is None.
+
+ Matching prefers a type-correct candidate, so a column is only recreated when
+ no usable state of the right type exists. The passes:
+
+ 1. exact name + correct type -> update
+ 2. source alias + correct type -> update (reuse a default, e.g. Todo -> Backlog)
+ 3. exact name, wrong type -> recreate (type is immutable)
+
+ This makes the same target definitions fit both a fresh team and the live
+ workspace. On a fresh team Icebox reuses the backlog default "Backlog" and
+ Backlog reuses the unstarted "Todo" (pass 2). On the live workspace, where a
+ correctly-named Icebox already exists and there's no Todo, the wrong-type
+ Triage and Backlog fall to pass 3 and get recreated. Pure: no I/O. Each
+ existing state is claimed by at most one target.
"""
targets = targets or TARGET_STATES
- plan, used = [], set()
+ used, result = set(), {}
+
+ def claim(target, predicate, action):
+ if target["name"] in result:
+ return
+ m = next((s for s in existing if s["id"] not in used and predicate(s)), None)
+ if m:
+ used.add(m["id"])
+ result[target["name"]] = (action, m["id"])
+
+ for target in targets: # pass 1: exact name + correct type
+ claim(target, lambda s, t=target:
+ s["name"].lower() == t["name"].lower() and s["type"] == t["type"], "update")
+ for target in targets: # pass 2: source alias + correct type
+ aliases = {s.lower() for s in target.get("sources", [])}
+ claim(target, lambda s, t=target, a=aliases:
+ s["name"].lower() in a and s["type"] == t["type"], "update")
+ for target in targets: # pass 3: exact name, wrong type
+ claim(target, lambda s, t=target: s["name"].lower() == t["name"].lower(), "recreate")
+
+ plan = []
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})
+ action, match_id = result.get(target["name"], ("create", None))
+ plan.append({"target": target, "action": action, "match_id": match_id})
return plan
@@ -130,8 +170,9 @@ class LinearClient:
"""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.
+ TeamCreate, StateCreate, StateUpdate, StateArchive, StateIssues, IssueMove,
+ TeamUpdate, Projects, ProjectCreate, CustomViews, CustomViewCreate) so a fake
+ transport can route on the operation name.
"""
def __init__(self, api_key, transport=None):
@@ -170,11 +211,42 @@ class LinearClient:
"color": spec["color"], "position": spec["position"]}})
return data["workflowStateCreate"]["workflowState"]
- def update_state(self, state_id, name, position):
+ def update_state(self, state_id, name=None, position=None):
+ fields = {}
+ if name is not None:
+ fields["name"] = name
+ if position is not None:
+ fields["position"] = position
self.execute(
"mutation StateUpdate($id: String!, $input: WorkflowStateUpdateInput!) "
"{ workflowStateUpdate(id: $id, input: $input) { success } }",
- {"id": state_id, "input": {"name": name, "position": position}})
+ {"id": state_id, "input": fields})
+
+ def archive_state(self, state_id):
+ self.execute(
+ "mutation StateArchive($id: String!) "
+ "{ workflowStateArchive(id: $id) { success } }", {"id": state_id})
+
+ def issues_in_state(self, state_id):
+ """Return every issue id in a workflow state, paging through results."""
+ ids, after = [], None
+ while True:
+ data = self.execute(
+ "query StateIssues($id: String!, $after: String) { workflowState(id: $id) "
+ "{ issues(first: 250, after: $after) "
+ "{ nodes { id } pageInfo { hasNextPage endCursor } } } }",
+ {"id": state_id, "after": after})
+ conn = data["workflowState"]["issues"]
+ ids.extend(n["id"] for n in conn["nodes"])
+ if not conn["pageInfo"]["hasNextPage"]:
+ return ids
+ after = conn["pageInfo"]["endCursor"]
+
+ def move_issue(self, issue_id, state_id):
+ self.execute(
+ "mutation IssueMove($id: String!, $state: String!) "
+ "{ issueUpdate(id: $id, input: { stateId: $state }) { success } }",
+ {"id": issue_id, "state": state_id})
def set_default_issue_state(self, team_id, state_id):
self.execute(
@@ -208,7 +280,8 @@ class LinearClient:
def seed(client):
"""Reconcile the Pearl team, columns, project, and views; idempotent.
- Returns {team, project, states_created, views_created, leftover_states}.
+ Returns {team, project, states_created, states_recreated, issues_moved,
+ views_created, leftover_states, default_issue_state}.
"""
team = client.find_team()
if team is None:
@@ -220,25 +293,60 @@ def seed(client):
current_default = (team.get("defaultIssueState") or {}).get("id")
team_id = team["id"]
- states_created = []
- resolved = {}
- 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"])
- resolved[target["name"]] = match_id
+ plan = reconcile_state_plan(states)
leftover = leftover_state_names(states)
- # New issues should default to the intake column, not Backlog.
+ # Phase 1: free up the canonical names held by states we're about to replace,
+ # so the new state can be created under the real name.
+ for entry in plan:
+ if entry["action"] == "recreate":
+ client.update_state(entry["match_id"], name=f"{entry['target']['name']} (migrating)")
+
+ # Phase 2: create new states -- genuinely new columns and type-healed
+ # replacements. A replacement's new type is set here (the only place type can
+ # be set), so the new unstarted Backlog exists before any archive in phase 5.
+ resolved, created, recreated = {}, [], []
+ for entry in plan:
+ target = entry["target"]
+ if entry["action"] == "create":
+ resolved[target["name"]] = client.create_state(team_id, target)["id"]
+ created.append(target["name"])
+ elif entry["action"] == "recreate":
+ resolved[target["name"]] = client.create_state(team_id, target)["id"]
+ recreated.append(target["name"])
+ else:
+ resolved[target["name"]] = entry["match_id"]
+
+ # Phase 3: move issues off each replaced state onto its replacement.
+ issues_moved = 0
+ for entry in plan:
+ if entry["action"] == "recreate":
+ new_id = resolved[entry["target"]["name"]]
+ for issue_id in client.issues_in_state(entry["match_id"]):
+ client.move_issue(issue_id, new_id)
+ issues_moved += 1
+
+ # Phase 4: point the new-issue default at the intake column (before archiving
+ # the old default's state in phase 5).
intake_id = resolved.get(INTAKE_STATE)
default_set = bool(intake_id) and intake_id != current_default
if default_set:
client.set_default_issue_state(team_id, intake_id)
+ # Phase 5: archive the replaced states. A new unstarted state now exists, so
+ # archiving the old unstarted one is allowed.
+ for entry in plan:
+ if entry["action"] == "recreate":
+ client.archive_state(entry["match_id"])
+
+ # Phase 6: set each column's final name + position, in target order so a
+ # rename chain (a fresh team's Backlog -> Icebox, then Todo -> Backlog) never
+ # collides on a name an unprocessed column still holds.
+ for entry in plan:
+ target = entry["target"]
+ client.update_state(resolved[target["name"]], name=target["name"],
+ position=target["position"])
+
project = client.find_project() or client.create_project(team_id)
project_id = project["id"]
@@ -249,7 +357,8 @@ def seed(client):
client.create_view(team_id, spec)
views_created.append(spec["name"])
- return {"team": team_id, "project": project_id, "states_created": states_created,
+ return {"team": team_id, "project": project_id, "states_created": created,
+ "states_recreated": recreated, "issues_moved": issues_moved,
"views_created": views_created, "leftover_states": leftover,
"default_issue_state": INTAKE_STATE if default_set else "unchanged"}
@@ -272,16 +381,22 @@ def main(argv=None):
print("Plan:")
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']}")
+ verb = entry["action"]
+ note = ""
+ if verb == "recreate":
+ moving = len(client.issues_in_state(entry["match_id"]))
+ note = f" (type change; moving {moving} issue(s))"
+ print(f" {verb:8} {entry['target']['name']}{note}")
if states:
print(f" leftover columns: {leftover_state_names(states) or 'none'}")
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'}")
+ print(f" states created: {summary['states_created'] or 'none'}")
+ print(f" states recreated: {summary['states_recreated'] or 'none'} "
+ f"(moved {summary['issues_moved']} issue(s))")
+ print(f" views created: {summary['views_created'] or 'none'}")
if summary["leftover_states"]:
print(f" leftover columns (left as-is; Duplicate is reserved/hidden): "
f"{summary['leftover_states']}")