aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-07 00:46:29 -0500
committerCraig Jennings <c@cjennings.net>2026-06-07 00:46:29 -0500
commiteef4b44b461a21fb99eedc4a70cb3f25fc536ba9 (patch)
tree4197b2dcbbdc7febd50580745f9af4ae25549cf8
parentfef03a1f4f19c45a31928989aecb4ff8a47cea4a (diff)
downloadpearl-eef4b44b461a21fb99eedc4a70cb3f25fc536ba9.tar.gz
pearl-eef4b44b461a21fb99eedc4a70cb3f25fc536ba9.zip
fix(scripts): seed column types for board order, healing type drift
The seed put Triage on the unstarted type and Backlog on the backlog type. Linear's board orders columns by type category first (backlog before unstarted) and only then by position, so it grouped Icebox and Backlog together and pushed Triage to the right of Backlog. Position couldn't fix it: a state's type is immutable (workflowStateUpdate takes name, color, description, and position only), so the columns had to be retyped at the source. To make the board read Icebox, Triage, Backlog, two of the three pre-work columns have to be the backlog type and the third the unstarted one Linear renders last. So Icebox and Triage are now backlog and Backlog is the team's unstarted state, which also satisfies Linear's rule that a team keep one. Since type can't change in place, the reconcile gained a recreate path: a column found with the wrong type gets a fresh state of the right type, its issues moved over, and the old one archived. The phases are ordered so a new unstarted state exists before the old one is archived, and so a name is freed before another column reuses it. That lets a re-run heal a workspace seeded under the old layout, which is how I fixed the live one (Triage and Backlog recreated, 32 issues moved). Matching now prefers a type-correct state and only recreates when there's none, so the same targets build a fresh team and heal an existing one. pearl's own grouped view needed no change — it already orders sections the way Linear's board does, by type rank then position, so the migrated types sort it correctly.
-rw-r--r--scripts/README.org44
-rw-r--r--scripts/seed_pearl_workspace.py241
-rw-r--r--scripts/tests/test_seed_pearl_workspace.py198
3 files changed, 351 insertions, 132 deletions
diff --git a/scripts/README.org b/scripts/README.org
index 5d5ab6d..0f180c5 100644
--- a/scripts/README.org
+++ b/scripts/README.org
@@ -53,31 +53,41 @@ the repo. A convenient way to pass it:
** Notes
+- *Column types are chosen for board order.* 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, Icebox and
+ Triage are the backlog type and Backlog is the unstarted state (rendered last
+ in the pre-work group). Backlog being the unstarted state also satisfies
+ Linear's rule that a team keep one.
+
- *Reconcile, not create-from-scratch.* Linear's =teamCreate= seeds its own
default columns, and Linear dedups state names case-insensitively, so a naive
- "create all six" run collides with the defaults. The script instead renames
- the 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.
-
-- *It never archives.* A Linear team must keep at least one unstarted state, and
- the Duplicate state is reserved, so neither can be removed by API. The script
- leaves any column it doesn't claim (Duplicate, which Linear hides in the board
- UI) and reports it rather than failing.
-
-- *Positions are set in a second pass with distinct nonzero values.* Linear
+ "create all six" run collides with the defaults. The script reconciles by
+ name: it reuses a same-named (or default-aliased) state of the right type, and
+ creates only what's genuinely missing. On a fresh team Icebox takes over the
+ default backlog "Backlog" and Backlog takes over the unstarted "Todo".
+
+- *Wrong-type columns are recreated.* A state's type is immutable
+ (=workflowStateUpdate= takes name/color/description/position only), so a column
+ found with the wrong type can't be fixed in place. 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. This is what lets a re-run heal a workspace seeded under the older
+ type layout.
+
+- *The Duplicate state is left alone.* It's reserved and Linear hides it in the
+ board UI; the script reports it as a leftover rather than touching it.
+
+- *Positions are set in a final pass with distinct nonzero values.* Linear
ignores a 0.0 position and appends a freshly-created state at a high one, so
- the script updates positions after the states exist.
+ the script sets positions after the states exist.
- *Grouping isn't set at view-create time.* 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 the Linear UI.
-- *Triage is the unstarted type* (it took over the default Todo). pearl's
- grouped view orders sections by state type and then position, so Triage
- lists after the backlog columns in pearl even though the Linear board keeps
- your column order.
-
- *New issues default to Triage, not Backlog.* The script points the team's
default new-issue state at Triage (the intake column) after the columns
reconcile, so a fresh issue lands there.
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']}")
diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py
index fdf967b..5f4147e 100644
--- a/scripts/tests/test_seed_pearl_workspace.py
+++ b/scripts/tests/test_seed_pearl_workspace.py
@@ -1,17 +1,18 @@
"""Tests for seed_pearl_workspace.
The seeding logic splits into a pure planner (given a workspace snapshot, decide
-what to create or reconcile) and a thin Linear GraphQL client (the only network
-boundary). The planner carries the behavior and gets exhaustive pure tests; the
-client and seed() orchestration get fake-transport tests.
+what to create, recreate, or reconcile) and a thin Linear GraphQL client (the
+only network boundary). The planner carries the behavior and gets exhaustive
+pure tests; the client and seed() orchestration get fake-transport tests.
The state reconcile is shaped by what Linear actually allows, verified live:
teamCreate seeds its own default columns, a team must keep an unstarted state,
-and the Duplicate state is reserved. So the script never archives -- it renames
-Linear's defaults into the six Agile columns (repurposing the unstarted Todo as
-Triage), creates only the genuinely-new Icebox, and leaves Duplicate
-alone. Positions are set with distinct nonzero values, since Linear ignores a
-0.0 position and appends new states at a high one.
+the Duplicate state is reserved, and a state's type is immutable. Because the
+board orders columns by type category first, the columns need specific types
+(Icebox and Triage backlog, Backlog unstarted) to render in board order, and a
+column found with the wrong type has to be recreated -- create with the right
+type, move its issues, archive the old one. Positions are set with distinct
+nonzero values, since Linear ignores a 0.0 position and appends new states high.
"""
import os
@@ -31,11 +32,13 @@ def test_target_states_are_the_six_agile_columns_in_order():
"Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled"]
-def test_target_state_types_and_distinct_nonzero_positions():
+def test_target_state_types_put_backlog_last_in_the_pre_work_group():
by_name = {s["name"]: s for s in seed.TARGET_STATES}
+ # Icebox and Triage are backlog-type; Backlog is the unstarted state, which
+ # Linear renders after the backlog columns -> board order Icebox, Triage, Backlog.
assert by_name["Icebox"]["type"] == "backlog"
- assert by_name["Triage"]["type"] == "unstarted" # repurposed from Todo
- assert by_name["Backlog"]["type"] == "backlog"
+ assert by_name["Triage"]["type"] == "backlog"
+ assert by_name["Backlog"]["type"] == "unstarted"
assert by_name["In Progress"]["type"] == "started"
assert by_name["Done"]["type"] == "completed"
assert by_name["Canceled"]["type"] == "canceled"
@@ -45,9 +48,12 @@ def test_target_state_types_and_distinct_nonzero_positions():
assert len(set(positions)) == 6 # distinct
-def test_triage_queue_sources_the_default_todo():
- tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage")
- assert "Todo" in tq["sources"]
+def test_sources_take_over_the_right_defaults_on_a_fresh_team():
+ by_name = {s["name"]: s for s in seed.TARGET_STATES}
+ # On a fresh team Icebox takes over the default Backlog and Backlog takes
+ # over the unstarted Todo (keeping its type).
+ assert "Backlog" in by_name["Icebox"]["sources"]
+ assert "Todo" in by_name["Backlog"]["sources"]
def test_view_specs_names_and_project_scope():
@@ -82,38 +88,66 @@ LINEAR_DEFAULTS = [
]
-def _plan_by_target(existing):
- return {p["target"]["name"]: p["match_id"]
- for p in seed.reconcile_state_plan(existing)}
-
-
-def test_reconcile_against_linear_defaults_renames_and_creates():
- plan = _plan_by_target(LINEAR_DEFAULTS)
- # Icebox is genuinely new; the rest map onto a default (Triage <- Todo).
- assert plan["Icebox"] is None
- assert plan["Triage"] == "d2" # the unstarted Todo
- assert plan["Backlog"] == "d1"
- assert plan["In Progress"] == "d3"
- assert plan["Done"] == "d4"
- assert plan["Canceled"] == "d5"
-
-
-def test_reconcile_is_idempotent_against_already_named_columns():
- existing = [{"id": f"s{i}", "name": n, "type": "x"} for i, n in enumerate(
- ["Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled"])]
- plan = _plan_by_target(existing)
- assert all(mid is not None for mid in plan.values()) # everything matches, nothing created
+def _entries_by_target(existing):
+ return {p["target"]["name"]: p for p in seed.reconcile_state_plan(existing)}
+
+
+def test_reconcile_against_linear_defaults_reuses_defaults_and_creates_triage():
+ plan = _entries_by_target(LINEAR_DEFAULTS)
+ # Icebox takes the default backlog Backlog; Backlog takes the unstarted Todo
+ # (both type-correct -> update). Triage is genuinely new.
+ assert plan["Icebox"]["action"] == "update" and plan["Icebox"]["match_id"] == "d1"
+ assert plan["Backlog"]["action"] == "update" and plan["Backlog"]["match_id"] == "d2"
+ assert plan["Triage"]["action"] == "create"
+ assert plan["In Progress"]["match_id"] == "d3"
+ assert plan["Done"]["match_id"] == "d4"
+ assert plan["Canceled"]["match_id"] == "d5"
+
+
+def test_reconcile_exact_name_wins_over_a_source_alias():
+ # A live "Backlog" must be claimed by the Backlog target, not stolen by
+ # Icebox's "Backlog" source alias.
+ existing = [{"id": "s1", "name": "Backlog", "type": "unstarted"},
+ {"id": "s2", "name": "Icebox", "type": "backlog"}]
+ plan = _entries_by_target(existing)
+ assert plan["Backlog"]["match_id"] == "s1"
+ assert plan["Icebox"]["match_id"] == "s2"
+
+
+def test_reconcile_marks_a_wrong_type_match_for_recreate():
+ # The live workspace before the fix: Triage is unstarted, Backlog is backlog
+ # -- both the wrong type, so both must be recreated.
+ existing = [
+ {"id": "s1", "name": "Icebox", "type": "backlog"},
+ {"id": "s2", "name": "Triage", "type": "unstarted"},
+ {"id": "s3", "name": "Backlog", "type": "backlog"},
+ {"id": "s4", "name": "In Progress", "type": "started"},
+ {"id": "s5", "name": "Done", "type": "completed"},
+ {"id": "s6", "name": "Canceled", "type": "canceled"},
+ ]
+ plan = _entries_by_target(existing)
+ assert plan["Triage"]["action"] == "recreate" and plan["Triage"]["match_id"] == "s2"
+ assert plan["Backlog"]["action"] == "recreate" and plan["Backlog"]["match_id"] == "s3"
+ assert plan["Icebox"]["action"] == "update"
+
+
+def test_reconcile_is_idempotent_against_correctly_typed_columns():
+ correct = [("Icebox", "backlog"), ("Triage", "backlog"), ("Backlog", "unstarted"),
+ ("In Progress", "started"), ("Done", "completed"), ("Canceled", "canceled")]
+ existing = [{"id": f"s{i}", "name": n, "type": t} for i, (n, t) in enumerate(correct)]
+ plan = _entries_by_target(existing)
+ assert all(e["action"] == "update" for e in plan.values())
def test_reconcile_matches_case_insensitively():
- # the half-seeded board carried a lowercase "icebox"
existing = [{"id": "s1", "name": "icebox", "type": "backlog"}]
- plan = _plan_by_target(existing)
- assert plan["Icebox"] == "s1"
+ plan = _entries_by_target(existing)
+ assert plan["Icebox"]["match_id"] == "s1"
def test_leftover_state_names_reports_unmatched():
- # Duplicate isn't a target and stays; report it for the user.
+ # Duplicate isn't a target and stays; Todo is claimed (by Backlog), so only
+ # Duplicate is reported.
assert seed.leftover_state_names(LINEAR_DEFAULTS) == ["Duplicate"]
@@ -157,33 +191,93 @@ def test_seed_on_empty_workspace_reconciles_then_builds():
summary = seed.seed(seed.LinearClient("key", transport=fake))
ops = fake.ops()
assert ops.count("TeamCreate") == 1
- assert ops.count("StateCreate") == 1 # only Icebox is new
- assert ops.count("StateUpdate") == 6 # six columns positioned/renamed
+ assert ops.count("StateCreate") == 1 # only Triage is new
+ assert ops.count("StateUpdate") == 6 # six columns named/positioned
+ assert ops.count("StateArchive") == 0 # nothing to recreate on a fresh team
assert ops.count("TeamUpdate") == 1 # default new-issue state -> Triage
assert ops.count("ProjectCreate") == 1
assert ops.count("CustomViewCreate") == 3
assert summary["team"] == "t1"
assert summary["project"] == "p1"
+ assert summary["states_created"] == ["Triage"]
+ assert summary["states_recreated"] == []
assert summary["leftover_states"] == ["Duplicate"]
assert summary["default_issue_state"] == "Triage"
- # the default points at Triage (which reconciled from the unstarted Todo, d2)
+ # the default points at the freshly-created Triage
team_update = next(v for op, v in fake.calls if op == "TeamUpdate")
- assert team_update["input"]["defaultIssueStateId"] == "d2"
- assert "workflowStateArchive" not in ops # never archives
+ assert team_update["input"]["defaultIssueStateId"] == "new1"
+
+
+def test_seed_heals_type_drift_by_recreating_and_migrating():
+ # The live workspace before the fix: Triage unstarted (the old default,
+ # empty), Backlog backlog (holds two issues). Both wrong type -> recreate.
+ live_states = [
+ {"id": "s1", "name": "Icebox", "type": "backlog", "position": 1.0},
+ {"id": "s2", "name": "Triage", "type": "unstarted", "position": 2.0},
+ {"id": "s3", "name": "Backlog", "type": "backlog", "position": 3.0},
+ {"id": "s4", "name": "In Progress", "type": "started", "position": 4.0},
+ {"id": "s5", "name": "Done", "type": "completed", "position": 5.0},
+ {"id": "s6", "name": "Canceled", "type": "canceled", "position": 6.0},
+ {"id": "s7", "name": "Duplicate", "type": "duplicate", "position": 5.0},
+ ]
+ new_ids = iter(["nTriage", "nBacklog"])
+
+ def make_state(_v):
+ return _ok("workflowStateCreate", {"workflowState": {"id": next(new_ids)}})
+
+ def state_issues(v):
+ nodes = [{"id": "i1"}, {"id": "i2"}] if v["id"] == "s3" else []
+ return {"data": {"workflowState": {"issues": {
+ "nodes": nodes, "pageInfo": {"hasNextPage": False, "endCursor": None}}}}}
+ fake = FakeTransport({
+ "Teams": {"data": {"teams": {"nodes": [
+ {"id": "t1", "key": "PEARL", "name": "Pearl",
+ "defaultIssueState": {"id": "s2"},
+ "states": {"nodes": live_states}}]}}},
+ "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}),
+ "StateCreate": make_state,
+ "StateIssues": state_issues,
+ "IssueMove": _ok("issueUpdate", {"issue": {"id": "i"}}),
+ "StateArchive": _ok("workflowStateArchive", {}),
+ "TeamUpdate": _ok("teamUpdate", {}),
+ "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"}]}}},
+ })
+ summary = seed.seed(seed.LinearClient("key", transport=fake))
+ ops = fake.ops()
+ assert ops.count("StateCreate") == 2 # new Triage + new Backlog
+ assert ops.count("StateArchive") == 2 # old Triage + old Backlog
+ assert ops.count("IssueMove") == 2 # the two issues off old Backlog
+ # every create happens before any archive, so a new unstarted state exists
+ # before the old unstarted one is archived
+ assert max(i for i, op in enumerate(ops) if op == "StateCreate") \
+ < min(i for i, op in enumerate(ops) if op == "StateArchive")
+ assert summary["states_recreated"] == ["Triage", "Backlog"]
+ assert summary["issues_moved"] == 2
+ # the issues land on the new Backlog, and the default moves to the new Triage
+ moves = [v for op, v in fake.calls if op == "IssueMove"]
+ assert all(m["state"] == "nBacklog" for m in moves)
+ team_update = next(v for op, v in fake.calls if op == "TeamUpdate")
+ assert team_update["input"]["defaultIssueStateId"] == "nTriage"
-def test_seed_is_idempotent_against_a_seeded_workspace():
- seeded_states = [{"id": f"s{i}", "name": n, "type": "x"} for i, n in enumerate(
- ["Icebox", "Triage", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])]
+
+def test_seed_is_idempotent_against_a_correctly_seeded_workspace():
+ correct = [("Icebox", "backlog"), ("Triage", "backlog"), ("Backlog", "unstarted"),
+ ("In Progress", "started"), ("Done", "completed"),
+ ("Canceled", "canceled"), ("Duplicate", "duplicate")]
+ seeded_states = [{"id": f"s{i}", "name": n, "type": t, "position": float(i + 1)}
+ for i, (n, t) in enumerate(correct)]
fake = FakeTransport({
# default already points at Triage (s1), so no team update fires either
"Teams": {"data": {"teams": {"nodes": [
{"id": "t1", "key": "PEARL", "name": "Pearl",
"defaultIssueState": {"id": "s1"},
"states": {"nodes": seeded_states}}]}}},
- "TeamStates": {"data": {"team": {"states": {"nodes": seeded_states}}}},
"StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}),
- "TeamUpdate": _ok("teamUpdate", {}),
"Projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}},
"CustomViews": {"data": {"customViews": {"nodes": [
{"id": "v1", "name": "Pearl Open Issues"},
@@ -192,9 +286,9 @@ def test_seed_is_idempotent_against_a_seeded_workspace():
})
seed.seed(seed.LinearClient("key", transport=fake))
ops = fake.ops()
- for create_op in ("TeamCreate", "StateCreate", "ProjectCreate", "CustomViewCreate"):
- assert create_op not in ops # nothing re-created
- assert "TeamUpdate" not in ops # default already correct
+ for absent in ("TeamCreate", "StateCreate", "StateArchive", "ProjectCreate",
+ "CustomViewCreate", "TeamUpdate"):
+ assert absent not in ops # nothing created, recreated, or repointed
def test_client_raises_on_graphql_errors():