@jeffober/thatch 0.1.34 → 0.1.36

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.
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Watchers: event-driven notifications from external sources, delivered into
3
+ * a live opencode session as injected prompts.
4
+ *
5
+ * The motivating case: the model registers a watcher on a GitHub PR, and
6
+ * thatch polls the PR in the background. When something the watcher cares
7
+ * about happens (new comments, commits, CI results), the plugin prompts the
8
+ * session with a synthetic notification part - the same delivery mechanism
9
+ * opencode itself uses for background task completions.
10
+ *
11
+ * Lifetime is deliberately process-scoped. The registry lives in plugin
12
+ * memory, never in SQLite: opencode loads thatch in-process, so the plugin
13
+ * process is the natural owner of its watchers. This makes multi-instance
14
+ * ownership structurally impossible (each process polls only what it
15
+ * registered), leaves no orphan data behind, and matches the extraction
16
+ * pipeline's opencode path, which is also in-memory. A crashed or restarted
17
+ * opencode loses its watchers - acceptable, because the session that created
18
+ * them lost its conversational context too, and a delivered notification
19
+ * into a session that no longer knows why it is being watched is worse than
20
+ * a lost watch.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * The event vocabulary for the github-pr source. Each type maps to one diff
29
+ * against the watcher's last-seen PR state. Notifications carry pointer data
30
+ * (author, URL, counts) rather than content, so text authored by strangers
31
+ * on GitHub never enters the model's context directly - the model fetches
32
+ * details on demand with the gh CLI.
33
+ */
34
+ export type WatcherEventType =
35
+ | "pr_comment"
36
+ | "pr_review_comment"
37
+ | "pr_review_reply"
38
+ | "pr_commit"
39
+ | "pr_status"
40
+ | "pr_description"
41
+ | "pr_ci";
42
+
43
+ export const WATCHER_EVENT_TYPES: WatcherEventType[] = [
44
+ "pr_comment",
45
+ "pr_review_comment",
46
+ "pr_review_reply",
47
+ "pr_commit",
48
+ "pr_status",
49
+ "pr_description",
50
+ "pr_ci",
51
+ ];
52
+
53
+ /** A single detected change, ready for delivery. Pointer data only. */
54
+ export interface WatcherEvent {
55
+ type: WatcherEventType;
56
+ /** Human-readable, content-free summary: who/what/where, never body text. */
57
+ summary: string;
58
+ url: string;
59
+ }
60
+
61
+ /** A comment seen on the PR. Only identifying fields - never comment text. */
62
+ export interface CommentRef {
63
+ id: number;
64
+ author: string;
65
+ url: string;
66
+ /** True when the comment is a reply to another review comment. */
67
+ isReply: boolean;
68
+ }
69
+
70
+ /**
71
+ * The last-seen state of a PR, captured at watch creation and updated each
72
+ * poll. Comment arrays are kept whole so each diff can name individual new
73
+ * comments; they are capped at the fetch page size (100).
74
+ */
75
+ export interface PrState {
76
+ headSha: string;
77
+ state: string;
78
+ merged: boolean;
79
+ title: string;
80
+ /** SHA-256 of the PR body text - detects description edits without storing the body. */
81
+ bodySha: string;
82
+ lastIssueCommentId: number;
83
+ lastReviewCommentId: number;
84
+ issueComments: CommentRef[];
85
+ reviewComments: CommentRef[];
86
+ /** Check runs on the head SHA, keyed by check run id. */
87
+ checkRuns: Record<string, { name: string; status: string; conclusion: string | null; url: string | null }>;
88
+ }
89
+
90
+ export interface Watcher {
91
+ id: string;
92
+ sessionID: string;
93
+ /** owner/repo */
94
+ repo: string;
95
+ pr: number;
96
+ events: WatcherEventType[];
97
+ /** Epoch ms. When now > expiresAt the watcher is silently dropped. */
98
+ expiresAt: number;
99
+ createdAt: number;
100
+ state: PrState;
101
+ }
102
+
103
+ /** Runs one GitHub REST API path via the gh CLI and parses the JSON response. */
104
+ export type GhRunner = (apiPath: string) => Promise<unknown>;
105
+
106
+ export interface WatcherRegistryOptions {
107
+ /** Delivers a batch of events for a session. Injected so tests never spawn. */
108
+ deliver: (sessionID: string, events: WatcherEvent[]) => Promise<void>;
109
+ /**
110
+ * Gate for delivery. The plugin passes a predicate that returns true only
111
+ * when the session can accept a proactive prompt right now (idle, not
112
+ * compacting). Undeliverable events stay pending and retry on later cycles
113
+ * or when the session next goes idle.
114
+ */
115
+ canDeliver: (sessionID: string) => boolean;
116
+ ghRunner: GhRunner;
117
+ pollIntervalMs?: number;
118
+ ttlMinutes?: number;
119
+ maxPerSession?: number;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // GitHub access via the gh CLI
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * The default GhRunner. Shells out to `gh api` so auth comes from the user's
128
+ * existing gh session - thatch never sees or stores a token. Throws on
129
+ * non-zero exit or unparseable output.
130
+ */
131
+ export async function ghApiRun(apiPath: string): Promise<unknown> {
132
+ const proc = Bun.spawn(["gh", "api", apiPath], {
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ });
136
+ const timeout = setTimeout(() => proc.kill(), 15_000);
137
+ try {
138
+ const [stdout, exitCode] = await Promise.all([
139
+ new Response(proc.stdout).text(),
140
+ proc.exited,
141
+ ]);
142
+ if (exitCode !== 0) {
143
+ const stderr = await new Response(proc.stderr).text();
144
+ throw new Error(`gh api ${apiPath} failed (exit ${exitCode}): ${stderr.trim().slice(0, 200)}`);
145
+ }
146
+ return JSON.parse(stdout);
147
+ } finally {
148
+ clearTimeout(timeout);
149
+ }
150
+ }
151
+
152
+ let ghAvailability: boolean | null = null;
153
+
154
+ /**
155
+ * Checks whether the gh CLI is usable, once per process. A tool that fails
156
+ * this check reports unavailability instead of registering a watcher that
157
+ * can never poll.
158
+ */
159
+ export async function ghAvailable(): Promise<boolean> {
160
+ if (ghAvailability !== null) return ghAvailability;
161
+ try {
162
+ const proc = Bun.spawn(["gh", "--version"], { stdout: "ignore", stderr: "ignore" });
163
+ const timeout = setTimeout(() => proc.kill(), 5_000);
164
+ const code = await proc.exited;
165
+ clearTimeout(timeout);
166
+ ghAvailability = code === 0;
167
+ } catch {
168
+ ghAvailability = false;
169
+ }
170
+ return ghAvailability;
171
+ }
172
+
173
+ // For testing: reset the cached availability check.
174
+ export function _resetGhAvailability(): void {
175
+ ghAvailability = null;
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // PR state construction and diffing
180
+ // ---------------------------------------------------------------------------
181
+
182
+ function sha256(text: string): string {
183
+ return Bun.SHA256.hash(text, "hex");
184
+ }
185
+
186
+ interface RawComment {
187
+ id?: number;
188
+ user?: { login?: string };
189
+ html_url?: string;
190
+ in_reply_to_id?: number;
191
+ }
192
+
193
+ function toCommentRefs(raw: unknown): CommentRef[] {
194
+ if (!Array.isArray(raw)) return [];
195
+ return raw
196
+ .filter((c): c is RawComment => typeof (c as RawComment).id === "number")
197
+ .map((c) => ({
198
+ id: c.id!,
199
+ author: c.user?.login ?? "unknown",
200
+ url: c.html_url ?? "",
201
+ isReply: typeof c.in_reply_to_id === "number",
202
+ }));
203
+ }
204
+
205
+ function maxCommentId(refs: CommentRef[]): number {
206
+ return refs.reduce((max, c) => Math.max(max, c.id), 0);
207
+ }
208
+
209
+ /**
210
+ * Fetches the current state of a PR plus everything the diff functions need.
211
+ * Four gh calls per poll per watcher: the PR itself, issue comments
212
+ * (top-level PR conversation), review comments (inline diff comments), and
213
+ * check runs on the head SHA. Comment and check-run fetches run in parallel.
214
+ */
215
+ export async function fetchPrState(gh: GhRunner, repo: string, pr: number): Promise<PrState> {
216
+ const pull = (await gh(`/repos/${repo}/pulls/${pr}`)) as {
217
+ head?: { sha?: string };
218
+ state?: string;
219
+ merged?: boolean;
220
+ title?: string;
221
+ body?: string | null;
222
+ };
223
+ const headSha = pull.head?.sha ?? "";
224
+ const [issueComments, reviewComments, checkRuns] = await Promise.all([
225
+ gh(`/repos/${repo}/issues/${pr}/comments?per_page=100&sort=created&direction=desc`),
226
+ gh(`/repos/${repo}/pulls/${pr}/comments?per_page=100&sort=created&direction=desc`),
227
+ gh(`/repos/${repo}/commits/${headSha}/check-runs?per_page=100`),
228
+ ]);
229
+
230
+ const issueRefs = toCommentRefs(issueComments);
231
+ const reviewRefs = toCommentRefs(reviewComments);
232
+
233
+ const runs: PrState["checkRuns"] = {};
234
+ const rawRuns = (checkRuns as { check_runs?: Array<{
235
+ id?: number;
236
+ name?: string;
237
+ status?: string;
238
+ conclusion?: string | null;
239
+ html_url?: string | null;
240
+ }> }).check_runs ?? [];
241
+ for (const run of rawRuns) {
242
+ if (run.id === undefined) continue;
243
+ runs[String(run.id)] = {
244
+ name: run.name ?? "check",
245
+ status: run.status ?? "unknown",
246
+ conclusion: run.conclusion ?? null,
247
+ url: run.html_url ?? null,
248
+ };
249
+ }
250
+
251
+ return {
252
+ headSha,
253
+ state: pull.state ?? "unknown",
254
+ merged: pull.merged ?? false,
255
+ title: pull.title ?? "",
256
+ bodySha: sha256(pull.body ?? ""),
257
+ lastIssueCommentId: maxCommentId(issueRefs),
258
+ lastReviewCommentId: maxCommentId(reviewRefs),
259
+ issueComments: issueRefs,
260
+ reviewComments: reviewRefs,
261
+ checkRuns: runs,
262
+ };
263
+ }
264
+
265
+ /** At most this many events per watcher per cycle; comment floods collapse. */
266
+ const MAX_EVENTS_PER_DIFF = 10;
267
+
268
+ /**
269
+ * Pure diff between the last-seen and current PR state. Detects comments by
270
+ * id monotonicity, commits by head SHA, description by body hash, CI by
271
+ * check-run transitions to a completed status.
272
+ */
273
+ export function diffPrState(before: PrState, after: PrState, url: string): WatcherEvent[] {
274
+ const events: WatcherEvent[] = [];
275
+
276
+ if (after.headSha !== before.headSha && after.headSha) {
277
+ events.push({
278
+ type: "pr_commit",
279
+ summary: `head moved to ${after.headSha.slice(0, 7)}`,
280
+ url,
281
+ });
282
+ }
283
+
284
+ if (after.state !== before.state || after.merged !== before.merged) {
285
+ const status = after.merged ? "merged" : after.state;
286
+ events.push({ type: "pr_status", summary: `PR is now ${status}`, url });
287
+ }
288
+
289
+ if (after.bodySha !== before.bodySha || after.title !== before.title) {
290
+ events.push({ type: "pr_description", summary: "PR title or description changed", url });
291
+ }
292
+
293
+ const fresh = (refs: CommentRef[], lastSeenId: number) =>
294
+ refs.filter((c) => c.id > lastSeenId);
295
+
296
+ for (const c of fresh(after.issueComments, before.lastIssueCommentId)) {
297
+ events.push({ type: "pr_comment", summary: `comment by ${c.author}`, url: c.url || url });
298
+ }
299
+ for (const c of fresh(after.reviewComments, before.lastReviewCommentId)) {
300
+ events.push({
301
+ type: c.isReply ? "pr_review_reply" : "pr_review_comment",
302
+ summary: `comment by ${c.author}`,
303
+ url: c.url || url,
304
+ });
305
+ }
306
+
307
+ for (const [runId, run] of Object.entries(after.checkRuns)) {
308
+ const previous = before.checkRuns[runId];
309
+ const completed = run.status === "completed";
310
+ if (!completed) continue;
311
+ if (previous && previous.status === "completed") continue;
312
+ const conclusion = run.conclusion ? ` (${run.conclusion})` : "";
313
+ events.push({ type: "pr_ci", summary: `check "${run.name}" completed${conclusion}`, url: run.url ?? url });
314
+ }
315
+
316
+ return events.slice(0, MAX_EVENTS_PER_DIFF);
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Registry
321
+ // ---------------------------------------------------------------------------
322
+
323
+ export class WatcherRegistry {
324
+ #watchers = new Map<string, Watcher>();
325
+ /** sessionID -> events detected but not yet delivered. */
326
+ #pending = new Map<string, WatcherEvent[]>();
327
+ #timer: ReturnType<typeof setInterval> | null = null;
328
+ #delivering = false;
329
+ #polling = false;
330
+ readonly #opts: WatcherRegistryOptions & { pollIntervalMs: number; ttlMinutes: number; maxPerSession: number };
331
+
332
+ constructor(options: WatcherRegistryOptions) {
333
+ this.#opts = {
334
+ pollIntervalMs: Number(process.env.THATCH_WATCH_POLL_SECONDS ?? 0) * 1000 || 60_000,
335
+ ttlMinutes: Number(process.env.THATCH_WATCH_TTL_MINUTES ?? 0) || 480,
336
+ maxPerSession: Number(process.env.THATCH_WATCH_MAX_PER_SESSION ?? 0) || 5,
337
+ ...options,
338
+ };
339
+ }
340
+
341
+ // -- Lifecycle -----------------------------------------------------------
342
+
343
+ start(): void {
344
+ if (this.#timer) return;
345
+ this.#timer = setInterval(() => {
346
+ void this.poll();
347
+ }, this.#opts.pollIntervalMs);
348
+ // Never keep the host process alive just for the poller.
349
+ this.#timer.unref?.();
350
+ }
351
+
352
+ stop(): void {
353
+ if (this.#timer) {
354
+ clearInterval(this.#timer);
355
+ this.#timer = null;
356
+ }
357
+ }
358
+
359
+ /** True while the background poller timer is armed. */
360
+ get running(): boolean {
361
+ return this.#timer !== null;
362
+ }
363
+
364
+ /** Stops polling and drops all state. Called from the plugin's dispose. */
365
+ dispose(): void {
366
+ this.stop();
367
+ this.#watchers.clear();
368
+ this.#pending.clear();
369
+ }
370
+
371
+ // -- CRUD ----------------------------------------------------------------
372
+
373
+ /**
374
+ * Registers a watcher and captures the baseline state immediately. The
375
+ * baseline fetch doubles as validation: a bad repo, missing PR, or broken
376
+ * gh setup fails here with a real error instead of a watcher that never
377
+ * fires.
378
+ */
379
+ async create(
380
+ sessionID: string,
381
+ repo: string,
382
+ pr: number,
383
+ events: WatcherEventType[],
384
+ ): Promise<{ ok: true; watcher: Watcher } | { ok: false; error: string }> {
385
+ const existing = this.listForSession(sessionID);
386
+ if (existing.length >= this.#opts.maxPerSession) {
387
+ return {
388
+ ok: false,
389
+ error: `Watcher limit reached: ${this.#opts.maxPerSession} active watchers per session. Cancel one with watch_cancel first.`,
390
+ };
391
+ }
392
+
393
+ let state: PrState;
394
+ try {
395
+ state = await fetchPrState(this.#opts.ghRunner, repo, pr);
396
+ } catch (err) {
397
+ return { ok: false, error: `Failed to read ${repo}#${pr}: ${err instanceof Error ? err.message : String(err)}` };
398
+ }
399
+ if (!state.headSha) {
400
+ return { ok: false, error: `Could not resolve a head SHA for ${repo}#${pr}. Is it an open PR?` };
401
+ }
402
+
403
+ const watcher: Watcher = {
404
+ id: `watch_${Math.random().toString(36).slice(2, 10)}`,
405
+ sessionID,
406
+ repo,
407
+ pr,
408
+ events,
409
+ expiresAt: Date.now() + this.#opts.ttlMinutes * 60_000,
410
+ createdAt: Date.now(),
411
+ state,
412
+ };
413
+ this.#watchers.set(watcher.id, watcher);
414
+ return { ok: true, watcher };
415
+ }
416
+
417
+ /** Summaries of one session's watchers, newest first. */
418
+ listForSession(sessionID: string): Watcher[] {
419
+ return [...this.#watchers.values()]
420
+ .filter((w) => w.sessionID === sessionID)
421
+ .sort((a, b) => b.createdAt - a.createdAt);
422
+ }
423
+
424
+ /** Cancels one watcher. Returns false if it does not exist or belongs to another session. */
425
+ cancel(sessionID: string, id: string): boolean {
426
+ const watcher = this.#watchers.get(id);
427
+ if (!watcher || watcher.sessionID !== sessionID) return false;
428
+ this.#watchers.delete(id);
429
+ return true;
430
+ }
431
+
432
+ /** Drops every watcher for a session. Called on session.deleted. */
433
+ cancelSession(sessionID: string): void {
434
+ for (const [id, w] of this.#watchers) {
435
+ if (w.sessionID === sessionID) this.#watchers.delete(id);
436
+ }
437
+ this.#pending.delete(sessionID);
438
+ }
439
+
440
+ // -- Polling and delivery ------------------------------------------------
441
+
442
+ /**
443
+ * One poll cycle: diff every watcher, queue events, then deliver whatever
444
+ * is pending for sessions that can accept a prompt. Errors are per-watcher:
445
+ * one bad PR never blocks the others. Reentrant calls (a cycle slower than
446
+ * the interval) are dropped - overlapping diffs against the same baseline
447
+ * would queue duplicate events.
448
+ */
449
+ async poll(): Promise<void> {
450
+ if (this.#polling) return;
451
+ this.#polling = true;
452
+ try {
453
+ const now = Date.now();
454
+ for (const [id, watcher] of this.#watchers) {
455
+ if (now > watcher.expiresAt) {
456
+ this.#watchers.delete(id);
457
+ continue;
458
+ }
459
+ try {
460
+ const after = await fetchPrState(this.#opts.ghRunner, watcher.repo, watcher.pr);
461
+ const events = diffPrState(watcher.state, after, this.pullUrl(watcher))
462
+ .filter((e) => watcher.events.includes(e.type));
463
+ if (events.length > 0) {
464
+ const queue = this.#pending.get(watcher.sessionID) ?? [];
465
+ queue.push(...events);
466
+ this.#pending.set(watcher.sessionID, queue);
467
+ }
468
+ watcher.state = after;
469
+ } catch (err) {
470
+ console.error(`[thatch] watcher ${id} poll failed: ${err}`);
471
+ }
472
+ }
473
+ await this.deliverPending();
474
+ } finally {
475
+ this.#polling = false;
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Delivers pending events for every session that can accept a prompt.
481
+ * Failed deliveries stay pending and retry on the next cycle or the next
482
+ * idle event.
483
+ */
484
+ async deliverPending(): Promise<void> {
485
+ if (this.#delivering) return;
486
+ this.#delivering = true;
487
+ try {
488
+ for (const [sessionID, events] of this.#pending) {
489
+ if (events.length === 0) continue;
490
+ if (!this.#opts.canDeliver(sessionID)) continue;
491
+ try {
492
+ await this.#opts.deliver(sessionID, events);
493
+ this.#pending.delete(sessionID);
494
+ } catch (err) {
495
+ console.error(`[thatch] watcher delivery to ${sessionID} failed: ${err}`);
496
+ }
497
+ }
498
+ } finally {
499
+ this.#delivering = false;
500
+ }
501
+ }
502
+
503
+ /** Diagnostic: number of events waiting for a session. */
504
+ pendingCount(sessionID: string): number {
505
+ return (this.#pending.get(sessionID) ?? []).length;
506
+ }
507
+
508
+ private pullUrl(watcher: Watcher): string {
509
+ return `https://github.com/${watcher.repo}/pull/${watcher.pr}`;
510
+ }
511
+ }