pierre-review 0.1.13 → 0.1.15

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,5 +1,5 @@
1
1
  import { config } from '../../config.js';
2
- import { getClaudeReviewById, getClaudeReviewContext, getFindingPostContext, getLatestClaudeReview, listClaudeReviewHistory, } from '../../db/queries.js';
2
+ import { getClaudeReviewById, getClaudeReviewContext, getFindingPostContext, getLatestClaudeReview, listAllClaudeReviews, listClaudeReviewHistory, } from '../../db/queries.js';
3
3
  import { markFindingPosted, markReviewPosted, updateFinding, updateReviewDraft, } from '../../review/persist.js';
4
4
  import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, } from '../../review/review-manager.js';
5
5
  import { detectClaudeAuth } from '../../review/auth.js';
@@ -7,7 +7,7 @@ import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-se
7
7
  import { buildReview, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, stripNoiseFromDiff, submitGithubComment, submitGithubReview, } from '../../review/post-review.js';
8
8
  import { isNoiseFile } from '../../review/prompt.js';
9
9
  import { accountIdOf } from '../plugins/auth.js';
10
- const MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
10
+ const MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6'];
11
11
  const VERDICTS = ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'];
12
12
  const idParam = {
13
13
  params: {
@@ -175,6 +175,14 @@ export async function claudeReviewRoutes(app) {
175
175
  }
176
176
  return { status: 'cancelling' };
177
177
  });
178
+ // Cross-PR list of prior Claude reviews (one entry per PR = its most-recent
179
+ // succeeded run, within the timeline window). Static path — registered before
180
+ // the /:reviewId param route so Fastify never mis-routes it.
181
+ app.get('/api/claude-reviews', async (req) => {
182
+ if (!config.claudeReviewEnabled)
183
+ return { reviews: [] };
184
+ return { reviews: await listAllClaudeReviews(accountIdOf(req)) };
185
+ });
178
186
  // All in-flight reviews (global progress banner). Static path — Fastify
179
187
  // prioritises it over the /:reviewId param route.
