limn-review 0.1.181 → 0.1.183
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/dist/db/migrations/0165_withdraw_approval_on_push.sql +6 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/schema.sqlite.js +4 -0
- package/dist/db/teammate-reviews.js +11 -0
- package/dist/review/claude-review/approval-withdraw.js +233 -0
- package/dist/review/claude-review/auto-post.js +75 -18
- package/dist/review/claude-review/auto-settings.js +24 -1
- package/dist/review/claude-review/auto-verdict.js +8 -0
- package/dist/review/claude-review/persist.js +2 -0
- package/dist/review/claude-review/routes.js +46 -9
- package/dist/review/claude-review/team-view.js +59 -8
- package/dist/review/claude-review/teammate-reminder.js +135 -0
- package/dist/review/claude-review/thread-judgements.js +110 -3
- package/dist/shared/limn-review-meta.js +52 -0
- package/dist/sync/upsert.js +15 -0
- package/package.json +1 -1
- package/public/assets/{AiFixTab-uoyOe44d.js → AiFixTab-DwjVZ3Od.js} +1 -1
- package/public/assets/ClaudeReviewTab-CMCKluXj.js +22 -0
- package/public/assets/{PrBotBehaviourTab-Dl_TppS2.js → PrBotBehaviourTab-3mieFdQL.js} +1 -1
- package/public/assets/{index-B-MIQUya.js → index-DnvzZnsD.js} +45 -45
- package/public/index.html +1 -1
- package/public/assets/ClaudeReviewTab-NiqDVtMN.js +0 -20
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- WITHDRAWING LIMN'S OWN APPROVAL ON A PUSH (docs/CLAUDE-REVIEW.md § Withdrawing Limn's approval).
|
|
2
|
+
-- ONE column, `workspaces.withdraw_approval_on_push`: when a PR's head moves off the commit Limn's OWN
|
|
3
|
+
-- automatic approval was given on, Limn dismisses that approval. NULL = ON (the default); only a
|
|
4
|
+
-- stored 0 is off. The claim and the outcome ride the approving run's `claude_reviews.auto_post` JSON
|
|
5
|
+
-- (`approvalWithdrawal`), so no other DDL. Additive and nullable: no backfill.
|
|
6
|
+
ALTER TABLE `workspaces` ADD `withdraw_approval_on_push` integer;
|
|
@@ -1156,6 +1156,13 @@
|
|
|
1156
1156
|
"when": 1792224071016,
|
|
1157
1157
|
"tag": "0164_teammate_block_lift",
|
|
1158
1158
|
"breakpoints": true
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
"idx": 165,
|
|
1162
|
+
"version": "6",
|
|
1163
|
+
"when": 1792224073016,
|
|
1164
|
+
"tag": "0165_withdraw_approval_on_push",
|
|
1165
|
+
"breakpoints": true
|
|
1159
1166
|
}
|
|
1160
1167
|
]
|
|
1161
1168
|
}
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -1927,6 +1927,10 @@ export const workspaces = sqliteTable('workspaces', {
|
|
|
1927
1927
|
// PR's head so teammates' Limn skips a commit someone is already reviewing with the same
|
|
1928
1928
|
// provider. NULL/false = OFF, the default. The ONE writer is `setWorkspaceAutoReview`.
|
|
1929
1929
|
teamClaimsEnabled: integer('team_claims_enabled', { mode: 'boolean' }),
|
|
1930
|
+
// "Withdraw Limn's approval when new commits are pushed" (migration 0165; docs/CLAUDE-REVIEW.md
|
|
1931
|
+
// § Withdrawing Limn's approval). NULL = ON, the default; only a stored false is off. The ONE
|
|
1932
|
+
// writer is `setWorkspaceAutoReview`.
|
|
1933
|
+
withdrawApprovalOnPush: integer('withdraw_approval_on_push', { mode: 'boolean' }),
|
|
1930
1934
|
// WHO RUNS AUTO REVIEWS (migration 0135): NULL = the default provider (Settings → AI); 'both' =
|
|
1931
1935
|
// Claude then Codex as one pair (docs/CLAUDE-REVIEW.md § Claude + Codex). The ONE writer is
|
|
1932
1936
|
// `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// TEAMMATES' POSTED REVIEWS — THE READ SIDE (docs/CLAUDE-REVIEW.md § Teammates' reviews). The import
|
|
2
2
|
// is db/teammate-reviews-import.ts. ⚠ This module opens NO database at import (every reader takes an
|
|
3
3
|
// executor): review modules and their fake-context tests import it.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
|
|
5
6
|
import * as schema from './schema.sqlite.js';
|
|
6
7
|
const { users, reviewComments, commits, pullRequests, repos } = schema;
|
|
@@ -67,6 +68,9 @@ export async function listTeammateReviews(exec, accountId, prId) {
|
|
|
67
68
|
kind: crf.postedCommentKind,
|
|
68
69
|
githubCommentId: crf.githubCommentId,
|
|
69
70
|
nodeId: crf.sourceCommentNodeId,
|
|
71
|
+
postedAt: crf.postedAt,
|
|
72
|
+
side: crf.side,
|
|
73
|
+
reminder: crf.reminder,
|
|
70
74
|
})
|
|
71
75
|
.from(crf)
|
|
72
76
|
.where(inArray(crf.reviewId, rows.map((r) => r.id)))
|
|
@@ -114,6 +118,13 @@ export async function listTeammateReviews(exec, accountId, prId) {
|
|
|
114
118
|
threadId: kind === 'inline' && f.nodeId != null ? (threadOf.get(f.nodeId) ?? null) : null,
|
|
115
119
|
...providersOf(f.nodeId),
|
|
116
120
|
githubCommentId: f.githubCommentId ?? null,
|
|
121
|
+
postedAt: f.postedAt != null ? f.postedAt.toISOString() : null,
|
|
122
|
+
side: f.side === 'LEFT' ? 'LEFT' : 'RIGHT',
|
|
123
|
+
diffAnchorId: createHash('sha256').update(f.path, 'utf8').digest('hex'),
|
|
124
|
+
// The reader's reminder (stored beside the finding, never its own post); wire shape only.
|
|
125
|
+
reminder: f.reminder == null
|
|
126
|
+
? null
|
|
127
|
+
: { githubCommentId: f.reminder.githubCommentId, postedCommentKind: f.reminder.postedCommentKind, postedAt: f.reminder.postedAt },
|
|
117
128
|
};
|
|
118
129
|
});
|
|
119
130
|
const at = r.postedAt ?? r.createdAt;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// WITHDRAWING LIMN'S OWN AUTOMATIC APPROVAL (docs/CLAUDE-REVIEW.md § Withdrawing Limn's approval).
|
|
2
|
+
//
|
|
3
|
+
// An auto-posted APPROVE is Limn's judgement of ONE head. When the head moves, or a later run at a new
|
|
4
|
+
// head finds problems, that approval must not stay in force on GitHub. Two paths dismiss it:
|
|
5
|
+
//
|
|
6
|
+
// 1. THE RUN PATH (auto-post.ts, ALWAYS ON): a later auto run — or a "Review and post" run — at a NEW
|
|
7
|
+
// head that does not approve dismisses Limn's standing approval FIRST, then posts its review.
|
|
8
|
+
// 2. THE PUSH PATH (`maybeWithdrawApprovalOnPush`, called fire-and-forget by sync/upsert.ts when it
|
|
9
|
+
// sees a PR's head move): only in a workspace with auto review AND auto-posting on and the
|
|
10
|
+
// per-workspace switch `withdraw_approval_on_push` on (ON by default, migration 0165), never on
|
|
11
|
+
// the reader's own PR.
|
|
12
|
+
//
|
|
13
|
+
// ⚠ RECOGNITION IS BY STORED ID ONLY: the approval is Limn's when an earlier run's `auto_post` record
|
|
14
|
+
// holds its GitHub review id with `verdict.submitted === 'APPROVE'` (the twin of
|
|
15
|
+
// `loadLimnAutoBlockIds`). Never by marker or body: a manual Post, or an approval by hand, carries the
|
|
16
|
+
// same marker (or none) and is the reader's own judgement — never touched.
|
|
17
|
+
// ⚠ LIVE: it is dismissed only while it is still the reader's standing review on GitHub.
|
|
18
|
+
// ⚠ THE CLAIM: `approvalWithdrawal` on the APPROVING run's `auto_post` JSON, compare-and-set from
|
|
19
|
+
// absent BEFORE the GitHub write (a `json_set` guarded by `json_extract … IS NULL`). Whichever path
|
|
20
|
+
// claims first dismisses; the other does nothing. A failure is recorded in GitHub's words and NEVER
|
|
21
|
+
// retried (the record is never cleared).
|
|
22
|
+
import { and, asc, eq, lt, sql } from 'drizzle-orm';
|
|
23
|
+
import { ownRunsOnly } from '../../db/teammate-reviews.js';
|
|
24
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
25
|
+
const s = (ctx) => ctx.schema;
|
|
26
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
27
|
+
const nowIso = () => new Date().toISOString();
|
|
28
|
+
const errText = (err) => (err instanceof Error ? err.message : String(err)).slice(0, 300);
|
|
29
|
+
const shortSha = (sha) => sha.slice(0, 7);
|
|
30
|
+
/**
|
|
31
|
+
* Limn's own automatic APPROVE reviews on this PR (account-scoped), by GitHub review id: every own
|
|
32
|
+
* run (before `beforeReviewId`, when given) whose auto-post LANDED a review SENT as APPROVE. A pair's
|
|
33
|
+
* one review is recorded on both halves; the claim row is the half without `viaPairReviewId`.
|
|
34
|
+
*/
|
|
35
|
+
export async function loadLimnAutoApprovals(ctx, accountId, prId, beforeReviewId) {
|
|
36
|
+
const cr = s(ctx).claudeReviews;
|
|
37
|
+
const rows = (await ctx.db
|
|
38
|
+
.select({ id: cr.id, headSha: cr.headSha, autoPost: cr.autoPost })
|
|
39
|
+
.from(cr)
|
|
40
|
+
.where(and(eq(cr.prId, prId), eq(cr.accountId, accountId), ownRunsOnly(cr.kind), ...(beforeReviewId != null ? [lt(cr.id, beforeReviewId)] : [])))
|
|
41
|
+
.orderBy(asc(cr.id))
|
|
42
|
+
.execute());
|
|
43
|
+
const out = new Map();
|
|
44
|
+
for (const r of rows) {
|
|
45
|
+
const a = r.autoPost;
|
|
46
|
+
if (a?.githubReviewId == null || a.verdict?.submitted !== 'APPROVE')
|
|
47
|
+
continue;
|
|
48
|
+
const id = String(a.githubReviewId);
|
|
49
|
+
const via = a.viaPairReviewId != null;
|
|
50
|
+
const had = out.get(id);
|
|
51
|
+
if (had != null && !(had.via && !via))
|
|
52
|
+
continue;
|
|
53
|
+
out.set(id, { runId: r.id, githubReviewId: id, headSha: r.headSha, withdrawal: a.approvalWithdrawal ?? null, via });
|
|
54
|
+
}
|
|
55
|
+
return new Map([...out].map(([k, { via: _via, ...v }]) => [k, v]));
|
|
56
|
+
}
|
|
57
|
+
/** The dismissal message for a later run that found problems: counts by severity, then the head. */
|
|
58
|
+
export function withdrawMessageForRun(counts, headSha) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (counts.blockers > 0)
|
|
61
|
+
parts.push(counts.blockers === 1 ? '1 blocker' : `${counts.blockers} blockers`);
|
|
62
|
+
if (counts.warnings > 0)
|
|
63
|
+
parts.push(counts.warnings === 1 ? '1 warning' : `${counts.warnings} warnings`);
|
|
64
|
+
return parts.length > 0
|
|
65
|
+
? `Limn withdrew its approval: ${parts.join(' and ')} found at ${shortSha(headSha)}.`
|
|
66
|
+
: `Limn withdrew its approval: not approved at ${shortSha(headSha)}.`;
|
|
67
|
+
}
|
|
68
|
+
/** The dismissal message for a push. */
|
|
69
|
+
export function withdrawMessageForPush(headSha) {
|
|
70
|
+
return `Limn withdrew its approval: new commits since it approved (${shortSha(headSha)}).`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* THE CLAIM: write `approvalWithdrawal` (status 'withdrawing') on the approving run's record, only
|
|
74
|
+
* while it has none. false = someone (the other path, or an earlier attempt) already claimed it.
|
|
75
|
+
*/
|
|
76
|
+
export async function claimApprovalWithdrawal(ctx, accountId, runId, rec) {
|
|
77
|
+
const cr = s(ctx).claudeReviews;
|
|
78
|
+
const value = { ...rec, status: 'withdrawing', at: nowIso(), error: null };
|
|
79
|
+
const rows = (await ctx.db
|
|
80
|
+
.update(cr)
|
|
81
|
+
.set({ autoPost: sql `json_set(${cr.autoPost}, '$.approvalWithdrawal', json(${JSON.stringify(value)}))` })
|
|
82
|
+
.where(and(eq(cr.id, runId), eq(cr.accountId, accountId), sql `${cr.autoPost} is not null`, sql `json_extract(${cr.autoPost}, '$.approvalWithdrawal') is null`))
|
|
83
|
+
.returning({ id: cr.id })
|
|
84
|
+
.execute());
|
|
85
|
+
return rows.length > 0;
|
|
86
|
+
}
|
|
87
|
+
/** Settle a claimed withdrawal: 'withdrawn', or 'failed' with GitHub's words. */
|
|
88
|
+
export async function settleApprovalWithdrawal(ctx, accountId, runId, rec) {
|
|
89
|
+
const cr = s(ctx).claudeReviews;
|
|
90
|
+
await ctx.db
|
|
91
|
+
.update(cr)
|
|
92
|
+
.set({ autoPost: sql `json_set(${cr.autoPost}, '$.approvalWithdrawal', json(${JSON.stringify(rec)}))` })
|
|
93
|
+
.where(and(eq(cr.id, runId), eq(cr.accountId, accountId), sql `${cr.autoPost} is not null`))
|
|
94
|
+
.execute();
|
|
95
|
+
}
|
|
96
|
+
/** Stamp the local review row dismissed (GitHub accepted the dismissal; a sync reconciles anyway). */
|
|
97
|
+
export async function stampApprovalDismissed(ctx, prId, githubReviewId) {
|
|
98
|
+
const rv = s(ctx).reviews;
|
|
99
|
+
await ctx.db
|
|
100
|
+
.update(rv)
|
|
101
|
+
.set({ state: 'dismissed' })
|
|
102
|
+
.where(and(eq(rv.prId, prId), eq(rv.databaseId, githubReviewId)))
|
|
103
|
+
.execute();
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Claim, dismiss, settle — ONE attempt, never retried. Returns null when the claim was lost (another
|
|
107
|
+
* path has it), else what GitHub said. Never throws.
|
|
108
|
+
*/
|
|
109
|
+
export async function withdrawApproval(ctx, a, dismiss) {
|
|
110
|
+
const base = {
|
|
111
|
+
why: a.why,
|
|
112
|
+
headSha: a.headSha,
|
|
113
|
+
message: a.message,
|
|
114
|
+
...(a.byReviewId != null ? { byReviewId: a.byReviewId } : {}),
|
|
115
|
+
};
|
|
116
|
+
let claimed = false;
|
|
117
|
+
try {
|
|
118
|
+
claimed = await claimApprovalWithdrawal(ctx, a.accountId, a.approval.runId, base);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
ctx.log.warn(`withdraw approval ${a.approval.githubReviewId}: could not claim: ${errText(err)}`);
|
|
122
|
+
}
|
|
123
|
+
if (!claimed)
|
|
124
|
+
return null;
|
|
125
|
+
let res;
|
|
126
|
+
try {
|
|
127
|
+
res = await dismiss(a.approval.githubReviewId, a.message);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
res = { ok: false, message: errText(err) };
|
|
131
|
+
}
|
|
132
|
+
const rec = {
|
|
133
|
+
...base,
|
|
134
|
+
status: res.ok ? 'withdrawn' : 'failed',
|
|
135
|
+
at: nowIso(),
|
|
136
|
+
error: res.ok ? null : res.message.slice(0, 300),
|
|
137
|
+
};
|
|
138
|
+
await settleApprovalWithdrawal(ctx, a.accountId, a.approval.runId, rec).catch((err) => ctx.log.warn(`withdraw approval ${a.approval.githubReviewId}: could not record the result: ${errText(err)}`));
|
|
139
|
+
if (res.ok) {
|
|
140
|
+
await stampApprovalDismissed(ctx, a.prId, a.approval.githubReviewId).catch((err) => ctx.log.warn(`withdraw approval ${a.approval.githubReviewId}: could not stamp the dismissal: ${errText(err)}`));
|
|
141
|
+
}
|
|
142
|
+
return rec;
|
|
143
|
+
}
|
|
144
|
+
export const defaultPushWithdrawDeps = {
|
|
145
|
+
async accountLogin(accountId) {
|
|
146
|
+
const { defaultAutoPostDeps } = await import('./auto-post.js');
|
|
147
|
+
return defaultAutoPostDeps.accountLogin(accountId);
|
|
148
|
+
},
|
|
149
|
+
async accountUserId(accountId) {
|
|
150
|
+
const { getAccountUserId } = await import('../../auth/account.js');
|
|
151
|
+
return getAccountUserId(accountId);
|
|
152
|
+
},
|
|
153
|
+
async liveReviews(owner, name, prNumber) {
|
|
154
|
+
const { defaultAutoPostDeps } = await import('./auto-post.js');
|
|
155
|
+
return defaultAutoPostDeps.liveReviews(owner, name, prNumber);
|
|
156
|
+
},
|
|
157
|
+
async dismissReview(accountId, owner, name, prNumber, reviewId, message) {
|
|
158
|
+
const { defaultAutoPostDeps } = await import('./auto-post.js');
|
|
159
|
+
return defaultAutoPostDeps.dismissReview(accountId, owner, name, prNumber, reviewId, message);
|
|
160
|
+
},
|
|
161
|
+
async settle({ accountId, prId }) {
|
|
162
|
+
const { settlePrAfterWrite } = await import('../../sync/resync-after-write.js');
|
|
163
|
+
const quiet = { info: () => { }, warn: () => { }, error: () => { } };
|
|
164
|
+
return settlePrAfterWrite({ accountId, prId, log: quiet });
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* A PR's head was seen at `headSha`: dismiss Limn's own automatic approval of an OLDER head, if it
|
|
169
|
+
* still stands live. DB pre-checks first (the workspace's switches, the reader's own PR, an
|
|
170
|
+
* unwithdrawn approval of another head), so an ordinary walk costs no GitHub call. Never throws.
|
|
171
|
+
*/
|
|
172
|
+
export async function maybeWithdrawApprovalOnPush(ctx, a, deps = defaultPushWithdrawDeps) {
|
|
173
|
+
try {
|
|
174
|
+
return await withdrawOnPush(ctx, a, deps);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
ctx.log.warn(`withdraw approval on push, PR ${a.prId}: ${errText(err)}`);
|
|
178
|
+
return { kind: 'none' };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function withdrawOnPush(ctx, { accountId, prId, headSha }, deps) {
|
|
182
|
+
const t = s(ctx);
|
|
183
|
+
const { workspaces: w, workspaceRepos: wr, pullRequests: prs, repos } = t;
|
|
184
|
+
const rows = (await ctx.db
|
|
185
|
+
.select({
|
|
186
|
+
reviewOn: w.autoReviewEnabled,
|
|
187
|
+
postOn: w.autoPostEnabled,
|
|
188
|
+
withdrawOn: w.withdrawApprovalOnPush,
|
|
189
|
+
state: prs.state,
|
|
190
|
+
authorId: prs.authorId,
|
|
191
|
+
number: prs.number,
|
|
192
|
+
owner: repos.owner,
|
|
193
|
+
name: repos.name,
|
|
194
|
+
})
|
|
195
|
+
.from(prs)
|
|
196
|
+
.innerJoin(repos, and(eq(repos.id, prs.repoId), eq(repos.accountId, accountId)))
|
|
197
|
+
.innerJoin(wr, and(eq(wr.repoId, prs.repoId), eq(wr.accountId, accountId)))
|
|
198
|
+
.innerJoin(w, and(eq(w.id, wr.workspaceId), eq(w.accountId, accountId)))
|
|
199
|
+
.where(and(eq(prs.id, prId), eq(prs.accountId, accountId)))
|
|
200
|
+
.limit(1)
|
|
201
|
+
.execute());
|
|
202
|
+
const pr = rows[0];
|
|
203
|
+
// Auto review AND auto-posting on (the approval could only exist there), and the switch not off.
|
|
204
|
+
if (!pr || pr.reviewOn !== true || pr.postOn !== true || pr.withdrawOn === false)
|
|
205
|
+
return { kind: 'off' };
|
|
206
|
+
if (pr.state !== 'open')
|
|
207
|
+
return { kind: 'none' };
|
|
208
|
+
// The latest unwithdrawn approval of ANOTHER head (only the latest can still stand).
|
|
209
|
+
const approvals = [...(await loadLimnAutoApprovals(ctx, accountId, prId)).values()]
|
|
210
|
+
.filter((x) => x.headSha !== headSha && x.withdrawal == null)
|
|
211
|
+
.sort((x, y) => y.runId - x.runId);
|
|
212
|
+
const approval = approvals[0];
|
|
213
|
+
if (approval == null)
|
|
214
|
+
return { kind: 'none' };
|
|
215
|
+
// Never on the reader's own PR (GitHub refuses self-approval; nothing of Limn's can stand there).
|
|
216
|
+
const me = await deps.accountUserId(accountId);
|
|
217
|
+
if (me != null && pr.authorId === me)
|
|
218
|
+
return { kind: 'none' };
|
|
219
|
+
// LIVE: still the reader's standing review on GitHub?
|
|
220
|
+
const [login, reviews] = await Promise.all([deps.accountLogin(accountId), deps.liveReviews(pr.owner, pr.name, pr.number)]);
|
|
221
|
+
const { ownStandingReview } = await import('./auto-post.js');
|
|
222
|
+
const standing = ownStandingReview(reviews, login, new Set(), new Set([approval.githubReviewId]));
|
|
223
|
+
if (standing.state !== 'LIMN_AUTO_APPROVED' || standing.reviewId !== approval.githubReviewId)
|
|
224
|
+
return { kind: 'not_standing' };
|
|
225
|
+
const rec = await withdrawApproval(ctx, { accountId, prId, approval, why: 'push', headSha, message: withdrawMessageForPush(headSha) }, (reviewId, message) => deps.dismissReview(accountId, pr.owner, pr.name, pr.number, reviewId, message));
|
|
226
|
+
if (rec == null)
|
|
227
|
+
return { kind: 'claimed_elsewhere' };
|
|
228
|
+
// SETTLE, THEN CASCADE (the post-write rule): a dismissal changes the PR's review decision.
|
|
229
|
+
if (rec.status === 'withdrawn')
|
|
230
|
+
await Promise.resolve(deps.settle({ accountId, prId })).catch(() => undefined);
|
|
231
|
+
return { kind: 'done', record: rec };
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=approval-withdraw.js.map
|
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
// (`unsettledEarlierBlockers`) and none of the blocking run's own blockers that never reached
|
|
38
38
|
// GitHub (`loadUnpostedBlockersOfBlock` — never followed up, so never judged). Otherwise no
|
|
39
39
|
// dismissal, and an APPROVE over the block is held as 'already_requested';
|
|
40
|
+
// 3b'. THE WITHDRAWAL (./approval-withdraw.ts, always on): a run at a NEW head that does not approve
|
|
41
|
+
// while Limn's OWN earlier AUTOMATIC APPROVE still stands (an earlier run's `githubReviewId` —
|
|
42
|
+
// never the marker) DISMISSES it before the post ("Limn withdrew its approval: 3 warnings found
|
|
43
|
+
// at 91482c8."), claimed on the approving run's record (`approvalWithdrawal`) so the push path
|
|
44
|
+
// and this never both dismiss; recorded as `withdrewApproval`, never retried. Its REQUEST_CHANGES
|
|
45
|
+
// then goes out (the gate's 'LIMN_AUTO_APPROVED' is not the reader's verdict);
|
|
40
46
|
// 3c. THE REASON (only with `autoVerdict` on): a run that does not end in an APPROVE gets ONE
|
|
41
47
|
// plain-English "Not approved: …" line (`findingsNotApproved` + the gate's `heldReason`,
|
|
42
48
|
// ./auto-verdict.ts), stored on the verdict record (`reasonText`). Posted findings carry it as the
|
|
@@ -75,7 +81,8 @@ import { similarTitles } from './settled-by-reply.js';
|
|
|
75
81
|
import { prAuthorIsAccount } from '../../coding/ai-fix/auto-fix.js';
|
|
76
82
|
import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, readPairConsolidation, } from './persist.js';
|
|
77
83
|
import { resolveFindingBody } from './follow-up.js';
|
|
78
|
-
import {
|
|
84
|
+
import { loadLimnAutoApprovals, withdrawApproval, withdrawMessageForRun, } from './approval-withdraw.js';
|
|
85
|
+
import { autoVerdictFor, blockerWarningCounts, findingsNotApproved, pairVerdict, reasonMayPost, safeReasonText, withReason, } from './auto-verdict.js';
|
|
79
86
|
import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
|
|
80
87
|
import { pairRaisedByLead, pairReviewBody } from './pair.js';
|
|
81
88
|
import { limnMetaForReview, loadJudgedForPost } from './post-meta.js';
|
|
@@ -212,6 +219,10 @@ export function verdictGate(wanted, g) {
|
|
|
212
219
|
? { wanted, submitted: wanted, heldReason: null }
|
|
213
220
|
: { wanted, submitted: 'COMMENT', heldReason: 'already_requested' };
|
|
214
221
|
}
|
|
222
|
+
// Limn's OWN automatic approval is not the reader's: a new APPROVE supersedes it, and a request for
|
|
223
|
+
// changes goes out (auto-post withdraws the approval first when the head moved).
|
|
224
|
+
if (g.ownLatest === 'LIMN_AUTO_APPROVED')
|
|
225
|
+
return { wanted, submitted: wanted, heldReason: null };
|
|
215
226
|
if (g.ownLatest !== 'none')
|
|
216
227
|
return { wanted, submitted: 'COMMENT', heldReason: 'prior_review' };
|
|
217
228
|
return { wanted, submitted: wanted, heldReason: null };
|
|
@@ -220,9 +231,10 @@ export function verdictGate(wanted, g) {
|
|
|
220
231
|
* The reader's standing verdict in a live GitHub list (oldest first): the latest of their DECISIVE
|
|
221
232
|
* reviews — COMMENTED and PENDING are skipped, because neither supersedes a standing APPROVED or
|
|
222
233
|
* CHANGES_REQUESTED on GitHub; a latest DISMISSED stands for nothing. `limnAutoBlockIds` = the ids of
|
|
223
|
-
* Limn's own earlier AUTOMATIC REQUEST_CHANGES reviews on this PR (`loadLimnAutoBlockIds`)
|
|
234
|
+
* Limn's own earlier AUTOMATIC REQUEST_CHANGES reviews on this PR (`loadLimnAutoBlockIds`);
|
|
235
|
+
* `limnAutoApproveIds` = those of its own automatic APPROVEs (`loadLimnAutoApprovals`).
|
|
224
236
|
*/
|
|
225
|
-
export function ownStandingReview(reviews, login, limnAutoBlockIds = new Set()) {
|
|
237
|
+
export function ownStandingReview(reviews, login, limnAutoBlockIds = new Set(), limnAutoApproveIds = new Set()) {
|
|
226
238
|
if (!login)
|
|
227
239
|
return { state: 'unreadable', reviewId: null };
|
|
228
240
|
const me = login.toLowerCase();
|
|
@@ -237,7 +249,7 @@ export function ownStandingReview(reviews, login, limnAutoBlockIds = new Set())
|
|
|
237
249
|
case 'DISMISSED':
|
|
238
250
|
return { state: 'none', reviewId: null };
|
|
239
251
|
case 'APPROVED':
|
|
240
|
-
return { state: 'APPROVED', reviewId };
|
|
252
|
+
return { state: reviewId != null && limnAutoApproveIds.has(reviewId) ? 'LIMN_AUTO_APPROVED' : 'APPROVED', reviewId };
|
|
241
253
|
case 'CHANGES_REQUESTED':
|
|
242
254
|
return {
|
|
243
255
|
state: reviewId != null && limnAutoBlockIds.has(reviewId) ? 'LIMN_AUTO_CHANGES_REQUESTED' : 'CHANGES_REQUESTED',
|
|
@@ -726,6 +738,14 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
726
738
|
// (Read for a REQUEST_CHANGES too: the gate must tell Limn's own standing block from a person's.)
|
|
727
739
|
const limnBlockIds = !facts.authorIsMe ? await loadLimnAutoBlockIds(ctx, accountId, prId, reviewId) : new Set();
|
|
728
740
|
const mayLift = noBlocker && unjudgedBlockers.length === 0 && limnBlockIds.size > 0;
|
|
741
|
+
// WITHDRAWING LIMN'S OWN APPROVAL (./approval-withdraw.ts): a run at a NEW head that does not
|
|
742
|
+
// approve (whatever the auto-verdict switch says now) dismisses Limn's earlier AUTOMATIC approval
|
|
743
|
+
// that still stands, BEFORE it posts. A DB pre-check first, so an ordinary run reads nothing more.
|
|
744
|
+
const runApproves = autoVerdictFor(runVerdict, allFindings, rules.approveLeftovers) === 'APPROVE';
|
|
745
|
+
const limnApprovals = !facts.authorIsMe
|
|
746
|
+
? await loadLimnAutoApprovals(ctx, accountId, prId, reviewId)
|
|
747
|
+
: new Map();
|
|
748
|
+
const mayWithdraw = !runApproves && [...limnApprovals.values()].some((x) => x.headSha !== review.headSha && x.withdrawal == null);
|
|
729
749
|
// LIFTING A TEAMMATE'S LIMN BLOCK (./teammate-lift.ts): the DB pre-check first (both opt-ins, every
|
|
730
750
|
// point behind the block settled), so an ordinary run costs no extra GitHub read.
|
|
731
751
|
let readerLogin = null;
|
|
@@ -754,6 +774,7 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
754
774
|
(wanted == null || wanted === 'COMMENT') &&
|
|
755
775
|
!mayLift &&
|
|
756
776
|
!mayLiftTeammate &&
|
|
777
|
+
!mayWithdraw &&
|
|
757
778
|
!commentReasonToPost) {
|
|
758
779
|
return skip(...nothingReason());
|
|
759
780
|
}
|
|
@@ -786,14 +807,14 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
786
807
|
let standing = { state: 'unreadable', reviewId: null };
|
|
787
808
|
// A teammate block still standing LIVE, with Limn's marker and the consent in its live body.
|
|
788
809
|
let teammateLifts = [];
|
|
789
|
-
if (!facts.authorIsMe && ((wantsVerdict && !impactHeld) || mayLift || mayLiftTeammate)) {
|
|
810
|
+
if (!facts.authorIsMe && ((wantsVerdict && !impactHeld) || mayLift || mayLiftTeammate || mayWithdraw)) {
|
|
790
811
|
try {
|
|
791
812
|
const [login, reviews] = await Promise.all([
|
|
792
813
|
deps.accountLogin(accountId),
|
|
793
814
|
deps.liveReviews(pctx.owner, pctx.name, pctx.prNumber),
|
|
794
815
|
]);
|
|
795
816
|
readerLogin = login;
|
|
796
|
-
standing = ownStandingReview(reviews, login, limnBlockIds);
|
|
817
|
+
standing = ownStandingReview(reviews, login, limnBlockIds, new Set(limnApprovals.keys()));
|
|
797
818
|
teammateLifts = teammateCandidates.filter((c) => liveLiftCheck(reviews, c, login) == null);
|
|
798
819
|
}
|
|
799
820
|
catch (err) {
|
|
@@ -834,9 +855,16 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
834
855
|
// Limn's own automatic request for changes still stands, this run has no blocker and none behind
|
|
835
856
|
// the block is left unjudged: lift it — superseded by the APPROVE this run sends, else dismissed.
|
|
836
857
|
const liftId = noBlocker && !blockersRemain && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' ? standing.reviewId : null;
|
|
858
|
+
// Limn's own automatic approval of an OLDER head still stands and this run does not approve:
|
|
859
|
+
// withdraw it — unless the push path (or an earlier attempt) already claimed it.
|
|
860
|
+
const standingApproval = standing.state === 'LIMN_AUTO_APPROVED' && standing.reviewId != null ? limnApprovals.get(standing.reviewId) : undefined;
|
|
861
|
+
const withdrawFrom = !runApproves && standingApproval != null && standingApproval.headSha !== review.headSha && standingApproval.withdrawal == null
|
|
862
|
+
? standingApproval
|
|
863
|
+
: null;
|
|
837
864
|
if (picked.toPost.length === 0 &&
|
|
838
865
|
(verdict?.submitted ?? 'COMMENT') === 'COMMENT' &&
|
|
839
866
|
liftId == null &&
|
|
867
|
+
withdrawFrom == null &&
|
|
840
868
|
!reasonOnly &&
|
|
841
869
|
teammateLifts.length === 0) {
|
|
842
870
|
return skip(...nothingReason());
|
|
@@ -881,6 +909,25 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
881
909
|
first = null;
|
|
882
910
|
}
|
|
883
911
|
const pairFirst = first;
|
|
912
|
+
// WITHDRAW LIMN'S OWN APPROVAL FIRST (claimed on the approving run, then dismissed, never retried),
|
|
913
|
+
// so the review below never sits under a stale approval.
|
|
914
|
+
let withdrew = false;
|
|
915
|
+
if (withdrawFrom != null) {
|
|
916
|
+
const message = withdrawMessageForRun(blockerWarningCounts(allFindings), review.headSha);
|
|
917
|
+
const w = await withdrawApproval(ctx, { accountId, prId, approval: withdrawFrom, why: 'run', headSha: review.headSha, message, byReviewId: reviewId }, (id, msg) => deps.dismissReview
|
|
918
|
+
? deps.dismissReview(accountId, pctx.owner, pctx.name, pctx.prNumber, id, msg)
|
|
919
|
+
: Promise.resolve({ ok: false, message: 'Dismissing is not available.' }));
|
|
920
|
+
// null = the push path (or an earlier attempt) has it: nothing more to do here.
|
|
921
|
+
if (w != null) {
|
|
922
|
+
withdrew = w.status === 'withdrawn';
|
|
923
|
+
record.withdrewApproval = {
|
|
924
|
+
reviewId: withdrawFrom.githubReviewId,
|
|
925
|
+
how: withdrew ? 'withdrawn' : 'withdraw_failed',
|
|
926
|
+
message,
|
|
927
|
+
error: w.error,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
}
|
|
884
931
|
// The earlier Limn findings on GitHub this review judged, for the marker's `j=` (DB only, never throws).
|
|
885
932
|
const judged = await loadJudgedForPost(ctx, accountId, prId, pairFirst != null ? [pairFirst, review] : [review]);
|
|
886
933
|
// THE PAIR'S ONE REVIEW (pair-consolidate.ts): its summary, and who raised each kept finding. A pair
|
|
@@ -1059,28 +1106,38 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
1059
1106
|
if (lifted.length > 0)
|
|
1060
1107
|
record.liftedTeammateBlocks = lifted;
|
|
1061
1108
|
}
|
|
1062
|
-
// A run whose only write was the lift: posted when it took, failed (GitHub's
|
|
1109
|
+
// A run whose only write was the lift or the withdrawal: posted when it took, failed (GitHub's
|
|
1110
|
+
// words) when not. A withdrawal the push path had already claimed left nothing to do: skipped.
|
|
1063
1111
|
const liftOnly = !sendsReview;
|
|
1112
|
+
const otherWriteOk = dismissed || withdrew;
|
|
1113
|
+
const nothingDone = liftOnly && liftId == null && record.withdrewApproval == null && teammateLifts.length === 0;
|
|
1064
1114
|
const final = {
|
|
1065
1115
|
...record,
|
|
1066
|
-
status:
|
|
1067
|
-
?
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
:
|
|
1073
|
-
? '
|
|
1074
|
-
:
|
|
1116
|
+
status: nothingDone
|
|
1117
|
+
? 'skipped'
|
|
1118
|
+
: liftOnly
|
|
1119
|
+
? otherWriteOk
|
|
1120
|
+
? 'posted'
|
|
1121
|
+
: 'failed'
|
|
1122
|
+
: !landed
|
|
1123
|
+
? 'failed'
|
|
1124
|
+
: errors.length > 0
|
|
1125
|
+
? 'partial'
|
|
1126
|
+
: 'posted',
|
|
1127
|
+
...(nothingDone ? { reason: nothingReason()[0] } : {}),
|
|
1075
1128
|
at: nowIso(),
|
|
1076
|
-
error: liftOnly
|
|
1129
|
+
error: liftOnly
|
|
1130
|
+
? otherWriteOk || nothingDone
|
|
1131
|
+
? null
|
|
1132
|
+
: (record.liftedBlock?.error ?? record.withdrewApproval?.error ?? null)
|
|
1133
|
+
: (errors[0] ?? null),
|
|
1077
1134
|
postedFindingIds: [...posted],
|
|
1078
1135
|
};
|
|
1079
1136
|
await writeRecord(ctx, reviewId, final).catch((err) => ctx.log.warn(`auto post review ${reviewId}: could not record the result: ${errText(err)}`));
|
|
1080
1137
|
if (pairFirst != null) {
|
|
1081
1138
|
await writeRecord(ctx, pairFirst.id, { ...final, viaPairReviewId: reviewId }).catch((err) => ctx.log.warn(`auto post review ${pairFirst.id}: could not record the result: ${errText(err)}`));
|
|
1082
1139
|
}
|
|
1083
|
-
if (landed || dismissed) {
|
|
1140
|
+
if (landed || dismissed || withdrew) {
|
|
1084
1141
|
await deps.settle({ accountId, prId, log: ctx.log }).catch(() => ({ visible: false }));
|
|
1085
1142
|
}
|
|
1086
1143
|
return { kind: 'done', record: final };
|
|
@@ -55,6 +55,11 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
|
|
|
55
55
|
// teammates' Limn skips it (team-claim.ts)? ⚠ ON BY DEFAULT since 2026-11-02 (migration 0159 switched
|
|
56
56
|
// every workspace on) — only a stored false is off, in every reader and in the writer.
|
|
57
57
|
//
|
|
58
|
+
// WITHDRAWING LIMN'S APPROVAL ON A PUSH rides the same route as one more column,
|
|
59
|
+
// `withdraw_approval_on_push` (migration 0165): when a PR's head moves off the commit Limn's OWN
|
|
60
|
+
// automatic approval was given on, dismiss it (./approval-withdraw.ts). ⚠ ON BY DEFAULT — only a
|
|
61
|
+
// stored false is off, in every reader and in the writer.
|
|
62
|
+
//
|
|
58
63
|
// AUTOMATIC DESIGN DIAGRAMS ride the same route as one more column, `auto_diagrams_enabled`
|
|
59
64
|
// (migration 0126): may a succeeded review (MANUAL OR AUTO) queue its design diagrams
|
|
60
65
|
// (review/diagrams/trigger.ts)? ⚠ ON BY DEFAULT — only a stored false is off, in every reader and
|
|
@@ -187,6 +192,8 @@ export function autoReviewOf(row) {
|
|
|
187
192
|
autoFix: resolveAutoFixSettings(row?.autoFixSettings),
|
|
188
193
|
// ON unless explicitly switched off (0159).
|
|
189
194
|
teamClaimsEnabled: row?.teamClaimsEnabled !== false,
|
|
195
|
+
// ON unless explicitly switched off (0165).
|
|
196
|
+
withdrawApprovalOnPush: row?.withdrawApprovalOnPush !== false,
|
|
190
197
|
};
|
|
191
198
|
}
|
|
192
199
|
/**
|
|
@@ -287,6 +294,7 @@ const autoCols = (w) => ({
|
|
|
287
294
|
autoFixEnabled: w.autoFixEnabled,
|
|
288
295
|
autoDiagramsEnabled: w.autoDiagramsEnabled,
|
|
289
296
|
...(w.teamClaimsEnabled ? { teamClaimsEnabled: w.teamClaimsEnabled } : {}),
|
|
297
|
+
...(w.withdrawApprovalOnPush ? { withdrawApprovalOnPush: w.withdrawApprovalOnPush } : {}),
|
|
290
298
|
dailyCap: w.autoReviewDailyCap,
|
|
291
299
|
autoPostEnabled: w.autoPostEnabled,
|
|
292
300
|
autoPostSettings: w.autoPostSettings,
|
|
@@ -323,7 +331,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
323
331
|
const existing = rows[0];
|
|
324
332
|
if (!existing)
|
|
325
333
|
return null;
|
|
326
|
-
const { enabled, autoFixEnabled, autoDiagramsEnabled, teamClaimsEnabled, dailyCap, autoPost, autoFix, autoReviewProviders, pairStrictness, } = change;
|
|
334
|
+
const { enabled, autoFixEnabled, autoDiagramsEnabled, teamClaimsEnabled, withdrawApprovalOnPush, dailyCap, autoPost, autoFix, autoReviewProviders, pairStrictness, } = change;
|
|
327
335
|
const wasOn = existing.enabled === true && toMs(existing.enabledAt) != null;
|
|
328
336
|
let review;
|
|
329
337
|
if (enabled === undefined || (enabled && wasOn))
|
|
@@ -349,6 +357,9 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
349
357
|
// Only a stored false is off (ON by default, 0159).
|
|
350
358
|
const claimsWas = existing.teamClaimsEnabled !== false;
|
|
351
359
|
const claims = teamClaimsEnabled ?? claimsWas;
|
|
360
|
+
// Only a stored false is off (ON by default, 0165).
|
|
361
|
+
const withdrawWas = existing.withdrawApprovalOnPush !== false;
|
|
362
|
+
const withdraw = withdrawApprovalOnPush ?? withdrawWas;
|
|
352
363
|
const capOverride = dailyCap === undefined ? existing.dailyCap : dailyCap === AUTO_REVIEW_DAILY_CAP ? null : dailyCap;
|
|
353
364
|
// Auto-posting: each field optional; a kind left out keeps its stored value.
|
|
354
365
|
const postBefore = resolveAutoPostSettings(existing.autoPostEnabled, existing.autoPostSettings, existing.approveLeftovers);
|
|
@@ -403,6 +414,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
403
414
|
autoFixEnabled: fix,
|
|
404
415
|
autoDiagramsEnabled: diagrams,
|
|
405
416
|
teamClaimsEnabled: claims,
|
|
417
|
+
withdrawApprovalOnPush: withdraw,
|
|
406
418
|
dailyCap: capOverride,
|
|
407
419
|
autoPostEnabled: postAfter.enabled,
|
|
408
420
|
autoPostSettings: postOverrides,
|
|
@@ -422,6 +434,9 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
422
434
|
if (teamClaimsEnabled !== undefined && teamClaimsEnabled !== claimsWas) {
|
|
423
435
|
set.teamClaimsEnabled = teamClaimsEnabled;
|
|
424
436
|
}
|
|
437
|
+
if (withdrawApprovalOnPush !== undefined && withdrawApprovalOnPush !== withdrawWas) {
|
|
438
|
+
set.withdrawApprovalOnPush = withdrawApprovalOnPush;
|
|
439
|
+
}
|
|
425
440
|
if (dailyCap !== undefined && capOverride !== existing.dailyCap) {
|
|
426
441
|
set.autoReviewDailyCap = capOverride;
|
|
427
442
|
}
|
|
@@ -479,6 +494,9 @@ function settingChanges(set, existing, next) {
|
|
|
479
494
|
if ('teamClaimsEnabled' in set) {
|
|
480
495
|
out.push(['auto_review', `Mark auto reviews on GitHub ${onOff(next.teamClaimsEnabled === true)}`]);
|
|
481
496
|
}
|
|
497
|
+
if ('withdrawApprovalOnPush' in set) {
|
|
498
|
+
out.push(['auto_post', `Withdraw Limn's approval when new commits are pushed ${onOff(next.withdrawApprovalOnPush !== false)}`]);
|
|
499
|
+
}
|
|
482
500
|
if ('autoFixEnabled' in set)
|
|
483
501
|
out.push(['auto_fix', `Auto fix ${onOff(next.autoFixEnabled === true)}`]);
|
|
484
502
|
if ('autoFixSettings' in set) {
|
|
@@ -655,6 +673,7 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
|
|
|
655
673
|
autoFixEnabled: { type: 'boolean' },
|
|
656
674
|
autoDiagramsEnabled: { type: 'boolean' },
|
|
657
675
|
teamClaimsEnabled: { type: 'boolean' },
|
|
676
|
+
withdrawApprovalOnPush: { type: 'boolean' },
|
|
658
677
|
autoReviewProviders: { type: 'string', enum: ['default', 'both'] },
|
|
659
678
|
pairStrictness: { type: 'string', enum: ['strict', 'relaxed'] },
|
|
660
679
|
// An integer 1..500; anything else (a fraction, a string, out of range) is a 400.
|
|
@@ -734,6 +753,10 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
|
|
|
734
753
|
reply.status(400);
|
|
735
754
|
return reply.send({ error: 'BadRequest', message: 'autoFix switches must be true or false' });
|
|
736
755
|
}
|
|
756
|
+
if (notBool(raw, 'withdrawApprovalOnPush')) {
|
|
757
|
+
reply.status(400);
|
|
758
|
+
return reply.send({ error: 'BadRequest', message: 'withdrawApprovalOnPush must be true or false' });
|
|
759
|
+
}
|
|
737
760
|
if ('dailyCap' in raw && typeof raw.dailyCap !== 'number') {
|
|
738
761
|
reply.status(400);
|
|
739
762
|
return reply.send({
|
|
@@ -39,6 +39,14 @@ export function leftoverAllowed(sev, leftovers) {
|
|
|
39
39
|
// ⚠ Everything except a READER'S ignore counts — a re-raise the server left out because its comment
|
|
40
40
|
// is already on this commit is still an open issue (`isReaderIgnoredFinding`).
|
|
41
41
|
const countedOf = (findings) => findings.filter((f) => f.story == null && !isReaderIgnoredFinding(f));
|
|
42
|
+
/** How many blockers and warnings a run's verdict counts (the same findings `autoVerdictFor` reads). */
|
|
43
|
+
export function blockerWarningCounts(findings) {
|
|
44
|
+
const counted = countedOf(findings);
|
|
45
|
+
return {
|
|
46
|
+
blockers: counted.filter((f) => f.severity === 'blocker').length,
|
|
47
|
+
warnings: counted.filter((f) => f.severity === 'warning').length,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
42
50
|
/**
|
|
43
51
|
* The verdict an auto post wants — the model's, made STRICTER. Counts the run's non-story findings a
|
|
44
52
|
* reader did not ignore (whatever the kind toggles say): any blocker ⇒ REQUEST_CHANGES; APPROVE only
|
|
@@ -91,6 +91,8 @@ export function autoPostWireOf(v) {
|
|
|
91
91
|
...(typeof r.viaPairReviewId === 'number' ? { viaPairReviewId: r.viaPairReviewId } : {}),
|
|
92
92
|
...(typeof r.reasonPosted === 'string' && r.reasonPosted !== '' ? { reasonPosted: true } : {}),
|
|
93
93
|
...(r.reasonOnly === true ? { reasonOnly: true } : {}),
|
|
94
|
+
...(r.approvalWithdrawal != null && typeof r.approvalWithdrawal === 'object' ? { approvalWithdrawal: r.approvalWithdrawal } : {}),
|
|
95
|
+
...(r.withdrewApproval != null && typeof r.withdrewApproval === 'object' ? { withdrewApproval: r.withdrewApproval } : {}),
|
|
94
96
|
};
|
|
95
97
|
}
|
|
96
98
|
// A stored CI record, or null for an older row / anything malformed (never a guessed shape).
|