typeclaw 0.28.0 → 0.28.2

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