typeclaw 0.28.0 → 0.28.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/provider-error.ts +33 -1
- package/src/agent/tools/channel-reply.ts +23 -0
- package/src/agent/tools/channel-send.ts +22 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +3 -3
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/channels/adapters/github/inbound.ts +7 -6
- package/src/channels/adapters/github/index.ts +46 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +206 -0
- package/src/channels/github-rereview-guard.ts +100 -0
- package/src/channels/github-review-claim.ts +58 -10
- package/src/channels/router.ts +191 -7
- package/src/channels/schema.ts +3 -2
- package/src/channels/types.ts +36 -0
- package/src/inspect/transcript-view.ts +10 -0
- package/src/server/index.ts +11 -1
- package/src/shared/protocol.ts +18 -6
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/src/tui/format.ts +13 -0
- package/src/tui/index.ts +21 -7
|
@@ -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
|
-
//
|
|
28
|
-
// "resolved"
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
//
|
|
44
|
-
|
|
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
|
|
69
|
-
if (
|
|
81
|
+
const segments = claimSegments(rawText)
|
|
82
|
+
if (segments.length === 0) return 'ignore'
|
|
83
|
+
|
|
84
|
+
const claims = segments.map(classifySegment)
|
|
85
|
+
|
|
86
|
+
if (claims.includes('block-approve')) return 'block-approve'
|
|
87
|
+
if (claims.includes('block-request-changes')) return 'block-request-changes'
|
|
88
|
+
if (claims.includes('block-resolve')) return 'block-resolve'
|
|
89
|
+
if (claims.includes('warn')) return 'warn'
|
|
90
|
+
return 'ignore'
|
|
91
|
+
}
|
|
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 {
|