pierre-review 0.1.12 → 0.1.14
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 +10 -2
- package/dist/api/routes/timeline.js +1 -1
- package/dist/config.js +3 -2
- package/dist/db/queries.js +54 -0
- package/dist/db/schema.pg.js +1 -1
- package/dist/db/schema.sqlite.js +1 -1
- package/dist/review/agent.js +106 -5
- package/dist/review/clone-manager.js +22 -3
- package/dist/review/prompt.js +2 -1
- package/package.json +1 -1
- package/public/assets/index-Bfko6Dwb.js +1371 -0
- package/public/assets/index-DqMYNa1t.css +10 -0
- package/public/index.html +2 -2
- package/public-landing/assets/{index-C9adkFFb.js → index-C0SBF1kW.js} +9 -9
- package/public-landing/assets/{index-BtpOo-9R.css → index-a50qd0Kt.css} +1 -1
- package/public-landing/index.html +2 -2
- package/public-landing/shots/claude-review.png +0 -0
- package/public-landing/shots/pr-detail.png +0 -0
- package/public-landing/shots/timeline.png +0 -0
- package/public/assets/index-C3UqK6P1.js +0 -1371
- package/public/assets/index-xfGfsqU7.css +0 -10
|
@@ -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'
|
|
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
|
|
69
|
+
excludeBots: q.excludeBots === 'true',
|
|
70
70
|
excludeStale: q.excludeStale === 'true',
|
|
71
71
|
};
|
|
72
72
|
return getTimeline(filters);
|
package/dist/config.js
CHANGED
|
@@ -108,8 +108,9 @@ export const config = {
|
|
|
108
108
|
cloneCacheMaxBytes: intFromEnv('CLONE_CACHE_MAX_BYTES', 2 * 1024 * 1024 * 1024),
|
|
109
109
|
// Default model for the picker; per-run model still overrides on the request.
|
|
110
110
|
defaultReviewModel: process.env.DEFAULT_REVIEW_MODEL ?? 'claude-sonnet-4-6',
|
|
111
|
-
// Per-run caps (cost/disk/time runaway guards).
|
|
112
|
-
|
|
111
|
+
// Per-run caps (cost/disk/time runaway guards). The diff is inlined in full, so
|
|
112
|
+
// reviews need far fewer turns than the old default; 30 is still generous.
|
|
113
|
+
reviewMaxTurns: intFromEnv('REVIEW_MAX_TURNS', 30),
|
|
113
114
|
reviewBudgetUsd: floatFromEnv('REVIEW_BUDGET_USD', 1.0),
|
|
114
115
|
// At most one review per PR; this caps concurrent reviews across all PRs.
|
|
115
116
|
reviewConcurrency: intFromEnv('REVIEW_CONCURRENCY', 1),
|
package/dist/db/queries.js
CHANGED
|
@@ -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({
|
package/dist/db/schema.pg.js
CHANGED
|
@@ -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'
|
|
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'),
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -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'
|
|
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'] }),
|
package/dist/review/agent.js
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
package/dist/review/prompt.js
CHANGED
|
@@ -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('
|
|
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.
|
|
3
|
+
"version": "0.1.14",
|
|
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",
|