limn-review 0.1.180 → 0.1.182

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.
Files changed (35) hide show
  1. package/dist/api/plugins/rate-limit.js +5 -0
  2. package/dist/api/routes/me.js +18 -1
  3. package/dist/db/migrations/0164_teammate_block_lift.sql +7 -0
  4. package/dist/db/migrations/0165_withdraw_approval_on_push.sql +6 -0
  5. package/dist/db/migrations/meta/_journal.json +14 -0
  6. package/dist/db/my-turn-pushed-since.js +74 -0
  7. package/dist/db/schema.sqlite.js +9 -0
  8. package/dist/db/teammate-reviews-import.js +18 -0
  9. package/dist/db/teammate-reviews.js +14 -0
  10. package/dist/review/claude-review/approval-withdraw.js +233 -0
  11. package/dist/review/claude-review/auto-post.js +172 -25
  12. package/dist/review/claude-review/auto-settings.js +56 -3
  13. package/dist/review/claude-review/auto-verdict.js +8 -0
  14. package/dist/review/claude-review/manager.js +21 -3
  15. package/dist/review/claude-review/persist.js +41 -3
  16. package/dist/review/claude-review/post-meta.js +100 -1
  17. package/dist/review/claude-review/routes.js +39 -11
  18. package/dist/review/claude-review/team-view.js +555 -31
  19. package/dist/review/claude-review/teammate-anchor.js +80 -9
  20. package/dist/review/claude-review/teammate-lift.js +231 -0
  21. package/dist/review/claude-review/thread-judgements.js +110 -3
  22. package/dist/review/post-seam.js +2 -0
  23. package/dist/shared/limn-review-meta.js +48 -1
  24. package/dist/shared/pending-rules.js +7 -0
  25. package/dist/shared/types.js +2 -0
  26. package/dist/sync/upsert.js +15 -0
  27. package/package.json +1 -1
  28. package/public/assets/{AiFixTab-C_4GZ1f7.js → AiFixTab-Bhq_0mmG.js} +1 -1
  29. package/public/assets/ClaudeReviewTab-BVlkGiBL.js +20 -0
  30. package/public/assets/{PrBotBehaviourTab-BS6_D2CB.js → PrBotBehaviourTab-oOBsdGPH.js} +1 -1
  31. package/public/assets/{index-CPNM_C-u.js → index-BTsR634o.js} +56 -56
  32. package/public/assets/index-DG7ITfra.css +1 -0
  33. package/public/index.html +2 -2
  34. package/public/assets/ClaudeReviewTab-kbXqq0_E.js +0 -20
  35. package/public/assets/index-KI5WT9Ev.css +0 -1
