limn-review 0.1.180 → 0.1.181
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/api/plugins/rate-limit.js +5 -0
- package/dist/api/routes/me.js +18 -1
- package/dist/db/migrations/0164_teammate_block_lift.sql +7 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/my-turn-pushed-since.js +74 -0
- package/dist/db/schema.sqlite.js +5 -0
- package/dist/db/teammate-reviews-import.js +18 -0
- package/dist/db/teammate-reviews.js +14 -0
- package/dist/review/claude-review/auto-post.js +98 -8
- package/dist/review/claude-review/auto-settings.js +32 -2
- package/dist/review/claude-review/manager.js +21 -3
- package/dist/review/claude-review/persist.js +39 -3
- package/dist/review/claude-review/post-meta.js +100 -1
- package/dist/review/claude-review/routes.js +9 -2
- package/dist/review/claude-review/team-view.js +550 -31
- package/dist/review/claude-review/teammate-anchor.js +80 -9
- package/dist/review/claude-review/teammate-lift.js +231 -0
- package/dist/review/post-seam.js +2 -0
- package/dist/shared/limn-review-meta.js +48 -1
- package/dist/shared/pending-rules.js +7 -0
- package/dist/shared/types.js +2 -0
- package/package.json +1 -1
- package/public/assets/{AiFixTab-C_4GZ1f7.js → AiFixTab-uoyOe44d.js} +1 -1
- package/public/assets/ClaudeReviewTab-NiqDVtMN.js +20 -0
- package/public/assets/{PrBotBehaviourTab-BS6_D2CB.js → PrBotBehaviourTab-Dl_TppS2.js} +1 -1
- package/public/assets/{index-CPNM_C-u.js → index-B-MIQUya.js} +57 -57
- package/public/assets/index-DG7ITfra.css +1 -0
- package/public/index.html +2 -2
- package/public/assets/ClaudeReviewTab-kbXqq0_E.js +0 -20
- 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')
|
package/dist/api/routes/me.js
CHANGED
|
@@ -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;
|
|
@@ -1149,6 +1149,13 @@
|
|
|
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
|
|
1152
1159
|
}
|
|
1153
1160
|
]
|
|
1154
1161
|
}
|
|
@@ -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
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -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),
|
|
@@ -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
|
}
|
|
@@ -78,8 +78,9 @@ import { resolveFindingBody } from './follow-up.js';
|
|
|
78
78
|
import { autoVerdictFor, findingsNotApproved, pairVerdict, reasonMayPost, safeReasonText, withReason, } from './auto-verdict.js';
|
|
79
79
|
import { aiRunProviderName, autoPostFooter, findingSourceLabel, limnSeverityLabel, PAIR_AUTO_POST_FOOTER, } from '../../shared/index.js';
|
|
80
80
|
import { pairRaisedByLead, pairReviewBody } from './pair.js';
|
|
81
|
-
import { limnMetaForReview } from './post-meta.js';
|
|
81
|
+
import { limnMetaForReview, loadJudgedForPost } from './post-meta.js';
|
|
82
82
|
import { TEAMMATE_KIND, ownRunsOnly } from '../../db/teammate-reviews.js';
|
|
83
|
+
import { claimTeammateLift, liveLiftCheck, loadTeammateLiftCandidates, settleTeammateLift, teammateLiftMessage, } from './teammate-lift.js';
|
|
83
84
|
// A pair's body names both when it falls back to a count ("3 comments from Claude and Codex.").
|
|
84
85
|
const PAIR_BODY_BY = 'Claude and Codex';
|
|
85
86
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -171,6 +172,17 @@ export function selectFindingsToPost(findings, kinds, earlier) {
|
|
|
171
172
|
}
|
|
172
173
|
return { toPost, alreadyPosted };
|
|
173
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* May this post carry `lift=1` (the poster's consent to a teammate's Limn lifting its block)? Only
|
|
177
|
+
* when every BLOCKER the verdict counts is one of the post's own comments. A blocker already on GitHub
|
|
178
|
+
* from an earlier post is left out of this one, yet still makes it a REQUEST_CHANGES: a teammate's
|
|
179
|
+
* Limn reads the block's points from its comments alone (teammate-lift.ts `pointsBehindBlock`), so
|
|
180
|
+
* it could lift a block whose real reason it never checked.
|
|
181
|
+
*/
|
|
182
|
+
export function blockersAllInPost(findings, toPost) {
|
|
183
|
+
const posted = new Set(toPost.map((f) => f.id));
|
|
184
|
+
return findings.every((f) => f.severity !== 'blocker' || f.story != null || isReaderIgnoredFinding(f) || posted.has(f.id));
|
|
185
|
+
}
|
|
174
186
|
// THE VERDICT RULE (./auto-verdict.ts), re-exported under its old name.
|
|
175
187
|
export { autoVerdictFor } from './auto-verdict.js';
|
|
176
188
|
/** May an automatic APPROVE go out at this impact level? ⚠ 'unknown' is judged as HIGH. */
|
|
@@ -324,7 +336,7 @@ export const defaultAutoPostDeps = {
|
|
|
324
336
|
for (let page = 1; page <= 10; page += 1) {
|
|
325
337
|
const rows = await ghRestGet(`/repos/${owner}/${name}/pulls/${prNumber}/reviews?per_page=100&page=${page}`);
|
|
326
338
|
for (const r of rows)
|
|
327
|
-
out.push({ id: r.id, login: r.user?.login ?? null, state: r.state });
|
|
339
|
+
out.push({ id: r.id, login: r.user?.login ?? null, state: r.state, body: r.body ?? null });
|
|
328
340
|
if (rows.length < 100)
|
|
329
341
|
return out;
|
|
330
342
|
}
|
|
@@ -714,10 +726,35 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
714
726
|
// (Read for a REQUEST_CHANGES too: the gate must tell Limn's own standing block from a person's.)
|
|
715
727
|
const limnBlockIds = !facts.authorIsMe ? await loadLimnAutoBlockIds(ctx, accountId, prId, reviewId) : new Set();
|
|
716
728
|
const mayLift = noBlocker && unjudgedBlockers.length === 0 && limnBlockIds.size > 0;
|
|
729
|
+
// LIFTING A TEAMMATE'S LIMN BLOCK (./teammate-lift.ts): the DB pre-check first (both opt-ins, every
|
|
730
|
+
// point behind the block settled), so an ordinary run costs no extra GitHub read.
|
|
731
|
+
let readerLogin = null;
|
|
732
|
+
let teammateCandidates = [];
|
|
733
|
+
if (ws.settings.liftTeammateBlocks && !facts.authorIsMe && noBlocker && unjudgedBlockers.length === 0) {
|
|
734
|
+
try {
|
|
735
|
+
readerLogin = await deps.accountLogin(accountId);
|
|
736
|
+
teammateCandidates = await loadTeammateLiftCandidates(ctx, {
|
|
737
|
+
accountId,
|
|
738
|
+
prId,
|
|
739
|
+
reviewId,
|
|
740
|
+
headSha: review.headSha,
|
|
741
|
+
leftovers: rules.approveLeftovers,
|
|
742
|
+
readerLogin,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
ctx.log.warn(`auto post review ${reviewId}: could not read teammates' blocks: ${errText(err)}`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const mayLiftTeammate = teammateCandidates.length > 0;
|
|
717
750
|
// A COMMENT-only run with a reason still to tell GitHub goes on (to the reason-only review).
|
|
718
751
|
// (Never while a review request of the reader is open: any review answers it.)
|
|
719
752
|
const commentReasonToPost = wanted === 'COMMENT' && reasonMayPost(verdict) && !reasonAlreadyAtHead && facts.openRequestOfMe !== true;
|
|
720
|
-
if (picked.toPost.length === 0 &&
|
|
753
|
+
if (picked.toPost.length === 0 &&
|
|
754
|
+
(wanted == null || wanted === 'COMMENT') &&
|
|
755
|
+
!mayLift &&
|
|
756
|
+
!mayLiftTeammate &&
|
|
757
|
+
!commentReasonToPost) {
|
|
721
758
|
return skip(...nothingReason());
|
|
722
759
|
}
|
|
723
760
|
const pctx = await getReviewPostContext(ctx, reviewId, accountId);
|
|
@@ -747,13 +784,17 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
747
784
|
}
|
|
748
785
|
const impactHeld = impact != null && !impactAllowsApprove(impact.level, impact.allowed);
|
|
749
786
|
let standing = { state: 'unreadable', reviewId: null };
|
|
750
|
-
|
|
787
|
+
// A teammate block still standing LIVE, with Limn's marker and the consent in its live body.
|
|
788
|
+
let teammateLifts = [];
|
|
789
|
+
if (!facts.authorIsMe && ((wantsVerdict && !impactHeld) || mayLift || mayLiftTeammate)) {
|
|
751
790
|
try {
|
|
752
791
|
const [login, reviews] = await Promise.all([
|
|
753
792
|
deps.accountLogin(accountId),
|
|
754
793
|
deps.liveReviews(pctx.owner, pctx.name, pctx.prNumber),
|
|
755
794
|
]);
|
|
795
|
+
readerLogin = login;
|
|
756
796
|
standing = ownStandingReview(reviews, login, limnBlockIds);
|
|
797
|
+
teammateLifts = teammateCandidates.filter((c) => liveLiftCheck(reviews, c, login) == null);
|
|
757
798
|
}
|
|
758
799
|
catch (err) {
|
|
759
800
|
ctx.log.warn(`auto post review ${reviewId}: could not read the PR's reviews: ${errText(err)}`);
|
|
@@ -793,12 +834,18 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
793
834
|
// Limn's own automatic request for changes still stands, this run has no blocker and none behind
|
|
794
835
|
// the block is left unjudged: lift it — superseded by the APPROVE this run sends, else dismissed.
|
|
795
836
|
const liftId = noBlocker && !blockersRemain && standing.state === 'LIMN_AUTO_CHANGES_REQUESTED' ? standing.reviewId : null;
|
|
796
|
-
if (picked.toPost.length === 0 &&
|
|
837
|
+
if (picked.toPost.length === 0 &&
|
|
838
|
+
(verdict?.submitted ?? 'COMMENT') === 'COMMENT' &&
|
|
839
|
+
liftId == null &&
|
|
840
|
+
!reasonOnly &&
|
|
841
|
+
teammateLifts.length === 0) {
|
|
797
842
|
return skip(...nothingReason());
|
|
798
843
|
}
|
|
799
844
|
const event = verdict?.submitted ?? 'COMMENT';
|
|
800
|
-
// Nothing but the lift to do: no review is sent at all.
|
|
801
|
-
|
|
845
|
+
// Nothing but the lift of Limn's own block to do: no review is sent at all. Lifting a TEAMMATE'S
|
|
846
|
+
// block always sends the reader's own review (APPROVE when the gate allows it, else COMMENT): it
|
|
847
|
+
// carries the judgements (`j=`) the teammate's Limn reads.
|
|
848
|
+
const sendsReview = picked.toPost.length > 0 || event !== 'COMMENT' || reasonOnly || teammateLifts.length > 0;
|
|
802
849
|
const record = {
|
|
803
850
|
status: 'posting',
|
|
804
851
|
at: nowIso(),
|
|
@@ -834,6 +881,8 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
834
881
|
first = null;
|
|
835
882
|
}
|
|
836
883
|
const pairFirst = first;
|
|
884
|
+
// The earlier Limn findings on GitHub this review judged, for the marker's `j=` (DB only, never throws).
|
|
885
|
+
const judged = await loadJudgedForPost(ctx, accountId, prId, pairFirst != null ? [pairFirst, review] : [review]);
|
|
837
886
|
// THE PAIR'S ONE REVIEW (pair-consolidate.ts): its summary, and who raised each kept finding. A pair
|
|
838
887
|
// from before the record reads as the two summaries joined and each half's own provider.
|
|
839
888
|
const consolidation = pairFirst != null ? await readPairConsolidation(ctx, pairFirst.id, prId).catch(() => null) : null;
|
|
@@ -861,6 +910,8 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
861
910
|
return { by: aiRunProviderName(r), text: autoReviewBodyLead(r.summary, n, aiRunProviderName(r)) };
|
|
862
911
|
}), withReasonLine(reason, footer));
|
|
863
912
|
};
|
|
913
|
+
// The poster's consent rides only a post whose comments hold every blocker behind it.
|
|
914
|
+
const liftConsent = ws.settings.teamLiftConsent && blockersAllInPost(allFindings, picked.toPost);
|
|
864
915
|
const submit = (ev) => ctx.review.postReview({
|
|
865
916
|
owner: pctx.owner,
|
|
866
917
|
name: pctx.name,
|
|
@@ -879,8 +930,10 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
879
930
|
model: r.model || null,
|
|
880
931
|
effort: r.effort ?? null,
|
|
881
932
|
})),
|
|
933
|
+
judged,
|
|
934
|
+
liftConsent,
|
|
882
935
|
}
|
|
883
|
-
: limnMetaForReview(review),
|
|
936
|
+
: { ...limnMetaForReview(review), judged, liftConsent },
|
|
884
937
|
// Questions ride the same review: the seam anchors each inline, else falls back. In a pair each
|
|
885
938
|
// comment carries its own provider's footer and names who raised it.
|
|
886
939
|
includedFindings: picked.toPost.map((f) => toPostFinding(f, byOf.get(f.id) ?? aiRunProviderName(review), tagOf(f))),
|
|
@@ -969,6 +1022,43 @@ async function autoPostReview(ctx, { accountId, prId, reviewId, pairFirstReviewI
|
|
|
969
1022
|
}
|
|
970
1023
|
}
|
|
971
1024
|
}
|
|
1025
|
+
// A TEAMMATE'S BLOCK, after the reader's review landed at the PR's current head: claim it on the
|
|
1026
|
+
// teammate's row (compare-and-set, once ever), dismiss, record what GitHub said. Never retried.
|
|
1027
|
+
if (teammateLifts.length > 0 && landed && !reviewFailed) {
|
|
1028
|
+
const lifted = [];
|
|
1029
|
+
for (const c of teammateLifts) {
|
|
1030
|
+
const message = teammateLiftMessage(readerLogin ?? 'you', c.points, pctx.reviewHeadSha);
|
|
1031
|
+
const base = { byReviewId: reviewId, headSha: pctx.reviewHeadSha, points: c.points, message };
|
|
1032
|
+
let claimed = false;
|
|
1033
|
+
try {
|
|
1034
|
+
claimed = await claimTeammateLift(ctx, accountId, c.teammateReviewId, base);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
ctx.log.warn(`auto post review ${reviewId}: could not claim the teammate lift: ${errText(err)}`);
|
|
1038
|
+
}
|
|
1039
|
+
// Another run tried this block already: never twice.
|
|
1040
|
+
if (!claimed)
|
|
1041
|
+
continue;
|
|
1042
|
+
let res;
|
|
1043
|
+
try {
|
|
1044
|
+
res = deps.dismissReview
|
|
1045
|
+
? await deps.dismissReview(accountId, pctx.owner, pctx.name, pctx.prNumber, c.githubReviewId, message)
|
|
1046
|
+
: { ok: false, message: 'Dismissing is not available.' };
|
|
1047
|
+
}
|
|
1048
|
+
catch (err) {
|
|
1049
|
+
res = { ok: false, message: errText(err) };
|
|
1050
|
+
}
|
|
1051
|
+
const error = res.ok ? null : res.message.slice(0, 300);
|
|
1052
|
+
const status = res.ok ? 'dismissed' : 'failed';
|
|
1053
|
+
await settleTeammateLift(ctx, accountId, c.teammateReviewId, { ...base, status, at: nowIso(), error }).catch((err) => ctx.log.warn(`auto post review ${reviewId}: could not record the teammate lift: ${errText(err)}`));
|
|
1054
|
+
if (res.ok) {
|
|
1055
|
+
await stampReviewDismissed(ctx, prId, c.githubReviewId).catch((err) => ctx.log.warn(`auto post review ${reviewId}: could not stamp the dismissal: ${errText(err)}`));
|
|
1056
|
+
}
|
|
1057
|
+
lifted.push({ teammateReviewId: c.teammateReviewId, login: c.login, status, error });
|
|
1058
|
+
}
|
|
1059
|
+
if (lifted.length > 0)
|
|
1060
|
+
record.liftedTeammateBlocks = lifted;
|
|
1061
|
+
}
|
|
972
1062
|
// A run whose only write was the lift: posted when it took, failed (GitHub's words) when not.
|
|
973
1063
|
const liftOnly = !sendsReview;
|
|
974
1064
|
const final = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, eq } from 'drizzle-orm';
|
|
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';
|
|
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_LIFT_TEAMMATE_BLOCKS, AUTO_POST_DEFAULT_SCOPE, AUTO_POST_DEFAULT_TEAM_LIFT_CONSENT, } from '../../shared/index.js';
|
|
3
3
|
import { dropAutoReviews } from './manager.js';
|
|
4
4
|
import { resolveAutoReviewProviders, storedAutoReviewProviders } from './pair.js';
|
|
5
5
|
import { DEFAULT_VERDICT_RULES, resolveApproveLeftovers, resolvePairStrictness, } from './auto-verdict.js';
|
|
@@ -42,6 +42,8 @@ import { onOff, writeSettingEvent } from '../../db/setting-event-write.js';
|
|
|
42
42
|
// while auto review is off, but switching auto review off and on keeps it. The `autoVerdict` and
|
|
43
43
|
// `autoResolve` switches (both OFF by default) ride the same overrides JSON (no column), and so
|
|
44
44
|
// does `approveImpact` (which blast-radius levels an auto verdict may APPROVE; all by default).
|
|
45
|
+
// So do the two TEAMMATE-LIFT opt-ins, `teamLiftConsent` and `liftTeammateBlocks` (both OFF by
|
|
46
|
+
// default; docs/CLAUDE-REVIEW.md § Lifting a teammate's block).
|
|
45
47
|
//
|
|
46
48
|
// THE AUTO VERDICT'S TWO RULES ride the same route as two more columns (migration 0145), OVERRIDES
|
|
47
49
|
// ONLY (NULL = today's rule): `pair_verdict_strictness` ('relaxed', else strict — wire
|
|
@@ -103,6 +105,8 @@ approveLeftovers) {
|
|
|
103
105
|
autoResolve: typeof stored?.autoResolve === 'boolean' ? stored.autoResolve : AUTO_POST_DEFAULT_AUTO_RESOLVE,
|
|
104
106
|
approveImpact,
|
|
105
107
|
approveLeftovers: resolveApproveLeftovers(approveLeftovers),
|
|
108
|
+
teamLiftConsent: typeof stored?.teamLiftConsent === 'boolean' ? stored.teamLiftConsent : AUTO_POST_DEFAULT_TEAM_LIFT_CONSENT,
|
|
109
|
+
liftTeammateBlocks: typeof stored?.liftTeammateBlocks === 'boolean' ? stored.liftTeammateBlocks : AUTO_POST_DEFAULT_LIFT_TEAMMATE_BLOCKS,
|
|
106
110
|
};
|
|
107
111
|
}
|
|
108
112
|
/** The settings in force → what to store: only what differs from the product default (null = none). */
|
|
@@ -120,6 +124,12 @@ export function autoPostOverrides(s) {
|
|
|
120
124
|
out.autoVerdict = s.autoVerdict;
|
|
121
125
|
if (s.autoResolve !== undefined && s.autoResolve !== AUTO_POST_DEFAULT_AUTO_RESOLVE)
|
|
122
126
|
out.autoResolve = s.autoResolve;
|
|
127
|
+
if (s.teamLiftConsent !== undefined && s.teamLiftConsent !== AUTO_POST_DEFAULT_TEAM_LIFT_CONSENT) {
|
|
128
|
+
out.teamLiftConsent = s.teamLiftConsent;
|
|
129
|
+
}
|
|
130
|
+
if (s.liftTeammateBlocks !== undefined && s.liftTeammateBlocks !== AUTO_POST_DEFAULT_LIFT_TEAMMATE_BLOCKS) {
|
|
131
|
+
out.liftTeammateBlocks = s.liftTeammateBlocks;
|
|
132
|
+
}
|
|
123
133
|
if (s.approveImpact !== undefined) {
|
|
124
134
|
const levels = {};
|
|
125
135
|
for (const k of APPROVE_IMPACT_KEYS) {
|
|
@@ -184,6 +194,18 @@ export function autoReviewOf(row) {
|
|
|
184
194
|
* by auto-posting before it touches GitHub. A repo with no membership row (or an unknown PR) reads
|
|
185
195
|
* null, i.e. OFF: this switch WRITES as the reader, so a workspace it cannot find never says yes.
|
|
186
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* The BLOCKED side's consent (`teamLiftConsent`) for the PR's workspace: does a post carry `lift=1`?
|
|
199
|
+
* Never throws — a failed read is no consent (docs/CLAUDE-REVIEW.md § Lifting a teammate's block).
|
|
200
|
+
*/
|
|
201
|
+
export async function readTeamLiftConsent(ctx, accountId, prId) {
|
|
202
|
+
try {
|
|
203
|
+
return (await readWorkspaceAutoPostForPr(ctx, accountId, prId))?.settings.teamLiftConsent === true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
187
209
|
export async function readWorkspaceAutoPostForPr(ctx, accountId, prId) {
|
|
188
210
|
const { workspaces: w, workspaceRepos: wr, pullRequests: prs } = ctx.schema;
|
|
189
211
|
const rows = (await ctx.db
|
|
@@ -338,6 +360,8 @@ export async function setWorkspaceAutoReview(ctx, accountId, workspaceId, change
|
|
|
338
360
|
autoResolve: autoPost?.autoResolve ?? postBefore.autoResolve,
|
|
339
361
|
approveImpact: { ...postBefore.approveImpact },
|
|
340
362
|
approveLeftovers: autoPost?.approveLeftovers ?? postBefore.approveLeftovers,
|
|
363
|
+
teamLiftConsent: autoPost?.teamLiftConsent ?? postBefore.teamLiftConsent,
|
|
364
|
+
liftTeammateBlocks: autoPost?.liftTeammateBlocks ?? postBefore.liftTeammateBlocks,
|
|
341
365
|
};
|
|
342
366
|
// The leftovers rule is its own column (migration 0145): NULL = the default.
|
|
343
367
|
const leftoversStored = postAfter.approveLeftovers === 'nits_questions' ? null : postAfter.approveLeftovers;
|
|
@@ -654,6 +678,8 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
|
|
|
654
678
|
},
|
|
655
679
|
autoVerdict: { type: 'boolean' },
|
|
656
680
|
autoResolve: { type: 'boolean' },
|
|
681
|
+
teamLiftConsent: { type: 'boolean' },
|
|
682
|
+
liftTeammateBlocks: { type: 'boolean' },
|
|
657
683
|
approveImpact: {
|
|
658
684
|
type: 'object',
|
|
659
685
|
minProperties: 1,
|
|
@@ -691,7 +717,11 @@ export function registerAutoReviewSettingsRoutes(app, ctx) {
|
|
|
691
717
|
const notBool = (o, k) => k in o && typeof o[k] !== 'boolean';
|
|
692
718
|
if ((post != null &&
|
|
693
719
|
typeof post === 'object' &&
|
|
694
|
-
(notBool(post, 'enabled') ||
|
|
720
|
+
(notBool(post, 'enabled') ||
|
|
721
|
+
notBool(post, 'autoVerdict') ||
|
|
722
|
+
notBool(post, 'autoResolve') ||
|
|
723
|
+
notBool(post, 'teamLiftConsent') ||
|
|
724
|
+
notBool(post, 'liftTeammateBlocks'))) ||
|
|
695
725
|
(kinds != null && typeof kinds === 'object' && Object.values(kinds).some((v) => typeof v !== 'boolean')) ||
|
|
696
726
|
(impact != null && typeof impact === 'object' && Object.values(impact).some((v) => typeof v !== 'boolean'))) {
|
|
697
727
|
reply.status(400);
|
|
@@ -14,7 +14,7 @@ import { consolidatePair, notStartedHalf } from './pair-consolidate.js';
|
|
|
14
14
|
import { markPairBusy } from './pair-view.js';
|
|
15
15
|
import { preflightFor } from '../../ai/run-choice.js';
|
|
16
16
|
import { linkReraisedFindings, reconcileFollowUp, dropAcceptedReraises, isResolvedWithReplies, selectPriorFindings, SINCE_PATCH_CHARS, } from './follow-up.js';
|
|
17
|
-
import { loadAuthorReviewAnchor, mergeAuthorAnchor } from './teammate-anchor.js';
|
|
17
|
+
import { loadAuthorReviewAnchor, loadStandingBlockAnchors, mergeAuthorAnchor, mergeStandingBlocks } from './teammate-anchor.js';
|
|
18
18
|
import { planThreadReview, reconcileThreads } from './threads.js';
|
|
19
19
|
import { currentThreadAssessmentsForPrs, priorAssessmentsForProvider } from './thread-judgements.js';
|
|
20
20
|
import { activeProviderId } from '../../ai/provider-setting.js';
|
|
@@ -876,8 +876,26 @@ async function runPipeline(item, controller, emit) {
|
|
|
876
876
|
ctx.log.warn(`claude review pr ${item.prId}: author's review not read: ${err instanceof Error ? err.message : String(err)}`);
|
|
877
877
|
}
|
|
878
878
|
}
|
|
879
|
-
const
|
|
880
|
-
|
|
879
|
+
const anchored = authorAnchor && authorAnchor.findings.length > 0 ? mergeAuthorAnchor(authorAnchor, ownPrior, settledIds) : ownPrior;
|
|
880
|
+
// A TEAMMATE'S STANDING REQUEST FOR CHANGES (teammate-anchor.ts `loadStandingBlockAnchors`): its
|
|
881
|
+
// findings join as carried, so this run checks them too. A failure costs those findings only.
|
|
882
|
+
let blocks = [];
|
|
883
|
+
if (!fresh) {
|
|
884
|
+
try {
|
|
885
|
+
blocks = await loadStandingBlockAnchors(ctx, item.prId, item.accountId, authorAnchor?.reviewId ?? null, item.headSha);
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
ctx.log.warn(`claude review pr ${item.prId}: teammates' standing reviews not read: ${err instanceof Error ? err.message : String(err)}`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const prior = mergeStandingBlocks(anchored, blocks, settledIds);
|
|
892
|
+
// The follow-up names a teammate when THEIR review anchors it: the author's, else (no review of the
|
|
893
|
+
// reader's at all) the newest standing block's.
|
|
894
|
+
const teammateAnchor = authorAnchor && anchored !== ownPrior
|
|
895
|
+
? authorAnchor.teammate
|
|
896
|
+
: anchored == null && prior != null
|
|
897
|
+
? (prior.teammate ?? null)
|
|
898
|
+
: null;
|
|
881
899
|
// BACKWARD COMPATIBILITY: findings the RETIRED settle-on-sight rule took out of the chain were
|
|
882
900
|
// never judged. Load them as carried items; only the ones whose thread is resolved WITH a
|
|
883
901
|
// person's reply are kept (below), so they are judged once like any other.
|
|
@@ -5,7 +5,7 @@ import { storedList, stripStoredStoryLead } from '../../shared/index.js';
|
|
|
5
5
|
import { referenceDocCitationsFromStored, reviewDocReferencesFromStored } from '../../shared/index.js';
|
|
6
6
|
import { automaticRetryTarget, pressRetryTarget } from '../../db/auto-retry-at-head.js';
|
|
7
7
|
import { TEAMMATE_KIND, newestTeammateReviewsForPrs, ownFindingsOnly, ownRunsOnly } from '../../db/teammate-reviews.js';
|
|
8
|
-
import { teamReviewLead } from './team-view.js';
|
|
8
|
+
import { loadTeamStandingsForPrs, teamReviewLead } from './team-view.js';
|
|
9
9
|
import { ticketEntriesOf } from './ticket.js';
|
|
10
10
|
import { overlayFollowUpItems } from './replies-pass.js';
|
|
11
11
|
import { findFindingThread } from './finding-thread.js';
|
|
@@ -85,6 +85,9 @@ export function autoPostWireOf(v) {
|
|
|
85
85
|
: {}),
|
|
86
86
|
...(r.verdict != null && typeof r.verdict === 'object' ? { verdict: r.verdict } : {}),
|
|
87
87
|
...(r.liftedBlock != null && typeof r.liftedBlock === 'object' ? { liftedBlock: r.liftedBlock } : {}),
|
|
88
|
+
...(Array.isArray(r.liftedTeammateBlocks) && r.liftedTeammateBlocks.length > 0
|
|
89
|
+
? { liftedTeammateBlocks: r.liftedTeammateBlocks }
|
|
90
|
+
: {}),
|
|
88
91
|
...(typeof r.viaPairReviewId === 'number' ? { viaPairReviewId: r.viaPairReviewId } : {}),
|
|
89
92
|
...(typeof r.reasonPosted === 'string' && r.reasonPosted !== '' ? { reasonPosted: true } : {}),
|
|
90
93
|
...(r.reasonOnly === true ? { reasonOnly: true } : {}),
|
|
@@ -903,7 +906,24 @@ fixRunning) {
|
|
|
903
906
|
await foldStateSummaries(ctx, out, latestByPr, repliesAfter);
|
|
904
907
|
if (fixRunning)
|
|
905
908
|
await foldFixStates(ctx, out, latestByPr, accountId, fixRunning);
|
|
906
|
-
|
|
909
|
+
// The reader's latest run that REVIEWED, per PR (succeeded, not a skip; a pair's verdict is the
|
|
910
|
+
// pair's when it is the latest run) — the standings' "you reviewed last".
|
|
911
|
+
const ownLatest = new Map();
|
|
912
|
+
const stateOf = new Map(out.map((st) => [st.prId, st]));
|
|
913
|
+
for (const r of rows) {
|
|
914
|
+
if (r.kind != null || ownLatest.has(r.prId) || r.status !== 'succeeded' || r.reviewMode === 'skip')
|
|
915
|
+
continue;
|
|
916
|
+
const latest = latestByPr.get(r.prId);
|
|
917
|
+
const st = stateOf.get(r.prId);
|
|
918
|
+
const isLatest = latest != null && (latest.reviewId === r.reviewId || (latest.pairId != null && latest.pairId === r.pairId));
|
|
919
|
+
ownLatest.set(r.prId, {
|
|
920
|
+
reviewId: isLatest ? latest.reviewId : r.reviewId,
|
|
921
|
+
verdict: isLatest && st != null ? st.verdict : r.verdict,
|
|
922
|
+
at: (r.finishedAt ?? r.createdAt).getTime(),
|
|
923
|
+
headSha: r.headSha,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
await foldTeammateLeads(ctx, out, latestByPr, prIds, accountId, ownLatest);
|
|
907
927
|
return out;
|
|
908
928
|
}
|
|
909
929
|
/**
|
|
@@ -913,7 +933,21 @@ fixRunning) {
|
|
|
913
933
|
* PR with no run of the reader's but a teammate review is appended, so its column is not blank.
|
|
914
934
|
* Mutates `states` in place. DB-only.
|
|
915
935
|
*/
|
|
916
|
-
async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId) {
|
|
936
|
+
async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId, ownLatest = new Map()) {
|
|
937
|
+
// STANDING VERDICTS (team-view.ts `teamStandings`): the strictest verdict still in force on GitHub.
|
|
938
|
+
// A failed read costs the field only.
|
|
939
|
+
let standings = new Map();
|
|
940
|
+
try {
|
|
941
|
+
standings = await loadTeamStandingsForPrs(ctx.db, accountId, prIds, ownLatest);
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
ctx.log.warn(`review standings: ${err instanceof Error ? err.message : String(err)}`);
|
|
945
|
+
}
|
|
946
|
+
for (const st of states) {
|
|
947
|
+
const got = standings.get(st.prId);
|
|
948
|
+
if (got != null)
|
|
949
|
+
st.standing = got;
|
|
950
|
+
}
|
|
917
951
|
const newest = await newestTeammateReviewsForPrs(ctx.db, accountId, prIds);
|
|
918
952
|
if (newest.size === 0)
|
|
919
953
|
return;
|
|
@@ -930,6 +964,7 @@ async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId) {
|
|
|
930
964
|
status: state.status,
|
|
931
965
|
reviewMode: row.reviewMode,
|
|
932
966
|
at: (row.finishedAt ?? row.createdAt).getTime(),
|
|
967
|
+
lastReviewedAt: ownLatest.get(prId)?.at ?? null,
|
|
933
968
|
}
|
|
934
969
|
: null;
|
|
935
970
|
if (teamReviewLead(own, mate.postedAt.getTime()) !== 'teammate')
|
|
@@ -960,6 +995,7 @@ async function foldTeammateLeads(ctx, states, latestByPr, prIds, accountId) {
|
|
|
960
995
|
headMoved: false,
|
|
961
996
|
currentHeadSha: mate.prHeadSha,
|
|
962
997
|
teammateLead,
|
|
998
|
+
...(standings.get(prId) != null ? { standing: standings.get(prId) } : {}),
|
|
963
999
|
});
|
|
964
1000
|
}
|
|
965
1001
|
}
|