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/README.md +34 -15
- package/dist/bundle.d.ts +47 -5
- package/dist/cli.js +818 -400
- package/dist/config.d.ts +50 -9
- package/dist/index.js +50 -13
- package/dist/linear.d.ts +17 -0
- package/package.json +4 -1
- package/src/bundle.ts +242 -51
- package/src/cli.ts +224 -54
- package/src/config.ts +180 -37
- package/src/linear.ts +368 -0
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
|
+
}
|