srcpack 0.2.0 → 1.0.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
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,238 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import type { BundleResult } from "./bundle.ts";
5
+ import { ConfigError, type BundleConfig } from "./config.ts";
6
+ import { entryPath, pathKey, physicalPath } from "./fs.ts";
7
+ import {
8
+ isImageOf,
9
+ toScreenshotTarget,
10
+ type CapturedPage,
11
+ type ScreenshotTarget,
12
+ } from "./screenshot.ts";
13
+
14
+ // Check output destinations before resolving sources or launching a browser.
15
+ // Retain each active plan through writing, reporting and upload.
16
+
17
+ /** Where a bundle writes. */
18
+ export interface PlannedBundle {
19
+ name: string;
20
+ source: BundleConfig;
21
+ /** Absolute path of the text output. Absent for a screenshot-only bundle. */
22
+ text?: { outfile: string };
23
+ /** Page to capture into `<dir>/<name>-NN.png`. */
24
+ images?: { target: ScreenshotTarget; dir: string };
25
+ }
26
+
27
+ /** What a bundle produced, ready to write. */
28
+ export interface ResolvedBundle {
29
+ plan: PlannedBundle;
30
+ text?: BundleResult;
31
+ images?: CapturedPage;
32
+ }
33
+
34
+ export interface BundleSelection {
35
+ /** Bundles this run builds, in the order named or configured. */
36
+ names: string[];
37
+ /** On-demand bundles a full run left out. Empty when bundles are named. */
38
+ skipped: string[];
39
+ }
40
+
41
+ function isOnDemand(config: BundleConfig): boolean {
42
+ return (
43
+ typeof config === "object" &&
44
+ !Array.isArray(config) &&
45
+ config.onDemand === true
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Pick the bundles a run builds. Naming a bundle always builds it; a full run
51
+ * builds everything not marked `onDemand`.
52
+ */
53
+ export function selectBundles(
54
+ bundles: Record<string, BundleConfig>,
55
+ requested: string[],
56
+ ): BundleSelection {
57
+ for (const name of requested) {
58
+ // hasOwn, not `in`: `srcpack toString` would otherwise find Object.prototype
59
+ if (!Object.hasOwn(bundles, name)) {
60
+ throw new ConfigError(`Unknown bundle: ${name}`);
61
+ }
62
+ }
63
+ if (requested.length) {
64
+ return { names: [...new Set(requested)], skipped: [] };
65
+ }
66
+
67
+ const names: string[] = [];
68
+ const skipped: string[] = [];
69
+ for (const [name, config] of Object.entries(bundles)) {
70
+ (isOnDemand(config) ? skipped : names).push(name);
71
+ }
72
+ return { names, skipped };
73
+ }
74
+
75
+ export interface OutputPlan {
76
+ /** The bundles this run builds, in order. */
77
+ bundles: PlannedBundle[];
78
+ /**
79
+ * Absolute paths srcpack writes, never to be bundled: every configured text
80
+ * output and every active one, each in lexical and entry spelling. A glob
81
+ * rooted at a symlink yields lexical paths, one rooted at the real directory
82
+ * yields physical ones, and either can name a file the previous run wrote.
83
+ *
84
+ * Image families need no entry: they always live in outDir, which the CLI
85
+ * excludes, and when outDir holds the root a PNG is skipped as binary anyway.
86
+ */
87
+ ownOutputs: string[];
88
+ }
89
+
90
+ function planBundle(
91
+ name: string,
92
+ source: BundleConfig,
93
+ root: string,
94
+ outDir: string,
95
+ ): PlannedBundle {
96
+ const object =
97
+ typeof source === "object" && !Array.isArray(source) ? source : undefined;
98
+ const plan: PlannedBundle = { name, source };
99
+ // Every bundle writes text unless a screenshot is all it declares
100
+ if (!object || object.include || object.linear) {
101
+ const outfile = object?.outfile ?? join(outDir, `${name}.txt`);
102
+ plan.text = { outfile: resolve(root, outfile) };
103
+ }
104
+ // Keep image cleanup within outDir, independent of any text outfile.
105
+ if (object?.screenshot) {
106
+ plan.images = {
107
+ target: toScreenshotTarget(object.screenshot),
108
+ dir: resolve(root, outDir),
109
+ };
110
+ }
111
+ return plan;
112
+ }
113
+
114
+ /**
115
+ * A claim on a destination: a text file, or an image family — a directory
116
+ * plus a name prefix. Compared folded (`pathKey`) and physically.
117
+ *
118
+ * A text file's directory is its entry path's parent, as for any output. A
119
+ * family's directory resolves fully: it is an ancestor of every PNG, and
120
+ * `rename` replaces only the last component (ADR 004).
121
+ */
122
+ interface Claim {
123
+ owner: string;
124
+ kind: "file" | "family";
125
+ dir: string;
126
+ /** Folded file name, or folded bundle name for a family. */
127
+ name: string;
128
+ /** Absolute path shown in a collision message. */
129
+ display: string;
130
+ }
131
+
132
+ function overlaps(a: Claim, b: Claim): boolean {
133
+ if (a.dir !== b.dir) return false;
134
+ if (a.kind === b.kind) return a.name === b.name;
135
+ const [file, family] = a.kind === "file" ? [a, b] : [b, a];
136
+ return isImageOf(family.name, file.name);
137
+ }
138
+
139
+ /** `entry` is the text output's entry path, resolved once by the caller. */
140
+ async function claimsOf(
141
+ plan: PlannedBundle,
142
+ entry: string | undefined,
143
+ ): Promise<Claim[]> {
144
+ const claims: Claim[] = [];
145
+ if (plan.text && entry) {
146
+ claims.push({
147
+ owner: plan.name,
148
+ kind: "file",
149
+ dir: pathKey(dirname(entry)),
150
+ name: pathKey(basename(entry)),
151
+ display: plan.text.outfile,
152
+ });
153
+ }
154
+ if (plan.images) {
155
+ claims.push({
156
+ owner: plan.name,
157
+ kind: "family",
158
+ dir: pathKey(await physicalPath(plan.images.dir)),
159
+ name: pathKey(plan.name),
160
+ display: join(plan.images.dir, `${plan.name}-NN.png`),
161
+ });
162
+ }
163
+ return claims;
164
+ }
165
+
166
+ function collision(first: Claim, second: Claim, root: string): ConfigError {
167
+ // Name the later bundle's destination, unless only the earlier one is a file:
168
+ // a file is what the user can change
169
+ const { display } =
170
+ first.kind === "file" && second.kind === "family" ? first : second;
171
+ const where = relative(root, display) || display;
172
+ if (first.owner === second.owner) {
173
+ return new ConfigError(
174
+ `Bundle "${first.owner}" writes its text and its images to "${where}". Give it another outfile.`,
175
+ );
176
+ }
177
+ return new ConfigError(
178
+ `Bundles "${first.owner}" and "${second.owner}" both write to "${where}". ` +
179
+ (first.kind === "family" && second.kind === "family"
180
+ ? "Rename one of them."
181
+ : "Give one of them its own outfile."),
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Derive where every bundle writes and reject two bundles sharing a file.
187
+ *
188
+ * Resolve directory aliases and compare destinations with `pathKey` to prevent
189
+ * silent overwrites. Text files also collide with numbered images in the same
190
+ * directory, including their own bundle's images.
191
+ *
192
+ * Configured bundles are checked against each other on every run, so a config
193
+ * error doesn't depend on what was asked for. Active bundles — the ones `active`
194
+ * names, ad-hoc included — are checked against configured bundles of a
195
+ * different name: an ad-hoc bundle shadows a configured bundle of its own name
196
+ * (`--staged` over a `staged` bundle) but never overwrites another's output.
197
+ * Active bundles need no check among themselves: they are either configured,
198
+ * and already checked, or a single ad-hoc bundle.
199
+ */
200
+ export async function planOutputs(
201
+ root: string,
202
+ outDir: string,
203
+ configured: Record<string, BundleConfig>,
204
+ active: [name: string, source: BundleConfig][],
205
+ ): Promise<OutputPlan> {
206
+ const ownOutputs = new Set<string>();
207
+ const claims: Claim[] = [];
208
+
209
+ const claim = async (plan: PlannedBundle, shadowsOwnName: boolean) => {
210
+ const entry = plan.text && (await entryPath(plan.text.outfile));
211
+ for (const next of await claimsOf(plan, entry)) {
212
+ const taken = claims.find(
213
+ (prior) =>
214
+ !(shadowsOwnName && prior.owner === next.owner) &&
215
+ overlaps(prior, next),
216
+ );
217
+ if (taken) throw collision(taken, next, root);
218
+ claims.push(next);
219
+ }
220
+ if (plan.text && entry) {
221
+ ownOutputs.add(plan.text.outfile);
222
+ ownOutputs.add(entry);
223
+ }
224
+ };
225
+
226
+ for (const [name, source] of Object.entries(configured)) {
227
+ await claim(planBundle(name, source, root, outDir), false);
228
+ }
229
+
230
+ const bundles: PlannedBundle[] = [];
231
+ for (const [name, source] of active) {
232
+ const plan = planBundle(name, source, root, outDir);
233
+ await claim(plan, true);
234
+ bundles.push(plan);
235
+ }
236
+
237
+ return { bundles, ownOutputs: [...ownOutputs] };
238
+ }