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.
@@ -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
- : 'Another review is in progress; try again shortly.',
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
@@ -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,
@@ -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
- reviewConcurrency: intFromEnv('REVIEW_CONCURRENCY', 1),
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)
@@ -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({
@@ -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 { addWorktree, cleanupCloneCache, ensureClone, fetchPrHead, removeWorktree, } from './clone-manager.js';
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
- repoCloneDir = await ensureClone(args.owner, args.name);
94
- await fetchPrHead(repoCloneDir, args.prNumber, args.headSha);
95
- worktreePath = await addWorktree(repoCloneDir, args.headSha);
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 removeWorktree(repoCloneDir, worktreePath).catch(() => { });
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
- // a global gate (config.reviewConcurrency) bounds concurrent reviews overall.
8
- const running = new Set(); // prIds with a review in flight
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
- if (running.has(prId))
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
- if (running.size >= config.reviewConcurrency) {
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
- // Reserve the slot SYNCHRONOUSLY before any await, so a second concurrent
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
- running.delete(prId);
32
+ claimed.delete(prId);
30
33
  return { ok: false, reason: 'not_found' };
31
34
  }
32
35
  if (!ctx.headSha) {
33
- running.delete(prId);
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
- running.delete(prId);
42
+ claimed.delete(prId);
40
43
  throw err;
41
44
  }
42
- const headSha = ctx.headSha;
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 now, so the first
47
- // real phase is always 'fetching_diff' — not 'cloning' (which a diff-only run skips).
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
- return { ok: true, reviewId };
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 == null)
82
- return false;
83
- controllers.get(reviewId)?.abort();
84
- return true;
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), joined with
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.22",
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",