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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -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) {
@@ -187,6 +187,7 @@ export function createChannelSendTool({
187
187
  thread: params.thread ?? null,
188
188
  text: bodyText,
189
189
  wantsResolve,
190
+ isContinue: false,
190
191
  getReviewState: (req) => router.getReviewState(req),
191
192
  })
192
193
  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. State the decision in your \`<summary>\` if you take this path.
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
- [One paragraph: what the target is (in your words), what it is trying to achieve, your overall read. Name the skill(s) you loaded and why. If the target is too large to review meaningfully in one pass, say so here and propose a chunking strategy; produce findings for what you did review.]
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\` and explain in \`<summary>\` why no domain skill applied.`,
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? Put this in \`<summary>\`. If you cannot state it after reading, that itself is a finding — the artifact does not communicate its purpose.
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: (message) => {
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 fetchSelfReviews(fetchImpl, token, target, selfLogin)
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 { ok: true, selfBlocking: lastDecisive === 'CHANGES_REQUESTED', approve }
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.wantsResolve, input.thread, input.text)) return ALLOW
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) return ALLOW
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. Plain discussion replies (ignore/warn) do not fire.
51
- function isCloseoutAttempt(wantsResolve: boolean, thread: string | null, text: string | undefined): boolean {
52
- if (wantsResolve && thread !== null) return true
53
- const claim = classifyReviewClaim(text ?? '')
54
- return claim === 'block-resolve' || claim === 'block-approve'
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
- // Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
45
- const WARN: readonly RegExp[] = [
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 text = normalize(rawText)
70
- if (text === '') return 'ignore'
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 {
@@ -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
@@ -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
- // PRs are skipped until an explicit review_requested
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
 
@@ -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 bot's latest
399
- // effective formal review is its own CHANGES_REQUESTED (COMMENTED reviews are
400
- // ignored — they never clear the sticky block, GitHub's own rule). `approve`
401
- // mirrors `channels.github.review.approve` so the guard's denial text can tell
402
- // the model whether to land a fresh APPROVE or to DISMISS its prior review.
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