typeclaw 0.28.1 → 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/tools/channel-reply.ts +1 -0
- package/src/agent/tools/channel-send.ts +1 -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 +36 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +71 -2
- package/src/channels/github-rereview-guard.ts +32 -8
- package/src/channels/github-review-claim.ts +53 -6
- package/src/channels/router.ts +12 -0
- package/src/channels/schema.ts +3 -2
- package/src/channels/types.ts +11 -6
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -171,6 +171,7 @@ export function createChannelReplyTool({
|
|
|
171
171
|
thread: origin.thread,
|
|
172
172
|
text,
|
|
173
173
|
wantsResolve: params.resolve_review_thread === true,
|
|
174
|
+
isContinue: keepTurnAlive,
|
|
174
175
|
getReviewState: (req) => router.getReviewState(req),
|
|
175
176
|
})
|
|
176
177
|
if (rereview.block) {
|
|
@@ -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,7 @@ import {
|
|
|
21
21
|
parseListHooksPermissionStatus,
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
|
+
import { reconcileOpenPrs } from './reconcile-open-prs'
|
|
24
25
|
import { createGithubReviewStateResolver } from './review-state'
|
|
25
26
|
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
26
27
|
import { createTeamMembershipChecker } from './team-membership'
|
|
@@ -160,6 +161,19 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
160
161
|
const typing = async (): Promise<void> => {}
|
|
161
162
|
const dedup = createDeliveryDedup()
|
|
162
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
|
+
}
|
|
163
177
|
const handler = createGithubWebhookHandler({
|
|
164
178
|
webhookSecret,
|
|
165
179
|
dedup,
|
|
@@ -173,16 +187,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
173
187
|
authToken,
|
|
174
188
|
fetchImpl,
|
|
175
189
|
logger,
|
|
176
|
-
route:
|
|
177
|
-
rememberWorkspace(message.workspace, message.chat)
|
|
178
|
-
// Ack-first: wrap in Promise.resolve so a synchronous throw inside
|
|
179
|
-
// router.route() cannot prevent the 200 response from being returned.
|
|
180
|
-
void Promise.resolve()
|
|
181
|
-
.then(() => options.router.route(message))
|
|
182
|
-
.catch((err: unknown) => {
|
|
183
|
-
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
184
|
-
})
|
|
185
|
-
},
|
|
190
|
+
route: routeInbound,
|
|
186
191
|
})
|
|
187
192
|
|
|
188
193
|
return {
|
|
@@ -330,6 +335,26 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
330
335
|
)
|
|
331
336
|
logRegistrationOutcome(logger, registration, options.secrets.auth.type)
|
|
332
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
|
+
}
|
|
333
358
|
},
|
|
334
359
|
async stop(): Promise<void> {
|
|
335
360
|
if (!started) return
|
|
@@ -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
|
+
}
|
|
@@ -31,11 +31,20 @@ export function createGithubReviewStateResolver(deps: {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
|
|
34
|
-
const reviews = await
|
|
34
|
+
const [reviews, reviewDecision] = await Promise.all([
|
|
35
|
+
fetchSelfReviews(fetchImpl, token, target, selfLogin),
|
|
36
|
+
fetchReviewDecision(fetchImpl, token, target),
|
|
37
|
+
])
|
|
35
38
|
if (!reviews.ok) return { ok: false, error: reviews.error, code: reviews.code }
|
|
39
|
+
if (!reviewDecision.ok) return { ok: false, error: reviewDecision.error, code: reviewDecision.code }
|
|
36
40
|
|
|
37
41
|
const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
|
|
38
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
selfBlocking: lastDecisive === 'CHANGES_REQUESTED',
|
|
45
|
+
approve,
|
|
46
|
+
...(reviewDecision.reviewDecision !== null ? { reviewDecision: reviewDecision.reviewDecision } : {}),
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -55,6 +64,12 @@ type SelfReviewsResult =
|
|
|
55
64
|
| { ok: true; states: string[] }
|
|
56
65
|
| { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
|
|
57
66
|
|
|
67
|
+
type ReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
|
|
68
|
+
|
|
69
|
+
type ReviewDecisionResult =
|
|
70
|
+
| { ok: true; reviewDecision: ReviewDecision | null }
|
|
71
|
+
| { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
|
|
72
|
+
|
|
58
73
|
async function fetchSelfReviews(
|
|
59
74
|
fetchImpl: typeof fetch,
|
|
60
75
|
token: string,
|
|
@@ -94,6 +109,47 @@ async function fetchSelfReviews(
|
|
|
94
109
|
return { ok: true, states }
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
async function fetchReviewDecision(
|
|
113
|
+
fetchImpl: typeof fetch,
|
|
114
|
+
token: string,
|
|
115
|
+
target: Target,
|
|
116
|
+
): Promise<ReviewDecisionResult> {
|
|
117
|
+
let response: Response
|
|
118
|
+
try {
|
|
119
|
+
response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: githubJsonHeaders(token),
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
query:
|
|
124
|
+
'query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}',
|
|
125
|
+
variables: { owner: target.owner, repo: target.repo, number: target.prNumber },
|
|
126
|
+
}),
|
|
127
|
+
})
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const text = await response.text().catch(() => '')
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: `GitHub reviewDecision ${response.status}${text !== '' ? `: ${text}` : ''}`,
|
|
136
|
+
code: classifyStatus(response.status),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const raw = (await response.json().catch(() => null)) as ReviewDecisionResponse | null
|
|
140
|
+
if (raw === null) return { ok: false, error: 'GitHub reviewDecision returned non-JSON', code: 'transient' }
|
|
141
|
+
if (Array.isArray(raw.errors) && raw.errors.length > 0) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
error: `GitHub reviewDecision errors: ${raw.errors.map(describeGraphqlError).join('; ')}`,
|
|
145
|
+
code: 'transient',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const value = raw.data?.repository?.pullRequest?.reviewDecision ?? null
|
|
149
|
+
if (value === null || isReviewDecision(value)) return { ok: true, reviewDecision: value }
|
|
150
|
+
return { ok: false, error: `GitHub reviewDecision returned unknown value: ${String(value)}`, code: 'transient' }
|
|
151
|
+
}
|
|
152
|
+
|
|
97
153
|
// A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
|
|
98
154
|
// these three states decide the block. COMMENTED and PENDING are non-deciding
|
|
99
155
|
// noise that must NOT shadow an earlier CHANGES_REQUESTED.
|
|
@@ -135,3 +191,16 @@ function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'tr
|
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
type ReviewRow = { id?: number; state?: unknown; user?: { login?: string; type?: string } }
|
|
194
|
+
|
|
195
|
+
type ReviewDecisionResponse = {
|
|
196
|
+
data?: { repository?: { pullRequest?: { reviewDecision?: unknown } | null } | null }
|
|
197
|
+
errors?: Array<{ message?: unknown }>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isReviewDecision(value: unknown): value is ReviewDecision {
|
|
201
|
+
return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function describeGraphqlError(error: { message?: unknown }): string {
|
|
205
|
+
return typeof error.message === 'string' ? error.message : JSON.stringify(error)
|
|
206
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { classifyReviewClaim } from './github-review-claim'
|
|
1
|
+
import { classifyReviewClaim, isPositiveWarnCloseout } from './github-review-claim'
|
|
2
2
|
import type { ReviewStateResult } from './types'
|
|
3
3
|
|
|
4
4
|
// The re-review stranding guard. A bot that resolves a review thread (or posts a
|
|
@@ -17,6 +17,10 @@ export type RereviewGuardInput = {
|
|
|
17
17
|
thread: string | null
|
|
18
18
|
text: string | undefined
|
|
19
19
|
wantsResolve: boolean
|
|
20
|
+
// A mid-turn status reply (continue:true) is not the turn's receipt, so it
|
|
21
|
+
// suppresses the warn-tier escalation below — but never the explicit resolve,
|
|
22
|
+
// which is a real mutation. Mirrors the false-receipt guard's continue rule.
|
|
23
|
+
isContinue: boolean
|
|
20
24
|
getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
|
|
21
25
|
workspace: string
|
|
22
26
|
}
|
|
@@ -32,14 +36,19 @@ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<
|
|
|
32
36
|
// a close-out ack in it ("Verified — that closes it") strands the block just
|
|
33
37
|
// as a thread reply would. Only the resolve ACTION needs a thread; the
|
|
34
38
|
// text-claim path fires regardless (caught by isCloseoutAttempt below).
|
|
35
|
-
if (!isCloseoutAttempt(input
|
|
39
|
+
if (!isCloseoutAttempt(input)) return ALLOW
|
|
36
40
|
|
|
37
41
|
const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
|
|
38
42
|
|
|
39
43
|
// Fail closed: an unverifiable review state is treated as a live block, so the
|
|
40
44
|
// bot never strands a re-review on a transient API failure.
|
|
41
45
|
if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
|
|
42
|
-
if (!state.selfBlocking)
|
|
46
|
+
if (!state.selfBlocking) {
|
|
47
|
+
if (state.reviewDecision === 'REVIEW_REQUIRED' && isPositiveWarnCloseout(input.text ?? '')) {
|
|
48
|
+
return { block: true, reason: INITIAL_REVIEW_REQUIRED }
|
|
49
|
+
}
|
|
50
|
+
return ALLOW
|
|
51
|
+
}
|
|
43
52
|
|
|
44
53
|
return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
|
|
45
54
|
}
|
|
@@ -47,11 +56,20 @@ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<
|
|
|
47
56
|
// Trigger when the model asks to resolve a thread (only meaningful with a
|
|
48
57
|
// thread), OR when its reply reads as a close-out/verdict claim — the latter
|
|
49
58
|
// strands the block whether or not it sits in a thread, so it fires for any PR
|
|
50
|
-
// chat.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
59
|
+
// chat. Unlike the pure false-receipt classifier, this guard has the objective
|
|
60
|
+
// review state available, so an approval-shaped warn reply ("looks good"/"lgtm")
|
|
61
|
+
// is escalated to a closeout too: it only blocks when the bot actually holds a
|
|
62
|
+
// live CHANGES_REQUESTED, so casual approval-shaped chatter on an unblocked PR
|
|
63
|
+
// still posts. Only POSITIVE warn phrases escalate — negative ones ("needs
|
|
64
|
+
// changes", "still needs work") re-assert a block rather than strand it, so they
|
|
65
|
+
// stay non-firing. `continue:true` exempts the warn escalation (mid-turn
|
|
66
|
+
// planning, not the receipt), but never the explicit resolve action. Plain
|
|
67
|
+
// `ignore` text never fires.
|
|
68
|
+
function isCloseoutAttempt(input: RereviewGuardInput): boolean {
|
|
69
|
+
if (input.wantsResolve && input.thread !== null) return true
|
|
70
|
+
const claim = classifyReviewClaim(input.text ?? '')
|
|
71
|
+
if (claim === 'block-resolve' || claim === 'block-approve') return true
|
|
72
|
+
return !input.isContinue && isPositiveWarnCloseout(input.text ?? '')
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
function unverifiableReason(error: string): string {
|
|
@@ -74,3 +92,9 @@ const STICKY_BLOCK_APPROVE_DISABLED =
|
|
|
74
92
|
'dismiss your prior review via ' +
|
|
75
93
|
'`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
|
|
76
94
|
'if the blockers are fixed (or submit REQUEST_CHANGES if not), THEN resolve the thread / reply.'
|
|
95
|
+
|
|
96
|
+
const INITIAL_REVIEW_REQUIRED =
|
|
97
|
+
'This PR still requires a formal GitHub review. A flat `LGTM` / `looks good` PR comment does not create ' +
|
|
98
|
+
'review state, so it leaves the PR awaiting review. Submit the reviewer verdict via ' +
|
|
99
|
+
'`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` with event `APPROVE` when approval is enabled, ' +
|
|
100
|
+
'or event `COMMENT` when approval is disabled, then narrate only if needed.'
|
|
@@ -41,19 +41,26 @@ const BLOCK_RESOLVE: readonly RegExp[] = [
|
|
|
41
41
|
/\b(thanks,?|fixed,?) (looks )?resolved\b/,
|
|
42
42
|
]
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
|
|
44
|
+
// Approval/resolve-shaped warn phrases: casual chatter that, on a PR the bot is
|
|
45
|
+
// still blocking, READS as a close-out and so can strand the block. Split out so
|
|
46
|
+
// the re-review guard can escalate only these — never the negative warn phrases
|
|
47
|
+
// below, which re-assert a block rather than strand it.
|
|
48
|
+
const WARN_POSITIVE_CLOSEOUT: readonly RegExp[] = [
|
|
46
49
|
/\blgtm\b/,
|
|
47
50
|
/\blooks good\b/,
|
|
48
51
|
/\blooks fine\b/,
|
|
49
52
|
/\bseems fine\b/,
|
|
50
53
|
/\bshould be (fine|good)\b/,
|
|
51
|
-
/\bneeds changes\b/,
|
|
52
|
-
/\bstill needs work\b/,
|
|
53
54
|
/\blooks resolved\b/,
|
|
54
55
|
/\bseems resolved\b/,
|
|
55
56
|
]
|
|
56
57
|
|
|
58
|
+
// Negative warn phrases re-assert a block ("not done yet") instead of closing it
|
|
59
|
+
// out, so they are NOT close-out attempts — the re-review guard must ignore them.
|
|
60
|
+
const WARN_NEGATIVE: readonly RegExp[] = [/\bneeds changes\b/, /\bstill needs work\b/]
|
|
61
|
+
|
|
62
|
+
const WARN: readonly RegExp[] = [...WARN_POSITIVE_CLOSEOUT, ...WARN_NEGATIVE]
|
|
63
|
+
|
|
57
64
|
// Negation / future-intent / past-reference markers DEMOTE a positive match to
|
|
58
65
|
// ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
|
|
59
66
|
// (answering a question) is the worst false-positive class, so it is checked first.
|
|
@@ -61,15 +68,40 @@ const DEMOTE_TO_IGNORE: readonly RegExp[] = [
|
|
|
61
68
|
/\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
62
69
|
/\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
|
|
63
70
|
/\bnot (approved|resolved|blocked|requesting)\b/,
|
|
71
|
+
/\b(not|no longer|hardly|barely)\b[^.!?]*\b(lgtm|looks good|looks fine|seems fine|should be (fine|good)|looks resolved|seems resolved)\b/,
|
|
64
72
|
/\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
|
|
65
73
|
/\b(approved|resolved|requested changes)\b[^.!?]*\b(earlier|already|yesterday|before|last (review|time)|previously)\b/,
|
|
74
|
+
/\b(pre|self|co|re|un|non|ai|admin|user|machine|auto) approved\b/,
|
|
66
75
|
]
|
|
67
76
|
|
|
77
|
+
const QUESTION_CONTEXT =
|
|
78
|
+
/(?:^|\b)(who|what|when|where|why|how|was|were|is|are|did|do|does|has|have|can|could|would|should)\b[^.!?]*\?/
|
|
79
|
+
|
|
68
80
|
export function classifyReviewClaim(rawText: string): ReviewClaim {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
81
|
+
const segments = claimSegments(rawText)
|
|
82
|
+
if (segments.length === 0) return 'ignore'
|
|
83
|
+
|
|
84
|
+
const claims = segments.map(classifySegment)
|
|
85
|
+
|
|
86
|
+
if (claims.includes('block-approve')) return 'block-approve'
|
|
87
|
+
if (claims.includes('block-request-changes')) return 'block-request-changes'
|
|
88
|
+
if (claims.includes('block-resolve')) return 'block-resolve'
|
|
89
|
+
if (claims.includes('warn')) return 'warn'
|
|
90
|
+
return 'ignore'
|
|
91
|
+
}
|
|
71
92
|
|
|
93
|
+
// True only for warn-tier replies whose phrasing reads as an approval/resolve
|
|
94
|
+
// close-out (e.g. "looks good", "lgtm"), excluding negative warn phrases like
|
|
95
|
+
// "needs changes" that re-assert a block. The re-review guard uses this to
|
|
96
|
+
// escalate just the stranding-shaped warns, not the whole warn bucket.
|
|
97
|
+
export function isPositiveWarnCloseout(rawText: string): boolean {
|
|
98
|
+
if (classifyReviewClaim(rawText) !== 'warn') return false
|
|
99
|
+
return claimSegments(rawText).some((segment) => WARN_POSITIVE_CLOSEOUT.some((re) => re.test(segment)))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function classifySegment(text: string): ReviewClaim {
|
|
72
103
|
if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
|
|
104
|
+
if (QUESTION_CONTEXT.test(text)) return 'ignore'
|
|
73
105
|
|
|
74
106
|
// Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
|
|
75
107
|
// is still a formal claim.
|
|
@@ -80,6 +112,21 @@ export function classifyReviewClaim(rawText: string): ReviewClaim {
|
|
|
80
112
|
return 'ignore'
|
|
81
113
|
}
|
|
82
114
|
|
|
115
|
+
function claimSegments(text: string): string[] {
|
|
116
|
+
return redactQuotedAndCode(text)
|
|
117
|
+
.split(/(?<=[.!?])\s+|\n+/)
|
|
118
|
+
.map(normalize)
|
|
119
|
+
.filter((segment) => segment !== '')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function redactQuotedAndCode(text: string): string {
|
|
123
|
+
return text
|
|
124
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
125
|
+
.replace(/`[^`\n]*`/g, ' ')
|
|
126
|
+
.replace(/"[^"\n]*"|“[^”\n]*”|‘[^’\n]*’/g, ' ')
|
|
127
|
+
.replace(/^\s*>.*$/gm, ' ')
|
|
128
|
+
}
|
|
129
|
+
|
|
83
130
|
// Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
|
|
84
131
|
// keeping apostrophes + sentence punctuation that the negation regexes rely on.
|
|
85
132
|
function normalize(text: string): string {
|
package/src/channels/router.ts
CHANGED
|
@@ -1422,8 +1422,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1422
1422
|
unsubTypingActivity: null,
|
|
1423
1423
|
unsubTodoOutcome: null,
|
|
1424
1424
|
}
|
|
1425
|
+
// Tracks the `turnSeq` a provider-error notice was last POSTED for, so the
|
|
1426
|
+
// channel surfaces at most one notice per turn. The upstream SDK retries
|
|
1427
|
+
// internally, and each retry emits its own `message_end` with
|
|
1428
|
+
// `stopReason: 'error'` — without this gate a single failing turn posts N
|
|
1429
|
+
// identical "⚠️ upstream provider failed" notices (one per retry). Logs
|
|
1430
|
+
// still record every attempt; only the user-facing notice is deduped.
|
|
1431
|
+
let lastProviderErrorNoticeTurn: number | undefined
|
|
1425
1432
|
live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
|
|
1426
1433
|
logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
|
|
1434
|
+
// Suppress duplicate notices for the SAME turn (retry storm). Set the
|
|
1435
|
+
// marker BEFORE the async send so a synchronous burst of retry events
|
|
1436
|
+
// can't each slip past the check and enqueue their own notice.
|
|
1437
|
+
if (lastProviderErrorNoticeTurn === live.turnSeq) return
|
|
1438
|
+
lastProviderErrorNoticeTurn = live.turnSeq
|
|
1427
1439
|
// A provider soft-error (rate/usage limit, billing, malformed response)
|
|
1428
1440
|
// ends the turn with no assistant text, so the human otherwise sees
|
|
1429
1441
|
// silence. Surface the REDACTED `safeMessage` (never the raw provider
|
package/src/channels/schema.ts
CHANGED
|
@@ -192,8 +192,9 @@ export const SEEDED_GITHUB_EVENT_ALLOWLISTS: readonly (readonly string[])[] = [
|
|
|
192
192
|
// prefix is implied by this field living under the review config); `off` is the
|
|
193
193
|
// disable sentinel, matching the `engagement.stickiness: 'off'` convention:
|
|
194
194
|
// - 'review_requested' — review only when the bot is requested (default)
|
|
195
|
-
// - 'opened' — review every non-draft PR as soon as it opens; draft
|
|
196
|
-
//
|
|
195
|
+
// - 'opened' — review every non-draft PR as soon as it opens; a draft
|
|
196
|
+
// PR wakes no session and is reviewed once it turns ready
|
|
197
|
+
// (ready_for_review) or the bot is explicitly requested
|
|
197
198
|
// - 'off' — disable code review entirely
|
|
198
199
|
export const GITHUB_REVIEW_ON_VALUES = ['review_requested', 'opened', 'off'] as const
|
|
199
200
|
|
package/src/channels/types.ts
CHANGED
|
@@ -395,19 +395,24 @@ export type ReviewStateRequest = {
|
|
|
395
395
|
chat: string
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
// `selfBlocking` is the answer the guard acts on: true means the
|
|
399
|
-
// effective formal review is its own CHANGES_REQUESTED (COMMENTED
|
|
400
|
-
// ignored — they never clear the sticky block, GitHub's own rule).
|
|
401
|
-
//
|
|
402
|
-
//
|
|
398
|
+
// `selfBlocking` is the answer the guard acts on for re-reviews: true means the
|
|
399
|
+
// bot's latest effective formal review is its own CHANGES_REQUESTED (COMMENTED
|
|
400
|
+
// reviews are ignored — they never clear the sticky block, GitHub's own rule).
|
|
401
|
+
// `reviewDecision` is GitHub's aggregate PR review status when GraphQL can
|
|
402
|
+
// provide it; REVIEW_REQUIRED means an approval-shaped flat comment would still
|
|
403
|
+
// leave the PR awaiting a formal review. `approve` mirrors
|
|
404
|
+
// `channels.github.review.approve` so the guard's denial text can tell the model
|
|
405
|
+
// whether to land a fresh APPROVE or to DISMISS its prior review.
|
|
403
406
|
//
|
|
404
407
|
// On `ok: false` the caller MUST fail closed: an unverifiable review state is
|
|
405
408
|
// treated like a live block, so the bot never claims close-out when the runtime
|
|
406
409
|
// could not confirm the platform-side verdict.
|
|
407
410
|
export type ReviewStateResult =
|
|
408
|
-
| { ok: true; selfBlocking: boolean; approve: boolean }
|
|
411
|
+
| { ok: true; selfBlocking: boolean; approve: boolean; reviewDecision?: GithubReviewDecision }
|
|
409
412
|
| { ok: false; error: string; code?: 'unsupported' | 'not-found' | 'permission-denied' | 'transient' }
|
|
410
413
|
|
|
414
|
+
export type GithubReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
|
|
415
|
+
|
|
411
416
|
// Registered per-adapter on the ChannelRouter, last-write-wins like the
|
|
412
417
|
// review-thread resolver. Adapters that never register one make `getReviewState`
|
|
413
418
|
// answer `unsupported`.
|
|
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
189
189
|
|
|
190
190
|
A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
|
|
191
191
|
|
|
192
|
-
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
192
|
+
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **Post the `<summary>` verbatim — do not pad it back into a play-by-play.** The reviewer's contract already makes the summary a terse, author-facing verdict justification (no process narration, no "I loaded the X skill", no recap of what the PR does); your job is to forward it, not re-expand it. **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
193
193
|
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
|
|
194
194
|
- `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
195
195
|
|