srcpack 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/linear.ts ADDED
@@ -0,0 +1,368 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { LinearSourceInput } from "./config.ts";
4
+
5
+ const API_URL = "https://api.linear.app/graphql";
6
+
7
+ /** A stalled connection shouldn't leave the CLI hanging with a spinner up. */
8
+ const TIMEOUT_MS = 30_000;
9
+
10
+ /** Personal API key: Linear → Settings → Security & access → Personal API keys. */
11
+ const TOKEN_ENV = "LINEAR_API_KEY";
12
+
13
+ /**
14
+ * Linear charges query complexity as page size × selected fields, and the issue
15
+ * selection below is wide enough that asking for its 250 maximum is rejected
16
+ * outright as "Query too complex".
17
+ */
18
+ const PAGE = 50;
19
+
20
+ /**
21
+ * Terminal workflow state types, excluded unless asked for. `duplicate` is easy
22
+ * to miss — it is a state type of its own, not a state name under `canceled`,
23
+ * so omitting it leaks duplicates into a bundle meant to be non-terminal.
24
+ */
25
+ const CLOSED_STATES = ["completed", "canceled", "duplicate"];
26
+
27
+ /** Linear encodes priority as 0-4; 0 sorts as "no priority", not "lowest". */
28
+ const PRIORITY = ["None", "Urgent", "High", "Medium", "Low"];
29
+
30
+ export class LinearError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "LinearError";
34
+ }
35
+ }
36
+
37
+ interface Issue {
38
+ identifier: string;
39
+ title: string;
40
+ description: string | null;
41
+ priority: number;
42
+ estimate: number | null;
43
+ dueDate: string | null;
44
+ url: string;
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ state: { name: string; type: string };
48
+ labels: { nodes: { name: string; parent: { name: string } | null }[] };
49
+ project: { name: string } | null;
50
+ projectMilestone: { name: string } | null;
51
+ parent: { identifier: string } | null;
52
+ assignee: { name: string } | null;
53
+ }
54
+
55
+ interface Page<T> {
56
+ nodes: T[];
57
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
58
+ }
59
+
60
+ function requireToken(): string {
61
+ // Trimmed: a key read from a file or piped through shell tooling arrives with
62
+ // a trailing newline, which Linear answers with a bare "not authorized".
63
+ const token = process.env[TOKEN_ENV]?.trim();
64
+ if (!token) {
65
+ throw new LinearError(
66
+ `${TOKEN_ENV} is not set. Create a personal API key in Linear ` +
67
+ "(Settings → Security & access → Personal API keys).",
68
+ );
69
+ }
70
+ return token;
71
+ }
72
+
73
+ async function graphql<T>(
74
+ query: string,
75
+ variables: Record<string, unknown>,
76
+ token: string,
77
+ ): Promise<T> {
78
+ let response: Response;
79
+ try {
80
+ response = await fetch(API_URL, {
81
+ method: "POST",
82
+ headers: { authorization: token, "content-type": "application/json" },
83
+ body: JSON.stringify({ query, variables }),
84
+ signal: AbortSignal.timeout(TIMEOUT_MS),
85
+ });
86
+ } catch (error) {
87
+ // A timeout arrives as a DOMException, whose message ("The operation was
88
+ // aborted due to timeout") reads like an internal error rather than advice
89
+ if ((error as Error).name === "TimeoutError") {
90
+ throw new LinearError(
91
+ `Linear API request timed out after ${TIMEOUT_MS / 1000}s.`,
92
+ );
93
+ }
94
+ throw new LinearError(
95
+ `Cannot reach the Linear API: ${(error as Error).message}`,
96
+ );
97
+ }
98
+
99
+ if (response.status === 401 || response.status === 403) {
100
+ throw new LinearError(
101
+ `${TOKEN_ENV} was rejected by Linear (not authorized).`,
102
+ );
103
+ }
104
+
105
+ let body: { data?: T; errors?: { message: string }[] };
106
+ try {
107
+ body = (await response.json()) as typeof body;
108
+ } catch {
109
+ throw new LinearError(`Linear API returned ${response.status}.`);
110
+ }
111
+
112
+ // Linear answers 200 with an `errors` array, so status alone proves nothing.
113
+ if (body.errors?.length) {
114
+ throw new LinearError(body.errors.map((e) => e.message).join("; "));
115
+ }
116
+ if (!response.ok || !body.data) {
117
+ throw new LinearError(`Linear API returned ${response.status}.`);
118
+ }
119
+ return body.data;
120
+ }
121
+
122
+ /** Fill in the shorthand form and the `includeClosed` default. */
123
+ function normalize(source: LinearSourceInput): {
124
+ team: string;
125
+ project?: string;
126
+ includeClosed: boolean;
127
+ } {
128
+ if (typeof source === "string") {
129
+ return { team: source, includeClosed: false };
130
+ }
131
+ return {
132
+ team: source.team,
133
+ project: source.project,
134
+ includeClosed: source.includeClosed ?? false,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Confirm the team exists and resolve the project name to a single id.
140
+ *
141
+ * Linear answers an unknown team key with an empty issue list rather than an
142
+ * error, so without this a typo yields a silently empty bundle — the one
143
+ * failure mode that looks like success. Project names are neither unique nor
144
+ * stable, so they are resolved to an id within the team and required to match
145
+ * exactly one project; filtering issues by name would silently union two.
146
+ */
147
+ async function resolveScope(
148
+ team: string,
149
+ project: string | undefined,
150
+ token: string,
151
+ ): Promise<{ projectId?: string }> {
152
+ const projects = project
153
+ ? "projects(filter: { name: { eq: $project } }, first: 2) { nodes { id } }"
154
+ : "";
155
+ const query = `
156
+ query ($team: String!${project ? ", $project: String!" : ""}) {
157
+ teams(filter: { key: { eq: $team } }, first: 1) {
158
+ nodes { key ${projects} }
159
+ }
160
+ }
161
+ `;
162
+ const data = await graphql<{
163
+ teams: { nodes: { projects?: { nodes: { id: string }[] } }[] };
164
+ }>(query, project ? { team, project } : { team }, token);
165
+
166
+ const found = data.teams.nodes[0];
167
+ if (!found) {
168
+ throw new LinearError(
169
+ `Team "${team}" not found in this Linear workspace. ` +
170
+ "Use the team key shown in issue identifiers (the ENG in ENG-123).",
171
+ );
172
+ }
173
+ if (!project) return {};
174
+
175
+ const matches = found.projects?.nodes ?? [];
176
+ if (matches.length === 0) {
177
+ throw new LinearError(`Project "${project}" not found in team "${team}".`);
178
+ }
179
+ if (matches.length > 1) {
180
+ throw new LinearError(
181
+ `Project "${project}" is ambiguous: team "${team}" has more than one project with that name.`,
182
+ );
183
+ }
184
+ return { projectId: matches[0]!.id };
185
+ }
186
+
187
+ async function fetchIssues(
188
+ filter: Record<string, unknown>,
189
+ token: string,
190
+ ): Promise<Issue[]> {
191
+ // Ordered by `createdAt`, not the `updatedAt` default: a cursor walk is only
192
+ // stable while the sort key is. An issue edited mid-run reorders an
193
+ // `updatedAt` list under the cursor, dropping or repeating its neighbours.
194
+ const query = `
195
+ query ($cursor: String, $filter: IssueFilter) {
196
+ issues(first: ${PAGE}, after: $cursor, filter: $filter, orderBy: createdAt) {
197
+ nodes {
198
+ identifier
199
+ title
200
+ description
201
+ priority
202
+ estimate
203
+ dueDate
204
+ url
205
+ createdAt
206
+ updatedAt
207
+ state { name type }
208
+ # A deliberate cap, not pagination: 50 labels is far past what an
209
+ # issue carries, and each nested page multiplies query complexity
210
+ labels(first: 50) { nodes { name parent { name } } }
211
+ project { name }
212
+ projectMilestone { name }
213
+ parent { identifier }
214
+ assignee { name }
215
+ }
216
+ pageInfo { hasNextPage endCursor }
217
+ }
218
+ }
219
+ `;
220
+
221
+ const issues: Issue[] = [];
222
+ let cursor: string | null = null;
223
+ do {
224
+ const data: { issues: Page<Issue> } = await graphql<{
225
+ issues: Page<Issue>;
226
+ }>(query, { cursor, filter }, token);
227
+ issues.push(...data.issues.nodes);
228
+ cursor = data.issues.pageInfo.hasNextPage
229
+ ? data.issues.pageInfo.endCursor
230
+ : null;
231
+ } while (cursor);
232
+
233
+ return issues;
234
+ }
235
+
236
+ /**
237
+ * Issue mentions arrive as ordinary markdown links and need no handling.
238
+ * Attachments do: an embedded image or video is one long `<linear-embed>` tag
239
+ * carrying an upload URL and a JSON blob, which burns context for nothing.
240
+ */
241
+ function stripEmbeds(markdown: string): string {
242
+ return markdown
243
+ .replace(/<linear-embed\b[^>]*>.*?<\/linear-embed>/gs, "[embed]")
244
+ .replace(/<linear-embed\b[^>]*\/?>/g, "[embed]")
245
+ .trimEnd();
246
+ }
247
+
248
+ function labelNames(issue: Issue): string[] {
249
+ return issue.labels.nodes.map((l) =>
250
+ l.parent ? `${l.parent.name}/${l.name}` : l.name,
251
+ );
252
+ }
253
+
254
+ /** The number in `SM-13`, for ordering. Non-numeric suffixes sort last. */
255
+ function issueNumber(issue: Issue): number {
256
+ const n = Number(issue.identifier.split("-").pop());
257
+ return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
258
+ }
259
+
260
+ /**
261
+ * A roster of every issue in the bundle, emitted as the first Linear entry.
262
+ *
263
+ * The bundle index lists paths, and `linear/issues/SM-13.md` says nothing about
264
+ * what SM-13 is — so without this a model has to read forty issue bodies to
265
+ * find the two that matter, and can't answer "what's in progress" at all.
266
+ *
267
+ * Rows are ordered by issue number, which the index itself cannot be: it sorts
268
+ * paths as text, so SM-2 lands between SM-19 and SM-20.
269
+ */
270
+ function renderSummary(scope: string, issues: Issue[]): string {
271
+ const tally = new Map<string, number>();
272
+ for (const issue of issues) {
273
+ tally.set(issue.state.name, (tally.get(issue.state.name) ?? 0) + 1);
274
+ }
275
+ const counts = [...tally]
276
+ .sort((a, b) => b[1] - a[1])
277
+ .map(([name, n]) => `${name} ${n}`)
278
+ .join(" · ");
279
+
280
+ const rows = [...issues]
281
+ .sort((a, b) => issueNumber(a) - issueNumber(b))
282
+ .map((issue) => {
283
+ // A title carrying `|` would otherwise split into a phantom column
284
+ const title = issue.title.replace(/\|/g, "\\|");
285
+ const priority = PRIORITY[issue.priority] ?? String(issue.priority);
286
+ return `| ${issue.identifier} | ${issue.state.name} | ${priority} | ${title} |`;
287
+ });
288
+
289
+ return [
290
+ `# ${scope} — ${issues.length} ${issues.length === 1 ? "issue" : "issues"}`,
291
+ "",
292
+ counts,
293
+ "",
294
+ "| Issue | State | Priority | Title |",
295
+ "| --- | --- | --- | --- |",
296
+ ...rows,
297
+ ].join("\n");
298
+ }
299
+
300
+ /** Render one issue as markdown: a title, an aligned field block, the body. */
301
+ function render(issue: Issue): string {
302
+ const field = (name: string, value: string | null | undefined) =>
303
+ `${name.padEnd(10)} ${value?.length ? value : "—"}`;
304
+
305
+ const description = issue.description?.trim()
306
+ ? stripEmbeds(issue.description)
307
+ : "(no description)";
308
+
309
+ return [
310
+ `# ${issue.identifier} ${issue.title}`,
311
+ "",
312
+ field("State", `${issue.state.name} (${issue.state.type})`),
313
+ field("Priority", PRIORITY[issue.priority] ?? String(issue.priority)),
314
+ field("Estimate", issue.estimate === null ? null : String(issue.estimate)),
315
+ field("Labels", labelNames(issue).join(", ")),
316
+ field("Project", issue.project?.name),
317
+ field("Milestone", issue.projectMilestone?.name),
318
+ field("Parent", issue.parent?.identifier),
319
+ field("Assignee", issue.assignee?.name),
320
+ field("Due", issue.dueDate),
321
+ field("Created", issue.createdAt.slice(0, 10)),
322
+ field("Updated", issue.updatedAt.slice(0, 10)),
323
+ field("URL", issue.url),
324
+ "",
325
+ description,
326
+ ].join("\n");
327
+ }
328
+
329
+ /**
330
+ * Resolve a `linear` bundle source to one virtual file per issue.
331
+ *
332
+ * Paths are namespaced under `linear/issues/` so they sort together, read as
333
+ * files in the index, and can be filtered with ordinary `!` exclusions.
334
+ *
335
+ * The return type is structural rather than `Entry` from `bundle.ts`: a source
336
+ * shouldn't depend on the module that orchestrates it.
337
+ */
338
+ export async function resolveLinearSource(
339
+ source: LinearSourceInput,
340
+ ): Promise<{ path: string; content: string }[]> {
341
+ const { team, project, includeClosed } = normalize(source);
342
+ const token = requireToken();
343
+
344
+ const { projectId } = await resolveScope(team, project, token);
345
+
346
+ const filter: Record<string, unknown> = { team: { key: { eq: team } } };
347
+ if (projectId) filter.project = { id: { eq: projectId } };
348
+ if (!includeClosed) filter.state = { type: { nin: CLOSED_STATES } };
349
+
350
+ const issues = await fetchIssues(filter, token);
351
+ if (issues.length === 0) return [];
352
+
353
+ // Sorts ahead of `linear/issues/…` because "." precedes "/", so the roster is
354
+ // the first Linear entry a reader meets. Subject to the same collision check
355
+ // and `!` exclusions as any other entry.
356
+ const scope = project ? `${team} / ${project}` : team;
357
+ const entries = [
358
+ { path: "linear/issues.md", content: renderSummary(scope, issues) },
359
+ ];
360
+
361
+ for (const issue of issues) {
362
+ entries.push({
363
+ path: `linear/issues/${issue.identifier}.md`,
364
+ content: render(issue),
365
+ });
366
+ }
367
+ return entries;
368
+ }