issuary 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,3528 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { createRequire } from 'node:module';
4
+ import { homedir } from 'node:os';
5
+ import { join, dirname } from 'node:path';
6
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import Database from 'better-sqlite3';
8
+ import { parse } from 'yaml';
9
+ import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
10
+
11
+ /**
12
+ * Names of the typed errors issuary raises for expected, user-facing failures.
13
+ *
14
+ * These carry a message written for a human, so the CLI prints just that line
15
+ * (no stack trace) and exits non-zero. Anything else is treated as unexpected
16
+ * and shown with more detail to aid debugging.
17
+ */
18
+ const FRIENDLY_ERROR_NAMES = new Set([
19
+ "ConfigError",
20
+ "GitHubError",
21
+ "NetworkError",
22
+ "RepoCommandError",
23
+ "SyncCommandError",
24
+ "DigestError",
25
+ "RepoDigestError",
26
+ "CompactCommandError",
27
+ "CompactValidationError",
28
+ "ShowCommandError",
29
+ "SkillCommandError",
30
+ "AuthError",
31
+ ]);
32
+ /**
33
+ * Top-level error handler for the CLI entry point.
34
+ *
35
+ * Known typed errors ({@link FRIENDLY_ERROR_NAMES}) print their message alone,
36
+ * with no stack trace. Unknown errors print a generic line plus their stack (or
37
+ * string form) so genuine bugs stay debuggable. Always sets a non-zero exit
38
+ * code; never swallows an error silently.
39
+ *
40
+ * @param error - The thrown value to report.
41
+ * @param sink - Output sink; defaults to `console`.
42
+ * @returns The process exit code to use (always 1).
43
+ */
44
+ function handleCliError(error, sink = console) {
45
+ if (error instanceof Error && FRIENDLY_ERROR_NAMES.has(error.name)) {
46
+ sink.error(error.message);
47
+ return 1;
48
+ }
49
+ if (error instanceof Error) {
50
+ sink.error(`Unexpected error: ${error.stack ?? error.message}`);
51
+ return 1;
52
+ }
53
+ sink.error(`Unexpected error: ${String(error)}`);
54
+ return 1;
55
+ }
56
+
57
+ /** Name of the credentials file inside the issuary home directory. */
58
+ const CREDENTIALS_FILE = "credentials.json";
59
+ /** Absolute path to the credentials file inside the given issuary home. */
60
+ function credentialsPath(home) {
61
+ return join(home, CREDENTIALS_FILE);
62
+ }
63
+ /**
64
+ * Reads the stored GitHub token from `{home}/credentials.json`.
65
+ *
66
+ * @param home - The issuary home directory.
67
+ * @returns The trimmed token, or `null` when absent or unreadable.
68
+ */
69
+ function readStoredToken(home) {
70
+ const path = credentialsPath(home);
71
+ if (!existsSync(path)) {
72
+ return null;
73
+ }
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
76
+ const token = (parsed.github_token ?? "").trim();
77
+ return token === "" ? null : token;
78
+ }
79
+ catch {
80
+ // A corrupt or unreadable file should not crash callers; treat as no token.
81
+ return null;
82
+ }
83
+ }
84
+ /**
85
+ * Writes the GitHub token to `{home}/credentials.json` with mode `0600`.
86
+ *
87
+ * Creates the home directory if needed. The token is never logged.
88
+ *
89
+ * @param home - The issuary home directory.
90
+ * @param token - The GitHub access token to store.
91
+ */
92
+ function writeStoredToken(home, token) {
93
+ // 0700 so a stored credential is not even listable by other local users; the
94
+ // file itself is 0600. mode only applies on creation, which is fine here.
95
+ mkdirSync(home, { recursive: true, mode: 0o700 });
96
+ const path = credentialsPath(home);
97
+ const data = { github_token: token };
98
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
99
+ }
100
+ /**
101
+ * Removes the stored credentials file.
102
+ *
103
+ * @param home - The issuary home directory.
104
+ * @returns `true` when a file was removed, `false` when none existed.
105
+ */
106
+ function clearStoredToken(home) {
107
+ const path = credentialsPath(home);
108
+ if (!existsSync(path)) {
109
+ return false;
110
+ }
111
+ rmSync(path);
112
+ return true;
113
+ }
114
+
115
+ /** Default GitHub REST API base URL used when `GITHUB_API_URL` is unset. */
116
+ const DEFAULT_API_URL = "https://api.github.com";
117
+ /**
118
+ * Error thrown when configuration is invalid or incomplete.
119
+ *
120
+ * Callers should catch this to print a friendly, actionable message instead of
121
+ * a stack trace. Messages never contain secret values such as the token.
122
+ */
123
+ class ConfigError extends Error {
124
+ constructor(message) {
125
+ super(message);
126
+ this.name = "ConfigError";
127
+ }
128
+ }
129
+ /**
130
+ * Loads and validates configuration from the environment.
131
+ *
132
+ * Reads `GITHUB_TOKEN`, `GITHUB_API_URL` and `ISSUARY_HOME`, applying defaults and
133
+ * normalization. The token is resolved in precedence order: the `GITHUB_TOKEN`
134
+ * environment variable wins, then the token stored by `issuary login` in the
135
+ * credentials file. Throws {@link ConfigError} when a required value is missing.
136
+ *
137
+ * @param options - Resolution options; see {@link LoadConfigOptions}.
138
+ * @returns The resolved {@link Config}.
139
+ * @throws {ConfigError} When `requireToken` is `true` and no token is resolvable.
140
+ */
141
+ function loadConfig(options = {}) {
142
+ const { requireToken = true } = options;
143
+ const apiUrl = normalizeApiUrl(process.env.GITHUB_API_URL);
144
+ const home = (process.env.ISSUARY_HOME ?? "").trim() || join(homedir(), ".issuary");
145
+ const dbPath = join(home, "db.sqlite");
146
+ // Precedence: GITHUB_TOKEN env wins, then a token stored by `issuary login`.
147
+ const envToken = (process.env.GITHUB_TOKEN ?? "").trim();
148
+ const token = envToken || readStoredToken(home) || "";
149
+ if (requireToken && token === "") {
150
+ throw new ConfigError("No GitHub token found. issuary needs a GitHub personal access token to reach the API. " +
151
+ "Either run `issuary login` to authenticate via the browser, or create a token at " +
152
+ "https://github.com/settings/tokens with the `repo` (or `public_repo`) read scope and " +
153
+ "export it, e.g. `export GITHUB_TOKEN=ghp_...`.");
154
+ }
155
+ return { token, apiUrl, home, dbPath };
156
+ }
157
+ /**
158
+ * Normalizes the API base URL: falls back to the default when unset/empty and
159
+ * trims any trailing slashes.
160
+ */
161
+ function normalizeApiUrl(raw) {
162
+ const value = (raw ?? "").trim() || DEFAULT_API_URL;
163
+ return value.replace(/\/+$/, "");
164
+ }
165
+
166
+ /**
167
+ * Error thrown for a non-2xx, non-304 GitHub response (401, 403, 404, 422, ...).
168
+ *
169
+ * Carries the HTTP `status` and the parsed {@link RateLimit} so callers can
170
+ * distinguish auth failures from a rate-limit 403 and back off accordingly.
171
+ */
172
+ class GitHubError extends Error {
173
+ /** HTTP status code of the failing response. */
174
+ status;
175
+ /** Rate-limit state at the time of the failure, when available. */
176
+ rateLimit;
177
+ constructor(message, status, rateLimit = null) {
178
+ super(message);
179
+ this.name = "GitHubError";
180
+ this.status = status;
181
+ this.rateLimit = rateLimit;
182
+ }
183
+ }
184
+ /**
185
+ * Error thrown when a `fetch` to GitHub keeps failing at the transport level
186
+ * (DNS, connection reset, `fetch failed`) after the client's bounded retries are
187
+ * exhausted. Distinct from {@link GitHubError}, which carries an HTTP status.
188
+ *
189
+ * Callers should catch this to print a friendly message instead of a raw stack.
190
+ */
191
+ class NetworkError extends Error {
192
+ /** The last underlying transport error that triggered the failure, if any. */
193
+ cause;
194
+ constructor(message, cause) {
195
+ super(message);
196
+ this.name = "NetworkError";
197
+ this.cause = cause;
198
+ }
199
+ }
200
+
201
+ /** Returns true when the payload item is a pull request, not a real issue. */
202
+ function isPullRequest(item) {
203
+ return item.pull_request != null;
204
+ }
205
+ function asString(value) {
206
+ return typeof value === "string" ? value : "";
207
+ }
208
+ function asStringOrNull(value) {
209
+ return typeof value === "string" ? value : null;
210
+ }
211
+ function asNumber(value) {
212
+ return typeof value === "number" ? value : 0;
213
+ }
214
+ function authorOf(user) {
215
+ return asStringOrNull(user?.login);
216
+ }
217
+ function stateOf(value) {
218
+ return value === "closed" ? "closed" : "open";
219
+ }
220
+ function stateReasonOf(value) {
221
+ return value === "completed" || value === "not_planned" ? value : null;
222
+ }
223
+ function labelsOf(value) {
224
+ if (!Array.isArray(value)) {
225
+ return [];
226
+ }
227
+ const names = [];
228
+ for (const label of value) {
229
+ const name = asStringOrNull(label?.name);
230
+ if (name !== null) {
231
+ names.push(name);
232
+ }
233
+ }
234
+ return names;
235
+ }
236
+ /**
237
+ * Maps a raw GitHub issue payload to the lean {@link NormalizedIssue} shape.
238
+ *
239
+ * `state_reason` and `labels` are copied verbatim, never interpreted.
240
+ */
241
+ function normalizeIssue(raw) {
242
+ return {
243
+ number: asNumber(raw.number),
244
+ title: asString(raw.title),
245
+ state: stateOf(raw.state),
246
+ state_reason: stateReasonOf(raw.state_reason),
247
+ author: authorOf(raw.user),
248
+ labels: labelsOf(raw.labels),
249
+ created_at: asString(raw.created_at),
250
+ updated_at: asString(raw.updated_at),
251
+ closed_at: asStringOrNull(raw.closed_at),
252
+ comment_count: asNumber(raw.comments),
253
+ body: asStringOrNull(raw.body),
254
+ };
255
+ }
256
+ /**
257
+ * Maps a raw GitHub comment payload to the lean {@link NormalizedComment} shape.
258
+ */
259
+ function normalizeComment(raw) {
260
+ return {
261
+ id: asNumber(raw.id),
262
+ author: authorOf(raw.user),
263
+ created_at: asString(raw.created_at),
264
+ updated_at: asString(raw.updated_at),
265
+ body: asStringOrNull(raw.body),
266
+ };
267
+ }
268
+
269
+ const API_VERSION = "2022-11-28";
270
+ const ACCEPT = "application/vnd.github+json";
271
+ const USER_AGENT = "merencia-issuary";
272
+ const PER_PAGE = 100;
273
+ /** Default number of automatic retries for rate-limit and network failures. */
274
+ const DEFAULT_MAX_RETRIES = 3;
275
+ /** Default cap on how long the client waits for a rate-limit window to reset. */
276
+ const DEFAULT_MAX_RATE_LIMIT_WAIT_MS = 3 * 60 * 1000;
277
+ /** Base delay (ms) for the exponential backoff between network-error retries. */
278
+ const DEFAULT_NETWORK_BACKOFF_MS = 500;
279
+ /** Default sleep backed by a real timer. */
280
+ function defaultSleep$1(ms) {
281
+ return new Promise((resolve) => setTimeout(resolve, ms));
282
+ }
283
+ /**
284
+ * Heuristic for a transient transport failure worth retrying: a thrown error
285
+ * (not an HTTP response) whose code or message points at a reset connection,
286
+ * a DNS hiccup, a timeout, or undici's generic `fetch failed`.
287
+ */
288
+ function isTransientNetworkError(error) {
289
+ if (!(error instanceof Error)) {
290
+ return false;
291
+ }
292
+ const code = error.code;
293
+ if (typeof code === "string") {
294
+ if (["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_SOCKET"].includes(code)) {
295
+ return true;
296
+ }
297
+ }
298
+ const message = error.message.toLowerCase();
299
+ return (message.includes("fetch failed") ||
300
+ message.includes("econnreset") ||
301
+ message.includes("socket hang up") ||
302
+ message.includes("terminated"));
303
+ }
304
+ /** True when a response is a rate-limit rejection (403/429 with the tell-tale signals). */
305
+ function isRateLimitedResponse(response, rateLimit) {
306
+ if (response.status !== 403 && response.status !== 429) {
307
+ return false;
308
+ }
309
+ if (response.headers.has("retry-after")) {
310
+ return true;
311
+ }
312
+ return rateLimit?.remaining === 0;
313
+ }
314
+ /**
315
+ * Computes how long to wait (ms) before retrying a rate-limited response.
316
+ * Prefers `Retry-After` (delta seconds), falling back to `x-ratelimit-reset`
317
+ * (epoch seconds). Returns 0 when no hint is present.
318
+ */
319
+ function rateLimitWaitMs(response, rateLimit, nowMs) {
320
+ const retryAfter = response.headers.get("retry-after");
321
+ if (retryAfter !== null) {
322
+ const seconds = Number(retryAfter);
323
+ if (Number.isFinite(seconds) && seconds >= 0) {
324
+ return Math.ceil(seconds * 1000);
325
+ }
326
+ }
327
+ if (rateLimit?.reset != null && Number.isFinite(rateLimit.reset)) {
328
+ return Math.max(0, rateLimit.reset * 1000 - nowMs);
329
+ }
330
+ return 0;
331
+ }
332
+ /**
333
+ * Parses a {@link RepoInput} into a {@link RepoRef}.
334
+ *
335
+ * @throws {GitHubError} (status 0) when the `"owner/name"` string is malformed.
336
+ */
337
+ function parseRepo(repo) {
338
+ if (typeof repo !== "string") {
339
+ return repo;
340
+ }
341
+ const [owner, name, ...rest] = repo.split("/");
342
+ if (!owner || !name || rest.length > 0) {
343
+ throw new GitHubError(`Invalid repo "${repo}", expected "owner/name".`, 0);
344
+ }
345
+ return { owner, name };
346
+ }
347
+ /** Parses the `rel="next"` URL out of a `Link` header, or null when absent. */
348
+ function nextLink(linkHeader) {
349
+ if (!linkHeader) {
350
+ return null;
351
+ }
352
+ for (const part of linkHeader.split(",")) {
353
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
354
+ if (match) {
355
+ return match[1];
356
+ }
357
+ }
358
+ return null;
359
+ }
360
+ /** Reads `x-ratelimit-*` headers into a {@link RateLimit}. */
361
+ function parseRateLimit(headers) {
362
+ const remaining = headers.get("x-ratelimit-remaining");
363
+ const reset = headers.get("x-ratelimit-reset");
364
+ return {
365
+ remaining: remaining === null ? null : Number(remaining),
366
+ reset: reset === null ? null : Number(reset),
367
+ };
368
+ }
369
+ /**
370
+ * Creates a GitHub REST client backed by the global `fetch`.
371
+ *
372
+ * The client is stateless except for {@link GitHubClient.rateLimit}, which holds
373
+ * the rate-limit headers seen on the most recent response.
374
+ *
375
+ * @param options - Token, API base URL, and optional `fetch` override.
376
+ * @returns A {@link GitHubClient}.
377
+ */
378
+ function createGitHubClient(options) {
379
+ const { token, apiUrl } = options;
380
+ const doFetch = options.fetch ?? fetch;
381
+ const sleep = options.sleep ?? defaultSleep$1;
382
+ const now = options.now ?? Date.now;
383
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
384
+ const maxRateLimitWaitMs = options.maxRateLimitWaitMs ?? DEFAULT_MAX_RATE_LIMIT_WAIT_MS;
385
+ const networkBackoffMs = options.networkBackoffMs ?? DEFAULT_NETWORK_BACKOFF_MS;
386
+ let rateLimit = null;
387
+ function baseHeaders() {
388
+ return {
389
+ Authorization: `Bearer ${token}`,
390
+ Accept: ACCEPT,
391
+ "X-GitHub-Api-Version": API_VERSION,
392
+ "User-Agent": USER_AGENT,
393
+ };
394
+ }
395
+ /** Wraps a `Retry-After`/reset wait into a dated, fail-fast GitHubError. */
396
+ function rateLimitFailFast(waitMs) {
397
+ const retryAt = new Date(now() + waitMs).toISOString();
398
+ throw new GitHubError(`GitHub rate limit exceeded and the reset is ${Math.ceil(waitMs / 1000)}s away, ` +
399
+ `beyond the ${Math.ceil(maxRateLimitWaitMs / 1000)}s wait cap. Retry after ${retryAt}.`, 403, rateLimit);
400
+ }
401
+ /** Throws a dated GitHubError when rate-limit retries are exhausted. */
402
+ function rateLimitExhausted(waitMs) {
403
+ const retryAt = new Date(now() + waitMs).toISOString();
404
+ throw new GitHubError(`GitHub rate limit exceeded after ${maxRetries} retries. Retry after ${retryAt}.`, 403, rateLimit);
405
+ }
406
+ /**
407
+ * Performs a single request, retrying on rate-limit (403/429) and transient
408
+ * network failures up to {@link maxRetries} times. Rate-limit retries honor
409
+ * `Retry-After`/`x-ratelimit-reset` (capped); network retries use exponential
410
+ * backoff. Both waits go through the injectable {@link sleep}.
411
+ */
412
+ async function request(url, headers) {
413
+ let attempt = 0;
414
+ for (;;) {
415
+ let response;
416
+ try {
417
+ response = await doFetch(url, { headers });
418
+ }
419
+ catch (error) {
420
+ if (isTransientNetworkError(error) && attempt < maxRetries) {
421
+ await sleep(networkBackoffMs * 2 ** attempt);
422
+ attempt += 1;
423
+ continue;
424
+ }
425
+ if (isTransientNetworkError(error)) {
426
+ throw new NetworkError(`Network request to GitHub failed after ${attempt + 1} attempts: ${error.message}.`, error);
427
+ }
428
+ throw error;
429
+ }
430
+ rateLimit = parseRateLimit(response.headers);
431
+ if (isRateLimitedResponse(response, rateLimit)) {
432
+ const waitMs = rateLimitWaitMs(response, rateLimit, now());
433
+ if (waitMs > maxRateLimitWaitMs) {
434
+ rateLimitFailFast(waitMs);
435
+ }
436
+ if (attempt < maxRetries) {
437
+ await sleep(waitMs);
438
+ attempt += 1;
439
+ continue;
440
+ }
441
+ // Retries exhausted while still rate-limited: throw an explicit dated
442
+ // error rather than falling through to the generic failure message.
443
+ rateLimitExhausted(waitMs);
444
+ }
445
+ return response;
446
+ }
447
+ }
448
+ /** Turns a non-ok, non-304 response into a thrown {@link GitHubError}. */
449
+ async function fail(response) {
450
+ const { status } = response;
451
+ const isRateLimited = (status === 403 || status === 429) && rateLimit?.remaining === 0;
452
+ let message;
453
+ if (isRateLimited) {
454
+ message = "GitHub rate limit exceeded (x-ratelimit-remaining is 0).";
455
+ }
456
+ else if (status === 404) {
457
+ // 404 is ambiguous: the repo may not exist, or the token may simply lack
458
+ // access to a private repo (GitHub hides those behind a 404 on purpose).
459
+ message = "GitHub returned 404: the repo was not found or your token has no access to it.";
460
+ }
461
+ else {
462
+ const detail = await readErrorMessage(response);
463
+ message = `GitHub request failed with ${status}${detail ? `: ${detail}` : ""}.`;
464
+ }
465
+ throw new GitHubError(message, status, rateLimit);
466
+ }
467
+ async function getRepo(repo) {
468
+ const { owner, name } = parseRepo(repo);
469
+ const response = await request(`${apiUrl}/repos/${owner}/${name}`, baseHeaders());
470
+ if (!response.ok) {
471
+ await fail(response);
472
+ }
473
+ const body = (await response.json());
474
+ return {
475
+ owner: typeof body.owner?.login === "string" ? body.owner.login : owner,
476
+ name: typeof body.name === "string" ? body.name : name,
477
+ fullName: typeof body.full_name === "string" ? body.full_name : `${owner}/${name}`,
478
+ private: body.private === true,
479
+ };
480
+ }
481
+ async function listIssues(repo, listOptions = {}) {
482
+ const { owner, name } = parseRepo(repo);
483
+ const params = new URLSearchParams({
484
+ state: "all",
485
+ per_page: String(PER_PAGE),
486
+ sort: "updated",
487
+ direction: "asc",
488
+ });
489
+ if (listOptions.since) {
490
+ params.set("since", listOptions.since);
491
+ }
492
+ let url = `${apiUrl}/repos/${owner}/${name}/issues?${params.toString()}`;
493
+ const issues = [];
494
+ let etag = null;
495
+ let first = true;
496
+ while (url) {
497
+ const headers = baseHeaders();
498
+ // The conditional request only makes sense for the first page.
499
+ if (first && listOptions.etag) {
500
+ headers["If-None-Match"] = listOptions.etag;
501
+ }
502
+ const response = await request(url, headers);
503
+ if (first && response.status === 304) {
504
+ return { status: "notModified" };
505
+ }
506
+ if (!response.ok) {
507
+ await fail(response);
508
+ }
509
+ if (first) {
510
+ etag = response.headers.get("etag");
511
+ first = false;
512
+ }
513
+ const page = (await response.json());
514
+ for (const item of page) {
515
+ if (!isPullRequest(item)) {
516
+ issues.push(normalizeIssue(item));
517
+ }
518
+ }
519
+ url = nextLink(response.headers.get("link"));
520
+ }
521
+ return { status: "ok", issues, etag };
522
+ }
523
+ async function getComments(repo, number) {
524
+ const { owner, name } = parseRepo(repo);
525
+ const params = new URLSearchParams({ per_page: String(PER_PAGE) });
526
+ let url = `${apiUrl}/repos/${owner}/${name}/issues/${number}/comments?${params.toString()}`;
527
+ const comments = [];
528
+ while (url) {
529
+ const response = await request(url, baseHeaders());
530
+ if (!response.ok) {
531
+ await fail(response);
532
+ }
533
+ const page = (await response.json());
534
+ for (const item of page) {
535
+ comments.push(normalizeComment(item));
536
+ }
537
+ url = nextLink(response.headers.get("link"));
538
+ }
539
+ return comments;
540
+ }
541
+ return {
542
+ getRepo,
543
+ listIssues,
544
+ getComments,
545
+ get rateLimit() {
546
+ return rateLimit;
547
+ },
548
+ };
549
+ }
550
+ /** Best-effort extraction of GitHub's JSON `message` field for error detail. */
551
+ async function readErrorMessage(response) {
552
+ try {
553
+ const body = (await response.json());
554
+ return typeof body.message === "string" ? body.message : null;
555
+ }
556
+ catch {
557
+ return null;
558
+ }
559
+ }
560
+
561
+ const migrations = [
562
+ {
563
+ version: 1,
564
+ up: (db) => {
565
+ db.exec(`
566
+ CREATE TABLE repos (
567
+ id INTEGER PRIMARY KEY,
568
+ owner TEXT NOT NULL,
569
+ name TEXT NOT NULL,
570
+ full_name TEXT NOT NULL UNIQUE,
571
+ added_at TEXT NOT NULL,
572
+ active INTEGER NOT NULL DEFAULT 1,
573
+ last_synced_at TEXT,
574
+ etag TEXT
575
+ );
576
+
577
+ CREATE TABLE issues (
578
+ id INTEGER PRIMARY KEY,
579
+ repo_id INTEGER NOT NULL REFERENCES repos(id),
580
+ number INTEGER NOT NULL,
581
+ title TEXT NOT NULL,
582
+ state TEXT NOT NULL,
583
+ state_reason TEXT,
584
+ author TEXT,
585
+ labels TEXT,
586
+ created_at TEXT NOT NULL,
587
+ updated_at TEXT NOT NULL,
588
+ closed_at TEXT,
589
+ comment_count INTEGER NOT NULL DEFAULT 0,
590
+ raw_body TEXT,
591
+ raw_comments TEXT,
592
+ raw_fetched_at TEXT,
593
+ compact TEXT,
594
+ compact_tldr TEXT,
595
+ compact_stale INTEGER NOT NULL DEFAULT 0,
596
+ compacted_at TEXT,
597
+ UNIQUE(repo_id, number)
598
+ );
599
+
600
+ CREATE TABLE events (
601
+ id INTEGER PRIMARY KEY,
602
+ issue_id INTEGER NOT NULL REFERENCES issues(id),
603
+ type TEXT NOT NULL,
604
+ detected_at TEXT NOT NULL,
605
+ seen INTEGER NOT NULL DEFAULT 0
606
+ );
607
+
608
+ CREATE TABLE refs (
609
+ id INTEGER PRIMARY KEY,
610
+ issue_id INTEGER NOT NULL REFERENCES issues(id),
611
+ target TEXT NOT NULL,
612
+ UNIQUE(issue_id, target)
613
+ );
614
+
615
+ CREATE INDEX idx_issues_repo ON issues(repo_id);
616
+ CREATE INDEX idx_events_issue ON events(issue_id);
617
+ CREATE INDEX idx_refs_issue ON refs(issue_id);
618
+ `);
619
+ },
620
+ },
621
+ ];
622
+ /**
623
+ * The schema version the code expects. Equals the highest defined migration.
624
+ */
625
+ migrations[migrations.length - 1].version;
626
+ /**
627
+ * Runs any pending migrations against the database, in order. Idempotent: the
628
+ * current schema version is read from the `user_version` pragma, and only
629
+ * migrations newer than it are applied. Each migration runs in its own
630
+ * transaction.
631
+ */
632
+ function migrate(db) {
633
+ const current = db.pragma("user_version", { simple: true });
634
+ for (const migration of migrations) {
635
+ if (migration.version <= current) {
636
+ continue;
637
+ }
638
+ const run = db.transaction(() => {
639
+ migration.up(db);
640
+ db.pragma(`user_version = ${migration.version}`);
641
+ });
642
+ run();
643
+ }
644
+ }
645
+
646
+ function rowToEventWithContext(row) {
647
+ return {
648
+ id: row.id,
649
+ issueId: row.issue_id,
650
+ type: row.type,
651
+ detectedAt: row.detected_at,
652
+ seen: row.seen !== 0,
653
+ repoId: row.repo_id,
654
+ repoFullName: row.repo_full_name,
655
+ issueNumber: row.issue_number,
656
+ issueTitle: row.issue_title,
657
+ issueState: row.issue_state,
658
+ };
659
+ }
660
+ function rowToRef(row) {
661
+ return {
662
+ id: row.id,
663
+ issueId: row.issue_id,
664
+ target: row.target,
665
+ };
666
+ }
667
+ function rowToEvent(row) {
668
+ return {
669
+ id: row.id,
670
+ issueId: row.issue_id,
671
+ type: row.type,
672
+ detectedAt: row.detected_at,
673
+ seen: row.seen !== 0,
674
+ };
675
+ }
676
+ function rowToRepo(row) {
677
+ return {
678
+ id: row.id,
679
+ owner: row.owner,
680
+ name: row.name,
681
+ fullName: row.full_name,
682
+ addedAt: row.added_at,
683
+ active: row.active !== 0,
684
+ lastSyncedAt: row.last_synced_at,
685
+ etag: row.etag,
686
+ };
687
+ }
688
+ function rowToIssue(row) {
689
+ return {
690
+ id: row.id,
691
+ repoId: row.repo_id,
692
+ number: row.number,
693
+ title: row.title,
694
+ state: row.state,
695
+ stateReason: row.state_reason,
696
+ author: row.author,
697
+ labels: row.labels,
698
+ createdAt: row.created_at,
699
+ updatedAt: row.updated_at,
700
+ closedAt: row.closed_at,
701
+ commentCount: row.comment_count,
702
+ rawBody: row.raw_body,
703
+ rawComments: row.raw_comments,
704
+ rawFetchedAt: row.raw_fetched_at,
705
+ compact: row.compact,
706
+ compactTldr: row.compact_tldr,
707
+ compactStale: row.compact_stale !== 0,
708
+ compactedAt: row.compacted_at,
709
+ };
710
+ }
711
+ function rowToIssueWithRepo(row) {
712
+ return { ...rowToIssue(row), repoFullName: row.repo_full_name };
713
+ }
714
+ /**
715
+ * Opens (creating its parent directory if needed) a SQLite database at
716
+ * `dbPath`, enables foreign keys and WAL journaling, runs pending migrations,
717
+ * and returns a typed {@link Store}. Pass `:memory:` for an ephemeral database.
718
+ *
719
+ * The path is an explicit argument (dependency injection) so callers and tests
720
+ * control where state lives; use {@link defaultDbPath} for the production path.
721
+ */
722
+ function openStore(dbPath) {
723
+ if (dbPath !== ":memory:") {
724
+ mkdirSync(dirname(dbPath), { recursive: true });
725
+ }
726
+ const db = new Database(dbPath);
727
+ db.pragma("foreign_keys = ON");
728
+ db.pragma("journal_mode = WAL");
729
+ migrate(db);
730
+ const insertRepoStmt = db.prepare(`INSERT INTO repos (owner, name, full_name, added_at)
731
+ VALUES (?, ?, ?, ?)
732
+ RETURNING *`);
733
+ const getRepoStmt = db.prepare(`SELECT * FROM repos WHERE id = ?`);
734
+ const getRepoByFullNameStmt = db.prepare(`SELECT * FROM repos WHERE full_name = ?`);
735
+ const listReposStmt = db.prepare(`SELECT * FROM repos ORDER BY full_name`);
736
+ const listActiveReposStmt = db.prepare(`SELECT * FROM repos WHERE active = 1 ORDER BY full_name`);
737
+ const setRepoActiveStmt = db.prepare(`UPDATE repos SET active = ? WHERE full_name = ? RETURNING *`);
738
+ const upsertIssueStmt = db.prepare(`INSERT INTO issues (
739
+ repo_id, number, title, state, state_reason, author, labels,
740
+ created_at, updated_at, closed_at, comment_count,
741
+ raw_body, raw_comments, raw_fetched_at,
742
+ compact, compact_tldr, compact_stale, compacted_at
743
+ ) VALUES (
744
+ @repoId, @number, @title, @state, @stateReason, @author, @labels,
745
+ @createdAt, @updatedAt, @closedAt, @commentCount,
746
+ @rawBody, @rawComments, @rawFetchedAt,
747
+ @compact, @compactTldr, @compactStale, @compactedAt
748
+ )
749
+ ON CONFLICT(repo_id, number) DO UPDATE SET
750
+ title = excluded.title,
751
+ state = excluded.state,
752
+ state_reason = excluded.state_reason,
753
+ author = excluded.author,
754
+ labels = excluded.labels,
755
+ created_at = excluded.created_at,
756
+ updated_at = excluded.updated_at,
757
+ closed_at = excluded.closed_at,
758
+ comment_count = excluded.comment_count,
759
+ raw_body = excluded.raw_body,
760
+ raw_comments = excluded.raw_comments,
761
+ raw_fetched_at = excluded.raw_fetched_at,
762
+ compact = excluded.compact,
763
+ compact_tldr = excluded.compact_tldr,
764
+ compact_stale = excluded.compact_stale,
765
+ compacted_at = excluded.compacted_at
766
+ RETURNING *`);
767
+ const getIssueStmt = db.prepare(`SELECT * FROM issues WHERE repo_id = ? AND number = ?`);
768
+ const listIssuesStmt = db.prepare(`SELECT * FROM issues WHERE repo_id = ? ORDER BY number`);
769
+ const setCompactStmt = db.prepare(`UPDATE issues
770
+ SET compact = ?, compact_tldr = ?, compact_stale = 0, compacted_at = ?
771
+ WHERE repo_id = ? AND number = ?
772
+ RETURNING *`);
773
+ const setIssueRawCommentsStmt = db.prepare(`UPDATE issues
774
+ SET raw_comments = ?, raw_fetched_at = ?
775
+ WHERE repo_id = ? AND number = ?
776
+ RETURNING *`);
777
+ const insertEventStmt = db.prepare(`INSERT INTO events (issue_id, type, detected_at) VALUES (?, ?, ?) RETURNING *`);
778
+ const setCompactStaleStmt = db.prepare(`UPDATE issues SET compact_stale = ? WHERE id = ?`);
779
+ const updateRepoSyncStmt = db.prepare(`UPDATE repos SET last_synced_at = ?, etag = ? WHERE id = ?`);
780
+ const deleteIssueRefsStmt = db.prepare(`DELETE FROM refs WHERE issue_id = ?`);
781
+ const insertIssueRefStmt = db.prepare(`INSERT OR IGNORE INTO refs (issue_id, target) VALUES (?, ?)`);
782
+ const listIssueRefsStmt = db.prepare(`SELECT * FROM refs WHERE issue_id = ? ORDER BY id`);
783
+ return {
784
+ db,
785
+ insertRepo(repo) {
786
+ const addedAt = new Date().toISOString();
787
+ const row = insertRepoStmt.get(repo.owner, repo.name, repo.fullName, addedAt);
788
+ return rowToRepo(row);
789
+ },
790
+ getRepo(id) {
791
+ const row = getRepoStmt.get(id);
792
+ return row ? rowToRepo(row) : undefined;
793
+ },
794
+ getRepoByFullName(fullName) {
795
+ const row = getRepoByFullNameStmt.get(fullName);
796
+ return row ? rowToRepo(row) : undefined;
797
+ },
798
+ listRepos(options) {
799
+ const stmt = options?.activeOnly ? listActiveReposStmt : listReposStmt;
800
+ const rows = stmt.all();
801
+ return rows.map(rowToRepo);
802
+ },
803
+ setRepoActive(fullName, active) {
804
+ const row = setRepoActiveStmt.get(active ? 1 : 0, fullName);
805
+ return row ? rowToRepo(row) : undefined;
806
+ },
807
+ upsertIssue(issue) {
808
+ const row = upsertIssueStmt.get({
809
+ repoId: issue.repoId,
810
+ number: issue.number,
811
+ title: issue.title,
812
+ state: issue.state,
813
+ stateReason: issue.stateReason ?? null,
814
+ author: issue.author ?? null,
815
+ labels: issue.labels ?? null,
816
+ createdAt: issue.createdAt,
817
+ updatedAt: issue.updatedAt,
818
+ closedAt: issue.closedAt ?? null,
819
+ commentCount: issue.commentCount ?? 0,
820
+ rawBody: issue.rawBody ?? null,
821
+ rawComments: issue.rawComments ?? null,
822
+ rawFetchedAt: issue.rawFetchedAt ?? null,
823
+ compact: issue.compact ?? null,
824
+ compactTldr: issue.compactTldr ?? null,
825
+ compactStale: (issue.compactStale ?? false) ? 1 : 0,
826
+ compactedAt: issue.compactedAt ?? null,
827
+ });
828
+ return rowToIssue(row);
829
+ },
830
+ getIssue(repoId, number) {
831
+ const row = getIssueStmt.get(repoId, number);
832
+ return row ? rowToIssue(row) : undefined;
833
+ },
834
+ listIssues(repoId) {
835
+ const rows = listIssuesStmt.all(repoId);
836
+ return rows.map(rowToIssue);
837
+ },
838
+ queryIssues(filter = {}) {
839
+ // Only issues from active repos: a repo removed via `remove` keeps its
840
+ // history but must not surface in listings, consistent with every other
841
+ // command.
842
+ const conditions = ["r.active = 1"];
843
+ const params = [];
844
+ if (filter.state && filter.state !== "all") {
845
+ conditions.push("i.state = ?");
846
+ params.push(filter.state);
847
+ }
848
+ if (filter.repoIds && filter.repoIds.length > 0) {
849
+ const placeholders = filter.repoIds.map(() => "?").join(", ");
850
+ conditions.push(`i.repo_id IN (${placeholders})`);
851
+ params.push(...filter.repoIds);
852
+ }
853
+ if (filter.author) {
854
+ conditions.push("i.author = ?");
855
+ params.push(filter.author);
856
+ }
857
+ if (filter.stateReason) {
858
+ conditions.push("i.state_reason = ?");
859
+ params.push(filter.stateReason);
860
+ }
861
+ if (filter.since) {
862
+ conditions.push("i.updated_at >= ?");
863
+ params.push(filter.since);
864
+ }
865
+ if (filter.search) {
866
+ // Case-insensitive substring match. LIKE is case-insensitive for ASCII
867
+ // in SQLite by default; lower() on both sides covers the rest.
868
+ conditions.push("lower(i.title) LIKE '%' || lower(?) || '%'");
869
+ params.push(filter.search);
870
+ }
871
+ if (filter.compaction === "uncompacted") {
872
+ conditions.push("i.compact IS NULL");
873
+ }
874
+ else if (filter.compaction === "stale") {
875
+ conditions.push("i.compact IS NOT NULL AND i.compact_stale = 1");
876
+ }
877
+ else if (filter.compaction === "compacted") {
878
+ conditions.push("i.compact IS NOT NULL AND i.compact_stale = 0");
879
+ }
880
+ if (filter.labels && filter.labels.length > 0) {
881
+ const placeholders = filter.labels.map(() => "?").join(", ");
882
+ conditions.push(`EXISTS (SELECT 1 FROM json_each(i.labels) WHERE json_each.value IN (${placeholders}))`);
883
+ params.push(...filter.labels);
884
+ }
885
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
886
+ const sortColumn = { updated: "i.updated_at", created: "i.created_at", number: "i.number" }[filter.sort ?? "updated"];
887
+ const direction = filter.order === "asc" ? "ASC" : "DESC";
888
+ // Stable tiebreaker so equal sort keys have a deterministic order.
889
+ const orderBy = `ORDER BY ${sortColumn} ${direction}, i.number ${direction}`;
890
+ const limit = filter.limit !== undefined && filter.limit > 0 ? "LIMIT ?" : "";
891
+ if (limit) {
892
+ params.push(filter.limit);
893
+ }
894
+ const sql = `
895
+ SELECT i.*, r.full_name AS repo_full_name
896
+ FROM issues i
897
+ JOIN repos r ON r.id = i.repo_id
898
+ ${where}
899
+ ${orderBy}
900
+ ${limit}`;
901
+ const rows = db.prepare(sql).all(...params);
902
+ return rows.map(rowToIssueWithRepo);
903
+ },
904
+ setCompact(repoId, number, compact) {
905
+ const compactedAt = new Date().toISOString();
906
+ const row = setCompactStmt.get(compact.compact, compact.tldr, compactedAt, repoId, number);
907
+ return row ? rowToIssue(row) : undefined;
908
+ },
909
+ close() {
910
+ db.close();
911
+ },
912
+ setIssueRawComments(repoId, number, commentsJson, fetchedAt) {
913
+ const row = setIssueRawCommentsStmt.get(commentsJson, fetchedAt, repoId, number);
914
+ return row ? rowToIssue(row) : undefined;
915
+ },
916
+ insertEvent(issueId, type, detectedAt) {
917
+ const row = insertEventStmt.get(issueId, type, detectedAt);
918
+ return rowToEvent(row);
919
+ },
920
+ setCompactStale(issueId, stale) {
921
+ setCompactStaleStmt.run(stale ? 1 : 0, issueId);
922
+ },
923
+ updateRepoSync(repoId, sync) {
924
+ updateRepoSyncStmt.run(sync.lastSyncedAt, sync.etag, repoId);
925
+ },
926
+ listEvents(filter) {
927
+ const conditions = [];
928
+ const params = [];
929
+ if (filter?.seen !== undefined) {
930
+ conditions.push("e.seen = ?");
931
+ params.push(filter.seen ? 1 : 0);
932
+ }
933
+ if (filter?.since !== undefined) {
934
+ conditions.push("e.detected_at >= ?");
935
+ params.push(filter.since);
936
+ }
937
+ if (filter?.repoId !== undefined) {
938
+ conditions.push("r.id = ?");
939
+ params.push(filter.repoId);
940
+ }
941
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
942
+ const sql = `
943
+ SELECT
944
+ e.id AS id,
945
+ e.issue_id AS issue_id,
946
+ e.type AS type,
947
+ e.detected_at AS detected_at,
948
+ e.seen AS seen,
949
+ r.id AS repo_id,
950
+ r.full_name AS repo_full_name,
951
+ i.number AS issue_number,
952
+ i.title AS issue_title,
953
+ i.state AS issue_state
954
+ FROM events e
955
+ JOIN issues i ON i.id = e.issue_id
956
+ JOIN repos r ON r.id = i.repo_id
957
+ ${where}
958
+ ORDER BY e.detected_at DESC, e.id DESC`;
959
+ const rows = db.prepare(sql).all(...params);
960
+ return rows.map(rowToEventWithContext);
961
+ },
962
+ markEventsSeen(eventIds) {
963
+ if (eventIds.length === 0) {
964
+ return;
965
+ }
966
+ const placeholders = eventIds.map(() => "?").join(", ");
967
+ db.prepare(`UPDATE events SET seen = 1 WHERE id IN (${placeholders})`).run(...eventIds);
968
+ },
969
+ replaceIssueRefs(issueId, targets) {
970
+ const apply = db.transaction(() => {
971
+ deleteIssueRefsStmt.run(issueId);
972
+ for (const target of targets) {
973
+ insertIssueRefStmt.run(issueId, target);
974
+ }
975
+ });
976
+ apply();
977
+ },
978
+ listIssueRefs(issueId) {
979
+ const rows = listIssueRefsStmt.all(issueId);
980
+ return rows.map(rowToRef);
981
+ },
982
+ };
983
+ }
984
+
985
+ /**
986
+ * Error thrown by the repo commands for expected, user-facing failures (malformed
987
+ * argument, repo not found, repo not watched). The CLI prints the message and
988
+ * exits non-zero.
989
+ */
990
+ class RepoCommandError extends Error {
991
+ constructor(message) {
992
+ super(message);
993
+ this.name = "RepoCommandError";
994
+ }
995
+ }
996
+ /**
997
+ * Parses an `owner/repo` string argument.
998
+ *
999
+ * @throws {RepoCommandError} When the argument is not in the expected shape.
1000
+ */
1001
+ function parseRepoArg(arg) {
1002
+ const match = /^([^/\s]+)\/([^/\s]+)$/.exec(arg.trim());
1003
+ if (!match) {
1004
+ throw new RepoCommandError(`Invalid repo "${arg}". Expected the form owner/repo, e.g. octocat/hello.`);
1005
+ }
1006
+ return { owner: match[1], name: match[2], fullName: `${match[1]}/${match[2]}` };
1007
+ }
1008
+ /**
1009
+ * Core action for `issuary add`: validates the repo exists/accessible on GitHub,
1010
+ * then inserts it (or reactivates it if it was previously removed).
1011
+ *
1012
+ * Separated from the Commander wiring so it can be tested with an injected
1013
+ * client and store. The caller owns the {@link Store} lifecycle.
1014
+ *
1015
+ * @throws {RepoCommandError} For expected, user-facing failures.
1016
+ * @throws {GitHubError} For unexpected GitHub failures (auth, rate limit, ...).
1017
+ */
1018
+ async function runAdd(store, client, arg) {
1019
+ const { owner, name, fullName } = parseRepoArg(arg);
1020
+ try {
1021
+ await client.getRepo({ owner, name });
1022
+ }
1023
+ catch (error) {
1024
+ if (error instanceof GitHubError && error.status === 404) {
1025
+ throw new RepoCommandError(`Repo "${fullName}" not found or no access. Check the name and your token's scopes.`);
1026
+ }
1027
+ throw error;
1028
+ }
1029
+ const existing = store.getRepoByFullName(fullName);
1030
+ if (existing) {
1031
+ if (existing.active) {
1032
+ return { ok: true, repo: fullName, status: "already-watched" };
1033
+ }
1034
+ store.setRepoActive(fullName, true);
1035
+ return { ok: true, repo: fullName, status: "reactivated" };
1036
+ }
1037
+ store.insertRepo({ owner, name, fullName });
1038
+ return { ok: true, repo: fullName, status: "added" };
1039
+ }
1040
+ /** Human-readable line for a successful {@link runAdd}. */
1041
+ function addMessage(result) {
1042
+ switch (result.status) {
1043
+ case "added":
1044
+ return `Now watching ${result.repo}.`;
1045
+ case "reactivated":
1046
+ return `Reactivated ${result.repo} (it was previously removed).`;
1047
+ case "already-watched":
1048
+ return `${result.repo} is already watched.`;
1049
+ }
1050
+ }
1051
+ /** Builds the `add` command. */
1052
+ function addCommand() {
1053
+ return new Command("add")
1054
+ .description("Watch a GitHub repo's issues (validates it exists via the API)")
1055
+ .argument("<owner/repo>", "repository to watch, as owner/repo")
1056
+ .option("--json", "emit machine-readable JSON")
1057
+ .action(async (arg, options) => {
1058
+ const config = loadConfig();
1059
+ const client = createGitHubClient({ token: config.token, apiUrl: config.apiUrl });
1060
+ const store = openStore(config.dbPath);
1061
+ try {
1062
+ const result = await runAdd(store, client, arg);
1063
+ if (options.json) {
1064
+ console.log(JSON.stringify(result));
1065
+ }
1066
+ else {
1067
+ console.log(addMessage(result));
1068
+ }
1069
+ }
1070
+ catch (error) {
1071
+ if (error instanceof RepoCommandError || error instanceof GitHubError) {
1072
+ console.error(error.message);
1073
+ process.exitCode = 1;
1074
+ return;
1075
+ }
1076
+ throw error;
1077
+ }
1078
+ finally {
1079
+ store.close();
1080
+ }
1081
+ });
1082
+ }
1083
+
1084
+ /**
1085
+ * Error thrown when a compact file does not conform to the canonical format.
1086
+ *
1087
+ * Carries a clear, human-readable message pointing at what is wrong. Callers
1088
+ * (the `compact set` command) catch this to print a friendly error instead of a
1089
+ * stack trace.
1090
+ *
1091
+ * @see file://../../docs/compact-format.md
1092
+ */
1093
+ class CompactValidationError extends Error {
1094
+ constructor(message) {
1095
+ super(message);
1096
+ this.name = "CompactValidationError";
1097
+ }
1098
+ }
1099
+ /** The allowed values for the frontmatter `status` field. */
1100
+ const VALID_STATUS = ["open", "closed"];
1101
+ /** The allowed values for the frontmatter `state_reason` field (besides null). */
1102
+ const VALID_STATE_REASON = ["completed", "not_planned"];
1103
+ /**
1104
+ * Parses and validates a compact file in the canonical format.
1105
+ *
1106
+ * The canonical format is frontmatter between `---` fences (copied from the
1107
+ * GitHub API) followed by an AI-written body. This function validates that the
1108
+ * required structure and fields are present and well-formed, extracts the
1109
+ * `tldr` for separate storage, and returns the full original text unchanged so
1110
+ * it round-trips.
1111
+ *
1112
+ * @param text - The raw compact file contents.
1113
+ * @returns The validated {@link ParsedCompact}.
1114
+ * @throws {CompactValidationError} When the structure or any required field is invalid.
1115
+ * @see file://../../docs/compact-format.md
1116
+ */
1117
+ function parseCompact(text) {
1118
+ const { frontmatterText, bodyText } = splitFrontmatter(text);
1119
+ const frontmatter = parseFrontmatter(frontmatterText);
1120
+ const tldr = extractTldr(bodyText);
1121
+ return { compact: text, tldr, frontmatter };
1122
+ }
1123
+ /**
1124
+ * Splits a compact into its frontmatter and body around the `---` fences.
1125
+ *
1126
+ * The document must open with a `---` line and contain a closing `---` line.
1127
+ */
1128
+ function splitFrontmatter(text) {
1129
+ const lines = text.split(/\r?\n/);
1130
+ let start = 0;
1131
+ while (start < lines.length && lines[start].trim() === "") {
1132
+ start += 1;
1133
+ }
1134
+ if (start >= lines.length || lines[start].trim() !== "---") {
1135
+ throw new CompactValidationError("Compact must start with a `---` frontmatter fence.");
1136
+ }
1137
+ let end = -1;
1138
+ for (let i = start + 1; i < lines.length; i += 1) {
1139
+ if (lines[i].trim() === "---") {
1140
+ end = i;
1141
+ break;
1142
+ }
1143
+ }
1144
+ if (end === -1) {
1145
+ throw new CompactValidationError("Compact frontmatter is missing its closing `---` fence.");
1146
+ }
1147
+ const frontmatterText = lines.slice(start + 1, end).join("\n");
1148
+ const bodyText = lines.slice(end + 1).join("\n");
1149
+ return { frontmatterText, bodyText };
1150
+ }
1151
+ /**
1152
+ * Parses and validates the frontmatter block: `status`, `state_reason`, and the
1153
+ * optional `refs`, `versions`, and `labels` fields.
1154
+ */
1155
+ function parseFrontmatter(frontmatterText) {
1156
+ let raw;
1157
+ try {
1158
+ raw = parse(frontmatterText);
1159
+ }
1160
+ catch (error) {
1161
+ const detail = error instanceof Error ? error.message : String(error);
1162
+ throw new CompactValidationError(`Compact frontmatter is not valid YAML: ${detail}`);
1163
+ }
1164
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1165
+ throw new CompactValidationError("Compact frontmatter must be a set of key/value fields.");
1166
+ }
1167
+ const fm = raw;
1168
+ if (!("status" in fm)) {
1169
+ throw new CompactValidationError("Compact frontmatter is missing the required `status` field.");
1170
+ }
1171
+ const status = fm.status;
1172
+ if (status !== "open" && status !== "closed") {
1173
+ throw new CompactValidationError(`Compact \`status\` must be one of ${VALID_STATUS.join(", ")}, got ${formatValue(status)}.`);
1174
+ }
1175
+ if (!("state_reason" in fm)) {
1176
+ throw new CompactValidationError("Compact frontmatter is missing the required `state_reason` field.");
1177
+ }
1178
+ const stateReason = normalizeStateReason(fm.state_reason);
1179
+ if (status === "open" && stateReason !== null) {
1180
+ throw new CompactValidationError("An open issue must have `state_reason: null`.");
1181
+ }
1182
+ const frontmatter = { status, stateReason };
1183
+ if ("refs" in fm && fm.refs !== null && fm.refs !== undefined) {
1184
+ frontmatter.refs = validateStringArray(fm.refs, "refs");
1185
+ }
1186
+ if ("labels" in fm && fm.labels !== null && fm.labels !== undefined) {
1187
+ frontmatter.labels = validateStringArray(fm.labels, "labels");
1188
+ }
1189
+ if ("versions" in fm && fm.versions !== null && fm.versions !== undefined) {
1190
+ frontmatter.versions = validateVersions(fm.versions);
1191
+ }
1192
+ return frontmatter;
1193
+ }
1194
+ /** Coerces a YAML `state_reason` value into the allowed set or null. */
1195
+ function normalizeStateReason(value) {
1196
+ // `null` (bare or quoted as the string "null") is the no-reason case.
1197
+ if (value === null || value === undefined || value === "null") {
1198
+ return null;
1199
+ }
1200
+ if (value === "completed" || value === "not_planned") {
1201
+ return value;
1202
+ }
1203
+ throw new CompactValidationError(`Compact \`state_reason\` must be one of ${VALID_STATE_REASON.join(", ")}, or null, got ${formatValue(value)}.`);
1204
+ }
1205
+ /** Validates that a frontmatter value is an array of strings. */
1206
+ function validateStringArray(value, field) {
1207
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1208
+ throw new CompactValidationError(`Compact \`${field}\` must be a list of strings.`);
1209
+ }
1210
+ return value;
1211
+ }
1212
+ /** Validates that a frontmatter `versions` value is an `{ affected?, fixed? }` object. */
1213
+ function validateVersions(value) {
1214
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1215
+ throw new CompactValidationError("Compact `versions` must be an object like { affected, fixed }.");
1216
+ }
1217
+ const obj = value;
1218
+ const result = {};
1219
+ for (const key of ["affected", "fixed"]) {
1220
+ if (key in obj && obj[key] !== null && obj[key] !== undefined) {
1221
+ if (typeof obj[key] !== "string") {
1222
+ throw new CompactValidationError(`Compact \`versions.${key}\` must be a string.`);
1223
+ }
1224
+ result[key] = obj[key];
1225
+ }
1226
+ }
1227
+ return result;
1228
+ }
1229
+ /**
1230
+ * Extracts and validates the `tldr` body field: a non-empty, single-line value
1231
+ * on a line of the form `tldr: <text>`.
1232
+ */
1233
+ function extractTldr(bodyText) {
1234
+ const lines = bodyText.split("\n");
1235
+ const tldrLine = lines.find((line) => /^\s*tldr\s*:/.test(line));
1236
+ if (tldrLine === undefined) {
1237
+ throw new CompactValidationError("Compact body is missing the required `tldr` field.");
1238
+ }
1239
+ const tldr = tldrLine.replace(/^\s*tldr\s*:/, "").trim();
1240
+ if (tldr === "") {
1241
+ throw new CompactValidationError("Compact `tldr` must be a non-empty single line.");
1242
+ }
1243
+ if (tldr === "null") {
1244
+ throw new CompactValidationError("Compact `tldr` must not be null; it is the headline and must stand alone.");
1245
+ }
1246
+ return tldr;
1247
+ }
1248
+ /** Renders an unexpected value compactly for error messages. */
1249
+ function formatValue(value) {
1250
+ if (typeof value === "string") {
1251
+ return JSON.stringify(value);
1252
+ }
1253
+ if (value === null) {
1254
+ return "null";
1255
+ }
1256
+ return String(value);
1257
+ }
1258
+
1259
+ /**
1260
+ * Parses an `owner/repo#number` target string.
1261
+ *
1262
+ * @throws {Error} When the target is not in the expected shape.
1263
+ */
1264
+ function parseTarget(target) {
1265
+ const match = /^([^/\s]+\/[^/\s#]+)#(\d+)$/.exec(target.trim());
1266
+ if (!match) {
1267
+ throw new Error(`Invalid target "${target}". Expected the form owner/repo#number, e.g. octocat/hello#42.`);
1268
+ }
1269
+ return { fullName: match[1], number: Number.parseInt(match[2], 10) };
1270
+ }
1271
+ /**
1272
+ * Parses the `--limit <n>` option value into a positive integer.
1273
+ *
1274
+ * @throws {CompactCommandError} When the value is not a positive integer.
1275
+ */
1276
+ function parseLimit(value) {
1277
+ const n = Number.parseInt(value, 10);
1278
+ if (!Number.isInteger(n) || n < 1 || String(n) !== value.trim()) {
1279
+ throw new CompactCommandError(`Invalid --limit "${value}". Expected a positive integer.`);
1280
+ }
1281
+ return n;
1282
+ }
1283
+ /**
1284
+ * Error thrown by the `compact set` action for expected, user-facing failures
1285
+ * (malformed target, unwatched repo, missing issue, invalid file). The CLI
1286
+ * prints the message and exits non-zero.
1287
+ */
1288
+ class CompactCommandError extends Error {
1289
+ constructor(message) {
1290
+ super(message);
1291
+ this.name = "CompactCommandError";
1292
+ }
1293
+ }
1294
+ /**
1295
+ * Core action for `issuary compact set`: validates the target, looks up the repo
1296
+ * and issue, parses the compact file, and persists it via the store.
1297
+ *
1298
+ * Separated from the Commander wiring so it can be tested without spawning a
1299
+ * process. The caller is responsible for opening/closing the {@link Store}.
1300
+ *
1301
+ * @throws {CompactCommandError} For expected, user-facing failures.
1302
+ */
1303
+ function runCompactSet(store, target, options) {
1304
+ const { fullName, number } = parseTarget(target);
1305
+ const repo = store.getRepoByFullName(fullName);
1306
+ if (!repo) {
1307
+ throw new CompactCommandError(`Repo "${fullName}" is not watched. Add it with \`issuary add ${fullName}\` first.`);
1308
+ }
1309
+ const issue = store.getIssue(repo.id, number);
1310
+ if (!issue) {
1311
+ throw new CompactCommandError(`Issue ${fullName}#${number} is not in the local store. Run \`issuary sync\` first.`);
1312
+ }
1313
+ let text;
1314
+ try {
1315
+ text = readFileSync(options.fromFile, "utf8");
1316
+ }
1317
+ catch (error) {
1318
+ const detail = error instanceof Error ? error.message : String(error);
1319
+ throw new CompactCommandError(`Could not read compact file "${options.fromFile}": ${detail}`);
1320
+ }
1321
+ let parsed;
1322
+ try {
1323
+ parsed = parseCompact(text);
1324
+ }
1325
+ catch (error) {
1326
+ if (error instanceof CompactValidationError) {
1327
+ throw new CompactCommandError(`Invalid compact: ${error.message}`);
1328
+ }
1329
+ throw error;
1330
+ }
1331
+ store.setCompact(repo.id, number, { compact: parsed.compact, tldr: parsed.tldr });
1332
+ return { ok: true, repo: fullName, number, tldr: parsed.tldr };
1333
+ }
1334
+ /** Derives the {@link CompactStatus} of an issue from its compact fields. */
1335
+ function compactStatus(issue) {
1336
+ if (issue.compact === null) {
1337
+ return "uncompacted";
1338
+ }
1339
+ return issue.compactStale ? "stale" : "compacted";
1340
+ }
1341
+ /**
1342
+ * Core action for `issuary compact list`: walks the watched repos (or a single
1343
+ * repo via `options.repo`) and returns their issues with compaction status.
1344
+ * With `options.pending`, narrows to the actionable set (uncompacted or stale)
1345
+ * and stamps each with a `reason`. With `options.limit`, caps the number of
1346
+ * returned items (applied last, after the pending/repo filter).
1347
+ *
1348
+ * Separated from the Commander wiring so it can be tested without spawning a
1349
+ * process. The caller is responsible for opening/closing the {@link Store}.
1350
+ *
1351
+ * @throws {CompactCommandError} When `options.repo` names an unwatched repo.
1352
+ */
1353
+ function runCompactList(store, options = {}) {
1354
+ let repos;
1355
+ if (options.repo) {
1356
+ const repo = store.getRepoByFullName(options.repo);
1357
+ if (!repo) {
1358
+ throw new CompactCommandError(`Repo "${options.repo}" is not watched. Add it with \`issuary add ${options.repo}\` first.`);
1359
+ }
1360
+ repos = [repo];
1361
+ }
1362
+ else {
1363
+ repos = store.listRepos();
1364
+ }
1365
+ const items = [];
1366
+ for (const repo of repos) {
1367
+ for (const issue of store.listIssues(repo.id)) {
1368
+ const status = compactStatus(issue);
1369
+ const pending = status !== "compacted";
1370
+ if (options.pending && !pending) {
1371
+ continue;
1372
+ }
1373
+ items.push({
1374
+ repo: repo.fullName,
1375
+ number: issue.number,
1376
+ title: issue.title,
1377
+ state: issue.state,
1378
+ status,
1379
+ reason: pending ? status : null,
1380
+ rawBody: issue.rawBody,
1381
+ commentsNeedFetch: issue.rawComments === null && issue.commentCount > 0,
1382
+ });
1383
+ }
1384
+ }
1385
+ if (options.limit !== undefined && options.limit > 0) {
1386
+ return items.slice(0, options.limit);
1387
+ }
1388
+ return items;
1389
+ }
1390
+ /**
1391
+ * Renders the human-readable listing of {@link CompactListItem}s, grouped by
1392
+ * repo. Each row shows the issue number, status, and title.
1393
+ */
1394
+ function formatCompactList(items, options = {}) {
1395
+ if (items.length === 0) {
1396
+ return options.pending
1397
+ ? "Nothing to compact. Every watched issue has a fresh compact."
1398
+ : "No issues found. Run `issuary sync` to mirror issues first.";
1399
+ }
1400
+ const byRepo = new Map();
1401
+ for (const item of items) {
1402
+ const bucket = byRepo.get(item.repo);
1403
+ if (bucket) {
1404
+ bucket.push(item);
1405
+ }
1406
+ else {
1407
+ byRepo.set(item.repo, [item]);
1408
+ }
1409
+ }
1410
+ const lines = [];
1411
+ for (const [repo, repoItems] of byRepo) {
1412
+ if (lines.length > 0) {
1413
+ lines.push("");
1414
+ }
1415
+ lines.push(`${repo}:`);
1416
+ const numWidth = Math.max(...repoItems.map((item) => `#${item.number}`.length));
1417
+ const statusWidth = Math.max(...repoItems.map((item) => item.status.length));
1418
+ for (const item of repoItems) {
1419
+ const num = `#${item.number}`.padEnd(numWidth);
1420
+ const status = item.status.padEnd(statusWidth);
1421
+ lines.push(` ${num} ${status} ${item.title}`);
1422
+ }
1423
+ }
1424
+ return lines.join("\n");
1425
+ }
1426
+ /**
1427
+ * Builds the `compact` command group with its `set` and `list` subcommands.
1428
+ *
1429
+ * @see file://../../docs/compact-format.md
1430
+ */
1431
+ function compactCommand() {
1432
+ const compact = new Command("compact").description("Read and write AI-written compact summaries of issues");
1433
+ compact
1434
+ .command("list")
1435
+ .description("List issues with their compaction status; --pending narrows to what needs compacting")
1436
+ .option("--pending", "only issues that need compacting (uncompacted or stale)")
1437
+ .option("--repo <owner/repo>", "restrict to a single watched repo")
1438
+ .option("--limit <n>", "cap the number of issues returned, applied after the pending/repo filter", parseLimit)
1439
+ .option("--json", "emit machine-readable JSON")
1440
+ .action((options) => {
1441
+ const config = loadConfig({ requireToken: false });
1442
+ const store = openStore(config.dbPath);
1443
+ try {
1444
+ const items = runCompactList(store, options);
1445
+ if (options.json) {
1446
+ console.log(JSON.stringify(items));
1447
+ }
1448
+ else {
1449
+ console.log(formatCompactList(items, { pending: options.pending }));
1450
+ }
1451
+ }
1452
+ catch (error) {
1453
+ if (error instanceof CompactCommandError) {
1454
+ console.error(error.message);
1455
+ process.exitCode = 1;
1456
+ return;
1457
+ }
1458
+ throw error;
1459
+ }
1460
+ finally {
1461
+ store.close();
1462
+ }
1463
+ });
1464
+ compact
1465
+ .command("set")
1466
+ .description("Persist a compact for an issue from a file in the canonical format")
1467
+ .argument("<target>", "issue to compact, as owner/repo#number")
1468
+ .requiredOption("--from-file <file>", "path to the compact file to read")
1469
+ .option("--json", "emit machine-readable JSON")
1470
+ .action((target, options) => {
1471
+ const config = loadConfig({ requireToken: false });
1472
+ const store = openStore(config.dbPath);
1473
+ try {
1474
+ const result = runCompactSet(store, target, options);
1475
+ if (options.json) {
1476
+ console.log(JSON.stringify(result));
1477
+ }
1478
+ else {
1479
+ console.log(`Saved compact for ${result.repo}#${result.number}: ${result.tldr}`);
1480
+ }
1481
+ }
1482
+ catch (error) {
1483
+ if (error instanceof CompactCommandError) {
1484
+ console.error(error.message);
1485
+ process.exitCode = 1;
1486
+ return;
1487
+ }
1488
+ throw error;
1489
+ }
1490
+ finally {
1491
+ store.close();
1492
+ }
1493
+ });
1494
+ return compact;
1495
+ }
1496
+
1497
+ /**
1498
+ * Error thrown by the `digest` action for expected, user-facing failures (an
1499
+ * unwatched `--repo` filter, a malformed `--since` value). The CLI prints the
1500
+ * message and exits non-zero.
1501
+ */
1502
+ class DigestError extends Error {
1503
+ constructor(message) {
1504
+ super(message);
1505
+ this.name = "DigestError";
1506
+ }
1507
+ }
1508
+ /** Canonical order and human labels for event types. */
1509
+ const TYPE_ORDER = [
1510
+ { type: "opened", label: "new issues" },
1511
+ { type: "closed", label: "closed" },
1512
+ { type: "commented", label: "new comments" },
1513
+ { type: "closed_commented", label: "closed with new comment" },
1514
+ { type: "reopened", label: "reopened" },
1515
+ ];
1516
+ const TYPE_LABELS = new Map(TYPE_ORDER.map((t) => [t.type, t.label]));
1517
+ /** Returns the index of a type in {@link TYPE_ORDER}, or a high value if unknown. */
1518
+ function typeRank(type) {
1519
+ const index = TYPE_ORDER.findIndex((t) => t.type === type);
1520
+ return index === -1 ? TYPE_ORDER.length : index;
1521
+ }
1522
+ /**
1523
+ * Resolves a `--since` value to an ISO-8601 timestamp. Accepts a full ISO date
1524
+ * or a simple relative duration: `<n>d` (days) or `<n>h` (hours) before `now`.
1525
+ *
1526
+ * @throws {DigestError} When the value is neither a valid ISO date nor a
1527
+ * supported relative duration.
1528
+ */
1529
+ function resolveSince(value, now = new Date()) {
1530
+ const trimmed = value.trim();
1531
+ const relative = /^(\d+)([dh])$/.exec(trimmed);
1532
+ if (relative) {
1533
+ const amount = Number.parseInt(relative[1], 10);
1534
+ const unitMs = relative[2] === "d" ? 86_400_000 : 3_600_000;
1535
+ return new Date(now.getTime() - amount * unitMs).toISOString();
1536
+ }
1537
+ const parsed = new Date(trimmed);
1538
+ if (Number.isNaN(parsed.getTime())) {
1539
+ throw new DigestError(`Invalid --since value "${value}". Expected an ISO date or a duration like 7d or 24h.`);
1540
+ }
1541
+ return parsed.toISOString();
1542
+ }
1543
+ /** Groups context events by repo (ordered by full name), then by canonical type. */
1544
+ function groupEvents(events) {
1545
+ const byRepo = new Map();
1546
+ for (const event of events) {
1547
+ const bucket = byRepo.get(event.repoFullName);
1548
+ if (bucket) {
1549
+ bucket.push(event);
1550
+ }
1551
+ else {
1552
+ byRepo.set(event.repoFullName, [event]);
1553
+ }
1554
+ }
1555
+ const repos = [...byRepo.keys()].sort();
1556
+ return repos.map((repo) => {
1557
+ const repoEvents = byRepo.get(repo) ?? [];
1558
+ const byType = new Map();
1559
+ for (const event of repoEvents) {
1560
+ const digestEvent = {
1561
+ id: event.id,
1562
+ type: event.type,
1563
+ detectedAt: event.detectedAt,
1564
+ issueNumber: event.issueNumber,
1565
+ issueTitle: event.issueTitle,
1566
+ issueState: event.issueState,
1567
+ };
1568
+ const bucket = byType.get(event.type);
1569
+ if (bucket) {
1570
+ bucket.push(digestEvent);
1571
+ }
1572
+ else {
1573
+ byType.set(event.type, [digestEvent]);
1574
+ }
1575
+ }
1576
+ const groups = [...byType.entries()]
1577
+ .sort(([a], [b]) => typeRank(a) - typeRank(b))
1578
+ .map(([type, typeEvents]) => ({ type, events: typeEvents }));
1579
+ return { repo, groups };
1580
+ });
1581
+ }
1582
+ /**
1583
+ * Core action for `issuary digest`: the aggregated inbox across all watched repos.
1584
+ *
1585
+ * - Default (inbox): surfaces unseen events, then marks them seen so each change
1586
+ * appears once.
1587
+ * - `--since`: a read-only time window (`detected_at >= since`); nothing marked.
1588
+ * - `--all`: every event (seen and unseen); nothing marked.
1589
+ * - `--repo`: narrows any of the above to a single watched repo.
1590
+ *
1591
+ * Separated from the Commander wiring so it can be tested without spawning a
1592
+ * process. The caller is responsible for opening/closing the {@link Store}.
1593
+ *
1594
+ * @throws {DigestError} When `--repo` names an unwatched repo or `--since` is
1595
+ * malformed.
1596
+ */
1597
+ function runDigest(store, options = {}) {
1598
+ let repoId;
1599
+ if (options.repo) {
1600
+ const repo = store.getRepoByFullName(options.repo);
1601
+ if (!repo) {
1602
+ throw new DigestError(`Repo "${options.repo}" is not watched. Add it with \`issuary add ${options.repo}\` first.`);
1603
+ }
1604
+ repoId = repo.id;
1605
+ }
1606
+ let mode;
1607
+ let events;
1608
+ if (options.since) {
1609
+ mode = "since";
1610
+ events = store.listEvents({ since: resolveSince(options.since), repoId });
1611
+ }
1612
+ else if (options.all) {
1613
+ mode = "all";
1614
+ events = store.listEvents({ repoId });
1615
+ }
1616
+ else {
1617
+ mode = "inbox";
1618
+ events = store.listEvents({ seen: false, repoId });
1619
+ store.markEventsSeen(events.map((event) => event.id));
1620
+ }
1621
+ return { mode, total: events.length, repos: groupEvents(events) };
1622
+ }
1623
+ /** Renders a single digest event line. */
1624
+ function formatEvent(event) {
1625
+ return ` #${event.issueNumber} [${event.issueState}] ${event.issueTitle}`;
1626
+ }
1627
+ /** Pure formatter: renders a {@link DigestResult} as human-readable text. */
1628
+ function formatDigest(result) {
1629
+ if (result.total === 0) {
1630
+ if (result.mode === "inbox") {
1631
+ return "Inbox empty: no new changes.";
1632
+ }
1633
+ return "No matching events.";
1634
+ }
1635
+ const lines = [];
1636
+ for (const repo of result.repos) {
1637
+ lines.push(repo.repo);
1638
+ for (const group of repo.groups) {
1639
+ const label = TYPE_LABELS.get(group.type) ?? group.type;
1640
+ lines.push(` ${label} (${group.events.length})`);
1641
+ for (const event of group.events) {
1642
+ lines.push(formatEvent(event));
1643
+ }
1644
+ }
1645
+ lines.push("");
1646
+ }
1647
+ return lines.join("\n").trimEnd();
1648
+ }
1649
+ /**
1650
+ * Builds the `digest` command: an aggregated inbox of detected changes across
1651
+ * every watched repo. By default it shows unseen changes and marks them seen so
1652
+ * each appears once; `--since` and `--all` are read-only views that never mark.
1653
+ */
1654
+ function digestCommand() {
1655
+ return new Command("digest")
1656
+ .description("Show an aggregated inbox of issue changes across all watched repos")
1657
+ .option("--since <when>", "show events at or after an ISO date or duration (7d, 24h); does not mark them seen")
1658
+ .option("--all", "show all events, seen and unseen, without marking any seen")
1659
+ .option("--repo <owner/repo>", "limit to a single watched repo")
1660
+ .option("--json", "emit machine-readable JSON")
1661
+ .action((options) => {
1662
+ const config = loadConfig({ requireToken: false });
1663
+ const store = openStore(config.dbPath);
1664
+ try {
1665
+ const result = runDigest(store, options);
1666
+ console.log(options.json ? JSON.stringify(result) : formatDigest(result));
1667
+ }
1668
+ catch (error) {
1669
+ if (error instanceof DigestError) {
1670
+ console.error(error.message);
1671
+ process.exitCode = 1;
1672
+ return;
1673
+ }
1674
+ throw error;
1675
+ }
1676
+ finally {
1677
+ store.close();
1678
+ }
1679
+ });
1680
+ }
1681
+
1682
+ /**
1683
+ * Error thrown by the `issues` action for expected, user-facing failures (an
1684
+ * unwatched `--repo`, a malformed `--since`, mutually exclusive compaction
1685
+ * flags). The CLI prints the message and exits non-zero.
1686
+ */
1687
+ class IssuesError extends Error {
1688
+ constructor(message) {
1689
+ super(message);
1690
+ this.name = "IssuesError";
1691
+ }
1692
+ }
1693
+ /**
1694
+ * Resolves the mutually exclusive `--uncompacted` / `--stale` / `--compacted`
1695
+ * flags into a single compaction filter.
1696
+ *
1697
+ * @throws {IssuesError} When more than one of the three is passed.
1698
+ */
1699
+ function resolveCompaction(options) {
1700
+ const selected = [];
1701
+ if (options.uncompacted) {
1702
+ selected.push("uncompacted");
1703
+ }
1704
+ if (options.stale) {
1705
+ selected.push("stale");
1706
+ }
1707
+ if (options.compacted) {
1708
+ selected.push("compacted");
1709
+ }
1710
+ if (selected.length > 1) {
1711
+ throw new IssuesError("Use at most one of --uncompacted, --stale, or --compacted; they are mutually exclusive.");
1712
+ }
1713
+ return selected[0] ?? null;
1714
+ }
1715
+ /** Parses an issue's stored labels JSON into a string array, tolerating null/garbage. */
1716
+ function parseLabels$1(labels) {
1717
+ if (!labels) {
1718
+ return [];
1719
+ }
1720
+ try {
1721
+ const parsed = JSON.parse(labels);
1722
+ return Array.isArray(parsed) ? parsed.filter((l) => typeof l === "string") : [];
1723
+ }
1724
+ catch {
1725
+ return [];
1726
+ }
1727
+ }
1728
+ /** Whether an issue carries a fresh, trustworthy compact (protocol rule). */
1729
+ function isFresh(issue) {
1730
+ return issue.compact != null && !issue.compactStale;
1731
+ }
1732
+ /**
1733
+ * Core action for `issuary issues`: the filterable, cross-repo, state-based
1734
+ * listing of issues. STRICTLY READ-ONLY: no API calls, no state mutation.
1735
+ *
1736
+ * Resolves `--repo` full names to ids (erroring on an unwatched repo), resolves
1737
+ * `--since`, validates the mutually exclusive compaction flags, queries the
1738
+ * store, and builds the result with per-issue compaction flags and refs.
1739
+ *
1740
+ * Separated from the Commander wiring so it can be tested without spawning a
1741
+ * process. The caller is responsible for opening/closing the {@link Store}.
1742
+ *
1743
+ * @throws {IssuesError} For expected, user-facing failures.
1744
+ */
1745
+ function runIssues(store, options = {}) {
1746
+ const state = options.state ?? "open";
1747
+ const sort = options.sort ?? "updated";
1748
+ const order = options.order ?? "desc";
1749
+ const compaction = resolveCompaction(options);
1750
+ let repoIds;
1751
+ if (options.repo && options.repo.length > 0) {
1752
+ repoIds = options.repo.map((fullName) => {
1753
+ const repo = store.getRepoByFullName(fullName);
1754
+ if (!repo) {
1755
+ throw new IssuesError(`Repo "${fullName}" is not watched. Add it with \`issuary add ${fullName}\` first.`);
1756
+ }
1757
+ return repo.id;
1758
+ });
1759
+ }
1760
+ let since;
1761
+ if (options.since) {
1762
+ try {
1763
+ since = resolveSince(options.since);
1764
+ }
1765
+ catch (error) {
1766
+ if (error instanceof DigestError) {
1767
+ throw new IssuesError(error.message);
1768
+ }
1769
+ throw error;
1770
+ }
1771
+ }
1772
+ const filter = {
1773
+ state,
1774
+ repoIds,
1775
+ labels: options.label && options.label.length > 0 ? options.label : undefined,
1776
+ author: options.author,
1777
+ stateReason: options.stateReason,
1778
+ since,
1779
+ search: options.search,
1780
+ compaction: compaction ?? undefined,
1781
+ sort,
1782
+ order,
1783
+ limit: options.limit,
1784
+ };
1785
+ const rows = store.queryIssues(filter);
1786
+ let open = 0;
1787
+ const repoSet = new Set();
1788
+ const issues = rows.map((issue) => {
1789
+ if (issue.state === "open") {
1790
+ open += 1;
1791
+ }
1792
+ repoSet.add(issue.repoFullName);
1793
+ const fresh = isFresh(issue);
1794
+ return {
1795
+ repo: issue.repoFullName,
1796
+ number: issue.number,
1797
+ title: issue.title,
1798
+ state: issue.state,
1799
+ stateReason: issue.stateReason,
1800
+ author: issue.author,
1801
+ labels: parseLabels$1(issue.labels),
1802
+ commentCount: issue.commentCount,
1803
+ createdAt: issue.createdAt,
1804
+ updatedAt: issue.updatedAt,
1805
+ compact: issue.compact,
1806
+ compactTldr: issue.compactTldr,
1807
+ compacted: fresh,
1808
+ stale: issue.compact != null && issue.compactStale,
1809
+ refs: store.listIssueRefs(issue.id).map((ref) => ref.target),
1810
+ };
1811
+ });
1812
+ const filters = {
1813
+ state,
1814
+ repos: repoIds ? options.repo : null,
1815
+ labels: filter.labels ?? null,
1816
+ author: options.author ?? null,
1817
+ stateReason: options.stateReason ?? null,
1818
+ since: since ?? null,
1819
+ search: options.search ?? null,
1820
+ compaction,
1821
+ sort,
1822
+ order,
1823
+ limit: options.limit ?? null,
1824
+ };
1825
+ const summary = {
1826
+ total: issues.length,
1827
+ open,
1828
+ closed: issues.length - open,
1829
+ repos: repoSet.size,
1830
+ };
1831
+ return { filters, summary, issues };
1832
+ }
1833
+ /** Builds the short `(filter: ...)` suffix describing non-default filters. */
1834
+ function describeFilters(filters) {
1835
+ const parts = [];
1836
+ if (filters.repos) {
1837
+ parts.push(`repos=${filters.repos.join(",")}`);
1838
+ }
1839
+ if (filters.labels) {
1840
+ parts.push(`labels=${filters.labels.join("|")}`);
1841
+ }
1842
+ if (filters.author) {
1843
+ parts.push(`author=${filters.author}`);
1844
+ }
1845
+ if (filters.stateReason) {
1846
+ parts.push(`state_reason=${filters.stateReason}`);
1847
+ }
1848
+ if (filters.since) {
1849
+ parts.push(`since=${filters.since}`);
1850
+ }
1851
+ if (filters.search) {
1852
+ parts.push(`search="${filters.search}"`);
1853
+ }
1854
+ if (filters.compaction) {
1855
+ parts.push(filters.compaction);
1856
+ }
1857
+ if (filters.sort !== "updated" || filters.order !== "desc") {
1858
+ parts.push(`sort=${filters.sort} ${filters.order}`);
1859
+ }
1860
+ if (filters.limit) {
1861
+ parts.push(`limit=${filters.limit}`);
1862
+ }
1863
+ return parts.length > 0 ? ` (filter: ${parts.join(", ")})` : "";
1864
+ }
1865
+ /** The discreet compaction marker for a row, or empty when fresh-compacted. */
1866
+ function compactionMarker(issue) {
1867
+ if (issue.compacted) {
1868
+ return "";
1869
+ }
1870
+ return issue.stale ? " (stale)" : " (uncompacted)";
1871
+ }
1872
+ /** Pure formatter: renders an {@link IssuesResult} as human-readable text. */
1873
+ function formatIssues(result) {
1874
+ const { summary, filters } = result;
1875
+ if (summary.total === 0) {
1876
+ return `No issues match${describeFilters(filters)}.`;
1877
+ }
1878
+ const stateWord = filters.state === "all" ? "issues" : `${filters.state} issues`;
1879
+ const repoWord = summary.repos === 1 ? "repo" : "repos";
1880
+ const header = `${summary.total} ${stateWord} across ${summary.repos} ${repoWord}${describeFilters(filters)}`;
1881
+ const byRepo = new Map();
1882
+ for (const issue of result.issues) {
1883
+ const bucket = byRepo.get(issue.repo);
1884
+ if (bucket) {
1885
+ bucket.push(issue);
1886
+ }
1887
+ else {
1888
+ byRepo.set(issue.repo, [issue]);
1889
+ }
1890
+ }
1891
+ const lines = [header];
1892
+ for (const [repo, repoIssues] of byRepo) {
1893
+ lines.push("");
1894
+ lines.push(`${repo}:`);
1895
+ const numWidth = Math.max(...repoIssues.map((i) => `#${i.number}`.length));
1896
+ const stateWidth = Math.max(...repoIssues.map((i) => `[${i.state}]`.length));
1897
+ for (const issue of repoIssues) {
1898
+ const num = `#${issue.number}`.padEnd(numWidth);
1899
+ const stateTag = `[${issue.state}]`.padEnd(stateWidth);
1900
+ const labels = issue.labels.length ? ` {${issue.labels.join(", ")}}` : "";
1901
+ const comments = issue.commentCount > 0 ? ` (${issue.commentCount}c)` : "";
1902
+ lines.push(` ${num} ${stateTag} ${issue.title}${labels}${comments}${compactionMarker(issue)}`);
1903
+ }
1904
+ }
1905
+ return lines.join("\n");
1906
+ }
1907
+ /**
1908
+ * Builds the `issues` command: a read-only, state-based, filterable listing of
1909
+ * issues across watched repos. Distinct from `digest` (what changed) and
1910
+ * `repo-digest` (one repo's full dump). Defaults to open issues across all
1911
+ * watched repos, newest-updated first, grouped by repo.
1912
+ */
1913
+ function issuesCommand() {
1914
+ const collect = (value, previous = []) => [...previous, value];
1915
+ return new Command("issues")
1916
+ .description("List issues across watched repos with filters (read-only); defaults to open issues everywhere")
1917
+ .option("--state <state>", "issue state: open, closed, or all", "open")
1918
+ .option("--repo <owner/repo>", "scope to a watched repo (repeatable)", collect)
1919
+ .option("--label <name>", "match issues with any of these labels (repeatable, OR)", collect)
1920
+ .option("--author <login>", "restrict to issues by this author")
1921
+ .option("--state-reason <reason>", "restrict to this state_reason (completed, not_planned)")
1922
+ .option("--since <when>", "only issues updated at or after an ISO date or duration (7d, 24h)")
1923
+ .option("--search <text>", "case-insensitive substring match on the issue title")
1924
+ .option("--uncompacted", "only issues without a compact (exclusive with --stale/--compacted)")
1925
+ .option("--stale", "only issues whose compact is stale (exclusive with --uncompacted/--compacted)")
1926
+ .option("--compacted", "only issues with a fresh compact (exclusive with --uncompacted/--stale)")
1927
+ .option("--sort <key>", "sort by updated, created, or number", "updated")
1928
+ .option("--order <dir>", "sort direction: asc or desc", "desc")
1929
+ .option("--limit <n>", "cap the number of issues returned", (v) => Number.parseInt(v, 10))
1930
+ .option("--json", "emit machine-readable JSON")
1931
+ .action((options) => {
1932
+ const config = loadConfig({ requireToken: false });
1933
+ const store = openStore(config.dbPath);
1934
+ try {
1935
+ const result = runIssues(store, options);
1936
+ console.log(options.json ? JSON.stringify(result) : formatIssues(result));
1937
+ }
1938
+ catch (error) {
1939
+ if (error instanceof IssuesError) {
1940
+ console.error(error.message);
1941
+ process.exitCode = 1;
1942
+ return;
1943
+ }
1944
+ throw error;
1945
+ }
1946
+ finally {
1947
+ store.close();
1948
+ }
1949
+ });
1950
+ }
1951
+
1952
+ /**
1953
+ * Core action for `issuary list`: returns the watched repos and their state,
1954
+ * ordered by `full_name`. Separated from the Commander wiring for testing.
1955
+ */
1956
+ function runList(store) {
1957
+ return store.listRepos().map((repo) => ({
1958
+ repo: repo.fullName,
1959
+ active: repo.active,
1960
+ lastSyncedAt: repo.lastSyncedAt,
1961
+ }));
1962
+ }
1963
+ /**
1964
+ * Renders the human-readable, aligned listing. Repos are grouped active first,
1965
+ * then inactive; `last_synced_at` shows "never" when null.
1966
+ */
1967
+ function formatList(items) {
1968
+ if (items.length === 0) {
1969
+ return "No repos watched yet. Add one with `issuary add owner/repo`.";
1970
+ }
1971
+ const active = items.filter((item) => item.active);
1972
+ const inactive = items.filter((item) => !item.active);
1973
+ const width = Math.max(...items.map((item) => item.repo.length));
1974
+ const lines = [];
1975
+ const render = (item) => ` ${item.repo.padEnd(width)} last synced: ${item.lastSyncedAt ?? "never"}`;
1976
+ if (active.length > 0) {
1977
+ lines.push("active:");
1978
+ for (const item of active) {
1979
+ lines.push(render(item));
1980
+ }
1981
+ }
1982
+ if (inactive.length > 0) {
1983
+ if (lines.length > 0) {
1984
+ lines.push("");
1985
+ }
1986
+ lines.push("inactive:");
1987
+ for (const item of inactive) {
1988
+ lines.push(render(item));
1989
+ }
1990
+ }
1991
+ return lines.join("\n");
1992
+ }
1993
+ /** Builds the `list` command. */
1994
+ function listCommand() {
1995
+ return new Command("list")
1996
+ .description("List watched repos with their state and last sync time")
1997
+ .option("--json", "emit machine-readable JSON")
1998
+ .action((options) => {
1999
+ const config = loadConfig({ requireToken: false });
2000
+ const store = openStore(config.dbPath);
2001
+ try {
2002
+ const items = runList(store);
2003
+ if (options.json) {
2004
+ console.log(JSON.stringify(items));
2005
+ }
2006
+ else {
2007
+ console.log(formatList(items));
2008
+ }
2009
+ }
2010
+ finally {
2011
+ store.close();
2012
+ }
2013
+ });
2014
+ }
2015
+
2016
+ /**
2017
+ * Error thrown by the auth (device flow) module for expected, user-facing
2018
+ * failures: a missing client id, a denied or expired device-flow grant, or an
2019
+ * unexpected OAuth response.
2020
+ *
2021
+ * Callers should catch this to print a friendly, actionable message instead of a
2022
+ * stack trace. Messages never contain the resulting access token.
2023
+ */
2024
+ class AuthError extends Error {
2025
+ constructor(message) {
2026
+ super(message);
2027
+ this.name = "AuthError";
2028
+ }
2029
+ }
2030
+
2031
+ /**
2032
+ * Public client id of the registered GitHub app used for the device flow.
2033
+ *
2034
+ * A device-flow client id is not a secret, so it is safe to commit. Users can
2035
+ * override it at runtime with the `ISSUARY_GITHUB_CLIENT_ID` environment
2036
+ * variable (for example to point at their own app or a GitHub Enterprise one).
2037
+ */
2038
+ const DEFAULT_GITHUB_CLIENT_ID = "Ov23liOws9jSkjjC2PAL";
2039
+ /** Default OAuth scope requested by `issuary login`; `repo` so private repos work. */
2040
+ const DEFAULT_SCOPE = "repo";
2041
+ /**
2042
+ * Resolves the OAuth client id to use for the device flow.
2043
+ *
2044
+ * `ISSUARY_GITHUB_CLIENT_ID` (trimmed) overrides the baked
2045
+ * {@link DEFAULT_GITHUB_CLIENT_ID}.
2046
+ *
2047
+ * @param env - Environment to read from; defaults to `process.env`.
2048
+ * @param defaultClientId - Baked-in fallback; defaults to {@link DEFAULT_GITHUB_CLIENT_ID}.
2049
+ * @returns The resolved client id.
2050
+ * @throws {AuthError} When no client id is configured.
2051
+ */
2052
+ function resolveClientId(env = process.env, defaultClientId = DEFAULT_GITHUB_CLIENT_ID) {
2053
+ const fromEnv = (env.ISSUARY_GITHUB_CLIENT_ID ?? "").trim();
2054
+ const clientId = fromEnv || defaultClientId;
2055
+ if (clientId === "") {
2056
+ throw new AuthError("No GitHub OAuth client id is configured, so `issuary login` cannot run. " +
2057
+ "Set ISSUARY_GITHUB_CLIENT_ID to a GitHub OAuth App client id (device flow enabled), " +
2058
+ "or use `export GITHUB_TOKEN=...` instead.");
2059
+ }
2060
+ return clientId;
2061
+ }
2062
+ /**
2063
+ * Resolves the OAuth scope to request.
2064
+ *
2065
+ * `ISSUARY_GITHUB_SCOPE` (trimmed) overrides the default {@link DEFAULT_SCOPE}.
2066
+ *
2067
+ * @param env - Environment to read from; defaults to `process.env`.
2068
+ * @returns The resolved scope string.
2069
+ */
2070
+ function resolveScope(env = process.env) {
2071
+ return (env.ISSUARY_GITHUB_SCOPE ?? "").trim() || DEFAULT_SCOPE;
2072
+ }
2073
+
2074
+ /** Default OAuth endpoints host; overridable for tests and GitHub Enterprise. */
2075
+ const DEFAULT_OAUTH_HOST = "https://github.com";
2076
+ /** Standard extra delay (seconds) applied when GitHub asks us to `slow_down`. */
2077
+ const SLOW_DOWN_INCREMENT_SECONDS = 5;
2078
+ /** Fallback poll interval (seconds) when GitHub omits one. */
2079
+ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
2080
+ /** Default sleep backed by a real timer. */
2081
+ function defaultSleep(ms) {
2082
+ return new Promise((resolve) => setTimeout(resolve, ms));
2083
+ }
2084
+ /** Normalizes an OAuth host: trims trailing slashes, falls back to the default. */
2085
+ function normalizeHost(raw) {
2086
+ return ((raw ?? "").trim() || DEFAULT_OAUTH_HOST).replace(/\/+$/, "");
2087
+ }
2088
+ /**
2089
+ * Requests a device and user code to start the GitHub device flow.
2090
+ *
2091
+ * `POST {oauthHost}/login/device/code` with the client id and scope.
2092
+ *
2093
+ * @param options - Client id, scope, and optional host/`fetch` overrides.
2094
+ * @returns The parsed {@link DeviceCodeResponse}.
2095
+ * @throws {AuthError} When the request fails or the response is malformed.
2096
+ */
2097
+ async function requestDeviceCode(options) {
2098
+ const doFetch = options.fetch ?? fetch;
2099
+ const host = normalizeHost(options.oauthHost);
2100
+ let response;
2101
+ try {
2102
+ response = await doFetch(`${host}/login/device/code`, {
2103
+ method: "POST",
2104
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
2105
+ body: JSON.stringify({ client_id: options.clientId, scope: options.scope }),
2106
+ });
2107
+ }
2108
+ catch (error) {
2109
+ throw new AuthError(`Could not reach GitHub to start device login: ${error.message}.`);
2110
+ }
2111
+ if (!response.ok) {
2112
+ throw new AuthError(`GitHub rejected the device-code request with HTTP ${response.status}.`);
2113
+ }
2114
+ const body = (await response.json());
2115
+ if (body.error) {
2116
+ throw new AuthError(`GitHub device-code request failed: ${body.error_description ?? body.error}.`);
2117
+ }
2118
+ if (typeof body.device_code !== "string" ||
2119
+ typeof body.user_code !== "string" ||
2120
+ typeof body.verification_uri !== "string") {
2121
+ throw new AuthError("GitHub returned an unexpected device-code response.");
2122
+ }
2123
+ return {
2124
+ device_code: body.device_code,
2125
+ user_code: body.user_code,
2126
+ verification_uri: body.verification_uri,
2127
+ expires_in: typeof body.expires_in === "number" ? body.expires_in : 900,
2128
+ interval: typeof body.interval === "number" ? body.interval : DEFAULT_POLL_INTERVAL_SECONDS,
2129
+ };
2130
+ }
2131
+ /**
2132
+ * Polls GitHub for the access token after the user authorizes the device.
2133
+ *
2134
+ * `POST {oauthHost}/login/oauth/access_token` on the given `interval`, handling
2135
+ * the documented device-flow responses: `authorization_pending` (keep polling),
2136
+ * `slow_down` (back off by the standard 5s plus any returned interval),
2137
+ * `expired_token` and `access_denied` (throw), and success (`access_token`).
2138
+ * Stops once the `expiresIn` window elapses. Uses an injectable `sleep` and
2139
+ * `now` so tests never actually wait.
2140
+ *
2141
+ * @param options - Client id, device code, timing, and overrides.
2142
+ * @returns The access token string.
2143
+ * @throws {AuthError} On denial, expiry, timeout, or an unexpected response.
2144
+ */
2145
+ async function pollForAccessToken(options) {
2146
+ const doFetch = options.fetch ?? fetch;
2147
+ const sleep = options.sleep ?? defaultSleep;
2148
+ const now = options.now ?? Date.now;
2149
+ const host = normalizeHost(options.oauthHost);
2150
+ const deadline = now() + options.expiresIn * 1000;
2151
+ let intervalSeconds = options.interval > 0 ? options.interval : DEFAULT_POLL_INTERVAL_SECONDS;
2152
+ for (;;) {
2153
+ if (now() >= deadline) {
2154
+ throw new AuthError("Device login timed out before it was authorized. Run `issuary login` again.");
2155
+ }
2156
+ await sleep(intervalSeconds * 1000);
2157
+ let response;
2158
+ try {
2159
+ response = await doFetch(`${host}/login/oauth/access_token`, {
2160
+ method: "POST",
2161
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
2162
+ body: JSON.stringify({
2163
+ client_id: options.clientId,
2164
+ device_code: options.deviceCode,
2165
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
2166
+ }),
2167
+ });
2168
+ }
2169
+ catch (error) {
2170
+ throw new AuthError(`Could not reach GitHub while polling for the token: ${error.message}.`);
2171
+ }
2172
+ if (!response.ok) {
2173
+ throw new AuthError(`GitHub rejected the token poll with HTTP ${response.status}.`);
2174
+ }
2175
+ const body = (await response.json());
2176
+ if (typeof body.access_token === "string" && body.access_token !== "") {
2177
+ return body.access_token;
2178
+ }
2179
+ switch (body.error) {
2180
+ case "authorization_pending":
2181
+ // The user has not finished authorizing yet; keep the same interval.
2182
+ continue;
2183
+ case "slow_down":
2184
+ // GitHub asks us to back off. Always bump by at least the standard 5s,
2185
+ // and honor a larger returned interval if present. Never go below the
2186
+ // current interval, even if GitHub returns a smaller value.
2187
+ intervalSeconds = Math.max(intervalSeconds + SLOW_DOWN_INCREMENT_SECONDS, typeof body.interval === "number" ? body.interval : 0);
2188
+ continue;
2189
+ case "expired_token":
2190
+ throw new AuthError("The device code expired before login was authorized. Run `issuary login` again.");
2191
+ case "access_denied":
2192
+ throw new AuthError("Device login was denied. You cancelled or rejected the authorization.");
2193
+ default:
2194
+ throw new AuthError(`Device login failed: ${body.error_description ?? body.error ?? "unknown error"}.`);
2195
+ }
2196
+ }
2197
+ }
2198
+
2199
+ /** Default `/user` lookup used to confirm the freshly stored token. */
2200
+ async function defaultGetAuthenticatedUser(apiUrl, token) {
2201
+ let response;
2202
+ try {
2203
+ response = await fetch(`${apiUrl}/user`, {
2204
+ headers: {
2205
+ Authorization: `Bearer ${token}`,
2206
+ Accept: "application/vnd.github+json",
2207
+ "X-GitHub-Api-Version": "2022-11-28",
2208
+ "User-Agent": "merencia-issuary",
2209
+ },
2210
+ });
2211
+ }
2212
+ catch (error) {
2213
+ throw new AuthError(`Logged in, but could not confirm the token with GitHub: ${error.message}.`);
2214
+ }
2215
+ if (!response.ok) {
2216
+ throw new AuthError(`Logged in, but GitHub rejected the token confirmation with HTTP ${response.status}.`);
2217
+ }
2218
+ const body = (await response.json());
2219
+ const scopesHeader = response.headers.get("x-oauth-scopes") ?? "";
2220
+ const scopes = scopesHeader
2221
+ .split(",")
2222
+ .map((s) => s.trim())
2223
+ .filter((s) => s !== "");
2224
+ return {
2225
+ login: typeof body.login === "string" ? body.login : "unknown",
2226
+ scopes,
2227
+ };
2228
+ }
2229
+ /**
2230
+ * Core action for `issuary login`: runs the GitHub device flow, prints the user
2231
+ * code and verification URL, polls until the user authorizes, stores the token,
2232
+ * and confirms it by fetching the authenticated user. Never prints the token.
2233
+ *
2234
+ * Separated from the Commander wiring so it can be tested with injected fakes.
2235
+ *
2236
+ * @param deps - Injected dependencies; see {@link LoginDeps}.
2237
+ * @returns The {@link LoginResult} on success.
2238
+ * @throws {AuthError} For device-flow and confirmation failures.
2239
+ */
2240
+ async function runLogin(deps) {
2241
+ const log = deps.log ?? ((message) => console.log(message));
2242
+ const doRequest = deps.requestDeviceCode ?? requestDeviceCode;
2243
+ const doPoll = deps.pollForAccessToken ?? pollForAccessToken;
2244
+ const store = deps.writeStoredToken ?? writeStoredToken;
2245
+ const getUser = deps.getAuthenticatedUser ?? defaultGetAuthenticatedUser;
2246
+ const device = await doRequest({
2247
+ clientId: deps.clientId,
2248
+ scope: deps.scope,
2249
+ oauthHost: deps.oauthHost,
2250
+ });
2251
+ log(`To authorize issuary, open ${device.verification_uri} and enter the code: ${device.user_code}`);
2252
+ log("Waiting for you to authorize in the browser...");
2253
+ const token = await doPoll({
2254
+ clientId: deps.clientId,
2255
+ deviceCode: device.device_code,
2256
+ interval: device.interval,
2257
+ expiresIn: device.expires_in,
2258
+ oauthHost: deps.oauthHost,
2259
+ });
2260
+ store(deps.home, token);
2261
+ const user = await getUser(deps.apiUrl, token);
2262
+ return { ok: true, login: user.login, scopes: user.scopes };
2263
+ }
2264
+ /**
2265
+ * Builds the `login` command.
2266
+ *
2267
+ * `issuary login` authenticates via the GitHub OAuth device flow and stores the
2268
+ * resulting token in the credentials file. The action is kept thin: it resolves
2269
+ * config and the client id, then delegates to {@link runLogin}.
2270
+ */
2271
+ function loginCommand() {
2272
+ return new Command("login")
2273
+ .description("Authenticate with GitHub via the device flow and store the token")
2274
+ .option("--json", "emit machine-readable JSON")
2275
+ .action(async (options) => {
2276
+ // No token is required to log in; that is the whole point.
2277
+ const config = loadConfig({ requireToken: false });
2278
+ const clientId = resolveClientId();
2279
+ const scope = resolveScope();
2280
+ const result = await runLogin({
2281
+ home: config.home,
2282
+ apiUrl: config.apiUrl,
2283
+ clientId,
2284
+ scope,
2285
+ // Silence the human progress lines in --json mode so stdout stays clean.
2286
+ log: options.json ? () => { } : undefined,
2287
+ });
2288
+ if (options.json) {
2289
+ console.log(JSON.stringify(result));
2290
+ }
2291
+ else {
2292
+ console.log(`Logged in as ${result.login}.`);
2293
+ }
2294
+ });
2295
+ }
2296
+
2297
+ /**
2298
+ * Core action for `issuary logout`: clears the stored token. Local only; it does
2299
+ * not contact GitHub or revoke the token server-side.
2300
+ *
2301
+ * @param deps - Injected dependencies; see {@link LogoutDeps}.
2302
+ * @returns A {@link LogoutResult} reporting whether anything was removed.
2303
+ */
2304
+ function runLogout(deps) {
2305
+ const clear = deps.clearStoredToken ?? clearStoredToken;
2306
+ const removed = clear(deps.home);
2307
+ return { ok: true, removed };
2308
+ }
2309
+ /**
2310
+ * Builds the `logout` command.
2311
+ *
2312
+ * `issuary logout` removes the locally stored token. It never hits the network.
2313
+ */
2314
+ function logoutCommand() {
2315
+ return new Command("logout")
2316
+ .description("Remove the stored GitHub token saved by `issuary login`")
2317
+ .option("--json", "emit machine-readable JSON")
2318
+ .action((options) => {
2319
+ const config = loadConfig({ requireToken: false });
2320
+ const result = runLogout({ home: config.home });
2321
+ if (options.json) {
2322
+ console.log(JSON.stringify(result));
2323
+ }
2324
+ else {
2325
+ console.log(result.removed ? "Logged out. Stored token removed." : "No stored token to remove.");
2326
+ }
2327
+ });
2328
+ }
2329
+
2330
+ /**
2331
+ * The canonical AI compaction protocol, exposed as a first-class, machine-readable
2332
+ * contract. Kept consistent with the "Protocolo de compactacao" section of
2333
+ * `CLAUDE.md` and the full spec in `docs/compact-format.md`.
2334
+ *
2335
+ * The full field-by-field specification lives in {@link COMPACT_FORMAT_DOC}.
2336
+ */
2337
+ /** Pointer to the full, authoritative compact format specification. */
2338
+ const COMPACT_FORMAT_DOC = "docs/compact-format.md";
2339
+ /**
2340
+ * The canonical compaction protocol text that AI consumers follow.
2341
+ *
2342
+ * It states what each issue exposes (`compact` and `compact_stale`), the two
2343
+ * rules for using or regenerating a compact, the persistence command, and what
2344
+ * a compact must preserve. It points to {@link COMPACT_FORMAT_DOC} for the full
2345
+ * field spec.
2346
+ *
2347
+ * Asserted on by tests via substrings so the contract cannot silently drift.
2348
+ */
2349
+ const COMPACTION_PROTOCOL = `issuary compaction protocol (contract for AI consumers)
2350
+
2351
+ Each issue issuary exposes carries two fields:
2352
+ - compact: string | null the AI-written structured summary, or null if none
2353
+ - compact_stale: boolean true when the compact no longer reflects the issue
2354
+
2355
+ Rule 1 (use when fresh):
2356
+ If compact != null AND compact_stale == false, USE the compact. Do not refetch
2357
+ the raw issue, do not re-read raw_comments, and do not recompact. The compact is
2358
+ trusted and current.
2359
+
2360
+ Rule 2 (recompact when missing or stale):
2361
+ If compact == null OR compact_stale == true, read raw_body and raw_comments,
2362
+ write a fresh compact in the canonical format, and persist it:
2363
+
2364
+ issuary compact set <owner/repo>#<n> --from-file <file>
2365
+
2366
+ Persisting a compact clears the stale flag.
2367
+
2368
+ What a compact must preserve:
2369
+ - status and state_reason are copied verbatim from the GitHub API, never inferred.
2370
+ - labels are copied from the API; only their selection is a judgment call.
2371
+ - refs are preserved literally, exactly as written (for example "#812", "PR #820").
2372
+ - tldr is one standalone sentence (soft cap about 20 words).
2373
+ - The body fields tldr, problem, status_detail, decisions, open_questions keep a
2374
+ fixed order. An empty textual field is the literal null. Soft cap about 8 lines.
2375
+
2376
+ Full field-by-field specification, rules, and worked examples: ${COMPACT_FORMAT_DOC}.`;
2377
+ /** Structured, machine-readable description of the canonical compact format. */
2378
+ const COMPACT_FORMAT_SPEC = {
2379
+ doc: COMPACT_FORMAT_DOC,
2380
+ frontmatterFields: ["status", "state_reason", "refs", "versions", "labels"],
2381
+ bodyFields: ["tldr", "problem", "status_detail", "decisions", "open_questions"],
2382
+ persistCommand: "issuary compact set <owner/repo>#<n> --from-file <file>",
2383
+ };
2384
+
2385
+ /**
2386
+ * Core action for `issuary protocol`: returns the canonical compaction protocol.
2387
+ *
2388
+ * Separated from the Commander wiring so it can be tested without spawning a
2389
+ * process. Returns the human text, or the structured JSON payload when
2390
+ * `options.json` is set.
2391
+ */
2392
+ function runProtocol(options = {}) {
2393
+ if (options.json) {
2394
+ return { protocol: COMPACTION_PROTOCOL, compactFormat: COMPACT_FORMAT_SPEC };
2395
+ }
2396
+ return COMPACTION_PROTOCOL;
2397
+ }
2398
+ /**
2399
+ * Builds the `protocol` command. It prints the AI compaction contract so an
2400
+ * agent can discover the protocol it must follow.
2401
+ *
2402
+ * @see file://../../docs/compact-format.md
2403
+ */
2404
+ function protocolCommand() {
2405
+ return new Command("protocol")
2406
+ .description("Print the AI compaction protocol (the contract AI consumers follow)")
2407
+ .option("--json", "emit machine-readable JSON")
2408
+ .action((options) => {
2409
+ if (options.json) {
2410
+ console.log(JSON.stringify(runProtocol({ json: true })));
2411
+ }
2412
+ else {
2413
+ console.log(runProtocol());
2414
+ }
2415
+ });
2416
+ }
2417
+
2418
+ /**
2419
+ * Core action for `issuary remove`: deactivates a watched repo (sets `active = 0`).
2420
+ * Never deletes, so issues and compacts are preserved for history.
2421
+ *
2422
+ * Separated from the Commander wiring for testing. The caller owns the
2423
+ * {@link Store} lifecycle.
2424
+ *
2425
+ * @throws {RepoCommandError} When the repo is not watched.
2426
+ */
2427
+ function runRemove(store, arg, _options = {}) {
2428
+ const { fullName } = parseRepoArg(arg);
2429
+ const existing = store.getRepoByFullName(fullName);
2430
+ if (!existing) {
2431
+ throw new RepoCommandError(`Repo "${fullName}" is not watched. Nothing to remove.`);
2432
+ }
2433
+ if (!existing.active) {
2434
+ return { ok: true, repo: fullName, status: "already-inactive" };
2435
+ }
2436
+ store.setRepoActive(fullName, false);
2437
+ return { ok: true, repo: fullName, status: "removed" };
2438
+ }
2439
+ /** Human-readable line for a successful {@link runRemove}. */
2440
+ function removeMessage(result) {
2441
+ return result.status === "removed"
2442
+ ? `Stopped watching ${result.repo}. Its history and compacts are kept.`
2443
+ : `${result.repo} was already not being watched.`;
2444
+ }
2445
+ /** Builds the `remove` command. */
2446
+ function removeCommand() {
2447
+ return new Command("remove")
2448
+ .description("Stop watching a repo (deactivates it; history and compacts are kept)")
2449
+ .argument("<owner/repo>", "repository to stop watching, as owner/repo")
2450
+ .option("--json", "emit machine-readable JSON")
2451
+ .action((arg, options) => {
2452
+ const config = loadConfig({ requireToken: false });
2453
+ const store = openStore(config.dbPath);
2454
+ try {
2455
+ const result = runRemove(store, arg, options);
2456
+ if (options.json) {
2457
+ console.log(JSON.stringify(result));
2458
+ }
2459
+ else {
2460
+ console.log(removeMessage(result));
2461
+ }
2462
+ }
2463
+ catch (error) {
2464
+ if (error instanceof RepoCommandError) {
2465
+ console.error(error.message);
2466
+ process.exitCode = 1;
2467
+ return;
2468
+ }
2469
+ throw error;
2470
+ }
2471
+ finally {
2472
+ store.close();
2473
+ }
2474
+ });
2475
+ }
2476
+
2477
+ /**
2478
+ * Error thrown by the `repo-digest` action for expected, user-facing failures
2479
+ * (an unwatched repo). The CLI prints the message and exits non-zero.
2480
+ */
2481
+ class RepoDigestError extends Error {
2482
+ constructor(message) {
2483
+ super(message);
2484
+ this.name = "RepoDigestError";
2485
+ }
2486
+ }
2487
+ /**
2488
+ * Whether an issue has a fresh, trustworthy compact: it must exist and not be
2489
+ * stale. This is the protocol rule (see docs/compact-format.md section 5).
2490
+ */
2491
+ function hasFreshCompact(issue) {
2492
+ return issue.compact != null && !issue.compactStale;
2493
+ }
2494
+ /**
2495
+ * Orders issues for a project-wide view: open issues first, then closed, each
2496
+ * group ordered by issue number ascending (the order `listIssues` returns).
2497
+ */
2498
+ function orderIssues(issues) {
2499
+ const open = issues.filter((issue) => issue.state === "open");
2500
+ const closed = issues.filter((issue) => issue.state !== "open");
2501
+ return [...open, ...closed];
2502
+ }
2503
+ /** Computes the summary counts over all of a repo's issues. */
2504
+ function summarize(issues) {
2505
+ let open = 0;
2506
+ let compacted = 0;
2507
+ for (const issue of issues) {
2508
+ if (issue.state === "open") {
2509
+ open += 1;
2510
+ }
2511
+ if (hasFreshCompact(issue)) {
2512
+ compacted += 1;
2513
+ }
2514
+ }
2515
+ return {
2516
+ total: issues.length,
2517
+ open,
2518
+ closed: issues.length - open,
2519
+ compacted,
2520
+ staleOrUncompacted: issues.length - compacted,
2521
+ };
2522
+ }
2523
+ /**
2524
+ * Looks up the repo and returns its ordered issues plus the summary counts.
2525
+ *
2526
+ * @throws {RepoDigestError} When the repo is not watched.
2527
+ */
2528
+ function loadDigest(store, fullName) {
2529
+ const repo = store.getRepoByFullName(fullName);
2530
+ if (!repo) {
2531
+ throw new RepoDigestError(`Repo "${fullName}" is not watched. Add it with \`issuary add ${fullName}\` first.`);
2532
+ }
2533
+ const issues = store.listIssues(repo.id);
2534
+ return { repo: repo.fullName, ordered: orderIssues(issues), summary: summarize(issues) };
2535
+ }
2536
+ /**
2537
+ * Core action for `issuary repo-digest`: builds the full project-wide view of one
2538
+ * watched repo. For each issue it prefers a fresh compact and falls back to the
2539
+ * raw body, flagging which issues an AI may want to (re)compact.
2540
+ *
2541
+ * Separated from the Commander wiring so it can be tested without spawning a
2542
+ * process. The caller is responsible for opening/closing the {@link Store}.
2543
+ *
2544
+ * @throws {RepoDigestError} When the repo is not watched.
2545
+ */
2546
+ function runRepoDigest(store, fullName) {
2547
+ const { repo, ordered, summary } = loadDigest(store, fullName);
2548
+ const issues = ordered.map((issue) => {
2549
+ const fresh = hasFreshCompact(issue);
2550
+ return {
2551
+ number: issue.number,
2552
+ state: issue.state,
2553
+ stateReason: issue.stateReason,
2554
+ title: issue.title,
2555
+ representation: fresh ? issue.compact : issue.rawBody,
2556
+ compacted: fresh,
2557
+ stale: issue.compact != null && issue.compactStale,
2558
+ refs: store.listIssueRefs(issue.id).map((ref) => ref.target),
2559
+ };
2560
+ });
2561
+ return { repo, summary, issues };
2562
+ }
2563
+ /**
2564
+ * Core action for `issuary repo-digest --headlines`: lists the whole project using
2565
+ * only the cheap `tldr` headline (roughly 20 tokens per issue), falling back to
2566
+ * the title for issues without a tldr.
2567
+ *
2568
+ * @throws {RepoDigestError} When the repo is not watched.
2569
+ */
2570
+ function runRepoDigestHeadlines(store, fullName) {
2571
+ const { repo, ordered, summary } = loadDigest(store, fullName);
2572
+ const headlines = ordered.map((issue) => {
2573
+ const tldr = issue.compactTldr?.trim();
2574
+ const fromTldr = tldr != null && tldr !== "";
2575
+ return {
2576
+ number: issue.number,
2577
+ state: issue.state,
2578
+ headline: fromTldr ? tldr : issue.title,
2579
+ fromTldr,
2580
+ };
2581
+ });
2582
+ return { repo, summary, headlines };
2583
+ }
2584
+ /** Formats the summary header line shared by both human renderings. */
2585
+ function formatSummary(repo, summary) {
2586
+ return (`${repo}: ${summary.total} issues (${summary.open} open, ${summary.closed} closed; ` +
2587
+ `${summary.compacted} compacted, ${summary.staleOrUncompacted} stale/uncompacted)`);
2588
+ }
2589
+ /** Renders the lean headline digest as human-readable text. */
2590
+ function renderHeadlines(result) {
2591
+ const lines = [formatSummary(result.repo, result.summary), ""];
2592
+ for (const h of result.headlines) {
2593
+ lines.push(`#${h.number} [${h.state}] ${h.headline}`);
2594
+ }
2595
+ return lines.join("\n");
2596
+ }
2597
+ /** Renders the full digest as human-readable text. */
2598
+ function renderFull(result) {
2599
+ const lines = [formatSummary(result.repo, result.summary), ""];
2600
+ for (const issue of result.issues) {
2601
+ const reason = issue.stateReason ? `/${issue.stateReason}` : "";
2602
+ const flag = issue.compacted ? "compact" : issue.stale ? "stale, raw" : "uncompacted, raw";
2603
+ const refs = issue.refs.length ? ` refs: ${issue.refs.length}` : "";
2604
+ lines.push(`#${issue.number} [${issue.state}${reason}] (${flag})${refs} ${issue.title}`);
2605
+ if (issue.representation) {
2606
+ lines.push(issue.representation);
2607
+ }
2608
+ lines.push("");
2609
+ }
2610
+ return lines.join("\n").trimEnd();
2611
+ }
2612
+ /**
2613
+ * Builds the `repo-digest` command: a project-wide, AI-optimized view of all
2614
+ * issues in one watched repo.
2615
+ *
2616
+ * @see file://../../docs/compact-format.md
2617
+ */
2618
+ function repoDigestCommand() {
2619
+ return new Command("repo-digest")
2620
+ .description("Consume all issues of one watched repo as a project-wide, AI-optimized view")
2621
+ .argument("<repo>", "watched repo, as owner/name")
2622
+ .option("--headlines", "list every issue using only its cheap tldr headline")
2623
+ .option("--json", "emit machine-readable JSON")
2624
+ .action((repo, options) => {
2625
+ const config = loadConfig({ requireToken: false });
2626
+ const store = openStore(config.dbPath);
2627
+ try {
2628
+ if (options.headlines) {
2629
+ const result = runRepoDigestHeadlines(store, repo);
2630
+ console.log(options.json ? JSON.stringify(result) : renderHeadlines(result));
2631
+ }
2632
+ else {
2633
+ const result = runRepoDigest(store, repo);
2634
+ console.log(options.json ? JSON.stringify(result) : renderFull(result));
2635
+ }
2636
+ }
2637
+ catch (error) {
2638
+ if (error instanceof RepoDigestError) {
2639
+ console.error(error.message);
2640
+ process.exitCode = 1;
2641
+ return;
2642
+ }
2643
+ throw error;
2644
+ }
2645
+ finally {
2646
+ store.close();
2647
+ }
2648
+ });
2649
+ }
2650
+
2651
+ /**
2652
+ * Error thrown by the `show` action for expected, user-facing failures
2653
+ * (malformed target, unwatched repo, missing issue). The CLI prints the message
2654
+ * and exits non-zero.
2655
+ */
2656
+ class ShowCommandError extends Error {
2657
+ constructor(message) {
2658
+ super(message);
2659
+ this.name = "ShowCommandError";
2660
+ }
2661
+ }
2662
+ /** Parses an issue's JSON-encoded labels column into an array. */
2663
+ function parseLabels(labels) {
2664
+ if (!labels) {
2665
+ return [];
2666
+ }
2667
+ try {
2668
+ const parsed = JSON.parse(labels);
2669
+ return Array.isArray(parsed) ? parsed : [];
2670
+ }
2671
+ catch {
2672
+ return [];
2673
+ }
2674
+ }
2675
+ /** Parses an issue's cached `raw_comments` column into an array. */
2676
+ function parseComments(rawComments) {
2677
+ if (!rawComments) {
2678
+ return [];
2679
+ }
2680
+ try {
2681
+ const parsed = JSON.parse(rawComments);
2682
+ return Array.isArray(parsed) ? parsed : [];
2683
+ }
2684
+ catch {
2685
+ return [];
2686
+ }
2687
+ }
2688
+ /**
2689
+ * Resolves the comments for `--raw`: returns the cached `raw_comments` when
2690
+ * present, otherwise fetches them via the client and caches them on the store.
2691
+ */
2692
+ async function resolveComments(store, client, fullName, issue) {
2693
+ if (issue.rawComments !== null) {
2694
+ return parseComments(issue.rawComments);
2695
+ }
2696
+ const comments = await client.getComments(fullName, issue.number);
2697
+ store.setIssueRawComments(issue.repoId, issue.number, JSON.stringify(comments), new Date().toISOString());
2698
+ return comments;
2699
+ }
2700
+ /**
2701
+ * Core action for `issuary show`: validates the target, looks up the repo and
2702
+ * issue, and assembles a {@link ShowResult}. With `--raw`, the full body and
2703
+ * comments are included, fetching and caching comments on demand.
2704
+ *
2705
+ * Separated from the Commander wiring so it can be tested without spawning a
2706
+ * process. The caller owns the {@link Store}; `client` is only used (and so only
2707
+ * required) when `--raw` needs to fetch uncached comments.
2708
+ *
2709
+ * @throws {ShowCommandError} For expected, user-facing failures.
2710
+ */
2711
+ async function runShow(store, target, options, client) {
2712
+ const { fullName, number } = parseTarget(target);
2713
+ const repo = store.getRepoByFullName(fullName);
2714
+ if (!repo) {
2715
+ throw new ShowCommandError(`Repo "${fullName}" is not watched. Add it with \`issuary add ${fullName}\` first.`);
2716
+ }
2717
+ const issue = store.getIssue(repo.id, number);
2718
+ if (!issue) {
2719
+ throw new ShowCommandError(`Issue ${fullName}#${number} is not in the local store. Run \`issuary sync\` first.`);
2720
+ }
2721
+ const result = {
2722
+ repo: fullName,
2723
+ number: issue.number,
2724
+ title: issue.title,
2725
+ state: issue.state,
2726
+ stateReason: issue.stateReason,
2727
+ author: issue.author,
2728
+ labels: parseLabels(issue.labels),
2729
+ commentCount: issue.commentCount,
2730
+ createdAt: issue.createdAt,
2731
+ updatedAt: issue.updatedAt,
2732
+ closedAt: issue.closedAt,
2733
+ compact: issue.compact,
2734
+ compactStale: issue.compactStale,
2735
+ rawBody: issue.rawBody,
2736
+ refs: store.listIssueRefs(issue.id).map((ref) => ref.target),
2737
+ };
2738
+ if (options.raw) {
2739
+ if (!client) {
2740
+ throw new ShowCommandError("A GitHub client is required to fetch comments for --raw.");
2741
+ }
2742
+ result.comments = await resolveComments(store, client, fullName, issue);
2743
+ }
2744
+ return result;
2745
+ }
2746
+ /** Renders a {@link ShowResult} as human-readable text. */
2747
+ function formatShow(result, options) {
2748
+ const lines = [];
2749
+ const reason = result.stateReason ? ` (${result.stateReason})` : "";
2750
+ lines.push(`${result.repo}#${result.number} ${result.title}`);
2751
+ lines.push(`state: ${result.state}${reason}`);
2752
+ lines.push(`author: ${result.author ?? "unknown"}`);
2753
+ lines.push(`labels: ${result.labels.length ? result.labels.join(", ") : "(none)"}`);
2754
+ lines.push(`comments: ${result.commentCount}`);
2755
+ lines.push(`references: ${result.refs.length ? result.refs.join(", ") : "(none)"}`);
2756
+ lines.push(`created: ${result.createdAt}`);
2757
+ lines.push(`updated: ${result.updatedAt}`);
2758
+ if (result.closedAt) {
2759
+ lines.push(`closed: ${result.closedAt}`);
2760
+ }
2761
+ lines.push("");
2762
+ if (options.raw) {
2763
+ lines.push("--- body ---");
2764
+ lines.push(result.rawBody ?? "(no body)");
2765
+ lines.push("");
2766
+ lines.push("--- comments ---");
2767
+ const comments = result.comments ?? [];
2768
+ if (comments.length === 0) {
2769
+ lines.push("(no comments)");
2770
+ }
2771
+ else {
2772
+ for (const comment of comments) {
2773
+ lines.push(`@${comment.author ?? "unknown"} (${comment.created_at}):`);
2774
+ lines.push(comment.body ?? "(empty)");
2775
+ lines.push("");
2776
+ }
2777
+ }
2778
+ }
2779
+ else if (result.compact) {
2780
+ if (result.compactStale) {
2781
+ lines.push("(compact is stale)");
2782
+ }
2783
+ lines.push(result.compact);
2784
+ }
2785
+ else {
2786
+ lines.push(result.rawBody ?? "(no body)");
2787
+ }
2788
+ return lines.join("\n");
2789
+ }
2790
+ /**
2791
+ * Builds the `show` command.
2792
+ *
2793
+ * The default view is local-only and needs no token; `--raw` may fetch comments
2794
+ * on demand and therefore requires a token.
2795
+ */
2796
+ function showCommand() {
2797
+ return new Command("show")
2798
+ .description("Display a single issue from the local store")
2799
+ .argument("<target>", "issue to show, as owner/repo#number")
2800
+ .option("--raw", "show the full raw body and comments (fetches comments on demand)")
2801
+ .option("--json", "emit machine-readable JSON")
2802
+ .action(async (target, options) => {
2803
+ const config = loadConfig({ requireToken: Boolean(options.raw) });
2804
+ const store = openStore(config.dbPath);
2805
+ try {
2806
+ const client = options.raw ? createGitHubClient({ token: config.token, apiUrl: config.apiUrl }) : undefined;
2807
+ const result = await runShow(store, target, options, client);
2808
+ if (options.json) {
2809
+ console.log(JSON.stringify(result));
2810
+ }
2811
+ else {
2812
+ console.log(formatShow(result, options));
2813
+ }
2814
+ }
2815
+ catch (error) {
2816
+ if (error instanceof ShowCommandError) {
2817
+ console.error(error.message);
2818
+ process.exitCode = 1;
2819
+ return;
2820
+ }
2821
+ throw error;
2822
+ }
2823
+ finally {
2824
+ store.close();
2825
+ }
2826
+ });
2827
+ }
2828
+
2829
+ /**
2830
+ * The installable agent skill for issuary: a `SKILL.md` document an AI coding agent
2831
+ * (for example Claude Code) can discover and follow to operate issuary.
2832
+ *
2833
+ * The skill is intentionally THIN. It states what issuary is and when to reach for
2834
+ * it, then defers to `issuary protocol` and `issuary --help` for the exact contract and
2835
+ * flags so it can never drift from the actual CLI. The compaction contract itself
2836
+ * is reused from {@link COMPACTION_PROTOCOL} rather than restated here.
2837
+ */
2838
+ /** The skill name, used as the frontmatter `name` and the install directory. */
2839
+ const SKILL_NAME = "issuary";
2840
+ /** The skill description, used as the frontmatter `description`. */
2841
+ const SKILL_DESCRIPTION = "Monitor GitHub issues across repos and produce or consume AI-written compactions (structured summaries) of them.";
2842
+ /**
2843
+ * The full `SKILL.md` document text, including YAML frontmatter and body.
2844
+ *
2845
+ * Asserted on by tests via substrings (the core loop commands) so the skill
2846
+ * cannot silently drift away from the CLI it describes.
2847
+ */
2848
+ const SKILL_MD = `---
2849
+ name: ${SKILL_NAME}
2850
+ description: ${SKILL_DESCRIPTION}
2851
+ ---
2852
+
2853
+ # issuary
2854
+
2855
+ \`issuary\` is a CLI (npm \`issuary\`, binary \`issuary\`) that monitors GitHub
2856
+ issues across many repositories. It keeps a local mirror, detects what changed,
2857
+ and exposes a compaction layer: structured, AI-written summaries of issues that
2858
+ save context tokens when you later need to reason over a project's issues.
2859
+
2860
+ The tool never calls an LLM. You are the CPU of the compaction: issuary stores raw
2861
+ content, tells you what needs compacting, and takes your summary back.
2862
+
2863
+ ## When to use this
2864
+
2865
+ Reach for issuary when you need to understand or triage GitHub issues across one or
2866
+ more repos without burning context on raw bodies and long comment threads, or
2867
+ when you are asked to keep a project's issue knowledge compacted and
2868
+ current. If a compact exists and is fresh, read it instead of refetching the
2869
+ issue.
2870
+
2871
+ ## issuary vs GitHub's MCP
2872
+
2873
+ These are complementary, not competing. GitHub's MCP server gives live, raw
2874
+ access to issues: use it when you need the current, unfiltered state of an issue
2875
+ or its comments. issuary is NOT another raw-issue reader. Its value is the
2876
+ persistent, compacted MEMORY of issues plus the cross-repo digest of what changed
2877
+ since you last looked. Use issuary for the distilled memory and the "what changed"
2878
+ digest, and use GitHub's MCP for live, raw issue access.
2879
+
2880
+ ## Core loop
2881
+
2882
+ 1. Read the contract first: run \`issuary protocol\` (add \`--json\` for the machine
2883
+ form). It defines what \`compact\` and \`compact_stale\` mean and exactly when to
2884
+ reuse versus regenerate a compact. Follow it.
2885
+ 2. Find work: \`issuary compact list --pending --json\` lists issues that have no
2886
+ compact or whose compact went stale. To read the existing memory with filters
2887
+ (state, repo, label, author, since, search, compaction), use \`issuary issues
2888
+ --json\`; it returns the matched issues with their compacts and flags.
2889
+ 3. Read the raw issue: \`issuary show <owner/repo>#<n> --raw --json\` returns the raw
2890
+ body and comments to summarize.
2891
+ 4. Write a compact in the canonical format (frontmatter copied from the API, body
2892
+ written by you). The format and field rules come from \`issuary protocol\`.
2893
+ 5. Persist it: \`issuary compact set <owner/repo>#<n> --from-file <file>\`. Persisting
2894
+ clears the stale flag.
2895
+ 6. Re-compact when stale: a new comment marks a compact stale, so repeat the loop
2896
+ for anything \`issuary compact list --pending\` reports.
2897
+
2898
+ ## Flags and exact contract
2899
+
2900
+ Do not hardcode flags from memory. Consult \`issuary --help\` (and \`issuary <command>
2901
+ --help\`) for the current commands and options, and \`issuary protocol\` for the exact
2902
+ compaction contract and canonical compact format. Those are the source of truth;
2903
+ this skill only points you at them.
2904
+
2905
+ ## The compaction contract (for reference)
2906
+
2907
+ ${COMPACTION_PROTOCOL}
2908
+ `;
2909
+
2910
+ /** Raised for invalid `skill` command input, such as an unknown `--format`. */
2911
+ class SkillCommandError extends Error {
2912
+ constructor(message) {
2913
+ super(message);
2914
+ this.name = "SkillCommandError";
2915
+ }
2916
+ }
2917
+ /** The install formats the `skill` command accepts. */
2918
+ const SKILL_FORMATS = ["claude", "agents"];
2919
+ /** The default install format, kept as `claude` for back-compat. */
2920
+ const DEFAULT_SKILL_FORMAT = "claude";
2921
+ /** The marker that opens issuary's managed section inside an `AGENTS.md`. */
2922
+ const AGENTS_START_MARKER = "<!-- issuary:start -->";
2923
+ /** The marker that closes issuary's managed section inside an `AGENTS.md`. */
2924
+ const AGENTS_END_MARKER = "<!-- issuary:end -->";
2925
+ /**
2926
+ * Validates and normalizes the requested format.
2927
+ *
2928
+ * @param format - The raw `--format` value, or undefined for the default.
2929
+ * @returns The validated {@link SkillFormat}.
2930
+ * @throws If the value is not one of {@link SKILL_FORMATS}.
2931
+ */
2932
+ function resolveFormat(format) {
2933
+ const value = (format ?? "").trim() || DEFAULT_SKILL_FORMAT;
2934
+ if (!SKILL_FORMATS.includes(value)) {
2935
+ throw new SkillCommandError(`Unknown skill format "${value}". Expected one of: ${SKILL_FORMATS.join(", ")}.`);
2936
+ }
2937
+ return value;
2938
+ }
2939
+ /**
2940
+ * Resolves the skills root directory for the `claude` format.
2941
+ *
2942
+ * Precedence: explicit `--dir`, then the `CLAUDE_SKILLS_DIR` environment
2943
+ * override, then the default `~/.claude/skills`.
2944
+ */
2945
+ function resolveSkillsDir(dir) {
2946
+ const fromEnv = (process.env.CLAUDE_SKILLS_DIR ?? "").trim();
2947
+ return (dir ?? "").trim() || fromEnv || join(homedir(), ".claude", "skills");
2948
+ }
2949
+ /**
2950
+ * Resolves the absolute path the skill is (or would be) written to for a format.
2951
+ *
2952
+ * - `claude`: `<skillsDir>/issuary/SKILL.md` (see {@link resolveSkillsDir}).
2953
+ * - `agents`: `<dir or cwd>/AGENTS.md`.
2954
+ *
2955
+ * @param format - The install format.
2956
+ * @param dir - Optional directory override.
2957
+ * @returns The absolute path for the selected format.
2958
+ */
2959
+ function resolveSkillPath(format, dir) {
2960
+ if (format === "agents") {
2961
+ return join((dir ?? "").trim() || process.cwd(), "AGENTS.md");
2962
+ }
2963
+ return join(resolveSkillsDir(dir), SKILL_NAME, "SKILL.md");
2964
+ }
2965
+ /**
2966
+ * Builds the delimited issuary section written into an `AGENTS.md`.
2967
+ *
2968
+ * The content is the same neutral skill body, wrapped in start/end markers so it
2969
+ * can be replaced in place on a re-install.
2970
+ */
2971
+ function buildAgentsSection() {
2972
+ return `${AGENTS_START_MARKER}\n${SKILL_MD}\n${AGENTS_END_MARKER}`;
2973
+ }
2974
+ /**
2975
+ * Inserts or replaces issuary's delimited section in an existing `AGENTS.md` body.
2976
+ *
2977
+ * If the markers are present, only the content between them is replaced (leaving
2978
+ * the rest untouched). If they are absent, the section is appended. The
2979
+ * operation is idempotent: running it twice yields exactly one issuary section.
2980
+ *
2981
+ * Assumes well-formed markers: a matching start before end. A file that was
2982
+ * hand-corrupted (a start without an end, or end before start) is treated as
2983
+ * having no managed section, so the next run appends a fresh one rather than
2984
+ * trying to repair the damage.
2985
+ *
2986
+ * @param existing - The current `AGENTS.md` content (empty string if new).
2987
+ * @returns The updated file content.
2988
+ */
2989
+ function upsertAgentsSection(existing) {
2990
+ const section = buildAgentsSection();
2991
+ const start = existing.indexOf(AGENTS_START_MARKER);
2992
+ const end = existing.indexOf(AGENTS_END_MARKER);
2993
+ if (start !== -1 && end !== -1 && end > start) {
2994
+ const before = existing.slice(0, start);
2995
+ const after = existing.slice(end + AGENTS_END_MARKER.length);
2996
+ return `${before}${section}${after}`;
2997
+ }
2998
+ if (existing.trim().length === 0) {
2999
+ return `${section}\n`;
3000
+ }
3001
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
3002
+ return `${existing}${separator}${section}\n`;
3003
+ }
3004
+ /**
3005
+ * Core action for `issuary skill`.
3006
+ *
3007
+ * Separated from the Commander wiring so it can be tested without spawning a
3008
+ * process. Behavior by option:
3009
+ *
3010
+ * - default: returns the neutral skill document text.
3011
+ * - `json`: returns the structured {@link SkillJson} payload (includes `format`).
3012
+ * - `install`: writes the skill for the selected format and returns a
3013
+ * {@link SkillInstallResult}. `claude` writes a `SKILL.md`; `agents` inserts or
3014
+ * replaces a delimited, idempotent section in an `AGENTS.md`.
3015
+ */
3016
+ async function runSkill(options = {}) {
3017
+ const format = resolveFormat(options.format);
3018
+ if (options.install) {
3019
+ const path = resolveSkillPath(format, options.dir);
3020
+ const existed = await fileExists(path);
3021
+ await mkdir(dirname(path), { recursive: true });
3022
+ if (format === "agents") {
3023
+ const existing = existed ? await readFile(path, "utf8") : "";
3024
+ await writeFile(path, upsertAgentsSection(existing), "utf8");
3025
+ }
3026
+ else {
3027
+ await writeFile(path, SKILL_MD, "utf8");
3028
+ }
3029
+ return { format, path, overwrote: existed };
3030
+ }
3031
+ if (options.json) {
3032
+ const json = {
3033
+ name: SKILL_NAME,
3034
+ description: SKILL_DESCRIPTION,
3035
+ path: resolveSkillPath(format, options.dir),
3036
+ content: SKILL_MD,
3037
+ format,
3038
+ };
3039
+ return json;
3040
+ }
3041
+ return SKILL_MD;
3042
+ }
3043
+ /** Returns whether a file already exists at the given path. */
3044
+ async function fileExists(path) {
3045
+ try {
3046
+ await access(path);
3047
+ return true;
3048
+ }
3049
+ catch {
3050
+ return false;
3051
+ }
3052
+ }
3053
+ /**
3054
+ * Builds the `skill` command. It emits issuary's neutral agent skill, or installs
3055
+ * it for an AI agent: a Claude Code `SKILL.md` (`--format claude`, the default)
3056
+ * or a delimited section in a project `AGENTS.md` (`--format agents`). Printing
3057
+ * with no `--install` is the universal path: any agent can paste it into a system
3058
+ * prompt or rules file.
3059
+ *
3060
+ * @see file://../skill/skill.ts
3061
+ */
3062
+ function skillCommand() {
3063
+ return new Command("skill")
3064
+ .description("Print issuary's agent skill, or install it for an AI agent (claude SKILL.md or AGENTS.md)")
3065
+ .option("--install", "write the skill to disk instead of printing it")
3066
+ .option("--format <format>", `install format: ${SKILL_FORMATS.join("|")} (default ${DEFAULT_SKILL_FORMAT})`)
3067
+ .option("--dir <path>", "install directory (claude: skills root ~/.claude/skills; agents: dir holding AGENTS.md)")
3068
+ .option("--json", "emit machine-readable JSON ({ name, description, path, content, format })")
3069
+ .action(async (options) => {
3070
+ const result = await runSkill(options);
3071
+ if (options.install) {
3072
+ const { format, path, overwrote } = result;
3073
+ const verb = overwrote ? "Updated" : "Wrote";
3074
+ console.log(`${verb} issuary skill (${format}) at ${path}`);
3075
+ return;
3076
+ }
3077
+ if (options.json) {
3078
+ console.log(JSON.stringify(result));
3079
+ return;
3080
+ }
3081
+ console.log(result);
3082
+ });
3083
+ }
3084
+
3085
+ /**
3086
+ * Parser for explicit issue/PR cross-references found in issue bodies and
3087
+ * comments. This is deliberately literal: it recognizes only the textual forms
3088
+ * GitHub itself links (`#123`, `owner/repo#123`, and `GH-123`-style numeric
3089
+ * references), never semantic or similarity-based relationships. Anything beyond
3090
+ * a literal token match is out of scope (see CLAUDE.md, princípio 7 / relacionamento).
3091
+ */
3092
+ /**
3093
+ * Matches a cross-repo reference like `owner/repo#123`. Owner and repo follow
3094
+ * GitHub's allowed characters (alphanumerics, `-`, `_`, `.`). The leading `(?<![\w-])`
3095
+ * lookbehind keeps the owner from starting in the middle of another word so a URL
3096
+ * path segment such as `foo/bar/baz#1` does not match the wrong slice.
3097
+ */
3098
+ const CROSS_REPO = /(?<![\w./-])([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#(\d+)/g;
3099
+ /**
3100
+ * Matches a same-repo reference like `#123`. The leading lookbehind rejects a
3101
+ * `#` that is glued to a word character (e.g. an anchor in `page#section` or the
3102
+ * `repo#123` already handled by {@link CROSS_REPO}), so only standalone `#123`
3103
+ * tokens are captured.
3104
+ */
3105
+ const SAME_REPO = /(?<![\w/#])#(\d+)/g;
3106
+ /**
3107
+ * Matches an explicit `PR #123` / `pull request #123` / `pull/123` phrasing. The
3108
+ * issue/PR number is captured; the surrounding wording is normalized away to the
3109
+ * canonical `#123` form.
3110
+ */
3111
+ const PR_PHRASE = /\b(?:pull request|pull|pr)\s*(?:#|\/)\s*(\d+)/gi;
3112
+ /**
3113
+ * Strips fenced code blocks (```...```) and inline code spans (`...`) from the
3114
+ * text so references that are clearly shown as code (e.g. a literal `#123` in a
3115
+ * snippet) are not harvested. Autolink URLs are left intact: the regexes above
3116
+ * already avoid matching inside `owner/repo/...` URL paths via lookbehinds.
3117
+ */
3118
+ function stripCode(text) {
3119
+ return text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ");
3120
+ }
3121
+ /**
3122
+ * Extracts the explicit issue/PR references from a text blob, returning a deduped
3123
+ * list of normalized literal targets in first-seen order.
3124
+ *
3125
+ * Recognized forms and their normalized output:
3126
+ * - `#123` -> `"#123"` (same-repo issue or PR)
3127
+ * - `owner/repo#123` -> `"owner/repo#123"` (cross-repo)
3128
+ * - `PR #123`, `pull request #123`, `pull/123` -> `"#123"`
3129
+ *
3130
+ * References inside fenced or inline code are ignored. The issue's own number is
3131
+ * ignored when `selfNumber` is provided so an issue never references itself.
3132
+ *
3133
+ * @param text - The raw markdown body (or comment) to scan. `null`/empty yields `[]`.
3134
+ * @param selfNumber - The number of the issue being parsed, to drop self-references.
3135
+ * @returns Deduped, normalized literal targets in first-seen order.
3136
+ */
3137
+ function parseRefs(text, selfNumber) {
3138
+ if (!text) {
3139
+ return [];
3140
+ }
3141
+ const cleaned = stripCode(text);
3142
+ const seen = new Set();
3143
+ const targets = [];
3144
+ const add = (target, number) => {
3145
+ if (selfNumber !== undefined && number === selfNumber && !target.includes("/")) {
3146
+ // Self-reference: only drop the bare same-repo form; a cross-repo token
3147
+ // that happens to share the number still refers to a different repo.
3148
+ return;
3149
+ }
3150
+ if (!seen.has(target)) {
3151
+ seen.add(target);
3152
+ targets.push(target);
3153
+ }
3154
+ };
3155
+ for (const match of cleaned.matchAll(CROSS_REPO)) {
3156
+ add(`${match[1]}#${match[2]}`, Number(match[2]));
3157
+ }
3158
+ for (const match of cleaned.matchAll(PR_PHRASE)) {
3159
+ add(`#${match[1]}`, Number(match[1]));
3160
+ }
3161
+ for (const match of cleaned.matchAll(SAME_REPO)) {
3162
+ add(`#${match[1]}`, Number(match[1]));
3163
+ }
3164
+ return targets;
3165
+ }
3166
+
3167
+ /**
3168
+ * Serializes a normalized issue's labels into the JSON shape the store holds,
3169
+ * or null when there are none.
3170
+ */
3171
+ function labelsJson(issue) {
3172
+ return issue.labels.length > 0 ? JSON.stringify(issue.labels) : null;
3173
+ }
3174
+ /**
3175
+ * Detects the change events for one incoming issue relative to its stored
3176
+ * counterpart. Returns the event types to emit. An empty array means no change
3177
+ * worth recording (including a brand-new issue that arrives already closed).
3178
+ */
3179
+ function detectEvents(stored, incoming) {
3180
+ if (!stored) {
3181
+ // Brand-new issue. Only emit `opened` when it is actually open; an issue
3182
+ // that arrives already closed is stored silently (no historical event).
3183
+ return incoming.state === "open" ? ["opened"] : [];
3184
+ }
3185
+ const events = [];
3186
+ if (stored.state === "open" && incoming.state === "closed") {
3187
+ events.push("closed");
3188
+ }
3189
+ else if (stored.state === "closed" && incoming.state === "open") {
3190
+ events.push("reopened");
3191
+ }
3192
+ if (incoming.comment_count > stored.commentCount) {
3193
+ events.push(incoming.state === "closed" ? "closed_commented" : "commented");
3194
+ }
3195
+ return events;
3196
+ }
3197
+ /**
3198
+ * Returns true when an existing issue's tracked fields changed in a way that
3199
+ * should mark its compact stale (state, comment count, or update timestamp).
3200
+ */
3201
+ function hasMeaningfulChange(stored, incoming) {
3202
+ return (stored.state !== incoming.state ||
3203
+ stored.commentCount !== incoming.comment_count ||
3204
+ stored.updatedAt !== incoming.updated_at);
3205
+ }
3206
+ /**
3207
+ * Syncs a single repo: lists its issues conditionally, diffs each against the
3208
+ * local mirror, upserts metadata and raw bodies, records events, marks compacts
3209
+ * stale on change, and updates the repo's sync bookkeeping. All writes for the
3210
+ * repo run in one transaction. Comments are never fetched here.
3211
+ */
3212
+ async function syncRepo(store, client, repo, now) {
3213
+ const result = await client.listIssues(repo.fullName, {
3214
+ since: repo.lastSyncedAt ?? undefined,
3215
+ etag: repo.etag ?? undefined,
3216
+ });
3217
+ const base = {
3218
+ repo: repo.fullName,
3219
+ notModified: false,
3220
+ opened: 0,
3221
+ closed: 0,
3222
+ reopened: 0,
3223
+ commented: 0,
3224
+ processed: 0,
3225
+ error: null,
3226
+ };
3227
+ if (result.status === "notModified") {
3228
+ // 304: nothing changed. Do not touch anything and do not spend further
3229
+ // calls. We intentionally leave last_synced_at as-is.
3230
+ return { ...base, notModified: true };
3231
+ }
3232
+ const detectedAt = now();
3233
+ const apply = store.db.transaction(() => {
3234
+ const summary = { ...base };
3235
+ for (const incoming of result.issues) {
3236
+ const stored = store.getIssue(repo.id, incoming.number);
3237
+ const events = detectEvents(stored, incoming);
3238
+ const upserted = store.upsertIssue({
3239
+ repoId: repo.id,
3240
+ number: incoming.number,
3241
+ title: incoming.title,
3242
+ state: incoming.state,
3243
+ stateReason: incoming.state_reason,
3244
+ author: incoming.author,
3245
+ labels: labelsJson(incoming),
3246
+ createdAt: incoming.created_at,
3247
+ updatedAt: incoming.updated_at,
3248
+ closedAt: incoming.closed_at,
3249
+ commentCount: incoming.comment_count,
3250
+ rawBody: incoming.body,
3251
+ // Preserve raw comments and compact across syncs: upsert overwrites
3252
+ // every column, so carry the stored values forward explicitly.
3253
+ rawComments: stored?.rawComments ?? null,
3254
+ rawFetchedAt: stored?.rawFetchedAt ?? null,
3255
+ compact: stored?.compact ?? null,
3256
+ compactTldr: stored?.compactTldr ?? null,
3257
+ compactStale: stored?.compactStale ?? false,
3258
+ compactedAt: stored?.compactedAt ?? null,
3259
+ });
3260
+ // Extract explicit references from the raw body and persist them. Runs in
3261
+ // the same per-repo transaction; idempotent across syncs (replace clears
3262
+ // first), so a re-synced body never accumulates stale refs.
3263
+ store.replaceIssueRefs(upserted.id, parseRefs(incoming.body, incoming.number));
3264
+ for (const type of events) {
3265
+ store.insertEvent(upserted.id, type, detectedAt);
3266
+ if (type === "opened")
3267
+ summary.opened += 1;
3268
+ else if (type === "closed")
3269
+ summary.closed += 1;
3270
+ else if (type === "reopened")
3271
+ summary.reopened += 1;
3272
+ else
3273
+ summary.commented += 1;
3274
+ }
3275
+ // Mark an existing, already-compacted issue stale when it changed.
3276
+ if (stored && stored.compact !== null && hasMeaningfulChange(stored, incoming)) {
3277
+ store.setCompactStale(upserted.id, true);
3278
+ }
3279
+ summary.processed += 1;
3280
+ }
3281
+ store.updateRepoSync(repo.id, { lastSyncedAt: detectedAt, etag: result.etag });
3282
+ return summary;
3283
+ });
3284
+ return apply();
3285
+ }
3286
+ /**
3287
+ * Runs the sync engine over the given repos in sequence, returning a structured
3288
+ * summary. Each repo is synced independently in its own transaction.
3289
+ *
3290
+ * A failure in one repo (network, 404/private, ...) is recorded in that repo's
3291
+ * result and does not abort the run: the remaining repos are still synced. The
3292
+ * per-repo transaction means a failed repo never advances its etag or
3293
+ * `last_synced_at`, so a later run retries it from where it left off.
3294
+ *
3295
+ * @param deps - Injected store, GitHub client, and optional clock.
3296
+ * @param repos - The repos to sync (typically the active set, or a single repo).
3297
+ * @returns The aggregate {@link SyncResult}.
3298
+ */
3299
+ async function runSync(deps, repos) {
3300
+ const now = deps.now ?? (() => new Date().toISOString());
3301
+ const results = [];
3302
+ for (const repo of repos) {
3303
+ try {
3304
+ results.push(await syncRepo(deps.store, deps.client, repo, now));
3305
+ }
3306
+ catch (error) {
3307
+ // Isolate the failure: record it and keep going with the other repos.
3308
+ // No write happened for this repo (the listing threw before the
3309
+ // transaction, or the transaction rolled back), so its etag and
3310
+ // last_synced_at are untouched and the next run retries it.
3311
+ results.push({
3312
+ repo: repo.fullName,
3313
+ notModified: false,
3314
+ opened: 0,
3315
+ closed: 0,
3316
+ reopened: 0,
3317
+ commented: 0,
3318
+ processed: 0,
3319
+ error: error instanceof Error ? error.message : String(error),
3320
+ });
3321
+ }
3322
+ }
3323
+ return { repos: results };
3324
+ }
3325
+
3326
+ /**
3327
+ * Error thrown by the `sync` action for expected, user-facing failures (no repos
3328
+ * watched, the named repo is not watched). The CLI prints the message and exits
3329
+ * non-zero.
3330
+ */
3331
+ class SyncCommandError extends Error {
3332
+ constructor(message) {
3333
+ super(message);
3334
+ this.name = "SyncCommandError";
3335
+ }
3336
+ }
3337
+ /**
3338
+ * Resolves which repos to sync: the single named one (when `repo` is given), or
3339
+ * every active repo otherwise.
3340
+ *
3341
+ * @throws {SyncCommandError} When the named repo is not watched, or when no
3342
+ * active repos exist.
3343
+ */
3344
+ function resolveRepos(store, repo) {
3345
+ if (repo) {
3346
+ const found = store.getRepoByFullName(repo);
3347
+ if (!found) {
3348
+ throw new SyncCommandError(`Repo "${repo}" is not watched. Add it with \`issuary add ${repo}\` first.`);
3349
+ }
3350
+ return [found];
3351
+ }
3352
+ const active = store.listRepos({ activeOnly: true });
3353
+ if (active.length === 0) {
3354
+ throw new SyncCommandError("No active repos to sync. Add one with `issuary add <owner/repo>`.");
3355
+ }
3356
+ return active;
3357
+ }
3358
+ /**
3359
+ * Core action for `issuary sync [repo]`: resolves the target repos and runs the
3360
+ * diff engine over them. Separated from the Commander wiring so it can be tested
3361
+ * without spawning a process. The caller owns the {@link Store} and client.
3362
+ *
3363
+ * @throws {SyncCommandError} For expected, user-facing failures.
3364
+ */
3365
+ async function runSyncCommand(store, client, repo) {
3366
+ const repos = resolveRepos(store, repo);
3367
+ return runSync({ store, client }, repos);
3368
+ }
3369
+ /**
3370
+ * True when a repo result carries noteworthy activity worth reporting even in
3371
+ * quiet mode: a failure, or any recorded event (opened/closed/reopened/new
3372
+ * comments). A 304, a no-op incremental sync, and a silent baseline import all
3373
+ * count as no activity.
3374
+ */
3375
+ function hasActivity(r) {
3376
+ return r.error !== null || r.opened > 0 || r.closed > 0 || r.reopened > 0 || r.commented > 0;
3377
+ }
3378
+ /** Renders a single repo result as one human-readable line. */
3379
+ function formatRepoLine(r) {
3380
+ if (r.error) {
3381
+ return `${r.repo}: failed (${r.error})`;
3382
+ }
3383
+ if (r.notModified) {
3384
+ return `${r.repo}: unchanged`;
3385
+ }
3386
+ const parts = [];
3387
+ if (r.opened > 0)
3388
+ parts.push(`${r.opened} new`);
3389
+ if (r.closed > 0)
3390
+ parts.push(`${r.closed} closed`);
3391
+ if (r.reopened > 0)
3392
+ parts.push(`${r.reopened} reopened`);
3393
+ if (r.commented > 0)
3394
+ parts.push(`${r.commented} new comments`);
3395
+ if (parts.length > 0) {
3396
+ return `${r.repo}: ${parts.join(", ")}`;
3397
+ }
3398
+ if (r.processed > 0) {
3399
+ // Issues were fetched and mirrored but produced no noteworthy events. This
3400
+ // is the common case on a first sync of a repo whose issues are all closed
3401
+ // (closed issues are imported as a silent baseline). Saying "no changes"
3402
+ // here would wrongly imply nothing was stored.
3403
+ return `${r.repo}: no new activity (${r.processed} issues synced)`;
3404
+ }
3405
+ return `${r.repo}: no changes`;
3406
+ }
3407
+ /** Renders a sync result as grouped, human-readable lines. */
3408
+ function formatSyncResult(result) {
3409
+ return result.repos.map(formatRepoLine).join("\n");
3410
+ }
3411
+ /**
3412
+ * Quiet rendering for unattended/cron runs. Returns the empty string when no
3413
+ * repo had any activity (no events and no errors), so a scheduled run is silent
3414
+ * on a quiet cycle. When something happened it returns only the repos that had
3415
+ * activity (their event lines and any failures), never the "unchanged" or
3416
+ * "no changes" noise.
3417
+ */
3418
+ function formatSyncResultQuiet(result) {
3419
+ return result.repos.filter(hasActivity).map(formatRepoLine).join("\n");
3420
+ }
3421
+ /**
3422
+ * Process exit code for a sync result: `1` when any repo failed to sync (a
3423
+ * non-null `error`), `0` otherwise. Lets a scheduler/monitor detect failures
3424
+ * even when output is suppressed. Kept as a pure helper so the exit-code
3425
+ * contract can be tested without spawning a process or calling `process.exit`.
3426
+ */
3427
+ function syncExitCode(result) {
3428
+ return result.repos.some((r) => r.error !== null) ? 1 : 0;
3429
+ }
3430
+ /**
3431
+ * Builds the `sync` command.
3432
+ *
3433
+ * `issuary sync [repo]` hits the GitHub API, so a token is required. The action is
3434
+ * kept thin: it wires config, store, and client, then delegates to
3435
+ * {@link runSyncCommand}.
3436
+ */
3437
+ function syncCommand() {
3438
+ return new Command("sync")
3439
+ .description("Fetch issue updates for watched repos and record what changed")
3440
+ .argument("[repo]", "limit the sync to a single watched repo, as owner/repo")
3441
+ .option("--json", "emit machine-readable JSON")
3442
+ .option("--quiet", "print nothing when there was no activity (errors are always printed); for cron")
3443
+ .action(async (repo, options) => {
3444
+ const config = loadConfig();
3445
+ const store = openStore(config.dbPath);
3446
+ const client = createGitHubClient({ token: config.token, apiUrl: config.apiUrl });
3447
+ try {
3448
+ const result = await runSyncCommand(store, client, repo);
3449
+ if (options.json) {
3450
+ // --json always emits the full result; --quiet does not change it.
3451
+ console.log(JSON.stringify(result));
3452
+ }
3453
+ else if (options.quiet) {
3454
+ // Print only when something happened; stay silent on a no-op cycle.
3455
+ const text = formatSyncResultQuiet(result);
3456
+ if (text !== "") {
3457
+ console.log(text);
3458
+ }
3459
+ }
3460
+ else {
3461
+ console.log(formatSyncResult(result));
3462
+ }
3463
+ // Signal failures to the scheduler/monitor even when output is quiet.
3464
+ const code = syncExitCode(result);
3465
+ if (code !== 0) {
3466
+ process.exitCode = code;
3467
+ }
3468
+ }
3469
+ catch (error) {
3470
+ if (error instanceof SyncCommandError) {
3471
+ console.error(error.message);
3472
+ process.exitCode = 1;
3473
+ return;
3474
+ }
3475
+ throw error;
3476
+ }
3477
+ finally {
3478
+ store.close();
3479
+ }
3480
+ });
3481
+ }
3482
+
3483
+ /**
3484
+ * Wires every subcommand onto the root program.
3485
+ *
3486
+ * Each feature task adds its command module under `src/commands/` and registers
3487
+ * it here. This is the single, intentional merge point for new commands.
3488
+ */
3489
+ function registerCommands(program) {
3490
+ // Commands are added by feature tasks (add, remove, list, sync, digest,
3491
+ // repo-digest, show, compact, protocol). See .local/TASKS.md.
3492
+ program.addCommand(addCommand());
3493
+ program.addCommand(removeCommand());
3494
+ program.addCommand(listCommand());
3495
+ program.addCommand(compactCommand());
3496
+ program.addCommand(protocolCommand());
3497
+ program.addCommand(digestCommand());
3498
+ program.addCommand(repoDigestCommand());
3499
+ program.addCommand(issuesCommand());
3500
+ program.addCommand(showCommand());
3501
+ program.addCommand(skillCommand());
3502
+ program.addCommand(syncCommand());
3503
+ program.addCommand(loginCommand());
3504
+ program.addCommand(logoutCommand());
3505
+ }
3506
+
3507
+ const require$1 = createRequire(import.meta.url);
3508
+ const pkg = require$1("../package.json");
3509
+ /**
3510
+ * Builds the root `issuary` command with all subcommands registered.
3511
+ * Kept separate from {@link file://./cli.ts} so it can be exercised in tests
3512
+ * without spawning a process.
3513
+ */
3514
+ function createProgram() {
3515
+ const program = new Command();
3516
+ program.name("issuary").description(pkg.description).version(pkg.version);
3517
+ registerCommands(program);
3518
+ program.addHelpText("after", "\nAI consumers: run `issuary protocol` for the compaction contract (how to use, when to\nrecompact, and how to persist a compact). Add `--json` for the machine-readable form.\nAI agents: run `issuary skill --install` to install issuary as a discoverable agent skill.");
3519
+ return program;
3520
+ }
3521
+
3522
+ try {
3523
+ await createProgram().parseAsync(process.argv);
3524
+ }
3525
+ catch (error) {
3526
+ process.exitCode = handleCliError(error);
3527
+ }
3528
+ //# sourceMappingURL=cli.js.map