pierre-review 0.1.16 → 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.
@@ -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: { model: { type: 'string', enum: MODELS } },
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: f.line,
266
- side: f.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 = {
@@ -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,2 @@
1
+ ALTER TABLE "claude_reviews" ADD COLUMN "review_mode" text;--> statement-breakpoint
2
+ ALTER TABLE "claude_reviews" ADD COLUMN "route_reason" jsonb;
@@ -0,0 +1 @@
1
+ ALTER TABLE "accounts" ADD COLUMN "last_active_at" timestamp with time zone;