typeclaw 0.23.0 → 0.25.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.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
@@ -1,5 +1,6 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto'
2
2
 
3
+ import type { GithubReviewOn } from '@/channels/schema'
3
4
  import type { InboundMessage } from '@/channels/types'
4
5
 
5
6
  import type { GithubAuthContext } from './auth'
@@ -23,6 +24,14 @@ export type GithubWebhookHandlerOptions = {
23
24
  // an appended operator-policy note telling the agent not to submit an APPROVE
24
25
  // review; the github skill keys off that note to downgrade approve→COMMENT.
25
26
  allowApprove?: () => boolean
27
+ // Which pull_request action triggers an agent code review. Defaults to
28
+ // 'review_requested' when omitted, preserving the request-driven behavior.
29
+ // 'opened' additionally wakes the bot to review every PR the moment it opens;
30
+ // 'off' suppresses the dedicated review-trigger synthesis entirely (an
31
+ // explicit review_requested no longer wakes a session). Orthogonal to the
32
+ // eventAllowlist (the outer "process this webhook?" gate) — this is the inner
33
+ // "does an admitted pull_request event become a review-trigger inbound?" gate.
34
+ reviewOn?: () => GithubReviewOn
26
35
  route: (message: InboundMessage) => void
27
36
  logger: GithubInboundLogger
28
37
  // Optional: resolves whether the bot is a member of the given team. When
@@ -75,6 +84,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
75
84
  const classified = classifyGithubInbound(event, payload, selfLogin, {
76
85
  teamIsBotMember,
77
86
  authType: options.authType?.() ?? 'pat',
87
+ reviewOn: options.reviewOn?.() ?? 'review_requested',
78
88
  })
79
89
  if (classified === null) return ok()
80
90
 
@@ -173,7 +183,7 @@ export function classifyGithubInbound(
173
183
  event: string,
174
184
  payload: Record<string, unknown>,
175
185
  selfLogin: string | null,
176
- options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app' },
186
+ options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app'; reviewOn?: GithubReviewOn },
177
187
  ): InboundMessage | null {
178
188
  const repository = readRepository(payload)
179
189
  if (repository === null) return null
@@ -248,14 +258,22 @@ export function classifyGithubInbound(
248
258
  const number = readNumber(issue, 'number')
249
259
  const id = readNumber(issue, 'id') ?? number
250
260
  if (number === null || id === null) return null
261
+ const action = readString(payload, 'action')
262
+ const opener = readUser(issue.user)
263
+ const hasBody = readString(issue, 'body')?.trim() ? true : false
264
+ const text =
265
+ action === 'opened'
266
+ ? bodyOrOpenedTitle(issue.body, opener, 'issue', number, readString(issue, 'title'))
267
+ : issue.body
251
268
  return buildInbound(
252
269
  { ...base, chat: `issue:${number}`, thread: null },
253
- issue.body,
270
+ text,
254
271
  id,
255
- readUser(issue.user),
272
+ opener,
256
273
  selfLogin,
257
274
  issue.created_at,
258
275
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
276
+ action === 'opened' && !hasBody,
259
277
  )
260
278
  }
261
279
 
@@ -266,7 +284,12 @@ export function classifyGithubInbound(
266
284
  const id = readNumber(pr, 'id') ?? number
267
285
  if (number === null || id === null) return null
268
286
  const action = readString(payload, 'action')
287
+ const reviewOn = options?.reviewOn ?? 'review_requested'
269
288
  if (action === 'review_requested' || action === 'review_request_removed') {
289
+ // `off` disables the dedicated review trigger: these two actions exist
290
+ // only to drive review-request behavior here, so under `off` they wake no
291
+ // session rather than falling through to awareness-only context.
292
+ if (reviewOn === 'off') return null
270
293
  return classifyReviewRequest({
271
294
  action,
272
295
  payload,
@@ -278,14 +301,30 @@ export function classifyGithubInbound(
278
301
  teamIsBotMember: options?.teamIsBotMember,
279
302
  })
280
303
  }
304
+ if (action === 'opened' && reviewOn === 'opened') {
305
+ const trigger = classifyOpenedReviewTrigger({
306
+ payload,
307
+ pr,
308
+ number,
309
+ base,
310
+ selfLogin,
311
+ authType: options?.authType ?? 'pat',
312
+ })
313
+ if (trigger !== null) return trigger
314
+ }
315
+ const opener = readUser(pr.user)
316
+ const hasBody = readString(pr, 'body')?.trim() ? true : false
317
+ const prText =
318
+ action === 'opened' ? bodyOrOpenedTitle(pr.body, opener, 'PR', number, readString(pr, 'title')) : pr.body
281
319
  return buildInbound(
282
320
  { ...base, chat: `pr:${number}`, thread: null },
283
- pr.body,
321
+ prText,
284
322
  id,
285
- readUser(pr.user),
323
+ opener,
286
324
  selfLogin,
287
325
  pr.created_at,
288
326
  { kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
327
+ action === 'opened' && !hasBody,
289
328
  )
290
329
  }
291
330
 
@@ -296,14 +335,23 @@ export function classifyGithubInbound(
296
335
  const number = readNumber(pr, 'number')
297
336
  const id = readNumber(review, 'id')
298
337
  if (number === null || id === null) return null
338
+ const reviewer = readUser(review.user)
339
+ const body = readString(review, 'body')
340
+ const hasBody = body !== null && body.trim() !== ''
341
+ const text = hasBody
342
+ ? body
343
+ : reviewer !== null
344
+ ? synthesizeReviewStateText(reviewer.login, number, readString(pr, 'title'), readString(review, 'state'))
345
+ : ''
299
346
  return buildInbound(
300
347
  { ...base, chat: `pr:${number}`, thread: null },
301
- review.body,
348
+ text,
302
349
  id,
303
- readUser(review.user),
350
+ reviewer,
304
351
  selfLogin,
305
352
  review.submitted_at,
306
353
  null,
354
+ !hasBody,
307
355
  )
308
356
  }
309
357
 
@@ -313,14 +361,22 @@ export function classifyGithubInbound(
313
361
  const number = readNumber(discussion, 'number')
314
362
  const id = readNumber(discussion, 'id') ?? number
315
363
  if (number === null || id === null) return null
364
+ const action = readString(payload, 'action')
365
+ const opener = readUser(discussion.user)
366
+ const hasBody = readString(discussion, 'body')?.trim() ? true : false
367
+ const text =
368
+ action === 'created'
369
+ ? bodyOrOpenedTitle(discussion.body, opener, 'discussion', number, readString(discussion, 'title'))
370
+ : discussion.body
316
371
  return buildInbound(
317
372
  { ...base, chat: `discussion:${number}`, thread: null },
318
- discussion.body,
373
+ text,
319
374
  id,
320
- readUser(discussion.user),
375
+ opener,
321
376
  selfLogin,
322
377
  discussion.created_at,
323
378
  null,
379
+ action === 'created' && !hasBody,
324
380
  )
325
381
  }
326
382
 
@@ -418,6 +474,59 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
418
474
  }
419
475
  }
420
476
 
477
+ type OpenedReviewTriggerInput = {
478
+ payload: Record<string, unknown>
479
+ pr: Record<string, unknown>
480
+ number: number
481
+ base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
482
+ selfLogin: string | null
483
+ authType: 'pat' | 'app'
484
+ }
485
+
486
+ function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMessage | null {
487
+ const { payload, pr, number, base, selfLogin, authType } = input
488
+ if (selfLogin === null) return null
489
+ const sender = readUser(payload.sender) ?? readUser(pr.user)
490
+ if (sender === null) return null
491
+ // Defensive self-loop guard mirroring classifyReviewRequest: the handler-level
492
+ // self-author drop already discards bot-opened PRs, but the decoy account is a
493
+ // distinct login, so a decoy-opened PR would otherwise wake a self-review.
494
+ const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
495
+ if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
496
+
497
+ // A draft PR is work-in-progress, so the automatic `opened` path skips it: null
498
+ // here drops to awareness-only context (like a non-`opened` reviewOn) instead of
499
+ // waking a review. An explicit `review_requested` still triggers on a draft via
500
+ // classifyReviewRequest, preserving "skip until explicitly requested".
501
+ if (readBoolean(pr, 'draft') === true) return null
502
+
503
+ const title = readString(pr, 'title') ?? `#${number}`
504
+ const head = readString(readRecord(pr.head), 'ref')
505
+ const baseRef = readString(readRecord(pr.base), 'ref')
506
+ const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
507
+ const text =
508
+ `@${sender.login} opened PR #${number}: "${title}".${branchSegment}` +
509
+ ' Please review the changes line-by-line and post your feedback.'
510
+
511
+ const updatedAt = readString(pr, 'updated_at') ?? ''
512
+ const prId = readNumber(pr, 'id') ?? number
513
+ const externalMessageId = `pr-${prId}-opened-${updatedAt}`
514
+
515
+ return {
516
+ ...base,
517
+ chat: `pr:${number}`,
518
+ thread: null,
519
+ text,
520
+ externalMessageId,
521
+ authorId: String(sender.id),
522
+ authorName: sender.login,
523
+ authorIsBot: sender.type === 'Bot',
524
+ isBotMention: true,
525
+ replyToBotMessageId: null,
526
+ ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
527
+ }
528
+ }
529
+
421
530
  export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
422
531
 
423
532
  export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
@@ -440,9 +549,21 @@ function buildInbound(
440
549
  selfLogin: string | null,
441
550
  rawTs: unknown,
442
551
  reactionTarget: GithubReactionTarget | null,
552
+ synthesizedAwareness = false,
443
553
  ): InboundMessage | null {
444
554
  if (user === null) return null
445
555
  const text = typeof rawText === 'string' ? rawText : ''
556
+ // A body-less inbound reaches engagement as contentless text; in a solo-human
557
+ // channel the fallback engages on it and the agent replies with a generic
558
+ // greeting. The other adapters drop empty text at their classifier — this is
559
+ // the matching guard. Events whose empty body still carries signal (review
560
+ // state, opened-PR/issue title) synthesize non-empty text upstream and so
561
+ // never reach this drop.
562
+ if (text.trim() === '') return null
563
+ // Synthesized awareness lines carry an `@author` prefix describing who acted;
564
+ // that handle is the author, never a third-party mention of the bot, so the
565
+ // body-text mention heuristic must not fire on it.
566
+ const isBotMention = !synthesizedAwareness && selfLogin !== null && text.includes(`@${selfLogin}`)
446
567
  return {
447
568
  ...key,
448
569
  text,
@@ -451,12 +572,48 @@ function buildInbound(
451
572
  authorId: String(user.id),
452
573
  authorName: user.login,
453
574
  authorIsBot: user.type === 'Bot',
454
- isBotMention: selfLogin !== null && text.includes(`@${selfLogin}`),
575
+ isBotMention,
455
576
  replyToBotMessageId: null,
456
577
  ts: typeof rawTs === 'string' ? Date.parse(rawTs) || 0 : 0,
457
578
  }
458
579
  }
459
580
 
581
+ function bodyOrOpenedTitle(
582
+ rawBody: unknown,
583
+ opener: GithubUser | null,
584
+ kind: 'issue' | 'PR' | 'discussion',
585
+ number: number,
586
+ title: string | null,
587
+ ): string {
588
+ const body = typeof rawBody === 'string' ? rawBody : ''
589
+ if (body.trim() !== '' || opener === null) return body
590
+ const label = title !== null && title.trim() !== '' ? `: "${title}"` : ''
591
+ return `@${opener.login} opened ${kind} #${number}${label}.`
592
+ }
593
+
594
+ // Neutral phrasing per review state — must never imply a review was requested
595
+ // or that action is needed; a COMMENTED review in particular must not read as
596
+ // "please review", which is the review-request path's wording.
597
+ function synthesizeReviewStateText(
598
+ reviewer: string,
599
+ number: number,
600
+ title: string | null,
601
+ state: string | null,
602
+ ): string {
603
+ const label = title !== null && title.trim() !== '' ? `: "${title}"` : ''
604
+ // GitHub's pull_request_review webhook can send the state in either case
605
+ // depending on the source (webhook payload vs REST), so normalize before
606
+ // matching — an unmatched state would silently fall back to the neutral verb.
607
+ const normalized = state?.toLowerCase() ?? null
608
+ const verb =
609
+ normalized === 'approved'
610
+ ? 'approved'
611
+ : normalized === 'changes_requested'
612
+ ? 'requested changes on'
613
+ : 'submitted a review on'
614
+ return `@${reviewer} ${verb} PR #${number}${label}.`
615
+ }
616
+
460
617
  async function resolveTeamMembership(
461
618
  event: string,
462
619
  payload: Record<string, unknown>,
@@ -587,6 +744,11 @@ function readNumber(obj: Record<string, unknown> | null, key: string): number |
587
744
  return typeof value === 'number' && Number.isFinite(value) ? value : null
588
745
  }
589
746
 
747
+ function readBoolean(obj: Record<string, unknown> | null, key: string): boolean | null {
748
+ const value = obj?.[key]
749
+ return typeof value === 'boolean' ? value : null
750
+ }
751
+
590
752
  function ok(): Response {
591
753
  return new Response('ok', { status: 200 })
592
754
  }
@@ -21,6 +21,7 @@ import {
21
21
  parseListHooksPermissionStatus,
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
+ import { createGithubReviewThreadResolver } from './review-thread-resolver'
24
25
  import { createTeamMembershipChecker } from './team-membership'
25
26
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
26
27
 
@@ -137,6 +138,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
137
138
  workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
138
139
  })
139
140
  const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
141
+ const reviewThreadResolver = createGithubReviewThreadResolver({
142
+ token: authToken,
143
+ selfLogin: () => selfLogin,
144
+ fetchImpl,
145
+ })
140
146
  const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
141
147
  // GitHub addresses by `@login`, not the numeric id, so `username` carries
142
148
  // the login the model should type; the id is kept for completeness.
@@ -155,6 +161,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
155
161
  selfLogin: () => selfLogin,
156
162
  authType: () => options.secrets.auth.type,
157
163
  allowApprove: () => options.configRef().review.approve,
164
+ reviewOn: () => options.configRef().review.on,
158
165
  isBotInTeam,
159
166
  authToken,
160
167
  fetchImpl,
@@ -187,6 +194,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
187
194
  options.router.registerMembership('github', membership)
188
195
  options.router.registerChannelNameResolver('github', channelNameResolver)
189
196
  options.router.registerSelfIdentity('github', selfIdentityResolver)
197
+ options.router.registerReviewThreadResolver('github', reviewThreadResolver)
190
198
  options.router.registerFetchAttachment('github', fetchAttachment)
191
199
  try {
192
200
  server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
@@ -201,6 +209,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
201
209
  options.router.unregisterMembership('github', membership)
202
210
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
203
211
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
212
+ options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
204
213
  options.router.unregisterFetchAttachment('github', fetchAttachment)
205
214
  await auth.dispose()
206
215
  delete process.env.GH_TOKEN
@@ -324,6 +333,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
324
333
  options.router.unregisterMembership('github', membership)
325
334
  options.router.unregisterChannelNameResolver('github', channelNameResolver)
326
335
  options.router.unregisterSelfIdentity('github', selfIdentityResolver)
336
+ options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
327
337
  options.router.unregisterFetchAttachment('github', fetchAttachment)
328
338
  await server?.stop()
329
339
  // Detach hooks AFTER closing the listener so any in-flight deliveries
@@ -0,0 +1,246 @@
1
+ import type { ReviewThreadResolveRequest, ReviewThreadResolveResult, ReviewThreadResolver } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
7
+
8
+ // One page of review threads. `first: 100` is the GraphQL max; a busy PR can
9
+ // carry more, so the resolver paginates until it matches the root comment id
10
+ // or exhausts the pages — stopping early on a 404-equivalent (thread absent)
11
+ // rather than fabricating a node id.
12
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
13
+
14
+ const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
15
+
16
+ type ReviewThreadNode = {
17
+ id: string
18
+ isResolved: boolean
19
+ rootCommentId: number | null
20
+ rootAuthorLogin: string | null
21
+ }
22
+
23
+ type ThreadLookup =
24
+ | { kind: 'found'; thread: ReviewThreadNode }
25
+ | { kind: 'absent' }
26
+ | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
27
+
28
+ export function createGithubReviewThreadResolver(deps: {
29
+ token: (context?: GithubAuthContext) => Promise<string>
30
+ selfLogin: () => string | null
31
+ fetchImpl?: typeof fetch
32
+ }): ReviewThreadResolver {
33
+ const fetchImpl = deps.fetchImpl ?? fetch
34
+ return async (req): Promise<ReviewThreadResolveResult> => {
35
+ if (req.adapter !== 'github') {
36
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
37
+ }
38
+ const target = parseTarget(req)
39
+ if (target === null) {
40
+ return {
41
+ ok: false,
42
+ error: `unparseable github review-thread target (chat=${req.chat}, root=${req.rootCommentId})`,
43
+ code: 'transient',
44
+ }
45
+ }
46
+ const selfLogin = deps.selfLogin()
47
+ if (selfLogin === null) {
48
+ return {
49
+ ok: false,
50
+ error: 'github self-identity not resolved; cannot verify thread authorship',
51
+ code: 'transient',
52
+ }
53
+ }
54
+
55
+ const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
56
+ const lookup = await findThread(fetchImpl, token, target)
57
+ if (lookup.kind === 'error') return lookup.result
58
+ if (lookup.kind === 'absent') {
59
+ return {
60
+ ok: false,
61
+ error: `no review thread rooted at comment ${target.rootCommentId} on ${req.chat}`,
62
+ code: 'no-match',
63
+ }
64
+ }
65
+
66
+ const thread = lookup.thread
67
+ // The load-bearing guard: only the bot may resolve the bot's own thread.
68
+ // Resolving a human reviewer's thread would erase their open question.
69
+ if (thread.rootAuthorLogin !== selfLogin) {
70
+ return {
71
+ ok: false,
72
+ error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
73
+ code: 'not-author',
74
+ }
75
+ }
76
+ if (thread.isResolved) return { ok: true, alreadyResolved: true }
77
+
78
+ return await runResolveMutation(fetchImpl, token, thread.id)
79
+ }
80
+ }
81
+
82
+ type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
83
+
84
+ function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
85
+ const [owner, repo, ...rest] = req.workspace.split('/')
86
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
87
+ const prMatch = /^pr:(\d+)$/.exec(req.chat)
88
+ if (prMatch === null) return null
89
+ const prNumber = parseDecimalId(prMatch[1])
90
+ const rootCommentId = parseDecimalId(req.rootCommentId)
91
+ if (prNumber === null || rootCommentId === null) return null
92
+ return { owner, repo, prNumber, rootCommentId }
93
+ }
94
+
95
+ // Strict decimal-id parse: `Number()` would coerce '' -> 0, '1e2' -> 100, and
96
+ // silently round ids past 2^53 (GitHub comment ids are large), any of which
97
+ // could match the WRONG thread. Demand a plain run of digits and a safe
98
+ // integer, so a malformed or oversized id fails closed (no resolution) rather
99
+ // than resolving a collided thread.
100
+ function parseDecimalId(value: string | undefined): number | null {
101
+ if (value === undefined || !/^\d+$/.test(value)) return null
102
+ const n = Number(value)
103
+ return Number.isSafeInteger(n) ? n : null
104
+ }
105
+
106
+ async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
107
+ let after: string | null = null
108
+ for (;;) {
109
+ let response: Response
110
+ try {
111
+ response = await fetchImpl(GRAPHQL_ENDPOINT, {
112
+ method: 'POST',
113
+ headers: githubJsonHeaders(token),
114
+ body: JSON.stringify({
115
+ query: THREADS_QUERY,
116
+ variables: { owner: target.owner, name: target.repo, number: target.prNumber, after },
117
+ }),
118
+ })
119
+ } catch (err) {
120
+ return {
121
+ kind: 'error',
122
+ result: { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' },
123
+ }
124
+ }
125
+ const parsed = await parseThreadsPage(response)
126
+ if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
127
+
128
+ for (const node of parsed.nodes) {
129
+ if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
130
+ }
131
+ if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
132
+ after = parsed.endCursor
133
+ }
134
+ }
135
+
136
+ type ThreadsPage =
137
+ | { kind: 'ok'; nodes: ReviewThreadNode[]; hasNextPage: boolean; endCursor: string | null }
138
+ | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
139
+
140
+ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
141
+ if (!response.ok) {
142
+ const text = await response.text().catch(() => '')
143
+ return {
144
+ kind: 'error',
145
+ result: {
146
+ ok: false,
147
+ error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
148
+ code: classifyStatus(response.status),
149
+ },
150
+ }
151
+ }
152
+ const body = (await response.json().catch(() => null)) as GraphqlThreadsResponse | null
153
+ if (body === null)
154
+ return { kind: 'error', result: { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' } }
155
+ if (body.errors !== undefined && body.errors.length > 0) {
156
+ return {
157
+ kind: 'error',
158
+ result: {
159
+ ok: false,
160
+ error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
161
+ code: 'transient',
162
+ },
163
+ }
164
+ }
165
+ const connection = body.data?.repository?.pullRequest?.reviewThreads
166
+ if (connection === undefined)
167
+ return {
168
+ kind: 'error',
169
+ result: { ok: false, error: 'GitHub GraphQL response missing reviewThreads', code: 'transient' },
170
+ }
171
+ const nodes = connection.nodes.map((n) => {
172
+ const root = n.comments.nodes[0]
173
+ return {
174
+ id: n.id,
175
+ isResolved: n.isResolved,
176
+ rootCommentId: root?.databaseId ?? null,
177
+ rootAuthorLogin: root?.author?.login ?? null,
178
+ }
179
+ })
180
+ return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
181
+ }
182
+
183
+ async function runResolveMutation(
184
+ fetchImpl: typeof fetch,
185
+ token: string,
186
+ threadId: string,
187
+ ): Promise<ReviewThreadResolveResult> {
188
+ let response: Response
189
+ try {
190
+ response = await fetchImpl(GRAPHQL_ENDPOINT, {
191
+ method: 'POST',
192
+ headers: githubJsonHeaders(token),
193
+ body: JSON.stringify({ query: RESOLVE_MUTATION, variables: { threadId } }),
194
+ })
195
+ } catch (err) {
196
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
197
+ }
198
+ if (!response.ok) {
199
+ const text = await response.text().catch(() => '')
200
+ return {
201
+ ok: false,
202
+ error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
203
+ code: classifyStatus(response.status),
204
+ }
205
+ }
206
+ const body = (await response.json().catch(() => null)) as GraphqlResolveResponse | null
207
+ if (body === null) return { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' }
208
+ if (body.errors !== undefined && body.errors.length > 0) {
209
+ return {
210
+ ok: false,
211
+ error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
212
+ code: 'transient',
213
+ }
214
+ }
215
+ if (body.data?.resolveReviewThread?.thread?.isResolved === true) return { ok: true }
216
+ return { ok: false, error: 'resolveReviewThread mutation did not report isResolved', code: 'transient' }
217
+ }
218
+
219
+ function classifyStatus(status: number): 'permission-denied' | 'not-found' | 'transient' {
220
+ if (status === 401 || status === 403) return 'permission-denied'
221
+ if (status === 404) return 'not-found'
222
+ return 'transient'
223
+ }
224
+
225
+ type GraphqlThreadsResponse = {
226
+ data?: {
227
+ repository?: {
228
+ pullRequest?: {
229
+ reviewThreads?: {
230
+ pageInfo: { hasNextPage: boolean; endCursor: string | null }
231
+ nodes: Array<{
232
+ id: string
233
+ isResolved: boolean
234
+ comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
235
+ }>
236
+ }
237
+ }
238
+ }
239
+ }
240
+ errors?: Array<{ message: string }>
241
+ }
242
+
243
+ type GraphqlResolveResponse = {
244
+ data?: { resolveReviewThread?: { thread?: { id: string; isResolved: boolean } } }
245
+ errors?: Array<{ message: string }>
246
+ }
@@ -54,17 +54,25 @@ export async function registerGithubWebhooks(
54
54
  options: RegisterGithubWebhooksOptions,
55
55
  ): Promise<WebhookRegistrationResult> {
56
56
  const fetchImpl = options.fetchImpl ?? fetch
57
- const repos: WebhookRepoResult[] = []
58
- for (const repo of options.repos) {
59
- let token: string
60
- try {
61
- token = await options.token(repo)
62
- } catch (err) {
63
- repos.push({ repo, action: 'failed', error: describe(err) })
64
- continue
65
- }
66
- repos.push(await registerOne(fetchImpl, token, repo, options))
67
- }
57
+ // Dedupe before fanning out: the serial loop self-corrected on a repeated
58
+ // repo (the second pass saw the first pass's hook and updated it), but
59
+ // concurrent passes would both list an empty set and each POST a hook,
60
+ // creating a duplicate. Collapsing to distinct slugs restores convergence.
61
+ const distinctRepos = [...new Set(options.repos)]
62
+ // Repos are independent (own installation token, own hooks), so register them
63
+ // concurrently. Every task resolves to a result (failures are caught into a
64
+ // `failed` entry, never thrown), so the batch never rejects and order is kept.
65
+ const repos = await Promise.all(
66
+ distinctRepos.map(async (repo): Promise<WebhookRepoResult> => {
67
+ let token: string
68
+ try {
69
+ token = await options.token(repo)
70
+ } catch (err) {
71
+ return { repo, action: 'failed', error: describe(err) }
72
+ }
73
+ return registerOne(fetchImpl, token, repo, options)
74
+ }),
75
+ )
68
76
  return { repos }
69
77
  }
70
78
 
@@ -82,17 +90,17 @@ export async function deregisterGithubWebhooks(
82
90
  options: DeregisterGithubWebhooksOptions,
83
91
  ): Promise<WebhookDeregistrationResult> {
84
92
  const fetchImpl = options.fetchImpl ?? fetch
85
- const hooks: WebhookDeregistrationResult['hooks'] = []
86
- for (const hook of options.hooks) {
87
- let token: string
88
- try {
89
- token = await options.token(hook.repo)
90
- } catch (err) {
91
- hooks.push({ ...hook, action: 'failed', error: describe(err) })
92
- continue
93
- }
94
- hooks.push(await deleteOne(fetchImpl, token, hook))
95
- }
93
+ const hooks = await Promise.all(
94
+ options.hooks.map(async (hook): Promise<WebhookDeregistrationResult['hooks'][number]> => {
95
+ let token: string
96
+ try {
97
+ token = await options.token(hook.repo)
98
+ } catch (err) {
99
+ return { ...hook, action: 'failed', error: describe(err) }
100
+ }
101
+ return deleteOne(fetchImpl, token, hook)
102
+ }),
103
+ )
96
104
  return { hooks }
97
105
  }
98
106
 
@@ -125,11 +133,8 @@ async function registerOne(
125
133
  // inspecting the repo's webhook list.
126
134
  const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
127
135
  await updateHook(fetchImpl, token, parsed, keep!, options)
128
- let stalePruned = 0
129
- for (const id of stale) {
130
- const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
131
- if (ok) stalePruned++
132
- }
136
+ const pruned = await Promise.all(stale.map((id) => tryDeleteHook(fetchImpl, token, parsed, id)))
137
+ const stalePruned = pruned.filter(Boolean).length
133
138
  return { repo, action: 'updated', hookId: keep!, stalePruned }
134
139
  } catch (err) {
135
140
  return { repo, action: 'failed', error: describe(err) }