@@ -177,6 +177,11 @@ function tierFor(method, path) {
177
177
  // EXACT `===` for the same near-miss.
178
178
  if (path === '/api/me/my-turn-settings')
179
179
  return [TIERS.read];
180
+ // GET /api/my-turn/pushed-since/:prId — the expanded "N commits since you …" list on a Pushed
181
+ // since card. FOLLOWING THE TOKEN: two DB reads over synced `commits`/`reviews`/comments, no
182
+ // GitHub call, no model. Recorded, EXACT pattern, so a future live variant is not inherited.
183
+ if (/^\/api\/my-turn\/pushed-since\/\d+$/.test(path))
184
+ return [TIERS.read];
180
185
  // PUT /api/me/feature-settings — Settings → Features (which sidebar destinations show). One
181
186
  // validated UPDATE plus DB-only reads; no GitHub, no model. Recorded, EXACT `===`.
182
187
  if (path === '/api/me/feature-settings')
@@ -15,7 +15,8 @@ import { codexDetailCached } from '../../ai/codex/detect.js';
15
15
  import { usageLockedUntilIso } from '../../ai/usage-lockout.js';
16
16
  import { aiEffortSettingStatus } from '../../ai/effort.js';
17
17
  import { aiProviderSettingStatus, ALL_RUN_KINDS, CODEX_NOT_AVAILABLE_MESSAGE, CODEX_RUNNABLE, getActiveProvider, } from '../../ai/provider.js';
18
- import { getMyTurn } from '../../db/queries.js';
18
+ import { getMyTurn, resolveWorkspaceScope } from '../../db/queries.js';
19
+ import { getPushedSinceCommits } from '../../db/my-turn-pushed-since.js';
19
20
  import { clearDailyBriefCountsFor } from '../../db/daily-brief.js';
20
21
  import { dismissMyTurn, restoreMyTurn } from '../../db/my-turn-dismissals.js';
21
22
  import { featureFacts } from '../../db/feature-gates.js';
@@ -425,6 +426,22 @@ export async function meRoutes(app) {
425
426
  // The account-wide inbox. Unscoped on purpose (see `getMyTurn`): the browser-notification
426
427
  // watcher reads exactly this call — and without `onePerPr`, which is the board's rule.
427
428
  app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
429
+ // EVERY commit a "Pushed since" card counts — its expanded "N commits since you …" list. The
430
+ // SAME fold as the count (db/my-turn-pushed-since.ts). DB only; the SPA fetches it only when the
431
+ // reader expands the card. Scoped by `?workspace=` (absent/unknown ⇒ the default); a PR outside
432
+ // the account or the workspace answers 404, so the route is no existence oracle.
433
+ app.get('/api/my-turn/pushed-since/:prId', async (req, reply) => {
434
+ const raw = req.params.prId ?? '';
435
+ if (!/^[1-9][0-9]{0,15}$/.test(raw)) {
436
+ return reply.code(400).send({ error: 'BadRequest', message: 'Unknown pull request.' });
437
+ }
438
+ const accountId = accountIdOf(req);
439
+ const scope = await resolveWorkspaceScope(accountId, req.query.workspace);
440
+ const body = await getPushedSinceCommits(accountId, Number(raw), scope.workspaceId, scope.repoIds);
441
+ if (body == null)
442
+ return reply.code(404).send({ error: 'NotFound', message: 'Not found' });
443
+ return body;
444
+ });
428
445
  // DISMISS / BRING BACK one My Turn subject — a pull request (`pr`) or, for a red default branch,
429
446
  // a repository (`repo`). ⚠ NOT the "Done" routes 0060 deleted: a dismissal lasts only until
430
447
  // something newer happens on the subject, and a subject that leaves your plate discharges it
@@ -0,0 +1,7 @@
1
+ -- LIFTING A TEAMMATE'S LIMN BLOCK (docs/CLAUDE-REVIEW.md § Lifting a teammate's block). On an imported
2
+ -- teammate review (`claude_reviews.kind = 'teammate'`), the reader's Limn's ONE attempt to dismiss that
3
+ -- teammate's standing Request changes: JSON `TeammateLiftRecord` — claimed (NULL → 'dismissing') by a
4
+ -- compare-and-set BEFORE the GitHub write, then settled 'dismissed' or 'failed' with GitHub's words.
5
+ -- Never retried: a row that is not NULL is never claimed again. The teammate-review import never writes
6
+ -- it. NULL on every run of the reader's own. Additive and nullable: no backfill.
7
+ ALTER TABLE `claude_reviews` ADD `teammate_lift` text;
@@ -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;
@@ -1149,6 +1149,20 @@
1149
1149
  "when": 1792224069016,
1150
1150
  "tag": "0163_pr_cross_repository",
1151
1151
  "breakpoints": true
1152
+ },
1153
+ {
1154
+ "idx": 164,
1155
+ "version": "6",
1156
+ "when": 1792224071016,
1157
+ "tag": "0164_teammate_block_lift",
1158
+ "breakpoints": true
1159
+ },
1160
+ {
1161
+ "idx": 165,
1162
+ "version": "6",
1163
+ "when": 1792224073016,
1164
+ "tag": "0165_withdraw_approval_on_push",
1165
+ "breakpoints": true
1152
1166
  }
1153
1167
  ]
1154
1168
  }
