limn-review 0.1.140 → 0.1.141
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/coding/ai-fix/manager.js +4 -2
- package/dist/db/migrations/0137_review_pair_consolidation.sql +8 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/queries.js +30 -4
- package/dist/db/schema.sqlite.js +12 -0
- package/dist/platform/slack/signals.js +27 -3
- package/dist/review/agent-context.js +1 -0
- package/dist/review/claude-review/auto-post.js +40 -31
- package/dist/review/claude-review/auto-verdict.js +17 -0
- package/dist/review/claude-review/chat.js +5 -3
- package/dist/review/claude-review/finding-facts.js +27 -0
- package/dist/review/claude-review/manager.js +59 -10
- package/dist/review/claude-review/manual-reply.js +3 -2
- package/dist/review/claude-review/pair-consolidate.js +302 -0
- package/dist/review/claude-review/pair-view.js +248 -0
- package/dist/review/claude-review/pair.js +7 -3
- package/dist/review/claude-review/persist.js +139 -36
- package/dist/review/claude-review/routes.js +70 -128
- package/dist/review/llm.js +1 -1
- package/dist/shared/ai-provider-words.js +18 -1
- package/package.json +1 -1
- package/public/assets/AiFixTab-DCFKnCcv.js +3 -0
- package/public/assets/ClaudeReviewTab-CZxpjVme.js +20 -0
- package/public/assets/{PrBotBehaviourTab-Bwu839bx.js → PrBotBehaviourTab-Btzq2J3-.js} +1 -1
- package/public/assets/{index-B23U_yh7.css → index-CY_rIeEn.css} +1 -1
- package/public/assets/{index-BoS_VHWj.js → index-DInrk9cC.js} +46 -46
- package/public/index.html +2 -2
- package/public/assets/AiFixTab-D8HrsNZV.js +0 -3
- package/public/assets/ClaudeReviewTab-CJpvYk_E.js +0 -20
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { and, desc, eq } from 'drizzle-orm';
|
|
2
2
|
import { AI_FIX_PLAN_MAX_REVISIONS, aiRunProvider } from '../../shared/index.js';
|
|
3
3
|
import { getFixPrContext } from './pr-context.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getReviewView } from '../../review/claude-review/persist.js';
|
|
5
5
|
import { currentThreadAssessmentsForPrs } from '../../review/claude-review/thread-judgements.js';
|
|
6
6
|
import { pickReviewNonce } from '../../review/claude-review/prompts.js';
|
|
7
7
|
import { buildPickerPreview, buildReviewSeed, } from './review-seed.js';
|
|
@@ -103,7 +103,9 @@ function emitFixStream(prId, e) {
|
|
|
103
103
|
async function loadSourceReview(ctx, accountId, prId, reviewId) {
|
|
104
104
|
if (reviewId == null)
|
|
105
105
|
return null;
|
|
106
|
-
|
|
106
|
+
// A Claude + Codex pair seeds from its ONE consolidated review (both halves' findings, merged-away
|
|
107
|
+
// duplicates never read — pair-view.ts), never from one half.
|
|
108
|
+
const review = await getReviewView(ctx, reviewId, accountId);
|
|
107
109
|
if (!review || review.prId !== prId || review.status !== 'succeeded')
|
|
108
110
|
return null;
|
|
109
111
|
// A later Check threads run judged threads since this review: when this IS the latest review,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- CLAUDE + CODEX: ONE REVIEW BY BOTH (docs/CLAUDE-REVIEW.md § Claude + Codex). When a pair ends its two
|
|
2
|
+
-- halves are consolidated into one review. `pair_consolidation` (on the pair's FIRST row only) holds
|
|
3
|
+
-- the one summary, the code-computed verdict, each kept finding's source, the merged-away duplicates
|
|
4
|
+
-- and the half that did not finish; NULL everywhere else. `merged_into` marks a finding the other
|
|
5
|
+
-- half's finding stands for (never shown, posted, counted or sent to AI Fix); NULL everywhere else.
|
|
6
|
+
ALTER TABLE `claude_reviews` ADD `pair_consolidation` text;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
ALTER TABLE `claude_review_findings` ADD `merged_into` integer;
|
|
@@ -960,6 +960,13 @@
|
|
|
960
960
|
"when": 1792137623016,
|
|
961
961
|
"tag": "0136_review_kind",
|
|
962
962
|
"breakpoints": true
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
"idx": 137,
|
|
966
|
+
"version": "6",
|
|
967
|
+
"when": 1792137624016,
|
|
968
|
+
"tag": "0137_review_pair_consolidation",
|
|
969
|
+
"breakpoints": true
|
|
963
970
|
}
|
|
964
971
|
]
|
|
965
972
|
}
|
package/dist/db/queries.js
CHANGED
|
@@ -8038,6 +8038,7 @@ repoIds) {
|
|
|
8038
8038
|
reviewHead: claudeReviews.headSha,
|
|
8039
8039
|
prHead: pullRequests.headSha,
|
|
8040
8040
|
trigger: claudeReviews.trigger,
|
|
8041
|
+
pairId: claudeReviews.pairId,
|
|
8041
8042
|
})
|
|
8042
8043
|
.from(claudeReviews)
|
|
8043
8044
|
.innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
|
|
@@ -8049,16 +8050,41 @@ repoIds) {
|
|
|
8049
8050
|
.execute();
|
|
8050
8051
|
const seen = new Set(); // most-recent succeeded run per PR only
|
|
8051
8052
|
const candidates = [];
|
|
8053
|
+
// A CLAUDE + CODEX PAIR is ONE review (review/claude-review/pair-view.ts): its succeeded halves
|
|
8054
|
+
// are read together — posted when either was posted as a review, its findings both halves' (a
|
|
8055
|
+
// merged-away duplicate never counts), its verdict the pair's one verdict.
|
|
8056
|
+
const halvesOf = new Map();
|
|
8057
|
+
for (const r of rows) {
|
|
8058
|
+
if (r.pairId == null)
|
|
8059
|
+
continue;
|
|
8060
|
+
const list = halvesOf.get(r.pairId) ?? [];
|
|
8061
|
+
list.push(r);
|
|
8062
|
+
halvesOf.set(r.pairId, list);
|
|
8063
|
+
}
|
|
8052
8064
|
for (const r of rows) {
|
|
8053
8065
|
if (seen.has(r.prId))
|
|
8054
8066
|
continue;
|
|
8055
8067
|
seen.add(r.prId);
|
|
8056
|
-
|
|
8068
|
+
const halves = r.pairId != null ? (halvesOf.get(r.pairId) ?? [r]) : [r];
|
|
8069
|
+
if (halves.some((h) => h.postedAt != null))
|
|
8057
8070
|
continue; // the run itself was posted → actioned, no findings needed
|
|
8058
8071
|
candidates.push(r);
|
|
8059
8072
|
}
|
|
8060
8073
|
if (candidates.length === 0)
|
|
8061
8074
|
return [];
|
|
8075
|
+
const runIdsOf = (c) => c.pairId != null ? (halvesOf.get(c.pairId) ?? [c]).map((h) => h.reviewId) : [c.reviewId];
|
|
8076
|
+
// The pairs' one verdicts (on each pair's FIRST row), in ONE read.
|
|
8077
|
+
const pairIds = [...new Set(candidates.map((c) => c.pairId).filter((id) => id != null))];
|
|
8078
|
+
const pairVerdicts = new Map();
|
|
8079
|
+
if (pairIds.length > 0) {
|
|
8080
|
+
const recs = await db
|
|
8081
|
+
.select({ id: claudeReviews.id, rec: claudeReviews.pairConsolidation })
|
|
8082
|
+
.from(claudeReviews)
|
|
8083
|
+
.where(inArray(claudeReviews.id, pairIds))
|
|
8084
|
+
.execute();
|
|
8085
|
+
for (const p of recs)
|
|
8086
|
+
pairVerdicts.set(p.id, p.rec?.verdict ?? null);
|
|
8087
|
+
}
|
|
8062
8088
|
// ONE grouped read over the candidate runs — never one query per run, and never a per-run
|
|
8063
8089
|
// EXISTS in the select above, which would run the sub-query for every historical run of every
|
|
8064
8090
|
// open PR rather than the handful that got this far.
|
|
@@ -8070,7 +8096,7 @@ repoIds) {
|
|
|
8070
8096
|
postedAt: claudeReviewFindings.postedAt,
|
|
8071
8097
|
})
|
|
8072
8098
|
.from(claudeReviewFindings)
|
|
8073
|
-
.where(inArray(claudeReviewFindings.reviewId, candidates.
|
|
8099
|
+
.where(and(inArray(claudeReviewFindings.reviewId, candidates.flatMap(runIdsOf)), isNull(claudeReviewFindings.mergedInto)))
|
|
8074
8100
|
.execute();
|
|
8075
8101
|
const findingsByRun = new Map();
|
|
8076
8102
|
for (const f of findingRows) {
|
|
@@ -8080,7 +8106,7 @@ repoIds) {
|
|
|
8080
8106
|
}
|
|
8081
8107
|
const out = [];
|
|
8082
8108
|
for (const r of candidates) {
|
|
8083
|
-
const findings = findingsByRun.get(
|
|
8109
|
+
const findings = runIdsOf(r).flatMap((id) => findingsByRun.get(id) ?? []);
|
|
8084
8110
|
// STATE ONE: nothing from this run has reached GitHub. It was never worked, so it stays —
|
|
8085
8111
|
// including a run with no findings at all, whose summary and verdict are still yours to post
|
|
8086
8112
|
// or discard. ⚠ This branch is what keeps a fresh run visible: `included` defaults to false,
|
|
@@ -8101,7 +8127,7 @@ repoIds) {
|
|
|
8101
8127
|
repoFullName: `${r.owner}/${r.name}`,
|
|
8102
8128
|
prNumber: r.prNumber,
|
|
8103
8129
|
prTitle: r.prTitle,
|
|
8104
|
-
verdict: r.verdict,
|
|
8130
|
+
verdict: r.pairId != null ? (pairVerdicts.get(r.pairId) ?? r.verdict) : r.verdict,
|
|
8105
8131
|
finishedAt: iso(r.finishedAt),
|
|
8106
8132
|
headStale: r.reviewHead !== r.prHead,
|
|
8107
8133
|
githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -1113,6 +1113,12 @@ export const claudeReviews = sqliteTable('claude_reviews', {
|
|
|
1113
1113
|
pairId: integer('pair_id'),
|
|
1114
1114
|
pairRole: text('pair_role', { enum: ['first', 'second'] }),
|
|
1115
1115
|
pairDropped: integer('pair_dropped'),
|
|
1116
|
+
// THE PAIR'S ONE REVIEW (migration 0137; review/claude-review/pair-consolidate.ts): on the pair's
|
|
1117
|
+
// FIRST row only, written ONCE when the pair ends — the consolidated summary, the code verdict,
|
|
1118
|
+
// each kept finding's source, the merged-away duplicates and the half that did not finish.
|
|
1119
|
+
// NULL on every other row, on a pair still running, and on a pair from before the column (read as
|
|
1120
|
+
// the two halves joined in code).
|
|
1121
|
+
pairConsolidation: text('pair_consolidation', { mode: 'json' }).$type(),
|
|
1116
1122
|
// A REPLIES PASS (review/claude-review/replies-check.ts): 'replies' on a run that judged only
|
|
1117
1123
|
// people's replies on Limn's own posted findings; NULL on every full review (migration 0136).
|
|
1118
1124
|
// ⚠ Every "the latest review" / "a run at this head" reader filters `kind IS NULL`.
|
|
@@ -1207,6 +1213,12 @@ export const claudeReviewFindings = sqliteTable('claude_review_findings', {
|
|
|
1207
1213
|
// The reference-library sections this finding rests on, server-checked (migration 0117 / pg 0104).
|
|
1208
1214
|
// NULL = none.
|
|
1209
1215
|
docCitations: text('doc_citations', { mode: 'json' }).$type(),
|
|
1216
|
+
// A CLAUDE + CODEX PAIR'S DUPLICATE (migration 0137): the id of the finding kept in this one's
|
|
1217
|
+
// place when the pair was consolidated (the other half made the same point). A soft reference to
|
|
1218
|
+
// a finding of the same pair (no FK, like `prior_finding_id`). A merged-away finding stays here
|
|
1219
|
+
// but is NEVER shown, posted, counted in a verdict or sent to AI Fix; the next follow-up of its
|
|
1220
|
+
// provider follows up the kept one instead. NULL on every other finding.
|
|
1221
|
+
mergedInto: integer('merged_into'),
|
|
1210
1222
|
}, (t) => ({ reviewIdx: index('crf_review_idx').on(t.reviewId) }));
|
|
1211
1223
|
// ---- Claude Review chat (CORE table, written by the Pro plugin) ----
|
|
1212
1224
|
// One row per chat MESSAGE about one succeeded review: a general thread (`finding_id` NULL) and
|
|
@@ -3,7 +3,7 @@ import { proSchemaFor } from '../db.js';
|
|
|
3
3
|
import { escapeSlack, prLink, slackLink } from './links.js';
|
|
4
4
|
import { postToSlack } from './send.js';
|
|
5
5
|
import { readTargetRow } from './targets.js';
|
|
6
|
-
import { aiRunProviderName } from '../../shared/index.js';
|
|
6
|
+
import { aiRunProviderName, PAIR_RUN_LABEL } from '../../shared/index.js';
|
|
7
7
|
// ── THE TWO SLACK EVENT SIGNALS (Pro, `slackDigest`; plugin migration 0040) ──────────────────
|
|
8
8
|
//
|
|
9
9
|
// Per workspace, each its own switch on the delivery row, both OFF by default:
|
|
@@ -105,7 +105,9 @@ const trimSummary = (s) => {
|
|
|
105
105
|
};
|
|
106
106
|
/** Pure builder for the "Claude Review ran" message ("Codex Review ran" when Codex ran it). Exported
|
|
107
107
|
* for its test. */
|
|
108
|
-
export function reviewMessage(ctx, workspaceId, f, verdict, summary,
|
|
108
|
+
export function reviewMessage(ctx, workspaceId, f, verdict, summary,
|
|
109
|
+
// 'Claude + Codex' for a pair's one review.
|
|
110
|
+
ranBy = 'Claude') {
|
|
109
111
|
const by = f.authorLogin ? ` by @${escapeSlack(f.authorLogin)}` : '';
|
|
110
112
|
const head = `*${ranBy} Review ran* on ${prLine(ctx, workspaceId, f)}${by}`;
|
|
111
113
|
const lines = [head];
|
|
@@ -161,6 +163,8 @@ export async function onClaudeReviewCompleted(ctx, e) {
|
|
|
161
163
|
summary: cr.summary,
|
|
162
164
|
reviewMode: cr.reviewMode,
|
|
163
165
|
trigger: cr.trigger,
|
|
166
|
+
// A Claude + Codex pair announces its ONE review (pair-view.ts): the consolidation on its first row.
|
|
167
|
+
...(cr.pairId != null ? { pairId: cr.pairId } : {}),
|
|
164
168
|
// The provider that ran it: the stored `provider` (Phase 4, sqlite 0128), else read from the
|
|
165
169
|
// model id (aiRunProvider). A schema without the columns reads as Claude.
|
|
166
170
|
...(cr.model != null ? { model: cr.model } : {}),
|
|
@@ -177,10 +181,30 @@ export async function onClaudeReviewCompleted(ctx, e) {
|
|
|
177
181
|
// The STORED trigger agrees, or nothing is sent (no claim either).
|
|
178
182
|
if (run.trigger !== 'auto')
|
|
179
183
|
return;
|
|
184
|
+
// A PAIR: its one summary and verdict, named "Claude + Codex" (an older pair reads as this half).
|
|
185
|
+
let verdict = run.verdict;
|
|
186
|
+
let summary = run.summary;
|
|
187
|
+
let ranBy = aiRunProviderName(run);
|
|
188
|
+
if (run.pairId != null) {
|
|
189
|
+
const rec = (await ctx.db
|
|
190
|
+
.select({ pairConsolidation: cr.pairConsolidation })
|
|
191
|
+
.from(cr)
|
|
192
|
+
.where(and(eq(cr.id, run.pairId), eq(cr.accountId, e.accountId), eq(cr.prId, e.prId)))
|
|
193
|
+
.limit(1)
|
|
194
|
+
.execute())[0]?.pairConsolidation;
|
|
195
|
+
if (rec != null && typeof rec === 'object') {
|
|
196
|
+
if (typeof rec.summary === 'string')
|
|
197
|
+
summary = rec.summary;
|
|
198
|
+
if (rec.verdict === 'APPROVE' || rec.verdict === 'REQUEST_CHANGES' || rec.verdict === 'COMMENT')
|
|
199
|
+
verdict = rec.verdict;
|
|
200
|
+
if (rec.how !== 'single')
|
|
201
|
+
ranBy = PAIR_RUN_LABEL;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
180
204
|
if (!(await claimNotice(ctx, e.accountId, 'review', e.reviewId)))
|
|
181
205
|
return;
|
|
182
206
|
try {
|
|
183
|
-
await postToSlack(target.row.webhookUrl, reviewMessage(ctx, target.workspaceId, facts,
|
|
207
|
+
await postToSlack(target.row.webhookUrl, reviewMessage(ctx, target.workspaceId, facts, verdict, summary, ranBy));
|
|
184
208
|
}
|
|
185
209
|
catch (err) {
|
|
186
210
|
ctx.log.warn({ err, accountId: e.accountId, workspaceId: target.workspaceId, reviewId: e.reviewId }, 'slack review signal send failed');
|
|
@@ -30,6 +30,7 @@ export function buildAgentContext(log) {
|
|
|
30
30
|
const r = detectProviderAuth(provider, kind);
|
|
31
31
|
return r.status === 'ok' ? { status: 'ok' } : { status: 'none', message: r.message };
|
|
32
32
|
},
|
|
33
|
+
complete: async (opts) => (await import('./llm.js')).cheapComplete(opts),
|
|
33
34
|
},
|
|
34
35
|
recordAiUsage: (row) => recordAiUsage(row),
|
|
35
36
|
github: {
|
|
@@ -62,10 +62,13 @@ import {} from '../../shared/index.js';
|
|
|
62
62
|
import { readWorkspaceAutoPostForPr } from './auto-settings.js';
|
|
63
63
|
import { similarTitles } from './settled-by-reply.js';
|
|
64
64
|
import { prAuthorIsAccount } from '../../coding/ai-fix/auto-fix.js';
|
|
65
|
-
import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, } from './persist.js';
|
|
65
|
+
import { getClaudeReviewById, getReviewPostContext, isAlreadyPostedReraise, isReaderIgnoredFinding, markReviewPosted, readPairConsolidation, } from './persist.js';
|
|
66
66
|
import { resolveFindingBody } from './follow-up.js';
|
|
67
|
-
import {
|
|
67
|
+
import { autoVerdictFor } from './auto-verdict.js';
|
|
68
|
+
import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
|
|
68
69
|
import { pairRaisedByLead, pairReviewBody, pairVerdict } from './pair.js';
|
|
70
|
+
// A pair's body names both when it falls back to a count ("3 comments from Claude and Codex.").
|
|
71
|
+
const PAIR_BODY_BY = 'Claude and Codex';
|
|
69
72
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
70
73
|
const s = (ctx) => ctx.schema;
|
|
71
74
|
/** May auto-posting write on this PR? null = yes; else why not. */
|
|
@@ -143,21 +146,8 @@ export function selectFindingsToPost(findings, kinds, earlier) {
|
|
|
143
146
|
}
|
|
144
147
|
return { toPost, alreadyPosted };
|
|
145
148
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
* a reader did not ignore (whatever the kind toggles say): any blocker ⇒ REQUEST_CHANGES; APPROVE only when
|
|
149
|
-
* Claude approved and there is no warning either; everything else ⇒ COMMENT.
|
|
150
|
-
*/
|
|
151
|
-
export function autoVerdictFor(claudeVerdict, findings) {
|
|
152
|
-
// ⚠ Everything except a READER'S ignore counts — a re-raise the server left out because its
|
|
153
|
-
// comment is already on this commit is still an open issue (persist.ts `isReaderIgnoredFinding`).
|
|
154
|
-
const counted = findings.filter((f) => f.story == null && !isReaderIgnoredFinding(f));
|
|
155
|
-
if (counted.some((f) => f.severity === 'blocker'))
|
|
156
|
-
return 'REQUEST_CHANGES';
|
|
157
|
-
if (claudeVerdict === 'APPROVE' && !counted.some((f) => f.severity === 'warning'))
|
|
158
|
-
return 'APPROVE';
|
|
159
|
-
return 'COMMENT';
|
|
160
|
-
}
|
|
149
|
+
// THE VERDICT RULE (./auto-verdict.ts), re-exported under its old name.
|
|
150
|
+
export { autoVerdictFor } from './auto-verdict.js';
|
|
161
151
|
/** May an automatic APPROVE go out at this impact level? ⚠ 'unknown' is judged as HIGH. */
|
|
162
152
|
export function impactAllowsApprove(level, allowed) {
|
|
163
153
|
return allowed[level === 'unknown' ? 'high' : level];
|
|
@@ -355,9 +345,13 @@ export async function loadUnpostedBlockersOfBlock(ctx, accountId, prId, githubRe
|
|
|
355
345
|
.from(cr)
|
|
356
346
|
.where(and(eq(cr.prId, prId), eq(cr.accountId, accountId)))
|
|
357
347
|
.execute());
|
|
358
|
-
|
|
348
|
+
// Every run whose record carries this GitHub review: a pair's combined post is recorded on BOTH
|
|
349
|
+
// halves, and either half may hold the blocker that was posted (or merged away).
|
|
350
|
+
const blockRunIds = runs
|
|
351
|
+
.filter((r) => r.autoPost?.githubReviewId != null && String(r.autoPost.githubReviewId) === githubReviewId)
|
|
352
|
+
.map((r) => r.id);
|
|
359
353
|
// No such run: the caller only passes ids `loadLimnAutoBlockIds` read, so this is a race — hold.
|
|
360
|
-
if (
|
|
354
|
+
if (blockRunIds.length === 0)
|
|
361
355
|
return [-1];
|
|
362
356
|
const rows = (await ctx.db
|
|
363
357
|
.select({
|
|
@@ -371,7 +365,9 @@ export async function loadUnpostedBlockersOfBlock(ctx, accountId, prId, githubRe
|
|
|
371
365
|
storyRef: crf.storyRef,
|
|
372
366
|
})
|
|
373
367
|
.from(crf)
|
|
374
|
-
|
|
368
|
+
// A pair's merged-away duplicate is never posted and never read as a finding: its kept finding
|
|
369
|
+
// stands for it.
|
|
370
|
+
.where(and(inArray(crf.reviewId, blockRunIds), isNull(crf.mergedInto)))
|
|
375
371
|
.execute());
|
|
376
372
|
const onGithub = new Map(earlier.map((e) => [e.id, e.onGithub]));
|
|
377
373
|
return rows
|
|
@@ -491,9 +487,10 @@ async function writeRecord(ctx, reviewId, rec) {
|
|
|
491
487
|
const cr = s(ctx).claudeReviews;
|
|
492
488
|
await ctx.db.update(cr).set({ autoPost: rec }).where(eq(cr.id, reviewId)).execute();
|
|
493
489
|
}
|
|
494
|
-
// `
|
|
495
|
-
//
|
|
496
|
-
|
|
490
|
+
// `tag`: the comment rides a Claude + Codex pair's one review, so it names who raised it — "Claude",
|
|
491
|
+
// "Codex" or "Claude + Codex" (`findingSourceLabel`); null on an ordinary run. (Story findings are never
|
|
492
|
+
// auto-posted, so the lead slot is free.) `by` is the provider whose run holds the finding (its footer).
|
|
493
|
+
const toPostFinding = (f, by, tag = null) => ({
|
|
497
494
|
id: f.id,
|
|
498
495
|
path: f.path,
|
|
499
496
|
line: f.line,
|
|
@@ -505,8 +502,8 @@ const toPostFinding = (f, by, inPair = false) => ({
|
|
|
505
502
|
severity: f.severity,
|
|
506
503
|
// A pair's comment names who raised it ON the ONE severity lead ("**Warning** · Codex"); a finding
|
|
507
504
|
// with no label word (none posted today) falls back to its own "Raised by" line.
|
|
508
|
-
severityTag:
|
|
509
|
-
storyLead:
|
|
505
|
+
severityTag: tag,
|
|
506
|
+
storyLead: tag != null && limnSeverityLabel(f.severity) == null ? pairRaisedByLead(tag) : null,
|
|
510
507
|
// The footer names the provider that ran the review (Claude's text unchanged).
|
|
511
508
|
footer: autoPostFooter(by),
|
|
512
509
|
});
|
|
@@ -731,6 +728,15 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
731
728
|
first = null;
|
|
732
729
|
}
|
|
733
730
|
const pairFirst = first;
|
|
731
|
+
// THE PAIR'S ONE REVIEW (pair-consolidate.ts): its summary, and who raised each kept finding. A pair
|
|
732
|
+
// from before the record reads as the two summaries joined and each half's own provider.
|
|
733
|
+
const consolidation = pairFirst != null ? await readPairConsolidation(ctx, pairFirst.id, prId).catch(() => null) : null;
|
|
734
|
+
const tagOf = (f) => {
|
|
735
|
+
if (pairFirst == null)
|
|
736
|
+
return null;
|
|
737
|
+
const src = consolidation?.sourceOf[String(f.id)];
|
|
738
|
+
return src != null ? findingSourceLabel(src) : (byOf.get(f.id) ?? aiRunProviderName(review));
|
|
739
|
+
};
|
|
734
740
|
const posted = new Set();
|
|
735
741
|
const errors = [];
|
|
736
742
|
const submit = (ev) => ctx.review.postReview({
|
|
@@ -739,17 +745,20 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
739
745
|
prNumber: pctx.prNumber,
|
|
740
746
|
reviewHeadSha: pctx.reviewHeadSha,
|
|
741
747
|
body: pairFirst != null
|
|
742
|
-
?
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
748
|
+
? consolidation?.how === 'pass'
|
|
749
|
+
? `${autoReviewBodyLead(consolidation.summary, picked.toPost.length, PAIR_BODY_BY)}\n\n${PAIR_AUTO_POST_FOOTER}`
|
|
750
|
+
: // Joined in code (or an older pair): each half's lead, named.
|
|
751
|
+
pairReviewBody([pairFirst, review].map((r) => {
|
|
752
|
+
const mine = new Set(r.findings.map((x) => x.id));
|
|
753
|
+
const n = picked.toPost.filter((f) => mine.has(f.id)).length;
|
|
754
|
+
return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
|
|
755
|
+
}), PAIR_AUTO_POST_FOOTER)
|
|
747
756
|
: autoReviewBody(review.summary, picked.toPost.length, aiRunProviderName(review)),
|
|
748
757
|
// COMMENT unless auto verdict is on AND its gate let the verdict through.
|
|
749
758
|
verdict: ev,
|
|
750
759
|
// Questions ride the same review: the seam anchors each inline, else falls back. In a pair each
|
|
751
760
|
// comment carries its own provider's footer and names who raised it.
|
|
752
|
-
includedFindings: picked.toPost.map((f) => toPostFinding(f, byOf.get(f.id) ?? aiRunProviderName(review),
|
|
761
|
+
includedFindings: picked.toPost.map((f) => toPostFinding(f, byOf.get(f.id) ?? aiRunProviderName(review), tagOf(f))),
|
|
753
762
|
dryRun: false,
|
|
754
763
|
});
|
|
755
764
|
// Did the review half go wrong outright (thrown, or the head moved)? Then nothing more is written.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isReaderIgnoredFinding } from './finding-facts.js';
|
|
2
|
+
/**
|
|
3
|
+
* The verdict an auto post wants — Claude's, made STRICTER. Counts the run's non-story findings
|
|
4
|
+
* a reader did not ignore (whatever the kind toggles say): any blocker ⇒ REQUEST_CHANGES; APPROVE only when
|
|
5
|
+
* Claude approved and there is no warning either; everything else ⇒ COMMENT.
|
|
6
|
+
*/
|
|
7
|
+
export function autoVerdictFor(claudeVerdict, findings) {
|
|
8
|
+
// ⚠ Everything except a READER'S ignore counts — a re-raise the server left out because its
|
|
9
|
+
// comment is already on this commit is still an open issue (`isReaderIgnoredFinding`).
|
|
10
|
+
const counted = findings.filter((f) => f.story == null && !isReaderIgnoredFinding(f));
|
|
11
|
+
if (counted.some((f) => f.severity === 'blocker'))
|
|
12
|
+
return 'REQUEST_CHANGES';
|
|
13
|
+
if (claudeVerdict === 'APPROVE' && !counted.some((f) => f.severity === 'warning'))
|
|
14
|
+
return 'APPROVE';
|
|
15
|
+
return 'COMMENT';
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=auto-verdict.js.map
|
|
@@ -2,7 +2,7 @@ import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
|
|
|
2
2
|
import { CLAUDE_REVIEW_CHAT_EXPLAIN_DEFAULT_QUESTION, CLAUDE_REVIEW_CHAT_MAX_QUESTION_CHARS, CLAUDE_REVIEW_MODELS, aiRunProvider, DEFAULT_CLAUDE_REVIEW_MODEL, } from '../../shared/index.js';
|
|
3
3
|
import { requestModelOf } from '../../ai/provider.js';
|
|
4
4
|
import { AGENTIC_AI_ENABLED, chatMayApplyAuthEnv } from './manager.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getReviewPrContext, getReviewView } from './persist.js';
|
|
6
6
|
import { pickReviewNonce } from './prompts.js';
|
|
7
7
|
import { EXPLAIN_INSTRUCTION, EXPLAIN_SYSTEM_ADDENDUM, explainSectionLines, explanationsMarkdown, findingPinLabel, findingPinText, parsePinRefs, questionWithPins, storyPinLabel, storyPinText, validateExplanations, } from './chat-explain.js';
|
|
8
8
|
import { providerName } from '../../ai/prompt-tools.js';
|
|
@@ -389,7 +389,8 @@ export async function answerReviewChat(ctx, accountId, reviewId, body) {
|
|
|
389
389
|
}
|
|
390
390
|
inFlight.set(accountId, reviewId);
|
|
391
391
|
try {
|
|
392
|
-
|
|
392
|
+
// A Claude + Codex pair is asked about as its ONE review (both halves' findings).
|
|
393
|
+
const review = await getReviewView(ctx, reviewId, accountId);
|
|
393
394
|
if (!review) {
|
|
394
395
|
return { ok: false, status: 404, error: 'NotFound', message: `Review ${reviewId} not found` };
|
|
395
396
|
}
|
|
@@ -611,7 +612,8 @@ export function registerClaudeReviewChatRoutes(app, ctx) {
|
|
|
611
612
|
const { reviewId } = req.params;
|
|
612
613
|
const findingId = req.query.findingId ?? null;
|
|
613
614
|
const accountId = ctx.accountIdOf(req);
|
|
614
|
-
|
|
615
|
+
// A Claude + Codex pair is asked about as its ONE review (both halves' findings).
|
|
616
|
+
const review = await getReviewView(ctx, reviewId, accountId);
|
|
615
617
|
if (!review)
|
|
616
618
|
return notFound(reply, `Review ${reviewId}`);
|
|
617
619
|
if (findingId != null && !review.findings.some((f) => f.id === findingId)) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// PURE FACTS ABOUT A STORED FINDING, shared by persist.ts, the verdict rule (./auto-verdict.ts) and
|
|
2
|
+
// the pair's consolidated view (./pair-view.ts). No imports, so any of them may read these without a
|
|
3
|
+
// cycle. persist.ts re-exports all three under their old names.
|
|
4
|
+
/**
|
|
5
|
+
* The server's own left-out re-raise: `included: false` stored on a RE-RAISE (a `priorFindingId`)
|
|
6
|
+
* of a comment already posted on this same commit (follow-up.ts `isAlreadyOnThisCommit`, written
|
|
7
|
+
* by persist.ts) — an issue still OPEN, just not posted twice.
|
|
8
|
+
*/
|
|
9
|
+
export function isAlreadyPostedReraise(f) {
|
|
10
|
+
return f.included === false && f.postedAt == null && f.priorFindingId != null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* `included = false` is TWO facts, and only one is a READER'S IGNORE — every reader of the flag
|
|
14
|
+
* meaning "the reader ignored it" goes through this, never a bare `included === false`.
|
|
15
|
+
*/
|
|
16
|
+
export function isReaderIgnoredFinding(f) {
|
|
17
|
+
return f.included === false && !isAlreadyPostedReraise(f);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A CLAUDE + CODEX PAIR'S MERGED-AWAY DUPLICATE (`claude_review_findings.merged_into`, migration
|
|
21
|
+
* 0137): the other half's finding stands for it. It stays stored but is never shown, posted, counted
|
|
22
|
+
* in a verdict or sent to AI Fix — every read of a run's findings leaves it out.
|
|
23
|
+
*/
|
|
24
|
+
export function isMergedAway(f) {
|
|
25
|
+
return f.mergedInto != null;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=finding-facts.js.map
|
|
@@ -8,6 +8,8 @@ import { decideReviewMode } from './routing.js';
|
|
|
8
8
|
import { offeredSpecialists } from './specialists.js';
|
|
9
9
|
import { buildUserPrompt, peerRef, pickReviewNonce, skipSummary, sonarHasContent, systemPromptForMode, untrustedTexts, } from './prompts.js';
|
|
10
10
|
import { dropPairRepeats, pairInputOf, pairProvidersReady, readAutoReviewProvidersForPr } from './pair.js';
|
|
11
|
+
import { consolidatePair, notStartedHalf } from './pair-consolidate.js';
|
|
12
|
+
import { markPairBusy } from './pair-view.js';
|
|
11
13
|
import { preflightFor } from '../../ai/run-choice.js';
|
|
12
14
|
import { linkReraisedFindings, reconcileFollowUp, dropAcceptedReraises, isResolvedWithReplies, selectPriorFindings, SINCE_PATCH_CHARS, } from './follow-up.js';
|
|
13
15
|
import { planThreadReview, reconcileThreads } from './threads.js';
|
|
@@ -404,11 +406,15 @@ function launch(item) {
|
|
|
404
406
|
if (item.teamClaim)
|
|
405
407
|
startTeamClaimRenew(ctx, item.teamClaim);
|
|
406
408
|
void runPipeline(item, controller, emit)
|
|
407
|
-
.then((succeeded) => {
|
|
409
|
+
.then(async (succeeded) => {
|
|
408
410
|
item.succeeded = succeeded;
|
|
409
411
|
// A pair's FIRST half hands over to its second; everything after a run waits for the pair's end.
|
|
410
412
|
if (pairContinues(item))
|
|
411
413
|
return;
|
|
414
|
+
// ⚠ A PAIR'S END: the two halves become ONE review BEFORE anything is posted (pair-consolidate.ts).
|
|
415
|
+
// Still inside the run (slot, claim and stream held), so the tab shows "Merging the two reviews…".
|
|
416
|
+
if (item.pair?.role === 'second')
|
|
417
|
+
await consolidateAtPairEnd(item, emit);
|
|
412
418
|
item.autoPosted = runEnded(item, endOf(item, succeeded));
|
|
413
419
|
})
|
|
414
420
|
.catch(async (err) => {
|
|
@@ -416,9 +422,12 @@ function launch(item) {
|
|
|
416
422
|
ctx.log.error({ err }, `claude review pr ${prId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
417
423
|
await markReviewFailed(ctx, reviewId, err instanceof Error ? err.message : String(err)).catch(() => { });
|
|
418
424
|
item.succeeded = false;
|
|
419
|
-
// A pair's second half failing this way still ends the pair: the first half's results stand
|
|
420
|
-
|
|
425
|
+
// A pair's second half failing this way still ends the pair: the first half's results stand,
|
|
426
|
+
// consolidated as the pair's one review.
|
|
427
|
+
if (item.pair?.role === 'second') {
|
|
428
|
+
await consolidateAtPairEnd(item, emit);
|
|
421
429
|
item.autoPosted = runEnded(item, endOf(item, false));
|
|
430
|
+
}
|
|
422
431
|
})
|
|
423
432
|
.finally(() => {
|
|
424
433
|
// ⚠ A pair's first half keeps the PR's claim and its slot (`startingPair`, taken BEFORE the
|
|
@@ -435,8 +444,11 @@ function launch(item) {
|
|
|
435
444
|
reviewIdByPr.delete(prId);
|
|
436
445
|
controllers.delete(reviewId);
|
|
437
446
|
progressByReview.delete(reviewId);
|
|
438
|
-
if (continues)
|
|
447
|
+
if (continues) {
|
|
439
448
|
startingPair.set(prId, item);
|
|
449
|
+
// The pair reads as running until it ends (pair-view.ts), never as its first half alone.
|
|
450
|
+
markPairBusy(item.pair.pairId, true);
|
|
451
|
+
}
|
|
440
452
|
else {
|
|
441
453
|
claimed.delete(prId);
|
|
442
454
|
emitDone(ctx, prId, item.accountId, reviewId);
|
|
@@ -447,6 +459,29 @@ function launch(item) {
|
|
|
447
459
|
void startPairSecond(item);
|
|
448
460
|
});
|
|
449
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* A pair's end, on its second half (succeeded, failed or stopped): consolidate the two into ONE review
|
|
464
|
+
* and store it (pair-consolidate.ts) — with the quick pass when both succeeded ("Merging the two
|
|
465
|
+
* reviews…"), else in code. Marked "merging" meanwhile, so every read shows the pair as running.
|
|
466
|
+
* Never throws: a consolidation failure never fails the pair.
|
|
467
|
+
*/
|
|
468
|
+
async function consolidateAtPairEnd(item, emit) {
|
|
469
|
+
const p = item.pair;
|
|
470
|
+
if (p?.role !== 'second')
|
|
471
|
+
return;
|
|
472
|
+
markPairBusy(p.pairId, true);
|
|
473
|
+
try {
|
|
474
|
+
if (p.firstSucceeded && item.succeeded)
|
|
475
|
+
emit({ phase: 'merging' });
|
|
476
|
+
await consolidatePair(item.ctx, { accountId: item.accountId, prId: item.prId, pairId: p.pairId, secondId: item.reviewId });
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
item.ctx.log.warn(`claude review pr ${item.prId}: consolidating the pair failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
markPairBusy(p.pairId, false);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
450
485
|
function emitDone(ctx, prId, accountId, reviewId) {
|
|
451
486
|
void getLatestClaudeReview(ctx, prId, accountId)
|
|
452
487
|
.then((latest) => emitReviewStream(prId, { type: 'done', status: latest?.status ?? 'failed', reviewId }))
|
|
@@ -578,8 +613,11 @@ async function startPairSecond(first) {
|
|
|
578
613
|
const provider = pair.secondProvider;
|
|
579
614
|
let launched = false;
|
|
580
615
|
let teamClaim = null;
|
|
616
|
+
// Why the second half did not start (the pair's one review names it).
|
|
617
|
+
let why = 'error';
|
|
581
618
|
try {
|
|
582
619
|
if (first.trigger === 'auto' && !pair.pressed && !(await automaticRunAllowed(ctx, accountId, 'auto review (second half)'))) {
|
|
620
|
+
why = 'budget';
|
|
583
621
|
return;
|
|
584
622
|
}
|
|
585
623
|
// TEAM CLAIMS: an AUTO pair's second half claims its OWN provider's context. A teammate's Limn
|
|
@@ -594,8 +632,10 @@ async function startPairSecond(first) {
|
|
|
594
632
|
headSha: first.headSha,
|
|
595
633
|
provider,
|
|
596
634
|
}, { pressed: pair.pressed === true });
|
|
597
|
-
if (claim.kind === 'skip')
|
|
635
|
+
if (claim.kind === 'skip') {
|
|
636
|
+
why = 'claimed';
|
|
598
637
|
return;
|
|
638
|
+
}
|
|
599
639
|
teamClaim = claim.kind === 'run' ? claim.handle : null;
|
|
600
640
|
}
|
|
601
641
|
const firstReview = first.succeeded ? await getClaudeReviewById(ctx, first.reviewId, accountId) : null;
|
|
@@ -605,6 +645,8 @@ async function startPairSecond(first) {
|
|
|
605
645
|
await saveTeamClaimRecord(ctx, reviewId, teamClaim.record).catch(() => { });
|
|
606
646
|
startingPair.delete(prId);
|
|
607
647
|
launched = true;
|
|
648
|
+
// The second half's row is running now; the end (consolidateAtPairEnd) marks the merge itself.
|
|
649
|
+
markPairBusy(pair.pairId, false);
|
|
608
650
|
launch({
|
|
609
651
|
ctx,
|
|
610
652
|
accountId,
|
|
@@ -623,7 +665,7 @@ async function startPairSecond(first) {
|
|
|
623
665
|
first,
|
|
624
666
|
firstSucceeded: first.succeeded === true,
|
|
625
667
|
input: firstFindings.length > 0 ? pairInputOf(first.provider, firstFindings) : null,
|
|
626
|
-
firstFindings: firstFindings.map((f) => ({ path: f.path, line: f.line, title: f.title })),
|
|
668
|
+
firstFindings: firstFindings.map((f) => ({ path: f.path, line: f.line, title: f.title, priorFindingId: f.priorFindingId ?? null })),
|
|
627
669
|
},
|
|
628
670
|
});
|
|
629
671
|
}
|
|
@@ -632,11 +674,18 @@ async function startPairSecond(first) {
|
|
|
632
674
|
}
|
|
633
675
|
finally {
|
|
634
676
|
if (!launched) {
|
|
635
|
-
// The pair ends on its first half
|
|
677
|
+
// The pair ends on its first half: stored as the pair's one review (naming the half that did
|
|
678
|
+
// not start) BEFORE the end is announced and anything is posted.
|
|
636
679
|
startingPair.delete(prId);
|
|
637
680
|
claimed.delete(prId);
|
|
638
|
-
|
|
639
|
-
|
|
681
|
+
const consolidated = first.succeeded === true
|
|
682
|
+
? consolidatePair(ctx, { accountId, prId, pairId: pair.pairId, secondId: null, notStarted: notStartedHalf(provider, why) })
|
|
683
|
+
: Promise.resolve(null);
|
|
684
|
+
first.autoPosted = consolidated.then(() => {
|
|
685
|
+
markPairBusy(pair.pairId, false);
|
|
686
|
+
emitDone(ctx, prId, accountId, first.reviewId);
|
|
687
|
+
return runEnded(first, { reviewId: first.reviewId, succeeded: first.succeeded === true });
|
|
688
|
+
});
|
|
640
689
|
// TEAM CLAIMS: a claim this half posted before its row could be written is released, and the
|
|
641
690
|
// first half's held line gets its final one.
|
|
642
691
|
if (teamClaim)
|
|
@@ -699,7 +748,7 @@ async function runPipeline(item, controller, emit) {
|
|
|
699
748
|
const settledIds = new Set(settled.map((f) => f.id));
|
|
700
749
|
const prior = fresh
|
|
701
750
|
? null
|
|
702
|
-
: await loadPriorReviewForFollowUp(ctx, item.prId, item.accountId, reviewId, settledIds, item.provider);
|
|
751
|
+
: await loadPriorReviewForFollowUp(ctx, item.prId, item.accountId, reviewId, settledIds, item.provider, item.pair != null);
|
|
703
752
|
// BACKWARD COMPATIBILITY: findings the RETIRED settle-on-sight rule took out of the chain were
|
|
704
753
|
// never judged. Load them as carried items; only the ones whose thread is resolved WITH a
|
|
705
754
|
// person's reply are kept (below), so they are judged once like any other.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// re-claim of a refused record (local-only feature: one process).
|
|
15
15
|
import { and, eq, isNull } from 'drizzle-orm';
|
|
16
16
|
import { FINDING_REPLY_MARKER, } from '../../shared/index.js';
|
|
17
|
-
import {
|
|
17
|
+
import { getReviewView } from './persist.js';
|
|
18
18
|
import { findFindingThread } from './finding-thread.js';
|
|
19
19
|
import { PUSHBACK_MARKER, accountLoginOf, isClearReplyRefusal, loadPrThreads, loadThreadOwners, pushbackMayBePosted, resolveReplyMayBePosted, threadHasPushback, } from './finding-replies.js';
|
|
20
20
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -68,7 +68,8 @@ const inFlight = new Set(); // thread OWNER finding ids
|
|
|
68
68
|
const conflict = (code, message) => ({ kind: 'conflict', code, message });
|
|
69
69
|
/** Post the reply a reply status drafted, on that finding's thread. Never posts twice. */
|
|
70
70
|
export async function postFollowUpReply(ctx, { accountId, reviewId, priorFindingId }, deps = defaultManualReplyDeps) {
|
|
71
|
-
|
|
71
|
+
// A Claude + Codex pair is one review: its follow-up holds both halves' items (pair-view.ts).
|
|
72
|
+
const review = await getReviewView(ctx, reviewId, accountId);
|
|
72
73
|
if (!review || review.status !== 'succeeded')
|
|
73
74
|
return { kind: 'not_found' };
|
|
74
75
|
const item = review.followUp?.items.find((it) => it.priorFindingId === priorFindingId);
|