pierre-review 0.1.22 → 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 +232 -1
- 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-DmAenjsb.js +0 -1371
- package/public/assets/index-Lropvevu.css +0 -10
|
@@ -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
|
@@ -383,6 +383,154 @@ export async function getOpenPrs(filters) {
|
|
|
383
383
|
return a.openedAt.localeCompare(b.openedAt); // oldest first
|
|
384
384
|
});
|
|
385
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
|
+
}
|
|
386
534
|
// ---- merge-rights inference ----
|
|
387
535
|
// Distinct users who have merged a PR INTO THE DEFAULT BRANCH per repo (across
|
|
388
536
|
// ALL synced history, not the timeline window). We treat "has merged into the
|
|
@@ -438,6 +586,37 @@ export async function markPrViewed(prId, accountId, sha) {
|
|
|
438
586
|
.execute();
|
|
439
587
|
return true;
|
|
440
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
|
+
}
|
|
441
620
|
// ---- my turn ----
|
|
442
621
|
// Does this (kind, refId) actually belong to the account? Guards the dismiss
|
|
443
622
|
// insert so a buggy/hostile client can't seed orphan dismissal rows for ids it
|
|
@@ -608,6 +787,7 @@ export async function getMyTurn(accountId) {
|
|
|
608
787
|
awaitingReview: [],
|
|
609
788
|
yourPrs: [],
|
|
610
789
|
threadsAwaiting: [],
|
|
790
|
+
claudeReviewsToAction: [],
|
|
611
791
|
users: [],
|
|
612
792
|
};
|
|
613
793
|
if (localUserId == null)
|
|
@@ -690,6 +870,10 @@ export async function getMyTurn(accountId) {
|
|
|
690
870
|
if (ta.lastReplyAuthorId != null)
|
|
691
871
|
referencedUsers.add(ta.lastReplyAuthorId);
|
|
692
872
|
}
|
|
873
|
+
// Completed-but-unactioned Claude reviews (local-only feature; empty otherwise).
|
|
874
|
+
const claudeReviewsToAction = config.claudeReviewEnabled
|
|
875
|
+
? await getUnactionedClaudeReviews(accountId)
|
|
876
|
+
: [];
|
|
693
877
|
const users = referencedUsers.size > 0
|
|
694
878
|
? (await db
|
|
695
879
|
.select()
|
|
@@ -697,7 +881,7 @@ export async function getMyTurn(accountId) {
|
|
|
697
881
|
.where(inArray(schema.users.id, [...referencedUsers]))
|
|
698
882
|
.execute()).map(mapUser)
|
|
699
883
|
: [];
|
|
700
|
-
return { awaitingReview, yourPrs, threadsAwaiting, users };
|
|
884
|
+
return { awaitingReview, yourPrs, threadsAwaiting, claudeReviewsToAction, users };
|
|
701
885
|
}
|
|
702
886
|
async function countOtherReviewers(prId, localUserId) {
|
|
703
887
|
const rows = await db
|
|
@@ -1267,6 +1451,53 @@ export async function listAllClaudeReviews(accountId) {
|
|
|
1267
1451
|
}
|
|
1268
1452
|
return items;
|
|
1269
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
|
+
}
|
|
1270
1501
|
export async function getFindingPostContext(findingId, accountId) {
|
|
1271
1502
|
const rows = await db
|
|
1272
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;
|
|
@@ -2,49 +2,73 @@ import { config } from '../config.js';
|
|
|
2
2
|
import { LOCAL_ACCOUNT_ID } from '../auth/account.js';
|
|
3
3
|
import { getLatestClaudeReview, getReviewPrContext } from '../db/queries.js';
|
|
4
4
|
import { runReview } from './agent.js';
|
|
5
|
-
import { insertQueuedReview, reconcileOrphanedReviews } from './persist.js';
|
|
6
|
-
// In-memory state (analogous to sync/sync-manager.ts). At most one review per PR
|
|
7
|
-
//
|
|
8
|
-
|
|
5
|
+
import { insertQueuedReview, markReviewCancelled, reconcileOrphanedReviews, } from './persist.js';
|
|
6
|
+
// In-memory state (analogous to sync/sync-manager.ts). At most one review per PR.
|
|
7
|
+
// `config.reviewConcurrency` bounds concurrent RUNNING reviews; extras wait in a
|
|
8
|
+
// FIFO `pending` queue and launch as slots free (so the user can bulk-trigger).
|
|
9
|
+
const running = new Set(); // prIds with a review actively running
|
|
10
|
+
const pending = []; // FIFO of queued reviews waiting for a slot
|
|
11
|
+
const claimed = new Set(); // prIds either running OR pending — the sync guard
|
|
9
12
|
const reviewIdByPr = new Map();
|
|
10
13
|
const progressByReview = new Map();
|
|
11
14
|
const controllers = new Map();
|
|
12
15
|
export async function startReview(prId, model, requestedMode, log) {
|
|
13
16
|
if (!config.claudeReviewEnabled)
|
|
14
17
|
return { ok: false, reason: 'disabled' };
|
|
15
|
-
|
|
18
|
+
// `claimed` covers BOTH running and queued, so a re-trigger of an in-flight or
|
|
19
|
+
// already-queued PR is rejected without a TOCTOU double-start.
|
|
20
|
+
if (claimed.has(prId))
|
|
16
21
|
return { ok: false, reason: 'already_running' };
|
|
17
|
-
|
|
22
|
+
// Runaway guard for bulk triggering: cap the queue depth.
|
|
23
|
+
if (pending.length >= config.reviewMaxQueued)
|
|
18
24
|
return { ok: false, reason: 'busy' };
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// startReview for the same prId sees `running.has(prId)` immediately (no
|
|
22
|
-
// TOCTOU double-start). Roll it back on any early-bail / insert failure below.
|
|
23
|
-
running.add(prId);
|
|
25
|
+
// Reserve SYNCHRONOUSLY before any await; roll back on early-bail / insert failure.
|
|
26
|
+
claimed.add(prId);
|
|
24
27
|
let reviewId;
|
|
25
28
|
let ctx;
|
|
26
29
|
try {
|
|
27
30
|
ctx = await getReviewPrContext(prId, LOCAL_ACCOUNT_ID);
|
|
28
31
|
if (!ctx) {
|
|
29
|
-
|
|
32
|
+
claimed.delete(prId);
|
|
30
33
|
return { ok: false, reason: 'not_found' };
|
|
31
34
|
}
|
|
32
35
|
if (!ctx.headSha) {
|
|
33
|
-
|
|
36
|
+
claimed.delete(prId);
|
|
34
37
|
return { ok: false, reason: 'no_head' };
|
|
35
38
|
}
|
|
36
39
|
reviewId = await insertQueuedReview(prId, ctx.headSha, model, LOCAL_ACCOUNT_ID);
|
|
37
40
|
}
|
|
38
41
|
catch (err) {
|
|
39
|
-
|
|
42
|
+
claimed.delete(prId);
|
|
40
43
|
throw err;
|
|
41
44
|
}
|
|
42
|
-
const
|
|
45
|
+
const item = {
|
|
46
|
+
reviewId,
|
|
47
|
+
prId,
|
|
48
|
+
model,
|
|
49
|
+
requestedMode,
|
|
50
|
+
headSha: ctx.headSha,
|
|
51
|
+
ctx,
|
|
52
|
+
log,
|
|
53
|
+
};
|
|
54
|
+
reviewIdByPr.set(prId, reviewId);
|
|
55
|
+
if (running.size < config.reviewConcurrency) {
|
|
56
|
+
launch(item);
|
|
57
|
+
return { ok: true, reviewId, queued: false };
|
|
58
|
+
}
|
|
59
|
+
// No slot — leave it queued (the row's persisted status is already 'queued'); it
|
|
60
|
+
// launches from pump() when a running review finishes.
|
|
61
|
+
pending.push(item);
|
|
62
|
+
return { ok: true, reviewId, queued: true };
|
|
63
|
+
}
|
|
64
|
+
function launch(item) {
|
|
65
|
+
const { reviewId, prId, ctx } = item;
|
|
66
|
+
running.add(prId);
|
|
43
67
|
reviewIdByPr.set(prId, reviewId);
|
|
44
68
|
const controller = new AbortController();
|
|
45
69
|
controllers.set(reviewId, controller);
|
|
46
|
-
// The diff is fetched (and the mode decided) before any clone
|
|
47
|
-
//
|
|
70
|
+
// The diff is fetched (and the mode decided) before any clone, so the first real
|
|
71
|
+
// phase is always 'fetching_diff' — not 'cloning' (which a diff-only run skips).
|
|
48
72
|
progressByReview.set(reviewId, { phase: 'fetching_diff' });
|
|
49
73
|
void runReview({
|
|
50
74
|
reviewId,
|
|
@@ -56,32 +80,50 @@ export async function startReview(prId, model, requestedMode, log) {
|
|
|
56
80
|
title: ctx.title,
|
|
57
81
|
body: ctx.body,
|
|
58
82
|
baseRefName: ctx.baseRefName,
|
|
59
|
-
headSha,
|
|
60
|
-
model,
|
|
61
|
-
requestedMode,
|
|
83
|
+
headSha: item.headSha,
|
|
84
|
+
model: item.model,
|
|
85
|
+
requestedMode: item.requestedMode,
|
|
62
86
|
abortController: controller,
|
|
63
87
|
onProgress: (p) => progressByReview.set(reviewId, p),
|
|
64
88
|
})
|
|
65
89
|
.catch((err) => {
|
|
66
|
-
log.error(`claude review pr ${prId} failed: ${err instanceof Error ? err.message : err}`);
|
|
90
|
+
item.log.error(`claude review pr ${prId} failed: ${err instanceof Error ? err.message : err}`);
|
|
67
91
|
})
|
|
68
92
|
.finally(() => {
|
|
69
93
|
running.delete(prId);
|
|
70
94
|
reviewIdByPr.delete(prId);
|
|
71
95
|
controllers.delete(reviewId);
|
|
72
96
|
progressByReview.delete(reviewId);
|
|
97
|
+
claimed.delete(prId);
|
|
98
|
+
pump(); // a slot freed — start the next queued review
|
|
73
99
|
});
|
|
74
|
-
|
|
100
|
+
}
|
|
101
|
+
// Start as many queued reviews as there are free slots (FIFO).
|
|
102
|
+
function pump() {
|
|
103
|
+
while (running.size < config.reviewConcurrency && pending.length > 0) {
|
|
104
|
+
launch(pending.shift());
|
|
105
|
+
}
|
|
75
106
|
}
|
|
76
107
|
export function isReviewRunning(prId) {
|
|
77
108
|
return running.has(prId);
|
|
78
109
|
}
|
|
79
110
|
export function requestReviewCancel(prId) {
|
|
111
|
+
// Running → abort the SDK run (runReview's finally cleans up + pumps the queue).
|
|
80
112
|
const reviewId = reviewIdByPr.get(prId);
|
|
81
|
-
if (reviewId
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
113
|
+
if (running.has(prId) && reviewId != null) {
|
|
114
|
+
controllers.get(reviewId)?.abort();
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
// Queued (not yet started) → drop it from the queue and mark the row cancelled.
|
|
118
|
+
const idx = pending.findIndex((p) => p.prId === prId);
|
|
119
|
+
if (idx >= 0) {
|
|
120
|
+
const item = pending.splice(idx, 1)[0];
|
|
121
|
+
claimed.delete(prId);
|
|
122
|
+
reviewIdByPr.delete(prId);
|
|
123
|
+
void markReviewCancelled(item.reviewId).catch(() => { });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
85
127
|
}
|
|
86
128
|
// Live status when a review is in flight; otherwise the latest persisted run's
|
|
87
129
|
// status (or 'idle' if the PR was never reviewed).
|
|
@@ -94,13 +136,16 @@ export async function getReviewStatus(prId) {
|
|
|
94
136
|
progress: progressByReview.get(reviewId) ?? null,
|
|
95
137
|
};
|
|
96
138
|
}
|
|
139
|
+
const queued = pending.find((p) => p.prId === prId);
|
|
140
|
+
if (queued)
|
|
141
|
+
return { status: 'queued', reviewId: queued.reviewId, progress: null };
|
|
97
142
|
const latest = await getLatestClaudeReview(prId, LOCAL_ACCOUNT_ID);
|
|
98
143
|
if (!latest)
|
|
99
144
|
return { status: 'idle', reviewId: null, progress: null };
|
|
100
145
|
return { status: latest.status, reviewId: latest.id, progress: null };
|
|
101
146
|
}
|
|
102
|
-
// All reviews currently in flight (for the global progress banner),
|
|
103
|
-
// their PR coordinates.
|
|
147
|
+
// All reviews currently in flight OR queued (for the global progress banner),
|
|
148
|
+
// joined with their PR coordinates.
|
|
104
149
|
export async function listActiveReviews() {
|
|
105
150
|
const out = [];
|
|
106
151
|
for (const prId of running) {
|
|
@@ -120,6 +165,18 @@ export async function listActiveReviews() {
|
|
|
120
165
|
phase: progressByReview.get(reviewId)?.phase ?? null,
|
|
121
166
|
});
|
|
122
167
|
}
|
|
168
|
+
// Queued items use their stored ctx (no extra fetch); the banner shows them too.
|
|
169
|
+
for (const item of pending) {
|
|
170
|
+
out.push({
|
|
171
|
+
reviewId: item.reviewId,
|
|
172
|
+
prId: item.prId,
|
|
173
|
+
repoFullName: item.ctx.repoFullName,
|
|
174
|
+
prNumber: item.ctx.number,
|
|
175
|
+
prTitle: item.ctx.title,
|
|
176
|
+
status: 'queued',
|
|
177
|
+
phase: null,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
123
180
|
return out;
|
|
124
181
|
}
|
|
125
182
|
// Heal runs left 'running'/'queued' by a crash (our status is persisted).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pierre-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Dashboard for tracking your team's GitHub PR activity across repos — local (SQLite + gh) or self-hosted multi-tenant cloud (Postgres + GitHub App).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Alex Wakeman",
|