aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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():