1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
|
#!/usr/bin/env python3
"""Seed a Linear workspace with Pearl's dogfooding conventions.
Brings a workspace to:
- 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: 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 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.
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]
"""
import argparse
import json
import os
import sys
import urllib.request
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
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 Queue takes
# over the unstarted Todo). Positions are distinct and nonzero.
TARGET_STATES = [
{"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."""
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"}}}},
{"name": "Pearl Inbox",
"filterData": {**proj, "state": {"name": {"eq": "Triage Queue"}}}},
]
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.
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.
"""
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):
"""A GraphQL request returned an errors array."""
def _http_transport(api_key):
def transport(query, variables):
body = json.dumps({"query": query, "variables": variables}).encode()
req = urllib.request.Request(
LINEAR_GRAPHQL_URL, data=body,
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.
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)
def execute(self, query, variables):
resp = self._transport(query, variables)
if resp.get("errors"):
raise LinearError("; ".join(e.get("message", str(e)) for e in resp["errors"]))
return resp["data"]
def find_team(self):
data = self.execute(
"query Teams { teams { nodes { id key name "
"states { nodes { id name type position } } } } }", {})
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 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 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 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):
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"]}})
def seed(client):
"""Reconcile the Pearl team, columns, project, and views; idempotent.
Returns {team, project, states_created, views_created, leftover_states}.
"""
team = client.find_team()
if team is None:
team = client.create_team()
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"]
states_created = []
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 = 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"])
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 reconcile plan without changing anything")
args = parser.parse_args(argv)
api_key = os.environ.get("LINEAR_API_KEY")
if not api_key:
print("LINEAR_API_KEY is not set", file=sys.stderr)
return 2
client = LinearClient(api_key)
if args.dry_run:
team = client.find_team()
states = team.get("states", {}).get("nodes", []) if team else []
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']}")
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'}")
if summary["leftover_states"]:
print(f" leftover columns (left as-is; Duplicate is reserved/hidden): "
f"{summary['leftover_states']}")
return 0
if __name__ == "__main__":
sys.exit(main())
|