pierre-review 0.1.21 → 0.1.23
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/routes/claude-review.js +2 -2
- package/dist/api/routes/insights.js +23 -0
- package/dist/api/routes/me.js +1 -0
- package/dist/api/routes/prs.js +16 -1
- package/dist/app.js +2 -0
- package/dist/config.js +9 -2
- package/dist/db/queries.js +266 -16
- package/dist/review/agent.js +5 -5
- package/dist/review/clone-manager.js +40 -0
- package/dist/review/review-manager.js +85 -28
- package/package.json +1 -1
- package/public/assets/index-CbNTBha6.js +1371 -0
- package/public/assets/index-CjBmEO6s.css +10 -0
- package/public/index.html +2 -2
- package/public/assets/index-DlSTGoNj.css +0 -10
- package/public/assets/index-sjQwmtK6.js +0 -1371
|
@@ -154,8 +154,8 @@ export async function claudeReviewRoutes(app) {
|
|
|
154
154
|
return {
|
|
155
155
|
error: 'Conflict',
|
|
156
156
|
message: result.reason === 'already_running'
|
|
157
|
-
? 'A review is already running for this PR.'
|
|
158
|
-
: '
|
|
157
|
+
? 'A review is already running or queued for this PR.'
|
|
158
|
+
: 'The review queue is full; try again once some finish.',
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
161
|
reply.status(202);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getInsights } from '../../db/queries.js';
|
|
2
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
3
|
+
function parseIntList(raw) {
|
|
4
|
+
if (!raw)
|
|
5
|
+
return null;
|
|
6
|
+
const ids = raw
|
|
7
|
+
.split(',')
|
|
8
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
9
|
+
.filter((n) => Number.isFinite(n));
|
|
10
|
+
return ids.length > 0 ? ids : null;
|
|
11
|
+
}
|
|
12
|
+
export async function insightsRoutes(app) {
|
|
13
|
+
// Per-repo sprint/team stats for the Insights panel. Scoped to the account;
|
|
14
|
+
// `repoIds` narrows to the active watched-repo selection.
|
|
15
|
+
app.get('/api/insights', async (req) => {
|
|
16
|
+
const q = req.query;
|
|
17
|
+
return getInsights({
|
|
18
|
+
accountId: accountIdOf(req),
|
|
19
|
+
repoIds: parseIntList(q.repoIds),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=insights.js.map
|
package/dist/api/routes/me.js
CHANGED
|
@@ -23,6 +23,7 @@ export async function meRoutes(app) {
|
|
|
23
23
|
awaitingReview: myTurn.awaitingReview.length,
|
|
24
24
|
yourPrsActivity: myTurn.yourPrs.length,
|
|
25
25
|
threadsAwaiting: myTurn.threadsAwaiting.length,
|
|
26
|
+
claudeReviewsToAction: myTurn.claudeReviewsToAction.length,
|
|
26
27
|
},
|
|
27
28
|
claudeReviewEnabled: config.claudeReviewEnabled,
|
|
28
29
|
deploymentMode: config.deploymentMode,
|
package/dist/api/routes/prs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getPrDetail, markPrViewed } from '../../db/queries.js';
|
|
1
|
+
import { getPrDetail, markAllViewed, markPrViewed } from '../../db/queries.js';
|
|
2
2
|
import { hydratePrDetail } from '../../sync/hydrate-detail.js';
|
|
3
3
|
import { accountIdOf } from '../plugins/auth.js';
|
|
4
4
|
const idParamSchema = {
|
|
@@ -16,7 +16,22 @@ const markViewedSchema = {
|
|
|
16
16
|
properties: { sha: { type: 'string' } },
|
|
17
17
|
},
|
|
18
18
|
};
|
|
19
|
+
const markAllViewedSchema = {
|
|
20
|
+
body: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
properties: { repoIds: { type: 'array', items: { type: 'integer' } } },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
19
26
|
export async function prRoutes(app) {
|
|
27
|
+
// Bulk "mark all seen": stamp every open PR (optionally scoped to repoIds) viewed
|
|
28
|
+
// at its head, clearing all new-since badges at once. Static path — no :id — so it
|
|
29
|
+
// doesn't collide with /api/prs/:id.
|
|
30
|
+
app.post('/api/prs/mark-all-viewed', { schema: markAllViewedSchema }, async (req) => {
|
|
31
|
+
const { repoIds } = (req.body ?? {});
|
|
32
|
+
const count = await markAllViewed(accountIdOf(req), repoIds && repoIds.length > 0 ? repoIds : null);
|
|
33
|
+
return { status: 'ok', count };
|
|
34
|
+
});
|
|
20
35
|
app.get('/api/prs/:id', { schema: idParamSchema }, async (req, reply) => {
|
|
21
36
|
const { id } = req.params;
|
|
22
37
|
const accountId = accountIdOf(req);
|
package/dist/app.js
CHANGED
|
@@ -16,6 +16,7 @@ import { threadRoutes } from './api/routes/threads.js';
|
|
|
16
16
|
import { meRoutes } from './api/routes/me.js';
|
|
17
17
|
import { openPrsRoutes } from './api/routes/open-prs.js';
|
|
18
18
|
import { mergersRoutes } from './api/routes/mergers.js';
|
|
19
|
+
import { insightsRoutes } from './api/routes/insights.js';
|
|
19
20
|
import { claudeReviewRoutes } from './api/routes/claude-review.js';
|
|
20
21
|
export async function buildApp() {
|
|
21
22
|
const app = Fastify({
|
|
@@ -126,6 +127,7 @@ export async function buildApp() {
|
|
|
126
127
|
await app.register(meRoutes);
|
|
127
128
|
await app.register(openPrsRoutes);
|
|
128
129
|
await app.register(mergersRoutes);
|
|
130
|
+
await app.register(insightsRoutes);
|
|
129
131
|
// Claude Review is local-only + opt-in. Only register its routes when enabled,
|
|
130
132
|
// so the clone-manager / gh-CLI dependency is unreachable in cloud mode.
|
|
131
133
|
if (config.claudeReviewEnabled)
|
package/dist/config.js
CHANGED
|
@@ -126,8 +126,15 @@ export const config = {
|
|
|
126
126
|
// Turn cap for a diff-only run. These are TOOL-LESS (only submit_review), so they
|
|
127
127
|
// should finish in ~2 turns; a tight cap is a cheap runaway guard.
|
|
128
128
|
reviewDiffOnlyMaxTurns: intFromEnv('REVIEW_DIFF_ONLY_MAX_TURNS', 6),
|
|
129
|
-
// At most one review per PR; this caps concurrent reviews across all PRs.
|
|
130
|
-
|
|
129
|
+
// At most one review per PR; this caps concurrent reviews across all PRs. Default
|
|
130
|
+
// 4 so the user can bulk-review (extras queue, see review-manager). Raising this
|
|
131
|
+
// also DISABLES the pasted-key override (which mutates process.env and is only
|
|
132
|
+
// safe at concurrency 1 — see local-settings.applyUserAnthropicKey); ambient auth
|
|
133
|
+
// is used instead. Set REVIEW_CONCURRENCY=1 to restore the pasted-key path.
|
|
134
|
+
reviewConcurrency: intFromEnv('REVIEW_CONCURRENCY', 4),
|
|
135
|
+
// Hard ceiling on QUEUED (not-yet-started) reviews, a runaway guard for bulk
|
|
136
|
+
// triggering; further starts return 'busy' until the queue drains.
|
|
137
|
+
reviewMaxQueued: intFromEnv('REVIEW_MAX_QUEUED', 50),
|
|
131
138
|
// ---- Claude Review routing (diff-only vs worktree) — THE THRESHOLDS ----
|
|
132
139
|
// The deterministic pre-check (review/routing.ts) decides, BEFORE the agent runs,
|
|
133
140
|
// whether a PR can be reviewed from its diff alone (fast, tool-less, no worktree)
|
package/dist/db/queries.js
CHANGED
|
@@ -323,21 +323,40 @@ export async function getTimeline(filters) {
|
|
|
323
323
|
for (const r of rows)
|
|
324
324
|
reviewStateById.set(r.id, r.state);
|
|
325
325
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
type
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
326
|
+
// Batch-load the derived state of each review_comment event's thread, so the
|
|
327
|
+
// timeline's "Threads" filter can narrow markers to a specific thread state
|
|
328
|
+
// (e.g. only resolved) instead of every comment on a PR that has a matching
|
|
329
|
+
// thread. A single keyed lookup; the thread's id is the event's refId.
|
|
330
|
+
const threadRefIds = evRows
|
|
331
|
+
.filter((e) => e.type === 'review_comment' && e.refTable === 'review_threads' && e.refId != null)
|
|
332
|
+
.map((e) => e.refId);
|
|
333
|
+
const threadStateById = new Map();
|
|
334
|
+
if (threadRefIds.length > 0) {
|
|
335
|
+
const rows = await db
|
|
336
|
+
.select({ id: reviewThreads.id, state: reviewThreads.derivedState })
|
|
337
|
+
.from(reviewThreads)
|
|
338
|
+
.where(inArray(reviewThreads.id, threadRefIds))
|
|
339
|
+
.execute();
|
|
340
|
+
for (const r of rows)
|
|
341
|
+
threadStateById.set(r.id, r.state);
|
|
342
|
+
}
|
|
343
|
+
const timelineEvents = evRows.map((e) => {
|
|
344
|
+
const threadId = e.type === 'review_comment' && e.refTable === 'review_threads' ? e.refId : null;
|
|
345
|
+
return {
|
|
346
|
+
id: e.id,
|
|
347
|
+
repoId: e.repoId,
|
|
348
|
+
actorId: e.actorId,
|
|
349
|
+
prId: e.prId,
|
|
350
|
+
type: e.type,
|
|
351
|
+
occurredAt: e.occurredAt.toISOString(),
|
|
352
|
+
threadId,
|
|
353
|
+
derivedState: threadId != null ? (threadStateById.get(threadId) ?? null) : null,
|
|
354
|
+
refId: e.refId,
|
|
355
|
+
reviewState: e.type === 'review_submitted' && e.refTable === 'reviews' && e.refId != null
|
|
356
|
+
? (reviewStateById.get(e.refId) ?? null)
|
|
357
|
+
: null,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
341
360
|
return { prs, events: timelineEvents };
|
|
342
361
|
}
|
|
343
362
|
export async function getOpenPrs(filters) {
|
|
@@ -364,6 +383,154 @@ export async function getOpenPrs(filters) {
|
|
|
364
383
|
return a.openedAt.localeCompare(b.openedAt); // oldest first
|
|
365
384
|
});
|
|
366
385
|
}
|
|
386
|
+
const INSIGHTS_MERGED_WINDOW_DAYS = 7;
|
|
387
|
+
const INSIGHTS_REVIEW_WINDOW_DAYS = 30;
|
|
388
|
+
function median(xs) {
|
|
389
|
+
if (xs.length === 0)
|
|
390
|
+
return null;
|
|
391
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
392
|
+
const mid = Math.floor(s.length / 2);
|
|
393
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Per-repo snapshot for the Insights panel. Counts (open/draft/stalled) are current
|
|
397
|
+
* state; merged is a 7-day window; time-to-first-review is a median over PRs opened
|
|
398
|
+
* in the last 30 days that got a review. Per-repo only — no team aggregation yet.
|
|
399
|
+
* Scoped to the account; `repoIds` narrows to the watched-repo selection.
|
|
400
|
+
*/
|
|
401
|
+
export async function getInsights(filters) {
|
|
402
|
+
const { accountId, repoIds } = filters;
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
const mergedCutoff = new Date(now - INSIGHTS_MERGED_WINDOW_DAYS * 86_400_000);
|
|
405
|
+
const reviewCutoff = new Date(now - INSIGHTS_REVIEW_WINDOW_DAYS * 86_400_000);
|
|
406
|
+
const base = {
|
|
407
|
+
mergedWindowDays: INSIGHTS_MERGED_WINDOW_DAYS,
|
|
408
|
+
reviewWindowDays: INSIGHTS_REVIEW_WINDOW_DAYS,
|
|
409
|
+
stallThresholdDays: config.stallThresholdDays,
|
|
410
|
+
generatedAt: new Date().toISOString(),
|
|
411
|
+
};
|
|
412
|
+
const reposAll = await listRepos(accountId);
|
|
413
|
+
const repos = repoIds ? reposAll.filter((r) => repoIds.includes(r.id)) : reposAll;
|
|
414
|
+
if (repos.length === 0)
|
|
415
|
+
return { ...base, repos: [] };
|
|
416
|
+
const repoIdSet = new Set(repos.map((r) => r.id));
|
|
417
|
+
// Open PRs for these repos (one query → open/draft/stalled/oldest-unreviewed).
|
|
418
|
+
const openConds = [eq(pullRequests.accountId, accountId), eq(pullRequests.state, 'open')];
|
|
419
|
+
if (repoIds)
|
|
420
|
+
openConds.push(inArray(pullRequests.repoId, repoIds));
|
|
421
|
+
const openRows = await db.select().from(pullRequests).where(and(...openConds)).execute();
|
|
422
|
+
const counts = await buildThreadCounts(openRows.map((p) => p.id));
|
|
423
|
+
// Merged in the window (count per repo).
|
|
424
|
+
const mergedConds = [
|
|
425
|
+
eq(pullRequests.accountId, accountId),
|
|
426
|
+
eq(pullRequests.state, 'merged'),
|
|
427
|
+
gte(pullRequests.mergedAt, mergedCutoff),
|
|
428
|
+
];
|
|
429
|
+
if (repoIds)
|
|
430
|
+
mergedConds.push(inArray(pullRequests.repoId, repoIds));
|
|
431
|
+
const mergedRows = await db
|
|
432
|
+
.select({ repoId: pullRequests.repoId })
|
|
433
|
+
.from(pullRequests)
|
|
434
|
+
.where(and(...mergedConds))
|
|
435
|
+
.execute();
|
|
436
|
+
const mergedByRepo = new Map();
|
|
437
|
+
for (const r of mergedRows)
|
|
438
|
+
mergedByRepo.set(r.repoId, (mergedByRepo.get(r.repoId) ?? 0) + 1);
|
|
439
|
+
// Time-to-first-review samples: PRs opened in the review window with a first review.
|
|
440
|
+
const ttfrConds = [
|
|
441
|
+
eq(pullRequests.accountId, accountId),
|
|
442
|
+
gte(pullRequests.openedAt, reviewCutoff),
|
|
443
|
+
isNotNull(pullRequests.firstReviewAt),
|
|
444
|
+
];
|
|
445
|
+
if (repoIds)
|
|
446
|
+
ttfrConds.push(inArray(pullRequests.repoId, repoIds));
|
|
447
|
+
const ttfrRows = await db
|
|
448
|
+
.select({
|
|
449
|
+
repoId: pullRequests.repoId,
|
|
450
|
+
openedAt: pullRequests.openedAt,
|
|
451
|
+
firstReviewAt: pullRequests.firstReviewAt,
|
|
452
|
+
})
|
|
453
|
+
.from(pullRequests)
|
|
454
|
+
.where(and(...ttfrConds))
|
|
455
|
+
.execute();
|
|
456
|
+
const ttfrByRepo = new Map();
|
|
457
|
+
for (const r of ttfrRows) {
|
|
458
|
+
if (!r.firstReviewAt)
|
|
459
|
+
continue;
|
|
460
|
+
const hrs = (r.firstReviewAt.getTime() - r.openedAt.getTime()) / 3_600_000;
|
|
461
|
+
if (hrs < 0)
|
|
462
|
+
continue;
|
|
463
|
+
const arr = ttfrByRepo.get(r.repoId) ?? [];
|
|
464
|
+
arr.push(hrs);
|
|
465
|
+
ttfrByRepo.set(r.repoId, arr);
|
|
466
|
+
}
|
|
467
|
+
// Pending review-requests per reviewer, for OPEN PRs only (the review-load signal).
|
|
468
|
+
const rrRows = await db
|
|
469
|
+
.select({ repoId: pullRequests.repoId, userId: schema.reviewRequests.userId })
|
|
470
|
+
.from(schema.reviewRequests)
|
|
471
|
+
.innerJoin(pullRequests, eq(pullRequests.id, schema.reviewRequests.prId))
|
|
472
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.state, 'open'), isNotNull(schema.reviewRequests.userId)))
|
|
473
|
+
.execute();
|
|
474
|
+
const reviewLoadByRepo = new Map();
|
|
475
|
+
for (const r of rrRows) {
|
|
476
|
+
if (r.userId == null || !repoIdSet.has(r.repoId))
|
|
477
|
+
continue;
|
|
478
|
+
const m = reviewLoadByRepo.get(r.repoId) ?? new Map();
|
|
479
|
+
m.set(r.userId, (m.get(r.userId) ?? 0) + 1);
|
|
480
|
+
reviewLoadByRepo.set(r.repoId, m);
|
|
481
|
+
}
|
|
482
|
+
const OPEN_LIST_CAP = 100; // bound the payload for a pathologically busy repo
|
|
483
|
+
const repoInsights = repos.map((repo) => {
|
|
484
|
+
const repoOpen = openRows.filter((p) => p.repoId === repo.id);
|
|
485
|
+
const [owner, name] = repo.fullName.split('/');
|
|
486
|
+
const stalledOf = (p) => isStalled({ state: p.state, lastCommitAt: p.lastCommitAt }, counts.get(p.id) ?? emptyCounts());
|
|
487
|
+
const unreviewed = repoOpen
|
|
488
|
+
.filter((p) => !p.isDraft && p.firstReviewAt == null)
|
|
489
|
+
.sort((a, b) => a.openedAt.getTime() - b.openedAt.getTime());
|
|
490
|
+
const oldest = unreviewed[0];
|
|
491
|
+
// The full open-PR list (oldest first), independent of timeline filters; the
|
|
492
|
+
// client toggles stale visibility off the per-row flag.
|
|
493
|
+
const openPrList = [...repoOpen]
|
|
494
|
+
.sort((a, b) => a.openedAt.getTime() - b.openedAt.getTime())
|
|
495
|
+
.slice(0, OPEN_LIST_CAP)
|
|
496
|
+
.map((p) => ({
|
|
497
|
+
prId: p.id,
|
|
498
|
+
number: p.number,
|
|
499
|
+
title: p.title,
|
|
500
|
+
authorId: p.authorId,
|
|
501
|
+
isDraft: p.isDraft,
|
|
502
|
+
isStalled: stalledOf(p),
|
|
503
|
+
openedAt: p.openedAt.toISOString(),
|
|
504
|
+
githubUrl: `https://github.com/${owner}/${name}/pull/${p.number}`,
|
|
505
|
+
}));
|
|
506
|
+
const med = median(ttfrByRepo.get(repo.id) ?? []);
|
|
507
|
+
const reviewLoad = [...(reviewLoadByRepo.get(repo.id) ?? new Map()).entries()]
|
|
508
|
+
.map(([userId, pending]) => ({ userId, pending }))
|
|
509
|
+
.sort((a, b) => b.pending - a.pending)
|
|
510
|
+
.slice(0, 5);
|
|
511
|
+
return {
|
|
512
|
+
repoId: repo.id,
|
|
513
|
+
repoFullName: repo.fullName,
|
|
514
|
+
openPrs: repoOpen.filter((p) => !p.isDraft).length,
|
|
515
|
+
draftPrs: repoOpen.filter((p) => p.isDraft).length,
|
|
516
|
+
mergedLast7d: mergedByRepo.get(repo.id) ?? 0,
|
|
517
|
+
stalledPrs: repoOpen.filter(stalledOf).length,
|
|
518
|
+
medianHoursToFirstReview: med == null ? null : Math.round(med * 10) / 10,
|
|
519
|
+
oldestUnreviewed: oldest
|
|
520
|
+
? {
|
|
521
|
+
prId: oldest.id,
|
|
522
|
+
number: oldest.number,
|
|
523
|
+
title: oldest.title,
|
|
524
|
+
openedAt: oldest.openedAt.toISOString(),
|
|
525
|
+
githubUrl: `https://github.com/${owner}/${name}/pull/${oldest.number}`,
|
|
526
|
+
}
|
|
527
|
+
: null,
|
|
528
|
+
reviewLoad,
|
|
529
|
+
openPrList,
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
return { ...base, repos: repoInsights };
|
|
533
|
+
}
|
|
367
534
|
// ---- merge-rights inference ----
|
|
368
535
|
// Distinct users who have merged a PR INTO THE DEFAULT BRANCH per repo (across
|
|
369
536
|
// ALL synced history, not the timeline window). We treat "has merged into the
|
|
@@ -419,6 +586,37 @@ export async function markPrViewed(prId, accountId, sha) {
|
|
|
419
586
|
.execute();
|
|
420
587
|
return true;
|
|
421
588
|
}
|
|
589
|
+
// Mark every currently-open PR (optionally repo-scoped) viewed at its head — the
|
|
590
|
+
// bulk "mark all seen" that clears all new-since badges at once. Account-scoped;
|
|
591
|
+
// returns how many PRs were stamped. Closed/merged PRs carry no new-since badge,
|
|
592
|
+
// so open is the set that matters. One transaction, one upsert per PR (portable
|
|
593
|
+
// across dialects; the open-PR set is bounded in practice).
|
|
594
|
+
export async function markAllViewed(accountId, repoIds) {
|
|
595
|
+
const conds = [eq(pullRequests.accountId, accountId), eq(pullRequests.state, 'open')];
|
|
596
|
+
if (repoIds)
|
|
597
|
+
conds.push(inArray(pullRequests.repoId, repoIds));
|
|
598
|
+
const rows = await db
|
|
599
|
+
.select({ id: pullRequests.id, headSha: pullRequests.headSha })
|
|
600
|
+
.from(pullRequests)
|
|
601
|
+
.where(and(...conds))
|
|
602
|
+
.execute();
|
|
603
|
+
if (rows.length === 0)
|
|
604
|
+
return 0;
|
|
605
|
+
const now = new Date();
|
|
606
|
+
await runTransaction(async (tx) => {
|
|
607
|
+
for (const r of rows) {
|
|
608
|
+
await tx
|
|
609
|
+
.insert(prViews)
|
|
610
|
+
.values({ prId: r.id, lastViewedSha: r.headSha ?? null, lastViewedAt: now })
|
|
611
|
+
.onConflictDoUpdate({
|
|
612
|
+
target: prViews.prId,
|
|
613
|
+
set: { lastViewedSha: r.headSha ?? null, lastViewedAt: now },
|
|
614
|
+
})
|
|
615
|
+
.execute();
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
return rows.length;
|
|
619
|
+
}
|
|
422
620
|
// ---- my turn ----
|
|
423
621
|
// Does this (kind, refId) actually belong to the account? Guards the dismiss
|
|
424
622
|
// insert so a buggy/hostile client can't seed orphan dismissal rows for ids it
|
|
@@ -589,6 +787,7 @@ export async function getMyTurn(accountId) {
|
|
|
589
787
|
awaitingReview: [],
|
|
590
788
|
yourPrs: [],
|
|
591
789
|
threadsAwaiting: [],
|
|
790
|
+
claudeReviewsToAction: [],
|
|
592
791
|
users: [],
|
|
593
792
|
};
|
|
594
793
|
if (localUserId == null)
|
|
@@ -671,6 +870,10 @@ export async function getMyTurn(accountId) {
|
|
|
671
870
|
if (ta.lastReplyAuthorId != null)
|
|
672
871
|
referencedUsers.add(ta.lastReplyAuthorId);
|
|
673
872
|
}
|
|
873
|
+
// Completed-but-unactioned Claude reviews (local-only feature; empty otherwise).
|
|
874
|
+
const claudeReviewsToAction = config.claudeReviewEnabled
|
|
875
|
+
? await getUnactionedClaudeReviews(accountId)
|
|
876
|
+
: [];
|
|
674
877
|
const users = referencedUsers.size > 0
|
|
675
878
|
? (await db
|
|
676
879
|
.select()
|
|
@@ -678,7 +881,7 @@ export async function getMyTurn(accountId) {
|
|
|
678
881
|
.where(inArray(schema.users.id, [...referencedUsers]))
|
|
679
882
|
.execute()).map(mapUser)
|
|
680
883
|
: [];
|
|
681
|
-
return { awaitingReview, yourPrs, threadsAwaiting, users };
|
|
884
|
+
return { awaitingReview, yourPrs, threadsAwaiting, claudeReviewsToAction, users };
|
|
682
885
|
}
|
|
683
886
|
async function countOtherReviewers(prId, localUserId) {
|
|
684
887
|
const rows = await db
|
|
@@ -1248,6 +1451,53 @@ export async function listAllClaudeReviews(accountId) {
|
|
|
1248
1451
|
}
|
|
1249
1452
|
return items;
|
|
1250
1453
|
}
|
|
1454
|
+
// Completed Claude reviews on OPEN PRs that haven't been actioned: each PR's
|
|
1455
|
+
// MOST-RECENT succeeded run, kept only when it was never posted (postedAt null).
|
|
1456
|
+
// Account-scoped. Feeds the My Turn "Claude reviews to action" section so finished
|
|
1457
|
+
// reviews don't fall through the cracks.
|
|
1458
|
+
export async function getUnactionedClaudeReviews(accountId) {
|
|
1459
|
+
const rows = await db
|
|
1460
|
+
.select({
|
|
1461
|
+
reviewId: claudeReviews.id,
|
|
1462
|
+
prId: claudeReviews.prId,
|
|
1463
|
+
owner: repos.owner,
|
|
1464
|
+
name: repos.name,
|
|
1465
|
+
prNumber: pullRequests.number,
|
|
1466
|
+
prTitle: pullRequests.title,
|
|
1467
|
+
verdict: claudeReviews.verdict,
|
|
1468
|
+
finishedAt: claudeReviews.finishedAt,
|
|
1469
|
+
postedAt: claudeReviews.postedAt,
|
|
1470
|
+
reviewHead: claudeReviews.headSha,
|
|
1471
|
+
prHead: pullRequests.headSha,
|
|
1472
|
+
})
|
|
1473
|
+
.from(claudeReviews)
|
|
1474
|
+
.innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
|
|
1475
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
1476
|
+
.where(and(eq(repos.accountId, accountId), eq(claudeReviews.status, 'succeeded'), eq(pullRequests.state, 'open')))
|
|
1477
|
+
.orderBy(desc(claudeReviews.finishedAt), desc(claudeReviews.createdAt))
|
|
1478
|
+
.execute();
|
|
1479
|
+
const seen = new Set(); // most-recent succeeded run per PR only
|
|
1480
|
+
const out = [];
|
|
1481
|
+
for (const r of rows) {
|
|
1482
|
+
if (seen.has(r.prId))
|
|
1483
|
+
continue;
|
|
1484
|
+
seen.add(r.prId);
|
|
1485
|
+
if (r.postedAt != null)
|
|
1486
|
+
continue; // that latest run was already posted → actioned
|
|
1487
|
+
out.push({
|
|
1488
|
+
reviewId: r.reviewId,
|
|
1489
|
+
prId: r.prId,
|
|
1490
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
1491
|
+
prNumber: r.prNumber,
|
|
1492
|
+
prTitle: r.prTitle,
|
|
1493
|
+
verdict: r.verdict,
|
|
1494
|
+
finishedAt: iso(r.finishedAt),
|
|
1495
|
+
headStale: r.reviewHead !== r.prHead,
|
|
1496
|
+
githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1251
1501
|
export async function getFindingPostContext(findingId, accountId) {
|
|
1252
1502
|
const rows = await db
|
|
1253
1503
|
.select({
|
package/dist/review/agent.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createSdkMcpServer, query, tool, } from '@anthropic-ai/claude-agent-sdk
|
|
|
5
5
|
import { config } from '../config.js';
|
|
6
6
|
import { submitReviewShape } from './schema.js';
|
|
7
7
|
import { applyUserAnthropicKey } from './local-settings.js';
|
|
8
|
-
import {
|
|
8
|
+
import { cleanupCloneCache, prepWorktree, removeWorktreeLocked, } from './clone-manager.js';
|
|
9
9
|
import { buildUserPrompt, isNoiseFile, systemPromptForMode, } from './prompt.js';
|
|
10
10
|
import { buildAnchorIndex, extractHunk, fetchPrDiff, isFindingAnchored, splitDiffByFile, stripNoiseFromDiff, } from './post-review.js';
|
|
11
11
|
import { decideReviewMode } from './routing.js';
|
|
@@ -90,9 +90,9 @@ export async function runReview(args) {
|
|
|
90
90
|
let maxTurns;
|
|
91
91
|
if (mode === 'worktree') {
|
|
92
92
|
onProgress({ phase: 'cloning', reviewMode: mode });
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
worktreePath = await
|
|
93
|
+
// Clone/fetch/worktree-add run under a per-repo lock (clone-manager) so several
|
|
94
|
+
// concurrent reviews of the same repo can't race on git locks.
|
|
95
|
+
({ repoCloneDir, worktreePath } = await prepWorktree(args.owner, args.name, args.prNumber, args.headSha));
|
|
96
96
|
cwd = worktreePath;
|
|
97
97
|
allowedTools = WORKTREE_TOOLS;
|
|
98
98
|
maxTurns = config.reviewMaxTurns;
|
|
@@ -248,7 +248,7 @@ export async function runReview(args) {
|
|
|
248
248
|
finally {
|
|
249
249
|
restoreEnv?.();
|
|
250
250
|
if (repoCloneDir && worktreePath) {
|
|
251
|
-
await
|
|
251
|
+
await removeWorktreeLocked(args.owner, args.name, repoCloneDir, worktreePath).catch(() => { });
|
|
252
252
|
}
|
|
253
253
|
// Diff-only runs use a throwaway cwd — remove it (best-effort).
|
|
254
254
|
if (tempCwd) {
|
|
@@ -17,6 +17,28 @@ async function git(args, cwd) {
|
|
|
17
17
|
maxBuffer: 64 * 1024 * 1024,
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
|
+
// Serialise git PREP/CLEANUP (clone / fetch / worktree add+remove) PER REPO so
|
|
21
|
+
// concurrent reviews of PRs in the same repo don't race on git's index / worktree
|
|
22
|
+
// locks (`index.lock` exists, worktree-registry contention) once reviewConcurrency
|
|
23
|
+
// > 1. A simple promise-chain mutex keyed by `owner/name`; only this short prep
|
|
24
|
+
// phase serialises — the agent runs themselves (each in its own worktree) overlap.
|
|
25
|
+
const repoLocks = new Map();
|
|
26
|
+
async function withRepoLock(key, fn) {
|
|
27
|
+
const prev = repoLocks.get(key) ?? Promise.resolve();
|
|
28
|
+
let release;
|
|
29
|
+
const next = new Promise((r) => {
|
|
30
|
+
release = r;
|
|
31
|
+
});
|
|
32
|
+
// The map tail resolves when WE release, so the next caller queues behind us.
|
|
33
|
+
repoLocks.set(key, prev.then(() => next));
|
|
34
|
+
await prev.catch(() => { }); // our turn once the previous holder releases (ignore its error)
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
release();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
20
42
|
/** Absolute path to a repo's long-lived partial clone under config.cloneDir. */
|
|
21
43
|
function repoCloneDir(owner, name) {
|
|
22
44
|
return join(config.cloneDir, `${owner}__${name}`);
|
|
@@ -129,6 +151,24 @@ export async function removeWorktree(repoCloneDir, worktreePath) {
|
|
|
129
151
|
/* best-effort */
|
|
130
152
|
}
|
|
131
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Prepare a worktree for a review under the per-repo lock: ensure the clone, fetch
|
|
156
|
+
* the PR head, add the worktree. Serialised per repo so several concurrent reviews
|
|
157
|
+
* of the same repo can't collide on git locks; returns both the clone dir (for
|
|
158
|
+
* later cleanup) and the worktree path the agent runs in.
|
|
159
|
+
*/
|
|
160
|
+
export async function prepWorktree(owner, name, prNumber, sha) {
|
|
161
|
+
return withRepoLock(`${owner}/${name}`, async () => {
|
|
162
|
+
const dir = await ensureClone(owner, name);
|
|
163
|
+
await fetchPrHead(dir, prNumber, sha);
|
|
164
|
+
const worktreePath = await addWorktree(dir, sha);
|
|
165
|
+
return { repoCloneDir: dir, worktreePath };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** Tear down a per-run worktree under the per-repo lock (matches prepWorktree). */
|
|
169
|
+
export async function removeWorktreeLocked(owner, name, repoCloneDir, worktreePath) {
|
|
170
|
+
await withRepoLock(`${owner}/${name}`, () => removeWorktree(repoCloneDir, worktreePath));
|
|
171
|
+
}
|
|
132
172
|
/** Recursively sum file sizes and track the most-recent mtime under `dir`. */
|
|
133
173
|
function walkSize(dir) {
|
|
134
174
|
let bytes = 0;
|