180
188
  app.get('/api/claude-reviews/active', async () => {
@@ -66,7 +66,7 @@ export async function timelineRoutes(app) {
66
66
  userIds: parseIntList(q.userIds),
67
67
  types: parseTypes(q.types),
68
68
  statuses: parseStatuses(q.statuses),
69
- excludeBots: q.excludeBots !== 'false',
69
+ excludeBots: q.excludeBots === 'true',
70
70
  excludeStale: q.excludeStale === 'true',
71
71
  };
72
72
  return getTimeline(filters);
package/dist/app.js CHANGED
@@ -30,6 +30,39 @@ export async function buildApp() {
30
30
  : { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss' } },
31
31
  },
32
32
  });
33
+ // Cloud only: canonicalise the host and pin HTTPS. Local mode runs on
34
+ // http://127.0.0.1, where HSTS would wrongly pin localhost to HTTPS and a www
35
+ // redirect is meaningless — so both are cloud-gated. Registered first so it runs
36
+ // before everything else (the redirect short-circuits before CORS/routing).
37
+ if (config.isCloud) {
38
+ let canonicalHost = '';
39
+ try {
40
+ // hostname (not host) so a configured port never enters the comparison.
41
+ canonicalHost = new URL(config.appBaseUrl).hostname.toLowerCase();
42
+ }
43
+ catch {
44
+ canonicalHost = '';
45
+ }
46
+ app.addHook('onRequest', async (req, reply) => {
47
+ // HSTS: keep browsers on HTTPS for this domain. Honored only over HTTPS
48
+ // (Railway terminates TLS); ignored on plain HTTP. `includeSubDomains` also
49
+ // covers www. No `preload` — it's hard to undo. HSTS_MAX_AGE=0 disables it.
50
+ if (config.hstsMaxAge > 0) {
51
+ reply.header('Strict-Transport-Security', `max-age=${config.hstsMaxAge}; includeSubDomains`);
52
+ }
53
+ // Canonical host: 301 www.<apex> → <apex> so the OAuth round-trip and the
54
+ // session cookie stay on a single origin (and crawlers see one canonical
55
+ // URL). The HSTS header set above still rides on the redirect response.
56
+ if (canonicalHost) {
57
+ // Strip any :port from the Host header before comparing hostnames.
58
+ const host = (req.headers.host ?? '').toLowerCase().split(':')[0] ?? '';
59
+ if (host !== canonicalHost &&
60
+ host.replace(/^www\./, '') === canonicalHost) {
61
+ return reply.redirect(`${config.appBaseUrl}${req.url}`, 301);
62
+ }
63
+ }
64
+ });
65
+ }
33
66
  // CORS: in cloud mode lock to the app's own origin and allow credentials so the
34
67
  // session cookie rides along; local mode reflects any origin (dev convenience).
35
68
  await app.register(cors, {
package/dist/config.js CHANGED
@@ -96,6 +96,11 @@ export const config = {
96
96
  sessionSecret: process.env.SESSION_SECRET ?? '',
97
97
  // 32-byte (64-hex) key for AES-256-GCM encryption of stored access tokens.
98
98
  encryptionKey: process.env.ENCRYPTION_KEY ?? '',
99
+ // HSTS max-age (seconds) for the cloud public origin. Sent only in cloud mode
100
+ // and honored only over HTTPS (Railway terminates TLS). Default 1 year. Set
101
+ // HSTS_MAX_AGE=0 as a kill switch. `preload` is intentionally NOT sent — it is
102
+ // hard to undo; opt in manually once the domain is proven.
103
+ hstsMaxAge: intFromEnv('HSTS_MAX_AGE', 31536000),
99
104
  // ---- Claude Review (agentic PR review; opt-in, LOCAL-ONLY) ----
100
105
  // OFF by default: the feature spends real money / Agent-SDK credits per run.
101
106
  // Enable with ENABLE_CLAUDE_REVIEW=true. FORCE-DISABLED in cloud mode (it
@@ -108,8 +113,9 @@ export const config = {
108
113
  cloneCacheMaxBytes: intFromEnv('CLONE_CACHE_MAX_BYTES', 2 * 1024 * 1024 * 1024),
109
114
  // Default model for the picker; per-run model still overrides on the request.
110
115
  defaultReviewModel: process.env.DEFAULT_REVIEW_MODEL ?? 'claude-sonnet-4-6',
111
- // Per-run caps (cost/disk/time runaway guards).
112
- reviewMaxTurns: intFromEnv('REVIEW_MAX_TURNS', 40),
116
+ // Per-run caps (cost/disk/time runaway guards). The diff is inlined in full, so
117
+ // reviews need far fewer turns than the old default; 30 is still generous.
118
+ reviewMaxTurns: intFromEnv('REVIEW_MAX_TURNS', 30),
113
119
  reviewBudgetUsd: floatFromEnv('REVIEW_BUDGET_USD', 1.0),
114
120
  // At most one review per PR; this caps concurrent reviews across all PRs.
115
121
  reviewConcurrency: intFromEnv('REVIEW_CONCURRENCY', 1),
@@ -1028,6 +1028,60 @@ export async function listClaudeReviewHistory(prId, accountId) {
1028
1028
  finishedAt: iso(r.finishedAt),
1029
1029
  }));
1030
1030
  }
1031
+ // Cross-PR list of prior Claude reviews: ONE entry per PR = that PR's most-recent
1032
+ // SUCCEEDED run, accountId-scoped, restricted to PRs still within the timeline
1033
+ // window (open, or last touched within `backfillDays`), newest-first by finish
1034
+ // time. Used by GET /api/claude-reviews to populate the "prior reviews" view.
1035
+ export async function listAllClaudeReviews(accountId) {
1036
+ // Same window cutoff getTimeline uses for its overlap predicate (now − backfillDays).
1037
+ const cutoff = new Date(Date.now() - config.backfillDays * 24 * 60 * 60 * 1000);
1038
+ const rows = await db
1039
+ .select({
1040
+ reviewId: claudeReviews.id,
1041
+ prId: claudeReviews.prId,
1042
+ owner: repos.owner,
1043
+ name: repos.name,
1044
+ prNumber: pullRequests.number,
1045
+ prTitle: pullRequests.title,
1046
+ prState: pullRequests.state,
1047
+ summary: claudeReviews.summary,
1048
+ verdict: claudeReviews.verdict,
1049
+ headSha: claudeReviews.headSha,
1050
+ status: claudeReviews.status,
1051
+ createdAt: claudeReviews.createdAt,
1052
+ finishedAt: claudeReviews.finishedAt,
1053
+ })
1054
+ .from(claudeReviews)
1055
+ .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
1056
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
1057
+ .where(and(eq(repos.accountId, accountId), eq(claudeReviews.status, 'succeeded'), or(eq(pullRequests.state, 'open'), gte(sql `coalesce(${pullRequests.mergedAt}, ${pullRequests.closedAt}, ${pullRequests.openedAt})`, tsBound(cutoff)))))
1058
+ .orderBy(desc(claudeReviews.finishedAt), desc(claudeReviews.createdAt))
1059
+ .execute();
1060
+ // Keep the first (most-recent) succeeded run per PR. N is small (single local
1061
+ // user), so a JS pass is simpler and portable across both dialects.
1062
+ const seen = new Set();
1063
+ const items = [];
1064
+ for (const r of rows) {
1065
+ if (seen.has(r.prId))
1066
+ continue;
1067
+ seen.add(r.prId);
1068
+ items.push({
1069
+ reviewId: r.reviewId,
1070
+ prId: r.prId,
1071
+ repoFullName: `${r.owner}/${r.name}`,
1072
+ prNumber: r.prNumber,
1073
+ prTitle: r.prTitle,
1074
+ prState: r.prState,
1075
+ summary: r.summary,
1076
+ verdict: r.verdict,
1077
+ headSha: r.headSha,
1078
+ status: r.status,
1079
+ createdAt: r.createdAt.toISOString(),
1080
+ finishedAt: iso(r.finishedAt),
1081
+ });
1082
+ }
1083
+ return items;
1084
+ }
1031
1085
  export async function getFindingPostContext(findingId, accountId) {
1032
1086
  const rows = await db
1033
1087
  .select({
@@ -311,7 +311,7 @@ export const claudeReviews = pgTable('claude_reviews', {
311
311
  enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'],
312
312
  }).notNull(),
313
313
  model: text('model', {
314
- enum: ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'],
314
+ enum: ['claude-opus-4-8', 'claude-sonnet-4-6'],
315
315
  }).notNull(),
316
316
  scope: text('scope', { enum: ['diff_only', 'worktree'] }),
317
317
  summary: text('summary'),
@@ -339,7 +339,7 @@ export const claudeReviews = sqliteTable('claude_reviews', {
339
339
  enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'],
340
340
  }).notNull(),
341
341
  model: text('model', {
342
- enum: ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'],
342
+ enum: ['claude-opus-4-8', 'claude-sonnet-4-6'],
343
343
  }).notNull(),
344
344
  // Null until the agent decides whether it explored the worktree.
345
345
  scope: text('scope', { enum: ['diff_only', 'worktree'] }),
@@ -25,6 +25,8 @@ const DISALLOWED_TOOLS = [
25
25
  'Bash(git commit *)',
26
26
  'Bash(git config *)',
27
27
  ];
28
+ // How many recent-activity lines to keep in the live progress ring buffer.
29
+ const ACTIVITY_LOG_CAP = 25;
28
30
  // Run a review end-to-end and persist its result. Owns status transitions:
29
31
  // running → succeeded/failed/cancelled. Never throws to the caller for an
30
32
  // expected failure (it records it); rethrows only truly unexpected errors so the
@@ -88,11 +90,30 @@ export async function runReview(args) {
88
90
  abortController,
89
91
  },
90
92
  });
91
- let announcedReviewing = false;
93
+ // Rolling, newest-last log of what the agent is doing right now, surfaced via
94
+ // onProgress (rides the /status poll). Live-only — never persisted.
95
+ const activity = [];
96
+ const pushActivity = (line) => {
97
+ const trimmed = line.trim();
98
+ if (!trimmed)
99
+ return;
100
+ activity.push(trimmed);
101
+ if (activity.length > ACTIVITY_LOG_CAP)
102
+ activity.shift();
103
+ };
92
104
  for await (const message of q) {
93
- if (message.type === 'assistant' && !announcedReviewing) {
94
- announcedReviewing = true;
95
- onProgress({ phase: 'reviewing' });
105
+ if (message.type === 'assistant') {
106
+ // Derive short, human-readable lines from the assistant turn's content
107
+ // blocks. Defensive throughout: content may be missing and tool input
108
+ // shapes vary — a malformed block must never abort the review.
109
+ try {
110
+ for (const line of describeAssistantBlocks(message))
111
+ pushActivity(line);
112
+ }
113
+ catch {
114
+ /* never let progress derivation break the run */
115
+ }
116
+ onProgress({ phase: 'reviewing', recentActivity: [...activity] });
96
117
  }
97
118
  else if (message.type === 'result') {
98
119
  result = message;
@@ -164,7 +185,17 @@ export async function runReview(args) {
164
185
  if (repoCloneDir && worktreePath) {
165
186
  await removeWorktree(repoCloneDir, worktreePath).catch(() => { });
166
187
  }
167
- cleanupCloneCache();
188
+ // LRU eviction does a full recursive dir walk; keep it off the run-teardown
189
+ // critical path. Defer it (fire-and-forget) so the review result returns
190
+ // promptly. cleanupCloneCache is itself best-effort and never throws.
191
+ setImmediate(() => {
192
+ try {
193
+ cleanupCloneCache();
194
+ }
195
+ catch {
196
+ /* advisory cleanup — never surface */
197
+ }
198
+ });
168
199
  }
169
200
  }
170
201
  function errorMessage(err) {
@@ -172,4 +203,74 @@ function errorMessage(err) {
172
203
  return err.message;
173
204
  return String(err);
174
205
  }
206
+ const TEXT_SNIPPET_CAP = 120;
207
+ const BASH_CMD_CAP = 80;
208
+ /**
209
+ * Turn one assistant message into a few short, human-readable progress lines:
210
+ * a label per tool_use block (e.g. `Read src/foo.ts`, `Grep "TODO"`, `Bash npm
211
+ * test`) plus a clipped snippet of any assistant text. Defensive throughout —
212
+ * the SDK content/tool-input shapes vary, so every access is guarded and this
213
+ * never throws.
214
+ */
215
+ function describeAssistantBlocks(message) {
216
+ const lines = [];
217
+ const content = message?.message
218
+ ?.content;
219
+ if (!Array.isArray(content))
220
+ return lines;
221
+ for (const raw of content) {
222
+ if (!raw || typeof raw !== 'object')
223
+ continue;
224
+ const block = raw;
225
+ if (block.type === 'tool_use') {
226
+ const name = typeof block.name === 'string' ? block.name : 'Tool';
227
+ const input = block.input && typeof block.input === 'object'
228
+ ? block.input
229
+ : {};
230
+ lines.push(labelToolUse(name, input));
231
+ }
232
+ else if (block.type === 'text' && typeof block.text === 'string') {
233
+ const snippet = clip(block.text.replace(/\s+/g, ' ').trim(), TEXT_SNIPPET_CAP);
234
+ if (snippet)
235
+ lines.push(snippet);
236
+ }
237
+ }
238
+ return lines;
239
+ }
240
+ /** Build a short label for a tool call from its name + a truncated first arg. */
241
+ function labelToolUse(name, input) {
242
+ const str = (v) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : null;
243
+ switch (name) {
244
+ case 'Read': {
245
+ const p = str(input.file_path) ?? str(input.path);
246
+ return p ? `Read ${p}` : 'Read …';
247
+ }
248
+ case 'Glob': {
249
+ const p = str(input.pattern);
250
+ return p ? `Glob ${p}` : 'Glob …';
251
+ }
252
+ case 'Grep': {
253
+ const p = str(input.pattern);
254
+ return p ? `Grep "${clip(p, BASH_CMD_CAP)}"` : 'Grep …';
255
+ }
256
+ case 'Bash': {
257
+ const c = str(input.command);
258
+ return c ? `Bash ${clip(c, BASH_CMD_CAP)}` : 'Bash …';
259
+ }
260
+ case 'mcp__review__submit_review':
261
+ return 'Submitting review…';
262
+ default: {
263
+ // Generic: name + a truncated first string-valued arg, if any.
264
+ for (const key of Object.keys(input)) {
265
+ const v = str(input[key]);
266
+ if (v)
267
+ return `${name} ${clip(v, BASH_CMD_CAP)}`;
268
+ }
269
+ return `${name} …`;
270
+ }
271
+ }
272
+ }
273
+ function clip(s, max) {
274
+ return s.length > max ? `${s.slice(0, max)}…` : s;
275
+ }
175
276
  //# sourceMappingURL=agent.js.map
@@ -46,11 +46,15 @@ export async function ensureClone(owner, name) {
46
46
  * Fetch a PR's head ref into the clone's object store so its head commit
47
47
  * (`sha`) becomes resolvable for a worktree checkout. The current PR head
48
48
  * commit equals `pull/<n>/head`, so fetching that ref is sufficient.
49
+ *
50
+ * Fast path: if `sha` is already present in the local object store (a prior
51
+ * review of the same head left it there), skip the network fetch entirely —
52
+ * it's the dominant per-review network cost and is fully redundant when the
53
+ * head hasn't moved.
49
54
  */
50
55
  export async function fetchPrHead(repoCloneDir, prNumber, sha) {
51
- // `sha` is documented in the signature but the head ref fetch is what makes
52
- // it resolvable; nothing else is needed here.
53
- void sha;
56
+ if (await hasCommit(repoCloneDir, sha))
57
+ return;
54
58
  await git([
55
59
  '-C',
56
60
  repoCloneDir,
@@ -61,6 +65,21 @@ export async function fetchPrHead(repoCloneDir, prNumber, sha) {
61
65
  `pull/${prNumber}/head`,
62
66
  ]);
63
67
  }
68
+ /** True if `sha` resolves to a commit object already in the local store. */
69
+ async function hasCommit(repoCloneDir, sha) {
70
+ if (!sha)
71
+ return false;
72
+ try {
73
+ await execFileAsync('git', ['-C', repoCloneDir, 'cat-file', '-e', `${sha}^{commit}`], {
74
+ timeout: 10_000,
75
+ maxBuffer: 1024 * 1024,
76
+ });
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
64
83
  /**
65
84
  * Create an ephemeral detached worktree at `<clone>/.worktrees/<sha>` checked
66
85
  * out at `sha`, and return its absolute path. If a stale worktree at that path
@@ -113,6 +113,7 @@ Decide how far to look using this heuristic:
113
113
  When in doubt about whether something exported is used elsewhere, look — a wrong signature change with stale callers is a blocker, not a nit.
114
114
 
115
115
  # What you're given
116
+ - The COMPLETE unified diff for this PR is inlined in the user message. Use it directly — do NOT run \`git diff\`/\`git show\` to re-derive the change, and do NOT Read files just to see the diff. Explore the worktree only for context the diff doesn't show (callers, dependents, type definitions, surrounding code).
116
117
  - Noise files (lockfiles and generated/vendored artifacts) have ALREADY been stripped from the diff. Do not ask for them and do not flag their absence.
117
118
  - Line numbers for anchoring findings refer to the NEW-file (RIGHT) side of the diff unless you are pointing at a removed/old line.
118
119
 
@@ -194,7 +195,7 @@ export function buildUserPrompt(input) {
194
195
  }
195
196
  lines.push('## Diff');
196
197
  lines.push('');
197
- lines.push('This unified diff has already had noise files removed. Line numbers you cite for anchoring refer to the NEW-file (RIGHT) side unless you are pointing at a deleted line (LEFT).');
198
+ lines.push('The COMPLETE unified diff for this PR is provided in full below (noise files already removed). Use it directly — do NOT run `git diff` / `git show` to re-derive it, and do NOT Read files just to see the diff. Explore the worktree only for context the diff does not show: callers, dependents, type definitions, and surrounding code. Line numbers you cite for anchoring refer to the NEW-file (RIGHT) side unless you are pointing at a deleted line (LEFT).');
198
199
  lines.push('');
199
200
  lines.push('```diff');
200
201
  lines.push(diff);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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",