@icebergsnow/patchwork-cli 0.0.0-stage → 0.2.1

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/lib/board.js ADDED
@@ -0,0 +1,630 @@
1
+ import { digest } from "./store.js";
2
+
3
+ export const BOARD_LABEL = "patchwork:board";
4
+ export const REQUEST_LABEL = "patchwork";
5
+ export const INTERVENTION_LABEL = "patchwork:needs-human";
6
+ export const BOARD_MARKER = "<!-- patchwork-board:v1 -->";
7
+ const START = "<!-- patchwork-instructions:start -->",
8
+ END = "<!-- patchwork-instructions:end -->";
9
+ const labels = (item) =>
10
+ (item.labels || []).map((l) => (typeof l === "string" ? l : l.name));
11
+ const uuid = (value) =>
12
+ typeof value === "string" &&
13
+ /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value);
14
+ export const responses = ["can_handle", "cannot_handle", "need_clarification"];
15
+ const responseLabels = {
16
+ can_handle: "Can handle",
17
+ cannot_handle: "Cannot handle",
18
+ need_clarification: "Need clarification",
19
+ };
20
+ export function metadata(body = "") {
21
+ if (typeof body !== "string") return null;
22
+ const match = body.match(/\n<!-- patchwork:v1:([A-Za-z0-9+/=]+) -->\s*$/);
23
+ try {
24
+ if (!match || match[1].length > 8000) return null;
25
+ const value = JSON.parse(Buffer.from(match[1], "base64").toString("utf8"));
26
+ return value && typeof value === "object" && !Array.isArray(value)
27
+ ? value
28
+ : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ export function visible(body = "") {
34
+ return (typeof body === "string" ? body : "").replace(
35
+ /\n<!-- patchwork:v1:[A-Za-z0-9+/=]+ -->\s*$/,
36
+ "",
37
+ );
38
+ }
39
+ export function marked(body, data) {
40
+ return `${body}\n<!-- patchwork:v1:${Buffer.from(JSON.stringify(data)).toString("base64")} -->`;
41
+ }
42
+ function control(c) {
43
+ const m = metadata(c.body);
44
+ if (!m || !uuid(m.agent_id)) return null;
45
+ if (
46
+ m.type === "ack" &&
47
+ responses.includes(m.response) &&
48
+ (m.owner_id === undefined || m.owner_id === c.user?.id)
49
+ )
50
+ return { type: m.type, agent_id: m.agent_id, response: m.response };
51
+ if (m.owner_id !== c.user?.id) return null;
52
+ if (m.type === "retire")
53
+ return { type: m.type, agent_id: m.agent_id, owner_id: m.owner_id };
54
+ if (
55
+ m.type === "agent" &&
56
+ typeof m.active === "boolean" &&
57
+ ["codex", "claude"].includes(m.client) &&
58
+ typeof m.name === "string" &&
59
+ m.name.length > 0 &&
60
+ m.name.length <= 80
61
+ )
62
+ return {
63
+ type: m.type,
64
+ agent_id: m.agent_id,
65
+ owner_id: m.owner_id,
66
+ active: m.active,
67
+ client: m.client,
68
+ name: m.name,
69
+ };
70
+ return null;
71
+ }
72
+ const malformed = (c) => {
73
+ if (!c.body?.includes("<!-- patchwork:v1:")) return false;
74
+ const m = metadata(c.body);
75
+ return !m || (!["message", "request"].includes(m.type) && !control(c));
76
+ };
77
+ export async function verifyEdits(gh, comments) {
78
+ const pending = [];
79
+ for (const c of comments) {
80
+ const version = digest(JSON.stringify([c.body, c.updated_at]));
81
+ if (c.editProof?.version === version) continue;
82
+ // GitHub timestamps have second precision: a control comment can be edited
83
+ // during its creation second. Verify new marked comments as well as edits.
84
+ if (
85
+ c.created_at === c.updated_at &&
86
+ !metadata(c.body) &&
87
+ !c.body?.includes("<!-- patchwork:v1:")
88
+ ) {
89
+ c.editProof = { version, trusted: true };
90
+ continue;
91
+ }
92
+ // Older on-disk caches did not retain node_id. Refresh that one comment.
93
+ if (!c.node_id) {
94
+ const current = await gh.api("GET", `/issues/comments/${c.id}`);
95
+ if (current.body !== c.body || current.updated_at !== c.updated_at)
96
+ throw new Error(
97
+ "Comment changed during verification; retry the board check.",
98
+ );
99
+ c.node_id = current.node_id;
100
+ }
101
+ pending.push({ c, version });
102
+ }
103
+ for (let at = 0; at < pending.length; at += 50) {
104
+ const batch = pending.slice(at, at + 50);
105
+ const nodes = await gh.commentEditors(batch.map(({ c }) => c.node_id));
106
+ for (const { c, version } of batch) {
107
+ const node = nodes.find((n) => n?.id === c.node_id);
108
+ if (
109
+ !node ||
110
+ node.body !== c.body ||
111
+ Date.parse(node.updatedAt) !== Date.parse(c.updated_at) ||
112
+ node.issue?.repository?.nameWithOwner?.toLowerCase() !==
113
+ gh.repo.toLowerCase()
114
+ )
115
+ throw new Error(
116
+ "Comment changed during verification; retry the board check.",
117
+ );
118
+ c.editProof = {
119
+ version,
120
+ trusted:
121
+ !!node.author?.id &&
122
+ (node.author.id === node.editor?.id ||
123
+ (!node.editor && c.created_at === c.updated_at)),
124
+ };
125
+ }
126
+ }
127
+ }
128
+ export function instructions(body = "") {
129
+ if (typeof body !== "string") return "";
130
+ if (!body.includes(START) || !body.includes(END)) return "";
131
+ return body
132
+ .slice(body.indexOf(START) + START.length, body.indexOf(END))
133
+ .trim()
134
+ .slice(0, 4000);
135
+ }
136
+ export function boardBody(repo) {
137
+ return `${BOARD_MARKER}\n# Patchwork board\n\nPost announcements and questions in the comments. Create requests with the \`patchwork\` label; reply on the request and close it explicitly when resolved.\n\n[Open requests](https://github.com/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Apatchwork) · [Needs human intervention](https://github.com/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Apatchwork%3Aneeds-human)\n\n## Shared instructions (optional)\nEdit between the markers below. Keep this under 4,000 characters. Instructions do not grant agents permission to expand their scope.\n\n${START}\n\n${END}\n\nAgents introduce themselves in registration comments. Registered includes idle chats. Use Patchwork disconnect to retire finished chats; use status --repo OWNER/REPO and disconnect --repo OWNER/REPO --agent CHAT_ID when local state is lost. The chat owner or a repository maintainer can retire it. Resume a local chat explicitly with connect --client APP --agent CHAT_ID. Acknowledgments are per chat, never assignments.\n`;
138
+ }
139
+ export async function findBoard(gh) {
140
+ const issues = await gh.list(
141
+ `/issues?state=all&labels=${encodeURIComponent(BOARD_LABEL)}`,
142
+ );
143
+ const matches = issues
144
+ .filter(
145
+ (i) =>
146
+ !i.pull_request &&
147
+ i.body?.startsWith(BOARD_MARKER) &&
148
+ i.state === "open",
149
+ )
150
+ .sort((a, b) => a.number - b.number);
151
+ if (matches.length > 1)
152
+ throw new Error(
153
+ "Multiple open Patchwork boards exist. Close the unused duplicate board before connecting.",
154
+ );
155
+ return matches[0] || null;
156
+ }
157
+ export async function createBoard(gh) {
158
+ const existing = await findBoard(gh);
159
+ if (existing) return existing;
160
+ const wanted = [
161
+ [BOARD_LABEL, "555555"],
162
+ [REQUEST_LABEL, "0969da"],
163
+ [INTERVENTION_LABEL, "d1242f"],
164
+ ];
165
+ const current = await gh.list("/labels");
166
+ for (const [name, color] of wanted)
167
+ if (!current.some((l) => l.name === name)) {
168
+ try {
169
+ await gh.api("POST", "/labels", { name, color });
170
+ } catch (e) {
171
+ if (e.status !== 422) throw e;
172
+ }
173
+ }
174
+ // A second first-time installer may have completed while labels were created.
175
+ const found = await findBoard(gh);
176
+ if (found) return found;
177
+ const issue = await gh.api("POST", "/issues", {
178
+ title: "Patchwork board",
179
+ body: boardBody(gh.repo),
180
+ labels: [BOARD_LABEL],
181
+ });
182
+ if (!labels(issue).includes(BOARD_LABEL))
183
+ throw new Error(
184
+ `Board #${issue.number} was created without its label. Check GitHub permissions before retrying.`,
185
+ );
186
+ await gh.pin?.(issue.number);
187
+ return issue;
188
+ }
189
+ export const isRequest = (i, boardNumber) =>
190
+ !i.pull_request &&
191
+ i.number !== boardNumber &&
192
+ labels(i).includes(REQUEST_LABEL);
193
+ export async function assertRequest(gh, number, boardNumber) {
194
+ if (!Number.isSafeInteger(number) || number < 1)
195
+ throw new Error("Use a positive request issue number.");
196
+ const issue = await gh.api("GET", `/issues/${number}`);
197
+ if (!isRequest(issue, boardNumber))
198
+ throw new Error(
199
+ "This issue is not a Patchwork request in the connected board.",
200
+ );
201
+ return issue;
202
+ }
203
+
204
+ // Raw records are disposable GitHub cache data. Projection below retains the
205
+ // existing board semantics independently of how those records were fetched.
206
+ export function assertBoard(board) {
207
+ if (
208
+ board.pull_request ||
209
+ board.state !== "open" ||
210
+ !board.body?.startsWith(BOARD_MARKER) ||
211
+ !labels(board).includes(BOARD_LABEL)
212
+ )
213
+ throw new Error("The connected board is missing or closed.");
214
+ }
215
+ export async function fullRecords(gh, boardNumber, board) {
216
+ board ||= await gh.api("GET", `/issues/${boardNumber}`);
217
+ assertBoard(board);
218
+ const comments = await gh.list(`/issues/${boardNumber}/comments`);
219
+ const issues = await gh.list(
220
+ `/issues?state=all&labels=${encodeURIComponent(REQUEST_LABEL)}&sort=created&direction=asc`,
221
+ );
222
+ const records = { board, comments, requests: {} };
223
+ for (const issue of issues.filter((i) => isRequest(i, boardNumber))) {
224
+ records.requests[issue.number] = {
225
+ issue,
226
+ comments: await gh.list(`/issues/${issue.number}/comments`),
227
+ events: await gh.list(`/issues/${issue.number}/events`),
228
+ };
229
+ }
230
+ return records;
231
+ }
232
+ export async function snapshot(gh, boardNumber) {
233
+ return project(gh, await fullRecords(gh, boardNumber));
234
+ }
235
+ export async function project(
236
+ gh,
237
+ records,
238
+ permissionCache = {},
239
+ now = Date.now(),
240
+ ) {
241
+ const { board, comments } = records,
242
+ boardNumber = board.number;
243
+ const controlComments = [
244
+ ...comments,
245
+ ...Object.values(records.requests).flatMap((r) =>
246
+ r.comments.filter(
247
+ (c) => metadata(c.body) || c.body?.includes("<!-- patchwork:v1:"),
248
+ ),
249
+ ),
250
+ ];
251
+ await verifyEdits(gh, controlComments);
252
+ const warnings = controlComments
253
+ .filter((c) => !c.editProof.trusted || malformed(c))
254
+ .map(
255
+ (c) =>
256
+ `Ignored unverifiable or malformed control metadata in comment:${c.id}. Review it on GitHub; restore it as the original author or remove the invalid comment.`,
257
+ );
258
+ const uncertainRoster = comments.some(
259
+ (c) => !c.editProof.trusted || malformed(c),
260
+ );
261
+ const roster = new Map(),
262
+ permissions = new Map();
263
+ async function permission(user) {
264
+ if (!user?.login) return {};
265
+ if (!permissions.has(user.id)) {
266
+ const key = `${user.id}:${user.login}`,
267
+ cached = permissionCache[key];
268
+ if (
269
+ cached &&
270
+ typeof cached.manager === "boolean" &&
271
+ now >= cached.at &&
272
+ now - cached.at < 300_000
273
+ ) {
274
+ permissions.set(user.id, cached);
275
+ return cached;
276
+ }
277
+ let p;
278
+ try {
279
+ p = await gh.api(
280
+ "GET",
281
+ `/collaborators/${encodeURIComponent(user.login)}/permission`,
282
+ );
283
+ } catch (e) {
284
+ if (e.status !== 404) throw e;
285
+ permissionCache[key] = { at: now, allowed: false, manager: false };
286
+ permissions.set(user.id, permissionCache[key]);
287
+ return permissionCache[key];
288
+ }
289
+ permissions.set(user.id, {
290
+ allowed:
291
+ !!p.user?.permissions?.push ||
292
+ ["admin", "write", "maintain"].includes(p.permission),
293
+ manager:
294
+ !!(p.user?.permissions?.admin || p.user?.permissions?.maintain) ||
295
+ ["admin", "maintain"].includes(p.permission),
296
+ });
297
+ permissionCache[key] = { at: now, ...permissions.get(user.id) };
298
+ }
299
+ return permissions.get(user.id);
300
+ }
301
+ for (const c of [...comments].sort((a, b) => a.id - b.id)) {
302
+ const m = control(c);
303
+ if (!c.editProof.trusted) continue;
304
+ if (m?.type === "retire" && uuid(m.agent_id) && m.owner_id === c.user?.id) {
305
+ const old = roster.get(m.agent_id),
306
+ p = await permission(c.user);
307
+ if (old && p.allowed && (old.owner_id === c.user.id || p.manager))
308
+ roster.set(m.agent_id, { ...old, active: false, comment_id: c.id });
309
+ continue;
310
+ }
311
+ if (m?.type !== "agent") continue;
312
+ if (!(await permission(c.user)).allowed) continue;
313
+ const old = roster.get(m.agent_id);
314
+ if (old && old.owner_id !== c.user.id) continue;
315
+ roster.set(m.agent_id, { ...m, login: c.user.login, comment_id: c.id });
316
+ }
317
+ const events = comments.map((c) => commentEvent(c, boardNumber));
318
+ const requests = [];
319
+ for (const {
320
+ issue: i,
321
+ comments: replies,
322
+ events: transitions,
323
+ } of Object.values(records.requests).sort(
324
+ (a, b) => a.issue.number - b.issue.number,
325
+ )) {
326
+ const acknowledgments = new Map();
327
+ for (const c of [...replies].sort((a, b) => a.id - b.id)) {
328
+ const m = metadata(c.body),
329
+ agent = m && roster.get(m.agent_id);
330
+ if (
331
+ m?.type === "ack" &&
332
+ c.editProof?.trusted &&
333
+ (m.owner_id === undefined || m.owner_id === c.user?.id) &&
334
+ responses.includes(m.response) &&
335
+ agent &&
336
+ agent.owner_id === c.user?.id
337
+ )
338
+ acknowledgments.set(m.agent_id, m.response);
339
+ }
340
+ const active = [...roster.values()].filter((a) => a.active);
341
+ const needsHumanIntervention =
342
+ i.state === "open" &&
343
+ !uncertainRoster &&
344
+ !replies.some((c) => c.editProof?.trusted === false || malformed(c)) &&
345
+ active.length > 0 &&
346
+ active.every((a) => acknowledgments.get(a.agent_id) === "cannot_handle");
347
+ requests.push({
348
+ number: i.number,
349
+ title: i.title,
350
+ body: visible(i.body),
351
+ state: i.state,
352
+ url: i.html_url,
353
+ labels: labels(i),
354
+ acknowledgments: [...roster.values()].map((a) => ({
355
+ agent_id: a.agent_id,
356
+ name: a.name,
357
+ active: a.active,
358
+ response: acknowledgments.get(a.agent_id) || null,
359
+ })),
360
+ needsHumanIntervention,
361
+ });
362
+ events.push({
363
+ id: `issue:${i.number}`,
364
+ thread: i.number,
365
+ kind: "request",
366
+ body: visible(i.body),
367
+ title: i.title,
368
+ author: i.user?.login,
369
+ time: i.created_at,
370
+ updated: i.updated_at,
371
+ url: i.html_url,
372
+ state: i.state,
373
+ rawVersion: digest(i.body || ""),
374
+ });
375
+ events.push(...replies.map((c) => commentEvent(c, i.number)));
376
+ events.push(
377
+ ...transitions
378
+ .filter((e) => ["closed", "reopened"].includes(e.event))
379
+ .map((e) => ({
380
+ id: `event:${e.id}`,
381
+ thread: i.number,
382
+ kind: "status",
383
+ body: `Request ${e.event}.`,
384
+ author: e.actor?.login,
385
+ time: e.created_at,
386
+ updated: e.created_at,
387
+ url: i.html_url,
388
+ })),
389
+ );
390
+ }
391
+ for (const e of events) {
392
+ e.version = digest(
393
+ JSON.stringify([e.body, e.title, e.rawVersion, e.state]),
394
+ );
395
+ delete e.rawVersion;
396
+ }
397
+ events.sort(
398
+ (a, b) =>
399
+ a.time.localeCompare(b.time) ||
400
+ a.id.localeCompare(b.id, "en", { numeric: true }),
401
+ );
402
+ return {
403
+ board: boardNumber,
404
+ instructions: instructions(board.body),
405
+ agents: [...roster.values()],
406
+ requests,
407
+ events,
408
+ warnings,
409
+ };
410
+ }
411
+ function commentEvent(c, thread) {
412
+ return {
413
+ id: `comment:${c.id}`,
414
+ thread,
415
+ kind: "message",
416
+ body: visible(c.body),
417
+ rawVersion: digest(c.body || ""),
418
+ ...(c.editProof?.trusted && control(c) ? { metadata: control(c) } : {}),
419
+ author: c.user?.login,
420
+ time: c.created_at,
421
+ updated: c.updated_at,
422
+ url: c.html_url,
423
+ };
424
+ }
425
+ export async function reconcileIntervention(
426
+ gh,
427
+ state,
428
+ guard = async () => {},
429
+ changed = () => {},
430
+ ) {
431
+ for (const r of state.requests) {
432
+ const has = r.labels.includes(INTERVENTION_LABEL);
433
+ if (has === r.needsHumanIntervention) continue;
434
+ await guard();
435
+ if (r.needsHumanIntervention)
436
+ await gh.api("POST", `/issues/${r.number}/labels`, {
437
+ labels: [INTERVENTION_LABEL],
438
+ });
439
+ else
440
+ try {
441
+ await gh.api(
442
+ "DELETE",
443
+ `/issues/${r.number}/labels/${encodeURIComponent(INTERVENTION_LABEL)}`,
444
+ );
445
+ } catch (e) {
446
+ if (e.status !== 404) throw e;
447
+ }
448
+ r.labels = r.labels.filter((name) => name !== INTERVENTION_LABEL);
449
+ if (r.needsHumanIntervention) r.labels.push(INTERVENTION_LABEL);
450
+ changed(r);
451
+ }
452
+ }
453
+ export async function register(
454
+ gh,
455
+ board,
456
+ agent,
457
+ active,
458
+ operationId,
459
+ guard = async () => {},
460
+ ) {
461
+ const m = {
462
+ type: "agent",
463
+ agent_id: agent.id,
464
+ owner_id: agent.ownerId,
465
+ client: agent.client,
466
+ name: agent.name,
467
+ active,
468
+ operation_id: operationId,
469
+ };
470
+ return commentOnce(
471
+ gh,
472
+ board,
473
+ `${agent.name} ${active ? "connected" : "disconnected"}.`,
474
+ m,
475
+ guard,
476
+ );
477
+ }
478
+ export async function commentOnce(
479
+ gh,
480
+ issue,
481
+ body,
482
+ meta,
483
+ guard = async () => {},
484
+ knownComments,
485
+ ) {
486
+ const comments =
487
+ knownComments || (await gh.list(`/issues/${issue}/comments`));
488
+ await verifyEdits(
489
+ gh,
490
+ comments.filter(
491
+ (c) => metadata(c.body)?.operation_id === meta.operation_id,
492
+ ),
493
+ );
494
+ const old = comments.find(
495
+ (c) =>
496
+ c.editProof?.trusted &&
497
+ metadata(c.body)?.operation_id === meta.operation_id &&
498
+ c.user?.id === meta.owner_id,
499
+ );
500
+ if (old) return old;
501
+ await guard();
502
+ return gh.api("POST", `/issues/${issue}/comments`, {
503
+ body: marked(body, meta),
504
+ });
505
+ }
506
+ export async function perform(
507
+ gh,
508
+ c,
509
+ chat,
510
+ input,
511
+ operationId,
512
+ guard,
513
+ retry = true,
514
+ ) {
515
+ const base = {
516
+ agent_id: chat.id,
517
+ owner_id: c.ownerId,
518
+ operation_id: operationId,
519
+ };
520
+ if (input.action === "ack") {
521
+ if (!responses.includes(input.response))
522
+ throw new Error(
523
+ "Choose can_handle, cannot_handle, or need_clarification.",
524
+ );
525
+ const issue = await assertRequest(gh, input.request_id, c.board);
526
+ if (issue.state !== "open")
527
+ throw new Error("Reopen the request before changing an acknowledgment.");
528
+ const comments = await gh.list(`/issues/${input.request_id}/comments`);
529
+ await verifyEdits(
530
+ gh,
531
+ comments.filter((c) => metadata(c.body)?.type === "ack"),
532
+ );
533
+ const last = comments
534
+ .filter(
535
+ (x) =>
536
+ x.editProof?.trusted &&
537
+ x.user?.id === c.ownerId &&
538
+ metadata(x.body)?.type === "ack" &&
539
+ metadata(x.body)?.agent_id === chat.id,
540
+ )
541
+ .sort((a, b) => b.id - a.id)[0];
542
+ if (metadata(last?.body)?.response === input.response)
543
+ return { unchanged: true };
544
+ return commentOnce(
545
+ gh,
546
+ input.request_id,
547
+ `${chat.name}: **${responseLabels[input.response]}**.`,
548
+ { ...base, type: "ack", response: input.response },
549
+ guard,
550
+ comments,
551
+ );
552
+ }
553
+ if (input.action === "resolve") {
554
+ const issue = await assertRequest(gh, input.request_id, c.board),
555
+ state = input.reopen ? "open" : "closed";
556
+ if (issue.state === state) return { unchanged: true };
557
+ await guard();
558
+ return gh.api("PATCH", `/issues/${input.request_id}`, {
559
+ state,
560
+ state_reason: input.reopen ? "reopened" : "completed",
561
+ });
562
+ }
563
+ const body = input.body?.trim();
564
+ if (!body || body.length > 8000)
565
+ throw new Error("Write a message between 1 and 8,000 characters.");
566
+ if (input.kind === "request") {
567
+ if (input.replyTo) throw new Error("Start a new issue for a request.");
568
+ // Only uncertain retries need an operation lookup. Include unlabeled issues:
569
+ // GitHub can create the issue while declining to apply its requested label.
570
+ const existing = (
571
+ retry
572
+ ? await gh.list(
573
+ `/issues?state=all${c.login ? `&creator=${encodeURIComponent(c.login)}` : ""}&sort=created&direction=asc`,
574
+ )
575
+ : []
576
+ ).find(
577
+ (i) =>
578
+ !i.pull_request &&
579
+ i.user?.id === c.ownerId &&
580
+ metadata(i.body)?.operation_id === operationId,
581
+ );
582
+ const title = (input.title || body.split("\n")[0]).trim().slice(0, 160);
583
+ if (!existing) await guard();
584
+ const result =
585
+ existing ||
586
+ (await gh.api("POST", "/issues", {
587
+ title,
588
+ body: marked(`${chat.name}:\n\n${body}`, { ...base, type: "request" }),
589
+ labels: [REQUEST_LABEL],
590
+ }));
591
+ if (!labels(result).includes(REQUEST_LABEL)) {
592
+ try {
593
+ await guard();
594
+ await gh.api("POST", `/issues/${result.number}/labels`, {
595
+ labels: [REQUEST_LABEL],
596
+ });
597
+ result.labels = [...(result.labels || []), REQUEST_LABEL];
598
+ } catch (error) {
599
+ error.uncertain = true;
600
+ throw error;
601
+ }
602
+ }
603
+ return result;
604
+ }
605
+ let thread = c.board,
606
+ reply = "";
607
+ if (input.replyTo) {
608
+ const match = input.replyTo.match(/^(issue|comment):(\d+)$/);
609
+ if (!match)
610
+ throw new Error(
611
+ "Use a replyTo reference returned by read_messages (issue:NUMBER or comment:ID).",
612
+ );
613
+ if (match[1] === "issue") {
614
+ thread = Number(match[2]);
615
+ await assertRequest(gh, thread, c.board);
616
+ } else {
617
+ const comment = await gh.api("GET", `/issues/comments/${match[2]}`);
618
+ thread = Number(comment.issue_url.split("/").at(-1));
619
+ if (thread !== c.board) await assertRequest(gh, thread, c.board);
620
+ reply = `In reply to ${comment.html_url}\n\n`;
621
+ }
622
+ }
623
+ return commentOnce(
624
+ gh,
625
+ thread,
626
+ `${chat.name}:\n\n${reply}${body}`,
627
+ { ...base, type: "message" },
628
+ guard,
629
+ );
630
+ }