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.
@@ -0,0 +1,206 @@
1
+ import type { ReviewStateResolver, ReviewStateResult } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ // Answers the re-review stranding guard's question: is the bot's latest
7
+ // EFFECTIVE formal review on this PR a sticky CHANGES_REQUESTED? GitHub clears a
8
+ // same-reviewer CHANGES_REQUESTED only with a later APPROVED or DISMISSED from
9
+ // the same reviewer — a later COMMENTED review does NOT clear it (the PR #644
10
+ // trap). So we walk the bot's own reviews in chronological order, ignore
11
+ // COMMENTED/PENDING, and read the last decisive one.
12
+ export function createGithubReviewStateResolver(deps: {
13
+ token: (context?: GithubAuthContext) => Promise<string>
14
+ selfLogin: () => string | null
15
+ approve: () => boolean
16
+ fetchImpl?: typeof fetch
17
+ }): ReviewStateResolver {
18
+ const fetchImpl = deps.fetchImpl ?? fetch
19
+ return async (req): Promise<ReviewStateResult> => {
20
+ const approve = deps.approve()
21
+ if (req.adapter !== 'github') {
22
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
23
+ }
24
+ const target = parseTarget(req.workspace, req.chat)
25
+ if (target === null) {
26
+ return { ok: false, error: `unparseable github PR target (chat=${req.chat})`, code: 'transient' }
27
+ }
28
+ const selfLogin = deps.selfLogin()
29
+ if (selfLogin === null) {
30
+ return { ok: false, error: 'github self-identity not resolved; cannot read review state', code: 'transient' }
31
+ }
32
+
33
+ const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
34
+ const [reviews, reviewDecision] = await Promise.all([
35
+ fetchSelfReviews(fetchImpl, token, target, selfLogin),
36
+ fetchReviewDecision(fetchImpl, token, target),
37
+ ])
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 }
40
+
41
+ const lastDecisive = reviews.states.filter(isDecisive).at(-1) ?? null
42
+ return {
43
+ ok: true,
44
+ selfBlocking: lastDecisive === 'CHANGES_REQUESTED',
45
+ approve,
46
+ ...(reviewDecision.reviewDecision !== null ? { reviewDecision: reviewDecision.reviewDecision } : {}),
47
+ }
48
+ }
49
+ }
50
+
51
+ type Target = { owner: string; repo: string; prNumber: number }
52
+
53
+ function parseTarget(workspace: string, chat: string): Target | null {
54
+ const [owner, repo, ...rest] = workspace.split('/')
55
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
56
+ const m = /^pr:(\d+)$/.exec(chat)
57
+ if (m === null) return null
58
+ const prNumber = Number(m[1])
59
+ if (!Number.isSafeInteger(prNumber) || prNumber <= 0) return null
60
+ return { owner, repo, prNumber }
61
+ }
62
+
63
+ type SelfReviewsResult =
64
+ | { ok: true; states: string[] }
65
+ | { ok: false; error: string; code: 'not-found' | 'permission-denied' | 'transient' }
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
+
73
+ async function fetchSelfReviews(
74
+ fetchImpl: typeof fetch,
75
+ token: string,
76
+ target: Target,
77
+ selfLogin: string,
78
+ ): Promise<SelfReviewsResult> {
79
+ const states: string[] = []
80
+ let url: string | null =
81
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/pulls/${target.prNumber}/reviews?per_page=100`
82
+ while (url !== null) {
83
+ let response: Response
84
+ try {
85
+ response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
86
+ } catch (err) {
87
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
88
+ }
89
+ if (!response.ok) {
90
+ const text = await response.text().catch(() => '')
91
+ return {
92
+ ok: false,
93
+ error: `GitHub reviews ${response.status}${text !== '' ? `: ${text}` : ''}`,
94
+ code: classifyStatus(response.status),
95
+ }
96
+ }
97
+ const page = (await response.json().catch(() => null)) as ReviewRow[] | null
98
+ if (page === null) return { ok: false, error: 'GitHub reviews returned non-JSON', code: 'transient' }
99
+ for (const row of page) {
100
+ if (typeof row.state !== 'string') continue
101
+ const login = row.user?.login ?? null
102
+ if (login === null) continue
103
+ const isBot = row.user?.type === 'Bot'
104
+ if (!isSelfReviewer(login, isBot, selfLogin)) continue
105
+ states.push(row.state)
106
+ }
107
+ url = nextLink(response.headers.get('link'))
108
+ }
109
+ return { ok: true, states }
110
+ }
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
+
153
+ // A formal CHANGES_REQUESTED is sticky until a later APPROVED/DISMISSED; only
154
+ // these three states decide the block. COMMENTED and PENDING are non-deciding
155
+ // noise that must NOT shadow an earlier CHANGES_REQUESTED.
156
+ const DECISIVE = new Set(['CHANGES_REQUESTED', 'APPROVED', 'DISMISSED'])
157
+
158
+ function isDecisive(state: string): boolean {
159
+ return DECISIVE.has(state)
160
+ }
161
+
162
+ // A GitHub App's own login differs across REST (`slug[bot]`) and GraphQL (bare
163
+ // `slug`). The REST reviews endpoint returns `slug[bot]` for the App, but the
164
+ // suffix-strip must be gated on the reviewer actually being a Bot: a human User
165
+ // can own the bare slug as a login, and stripping `[bot]` off the App's
166
+ // selfLogin to match a human would wrongly attribute their review to the bot.
167
+ const BOT_LOGIN_SUFFIX = '[bot]'
168
+
169
+ function isSelfReviewer(login: string, isBot: boolean, selfLogin: string): boolean {
170
+ if (isBot) return normalizeBotLogin(login) === normalizeBotLogin(selfLogin)
171
+ return login === selfLogin
172
+ }
173
+
174
+ function normalizeBotLogin(login: string): string {
175
+ return login.endsWith(BOT_LOGIN_SUFFIX) ? login.slice(0, -BOT_LOGIN_SUFFIX.length) : login
176
+ }
177
+
178
+ function nextLink(linkHeader: string | null): string | null {
179
+ if (linkHeader === null) return null
180
+ for (const part of linkHeader.split(',')) {
181
+ const m = /<([^>]+)>;\s*rel="next"/.exec(part)
182
+ if (m !== null) return m[1] ?? null
183
+ }
184
+ return null
185
+ }
186
+
187
+ function classifyStatus(status: number): 'not-found' | 'permission-denied' | 'transient' {
188
+ if (status === 401 || status === 403) return 'permission-denied'
189
+ if (status === 404) return 'not-found'
190
+ return 'transient'
191
+ }
192
+
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
+ }
@@ -0,0 +1,100 @@
1
+ import { classifyReviewClaim, isPositiveWarnCloseout } from './github-review-claim'
2
+ import type { ReviewStateResult } from './types'
3
+
4
+ // The re-review stranding guard. A bot that resolves a review thread (or posts a
5
+ // close-out ack) while it still holds its own sticky CHANGES_REQUESTED leaves the
6
+ // PR blocked forever — the resolve/ack carries no review state, so GitHub's
7
+ // reviewDecision never clears (PR #644). This guard blocks that close-out and
8
+ // tells the model to land a formal APPROVE / dismissal first.
9
+ //
10
+ // It is the same enforcement seam as the false-receipt guard and the
11
+ // resolve-thread author check: BLOCK and instruct, never act on the model's
12
+ // behalf — the runtime cannot prove a semantic approval from "one thread closed".
13
+
14
+ export type RereviewGuardInput = {
15
+ adapter: string
16
+ chat: string
17
+ thread: string | null
18
+ text: string | undefined
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
24
+ getReviewState: (req: { adapter: 'github'; workspace: string; chat: string }) => Promise<ReviewStateResult>
25
+ workspace: string
26
+ }
27
+
28
+ export type RereviewGuardDecision = { block: false } | { block: true; reason: string }
29
+
30
+ const ALLOW: RereviewGuardDecision = { block: false }
31
+
32
+ export async function evaluateRereviewGuard(input: RereviewGuardInput): Promise<RereviewGuardDecision> {
33
+ if (input.adapter !== 'github') return ALLOW
34
+ if (!/^pr:\d+$/.test(input.chat)) return ALLOW
35
+ // No `thread === null` exemption: a top-level PR comment carries no thread but
36
+ // a close-out ack in it ("Verified — that closes it") strands the block just
37
+ // as a thread reply would. Only the resolve ACTION needs a thread; the
38
+ // text-claim path fires regardless (caught by isCloseoutAttempt below).
39
+ if (!isCloseoutAttempt(input)) return ALLOW
40
+
41
+ const state = await input.getReviewState({ adapter: 'github', workspace: input.workspace, chat: input.chat })
42
+
43
+ // Fail closed: an unverifiable review state is treated as a live block, so the
44
+ // bot never strands a re-review on a transient API failure.
45
+ if (!state.ok) return { block: true, reason: unverifiableReason(state.error) }
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
+ }
52
+
53
+ return { block: true, reason: state.approve ? STICKY_BLOCK_APPROVE_ENABLED : STICKY_BLOCK_APPROVE_DISABLED }
54
+ }
55
+
56
+ // Trigger when the model asks to resolve a thread (only meaningful with a
57
+ // thread), OR when its reply reads as a close-out/verdict claim — the latter
58
+ // strands the block whether or not it sits in a thread, so it fires for any PR
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 ?? '')
73
+ }
74
+
75
+ function unverifiableReason(error: string): string {
76
+ return (
77
+ 'Could not verify whether your prior CHANGES_REQUESTED on this PR is still live ' +
78
+ `(${error}). Refusing to close out the thread while the block state is unknown — ` +
79
+ 'retry once the GitHub API is reachable, or land a formal review verdict first.'
80
+ )
81
+ }
82
+
83
+ const STICKY_BLOCK_APPROVE_ENABLED =
84
+ 'You still hold a CHANGES_REQUESTED on this PR. Resolving the thread (or posting a close-out ack) ' +
85
+ 'does NOT clear it — only a fresh formal review does. Submit `APPROVE` via ' +
86
+ '`gh api -X POST /repos/<owner>/<repo>/pulls/<N>/reviews` (event: APPROVE) if the blockers are fixed, ' +
87
+ 'or `REQUEST_CHANGES` if not, THEN resolve the thread / reply.'
88
+
89
+ const STICKY_BLOCK_APPROVE_DISABLED =
90
+ 'You still hold a CHANGES_REQUESTED on this PR and resolving the thread does NOT clear it. ' +
91
+ 'Approval is disabled for this agent (channels.github.review.approve: false), so you cannot APPROVE — ' +
92
+ 'dismiss your prior review via ' +
93
+ '`gh api -X PUT /repos/<owner>/<repo>/pulls/<N>/reviews/<review_id>/dismissals -f message="..." -f event=DISMISS` ' +
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.'
@@ -24,15 +24,16 @@ const BLOCK_REQUEST_CHANGES: readonly RegExp[] = [
24
24
  /\bthis is blocked\b/,
25
25
  ]
26
26
 
27
- // Only consulted by the caller when thread!=null (a review thread). Bare
28
- // "resolved" is intentionally NOT here it collides with the warn-tier "looks
29
- // resolved?"; resolve claims must carry a definite marker (marked/that/this/
30
- // thanks) or a verify clause.
27
+ // Bare "resolved" is intentionally NOT here it collides with the warn-tier
28
+ // "looks resolved?"; resolve claims must carry a definite marker (marked/that/
29
+ // this/thanks) or a verify clause. "that/this closes it" is the canonical PR
30
+ // #644 incident phrasing and must classify as a close-out claim.
31
31
  const BLOCK_RESOLVE: readonly RegExp[] = [
32
32
  /\bmarked resolved\b/,
33
33
  /\bthread resolved\b/,
34
34
  /\bthat resolves it\b/,
35
35
  /\bthis resolves it\b/,
36
+ /\b(that|this) closes it\b/,
36
37
  /\bclosing this out\b/,
37
38
  /\bconfirmed fixed\b/,
38
39
  // verify clause + a fix/resolve verb, allowing a short gap ("verified at <sha>, that fixes it").
@@ -40,19 +41,26 @@ const BLOCK_RESOLVE: readonly RegExp[] = [
40
41
  /\b(thanks,?|fixed,?) (looks )?resolved\b/,
41
42
  ]
42
43
 
43
- // Casual phrasing that might be chatter, not a formal close-out: allow + nudge.
44
- 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[] = [
45
49
  /\blgtm\b/,
46
50
  /\blooks good\b/,
47
51
  /\blooks fine\b/,
48
52
  /\bseems fine\b/,
49
53
  /\bshould be (fine|good)\b/,
50
- /\bneeds changes\b/,
51
- /\bstill needs work\b/,
52
54
  /\blooks resolved\b/,
53
55
  /\bseems resolved\b/,
54
56
  ]
55
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
+
56
64
  // Negation / future-intent / past-reference markers DEMOTE a positive match to
57
65
  // ignore. Blocking "I haven't approved" / "I'll approve" / "approved it earlier"
58
66
  // (answering a question) is the worst false-positive class, so it is checked first.
@@ -60,15 +68,40 @@ const DEMOTE_TO_IGNORE: readonly RegExp[] = [
60
68
  /\b(haven'?t|have not|did ?n'?t|did not|not yet|never)\b[^.!?]*\b(approv|request|resolv|block)/,
61
69
  /\b(can'?t|cannot|won'?t|will not|wouldn'?t)\b[^.!?]*\b(approv|request|resolv|block)/,
62
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/,
63
72
  /\b(i'?ll|i will|going to|gonna|about to|planning to)\b[^.!?]*\b(approv|review|request|resolv)/,
64
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/,
65
75
  ]
66
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
+
67
80
  export function classifyReviewClaim(rawText: string): ReviewClaim {
68
- const text = normalize(rawText)
69
- 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
+ }
70
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 {
71
103
  if (DEMOTE_TO_IGNORE.some((re) => re.test(text))) return 'ignore'
104
+ if (QUESTION_CONTEXT.test(text)) return 'ignore'
72
105
 
73
106
  // Block-tier wins over warn-tier: an unambiguous "approved" in a casual message
74
107
  // is still a formal claim.
@@ -79,6 +112,21 @@ export function classifyReviewClaim(rawText: string): ReviewClaim {
79
112
  return 'ignore'
80
113
  }
81
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
+
82
130
  // Strips markdown/emoji noise so "**Approved!**" and "approved" classify alike,
83
131
  // keeping apostrophes + sentence punctuation that the negation regexes rely on.
84
132
  function normalize(text: string): string {