limn-review 0.1.148 → 0.1.150
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/ai/budget.js +5 -2
- package/dist/db/migrations/0145_verdict_strictness.sql +14 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/schema.sqlite.js +12 -0
- package/dist/review/claude-review/auto-post.js +101 -28
- package/dist/review/claude-review/auto-settings.js +79 -6
- package/dist/review/claude-review/auto-verdict.js +251 -8
- package/dist/review/claude-review/not-approved.js +65 -0
- package/dist/review/claude-review/pair-consolidate.js +10 -5
- package/dist/review/claude-review/pair-view.js +8 -8
- package/dist/review/claude-review/pair.js +3 -11
- package/dist/review/claude-review/persist.js +2 -0
- package/dist/review/claude-review/replies-check.js +25 -1
- package/dist/review/claude-review/replies-outcome.js +77 -13
- package/dist/review/claude-review/replies-pass.js +33 -1
- package/dist/review/claude-review/routes.js +5 -2
- package/dist/shared/ai-budget.js +17 -6
- package/dist/shared/types.js +2 -0
- package/package.json +1 -1
- package/public/assets/AiFixTab-DkE7ux2d.js +3 -0
- package/public/assets/ClaudeReviewTab-CuU74jVK.js +20 -0
- package/public/assets/PrBotBehaviourTab-BAm-ydkH.js +1 -0
- package/public/assets/index-B9TtX9l5.js +85 -0
- package/public/assets/index-DE3rTFfM.css +1 -0
- package/public/index.html +2 -2
- package/public/assets/AiFixTab-C4qSF6Rh.js +0 -3
- package/public/assets/ClaudeReviewTab-iR4WZm87.js +0 -20
- package/public/assets/PrBotBehaviourTab-D7Lnwfm8.js +0 -1
- package/public/assets/index-C-ZWx5Hz.css +0 -1
- package/public/assets/index-ZWr-Rhuj.js +0 -82
package/dist/ai/budget.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
//
|
|
46
46
|
// One budget per ACCOUNT (= per machine), never per workspace.
|
|
47
47
|
import { and, eq, gte, inArray } from 'drizzle-orm';
|
|
48
|
-
import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, rollingSpend, } from '../shared/index.js';
|
|
48
|
+
import { AI_BUDGET_DAY_MS, AI_BUDGET_WEEK_MS, aiProviderWorthShowing, aiUsageView, budgetWindowStart, decideAiBudget, liveFiveHourReset, liveWeekReset, resolveAiBudgetSettings, budgetSettingsInForce, rollingSpend, } from '../shared/index.js';
|
|
49
49
|
import { db, schema } from '../db/client.js';
|
|
50
50
|
import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
|
|
51
51
|
import { getActiveProvider, getProvider } from './provider.js';
|
|
@@ -288,7 +288,10 @@ export async function aiBudgetSnapshot(accountId, opts = {}) {
|
|
|
288
288
|
// the ledger — the named one (a manual run's per-run choice), else the default (active) one.
|
|
289
289
|
const provider = opts.provider ? getProvider(opts.provider) : getActiveProvider();
|
|
290
290
|
const route = provider.credentialRoute();
|
|
291
|
-
const
|
|
291
|
+
const stored = await readAiBudgetSettings(accountId);
|
|
292
|
+
const isDefault = stored.isDefault;
|
|
293
|
+
// On by default (a share of the week); a never-set row on an API key applies no US$ cap.
|
|
294
|
+
const settings = budgetSettingsInForce(stored.settings, isDefault, route);
|
|
292
295
|
const probe = opts.probe ?? 'when_on';
|
|
293
296
|
if (route === 'login' && (probe === 'always' || (probe === 'when_on' && settings.enabled)))
|
|
294
297
|
kickUsageProbe(nowMs, provider);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- THE AUTO VERDICT'S TWO RULES + THE NOT-APPROVED REASON (docs/CLAUDE-REVIEW.md § Auto-posting →
|
|
2
|
+
-- AUTO VERDICT). Both workspace columns are OVERRIDES ONLY: NULL = today's rule.
|
|
3
|
+
-- `pair_verdict_strictness`: how a Claude + Codex pair's verdict is folded — NULL / 'strict' = approve
|
|
4
|
+
-- only when both approved; 'relaxed' = approve when one approved and neither asked for changes.
|
|
5
|
+
ALTER TABLE `workspaces` ADD `pair_verdict_strictness` text;
|
|
6
|
+
--> statement-breakpoint
|
|
7
|
+
-- `approve_leftovers`: which open findings may remain when Limn approves — NULL / 'nits_questions' =
|
|
8
|
+
-- nits and questions; 'nits' = nits only; 'none' = nothing open at all.
|
|
9
|
+
ALTER TABLE `workspaces` ADD `approve_leftovers` text;
|
|
10
|
+
--> statement-breakpoint
|
|
11
|
+
-- A REPLIES PASS's not-approved reason ({heldReason, text}), written when the pass ends while the
|
|
12
|
+
-- workspace's auto verdict is on. NULL on every other row (a full review's reason is on its
|
|
13
|
+
-- `auto_post` record, or computed on read).
|
|
14
|
+
ALTER TABLE `claude_reviews` ADD `verdict_reason` text;
|
|
@@ -1016,6 +1016,13 @@
|
|
|
1016
1016
|
"when": 1792137640016,
|
|
1017
1017
|
"tag": "0144_workspace_tracker_notion",
|
|
1018
1018
|
"breakpoints": true
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
"idx": 145,
|
|
1022
|
+
"version": "6",
|
|
1023
|
+
"when": 1792137641016,
|
|
1024
|
+
"tag": "0145_verdict_strictness",
|
|
1025
|
+
"breakpoints": true
|
|
1019
1026
|
}
|
|
1020
1027
|
]
|
|
1021
1028
|
}
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -1123,6 +1123,11 @@ export const claudeReviews = sqliteTable('claude_reviews', {
|
|
|
1123
1123
|
// people's replies on Limn's own posted findings; NULL on every full review (migration 0136).
|
|
1124
1124
|
// ⚠ Every "the latest review" / "a run at this head" reader filters `kind IS NULL`.
|
|
1125
1125
|
kind: text('kind', { enum: ['replies'] }),
|
|
1126
|
+
// A REPLIES PASS's not-approved reason (migration 0145; review/claude-review/replies-outcome.ts):
|
|
1127
|
+
// `{heldReason, text}`, written when the pass ends while the workspace's auto verdict is on and
|
|
1128
|
+
// the outcome is not an approval. NULL on every full review (its reason rides `auto_post`, or is
|
|
1129
|
+
// computed on read).
|
|
1130
|
+
verdictReason: text('verdict_reason', { mode: 'json' }).$type(),
|
|
1126
1131
|
}, (t) => ({
|
|
1127
1132
|
prIdx: index('cr_pr_idx').on(t.prId),
|
|
1128
1133
|
prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
|
|
@@ -1808,6 +1813,13 @@ export const workspaces = sqliteTable('workspaces', {
|
|
|
1808
1813
|
// Claude then Codex as one pair (docs/CLAUDE-REVIEW.md § Claude + Codex). The ONE writer is
|
|
1809
1814
|
// `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
|
|
1810
1815
|
autoReviewProviders: text('auto_review_providers'),
|
|
1816
|
+
// THE AUTO VERDICT'S TWO RULES (migration 0145; docs/CLAUDE-REVIEW.md § Auto-posting → AUTO
|
|
1817
|
+
// VERDICT), OVERRIDES ONLY. `pairVerdictStrictness`: NULL = 'strict' (a Claude + Codex pair approves
|
|
1818
|
+
// only when both approved), 'relaxed' (one approved and neither asked for changes).
|
|
1819
|
+
// `approveLeftovers`: NULL = 'nits_questions' (nits and questions may remain open on an approve),
|
|
1820
|
+
// 'nits', 'none'. The ONE writer is `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
|
|
1821
|
+
pairVerdictStrictness: text('pair_verdict_strictness'),
|
|
1822
|
+
approveLeftovers: text('approve_leftovers'),
|
|
1811
1823
|
// DEPENDENCY AUTO-MERGE (migration 0093 / pg 0080): may the server arm "merge when ready" on
|
|
1812
1824
|
// every open dependency-automation PR in this workspace's repos (merge/dependency-policy.ts)?
|
|
1813
1825
|
// NULL/false = OFF, the default for every workspace; nullable with no default, so no insert
|
|
@@ -37,8 +37,15 @@
|
|
|
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
|
-
//
|
|
41
|
-
//
|
|
40
|
+
// 3c. THE REASON (only with `autoVerdict` on): a run that does not end in an APPROVE gets ONE
|
|
41
|
+
// plain-English "Not approved: …" line (`findingsNotApproved` + the gate's `heldReason`,
|
|
42
|
+
// ./auto-verdict.ts), stored on the verdict record (`reasonText`). Posted findings carry it as the
|
|
43
|
+
// review body's last line before the footer; with NOTHING else to post it goes out as ONE short
|
|
44
|
+
// COMMENT review of its own — at most once per (PR, head): an earlier record at this head with
|
|
45
|
+
// `reasonPosted` (a full review's or a replies pass's) means nothing more is posted;
|
|
46
|
+
// 4. nothing to post, nothing but COMMENT to send and no reason to post ⇒ `skipped` —
|
|
47
|
+
// `already_posted` (with the ids) when the dedupe alone emptied it, else `nothing_new` — no
|
|
48
|
+
// GitHub write;
|
|
42
49
|
// 5. the LIVE PR is still open, not a draft, at the reviewed head (a moved head ⇒ `failed`);
|
|
43
50
|
// 6. THE CLAIM — a compare-and-set of `claude_reviews.auto_post` from NULL to `status: 'posting'`
|
|
44
51
|
// BEFORE any write, so a second call (or a restart) can never post the same run twice;
|
|
@@ -65,9 +72,9 @@ import { similarTitles } from './settled-by-reply.js';
|
|
|
65
72
|
import { prAuthorIsAccount } from '../../coding/ai-fix/auto-fix.js';
|
|
66
73
|
import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, readPairConsolidation, } from './persist.js';
|
|
67
74
|
import { resolveFindingBody } from './follow-up.js';
|
|
68
|
-
import { autoVerdictFor } from './auto-verdict.js';
|
|
75
|
+
import { autoVerdictFor, findingsNotApproved, pairVerdict, reasonMayPost, safeReasonText, withReason, } from './auto-verdict.js';
|
|
69
76
|
import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
|
|
70
|
-
import { pairRaisedByLead, pairReviewBody
|
|
77
|
+
import { pairRaisedByLead, pairReviewBody } from './pair.js';
|
|
71
78
|
// A pair's body names both when it falls back to a count ("3 comments from Claude and Codex.").
|
|
72
79
|
const PAIR_BODY_BY = 'Claude and Codex';
|
|
73
80
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -245,9 +252,28 @@ export function autoReviewBodyLead(summary, commentCount, by = 'Claude') {
|
|
|
245
252
|
return `Reviewed by ${by}.`;
|
|
246
253
|
return commentCount === 1 ? `One comment from ${by}.` : `${commentCount} comments from ${by}.`;
|
|
247
254
|
}
|
|
248
|
-
/** The posted body of an auto review: the lead, then the
|
|
249
|
-
|
|
250
|
-
|
|
255
|
+
/** The posted body of an auto review: the lead, then the not-approved reason when there is one, then
|
|
256
|
+
* the footer (the seam appends the marker). */
|
|
257
|
+
export function autoReviewBody(summary, commentCount, by = 'Claude', reason = null) {
|
|
258
|
+
return `${autoReviewBodyLead(summary, commentCount, by)}\n\n${withReasonLine(reason, autoPostFooter(by))}`;
|
|
259
|
+
}
|
|
260
|
+
/** The footer, led by the not-approved reason as its own line when there is one. */
|
|
261
|
+
export function withReasonLine(reason, footer) {
|
|
262
|
+
return reason ? `${safeReasonText(reason)}\n\n${footer}` : footer;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Has a not-approved reason already gone to GitHub at this head (any run of the PR other than
|
|
266
|
+
* `exceptIds`, full review or replies pass — a record whose `reasonPosted` is set, whatever became of
|
|
267
|
+
* it: never retried)? A reason-only review goes out at most once per (PR, head).
|
|
268
|
+
*/
|
|
269
|
+
export async function reasonPostedAtHead(ctx, accountId, prId, headSha, exceptIds) {
|
|
270
|
+
const cr = s(ctx).claudeReviews;
|
|
271
|
+
const rows = (await ctx.db
|
|
272
|
+
.select({ id: cr.id, autoPost: cr.autoPost })
|
|
273
|
+
.from(cr)
|
|
274
|
+
.where(and(eq(cr.prId, prId), eq(cr.accountId, accountId), eq(cr.headSha, headSha)))
|
|
275
|
+
.execute());
|
|
276
|
+
return rows.some((r) => !exceptIds.includes(r.id) && r.autoPost?.reasonPosted != null && r.autoPost.reasonPosted !== '');
|
|
251
277
|
}
|
|
252
278
|
export const defaultAutoPostDeps = {
|
|
253
279
|
async livePr(owner, name, prNumber) {
|
|
@@ -416,6 +442,7 @@ export async function readAutoPostPrFacts(ctx, deps, accountId, workspaceId, prI
|
|
|
416
442
|
deps.accountUserId(accountId),
|
|
417
443
|
]);
|
|
418
444
|
let requestedOfMe = false;
|
|
445
|
+
let openRequestOfMe = false;
|
|
419
446
|
if (me != null) {
|
|
420
447
|
const [open, history] = await Promise.all([
|
|
421
448
|
ctx.db
|
|
@@ -431,7 +458,8 @@ export async function readAutoPostPrFacts(ctx, deps, accountId, workspaceId, prI
|
|
|
431
458
|
.limit(1)
|
|
432
459
|
.execute(),
|
|
433
460
|
]);
|
|
434
|
-
|
|
461
|
+
openRequestOfMe = open.length > 0;
|
|
462
|
+
requestedOfMe = openRequestOfMe || history.length > 0;
|
|
435
463
|
}
|
|
436
464
|
let participatedByMe = false;
|
|
437
465
|
if (me != null && !authorIsMe && !requestedOfMe) {
|
|
@@ -445,7 +473,7 @@ export async function readAutoPostPrFacts(ctx, deps, accountId, workspaceId, prI
|
|
|
445
473
|
const hits = await Promise.all([by(t.reviews), by(t.reviewComments), by(t.prComments)]);
|
|
446
474
|
participatedByMe = hits.some((h) => h.length > 0);
|
|
447
475
|
}
|
|
448
|
-
return { state: pr.state, isDraft: pr.isDraft === true, authorIsBot, authorIsMe, requestedOfMe, participatedByMe };
|
|
476
|
+
return { state: pr.state, isDraft: pr.isDraft === true, authorIsBot, authorIsMe, requestedOfMe, openRequestOfMe, participatedByMe };
|
|
449
477
|
}
|
|
450
478
|
/** Every earlier run's findings of this PR (account-scoped), as the dedupe reads them. */
|
|
451
479
|
export async function loadEarlierFindings(ctx, accountId, prId, beforeReviewId) {
|
|
@@ -591,8 +619,13 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
591
619
|
for (const f of r.findings)
|
|
592
620
|
byOf.set(f.id, aiRunProviderName(r));
|
|
593
621
|
const allFindings = runs.flatMap((r) => r.findings);
|
|
622
|
+
// THE WORKSPACE'S VERDICT RULES (migration 0145): strict / relaxed for a pair, and what may stay open.
|
|
623
|
+
const rules = { pairStrictness: ws.pairStrictness, approveLeftovers: ws.settings.approveLeftovers };
|
|
594
624
|
// The run's verdict as `autoVerdictFor` reads it: a pair's is both runs' together.
|
|
595
|
-
const runVerdict = first ? pairVerdict(first.verdict, review.verdict) : review.verdict;
|
|
625
|
+
const runVerdict = first ? pairVerdict(first.verdict, review.verdict, rules.pairStrictness) : review.verdict;
|
|
626
|
+
// WHY NOT APPROVED, from the findings and the model(s) — only while the auto verdict is on.
|
|
627
|
+
const verdictRuns = runs.map((r) => ({ by: aiRunProviderName(r), verdict: r.verdict, summary: r.summary }));
|
|
628
|
+
const findingsReason = ws.settings.autoVerdict ? findingsNotApproved(verdictRuns, allFindings, rules) : null;
|
|
596
629
|
// Claim the FIRST half as posted in this pair's review (compare-and-set from NULL): false ⇒ some
|
|
597
630
|
// other writer has it, and its findings leave this post.
|
|
598
631
|
const claimFirst = async (rec) => first != null && (await claimRun(ctx, first.id, { ...rec, viaPairReviewId: reviewId }));
|
|
@@ -615,6 +648,14 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
615
648
|
await claimFirst(rec);
|
|
616
649
|
return { kind: 'skipped', reason };
|
|
617
650
|
};
|
|
651
|
+
const wanted = ws.settings.autoVerdict
|
|
652
|
+
? autoVerdictFor(runVerdict, allFindings, rules.approveLeftovers)
|
|
653
|
+
: null;
|
|
654
|
+
// A run that never wanted more than a COMMENT is recorded with its reason from the start (shown in
|
|
655
|
+
// Limn whatever happens next).
|
|
656
|
+
if (wanted != null && wanted === 'COMMENT') {
|
|
657
|
+
verdict = withReason(verdictGate('COMMENT', { authorIsMe: false, ownLatest: 'none' }), findingsReason);
|
|
658
|
+
}
|
|
618
659
|
const facts = await readAutoPostPrFacts(ctx, deps, accountId, ws.workspaceId, prId);
|
|
619
660
|
if (!facts)
|
|
620
661
|
return { kind: 'not_found' };
|
|
@@ -626,13 +667,14 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
626
667
|
const earlier = await loadEarlierFindings(ctx, accountId, prId, runs[0].id);
|
|
627
668
|
const picked = selectFindingsToPost(allFindings, ws.settings.kinds, earlier);
|
|
628
669
|
const nothingReason = () => picked.alreadyPosted.length > 0 ? ['already_posted', picked.alreadyPosted.map((f) => f.id)] : ['nothing_new', undefined];
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
670
|
+
// A reason-only review goes out at most once per (PR, head): read only when nothing else would post.
|
|
671
|
+
const reasonAlreadyAtHead = ws.settings.autoVerdict && picked.toPost.length === 0
|
|
672
|
+
? await reasonPostedAtHead(ctx, accountId, prId, review.headSha, runs.map((r) => r.id))
|
|
673
|
+
: false;
|
|
632
674
|
// LIFTING LIMN'S OWN BLOCK: a run with NO blocker (whatever the auto-verdict switch says now) may
|
|
633
675
|
// withdraw an earlier AUTOMATIC request for changes of Limn's that still stands. A DB pre-check
|
|
634
676
|
// first, so an ordinary run costs no extra GitHub read.
|
|
635
|
-
const noBlocker = autoVerdictFor(runVerdict, allFindings) !== 'REQUEST_CHANGES';
|
|
677
|
+
const noBlocker = autoVerdictFor(runVerdict, allFindings, rules.approveLeftovers) !== 'REQUEST_CHANGES';
|
|
636
678
|
// ⚠ "No blocker" from this run's OWN findings is not enough to lift: an earlier blocker the run
|
|
637
679
|
// did not judge (a 'not_checked' follow-up) is still open as far as anyone knows. (A pair: either
|
|
638
680
|
// half's.)
|
|
@@ -640,8 +682,12 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
640
682
|
// (Read for a REQUEST_CHANGES too: the gate must tell Limn's own standing block from a person's.)
|
|
641
683
|
const limnBlockIds = !facts.authorIsMe ? await loadLimnAutoBlockIds(ctx, accountId, prId, reviewId) : new Set();
|
|
642
684
|
const mayLift = noBlocker && unjudgedBlockers.length === 0 && limnBlockIds.size > 0;
|
|
643
|
-
|
|
685
|
+
// A COMMENT-only run with a reason still to tell GitHub goes on (to the reason-only review).
|
|
686
|
+
// (Never while a review request of the reader is open: any review answers it.)
|
|
687
|
+
const commentReasonToPost = wanted === 'COMMENT' && reasonMayPost(verdict) && !reasonAlreadyAtHead && facts.openRequestOfMe !== true;
|
|
688
|
+
if (picked.toPost.length === 0 && (wanted == null || wanted === 'COMMENT') && !mayLift && !commentReasonToPost) {
|
|
644
689
|
return skip(...nothingReason());
|
|
690
|
+
}
|
|
645
691
|
const pctx = await getReviewPostContext(ctx, reviewId, accountId);
|
|
646
692
|
if (!pctx)
|
|
647
693
|
return { kind: 'not_found' };
|
|
@@ -700,16 +746,27 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
700
746
|
if (verdict.submitted === 'APPROVE' && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' && blockersRemain) {
|
|
701
747
|
verdict = { ...verdict, submitted: 'COMMENT', heldReason: 'already_requested' };
|
|
702
748
|
}
|
|
749
|
+
verdict = withReason(verdict, findingsReason);
|
|
703
750
|
}
|
|
751
|
+
// The reason this run tells GitHub (none on an approval, none with the verdict switched off, none
|
|
752
|
+
// for a Limn-only gate reason — own PR, unreadable reviews, a refusal: `reasonMayPost`).
|
|
753
|
+
const reasonNow = () => verdict != null && verdict.submitted !== 'APPROVE' && reasonMayPost(verdict) ? (verdict.reasonText ?? null) : null;
|
|
754
|
+
// NOTHING ELSE TO POST: the reason alone, as one short COMMENT review — once per (PR, head), and
|
|
755
|
+
// never while a review request of the reader is open (the review would answer it).
|
|
756
|
+
const reasonOnly = picked.toPost.length === 0 &&
|
|
757
|
+
(verdict?.submitted ?? 'COMMENT') === 'COMMENT' &&
|
|
758
|
+
reasonNow() != null &&
|
|
759
|
+
!reasonAlreadyAtHead &&
|
|
760
|
+
facts.openRequestOfMe !== true;
|
|
704
761
|
// Limn's own automatic request for changes still stands, this run has no blocker and none behind
|
|
705
762
|
// the block is left unjudged: lift it — superseded by the APPROVE this run sends, else dismissed.
|
|
706
763
|
const liftId = noBlocker && !blockersRemain && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' ? standing.reviewId : null;
|
|
707
|
-
if (picked.toPost.length === 0 && (verdict?.submitted ?? 'COMMENT') === 'COMMENT' && liftId == null) {
|
|
764
|
+
if (picked.toPost.length === 0 && (verdict?.submitted ?? 'COMMENT') === 'COMMENT' && liftId == null && !reasonOnly) {
|
|
708
765
|
return skip(...nothingReason());
|
|
709
766
|
}
|
|
710
767
|
const event = verdict?.submitted ?? 'COMMENT';
|
|
711
768
|
// Nothing but the lift to do: no review is sent at all.
|
|
712
|
-
const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT';
|
|
769
|
+
const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT' || reasonOnly;
|
|
713
770
|
const record = {
|
|
714
771
|
status: 'posting',
|
|
715
772
|
at: nowIso(),
|
|
@@ -727,6 +784,13 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
727
784
|
await claimFirst(rec);
|
|
728
785
|
return { kind: 'done', record: rec };
|
|
729
786
|
}
|
|
787
|
+
// The reason rides the review (its last line, or the whole body): recorded as POSSIBLY POSTED from
|
|
788
|
+
// the claim on, so a restart never sends it again at this head.
|
|
789
|
+
if (sendsReview && reasonNow() != null) {
|
|
790
|
+
record.reasonPosted = reasonNow();
|
|
791
|
+
if (reasonOnly)
|
|
792
|
+
record.reasonOnly = true;
|
|
793
|
+
}
|
|
730
794
|
// ⚠ THE CLAIM, BEFORE ANY WRITE — on THIS run, then (a pair) on its first half. A first half
|
|
731
795
|
// someone else claimed meanwhile keeps its findings out of this post.
|
|
732
796
|
if (!(await claimRun(ctx, reviewId, record)))
|
|
@@ -749,21 +813,28 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
749
813
|
};
|
|
750
814
|
const posted = new Set();
|
|
751
815
|
const errors = [];
|
|
816
|
+
// The body. With findings: the lead, the reason line, the footer. Reason only: the reason, the footer.
|
|
817
|
+
const bodyOf = (reason) => {
|
|
818
|
+
const footer = pairFirst != null ? PAIR_AUTO_POST_FOOTER : autoPostFooter(aiRunProviderName(review));
|
|
819
|
+
if (reasonOnly && reason != null)
|
|
820
|
+
return withReasonLine(reason, footer);
|
|
821
|
+
if (pairFirst == null)
|
|
822
|
+
return autoReviewBody(review.summary, picked.toPost.length, aiRunProviderName(review), reason);
|
|
823
|
+
return consolidation?.how === 'pass'
|
|
824
|
+
? `${autoReviewBodyLead(consolidation.summary, picked.toPost.length, PAIR_BODY_BY)}\n\n${withReasonLine(reason, footer)}`
|
|
825
|
+
: // Joined in code (or an older pair): each half's lead, named.
|
|
826
|
+
pairReviewBody([pairFirst, review].map((r) => {
|
|
827
|
+
const mine = new Set(r.findings.map((x) => x.id));
|
|
828
|
+
const n = picked.toPost.filter((f) => mine.has(f.id)).length;
|
|
829
|
+
return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
|
|
830
|
+
}), withReasonLine(reason, footer));
|
|
831
|
+
};
|
|
752
832
|
const submit = (ev) => ctx.review.postReview({
|
|
753
833
|
owner: pctx.owner,
|
|
754
834
|
name: pctx.name,
|
|
755
835
|
prNumber: pctx.prNumber,
|
|
756
836
|
reviewHeadSha: pctx.reviewHeadSha,
|
|
757
|
-
body:
|
|
758
|
-
? consolidation?.how === 'pass'
|
|
759
|
-
? `${autoReviewBodyLead(consolidation.summary, picked.toPost.length, PAIR_BODY_BY)}\n\n${PAIR_AUTO_POST_FOOTER}`
|
|
760
|
-
: // Joined in code (or an older pair): each half's lead, named.
|
|
761
|
-
pairReviewBody([pairFirst, review].map((r) => {
|
|
762
|
-
const mine = new Set(r.findings.map((x) => x.id));
|
|
763
|
-
const n = picked.toPost.filter((f) => mine.has(f.id)).length;
|
|
764
|
-
return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
|
|
765
|
-
}), PAIR_AUTO_POST_FOOTER)
|
|
766
|
-
: autoReviewBody(review.summary, picked.toPost.length, aiRunProviderName(review)),
|
|
837
|
+
body: bodyOf(ev === 'APPROVE' ? null : reasonNow()),
|
|
767
838
|
// COMMENT unless auto verdict is on AND its gate let the verdict through.
|
|
768
839
|
verdict: ev,
|
|
769
840
|
// Questions ride the same review: the seam anchors each inline, else falls back. In a pair each
|
|
@@ -787,8 +858,10 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
787
858
|
// Never on a COMMENT, never on an ambiguous failure (5xx, network, rate limit), never twice.
|
|
788
859
|
if (event === 'COMMENT' || !isVerdictRefusal(err))
|
|
789
860
|
throw err;
|
|
790
|
-
verdict = { ...verdict, submitted: 'COMMENT', heldReason: 'refused', refusedError: errText(err).slice(0, 300) };
|
|
861
|
+
verdict = withReason({ ...verdict, submitted: 'COMMENT', heldReason: 'refused', refusedError: errText(err).slice(0, 300) }, findingsReason);
|
|
791
862
|
record.verdict = verdict;
|
|
863
|
+
if (picked.toPost.length > 0 && reasonNow() != null)
|
|
864
|
+
record.reasonPosted = reasonNow();
|
|
792
865
|
// A bare verdict with no comments behind it: nothing is left to post. GitHub created
|
|
793
866
|
// nothing, so a lift may still go ahead.
|
|
794
867
|
if (picked.toPost.length === 0) {
|
|
@@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm';
|
|
|
2
2
|
import { AUTO_FIX_DEFAULT_AUTO_PUSH, AUTO_FIX_DEFAULT_INCLUDE, AUTO_POST_DEFAULT_APPROVE_IMPACT, AUTO_POST_DEFAULT_AUTO_RESOLVE, AUTO_POST_DEFAULT_AUTO_VERDICT, AUTO_POST_DEFAULT_KINDS, AUTO_POST_DEFAULT_SCOPE, } from '../../shared/index.js';
|
|
3
3
|
import { dropAutoReviews } from './manager.js';
|
|
4
4
|
import { resolveAutoReviewProviders } from './pair.js';
|
|
5
|
+
import { DEFAULT_VERDICT_RULES, resolveApproveLeftovers, resolvePairStrictness, } from './auto-verdict.js';
|
|
5
6
|
import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
|
|
6
7
|
// AUTO CLAUDE REVIEW — THE PER-WORKSPACE SWITCH (CORE since migration 0074 / pg 0061; it lived on
|
|
7
8
|
// the plugin's `pro_workspace_settings` until Claude Review left the plugin, and plugin migration
|
|
@@ -42,6 +43,11 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
|
|
|
42
43
|
// `autoResolve` switches (both OFF by default) ride the same overrides JSON (no column), and so
|
|
43
44
|
// does `approveImpact` (which blast-radius levels an auto verdict may APPROVE; all by default).
|
|
44
45
|
//
|
|
46
|
+
// THE AUTO VERDICT'S TWO RULES ride the same route as two more columns (migration 0145), OVERRIDES
|
|
47
|
+
// ONLY (NULL = today's rule): `pair_verdict_strictness` ('relaxed', else strict — wire
|
|
48
|
+
// `pairStrictness`) and `approve_leftovers` ('nits' / 'none', else 'nits_questions' — wire
|
|
49
|
+
// `autoPost.approveLeftovers`). `readVerdictRulesForPr` is what every verdict reads.
|
|
50
|
+
//
|
|
45
51
|
// TEAM CLAIMS ("Mark auto reviews on GitHub") ride the same route as one more column,
|
|
46
52
|
// `team_claims_enabled` (migration 0134): may an AUTO review post a status line on the PR's head so
|
|
47
53
|
// teammates' Limn skips it (team-claim.ts)? OFF by default — only a stored true is on.
|
|
@@ -68,9 +74,12 @@ export function resolveAutoReviewDailyCap(stored) {
|
|
|
68
74
|
}
|
|
69
75
|
const AUTO_POST_KIND_KEYS = Object.keys(AUTO_POST_DEFAULT_KINDS);
|
|
70
76
|
export const APPROVE_IMPACT_KEYS = Object.keys(AUTO_POST_DEFAULT_APPROVE_IMPACT);
|
|
77
|
+
const APPROVE_LEFTOVERS = ['none', 'nits', 'nits_questions'];
|
|
71
78
|
const isScope = (v) => v === 'mine' || v === 'all';
|
|
72
79
|
/** The stored switch + overrides → the settings in force. Anything malformed reads as the default. */
|
|
73
|
-
export function resolveAutoPostSettings(enabled, stored
|
|
80
|
+
export function resolveAutoPostSettings(enabled, stored,
|
|
81
|
+
// `workspaces.approve_leftovers` (migration 0145): its own column, NULL = the default.
|
|
82
|
+
approveLeftovers) {
|
|
74
83
|
const kinds = { ...AUTO_POST_DEFAULT_KINDS };
|
|
75
84
|
const sk = stored?.kinds;
|
|
76
85
|
if (sk != null && typeof sk === 'object') {
|
|
@@ -92,6 +101,7 @@ export function resolveAutoPostSettings(enabled, stored) {
|
|
|
92
101
|
autoVerdict: typeof stored?.autoVerdict === 'boolean' ? stored.autoVerdict : AUTO_POST_DEFAULT_AUTO_VERDICT,
|
|
93
102
|
autoResolve: typeof stored?.autoResolve === 'boolean' ? stored.autoResolve : AUTO_POST_DEFAULT_AUTO_RESOLVE,
|
|
94
103
|
approveImpact,
|
|
104
|
+
approveLeftovers: resolveApproveLeftovers(approveLeftovers),
|
|
95
105
|
};
|
|
96
106
|
}
|
|
97
107
|
/** The settings in force → what to store: only what differs from the product default (null = none). */
|
|
@@ -161,7 +171,8 @@ export function autoReviewOf(row) {
|
|
|
161
171
|
// ON unless explicitly switched off (0126).
|
|
162
172
|
autoDiagramsEnabled: row?.autoDiagramsEnabled !== false,
|
|
163
173
|
autoReviewProviders: resolveAutoReviewProviders(row?.providers),
|
|
164
|
-
|
|
174
|
+
pairStrictness: resolvePairStrictness(row?.pairStrictness),
|
|
175
|
+
autoPost: resolveAutoPostSettings(row?.autoPostEnabled, row?.autoPostSettings, row?.approveLeftovers),
|
|
165
176
|
autoFix: resolveAutoFixSettings(row?.autoFixSettings),
|
|
166
177
|
// OFF unless explicitly switched on (0134).
|
|
167
178
|
teamClaimsEnabled: row?.teamClaimsEnabled === true,
|
|
@@ -175,7 +186,12 @@ export function autoReviewOf(row) {
|
|
|
175
186
|
export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
|
|
176
187
|
const { workspaces: w, workspaceRepos: wr, pullRequests: prs } = ctx.schema;
|
|
177
188
|
const rows = (await ctx.db
|
|
178
|
-
.select({
|
|
189
|
+
.select({
|
|
190
|
+
workspaceId: w.id,
|
|
191
|
+
enabled: w.autoPostEnabled,
|
|
192
|
+
stored: w.autoPostSettings,
|
|
193
|
+
...verdictRuleCols(w),
|
|
194
|
+
})
|
|
179
195
|
.from(prs)
|
|
180
196
|
.innerJoin(wr, and(eq(wr.repoId, prs.repoId), eq(wr.accountId, accountId)))
|
|
181
197
|
.innerJoin(w, and(eq(w.id, wr.workspaceId), eq(w.accountId, accountId)))
|
|
@@ -183,7 +199,28 @@ export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
|
|
|
183
199
|
.limit(1)
|
|
184
200
|
.execute());
|
|
185
201
|
const r = rows[0];
|
|
186
|
-
return r
|
|
202
|
+
return r
|
|
203
|
+
? {
|
|
204
|
+
workspaceId: r.workspaceId,
|
|
205
|
+
settings: resolveAutoPostSettings(r.enabled, r.stored, r.approveLeftovers),
|
|
206
|
+
pairStrictness: resolvePairStrictness(r.pairStrictness),
|
|
207
|
+
}
|
|
208
|
+
: null;
|
|
209
|
+
}
|
|
210
|
+
// The two rule columns (migration 0145), when the schema has them (an older test schema may not).
|
|
211
|
+
const verdictRuleCols = (w) => ({
|
|
212
|
+
...(w.pairVerdictStrictness ? { pairStrictness: w.pairVerdictStrictness } : {}),
|
|
213
|
+
...(w.approveLeftovers ? { approveLeftovers: w.approveLeftovers } : {}),
|
|
214
|
+
});
|
|
215
|
+
/**
|
|
216
|
+
* THE AUTO VERDICT'S RULES for the workspace holding this PR's repo (strict / relaxed, what may stay
|
|
217
|
+
* open), with whether its auto verdict is on. A PR with no membership row reads the defaults, off.
|
|
218
|
+
*/
|
|
219
|
+
export async function readVerdictRulesForPr(ctx, accountId, prId) {
|
|
220
|
+
const ws = await readWorkspaceAutoPostForPr(ctx, accountId, prId);
|
|
221
|
+
if (!ws)
|
|
222
|
+
return { ...DEFAULT_VERDICT_RULES, autoVerdict: false };
|
|
223
|
+
return { pairStrictness: ws.pairStrictness, approveLeftovers: ws.settings.approveLeftovers, autoVerdict: ws.settings.autoVerdict };
|
|
187
224
|
}
|
|
188
225
|
/**
|
|
189
226
|
* Is auto AI Fix on for the workspace holding this PR's repo? Read by `maybeStartAutoFix`. Only a
|
|
@@ -221,6 +258,7 @@ export async function readWorkspaceAutoFixSettingsForPr(ctx, accountId, prId) {
|
|
|
221
258
|
}
|
|
222
259
|
const autoCols = (w) => ({
|
|
223
260
|
providers: w.autoReviewProviders,
|
|
261
|
+
...verdictRuleCols(w),
|
|
224
262
|
enabled: w.autoReviewEnabled,
|
|
225
263
|
enabledAt: w.autoReviewEnabledAt,
|
|
226
264
|
autoFixEnabled: w.autoFixEnabled,
|
|
@@ -262,7 +300,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
262
300
|
const existing = rows[0];
|
|
263
301
|
if (!existing)
|
|
264
302
|
return null;
|
|
265
|
-
const { enabled, autoFixEnabled, autoDiagramsEnabled, teamClaimsEnabled, dailyCap, autoPost, autoFix, autoReviewProviders } = change;
|
|
303
|
+
const { enabled, autoFixEnabled, autoDiagramsEnabled, teamClaimsEnabled, dailyCap, autoPost, autoFix, autoReviewProviders, pairStrictness, } = change;
|
|
266
304
|
const wasOn = existing.enabled === true && toMs(existing.enabledAt) != null;
|
|
267
305
|
let review;
|
|
268
306
|
if (enabled === undefined || (enabled && wasOn))
|
|
@@ -290,7 +328,7 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
290
328
|
const claims = teamClaimsEnabled ?? claimsWas;
|
|
291
329
|
const capOverride = dailyCap === undefined ? existing.dailyCap : dailyCap === AUTO_REVIEW_DAILY_CAP ? null : dailyCap;
|
|
292
330
|
// Auto-posting: each field optional; a kind left out keeps its stored value.
|
|
293
|
-
const postBefore = resolveAutoPostSettings(existing.autoPostEnabled, existing.autoPostSettings);
|
|
331
|
+
const postBefore = resolveAutoPostSettings(existing.autoPostEnabled, existing.autoPostSettings, existing.approveLeftovers);
|
|
294
332
|
const postAfter = {
|
|
295
333
|
enabled: autoPost?.enabled ?? postBefore.enabled,
|
|
296
334
|
scope: autoPost?.scope ?? postBefore.scope,
|
|
@@ -298,7 +336,13 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
298
336
|
autoVerdict: autoPost?.autoVerdict ?? postBefore.autoVerdict,
|
|
299
337
|
autoResolve: autoPost?.autoResolve ?? postBefore.autoResolve,
|
|
300
338
|
approveImpact: { ...postBefore.approveImpact },
|
|
339
|
+
approveLeftovers: autoPost?.approveLeftovers ?? postBefore.approveLeftovers,
|
|
301
340
|
};
|
|
341
|
+
// The leftovers rule is its own column (migration 0145): NULL = the default.
|
|
342
|
+
const leftoversStored = postAfter.approveLeftovers === 'nits_questions' ? null : postAfter.approveLeftovers;
|
|
343
|
+
// How a pair's verdict folds (migration 0145): NULL = strict.
|
|
344
|
+
const strictnessWas = resolvePairStrictness(existing.pairStrictness);
|
|
345
|
+
const strictness = pairStrictness ?? strictnessWas;
|
|
302
346
|
for (const k of AUTO_POST_KIND_KEYS) {
|
|
303
347
|
const v = autoPost?.kinds?.[k];
|
|
304
348
|
if (typeof v === 'boolean')
|
|
@@ -327,6 +371,8 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
327
371
|
const providers = autoReviewProviders ?? providersWas;
|
|
328
372
|
const next = {
|
|
329
373
|
providers: providers === 'both' ? 'both' : null,
|
|
374
|
+
pairStrictness: strictness === 'relaxed' ? 'relaxed' : null,
|
|
375
|
+
approveLeftovers: leftoversStored,
|
|
330
376
|
enabled: review.enabled,
|
|
331
377
|
enabledAt: review.enabledAt,
|
|
332
378
|
autoFixEnabled: fix,
|
|
@@ -357,6 +403,12 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
357
403
|
if (autoReviewProviders !== undefined && providers !== providersWas) {
|
|
358
404
|
set.autoReviewProviders = providers === 'both' ? 'both' : null;
|
|
359
405
|
}
|
|
406
|
+
if (pairStrictness !== undefined && strictness !== strictnessWas) {
|
|
407
|
+
set.pairVerdictStrictness = strictness === 'relaxed' ? 'relaxed' : null;
|
|
408
|
+
}
|
|
409
|
+
if (autoPost?.approveLeftovers !== undefined && postAfter.approveLeftovers !== postBefore.approveLeftovers) {
|
|
410
|
+
set.approveLeftovers = leftoversStored;
|
|
411
|
+
}
|
|
360
412
|
if (autoPost !== undefined) {
|
|
361
413
|
if (postAfter.enabled !== (existing.autoPostEnabled === true))
|
|
362
414
|
set.autoPostEnabled = postAfter.enabled;
|
|
@@ -409,6 +461,25 @@ function settingChanges(set, existing, next) {
|
|
|
409
461
|
const now = resolveAutoFixSettings(next.autoFixSettings).autoPush;
|
|
410
462
|
out.push(['auto_fix', was !== now ? `Auto fix push ${onOff(now)}` : 'Auto fix contents changed']);
|
|
411
463
|
}
|
|
464
|
+
if ('pairVerdictStrictness' in set) {
|
|
465
|
+
out.push([
|
|
466
|
+
'auto_post',
|
|
467
|
+
next.pairStrictness === 'relaxed'
|
|
468
|
+
? 'Claude + Codex verdict: relaxed (approve if one approves and neither asks for changes)'
|
|
469
|
+
: 'Claude + Codex verdict: strict (approve only if both approve)',
|
|
470
|
+
]);
|
|
471
|
+
}
|
|
472
|
+
if ('approveLeftovers' in set) {
|
|
473
|
+
const now = resolveApproveLeftovers(next.approveLeftovers);
|
|
474
|
+
out.push([
|
|
475
|
+
'auto_post',
|
|
476
|
+
now === 'none'
|
|
477
|
+
? 'Auto-approve: only with nothing open'
|
|
478
|
+
: now === 'nits'
|
|
479
|
+
? 'Auto-approve: nits may stay open'
|
|
480
|
+
: 'Auto-approve: nits and questions may stay open',
|
|
481
|
+
]);
|
|
482
|
+
}
|
|
412
483
|
if ('autoPostEnabled' in set)
|
|
413
484
|
out.push(['auto_post', `Auto-posting ${onOff(next.autoPostEnabled === true)}`]);
|
|
414
485
|
else if ('autoPostSettings' in set)
|
|
@@ -560,6 +631,7 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
|
|
|
560
631
|
autoDiagramsEnabled: { type: 'boolean' },
|
|
561
632
|
teamClaimsEnabled: { type: 'boolean' },
|
|
562
633
|
autoReviewProviders: { type: 'string', enum: ['default', 'both'] },
|
|
634
|
+
pairStrictness: { type: 'string', enum: ['strict', 'relaxed'] },
|
|
563
635
|
// An integer 1..500; anything else (a fraction, a string, out of range) is a 400.
|
|
564
636
|
dailyCap: {
|
|
565
637
|
type: 'integer',
|
|
@@ -587,6 +659,7 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
|
|
|
587
659
|
additionalProperties: false,
|
|
588
660
|
properties: Object.fromEntries(APPROVE_IMPACT_KEYS.map((k) => [k, { type: 'boolean' }])),
|
|
589
661
|
},
|
|
662
|
+
approveLeftovers: { type: 'string', enum: APPROVE_LEFTOVERS },
|
|
590
663
|
},
|
|
591
664
|
},
|
|
592
665
|
autoFix: {
|