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.
@@ -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
  }
@@ -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 { autoVerdictFor, findingsNotApproved, pairVerdict, reasonMayPost, safeReasonText, withReason, } from './auto-verdict.js';
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 words) when not.
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: liftOnly
1067
- ? dismissed
1068
- ? 'posted'
1069
- : 'failed'
1070
- : !landed
1071
- ? 'failed'
1072
- : errors.length > 0
1073
- ? 'partial'
1074
- : 'posted',
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 ? (dismissed ? null : (record.liftedBlock?.error ?? null)) : (errors[0] ?? null),
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).