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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
|
#!/usr/bin/env python3
"""Import an org-mode backlog into the Pearl Linear workspace.
The org file keeps open tasks under a "Pearl Open Work" section as level-2
TODO/DOING headings, each with a priority cookie, optional tags, and a body.
This brings them into Linear as issues.
The work splits into a pure parser/mapper (no I/O) and a thin GraphQL client:
- parse_open_work_tasks pulls the level-2 tasks out of the section, dropping
the two meta umbrellas (the dogfooding parent and the manual-testing
checklist) and everything under the Resolved section,
- label_names_needed / task_to_issue turn a task into an issueCreate input --
priority A-D maps to Linear 1-4, a [#D] task lands in Icebox and the rest in
Backlog, and tags become label ids (the discuss/next/cleanup/pearl tags are
dropped on the way in),
- import_backlog creates any missing labels, then the issues, skipping a task
whose title already exists so a re-run is safe.
Usage:
LINEAR_API_KEY=lin_api_... python3 import_org_backlog.py todo.org [--dry-run]
"""
import argparse
import json
import os
import re
import sys
import urllib.request
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"
TEAM_NAME = "Pearl"
PROJECT_NAME = "Pearl"
SECTION_HEADER = "Pearl Open Work"
# Umbrella headings that organize other work rather than being tasks themselves.
SKIP_TITLES = {"Personal Account Dogfooding", "Manual testing and validation"}
# Tags that carry no meaning as Linear labels -- workflow markers, not topics.
DROPPED_TAGS = {"discuss", "next", "cleanup", "pearl"}
PRIORITY_MAP = {"A": 1, "B": 2, "C": 3, "D": 4}
# A [#D] "someday" task is icebox material; everything above it is backlog.
ICEBOX_PRIORITY = "D"
LABEL_COLOR = "#bec2c8"
_HEADING_RE = re.compile(r"^\*\*\s+(TODO|DOING)\s+(.*)$")
_PRIORITY_RE = re.compile(r"^\[#([A-Z])\]\s*")
_TAGS_RE = re.compile(r"\s+(:[\w@#%]+(?::[\w@#%]+)*:)\s*$")
# --- pure: parser ------------------------------------------------------------
def _parse_heading(rest):
"""Split a heading's text into (priority, title, tags).
REST is everything after the TODO/DOING keyword. The priority cookie leads,
the tags trail; the title is what's left between them. The tag regex anchors
to the line end so a colon-laden title (e.g. an =#+title:= reference) isn't
mistaken for tags.
"""
rest = rest.strip()
priority = None
pm = _PRIORITY_RE.match(rest)
if pm:
priority = pm.group(1)
rest = rest[pm.end():]
tags = []
tm = _TAGS_RE.search(rest)
if tm:
tags = [t for t in tm.group(1).split(":") if t]
rest = rest[:tm.start()]
return priority, rest.strip(), tags
def _clean_body(lines):
"""Join body LINES, dropping the :PROPERTIES: drawer and trimming blanks."""
out, in_drawer = [], False
for line in lines:
s = line.strip()
if not in_drawer and s == ":PROPERTIES:":
in_drawer = True
continue
if in_drawer:
if s == ":END:":
in_drawer = False
continue
out.append(line.rstrip())
return "\n".join(out).strip()
def parse_open_work_tasks(text):
"""Return the open-work tasks as dicts: keyword, priority, title, tags, body.
Only level-2 TODO/DOING headings under the "Pearl Open Work" section are
returned. Sub-entries (level 3+), the property drawer, the two meta
umbrellas, and anything after the section ends are excluded.
"""
lines = text.splitlines()
start = None
for i, line in enumerate(lines):
if re.match(r"^\*\s", line) and line.lstrip("* ").strip() == SECTION_HEADER:
start = i + 1
break
if start is None:
return []
end = len(lines)
for i in range(start, len(lines)):
if re.match(r"^\*\s", lines[i]): # next level-1 heading ends the section
end = i
break
region = lines[start:end]
tasks = []
i = 0
while i < len(region):
m = _HEADING_RE.match(region[i])
if not m:
i += 1
continue
priority, title, tags = _parse_heading(m.group(2))
j = i + 1
body_lines = []
while j < len(region) and not re.match(r"^\*+\s", region[j]):
body_lines.append(region[j])
j += 1
if title not in SKIP_TITLES:
tasks.append({"keyword": m.group(1), "priority": priority,
"title": title, "tags": tags, "body": _clean_body(body_lines)})
i = j
return tasks
# --- pure: label set + mapping -----------------------------------------------
def label_names_needed(tasks):
"""Return the set of Linear label names the TASKS need (capitalized tags)."""
return {tag.capitalize()
for task in tasks for tag in task["tags"] if tag not in DROPPED_TAGS}
def task_to_issue(task, states, label_ids):
"""Map a TASK to an issueCreate input.
STATES maps state name -> id (needs Backlog and Icebox); LABEL_IDS maps a
lowercase tag -> label id. Dropped tags and tags without a known label id
are left off.
"""
state = "Icebox" if task["priority"] == ICEBOX_PRIORITY else "Backlog"
return {
"title": task["title"],
"description": task["body"],
"priority": PRIORITY_MAP.get(task["priority"], 0),
"stateId": states[state],
"labelIds": [label_ids[t] for t in task["tags"]
if t not in DROPPED_TAGS and t in label_ids],
}
# --- boundary: a thin GraphQL client -----------------------------------------
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, Projects,
ProjectIssues, LabelCreate, IssueCreate) 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 name "
"labels { nodes { id name } } states { nodes { id name } } } } }", {})
nodes = data["teams"]["nodes"]
return next((t for t in nodes if t.get("name") == TEAM_NAME), nodes[0])
def find_project(self):
data = self.execute("query Projects { projects { nodes { id name } } }", {})
nodes = data["projects"]["nodes"]
return next((p for p in nodes if p.get("name") == PROJECT_NAME),
nodes[0] if nodes else None)
def project_issue_titles(self, project_id):
data = self.execute(
"query ProjectIssues($id: String!) "
"{ project(id: $id) { issues { nodes { title } } } }", {"id": project_id})
return [n["title"] for n in data["project"]["issues"]["nodes"]]
def create_label(self, team_id, name):
data = self.execute(
"mutation LabelCreate($input: IssueLabelCreateInput!) "
"{ issueLabelCreate(input: $input) { success issueLabel { id } } }",
{"input": {"teamId": team_id, "name": name, "color": LABEL_COLOR}})
return data["issueLabelCreate"]["issueLabel"]["id"]
def create_issue(self, team_id, project_id, issue):
data = self.execute(
"mutation IssueCreate($input: IssueCreateInput!) "
"{ issueCreate(input: $input) { success issue { id } } }",
{"input": {"teamId": team_id, "projectId": project_id, **issue}})
return data["issueCreate"]["issue"]["id"]
def import_backlog(client, text):
"""Create labels and issues for the org backlog in TEXT; idempotent by title.
Returns {created, skipped, labels_created} -- lists of titles created,
titles skipped (already present), and label names freshly created.
"""
tasks = parse_open_work_tasks(text)
team = client.find_team()
team_id = team["id"]
# Existing labels, matched case-insensitively; create the ones we lack.
label_ids = {l["name"].lower(): l["id"]
for l in team.get("labels", {}).get("nodes", [])}
labels_created = []
for name in sorted(label_names_needed(tasks)):
if name.lower() not in label_ids:
label_ids[name.lower()] = client.create_label(team_id, name)
labels_created.append(name)
states = {s["name"]: s["id"] for s in team.get("states", {}).get("nodes", [])}
project = client.find_project()
project_id = project["id"]
existing = set(client.project_issue_titles(project_id))
created, skipped = [], []
for task in tasks:
if task["title"] in existing:
skipped.append(task["title"])
continue
client.create_issue(team_id, project_id, task_to_issue(task, states, label_ids))
created.append(task["title"])
return {"created": created, "skipped": skipped, "labels_created": labels_created}
def main(argv=None):
parser = argparse.ArgumentParser(description="Import an org backlog into Pearl Linear.")
parser.add_argument("orgfile", help="path to the org file (e.g. todo.org)")
parser.add_argument("--dry-run", action="store_true",
help="print the tasks that would be imported without creating anything")
args = parser.parse_args(argv)
with open(args.orgfile, encoding="utf-8") as fh:
text = fh.read()
if args.dry_run:
tasks = parse_open_work_tasks(text)
print(f"Would import {len(tasks)} task(s):")
for t in tasks:
state = "Icebox" if t["priority"] == ICEBOX_PRIORITY else "Backlog"
kept = [tag for tag in t["tags"] if tag not in DROPPED_TAGS]
print(f" [#{t['priority']}] -> {state:7} {t['title']}"
+ (f" :{':'.join(kept)}:" if kept else ""))
print(f"Labels needed: {sorted(label_names_needed(tasks)) or 'none'}")
return 0
api_key = os.environ.get("LINEAR_API_KEY")
if not api_key:
print("LINEAR_API_KEY is not set", file=sys.stderr)
return 2
summary = import_backlog(LinearClient(api_key), text)
print(f"Created {len(summary['created'])} issue(s); "
f"skipped {len(summary['skipped'])} already present.")
if summary["labels_created"]:
print(f" labels created: {summary['labels_created']}")
return 0
if __name__ == "__main__":
sys.exit(main())
|