pierre-review 0.1.17 → 0.1.18
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/plugins/auth.js +7 -1
- package/dist/api/routes/claude-review.js +34 -14
- package/dist/auth/account.js +33 -0
- package/dist/config.js +29 -0
- package/dist/db/migrations/0013_review_routing.sql +9 -0
- package/dist/db/migrations/0014_account_last_active.sql +6 -0
- package/dist/db/migrations/meta/_journal.json +14 -0
- package/dist/db/migrations-pg/0003_melted_secret_warriors.sql +2 -0
- package/dist/db/migrations-pg/0004_numerous_triton.sql +1 -0
- package/dist/db/migrations-pg/meta/0003_snapshot.json +2182 -0
- package/dist/db/migrations-pg/meta/0004_snapshot.json +2188 -0
- package/dist/db/migrations-pg/meta/_journal.json +14 -0
- package/dist/db/queries.js +3 -0
- package/dist/db/schema.pg.js +9 -0
- package/dist/db/schema.sqlite.js +12 -0
- package/dist/review/agent.js +105 -21
- package/dist/review/persist.js +11 -0
- package/dist/review/post-review.js +53 -4
- package/dist/review/prompt.js +67 -28
- package/dist/review/review-manager.js +5 -2
- package/dist/review/routing.js +164 -0
- package/dist/sync/sync-manager.js +18 -3
- package/package.json +1 -1
- package/public/assets/{index-BV9zXu0d.js → index-CAhglHOt.js} +90 -90
- package/public/assets/{index-B2JVv5CH.css → index-DaezRnbu.css} +1 -1
- package/public/index.html +2 -2
package/dist/api/plugins/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { config } from '../../config.js';
|
|
3
|
-
import { getAccountById, getLocalAccountCached, LOCAL_ACCOUNT_ID, } from '../../auth/account.js';
|
|
3
|
+
import { getAccountById, getLocalAccountCached, stampAccountActive, LOCAL_ACCOUNT_ID, } from '../../auth/account.js';
|
|
4
4
|
// Attaches `request.account` to every request. Registered once on the root app
|
|
5
5
|
// (so it covers all routes, including static/health which simply ignore it).
|
|
6
6
|
//
|
|
@@ -14,6 +14,12 @@ export function registerAccountContext(app) {
|
|
|
14
14
|
if (config.isCloud) {
|
|
15
15
|
const accountId = readSessionAccountId(req);
|
|
16
16
|
req.account = accountId != null ? await getAccountById(accountId) : null;
|
|
17
|
+
// A request from a signed-in account on a real data route means that tenant
|
|
18
|
+
// has a loaded frontend — stamp activity (throttled) so the scheduler keeps
|
|
19
|
+
// syncing their repos. Skips static/landing assets and the health/auth probes.
|
|
20
|
+
if (req.account && req.url.startsWith('/api/')) {
|
|
21
|
+
stampAccountActive(req.account.id);
|
|
22
|
+
}
|
|
17
23
|
}
|
|
18
24
|
else {
|
|
19
25
|
req.account =
|
|
@@ -4,11 +4,12 @@ import { markFindingPosted, markReviewPosted, updateFinding, updateReviewDraft,
|
|
|
4
4
|
import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, } from '../../review/review-manager.js';
|
|
5
5
|
import { detectClaudeAuth } from '../../review/auth.js';
|
|
6
6
|
import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-settings.js';
|
|
7
|
-
import { buildReview, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, stripNoiseFromDiff, submitGithubComment, submitGithubReview, } from '../../review/post-review.js';
|
|
7
|
+
import { buildAnchorIndex, buildReview, fallbackAnchor, 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
10
|
const MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6'];
|
|
11
11
|
const VERDICTS = ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'];
|
|
12
|
+
const REVIEW_MODES = ['auto', 'diff_only', 'worktree'];
|
|
12
13
|
const idParam = {
|
|
13
14
|
params: {
|
|
14
15
|
type: 'object',
|
|
@@ -36,7 +37,11 @@ const generateSchema = {
|
|
|
36
37
|
type: 'object',
|
|
37
38
|
required: ['model'],
|
|
38
39
|
additionalProperties: false,
|
|
39
|
-
properties: {
|
|
40
|
+
properties: {
|
|
41
|
+
model: { type: 'string', enum: MODELS },
|
|
42
|
+
// Review depth. Omitted defaults to 'auto' (the router decides).
|
|
43
|
+
mode: { type: 'string', enum: REVIEW_MODES },
|
|
44
|
+
},
|
|
40
45
|
},
|
|
41
46
|
};
|
|
42
47
|
const updateReviewSchema = {
|
|
@@ -121,7 +126,7 @@ export async function claudeReviewRoutes(app) {
|
|
|
121
126
|
// Kick off a run. 404 disabled/unknown PR, 400 no auth / no head, 409 busy.
|
|
122
127
|
app.post('/api/prs/:id/claude-review', { schema: generateSchema }, async (req, reply) => {
|
|
123
128
|
const { id } = req.params;
|
|
124
|
-
const { model } = req.body;
|
|
129
|
+
const { model, mode } = req.body;
|
|
125
130
|
if (!config.claudeReviewEnabled)
|
|
126
131
|
return featureOff(reply);
|
|
127
132
|
const auth = detectClaudeAuth();
|
|
@@ -129,7 +134,7 @@ export async function claudeReviewRoutes(app) {
|
|
|
129
134
|
reply.status(400);
|
|
130
135
|
return { error: 'NoClaudeAuth', message: auth.message };
|
|
131
136
|
}
|
|
132
|
-
const result = await startReview(id, model, app.log);
|
|
137
|
+
const result = await startReview(id, model, mode ?? 'auto', app.log);
|
|
133
138
|
if (!result.ok) {
|
|
134
139
|
if (result.reason === 'not_found') {
|
|
135
140
|
reply.status(404);
|
|
@@ -240,13 +245,6 @@ export async function claudeReviewRoutes(app) {
|
|
|
240
245
|
return { error: 'NotFound', message: `Finding ${findingId} not found` };
|
|
241
246
|
}
|
|
242
247
|
const f = ctx.finding;
|
|
243
|
-
if (f.line == null || !f.anchored) {
|
|
244
|
-
reply.status(400);
|
|
245
|
-
return {
|
|
246
|
-
error: 'NotAnchored',
|
|
247
|
-
message: "This finding isn't anchored to a diff line, so it can't post inline.",
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
248
|
try {
|
|
251
249
|
const currentHead = await fetchCurrentHeadSha(ctx.owner, ctx.name, ctx.prNumber);
|
|
252
250
|
if (currentHead !== ctx.reviewHeadSha) {
|
|
@@ -256,19 +254,41 @@ export async function claudeReviewRoutes(app) {
|
|
|
256
254
|
message: 'The PR head has moved since this review. Re-review before posting.',
|
|
257
255
|
};
|
|
258
256
|
}
|
|
257
|
+
// Anchor: the finding's own line if addable, else the file's first change
|
|
258
|
+
// (added preferred). A finding whose file isn't in the diff can't inline.
|
|
259
|
+
let line;
|
|
260
|
+
let side = f.side;
|
|
261
|
+
let onFallback = false;
|
|
262
|
+
if (f.line != null && f.anchored) {
|
|
263
|
+
line = f.line;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const { diff } = stripNoiseFromDiff(await fetchPrDiff(ctx.owner, ctx.name, ctx.prNumber), isNoiseFile);
|
|
267
|
+
const fb = fallbackAnchor(buildAnchorIndex(diff), f.path);
|
|
268
|
+
if (!fb) {
|
|
269
|
+
reply.status(400);
|
|
270
|
+
return {
|
|
271
|
+
error: 'NotAnchored',
|
|
272
|
+
message: "This finding's file has no changes in the PR diff, so it can't post inline.",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
line = fb.line;
|
|
276
|
+
side = fb.side;
|
|
277
|
+
onFallback = true;
|
|
278
|
+
}
|
|
259
279
|
const { commentId } = await submitGithubComment({
|
|
260
280
|
owner: ctx.owner,
|
|
261
281
|
name: ctx.name,
|
|
262
282
|
prNumber: ctx.prNumber,
|
|
263
283
|
commitId: ctx.reviewHeadSha,
|
|
264
284
|
path: f.path,
|
|
265
|
-
line
|
|
266
|
-
side
|
|
285
|
+
line,
|
|
286
|
+
side,
|
|
267
287
|
body: findingCommentBody({
|
|
268
288
|
body: f.body,
|
|
269
289
|
editedBody: f.editedBody,
|
|
270
290
|
suggestion: f.suggestion,
|
|
271
|
-
}),
|
|
291
|
+
}, onFallback ? { fallbackNote: true } : undefined),
|
|
272
292
|
});
|
|
273
293
|
await markFindingPosted(findingId, commentId);
|
|
274
294
|
const result = {
|
package/dist/auth/account.js
CHANGED
|
@@ -110,6 +110,9 @@ export async function upsertCloudAccount(input) {
|
|
|
110
110
|
accessTokenEnc: input.accessTokenEnc,
|
|
111
111
|
isLocal: false,
|
|
112
112
|
lastLoginAt: now,
|
|
113
|
+
// Seed activity at sign-in so the user's repos are eligible on the very next
|
|
114
|
+
// scheduled sync tick (don't wait for the first heartbeat).
|
|
115
|
+
lastActiveAt: now,
|
|
113
116
|
})
|
|
114
117
|
.onConflictDoUpdate({
|
|
115
118
|
target: accounts.githubUserId,
|
|
@@ -118,12 +121,42 @@ export async function upsertCloudAccount(input) {
|
|
|
118
121
|
avatarUrl: input.avatarUrl,
|
|
119
122
|
accessTokenEnc: input.accessTokenEnc,
|
|
120
123
|
lastLoginAt: now,
|
|
124
|
+
lastActiveAt: now,
|
|
121
125
|
},
|
|
122
126
|
})
|
|
123
127
|
.returning()
|
|
124
128
|
.execute();
|
|
125
129
|
return rowToAccount(rows[0]);
|
|
126
130
|
}
|
|
131
|
+
// In-memory throttle for the activity stamp below: accountId → last-stamp epoch ms.
|
|
132
|
+
// A loaded SPA is chatty (timeline, polls, heartbeat), so we only touch the DB at
|
|
133
|
+
// most once per window per account.
|
|
134
|
+
const lastActiveStampMs = new Map();
|
|
135
|
+
const ACTIVE_STAMP_THROTTLE_MS = 60_000;
|
|
136
|
+
/**
|
|
137
|
+
* Record that a loaded frontend for this account just talked to the backend
|
|
138
|
+
* (drives the scheduler's "only sync accounts with an open tab" gate). Throttled
|
|
139
|
+
* in-memory and fire-and-forget — a dropped stamp only means a slightly staler
|
|
140
|
+
* signal, and the next request re-stamps. Cloud-only in practice (the caller gates
|
|
141
|
+
* on isCloud; local has a single always-synced account).
|
|
142
|
+
*/
|
|
143
|
+
export function stampAccountActive(accountId) {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const last = lastActiveStampMs.get(accountId) ?? 0;
|
|
146
|
+
if (now - last < ACTIVE_STAMP_THROTTLE_MS)
|
|
147
|
+
return;
|
|
148
|
+
lastActiveStampMs.set(accountId, now);
|
|
149
|
+
const { accounts } = schema;
|
|
150
|
+
void db
|
|
151
|
+
.update(accounts)
|
|
152
|
+
.set({ lastActiveAt: new Date() })
|
|
153
|
+
.where(eq(accounts.id, accountId))
|
|
154
|
+
.execute()
|
|
155
|
+
.catch(() => {
|
|
156
|
+
// Best-effort: roll back the throttle so the next request retries the stamp.
|
|
157
|
+
lastActiveStampMs.delete(accountId);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
127
160
|
/** Load an account by id. */
|
|
128
161
|
export async function getAccountById(id) {
|
|
129
162
|
const { accounts } = schema;
|
package/dist/config.js
CHANGED
|
@@ -81,6 +81,12 @@ export const config = {
|
|
|
81
81
|
commitFileConcurrency: intFromEnv('COMMIT_FILE_CONCURRENCY', 10),
|
|
82
82
|
syncCron: process.env.SYNC_CRON ?? '*/5 * * * *',
|
|
83
83
|
syncOverlapMinutes: intFromEnv('SYNC_OVERLAP_MINUTES', 20),
|
|
84
|
+
// CLOUD ONLY: the scheduled sync skips any account whose loaded frontend hasn't
|
|
85
|
+
// been seen within this many minutes (accounts.lastActiveAt), so a tenant with no
|
|
86
|
+
// open tab is not re-synced every 5 min. Comfortably exceeds the cron period so a
|
|
87
|
+
// user active a few minutes ago isn't dropped between ticks. Local mode ignores
|
|
88
|
+
// this entirely (one always-on account). SYNC_ACTIVE_WINDOW_MINUTES overrides.
|
|
89
|
+
syncActiveWindowMinutes: intFromEnv('SYNC_ACTIVE_WINDOW_MINUTES', 15),
|
|
84
90
|
stallThresholdDays: intFromEnv('STALL_THRESHOLD_DAYS', 3),
|
|
85
91
|
// Disable the periodic scheduler (used by scripts/tests).
|
|
86
92
|
disableScheduler: process.env.DISABLE_SCHEDULER === 'true',
|
|
@@ -117,8 +123,31 @@ export const config = {
|
|
|
117
123
|
// reviews need far fewer turns than the old default; 30 is still generous.
|
|
118
124
|
reviewMaxTurns: intFromEnv('REVIEW_MAX_TURNS', 30),
|
|
119
125
|
reviewBudgetUsd: floatFromEnv('REVIEW_BUDGET_USD', 1.0),
|
|
126
|
+
// Turn cap for a diff-only run. These are TOOL-LESS (only submit_review), so they
|
|
127
|
+
// should finish in ~2 turns; a tight cap is a cheap runaway guard.
|
|
128
|
+
reviewDiffOnlyMaxTurns: intFromEnv('REVIEW_DIFF_ONLY_MAX_TURNS', 6),
|
|
120
129
|
// At most one review per PR; this caps concurrent reviews across all PRs.
|
|
121
130
|
reviewConcurrency: intFromEnv('REVIEW_CONCURRENCY', 1),
|
|
131
|
+
// ---- Claude Review routing (diff-only vs worktree) — THE THRESHOLDS ----
|
|
132
|
+
// The deterministic pre-check (review/routing.ts) decides, BEFORE the agent runs,
|
|
133
|
+
// whether a PR can be reviewed from its diff alone (fast, tool-less, no worktree)
|
|
134
|
+
// or needs the full cloned worktree as explorable context. A change stays
|
|
135
|
+
// 'diff_only' only if it is within EVERY ceiling below AND touches no exported/
|
|
136
|
+
// public API; otherwise it routes to 'worktree'. The numbers are deliberately
|
|
137
|
+
// CONSERVATIVE — any tie routes to worktree, since over-reviewing is safe but
|
|
138
|
+
// under-reviewing a risky change is not. Every decision input is logged on the
|
|
139
|
+
// run (claude_reviews.route_reason) so these can be tuned against the agent's own
|
|
140
|
+
// scopeUsed self-report. This is the single place to review/adjust the thresholds.
|
|
141
|
+
reviewRouting: {
|
|
142
|
+
// Max (non-noise) files changed for a diff-only review.
|
|
143
|
+
maxFiles: intFromEnv('REVIEW_ROUTE_MAX_FILES', 5),
|
|
144
|
+
// Max added + deleted lines (non-noise files) for a diff-only review.
|
|
145
|
+
maxLines: intFromEnv('REVIEW_ROUTE_MAX_LINES', 150),
|
|
146
|
+
// Max distinct directories touched for a diff-only review.
|
|
147
|
+
maxDirs: intFromEnv('REVIEW_ROUTE_MAX_DIRS', 2),
|
|
148
|
+
// Max distinct top-level subsystems (first path segment) for a diff-only review.
|
|
149
|
+
maxSubsystems: intFromEnv('REVIEW_ROUTE_MAX_SUBSYSTEMS', 1),
|
|
150
|
+
},
|
|
122
151
|
};
|
|
123
152
|
// Fail loud at startup if a required cloud var is missing — mirrors the
|
|
124
153
|
// gh-auth loud failure. Called from index.ts only when deploymentMode==='cloud'.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Claude Review routing (additive). Records the deterministic skip/diff_only/
|
|
2
|
+
-- worktree decision (review_mode) and its decision inputs (route_reason JSON) made
|
|
3
|
+
-- BEFORE the agent runs — so the diff-only fast path can be enforced and the
|
|
4
|
+
-- thresholds calibrated against the agent's own scopeUsed self-report. Both columns
|
|
5
|
+
-- are nullable; existing rows stay NULL until re-reviewed. SQLite-only: Claude
|
|
6
|
+
-- Review is force-disabled in cloud, so the Postgres claude_reviews table is never
|
|
7
|
+
-- populated (its baseline is regenerated separately via db:generate:pg).
|
|
8
|
+
ALTER TABLE `claude_reviews` ADD `review_mode` text;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE `claude_reviews` ADD `route_reason` text;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Activity gating for the scheduler (additive). `last_active_at` records the last
|
|
2
|
+
-- time a loaded frontend for this account talked to the backend; the periodic sync
|
|
3
|
+
-- skips accounts not active within config.syncActiveWindowMinutes so a tenant with no
|
|
4
|
+
-- open tab stops being re-synced. Nullable; existing rows stay NULL (eligible only
|
|
5
|
+
-- once they next show activity). Cloud-relevant; local has one always-on account.
|
|
6
|
+
ALTER TABLE `accounts` ADD `last_active_at` integer;
|
|
@@ -92,6 +92,20 @@
|
|
|
92
92
|
"when": 1780800000003,
|
|
93
93
|
"tag": "0012_findings_included_default",
|
|
94
94
|
"breakpoints": true
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"idx": 13,
|
|
98
|
+
"version": "6",
|
|
99
|
+
"when": 1780800000004,
|
|
100
|
+
"tag": "0013_review_routing",
|
|
101
|
+
"breakpoints": true
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
"idx": 14,
|
|
105
|
+
"version": "6",
|
|
106
|
+
"when": 1780800000005,
|
|
107
|
+
"tag": "0014_account_last_active",
|
|
108
|
+
"breakpoints": true
|
|
95
109
|
}
|
|
96
110
|
]
|
|
97
111
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "accounts" ADD COLUMN "last_active_at" timestamp with time zone;
|