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.
@@ -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.21",
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",