diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 17:35:25 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 17:35:25 -0500 |
| commit | 0eafcf90e0f671c6e05e3fd5a3b7aad9bd42d4bc (patch) | |
| tree | 11bd7cfa617626ab02968c6f838568772eea7640 | |
| parent | d8f60b1f86732b12e419d483a091634c31eb66c6 (diff) | |
| download | pearl-0eafcf90e0f671c6e05e3fd5a3b7aad9bd42d4bc.tar.gz pearl-0eafcf90e0f671c6e05e3fd5a3b7aad9bd42d4bc.zip | |
fix(scripts): reconcile the seed against Linear's default columns
The first cut of the seed assumed an empty team and created all six columns. Run live, it died on "Cannot create a duplicate workflow state": teamCreate seeds Linear's own default columns and Linear dedups state names case-insensitively, so creating "backlog" collided with the default "Backlog". The additive model is wrong whenever the team already has states, which after teamCreate it always does.
The rewrite reconciles instead. It renames Linear's defaults into the six Agile columns, keeping Backlog / In Progress / Done / Canceled and repurposing the unstarted Todo as Triage Queue, and creates only the genuinely-new Icebox. It never archives, which sidesteps two limits I hit live: a team must keep an unstarted state (so Todo can't be removed, only repurposed), and the Duplicate state is reserved. Positions are set in a second update pass with distinct nonzero values, because Linear ignores a 0.0 position and appends a new state at a high one. Leftover columns the script doesn't claim (Duplicate) are reported, not touched.
The planner (reconcile_state_plan, leftover_state_names) is pure and unit-tested, including the case-insensitive match that recovers a half-seeded board. The client and seed() get fake-transport tests. 13 tests, and the dry-run ran live against the seeded workspace and came back all-update, no-create, so the model matches what Linear does. The personal key stays in the gitignored apikey.txt.
| -rw-r--r-- | scripts/seed_pearl_workspace.py | 240 | ||||
| -rw-r--r-- | scripts/tests/test_seed_pearl_workspace.py | 249 |
2 files changed, 243 insertions, 246 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 diff --git a/scripts/tests/test_seed_pearl_workspace.py b/scripts/tests/test_seed_pearl_workspace.py index 2e7cb4a..ea22230 100644 --- a/scripts/tests/test_seed_pearl_workspace.py +++ b/scripts/tests/test_seed_pearl_workspace.py @@ -1,10 +1,17 @@ """Tests for seed_pearl_workspace. The seeding logic splits into a pure planner (given a workspace snapshot, decide -what to create) and a thin Linear GraphQL client (the only network boundary). -The planner carries the behavior, so it gets exhaustive pure tests; the client -and the seed() orchestration get fake-transport tests that assert the right -mutations go out and that a second run is a no-op (idempotence). +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. + +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 Queue), 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. """ import os @@ -20,31 +27,27 @@ import seed_pearl_workspace as seed # --- pure: target definitions ------------------------------------------------- def test_target_states_are_the_six_agile_columns_in_order(): - names = [s["name"] for s in seed.TARGET_STATES] - assert names == ["icebox", "triage queue", "backlog", - "in-progress", "done", "cancelled"] - - -def test_early_columns_share_backlog_type_with_ascending_positions(): - # icebox / triage queue / backlog all sort by position within one type, so - # pearl's (type-rank, position) grouping reproduces the board column order. - early = [s for s in seed.TARGET_STATES - if s["name"] in ("icebox", "triage queue", "backlog")] - assert all(s["type"] == "backlog" for s in early) - positions = [s["position"] for s in early] + assert [s["name"] for s in seed.TARGET_STATES] == [ + "Icebox", "Triage Queue", "Backlog", "In Progress", "Done", "Canceled"] + + +def test_target_state_types_and_distinct_nonzero_positions(): + by_name = {s["name"]: s for s in seed.TARGET_STATES} + assert by_name["Icebox"]["type"] == "backlog" + assert by_name["Triage Queue"]["type"] == "unstarted" # repurposed from Todo + assert by_name["Backlog"]["type"] == "backlog" + assert by_name["In Progress"]["type"] == "started" + assert by_name["Done"]["type"] == "completed" + assert by_name["Canceled"]["type"] == "canceled" + positions = [s["position"] for s in seed.TARGET_STATES] assert positions == sorted(positions) - assert len({s["position"] for s in seed.TARGET_STATES}) == 6 # all distinct + assert all(p > 0 for p in positions) # Linear ignores a 0.0 position + assert len(set(positions)) == 6 # distinct -def test_later_columns_map_to_distinct_linear_types(): - by_name = {s["name"]: s["type"] for s in seed.TARGET_STATES} - assert by_name["in-progress"] == "started" - assert by_name["done"] == "completed" - assert by_name["cancelled"] == "canceled" - - -def test_every_state_has_a_color(): - assert all(s.get("color") for s in seed.TARGET_STATES) +def test_triage_queue_sources_the_default_todo(): + tq = next(s for s in seed.TARGET_STATES if s["name"] == "Triage Queue") + assert "Todo" in tq["sources"] def test_view_specs_names_and_project_scope(): @@ -56,68 +59,68 @@ def test_view_specs_names_and_project_scope(): def test_open_view_excludes_completed_and_cancelled(): - open_view = next(v for v in seed.desired_views("p") - if v["name"] == "Pearl Open Issues") + open_view = next(v for v in seed.desired_views("p") if v["name"] == "Pearl Open Issues") nin = open_view["filterData"]["state"]["type"]["nin"] assert "completed" in nin and "canceled" in nin -def test_icebox_and_inbox_views_filter_to_their_state(): +def test_icebox_and_inbox_views_filter_to_their_column(): views = {v["name"]: v for v in seed.desired_views("p")} - assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "icebox" - assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "triage queue" + assert views["Pearl Icebox"]["filterData"]["state"]["name"]["eq"] == "Icebox" + assert views["Pearl Inbox"]["filterData"]["state"]["name"]["eq"] == "Triage Queue" -# --- pure: the planner -------------------------------------------------------- +# --- pure: the state reconcile planner ---------------------------------------- -EMPTY = {"team": None, "states": [], "project": None, "views": []} +LINEAR_DEFAULTS = [ + {"id": "d1", "name": "Backlog", "type": "backlog"}, + {"id": "d2", "name": "Todo", "type": "unstarted"}, + {"id": "d3", "name": "In Progress", "type": "started"}, + {"id": "d4", "name": "Done", "type": "completed"}, + {"id": "d5", "name": "Canceled", "type": "canceled"}, + {"id": "d6", "name": "Duplicate", "type": "duplicate"}, +] -def test_plan_on_empty_workspace_creates_everything(): - plan = seed.plan_seed(EMPTY) - assert plan.create_team is True - assert [s["name"] for s in plan.create_states] == [s["name"] for s in seed.TARGET_STATES] - assert plan.create_project is True - assert [v["name"] for v in plan.create_views] == [ - "Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"] +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 Queue <- Todo). + assert plan["Icebox"] is None + assert plan["Triage Queue"] == "d2" # the unstarted Todo + assert plan["Backlog"] == "d1" + assert plan["In Progress"] == "d3" + assert plan["Done"] == "d4" + assert plan["Canceled"] == "d5" -def test_plan_is_idempotent_when_everything_exists(): - current = { - "team": {"id": "t1", "name": "Pearl"}, - "states": [{"name": n} for n in - ["icebox", "triage queue", "backlog", "in-progress", "done", "cancelled"]], - "project": {"id": "p1", "name": "Pearl"}, - "views": [{"name": n} for n in - ["Pearl Open Issues", "Pearl Icebox", "Pearl Inbox"]], - } - plan = seed.plan_seed(current) - assert plan.create_team is False - assert plan.create_states == [] - assert plan.create_project is False - assert plan.create_views == [] - - -def test_plan_creates_only_missing_states_and_views(): - current = { - "team": {"id": "t1", "name": "Pearl"}, - "states": [{"name": "backlog"}, {"name": "done"}], - "project": {"id": "p1", "name": "Pearl"}, - "views": [{"name": "Pearl Icebox"}], - } - plan = seed.plan_seed(current) - assert plan.create_team is False - assert plan.create_project is False - assert [s["name"] for s in plan.create_states] == [ - "icebox", "triage queue", "in-progress", "cancelled"] - assert [v["name"] for v in plan.create_views] == [ - "Pearl Open Issues", "Pearl Inbox"] +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 Queue", "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 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" + + +def test_leftover_state_names_reports_unmatched(): + # Duplicate isn't a target and stays; report it for the user. + assert seed.leftover_state_names(LINEAR_DEFAULTS) == ["Duplicate"] # --- boundary: a fake GraphQL transport --------------------------------------- class FakeTransport: - """Records GraphQL calls and replies from a scripted op->response map.""" + """Records GraphQL calls and replies from a scripted op-name -> response map.""" def __init__(self, responses): self.responses = responses @@ -133,85 +136,59 @@ class FakeTransport: return [op for op, _ in self.calls] -def test_seed_on_empty_workspace_issues_creates_in_order(): - state_n = {"i": 0} +def _ok(field, payload): + return {"data": {field: {"success": True, **payload}}} - def make_state(_v): - state_n["i"] += 1 - return {"data": {"workflowStateCreate": - {"success": True, "workflowState": {"id": f"s{state_n['i']}"}}}} +def test_seed_on_empty_workspace_reconciles_then_builds(): fake = FakeTransport({ - # snapshot queries: empty workspace - "teams": {"data": {"teams": {"nodes": []}}}, - "teamCreate": {"data": {"teamCreate": {"success": True, - "team": {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": []}}}}}, - "workflowStateCreate": make_state, - "projects": {"data": {"projects": {"nodes": []}}}, - "projectCreate": {"data": {"projectCreate": - {"success": True, "project": {"id": "p1", "name": "Pearl"}}}}, - "customViews": {"data": {"customViews": {"nodes": []}}}, - "customViewCreate": {"data": {"customViewCreate": - {"success": True, "customView": {"id": "v1"}}}}, + "Teams": {"data": {"teams": {"nodes": []}}}, + "TeamCreate": _ok("teamCreate", {"team": {"id": "t1", "key": "PEARL", "name": "Pearl"}}), + # states re-fetched after create: Linear's defaults + "TeamStates": {"data": {"team": {"states": {"nodes": LINEAR_DEFAULTS}}}}, + "StateCreate": _ok("workflowStateCreate", {"workflowState": {"id": "new1"}}), + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "Projects": {"data": {"projects": {"nodes": []}}}, + "ProjectCreate": _ok("projectCreate", {"project": {"id": "p1", "name": "Pearl"}}), + "CustomViews": {"data": {"customViews": {"nodes": []}}}, + "CustomViewCreate": _ok("customViewCreate", {"customView": {"id": "v1"}}), }) - client = seed.LinearClient("key", transport=fake) - summary = seed.seed(client) - - assert fake.ops().count("teamCreate") == 1 - assert fake.ops().count("workflowStateCreate") == 6 - assert fake.ops().count("projectCreate") == 1 - assert fake.ops().count("customViewCreate") == 3 + 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("ProjectCreate") == 1 + assert ops.count("CustomViewCreate") == 3 assert summary["team"] == "t1" assert summary["project"] == "p1" + assert summary["leftover_states"] == ["Duplicate"] + assert "workflowStateArchive" not in ops # never archives -def test_seed_is_idempotent_against_a_populated_workspace(): - populated = { - "teams": {"data": {"teams": {"nodes": [ - {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": [{"id": f"s{i}", "name": n} - for i, n in enumerate( - ["icebox", "triage queue", "backlog", - "in-progress", "done", "cancelled"])]}}]}}}, - "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"}]}}}, - } - fake = FakeTransport(populated) - client = seed.LinearClient("key", transport=fake) - seed.seed(client) - for create_op in ("teamCreate", "workflowStateCreate", - "projectCreate", "customViewCreate"): - assert create_op not in fake.ops() - - -def test_seed_reports_leftover_default_states_without_archiving(): - # An existing team carrying the six columns plus Linear's leftover defaults: - # seed creates nothing and surfaces the defaults for manual tidy-up, and - # never issues an archive. - populated = { - "teams": {"data": {"teams": {"nodes": [ +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 Queue", "Backlog", "In Progress", "Done", "Canceled", "Duplicate"])] + fake = FakeTransport({ + "Teams": {"data": {"teams": {"nodes": [ {"id": "t1", "key": "PEARL", "name": "Pearl", - "states": {"nodes": [{"id": f"s{i}", "name": n} for i, n in enumerate( - ["icebox", "triage queue", "backlog", "in-progress", "done", - "cancelled", "Todo", "In Review"])]}}]}}}, - "projects": {"data": {"projects": {"nodes": [{"id": "p1", "name": "Pearl"}]}}}, - "customViews": {"data": {"customViews": {"nodes": [ + "states": {"nodes": seeded_states}}]}}}, + "TeamStates": {"data": {"team": {"states": {"nodes": seeded_states}}}}, + "StateUpdate": _ok("workflowStateUpdate", {"workflowState": {"id": "u"}}), + "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"}]}}}, - } - fake = FakeTransport(populated) - summary = seed.seed(seed.LinearClient("key", transport=fake)) - assert summary["leftover_states"] == ["Todo", "In Review"] - assert "workflowStateArchive" not in fake.ops() + }) + 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 def test_client_raises_on_graphql_errors(): - fake = FakeTransport({"teams": {"errors": [{"message": "bad key"}]}}) + fake = FakeTransport({"Teams": {"errors": [{"message": "bad key"}]}}) client = seed.LinearClient("key", transport=fake) with pytest.raises(seed.LinearError, match="bad key"): - client.execute("query { teams { nodes { id } } }", {}) + client.execute("query Teams { teams { nodes { id } } }", {}) |
