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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +133 -27
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +122 -8
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +26 -1
- package/src/agent/subagents.ts +75 -3
- package/src/agent/system-prompt.ts +5 -1
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +126 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +23 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +172 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +506 -45
- package/src/channels/schema.ts +21 -4
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +69 -1
- package/src/cli/inspect-controller.ts +132 -33
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +28 -16
- package/src/inspect/index.ts +53 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +74 -5
- package/src/sandbox/build.ts +20 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/policy.ts +22 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +178 -0
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +126 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- 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
|
-
|
|
270
|
+
text,
|
|
254
271
|
id,
|
|
255
|
-
|
|
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
|
-
|
|
321
|
+
prText,
|
|
284
322
|
id,
|
|
285
|
-
|
|
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
|
-
|
|
348
|
+
text,
|
|
302
349
|
id,
|
|
303
|
-
|
|
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
|
-
|
|
373
|
+
text,
|
|
319
374
|
id,
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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) }
|