aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/seed_pearl_workspace.py240
-rw-r--r--scripts/tests/test_seed_pearl_workspace.py249
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 } } }", {})