@@ -0,0 +1,74 @@
1
+ import { and, eq, inArray } from 'drizzle-orm';
2
+ import { PUSHED_SINCE_LIST_MAX, } from '../shared/index.js';
3
+ import { db, schema } from './client.js';
4
+ import { lastActionClocks } from './queries.js';
5
+ import { getAccountUserId } from '../auth/account.js';
6
+ const { commits, pullRequests } = schema;
7
+ // ── "N COMMITS SINCE YOU …" — THE EXPANDED LIST ON A "PUSHED SINCE" MY TURN CARD ───────────────
8
+ //
9
+ // The card carries the count (`NewPrBall.humanCommitsAfter`) and the newest `PUSHED_COMMITS_SHOWN`.
10
+ // When the reader opens the card's disclosure, this lists EVERY commit that count names. It runs
11
+ // the SAME fold (`lastActionClocks`) on the one PR, so the list and the count cannot disagree:
12
+ // human commits by someone other than you, strictly after your last review (any state), review
13
+ // comment, PR comment or push. NOT "since you last looked" — viewing is not acting (`pr_views` is
14
+ // never read here, as in the fold). DB only; ⚠ fetched only on expand, never on the board's mount.
15
+ /** null ⇔ the PR is not this account's, or not in the workspace's repos (→ 404). */
16
+ export async function getPushedSinceCommits(accountId, prId, workspaceId, repoIds, listMax = PUSHED_SINCE_LIST_MAX) {
17
+ if (repoIds.length === 0)
18
+ return null;
19
+ const pr = await db
20
+ .select({ id: pullRequests.id })
21
+ .from(pullRequests)
22
+ .where(and(eq(pullRequests.id, prId), eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, [...repoIds])))
23
+ .limit(1)
24
+ .execute();
25
+ if (pr.length === 0)
26
+ return null;
27
+ const empty = {
28
+ workspaceId,
29
+ prId,
30
+ yourLastAction: null,
31
+ anchorAt: null,
32
+ total: 0,
33
+ commits: [],
34
+ };
35
+ const viewerId = await getAccountUserId(accountId);
36
+ if (viewerId == null)
37
+ return empty;
38
+ const c = (await lastActionClocks(viewerId, [prId])).get(prId);
39
+ if (!c || c.mineLast == null)
40
+ return empty;
41
+ const counted = [...c.humanCommitIdsAfterMine].sort((a, b) => b.at.getTime() - a.at.getTime() || b.id - a.id);
42
+ const shownIds = counted.slice(0, Math.max(0, listMax)).map((x) => x.id);
43
+ let list = [];
44
+ if (shownIds.length > 0) {
45
+ const rows = await db
46
+ .select({
47
+ id: commits.id,
48
+ sha: commits.sha,
49
+ headline: commits.messageHeadline,
50
+ authorId: commits.authorId,
51
+ at: commits.committedAt,
52
+ })
53
+ .from(commits)
54
+ .where(and(eq(commits.prId, prId), inArray(commits.id, shownIds)))
55
+ .execute();
56
+ list = rows
57
+ .sort((a, b) => b.at.getTime() - a.at.getTime() || b.id - a.id)
58
+ .map((r) => ({
59
+ sha: r.sha,
60
+ headline: r.headline ?? null,
61
+ authorId: r.authorId,
62
+ at: r.at.toISOString(),
63
+ }));
64
+ }
65
+ return {
66
+ workspaceId,
67
+ prId,
68
+ yourLastAction: c.mineLastAction,
69
+ anchorAt: c.mineLast.toISOString(),
70
+ total: c.humanCommitsAfterMine,
71
+ commits: list,
72
+ };
73
+ }
74
+ //# sourceMappingURL=my-turn-pushed-since.js.map
@@ -1235,6 +1235,11 @@ export const claudeReviews = sqliteTable('claude_reviews', {
1235
1235
  sourceUserId: integer('source_user_id').references(() => users.id),
1236
1236
  sourceReviewNodeId: text('source_review_node_id'),
1237
1237
  teammate: text('teammate', { mode: 'json' }).$type(),
1238
+ // On a TEAMMATE review only (migration 0164; review/claude-review/teammate-lift.ts): the reader's
1239
+ // Limn's ONE attempt to dismiss this teammate's standing Request changes. Claimed NULL →
1240
+ // 'dismissing' BEFORE the GitHub write, settled 'dismissed' / 'failed'; never retried. The import
1241
+ // never writes it.
1242
+ teammateLift: text('teammate_lift', { mode: 'json' }).$type(),
1238
1243
  }, (t) => ({
1239
1244
  prIdx: index('cr_pr_idx').on(t.prId),
1240
1245
  prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
@@ -1922,6 +1927,10 @@ export const workspaces = sqliteTable('workspaces', {
1922
1927
  // PR's head so teammates' Limn skips a commit someone is already reviewing with the same
1923
1928
  // provider. NULL/false = OFF, the default. The ONE writer is `setWorkspaceAutoReview`.
1924
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' }),
1925
1934
  // WHO RUNS AUTO REVIEWS (migration 0135): NULL = the default provider (Settings → AI); 'both' =
1926
1935
  // Claude then Codex as one pair (docs/CLAUDE-REVIEW.md § Claude + Codex). The ONE writer is
1927
1936
  // `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
@@ -47,6 +47,21 @@ const STATE_VERDICT = {
47
47
  changes_requested: 'REQUEST_CHANGES',
48
48
  commented: 'COMMENT',
49
49
  };
50
+ /** A pair's finding comments → who raised each, from the lead's tag ("Codex", "Claude + Codex"). */
51
+ function pairProviders(parsed) {
52
+ const out = {};
53
+ for (const { c, p } of parsed) {
54
+ const t = (p.tag ?? '').toLowerCase();
55
+ const who = [];
56
+ if (/\bclaude\b/.test(t))
57
+ who.push('claude');
58
+ if (/\bcodex\b/.test(t))
59
+ who.push('codex');
60
+ if (who.length > 0)
61
+ out[c.nodeId] = who;
62
+ }
63
+ return Object.keys(out).length > 0 ? { findingProviders: out } : {};
64
+ }
50
65
  /**
51
66
  * Import every review a teammate's Limn posted on this PR (see the header). Returns what it wrote.
52
67
  * Safe to call after every walk of the PR; one transaction.
@@ -184,6 +199,9 @@ export async function importTeammateReviews(accountId, prId) {
184
199
  ...(parsed.some((x) => x.p.severity == null)
185
200
  ? { unlabelled: parsed.filter((x) => x.p.severity == null).map((x) => x.c.nodeId) }
186
201
  : {}),
202
+ ...(meta.judged != null && meta.judged.length > 0 ? { judged: meta.judged } : {}),
203
+ ...(meta.liftConsent === true ? { liftConsent: true } : {}),
204
+ ...(meta.pair ? pairProviders(parsed) : {}),
187
205
  };
188
206
  const first = meta.runs[0];
189
207
  const values = {
@@ -37,6 +37,7 @@ export async function listTeammateReviews(exec, accountId, prId) {
37
37
  postedAt: cr.postedAt,
38
38
  createdAt: cr.createdAt,
39
39
  teammate: cr.teammate,
40
+ teammateLift: cr.teammateLift,
40
41
  userId: users.id,
41
42
  login: users.githubLogin,
42
43
  displayName: users.displayName,
@@ -85,6 +86,14 @@ export async function listTeammateReviews(exec, accountId, prId) {
85
86
  const rec = (r.teammate ?? null);
86
87
  const unlabelled = new Set(rec?.unlabelled ?? []);
87
88
  const prUrl = `https://github.com/${r.owner}/${r.name}/pull/${r.number}`;
89
+ // Who raised each finding: a pair comment's lead tag, else the single run's provider.
90
+ const single = rec != null && !rec.pair && rec.runs.length === 1 ? rec.runs[0]?.provider ?? null : null;
91
+ const providersOf = (nodeId) => {
92
+ const tagged = nodeId != null ? rec?.findingProviders?.[nodeId] : undefined;
93
+ if (tagged != null && tagged.length > 0)
94
+ return { providers: tagged };
95
+ return single != null ? { providers: [single] } : {};
96
+ };
88
97
  const mine = findings
89
98
  .filter((f) => f.reviewId === r.id)
90
99
  .map((f) => {
@@ -103,6 +112,8 @@ export async function listTeammateReviews(exec, accountId, prId) {
103
112
  ? `${prUrl}#discussion_r${f.githubCommentId}`
104
113
  : `${prUrl}#issuecomment-${f.githubCommentId}`,
105
114
  threadId: kind === 'inline' && f.nodeId != null ? (threadOf.get(f.nodeId) ?? null) : null,
115
+ ...providersOf(f.nodeId),
116
+ githubCommentId: f.githubCommentId ?? null,
106
117
  };
107
118
  });
108
119
  const at = r.postedAt ?? r.createdAt;
@@ -120,6 +131,9 @@ export async function listTeammateReviews(exec, accountId, prId) {
120
131
  runs: rec?.runs ?? [],
121
132
  limnVersion: rec?.limnVersion ?? null,
122
133
  findings: mine,
134
+ ...(rec?.judged != null && rec.judged.length > 0 ? { judged: rec.judged } : {}),
135
+ // The reader's Limn's one attempt to lift this block (migration 0164).
136
+ ...(r.teammateLift != null ? { lift: r.teammateLift } : {}),
123
137
  };
124
138
  });
125
139
  }
@@ -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