typeclaw 0.28.0 → 0.28.2
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/package.json +1 -1
- package/src/agent/provider-error.ts +33 -1
- package/src/agent/tools/channel-reply.ts +23 -0
- package/src/agent/tools/channel-send.ts +22 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +3 -3
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/channels/adapters/github/inbound.ts +7 -6
- package/src/channels/adapters/github/index.ts +46 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +206 -0
- package/src/channels/github-rereview-guard.ts +100 -0
- package/src/channels/github-review-claim.ts +58 -10
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +3 -2
- package/src/channels/types.ts +36 -0
- package/src/inspect/transcript-view.ts +10 -0
- package/src/server/index.ts +11 -1
- package/src/shared/protocol.ts +18 -6
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
package/package.json
CHANGED
|
@@ -13,7 +13,39 @@ import type { AgentSession } from './index'
|
|
|
13
13
|
// not by this helper.
|
|
14
14
|
|
|
15
15
|
export type DetectedProviderError = {
|
|
16
|
+
// Raw provider text. Safe for logs and operator-only surfaces (TUI,
|
|
17
|
+
// `typeclaw logs`), but NOT for channels — see `safeMessage`.
|
|
16
18
|
message: string
|
|
19
|
+
// Redacted, user-facing variant for public/multi-user channels. Known-safe
|
|
20
|
+
// operational classes (rate/usage limit, billing/quota) map to a canonical
|
|
21
|
+
// sentence; everything else (malformed-response SDK dumps, unknown failures)
|
|
22
|
+
// collapses to a generic notice so provider response bodies, URLs, or tokens
|
|
23
|
+
// can never leak to a channel.
|
|
24
|
+
safeMessage: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const GENERIC_SAFE_NOTICE = 'The upstream LLM provider failed. Operators can check `typeclaw logs` for details.'
|
|
28
|
+
|
|
29
|
+
// Each entry pairs a narrow matcher against the raw provider text with the
|
|
30
|
+
// canonical, leak-free sentence shown in channels. Matchers are intentionally
|
|
31
|
+
// specific: a miss falls through to GENERIC_SAFE_NOTICE rather than echoing raw
|
|
32
|
+
// text, so adding a new class is opt-in and never widens what we expose.
|
|
33
|
+
const SAFE_CLASSES: ReadonlyArray<{ match: RegExp; safe: string }> = [
|
|
34
|
+
{
|
|
35
|
+
match: /\b(usage limit|rate limit|rate.?limited|too many requests|429)\b/i,
|
|
36
|
+
safe: 'The upstream LLM provider is rate-limited (usage limit reached). Try again shortly.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
match: /\b(billing|quota|insufficient.*(credit|fund|balance)|payment|account is not active)\b/i,
|
|
40
|
+
safe: 'The upstream LLM provider rejected the request for a billing/quota reason. Operators can check `typeclaw logs` for details.',
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
function toSafeMessage(raw: string): string {
|
|
45
|
+
for (const { match, safe } of SAFE_CLASSES) {
|
|
46
|
+
if (match.test(raw)) return safe
|
|
47
|
+
}
|
|
48
|
+
return GENERIC_SAFE_NOTICE
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
export function detectProviderError(message: unknown): DetectedProviderError | null {
|
|
@@ -25,7 +57,7 @@ export function detectProviderError(message: unknown): DetectedProviderError | n
|
|
|
25
57
|
// ignore aborts (no surface to render them on).
|
|
26
58
|
if (m.stopReason !== 'error') return null
|
|
27
59
|
const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
|
|
28
|
-
return { message: text }
|
|
60
|
+
return { message: text, safeMessage: toSafeMessage(text) }
|
|
29
61
|
}
|
|
30
62
|
|
|
31
63
|
export type ProviderErrorListener = (error: DetectedProviderError) => void
|
|
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
|
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
4
|
import { checkFalseReceipt } from '@/channels/github-false-receipt'
|
|
5
|
+
import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
|
|
5
6
|
import {
|
|
6
7
|
containsKimiToolDelimiter,
|
|
7
8
|
isNoReplySignal,
|
|
@@ -159,6 +160,28 @@ export function createChannelReplyTool({
|
|
|
159
160
|
}
|
|
160
161
|
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
161
162
|
|
|
163
|
+
// Re-review stranding guard: block a thread close-out / verdict ack while
|
|
164
|
+
// the bot still holds its own CHANGES_REQUESTED on this PR, so it can't
|
|
165
|
+
// silently leave the PR blocked (PR #644). Runs before the resolve so a
|
|
166
|
+
// blocked close-out never mutates the thread.
|
|
167
|
+
const rereview = await evaluateRereviewGuard({
|
|
168
|
+
adapter: origin.adapter,
|
|
169
|
+
workspace: origin.workspace,
|
|
170
|
+
chat: origin.chat,
|
|
171
|
+
thread: origin.thread,
|
|
172
|
+
text,
|
|
173
|
+
wantsResolve: params.resolve_review_thread === true,
|
|
174
|
+
isContinue: keepTurnAlive,
|
|
175
|
+
getReviewState: (req) => router.getReviewState(req),
|
|
176
|
+
})
|
|
177
|
+
if (rereview.block) {
|
|
178
|
+
logger.warn(formatChannelToolFailure('channel_reply', rereview.reason))
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: 'text' as const, text: `channel_reply denied: ${rereview.reason}` }],
|
|
181
|
+
details: { ok: false, error: rereview.reason },
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
162
185
|
// Resolve BEFORE posting: a successful channel_reply ends the turn, so a
|
|
163
186
|
// resolve attempted "after" the ack would never run (the exact bug this
|
|
164
187
|
// flag fixes). Resolve-failure blocks the reply so the agent never posts
|
|
@@ -2,6 +2,7 @@ import { Type } from '@mariozechner/pi-ai'
|
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
4
|
import { checkFalseReceipt } from '@/channels/github-false-receipt'
|
|
5
|
+
import { evaluateRereviewGuard } from '@/channels/github-rereview-guard'
|
|
5
6
|
import { recordResolvedThread } from '@/channels/github-review-turn-ledger'
|
|
6
7
|
import {
|
|
7
8
|
containsKimiToolDelimiter,
|
|
@@ -176,6 +177,27 @@ export function createChannelSendTool({
|
|
|
176
177
|
}
|
|
177
178
|
const falseReceiptNotice = falseReceipt.kind === 'warn' ? falseReceipt.notice : null
|
|
178
179
|
|
|
180
|
+
// Re-review stranding guard (mirrors channel_reply): block a thread
|
|
181
|
+
// close-out / verdict ack while the bot still holds its own
|
|
182
|
+
// CHANGES_REQUESTED on this PR, before the resolve mutates the thread.
|
|
183
|
+
const rereview = await evaluateRereviewGuard({
|
|
184
|
+
adapter,
|
|
185
|
+
workspace: params.workspace,
|
|
186
|
+
chat: params.chat,
|
|
187
|
+
thread: params.thread ?? null,
|
|
188
|
+
text: bodyText,
|
|
189
|
+
wantsResolve,
|
|
190
|
+
isContinue: false,
|
|
191
|
+
getReviewState: (req) => router.getReviewState(req),
|
|
192
|
+
})
|
|
193
|
+
if (rereview.block) {
|
|
194
|
+
logger.warn(formatChannelToolFailure('channel_send', rereview.reason))
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: 'text' as const, text: `channel_send denied: ${rereview.reason}` }],
|
|
197
|
+
details: { ok: false, error: rereview.reason },
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
179
201
|
// Resolve BEFORE posting (mirrors channel_reply): a failed resolve must
|
|
180
202
|
// block the acknowledgement so the bot never posts "addressed — resolving"
|
|
181
203
|
// next to a still-open thread. The router enforces that only the bot's own
|
|
@@ -94,7 +94,7 @@ The first thing you do for any review is:
|
|
|
94
94
|
|
|
95
95
|
You can load more than one skill if the target genuinely spans domains (e.g. a design doc with code examples — load \`design\`-something AND \`code-review\`). Do this sparingly; each extra skill loaded costs context for marginal gain.
|
|
96
96
|
|
|
97
|
-
Do NOT proceed past step 1 without loading a skill unless you have explicitly decided that no domain skill applies AND that the universal contract alone is sufficient.
|
|
97
|
+
Do NOT proceed past step 1 without loading a skill unless you have explicitly decided that no domain skill applies AND that the universal contract alone is sufficient. This skill-selection decision is internal reasoning — keep it out of \`<summary>\`, which stays a terse, author-facing verdict justification per the output contract.
|
|
98
98
|
|
|
99
99
|
## Universal review philosophy
|
|
100
100
|
|
|
@@ -124,7 +124,7 @@ End every response with a single \`<review>\` block. Use this exact structure:
|
|
|
124
124
|
|
|
125
125
|
<review>
|
|
126
126
|
<summary>
|
|
127
|
-
[
|
|
127
|
+
[Two or three sentences, no more. State only your overall judgment and the one or two facts that justify it — the verdict's reasoning, not a recap. The parent may post this verbatim as the review body on an approval, so write it for the PR author, not for an operator: do NOT restate what the change does (they wrote the description), do NOT narrate your process ("I reviewed…", "I loaded the X skill because…", "I checked…"), do NOT list which skills you loaded. Lead with the substance. If the target is too large to review in one pass, say so here and propose a chunking strategy; produce findings for what you did review.]
|
|
128
128
|
</summary>
|
|
129
129
|
<findings>
|
|
130
130
|
<finding severity="blocker|concern|nit|praise" location="path/to/file.ts:42, diff hunk, paragraph reference, or general">
|
|
@@ -167,7 +167,7 @@ export function createReviewerSubagent(): Subagent<ReviewerPayload> {
|
|
|
167
167
|
Available skills:
|
|
168
168
|
${REVIEWER_SKILLS.map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')}
|
|
169
169
|
|
|
170
|
-
If none of the listed skills fit the target, load \`general
|
|
170
|
+
If none of the listed skills fit the target, load \`general\`. Keep the skill-selection decision internal — do NOT narrate which skill you loaded or why in \`<summary>\`, per the output contract.`,
|
|
171
171
|
})
|
|
172
172
|
|
|
173
173
|
return {
|
|
@@ -20,7 +20,7 @@ You have been asked to review something that does not clearly fit a specific dom
|
|
|
20
20
|
|
|
21
21
|
A general review is the hardest because there are no domain shortcuts. Replace shortcuts with discipline:
|
|
22
22
|
|
|
23
|
-
1. **State the target's purpose in your own words.** What is the artifact trying to achieve? Who is it for?
|
|
23
|
+
1. **State the target's purpose in your own words — to yourself, as a comprehension check.** What is the artifact trying to achieve? Who is it for? If you cannot state it after reading, that itself is a finding — the artifact does not communicate its purpose. This is your private grounding, not summary copy: keep the restatement out of \`<summary>\`, which stays a terse verdict justification per the output contract.
|
|
24
24
|
2. **Identify the load-bearing claims.** What does the artifact assert that, if wrong, would invalidate the whole thing? List them mentally before looking for issues.
|
|
25
25
|
3. **Stress-test the load-bearing claims.** For each one: is the evidence sufficient? Are the assumptions stated? Are the counter-arguments addressed?
|
|
26
26
|
4. **Stress-test the boundaries.** Where does the artifact's argument or design stop applying? Does it acknowledge that boundary, or does it overgeneralize?
|
|
@@ -411,6 +411,13 @@ export function classifyGithubInbound(
|
|
|
411
411
|
// the PR is non-draft once ready — preserving "review when no longer draft".
|
|
412
412
|
const isOpenLike = action === 'opened' || action === 'ready_for_review'
|
|
413
413
|
if (isOpenLike && reviewOn === 'opened') {
|
|
414
|
+
// Draft opened under `review.on: "opened"`: skip cleanly (null wakes no
|
|
415
|
+
// session) and wait for the `ready_for_review` trigger. Must NOT fall
|
|
416
|
+
// through to the awareness path below, where a multi-collaborator repo
|
|
417
|
+
// silently `observed`s it — a draft whose `ready_for_review` delivery is
|
|
418
|
+
// later lost would then never get reviewed (huxley#1721). `review_requested`
|
|
419
|
+
// on a draft is unaffected: it returns above via classifyReviewRequest.
|
|
420
|
+
if (readBoolean(pr, 'draft') === true) return null
|
|
414
421
|
const trigger = classifyOpenedReviewTrigger({
|
|
415
422
|
payload,
|
|
416
423
|
pr,
|
|
@@ -644,12 +651,6 @@ function classifyOpenedReviewTrigger(input: OpenedReviewTriggerInput): InboundMe
|
|
|
644
651
|
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
645
652
|
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
646
653
|
|
|
647
|
-
// A draft PR is work-in-progress, so the automatic `opened` path skips it: null
|
|
648
|
-
// here drops to awareness-only context (like a non-`opened` reviewOn) instead of
|
|
649
|
-
// waking a review. An explicit `review_requested` still triggers on a draft via
|
|
650
|
-
// classifyReviewRequest, preserving "skip until explicitly requested".
|
|
651
|
-
if (readBoolean(pr, 'draft') === true) return null
|
|
652
|
-
|
|
653
654
|
const title = readString(pr, 'title') ?? `#${number}`
|
|
654
655
|
const head = readString(readRecord(pr.head), 'ref')
|
|
655
656
|
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GithubTokenBridge } from '@/channels/github-token-bridge'
|
|
2
2
|
import type { ChannelRouter } from '@/channels/router'
|
|
3
3
|
import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
|
|
4
|
-
import type { ChannelSelfIdentityResolver } from '@/channels/types'
|
|
4
|
+
import type { ChannelSelfIdentityResolver, InboundMessage } from '@/channels/types'
|
|
5
5
|
import { resolveSecret } from '@/secrets/resolve'
|
|
6
6
|
import type { GithubSecretsBlock } from '@/secrets/schema'
|
|
7
7
|
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
parseListHooksPermissionStatus,
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
|
+
import { reconcileOpenPrs } from './reconcile-open-prs'
|
|
25
|
+
import { createGithubReviewStateResolver } from './review-state'
|
|
24
26
|
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
25
27
|
import { createTeamMembershipChecker } from './team-membership'
|
|
26
28
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
@@ -143,6 +145,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
143
145
|
selfLogin: () => selfLogin,
|
|
144
146
|
fetchImpl,
|
|
145
147
|
})
|
|
148
|
+
const reviewStateResolver = createGithubReviewStateResolver({
|
|
149
|
+
token: authToken,
|
|
150
|
+
selfLogin: () => selfLogin,
|
|
151
|
+
approve: () => options.configRef().review.approve,
|
|
152
|
+
fetchImpl,
|
|
153
|
+
})
|
|
146
154
|
const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
|
|
147
155
|
// GitHub addresses by `@login`, not the numeric id, so `username` carries
|
|
148
156
|
// the login the model should type; the id is kept for completeness.
|
|
@@ -153,6 +161,19 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
153
161
|
const typing = async (): Promise<void> => {}
|
|
154
162
|
const dedup = createDeliveryDedup()
|
|
155
163
|
const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
|
|
164
|
+
// Shared inbound entry. Both the live webhook handler and the startup
|
|
165
|
+
// reconciliation pass route through this so a replayed PR takes the exact
|
|
166
|
+
// same path a real delivery would.
|
|
167
|
+
const routeInbound = (message: InboundMessage): void => {
|
|
168
|
+
rememberWorkspace(message.workspace, message.chat)
|
|
169
|
+
// Ack-first: wrap in Promise.resolve so a synchronous throw inside
|
|
170
|
+
// router.route() cannot prevent the 200 response from being returned.
|
|
171
|
+
void Promise.resolve()
|
|
172
|
+
.then(() => options.router.route(message))
|
|
173
|
+
.catch((err: unknown) => {
|
|
174
|
+
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
156
177
|
const handler = createGithubWebhookHandler({
|
|
157
178
|
webhookSecret,
|
|
158
179
|
dedup,
|
|
@@ -166,16 +187,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
166
187
|
authToken,
|
|
167
188
|
fetchImpl,
|
|
168
189
|
logger,
|
|
169
|
-
route:
|
|
170
|
-
rememberWorkspace(message.workspace, message.chat)
|
|
171
|
-
// Ack-first: wrap in Promise.resolve so a synchronous throw inside
|
|
172
|
-
// router.route() cannot prevent the 200 response from being returned.
|
|
173
|
-
void Promise.resolve()
|
|
174
|
-
.then(() => options.router.route(message))
|
|
175
|
-
.catch((err: unknown) => {
|
|
176
|
-
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
177
|
-
})
|
|
178
|
-
},
|
|
190
|
+
route: routeInbound,
|
|
179
191
|
})
|
|
180
192
|
|
|
181
193
|
return {
|
|
@@ -195,6 +207,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
195
207
|
options.router.registerChannelNameResolver('github', channelNameResolver)
|
|
196
208
|
options.router.registerSelfIdentity('github', selfIdentityResolver)
|
|
197
209
|
options.router.registerReviewThreadResolver('github', reviewThreadResolver)
|
|
210
|
+
options.router.registerReviewStateResolver('github', reviewStateResolver)
|
|
198
211
|
options.router.registerFetchAttachment('github', fetchAttachment)
|
|
199
212
|
try {
|
|
200
213
|
server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
|
|
@@ -210,6 +223,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
210
223
|
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
211
224
|
options.router.unregisterSelfIdentity('github', selfIdentityResolver)
|
|
212
225
|
options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
|
|
226
|
+
options.router.unregisterReviewStateResolver('github', reviewStateResolver)
|
|
213
227
|
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
214
228
|
await auth.dispose()
|
|
215
229
|
delete process.env.GH_TOKEN
|
|
@@ -321,6 +335,26 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
321
335
|
)
|
|
322
336
|
logRegistrationOutcome(logger, registration, options.secrets.auth.type)
|
|
323
337
|
}
|
|
338
|
+
// Catch up on PRs whose opened/ready_for_review/review_requested delivery
|
|
339
|
+
// was missed (tunnel-URL churn, dropped delivery, downtime). Best-effort
|
|
340
|
+
// and last so a failure here never blocks the adapter from coming up; the
|
|
341
|
+
// helper swallows per-repo errors internally. Runs on every start(), so a
|
|
342
|
+
// tunnel-driven restart re-checks too. `off` short-circuits inside.
|
|
343
|
+
if (repos.length > 0) {
|
|
344
|
+
await reconcileOpenPrs({
|
|
345
|
+
repos,
|
|
346
|
+
reviewOn: cfg.review.on,
|
|
347
|
+
selfLogin,
|
|
348
|
+
authType: options.secrets.auth.type,
|
|
349
|
+
token: authToken,
|
|
350
|
+
route: routeInbound,
|
|
351
|
+
logger,
|
|
352
|
+
isBotInTeam,
|
|
353
|
+
fetchImpl,
|
|
354
|
+
}).catch((err: unknown) => {
|
|
355
|
+
logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
356
|
+
})
|
|
357
|
+
}
|
|
324
358
|
},
|
|
325
359
|
async stop(): Promise<void> {
|
|
326
360
|
if (!started) return
|
|
@@ -334,6 +368,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
334
368
|
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
335
369
|
options.router.unregisterSelfIdentity('github', selfIdentityResolver)
|
|
336
370
|
options.router.unregisterReviewThreadResolver('github', reviewThreadResolver)
|
|
371
|
+
options.router.unregisterReviewStateResolver('github', reviewStateResolver)
|
|
337
372
|
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
338
373
|
await server?.stop()
|
|
339
374
|
// Detach hooks AFTER closing the listener so any in-flight deliveries
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { GithubReviewOn } from '@/channels/schema'
|
|
2
|
+
import type { InboundMessage } from '@/channels/types'
|
|
3
|
+
|
|
4
|
+
import type { GithubAuthContext } from './auth'
|
|
5
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
6
|
+
import type { TeamMembershipChecker } from './team-membership'
|
|
7
|
+
|
|
8
|
+
// Catches up on review work that a live webhook delivery missed. The github
|
|
9
|
+
// adapter only acts on deliveries it receives, so a `pull_request.opened` /
|
|
10
|
+
// `ready_for_review` dropped while the tunnel URL was churning (cloudflare-quick
|
|
11
|
+
// mints a fresh host every restart) leaves an open PR permanently un-reviewed —
|
|
12
|
+
// nothing wakes the bot for it again. This pass runs on every adapter start()
|
|
13
|
+
// (cold boot AND tunnel-driven restart) and replays the PRs that still need a
|
|
14
|
+
// review as synthetic inbounds through the same router path a real webhook uses.
|
|
15
|
+
//
|
|
16
|
+
// It is intentionally NOT a substitute for webhooks: it is a floor, not the
|
|
17
|
+
// primary path. Drift between a missed delivery and the next start() is the
|
|
18
|
+
// reconciliation window; webhooks remain the low-latency path when delivery
|
|
19
|
+
// works.
|
|
20
|
+
|
|
21
|
+
export type ReconcileOpenPrsOptions = {
|
|
22
|
+
repos: readonly string[]
|
|
23
|
+
reviewOn: GithubReviewOn
|
|
24
|
+
selfLogin: string | null
|
|
25
|
+
authType: 'pat' | 'app'
|
|
26
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
27
|
+
route: (message: InboundMessage) => void
|
|
28
|
+
logger: { info: (m: string) => void; warn: (m: string) => void }
|
|
29
|
+
// Resolves whether the bot is a member of a requested team, gating
|
|
30
|
+
// team-requested reviews under review.on 'review_requested' (mirrors the
|
|
31
|
+
// live webhook path's isMyTeam check in classifyReviewRequest). Omitted in
|
|
32
|
+
// tests that don't exercise team requests; treated as "not a member".
|
|
33
|
+
isBotInTeam?: TeamMembershipChecker
|
|
34
|
+
fetchImpl?: typeof fetch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ReconcileOutcome = { repo: string; scanned: number; replayed: number } | { repo: string; error: string }
|
|
38
|
+
|
|
39
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
40
|
+
|
|
41
|
+
export async function reconcileOpenPrs(options: ReconcileOpenPrsOptions): Promise<ReconcileOutcome[]> {
|
|
42
|
+
// `off` disables code review entirely, so there is nothing to catch up on.
|
|
43
|
+
if (options.reviewOn === 'off') return []
|
|
44
|
+
if (options.selfLogin === null) return []
|
|
45
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
46
|
+
const selfLogin = options.selfLogin
|
|
47
|
+
const decoyLogin = resolveDecoyLogin(selfLogin, options.authType)
|
|
48
|
+
|
|
49
|
+
const outcomes: ReconcileOutcome[] = []
|
|
50
|
+
for (const repo of new Set(options.repos)) {
|
|
51
|
+
const target = parseRepo(repo)
|
|
52
|
+
if (target === null) {
|
|
53
|
+
outcomes.push({ repo, error: 'malformed repo slug' })
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const outcome = await reconcileRepo(target, options, selfLogin, decoyLogin, fetchImpl)
|
|
58
|
+
outcomes.push(outcome)
|
|
59
|
+
if (outcome.replayed > 0) {
|
|
60
|
+
options.logger.info(`[github] reconcile ${repo}: replayed ${outcome.replayed}/${outcome.scanned} open PR(s)`)
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
64
|
+
options.logger.warn(`[github] reconcile ${repo} failed: ${message}`)
|
|
65
|
+
outcomes.push({ repo, error: message })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return outcomes
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function reconcileRepo(
|
|
72
|
+
target: RepoTarget,
|
|
73
|
+
options: ReconcileOpenPrsOptions,
|
|
74
|
+
selfLogin: string,
|
|
75
|
+
decoyLogin: string | null,
|
|
76
|
+
fetchImpl: typeof fetch,
|
|
77
|
+
): Promise<{ repo: string; scanned: number; replayed: number }> {
|
|
78
|
+
const repo = `${target.owner}/${target.repo}`
|
|
79
|
+
const token = await options.token({ repoSlug: repo })
|
|
80
|
+
const prs = await fetchOpenPrs(fetchImpl, token, target)
|
|
81
|
+
|
|
82
|
+
let replayed = 0
|
|
83
|
+
for (const pr of prs) {
|
|
84
|
+
const needs = await prNeedsReview({ pr, options, target, token, selfLogin, decoyLogin, fetchImpl })
|
|
85
|
+
if (!needs) continue
|
|
86
|
+
options.route(buildSyntheticInbound(pr, target))
|
|
87
|
+
replayed += 1
|
|
88
|
+
}
|
|
89
|
+
return { repo, scanned: prs.length, replayed }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function prNeedsReview(input: {
|
|
93
|
+
pr: OpenPr
|
|
94
|
+
options: ReconcileOpenPrsOptions
|
|
95
|
+
target: RepoTarget
|
|
96
|
+
token: string
|
|
97
|
+
selfLogin: string
|
|
98
|
+
decoyLogin: string | null
|
|
99
|
+
fetchImpl: typeof fetch
|
|
100
|
+
}): Promise<boolean> {
|
|
101
|
+
const { pr, options, target, token, selfLogin, decoyLogin, fetchImpl } = input
|
|
102
|
+
if (isSelfAuthored(pr, selfLogin, decoyLogin)) return false
|
|
103
|
+
|
|
104
|
+
if (options.reviewOn === 'review_requested') {
|
|
105
|
+
// Only the explicit request wakes a review under this mode. A draft can
|
|
106
|
+
// still carry a request, so draft state is irrelevant here. Mirrors the
|
|
107
|
+
// live path's `isMeAsUser || isMyTeam`: a direct user request, OR a team
|
|
108
|
+
// request the bot is a member of.
|
|
109
|
+
if (isUserReviewRequestedFromSelf(pr, selfLogin, decoyLogin)) return true
|
|
110
|
+
return await isTeamReviewRequestedFromSelf(pr, target, selfLogin, options.isBotInTeam)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// reviewOn === 'opened': a non-draft PR the bot has not yet reviewed. Draft
|
|
114
|
+
// PRs wait for the ready_for_review trigger, matching the live-webhook path.
|
|
115
|
+
if (pr.draft) return false
|
|
116
|
+
return !(await botAlreadyReviewed(fetchImpl, token, target, pr.number, selfLogin))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function isTeamReviewRequestedFromSelf(
|
|
120
|
+
pr: OpenPr,
|
|
121
|
+
target: RepoTarget,
|
|
122
|
+
selfLogin: string,
|
|
123
|
+
isBotInTeam: TeamMembershipChecker | undefined,
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
if (isBotInTeam === undefined || pr.requestedTeamSlugs.length === 0) return false
|
|
126
|
+
for (const slug of pr.requestedTeamSlugs) {
|
|
127
|
+
if (await isBotInTeam({ org: target.owner, slug, login: selfLogin })) return true
|
|
128
|
+
}
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildSyntheticInbound(pr: OpenPr, target: RepoTarget): InboundMessage {
|
|
133
|
+
const branchSegment = pr.headRef !== null && pr.baseRef !== null ? ` Branch: ${pr.headRef} → ${pr.baseRef}.` : ''
|
|
134
|
+
const text =
|
|
135
|
+
`@${pr.authorLogin} opened PR #${pr.number}: "${pr.title}".${branchSegment}` +
|
|
136
|
+
' Please review the changes line-by-line and post your feedback.'
|
|
137
|
+
// Distinct from the live `pr-<id>-opened-<updatedAt>` id so a replay never
|
|
138
|
+
// collides with a real opened delivery, while repeated reconciles for the
|
|
139
|
+
// same unchanged PR dedupe against each other (same updatedAt).
|
|
140
|
+
const externalMessageId = `pr-${pr.id}-reconcile-${pr.updatedAt}`
|
|
141
|
+
return {
|
|
142
|
+
adapter: 'github',
|
|
143
|
+
workspace: `${target.owner}/${target.repo}`,
|
|
144
|
+
chat: `pr:${pr.number}`,
|
|
145
|
+
thread: null,
|
|
146
|
+
text,
|
|
147
|
+
externalMessageId,
|
|
148
|
+
authorId: String(pr.authorId),
|
|
149
|
+
authorName: pr.authorLogin,
|
|
150
|
+
authorIsBot: pr.authorIsBot,
|
|
151
|
+
isBotMention: true,
|
|
152
|
+
replyToBotMessageId: null,
|
|
153
|
+
mentionsOthers: false,
|
|
154
|
+
replyToOtherMessageId: null,
|
|
155
|
+
isDm: false,
|
|
156
|
+
ts: pr.updatedAt !== '' ? Date.parse(pr.updatedAt) || 0 : 0,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type RepoTarget = { owner: string; repo: string }
|
|
161
|
+
|
|
162
|
+
type OpenPr = {
|
|
163
|
+
number: number
|
|
164
|
+
id: number
|
|
165
|
+
title: string
|
|
166
|
+
draft: boolean
|
|
167
|
+
authorLogin: string
|
|
168
|
+
authorId: number
|
|
169
|
+
authorIsBot: boolean
|
|
170
|
+
headRef: string | null
|
|
171
|
+
baseRef: string | null
|
|
172
|
+
updatedAt: string
|
|
173
|
+
requestedReviewerLogins: string[]
|
|
174
|
+
requestedTeamSlugs: string[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseRepo(slug: string): RepoTarget | null {
|
|
178
|
+
const [owner, repo, ...rest] = slug.trim().split('/')
|
|
179
|
+
if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
|
|
180
|
+
return { owner, repo }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fetchOpenPrs(fetchImpl: typeof fetch, token: string, target: RepoTarget): Promise<OpenPr[]> {
|
|
184
|
+
const prs: OpenPr[] = []
|
|
185
|
+
let url: string | null = `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls?state=open&per_page=100`
|
|
186
|
+
while (url !== null) {
|
|
187
|
+
const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
const body = await response.text().catch(() => '')
|
|
190
|
+
throw new Error(`GitHub pulls ${response.status}${body !== '' ? `: ${body}` : ''}`)
|
|
191
|
+
}
|
|
192
|
+
const page = (await response.json().catch(() => null)) as PrRow[] | null
|
|
193
|
+
if (page === null) throw new Error('GitHub pulls returned non-JSON')
|
|
194
|
+
for (const row of page) {
|
|
195
|
+
const parsed = parsePrRow(row)
|
|
196
|
+
if (parsed !== null) prs.push(parsed)
|
|
197
|
+
}
|
|
198
|
+
url = nextLink(response.headers.get('link'))
|
|
199
|
+
}
|
|
200
|
+
return prs
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function botAlreadyReviewed(
|
|
204
|
+
fetchImpl: typeof fetch,
|
|
205
|
+
token: string,
|
|
206
|
+
target: RepoTarget,
|
|
207
|
+
prNumber: number,
|
|
208
|
+
selfLogin: string,
|
|
209
|
+
): Promise<boolean> {
|
|
210
|
+
let url: string | null =
|
|
211
|
+
`${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${prNumber}/reviews?per_page=100`
|
|
212
|
+
while (url !== null) {
|
|
213
|
+
const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const body = await response.text().catch(() => '')
|
|
216
|
+
throw new Error(`GitHub reviews ${response.status}${body !== '' ? `: ${body}` : ''}`)
|
|
217
|
+
}
|
|
218
|
+
const page = (await response.json().catch(() => null)) as ReviewRow[] | null
|
|
219
|
+
if (page === null) throw new Error('GitHub reviews returned non-JSON')
|
|
220
|
+
for (const row of page) {
|
|
221
|
+
const login = row.user?.login ?? null
|
|
222
|
+
if (login === null) continue
|
|
223
|
+
if (isSelfLogin(login, row.user?.type === 'Bot', selfLogin)) return true
|
|
224
|
+
}
|
|
225
|
+
url = nextLink(response.headers.get('link'))
|
|
226
|
+
}
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isUserReviewRequestedFromSelf(pr: OpenPr, selfLogin: string, decoyLogin: string | null): boolean {
|
|
231
|
+
return pr.requestedReviewerLogins.some(
|
|
232
|
+
(login) => isSelfLogin(login, login.endsWith(BOT_LOGIN_SUFFIX), selfLogin) || login === decoyLogin,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isSelfAuthored(pr: OpenPr, selfLogin: string, decoyLogin: string | null): boolean {
|
|
237
|
+
return isSelfLogin(pr.authorLogin, pr.authorIsBot, selfLogin) || pr.authorLogin === decoyLogin
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Mirrors review-state.ts isSelfReviewer: a GitHub App's REST login is
|
|
241
|
+
// `slug[bot]`, so the `[bot]` suffix-strip comparison is gated on the actor
|
|
242
|
+
// actually being a Bot to avoid attributing a human who owns the bare slug.
|
|
243
|
+
function isSelfLogin(login: string, isBot: boolean, selfLogin: string): boolean {
|
|
244
|
+
if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
|
|
245
|
+
return login === selfLogin
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeBotLogin(login: string): string {
|
|
249
|
+
return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A GitHub App actor's REST login is `slug[bot]`; the decoy account an operator
|
|
253
|
+
// requests for App-auth reviews is the bare slug. PAT auth has no decoy.
|
|
254
|
+
function resolveDecoyLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
|
|
255
|
+
if (authType !== 'app') return null
|
|
256
|
+
if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
|
|
257
|
+
const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
|
|
258
|
+
return slug !== '' ? slug : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function nextLink(linkHeader: string | null): string | null {
|
|
262
|
+
if (linkHeader === null) return null
|
|
263
|
+
for (const part of linkHeader.split(',')) {
|
|
264
|
+
const m = /<([^>]+)>;\s*rel="next"/.exec(part)
|
|
265
|
+
if (m !== null) return m[1] ?? null
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type PrRow = {
|
|
271
|
+
number?: unknown
|
|
272
|
+
id?: unknown
|
|
273
|
+
title?: unknown
|
|
274
|
+
draft?: unknown
|
|
275
|
+
updated_at?: unknown
|
|
276
|
+
user?: { login?: unknown; id?: unknown; type?: unknown }
|
|
277
|
+
head?: { ref?: unknown }
|
|
278
|
+
base?: { ref?: unknown }
|
|
279
|
+
requested_reviewers?: Array<{ login?: unknown }>
|
|
280
|
+
requested_teams?: Array<{ slug?: unknown }>
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type ReviewRow = { user?: { login?: string; type?: string } }
|
|
284
|
+
|
|
285
|
+
function parsePrRow(row: PrRow): OpenPr | null {
|
|
286
|
+
const number = typeof row.number === 'number' ? row.number : null
|
|
287
|
+
const id = typeof row.id === 'number' ? row.id : null
|
|
288
|
+
const authorLogin = typeof row.user?.login === 'string' ? row.user.login : null
|
|
289
|
+
if (number === null || id === null || authorLogin === null) return null
|
|
290
|
+
return {
|
|
291
|
+
number,
|
|
292
|
+
id,
|
|
293
|
+
title: typeof row.title === 'string' ? row.title : `#${number}`,
|
|
294
|
+
draft: row.draft === true,
|
|
295
|
+
authorLogin,
|
|
296
|
+
authorId: typeof row.user?.id === 'number' ? row.user.id : 0,
|
|
297
|
+
authorIsBot: row.user?.type === 'Bot',
|
|
298
|
+
headRef: typeof row.head?.ref === 'string' ? row.head.ref : null,
|
|
299
|
+
baseRef: typeof row.base?.ref === 'string' ? row.base.ref : null,
|
|
300
|
+
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : '',
|
|
301
|
+
requestedReviewerLogins: (row.requested_reviewers ?? []).flatMap((r) =>
|
|
302
|
+
typeof r.login === 'string' ? [r.login] : [],
|
|
303
|
+
),
|
|
304
|
+
requestedTeamSlugs: (row.requested_teams ?? []).flatMap((t) => (typeof t.slug === 'string' ? [t.slug] : [])),
|
|
305
|
+
}
|
|
306
|
+
}
|