pierre-review 0.1.46 → 0.1.47

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.
@@ -1,7 +1,7 @@
1
1
  import { config } from '../../config.js';
2
2
  import { getClaudeReviewById, getClaudeReviewContext, getFindingPostContext, getLatestClaudeReview, listAllClaudeReviews, listClaudeReviewHistory, } from '../../db/queries.js';
3
3
  import { markFindingPosted, markReviewPosted, updateFinding, updateReviewDraft, } from '../../review/persist.js';
4
- import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, } from '../../review/review-manager.js';
4
+ import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, subscribeReviewStream, } from '../../review/review-manager.js';
5
5
  import { detectClaudeAuth } from '../../review/auth.js';
6
6
  import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-settings.js';
7
7
  import { buildAnchorIndex, buildReview, fallbackAnchor, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, prLevelFindingBody, stripNoiseFromDiff, submitGithubComment, submitGithubIssueComment, submitGithubReview, } from '../../review/post-review.js';
@@ -169,7 +169,7 @@ export async function claudeReviewRoutes(app) {
169
169
  reply.status(202);
170
170
  return { reviewId: result.reviewId, status: 'queued' };
171
171
  });
172
- // Live progress poll target.
172
+ // Live progress poll target (kept as a fallback + for the initial snapshot).
173
173
  app.get('/api/prs/:id/claude-review/status', { schema: idParam }, async (req) => {
174
174
  const { id } = req.params;
175
175
  if (!config.claudeReviewEnabled) {
@@ -177,6 +177,62 @@ export async function claudeReviewRoutes(app) {
177
177
  }
178
178
  return await getReviewStatus(id);
179
179
  });
180
+ // Live progress STREAM (SSE) — pushes each phase/activity/usage change in real
181
+ // time and one terminal `done`, so the UI progress bar tracks the run without
182
+ // polling. Local-only (Claude Review is gated off in cloud). We `hijack()` the
183
+ // reply and own the raw socket for the connection's lifetime.
184
+ app.get('/api/prs/:id/claude-review/stream', { schema: idParam }, async (req, reply) => {
185
+ const { id } = req.params;
186
+ reply.hijack();
187
+ const raw = reply.raw;
188
+ raw.writeHead(200, {
189
+ 'Content-Type': 'text/event-stream',
190
+ 'Cache-Control': 'no-cache, no-transform',
191
+ Connection: 'keep-alive',
192
+ // Disable proxy buffering (nginx) so frames flush immediately.
193
+ 'X-Accel-Buffering': 'no',
194
+ });
195
+ const send = (data) => {
196
+ if (!raw.writableEnded)
197
+ raw.write(`data: ${JSON.stringify(data)}\n\n`);
198
+ };
199
+ if (!config.claudeReviewEnabled) {
200
+ send({ type: 'done', status: 'idle', reviewId: null });
201
+ raw.end();
202
+ return;
203
+ }
204
+ let closed = false;
205
+ let unsubscribe = null;
206
+ const heartbeat = setInterval(() => {
207
+ if (!raw.writableEnded)
208
+ raw.write(': hb\n\n');
209
+ }, 15000);
210
+ const cleanup = () => {
211
+ if (closed)
212
+ return;
213
+ closed = true;
214
+ clearInterval(heartbeat);
215
+ unsubscribe?.();
216
+ if (!raw.writableEnded)
217
+ raw.end();
218
+ };
219
+ // Subscribe BEFORE reading the snapshot so a terminal `done` emitted during
220
+ // setup can't slip through the gap (it'd be delivered to this callback). If
221
+ // the run already finished, the snapshot check below sends `done` itself.
222
+ unsubscribe = subscribeReviewStream(id, (e) => {
223
+ send(e);
224
+ if (e.type === 'done')
225
+ cleanup();
226
+ });
227
+ req.raw.on('close', cleanup);
228
+ // Initial snapshot so a client that connects mid-run gets current state at once.
229
+ const snap = await getReviewStatus(id);
230
+ send({ type: 'snapshot', ...snap });
231
+ if (snap.status !== 'running' && snap.status !== 'queued') {
232
+ send({ type: 'done', status: snap.status, reviewId: snap.reviewId });
233
+ cleanup();
234
+ }
235
+ });
180
236
  app.post('/api/prs/:id/claude-review/cancel', { schema: idParam }, async (req, reply) => {
181
237
  const { id } = req.params;
182
238
  if (!config.claudeReviewEnabled)
package/dist/config.js CHANGED
@@ -183,9 +183,10 @@ export const config = {
183
183
  reviewDiffOnlyEffort: effortFromEnv('REVIEW_DIFF_ONLY_EFFORT', 'low'),
184
184
  // At most one review per PR; this caps concurrent reviews across all PRs. Default
185
185
  // 4 so the user can bulk-review (extras queue, see review-manager). Raising this
186
- // also DISABLES the pasted-key override (which mutates process.env and is only
187
- // safe at concurrency 1 — see local-settings.applyUserAnthropicKey); ambient auth
188
- // is used instead. Set REVIEW_CONCURRENCY=1 to restore the pasted-key path.
186
+ // also DISABLES the per-run env auth adjustment (which mutates process.env and is
187
+ // only safe at concurrency 1 — see auth.applyClaudeReviewAuth); the raw ambient
188
+ // env is used instead. Set REVIEW_CONCURRENCY=1 to restore prefer-ambient + the
189
+ // API-key fallback.
189
190
  reviewConcurrency: intFromEnv('REVIEW_CONCURRENCY', 4),
190
191
  // Hard ceiling on QUEUED (not-yet-started) reviews, a runaway guard for bulk
191
192
  // triggering; further starts return 'busy' until the queue drains.
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  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
- import { applyUserAnthropicKey } from './local-settings.js';
7
+ import { applyClaudeReviewAuth } from './auth.js';
8
8
  import { cleanupCloneCache, prepWorktree, removeWorktreeLocked, } from './clone-manager.js';
9
9
  import { buildUserPrompt, isNoiseFile, systemPromptForMode, } from './prompt.js';
10
10
  import { buildAnchorIndex, capDiff, extractHunk, fetchPrDiff, isFindingAnchored, splitDiffByFile, stripNoiseFromDiff, } from './post-review.js';
@@ -169,9 +169,10 @@ export async function runReview(args) {
169
169
  omittedFiles = capped.omittedFiles;
170
170
  diffCapped = capped.capped;
171
171
  }
172
- // If the user supplied an Anthropic API key, override ambient Claude auth for
173
- // this run (restored in finally). Safe only at reviewConcurrency === 1.
174
- restoreEnv = applyUserAnthropicKey();
172
+ // Establish this run's auth: prefer the ambient Claude subscription, falling
173
+ // back to an API key only when no ambient session exists (restored in finally).
174
+ // Safe only at reviewConcurrency === 1.
175
+ restoreEnv = applyClaudeReviewAuth();
175
176
  // ---- run the agent ----
176
177
  let captured = null;
177
178
  const server = createSdkMcpServer({
@@ -1,24 +1,15 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { config } from '../config.js';
4
5
  import { getUserAnthropicKey } from './local-settings.js';
5
- export function detectClaudeAuth() {
6
- // A user-supplied key (stored locally) takes precedenceit overrides the
7
- // ambient auth at run time (see local-settings.applyUserAnthropicKey).
8
- if (getUserAnthropicKey()) {
9
- return { status: 'ok', method: 'api_key' };
10
- }
11
- const apiKey = process.env.ANTHROPIC_API_KEY;
12
- if (apiKey && apiKey.length > 0) {
13
- return { status: 'ok', method: 'api_key' };
14
- }
15
- const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
16
- if (oauthToken && oauthToken.length > 0) {
17
- return { status: 'ok', method: 'oauth_token' };
18
- }
19
- // Ambient logged-in Claude Code session: any of these on disk is good enough.
20
- // The last two are weak signals (config dir present, but maybe not signed in) —
21
- // a wrong guess just means the run fails with a clear SDK auth error.
6
+ // Is an ambient Claude SUBSCRIPTION available (OAuth token or a logged-in Claude
7
+ // Code session on disk)? Deliberately excludes API keys this answers "can Claude
8
+ // Review run on the subscription (no per-token billing)?", which drives the
9
+ // prefer-ambient policy below.
10
+ export function hasAmbientClaudeAuth() {
11
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN)
12
+ return true;
22
13
  const home = homedir();
23
14
  const ambientCandidates = [
24
15
  join(home, '.claude', '.credentials.json'),
@@ -26,12 +17,71 @@ export function detectClaudeAuth() {
26
17
  join(home, '.claude.json'),
27
18
  join(home, '.claude'),
28
19
  ];
29
- if (ambientCandidates.some((path) => existsSync(path))) {
20
+ return ambientCandidates.some((path) => existsSync(path));
21
+ }
22
+ export function detectClaudeAuth() {
23
+ // Claude Review PREFERS the ambient subscription (see applyClaudeReviewAuth), so
24
+ // report it first when present — even if an API key also exists.
25
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
26
+ return { status: 'ok', method: 'oauth_token' };
27
+ }
28
+ if (hasAmbientClaudeAuth()) {
30
29
  return { status: 'ok', method: 'ambient' };
31
30
  }
31
+ // No ambient → fall back to an API key (the user's local key, then the env key).
32
+ if (getUserAnthropicKey() || process.env.ANTHROPIC_API_KEY) {
33
+ return { status: 'ok', method: 'api_key' };
34
+ }
32
35
  return {
33
36
  status: 'none',
34
- message: 'No Claude authentication found. Set ANTHROPIC_API_KEY, or run `claude` once to sign in to an eligible Claude plan (Pro/Max/Team/Enterprise), then restart pierre-review.',
37
+ message: 'No Claude authentication found. Run `claude` once to sign in to an eligible Claude plan (Pro/Max/Team/Enterprise), or set an Anthropic API key, then restart pierre-review.',
35
38
  };
36
39
  }
40
+ /**
41
+ * Establish the auth for ONE Claude Review run by mutating process.env, returning a
42
+ * restore fn (always call it in a finally). Policy — Claude Review prefers the
43
+ * ambient subscription so the user's plan/usage credits cover it:
44
+ *
45
+ * • ambient available → STRIP any ANTHROPIC_API_KEY for the run so the Agent SDK
46
+ * authenticates via the subscription/OAuth (an API key would otherwise win and
47
+ * silently meter the run).
48
+ * • no ambient → fall back to an API key (the user's local key, else the env key).
49
+ *
50
+ * process.env is process-global, so this only mutates at reviewConcurrency === 1
51
+ * (the env-race guard); above that it no-ops and the raw ambient env is used. The
52
+ * Pro summary is unaffected either way — it passes its own key explicitly to the
53
+ * llm seam and never reads this env.
54
+ */
55
+ export function applyClaudeReviewAuth() {
56
+ if (config.reviewConcurrency !== 1)
57
+ return () => { };
58
+ const prevApiKey = process.env.ANTHROPIC_API_KEY;
59
+ const prevOauth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
60
+ const restore = () => {
61
+ if (prevApiKey === undefined)
62
+ delete process.env.ANTHROPIC_API_KEY;
63
+ else
64
+ process.env.ANTHROPIC_API_KEY = prevApiKey;
65
+ if (prevOauth === undefined)
66
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
67
+ else
68
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = prevOauth;
69
+ };
70
+ if (hasAmbientClaudeAuth()) {
71
+ // Prefer the subscription: hide any API key so the Agent SDK uses ambient auth.
72
+ if (process.env.ANTHROPIC_API_KEY === undefined)
73
+ return () => { };
74
+ delete process.env.ANTHROPIC_API_KEY;
75
+ return restore;
76
+ }
77
+ // No ambient → use an API key. A user-supplied local key wins over the env key.
78
+ const key = getUserAnthropicKey();
79
+ if (key) {
80
+ process.env.ANTHROPIC_API_KEY = key;
81
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
82
+ return restore;
83
+ }
84
+ // Nothing to change — leave any existing ANTHROPIC_API_KEY in place.
85
+ return () => { };
86
+ }
37
87
  //# sourceMappingURL=auth.js.map
@@ -1,14 +1,27 @@
1
- import { getUserAnthropicKey } from './local-settings.js';
1
+ // The cheap-tier ("Haiku") completion seam. Core-owned so the optional @pierre/pro
2
+ // plugin can do a single-shot LLM call (e.g. the per-repo digest) through ctx.llm
3
+ // WITHOUT adding its own Anthropic dependency. Non-agentic: one completion, no
4
+ // tools, no thinking.
5
+ //
6
+ // AUTH is CALLER-OWNED (this is deliberate — it keeps each feature's auth discrete):
7
+ // • `opts.apiKey` given → the raw, metered `@anthropic-ai/sdk` with THAT key. The
8
+ // key is passed EXPLICITLY and this seam never reads/writes process.env, so a
9
+ // summary key can never leak into (or force metering on) Claude Review, which
10
+ // resolves auth separately and prefers the ambient subscription.
11
+ // • no `apiKey` → the Claude Agent SDK's `query()` (the SAME runtime Claude Review
12
+ // uses), which resolves a CLAUDE_CODE_OAUTH_TOKEN / ambient logged-in session.
13
+ // So the Pro summary passes its own dedicated key (fast, metered) while an OSS/dev
14
+ // caller with no key still works via the ambient session.
15
+ //
16
+ // Both SDK imports are LAZY (inside the function) so merely importing this module
17
+ // needs nothing loaded — only an actual caller pays. In OSS mode nothing calls it.
2
18
  const DEFAULT_MODEL = 'claude-haiku-4-5';
3
19
  export async function cheapComplete(opts) {
4
20
  const model = opts.model ?? DEFAULT_MODEL;
5
- // A real API key (user-supplied local key wins, then ANTHROPIC_API_KEY) takes the
6
- // cheap, metered, exact-cost raw path.
7
- const apiKey = getUserAnthropicKey() ?? process.env.ANTHROPIC_API_KEY;
8
- if (apiKey)
9
- return rawComplete(apiKey, model, opts);
10
- // No explicit key → use the Agent SDK, which resolves a CLAUDE_CODE_OAUTH_TOKEN
11
- // or an ambient logged-in Claude session exactly as Claude Review does.
21
+ // An explicit key the cheap, metered, exact-cost raw path. No key → the ambient
22
+ // session via the Agent SDK. (No implicit env / Claude-Review-key fallback here.)
23
+ if (opts.apiKey)
24
+ return rawComplete(opts.apiKey, model, opts);
12
25
  return agentComplete(model, opts);
13
26
  }
14
27
  // ---- raw @anthropic-ai/sdk path (explicit API key) ----------------------------
@@ -40,32 +40,7 @@ export function setUserAnthropicKey(key) {
40
40
  delete settings.anthropicApiKey;
41
41
  write(settings);
42
42
  }
43
- /**
44
- * If the user supplied an Anthropic key, override the ambient Claude auth for the
45
- * duration of one review run by mutating process.env, returning a restore fn.
46
- *
47
- * process.env is process-global, so this is ONLY safe when at most one review
48
- * runs at a time — gated on reviewConcurrency === 1. With concurrency > 1 the
49
- * override is skipped (the ambient auth is used) to avoid a cross-run env race.
50
- * Always call the returned restore fn in a finally.
51
- */
52
- export function applyUserAnthropicKey() {
53
- const key = getUserAnthropicKey();
54
- if (!key || config.reviewConcurrency !== 1)
55
- return () => { };
56
- const prevApiKey = process.env.ANTHROPIC_API_KEY;
57
- const prevOauth = process.env.CLAUDE_CODE_OAUTH_TOKEN;
58
- process.env.ANTHROPIC_API_KEY = key;
59
- delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
60
- return () => {
61
- if (prevApiKey === undefined)
62
- delete process.env.ANTHROPIC_API_KEY;
63
- else
64
- process.env.ANTHROPIC_API_KEY = prevApiKey;
65
- if (prevOauth === undefined)
66
- delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
67
- else
68
- process.env.CLAUDE_CODE_OAUTH_TOKEN = prevOauth;
69
- };
70
- }
43
+ // NOTE: the per-run env override for Claude Review now lives in review/auth.ts as
44
+ // `applyClaudeReviewAuth` (it implements the prefer-ambient policy and needs the
45
+ // ambient-session probe). This module stays a pure key store.
71
46
  //# sourceMappingURL=local-settings.js.map
@@ -13,6 +13,43 @@ const claimed = new Set(); // prIds either running OR pending — the sync guard
13
13
  const reviewIdByPr = new Map();
14
14
  const progressByReview = new Map();
15
15
  const controllers = new Map();
16
+ // SSE fan-out (keyed by prId): the live-progress stream endpoint subscribes here,
17
+ // so each phase/activity/usage change is PUSHED to connected clients in real time
18
+ // instead of being polled. Empty in the common case (no open stream). See
19
+ // subscribeReviewStream + GET /api/prs/:id/claude-review/stream.
20
+ const streamSubs = new Map();
21
+ function emitReviewStream(prId, e) {
22
+ const subs = streamSubs.get(prId);
23
+ if (!subs)
24
+ return;
25
+ for (const cb of subs) {
26
+ try {
27
+ cb(e);
28
+ }
29
+ catch {
30
+ /* a broken subscriber must never break the run */
31
+ }
32
+ }
33
+ }
34
+ // Subscribe to a PR's live review stream. Returns an unsubscribe fn. The manager
35
+ // emits 'progress' events across the queued→running lifecycle and one terminal
36
+ // 'done' (with the persisted final status) when the run settles.
37
+ export function subscribeReviewStream(prId, cb) {
38
+ let set = streamSubs.get(prId);
39
+ if (!set) {
40
+ set = new Set();
41
+ streamSubs.set(prId, set);
42
+ }
43
+ set.add(cb);
44
+ return () => {
45
+ const s = streamSubs.get(prId);
46
+ if (!s)
47
+ return;
48
+ s.delete(cb);
49
+ if (s.size === 0)
50
+ streamSubs.delete(prId);
51
+ };
52
+ }
16
53
  export async function startReview(prId, model, requestedMode, log) {
17
54
  if (!config.claudeReviewEnabled)
18
55
  return { ok: false, reason: 'disabled' };
@@ -78,7 +115,14 @@ function launch(item) {
78
115
  controllers.set(reviewId, controller);
79
116
  // The diff is fetched (and the mode decided) before any clone, so the first real
80
117
  // phase is always 'fetching_diff' — not 'cloning' (which a diff-only run skips).
81
- progressByReview.set(reviewId, { phase: 'fetching_diff' });
118
+ const initial = { phase: 'fetching_diff' };
119
+ progressByReview.set(reviewId, initial);
120
+ emitReviewStream(prId, {
121
+ type: 'progress',
122
+ status: 'running',
123
+ reviewId,
124
+ progress: initial,
125
+ });
82
126
  void runReview({
83
127
  reviewId,
84
128
  prId,
@@ -94,7 +138,15 @@ function launch(item) {
94
138
  requestedMode: item.requestedMode,
95
139
  priorReviewContext: item.priorReviewContext,
96
140
  abortController: controller,
97
- onProgress: (p) => progressByReview.set(reviewId, p),
141
+ onProgress: (p) => {
142
+ progressByReview.set(reviewId, p);
143
+ emitReviewStream(prId, {
144
+ type: 'progress',
145
+ status: 'running',
146
+ reviewId,
147
+ progress: p,
148
+ });
149
+ },
98
150
  })
99
151
  .catch((err) => {
100
152
  item.log.error(`claude review pr ${prId} failed: ${err instanceof Error ? err.message : err}`);
@@ -105,6 +157,15 @@ function launch(item) {
105
157
  controllers.delete(reviewId);
106
158
  progressByReview.delete(reviewId);
107
159
  claimed.delete(prId);
160
+ // Push a terminal event to any open stream with the PERSISTED final status
161
+ // (succeeded / failed / cancelled), then start the next queued review.
162
+ void getLatestClaudeReview(prId, LOCAL_ACCOUNT_ID)
163
+ .then((latest) => emitReviewStream(prId, {
164
+ type: 'done',
165
+ status: latest?.status ?? 'failed',
166
+ reviewId,
167
+ }))
168
+ .catch(() => emitReviewStream(prId, { type: 'done', status: 'failed', reviewId }));
108
169
  pump(); // a slot freed — start the next queued review
109
170
  });
110
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
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",