pierre-review 0.1.2 → 0.1.5

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.
Files changed (61) hide show
  1. package/dist/api/plugins/auth.js +103 -0
  2. package/dist/api/plugins/error-handler.js +28 -13
  3. package/dist/api/routes/auth.js +106 -0
  4. package/dist/api/routes/claude-review.js +349 -0
  5. package/dist/api/routes/me.js +10 -6
  6. package/dist/api/routes/mergers.js +2 -1
  7. package/dist/api/routes/open-prs.js +3 -1
  8. package/dist/api/routes/prs.js +4 -3
  9. package/dist/api/routes/repos.js +26 -15
  10. package/dist/api/routes/threads.js +2 -1
  11. package/dist/api/routes/timeline.js +2 -0
  12. package/dist/api/routes/users.js +1 -1
  13. package/dist/app.js +58 -8
  14. package/dist/auth/account.js +190 -0
  15. package/dist/auth/crypto.js +38 -0
  16. package/dist/cli.js +57 -27
  17. package/dist/config.js +76 -3
  18. package/dist/db/cleanup.js +17 -12
  19. package/dist/db/client.js +82 -9
  20. package/dist/db/migrate.js +5 -3
  21. package/dist/db/migrations/0006_cute_violations.sql +45 -0
  22. package/dist/db/migrations/0007_dark_anthem.sql +2 -0
  23. package/dist/db/migrations/0008_multitenant_accounts.sql +44 -0
  24. package/dist/db/migrations/meta/0006_snapshot.json +1745 -0
  25. package/dist/db/migrations/meta/0007_snapshot.json +1759 -0
  26. package/dist/db/migrations/meta/_journal.json +21 -0
  27. package/dist/db/migrations-pg/0000_tired_alex_power.sql +268 -0
  28. package/dist/db/migrations-pg/meta/0000_snapshot.json +2137 -0
  29. package/dist/db/migrations-pg/meta/_journal.json +13 -0
  30. package/dist/db/queries.js +423 -150
  31. package/dist/db/run-migrations.js +21 -7
  32. package/dist/db/schema.pg.js +364 -0
  33. package/dist/db/{schema.js → schema.sqlite.js} +181 -27
  34. package/dist/db/triage.js +13 -13
  35. package/dist/github/client.js +37 -16
  36. package/dist/index.js +23 -11
  37. package/dist/review/agent.js +175 -0
  38. package/dist/review/auth.js +37 -0
  39. package/dist/review/clone-manager.js +202 -0
  40. package/dist/review/local-settings.js +71 -0
  41. package/dist/review/persist.js +162 -0
  42. package/dist/review/post-review.js +277 -0
  43. package/dist/review/prompt.js +206 -0
  44. package/dist/review/review-manager.js +128 -0
  45. package/dist/review/schema.js +21 -0
  46. package/dist/sync/commit-files.js +8 -7
  47. package/dist/sync/sync-manager.js +56 -20
  48. package/dist/sync/sync-repo.js +14 -10
  49. package/dist/sync/upsert.js +64 -53
  50. package/package.json +11 -3
  51. package/public/assets/index-C6IVunNm.js +1368 -0
  52. package/public/assets/index-D3qeT16k.css +10 -0
  53. package/public/index.html +2 -2
  54. package/public-landing/assets/index-BtpOo-9R.css +1 -0
  55. package/public-landing/assets/index-DyEMCLrl.js +40 -0
  56. package/public-landing/index.html +73 -0
  57. package/public-landing/shots/pr-detail.png +0 -0
  58. package/public-landing/shots/timeline.png +0 -0
  59. package/dist/github/local-user.js +0 -92
  60. package/public/assets/index-CclNaSg7.css +0 -10
  61. package/public/assets/index-DzmZQCT9.js +0 -1360
@@ -0,0 +1,103 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { config } from '../../config.js';
3
+ import { getAccountById, getLocalAccountCached, LOCAL_ACCOUNT_ID, } from '../../auth/account.js';
4
+ // Attaches `request.account` to every request. Registered once on the root app
5
+ // (so it covers all routes, including static/health which simply ignore it).
6
+ //
7
+ // Local mode: always the synthesized local account (id 1).
8
+ // Cloud mode: resolved from the sealed session cookie (set up in registerCloudAuth,
9
+ // Phase 1). Unauthenticated requests get `null`; the requireAuth preHandler
10
+ // then 401s the data routes.
11
+ export function registerAccountContext(app) {
12
+ app.decorateRequest('account', null);
13
+ app.addHook('onRequest', async (req) => {
14
+ if (config.isCloud) {
15
+ const accountId = readSessionAccountId(req);
16
+ req.account = accountId != null ? await getAccountById(accountId) : null;
17
+ }
18
+ else {
19
+ req.account =
20
+ getLocalAccountCached() ?? {
21
+ id: LOCAL_ACCOUNT_ID,
22
+ githubUserId: '',
23
+ githubLogin: '',
24
+ avatarUrl: null,
25
+ isLocal: true,
26
+ };
27
+ }
28
+ });
29
+ }
30
+ // Reads {accountId} from the sealed session cookie. @fastify/secure-session is
31
+ // only registered in cloud mode (Phase 1); guarded so this is safe pre-registration.
32
+ function readSessionAccountId(req) {
33
+ const session = req
34
+ .session;
35
+ if (!session)
36
+ return null;
37
+ const raw = session.get('accountId');
38
+ return typeof raw === 'number' ? raw : null;
39
+ }
40
+ // Cloud only: register the cookie + sealed-session plugins. MUST run before
41
+ // registerAccountContext so the session cookie is parsed by the time the
42
+ // account-context onRequest hook reads it. The session key is derived from
43
+ // SESSION_SECRET (sha256 → 32 bytes, the size secure-session needs).
44
+ export async function registerSession(app) {
45
+ const cookie = await import('@fastify/cookie');
46
+ const secureSession = await import('@fastify/secure-session');
47
+ await app.register(cookie.default, { secret: config.sessionSecret });
48
+ const key = createHash('sha256').update(config.sessionSecret).digest();
49
+ await app.register(secureSession.default, {
50
+ key,
51
+ cookieName: 'pierre_session',
52
+ cookie: {
53
+ path: '/',
54
+ httpOnly: true,
55
+ sameSite: 'lax',
56
+ secure: config.appBaseUrl.startsWith('https://'),
57
+ maxAge: 30 * 24 * 60 * 60, // 30 days
58
+ },
59
+ });
60
+ }
61
+ // Cloud only: 401 any unauthenticated /api data route. Skips /api/health and
62
+ // /api/auth/* (sign-in itself) and all non-/api requests (the SPA + landing are
63
+ // served openly; the frontend gate handles the signed-out UI). MUST be registered
64
+ // AFTER registerAccountContext so req.account is already resolved.
65
+ export function registerAuthGate(app) {
66
+ app.addHook('onRequest', async (req, reply) => {
67
+ const path = req.url.split('?')[0] ?? req.url;
68
+ if (!path.startsWith('/api/'))
69
+ return;
70
+ if (path === '/api/health')
71
+ return;
72
+ if (path.startsWith('/api/auth/'))
73
+ return;
74
+ if (!req.account) {
75
+ await reply.code(401).send({
76
+ error: 'Unauthorized',
77
+ message: 'Sign in with GitHub to continue.',
78
+ });
79
+ }
80
+ });
81
+ }
82
+ // preHandler that rejects unauthenticated requests in cloud mode. A no-op in
83
+ // local mode (the account is always present). Kept for any route that wants an
84
+ // explicit per-route guard in addition to the global gate above.
85
+ export async function requireAuth(req, reply) {
86
+ if (!config.isCloud)
87
+ return;
88
+ if (!req.account) {
89
+ await reply
90
+ .code(401)
91
+ .send({ error: 'Unauthorized', message: 'Sign in with GitHub to continue.' });
92
+ }
93
+ }
94
+ // The request's account id, for scoping queries. Always present in local mode;
95
+ // in cloud mode requireAuth has already guaranteed it on data routes.
96
+ export function accountIdOf(req) {
97
+ if (req.account)
98
+ return req.account.id;
99
+ if (!config.isCloud)
100
+ return LOCAL_ACCOUNT_ID;
101
+ throw new Error('no account on request (requireAuth missing?)');
102
+ }
103
+ //# sourceMappingURL=auth.js.map
@@ -1,11 +1,9 @@
1
- // Centralised error handling: validation errors -> 400, anything with an
2
- // explicit statusCode is honoured, everything else -> 500.
3
- //
4
- // `spaFallback` is set in single-process production mode (when the built SPA is
5
- // served alongside the API). Fastify allows only one not-found handler per
6
- // context, so the SPA fallback lives here rather than as a second
7
- // setNotFoundHandler in app.ts.
8
- export function registerErrorHandler(app, spaFallback = false) {
1
+ // Centralised error handling + the single not-found handler (Fastify allows one
2
+ // per context). The not-found handler doubles as the SPA/landing router:
3
+ // /api/* unknown → JSON 404 (both modes)
4
+ // /app, /app/* (GET) → the timeline SPA index (client routing / reload)
5
+ // /, other non-/api (GET) cloud: the landing page; local: 302 → /app
6
+ export function registerErrorHandler(app, opts) {
9
7
  app.setErrorHandler((err, req, reply) => {
10
8
  if (err.validation) {
11
9
  reply.status(400).send({
@@ -25,11 +23,28 @@ export function registerErrorHandler(app, spaFallback = false) {
25
23
  });
26
24
  });
27
25
  app.setNotFoundHandler((req, reply) => {
28
- // SPA fallback: any non-/api GET that didn't match a static asset returns
29
- // index.html so client-side routes work on reload. Unknown /api routes still
30
- // return JSON 404 (below). `reply.sendFile` is decorated by @fastify/static.
31
- if (spaFallback && req.method === 'GET' && !req.url.startsWith('/api')) {
32
- return reply.sendFile('index.html');
26
+ const path = req.url.split('?')[0] ?? req.url;
27
+ // Unknown API routes always return a JSON 404 (never an HTML page).
28
+ if (path.startsWith('/api')) {
29
+ return reply.status(404).send({
30
+ error: 'NotFound',
31
+ message: `Route ${req.method} ${req.url} not found`,
32
+ });
33
+ }
34
+ if (req.method === 'GET') {
35
+ // The SPA lives under /app — serve its index for any unmatched /app path so
36
+ // deep-links and reloads work.
37
+ if (opts.serveSpa && (path === '/app' || path.startsWith('/app/'))) {
38
+ return reply.sendFile('index.html', opts.publicDir);
39
+ }
40
+ // Root + other non-/api paths: cloud serves the public landing page; local
41
+ // sends the user straight into the app (never shows a landing).
42
+ if (opts.serveLanding) {
43
+ return reply.sendFile('index.html', opts.publicLandingDir);
44
+ }
45
+ if (opts.serveSpa) {
46
+ return reply.redirect('/app/');
47
+ }
33
48
  }
34
49
  return reply.status(404).send({
35
50
  error: 'NotFound',
@@ -0,0 +1,106 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { config } from '../../config.js';
3
+ import { encryptToken } from '../../auth/crypto.js';
4
+ import { upsertCloudAccount } from '../../auth/account.js';
5
+ export async function authRoutes(app) {
6
+ const redirectUri = `${config.appBaseUrl}/api/auth/callback`;
7
+ const secureCookie = config.appBaseUrl.startsWith('https://');
8
+ // 302 → GitHub authorize, with a CSRF state in a short-lived signed cookie.
9
+ app.get('/api/auth/login', async (_req, reply) => {
10
+ const state = randomBytes(16).toString('hex');
11
+ reply.setCookie('pierre_oauth_state', state, {
12
+ path: '/',
13
+ httpOnly: true,
14
+ sameSite: 'lax',
15
+ secure: secureCookie,
16
+ signed: true,
17
+ maxAge: 600,
18
+ });
19
+ const params = new URLSearchParams({
20
+ client_id: config.githubAppClientId,
21
+ redirect_uri: redirectUri,
22
+ state,
23
+ });
24
+ return reply.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`);
25
+ });
26
+ // Verify state → exchange code for a token → fetch the user → upsert account →
27
+ // set session → 302 to the app.
28
+ app.get('/api/auth/callback', async (req, reply) => {
29
+ const { code, state } = req.query;
30
+ const rawCookie = req.cookies.pierre_oauth_state;
31
+ const unsigned = rawCookie
32
+ ? req.unsignCookie(rawCookie)
33
+ : { valid: false, value: null };
34
+ reply.clearCookie('pierre_oauth_state', { path: '/' });
35
+ if (!code || !state || !unsigned.valid || unsigned.value !== state) {
36
+ return reply
37
+ .code(400)
38
+ .send({ error: 'BadRequest', message: 'Invalid or expired OAuth state.' });
39
+ }
40
+ // Exchange the code for a user access token.
41
+ let token;
42
+ try {
43
+ const res = await fetch('https://github.com/login/oauth/access_token', {
44
+ method: 'POST',
45
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
46
+ body: JSON.stringify({
47
+ client_id: config.githubAppClientId,
48
+ client_secret: config.githubAppClientSecret,
49
+ code,
50
+ redirect_uri: redirectUri,
51
+ }),
52
+ });
53
+ const json = (await res.json());
54
+ if (!json.access_token) {
55
+ return reply.code(401).send({
56
+ error: 'Unauthorized',
57
+ message: `OAuth token exchange failed: ${json.error_description ?? json.error ?? 'no token'}`,
58
+ });
59
+ }
60
+ token = json.access_token;
61
+ }
62
+ catch (err) {
63
+ return reply.code(502).send({
64
+ error: 'GitHubError',
65
+ message: `OAuth exchange request failed: ${err instanceof Error ? err.message : err}`,
66
+ });
67
+ }
68
+ // Identify the user.
69
+ let user;
70
+ try {
71
+ const res = await fetch('https://api.github.com/user', {
72
+ headers: {
73
+ authorization: `token ${token}`,
74
+ accept: 'application/vnd.github+json',
75
+ 'x-github-api-version': '2022-11-28',
76
+ },
77
+ });
78
+ if (!res.ok) {
79
+ return reply
80
+ .code(401)
81
+ .send({ error: 'Unauthorized', message: 'Failed to fetch GitHub user.' });
82
+ }
83
+ user = (await res.json());
84
+ }
85
+ catch (err) {
86
+ return reply.code(502).send({
87
+ error: 'GitHubError',
88
+ message: `Fetching GitHub user failed: ${err instanceof Error ? err.message : err}`,
89
+ });
90
+ }
91
+ const account = await upsertCloudAccount({
92
+ githubUserId: user.node_id,
93
+ githubLogin: user.login,
94
+ avatarUrl: user.avatar_url,
95
+ accessTokenEnc: encryptToken(token),
96
+ });
97
+ req.session.set('accountId', account.id);
98
+ return reply.redirect('/app');
99
+ });
100
+ // Clear the session.
101
+ app.post('/api/auth/logout', async (req, reply) => {
102
+ req.session.delete();
103
+ return reply.code(204).send();
104
+ });
105
+ }
106
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1,349 @@
1
+ import { config } from '../../config.js';
2
+ import { getClaudeReviewById, getClaudeReviewContext, getFindingPostContext, getLatestClaudeReview, listClaudeReviewHistory, } from '../../db/queries.js';
3
+ import { markFindingPosted, markReviewPosted, updateFinding, updateReviewDraft, } from '../../review/persist.js';
4
+ import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, } from '../../review/review-manager.js';
5
+ import { detectClaudeAuth } from '../../review/auth.js';
6
+ import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-settings.js';
7
+ import { buildReview, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, stripNoiseFromDiff, submitGithubComment, submitGithubReview, } from '../../review/post-review.js';
8
+ import { isNoiseFile } from '../../review/prompt.js';
9
+ import { accountIdOf } from '../plugins/auth.js';
10
+ const MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
11
+ const VERDICTS = ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'];
12
+ const idParam = {
13
+ params: {
14
+ type: 'object',
15
+ required: ['id'],
16
+ properties: { id: { type: 'integer' } },
17
+ },
18
+ };
19
+ const reviewIdParam = {
20
+ params: {
21
+ type: 'object',
22
+ required: ['reviewId'],
23
+ properties: { reviewId: { type: 'integer' } },
24
+ },
25
+ };
26
+ const findingIdParam = {
27
+ params: {
28
+ type: 'object',
29
+ required: ['findingId'],
30
+ properties: { findingId: { type: 'integer' } },
31
+ },
32
+ };
33
+ const generateSchema = {
34
+ ...idParam,
35
+ body: {
36
+ type: 'object',
37
+ required: ['model'],
38
+ additionalProperties: false,
39
+ properties: { model: { type: 'string', enum: MODELS } },
40
+ },
41
+ };
42
+ const updateReviewSchema = {
43
+ ...reviewIdParam,
44
+ body: {
45
+ type: 'object',
46
+ additionalProperties: false,
47
+ properties: {
48
+ userBody: { type: 'string' },
49
+ userVerdict: { type: 'string', enum: VERDICTS },
50
+ },
51
+ },
52
+ };
53
+ const updateFindingSchema = {
54
+ ...findingIdParam,
55
+ body: {
56
+ type: 'object',
57
+ additionalProperties: false,
58
+ properties: {
59
+ included: { type: 'boolean' },
60
+ editedBody: { type: 'string' },
61
+ },
62
+ },
63
+ };
64
+ const postSchema = {
65
+ ...reviewIdParam,
66
+ body: {
67
+ type: 'object',
68
+ required: ['userVerdict'],
69
+ additionalProperties: false,
70
+ properties: { userVerdict: { type: 'string', enum: VERDICTS } },
71
+ },
72
+ };
73
+ // Feature is opt-in (ENABLE_CLAUDE_REVIEW). When off, mutating routes 404.
74
+ function featureOff(reply) {
75
+ reply.status(404);
76
+ return {
77
+ error: 'NotFound',
78
+ message: 'Claude Review is disabled (set ENABLE_CLAUDE_REVIEW=true).',
79
+ };
80
+ }
81
+ export async function claudeReviewRoutes(app) {
82
+ // Latest run + findings + history + auth + enabled.
83
+ app.get('/api/prs/:id/claude-review', { schema: idParam }, async (req) => {
84
+ const { id } = req.params;
85
+ if (!config.claudeReviewEnabled) {
86
+ return {
87
+ enabled: false,
88
+ auth: 'none',
89
+ hasUserKey: false,
90
+ review: null,
91
+ history: [],
92
+ };
93
+ }
94
+ const accountId = accountIdOf(req);
95
+ const auth = detectClaudeAuth();
96
+ return {
97
+ enabled: true,
98
+ auth: auth.status,
99
+ authMessage: auth.status === 'none' ? auth.message : undefined,
100
+ hasUserKey: hasUserAnthropicKey(),
101
+ review: await getLatestClaudeReview(id, accountId),
102
+ history: await listClaudeReviewHistory(id, accountId),
103
+ };
104
+ });
105
+ // Set or clear the locally-stored Anthropic API key (local mode only — the
106
+ // whole route file is unregistered in cloud). An empty `key` clears it.
107
+ app.put('/api/claude-review/key', {
108
+ schema: {
109
+ body: {
110
+ type: 'object',
111
+ required: ['key'],
112
+ additionalProperties: false,
113
+ properties: { key: { type: 'string' } },
114
+ },
115
+ },
116
+ }, async (req) => {
117
+ const { key } = req.body;
118
+ setUserAnthropicKey(key);
119
+ return { hasUserKey: hasUserAnthropicKey(), auth: detectClaudeAuth().status };
120
+ });
121
+ // Kick off a run. 404 disabled/unknown PR, 400 no auth / no head, 409 busy.
122
+ app.post('/api/prs/:id/claude-review', { schema: generateSchema }, async (req, reply) => {
123
+ const { id } = req.params;
124
+ const { model } = req.body;
125
+ if (!config.claudeReviewEnabled)
126
+ return featureOff(reply);
127
+ const auth = detectClaudeAuth();
128
+ if (auth.status === 'none') {
129
+ reply.status(400);
130
+ return { error: 'NoClaudeAuth', message: auth.message };
131
+ }
132
+ const result = await startReview(id, model, app.log);
133
+ if (!result.ok) {
134
+ if (result.reason === 'not_found') {
135
+ reply.status(404);
136
+ return { error: 'NotFound', message: `PR ${id} not found` };
137
+ }
138
+ if (result.reason === 'no_head') {
139
+ reply.status(400);
140
+ return {
141
+ error: 'NoHead',
142
+ message: 'PR has no head commit to review yet.',
143
+ };
144
+ }
145
+ if (result.reason === 'disabled')
146
+ return featureOff(reply);
147
+ // already_running | busy
148
+ reply.status(409);
149
+ return {
150
+ error: 'Conflict',
151
+ message: result.reason === 'already_running'
152
+ ? 'A review is already running for this PR.'
153
+ : 'Another review is in progress; try again shortly.',
154
+ };
155
+ }
156
+ reply.status(202);
157
+ return { reviewId: result.reviewId, status: 'queued' };
158
+ });
159
+ // Live progress poll target.
160
+ app.get('/api/prs/:id/claude-review/status', { schema: idParam }, async (req) => {
161
+ const { id } = req.params;
162
+ if (!config.claudeReviewEnabled) {
163
+ return { status: 'idle', reviewId: null, progress: null };
164
+ }
165
+ return await getReviewStatus(id);
166
+ });
167
+ app.post('/api/prs/:id/claude-review/cancel', { schema: idParam }, async (req, reply) => {
168
+ const { id } = req.params;
169
+ if (!config.claudeReviewEnabled)
170
+ return featureOff(reply);
171
+ const ok = requestReviewCancel(id);
172
+ if (!ok) {
173
+ reply.status(404);
174
+ return { error: 'NotFound', message: 'No running review for this PR.' };
175
+ }
176
+ return { status: 'cancelling' };
177
+ });
178
+ // All in-flight reviews (global progress banner). Static path — Fastify
179
+ // prioritises it over the /:reviewId param route.
180
+ app.get('/api/claude-reviews/active', async () => {
181
+ if (!config.claudeReviewEnabled)
182
+ return { reviews: [] };
183
+ return { reviews: await listActiveReviews() };
184
+ });
185
+ // A specific past run (with findings) — drives the history selector.
186
+ app.get('/api/claude-reviews/:reviewId', { schema: reviewIdParam }, async (req, reply) => {
187
+ if (!config.claudeReviewEnabled)
188
+ return featureOff(reply);
189
+ const { reviewId } = req.params;
190
+ const review = await getClaudeReviewById(reviewId, accountIdOf(req));
191
+ if (!review) {
192
+ reply.status(404);
193
+ return { error: 'NotFound', message: `Review ${reviewId} not found` };
194
+ }
195
+ return review;
196
+ });
197
+ // Save the user's authored draft (never touches Claude's summary/verdict).
198
+ app.patch('/api/claude-reviews/:reviewId', { schema: updateReviewSchema }, async (req, reply) => {
199
+ if (!config.claudeReviewEnabled)
200
+ return featureOff(reply);
201
+ const { reviewId } = req.params;
202
+ const body = req.body;
203
+ const ok = await updateReviewDraft(reviewId, body);
204
+ if (!ok) {
205
+ reply.status(404);
206
+ return { error: 'NotFound', message: `Review ${reviewId} not found` };
207
+ }
208
+ return { status: 'ok' };
209
+ });
210
+ // Tick a finding for inline posting and/or save the user's reworded body.
211
+ app.patch('/api/claude-findings/:findingId', { schema: updateFindingSchema }, async (req, reply) => {
212
+ if (!config.claudeReviewEnabled)
213
+ return featureOff(reply);
214
+ const { findingId } = req.params;
215
+ const body = req.body;
216
+ const ok = await updateFinding(findingId, body);
217
+ if (!ok) {
218
+ reply.status(404);
219
+ return { error: 'NotFound', message: `Finding ${findingId} not found` };
220
+ }
221
+ return { status: 'ok' };
222
+ });
223
+ // Post a single anchored finding as a standalone inline comment (no review
224
+ // submitted). Pins to the run's head SHA; 409 if the PR head has since moved.
225
+ app.post('/api/claude-findings/:findingId/post', { schema: findingIdParam }, async (req, reply) => {
226
+ if (!config.claudeReviewEnabled)
227
+ return featureOff(reply);
228
+ const { findingId } = req.params;
229
+ const ctx = await getFindingPostContext(findingId, accountIdOf(req));
230
+ if (!ctx) {
231
+ reply.status(404);
232
+ return { error: 'NotFound', message: `Finding ${findingId} not found` };
233
+ }
234
+ const f = ctx.finding;
235
+ if (f.line == null || !f.anchored) {
236
+ reply.status(400);
237
+ return {
238
+ error: 'NotAnchored',
239
+ message: "This finding isn't anchored to a diff line, so it can't post inline.",
240
+ };
241
+ }
242
+ try {
243
+ const currentHead = await fetchCurrentHeadSha(ctx.owner, ctx.name, ctx.prNumber);
244
+ if (currentHead !== ctx.reviewHeadSha) {
245
+ reply.status(409);
246
+ return {
247
+ error: 'HeadMoved',
248
+ message: 'The PR head has moved since this review. Re-review before posting.',
249
+ };
250
+ }
251
+ const { commentId } = await submitGithubComment({
252
+ owner: ctx.owner,
253
+ name: ctx.name,
254
+ prNumber: ctx.prNumber,
255
+ commitId: ctx.reviewHeadSha,
256
+ path: f.path,
257
+ line: f.line,
258
+ side: f.side,
259
+ body: findingCommentBody({
260
+ body: f.body,
261
+ editedBody: f.editedBody,
262
+ suggestion: f.suggestion,
263
+ }),
264
+ });
265
+ await markFindingPosted(findingId, commentId);
266
+ const result = {
267
+ githubCommentId: commentId,
268
+ postedAt: new Date().toISOString(),
269
+ };
270
+ return result;
271
+ }
272
+ catch (err) {
273
+ reply.status(502);
274
+ return {
275
+ error: 'GitHubError',
276
+ message: err instanceof Error ? err.message : String(err),
277
+ };
278
+ }
279
+ });
280
+ // Post a single GitHub review (or, with ?dryRun=true, return the exact payload
281
+ // without calling GitHub).
282
+ app.post('/api/claude-reviews/:reviewId/post', { schema: postSchema }, async (req, reply) => {
283
+ if (!config.claudeReviewEnabled)
284
+ return featureOff(reply);
285
+ const { reviewId } = req.params;
286
+ const { userVerdict } = req.body;
287
+ const dryRun = req.query.dryRun === 'true';
288
+ const accountId = accountIdOf(req);
289
+ const ctx = await getClaudeReviewContext(reviewId, accountId);
290
+ if (!ctx) {
291
+ reply.status(404);
292
+ return { error: 'NotFound', message: `Review ${reviewId} not found` };
293
+ }
294
+ const review = await getClaudeReviewById(reviewId, accountId);
295
+ if (!review) {
296
+ reply.status(404);
297
+ return { error: 'NotFound', message: `Review ${reviewId} not found` };
298
+ }
299
+ try {
300
+ // Re-validate against the live head — if it moved, a re-review is needed.
301
+ const currentHead = await fetchCurrentHeadSha(ctx.owner, ctx.name, ctx.prNumber);
302
+ if (currentHead !== ctx.review.headSha) {
303
+ reply.status(409);
304
+ return {
305
+ error: 'HeadMoved',
306
+ message: 'The PR head has moved since this review. Re-review before posting.',
307
+ };
308
+ }
309
+ // Persist the chosen verdict so the run records what was posted.
310
+ await updateReviewDraft(reviewId, { userVerdict });
311
+ const rawDiff = await fetchPrDiff(ctx.owner, ctx.name, ctx.prNumber);
312
+ const { diff } = stripNoiseFromDiff(rawDiff, isNoiseFile);
313
+ const built = buildReview({
314
+ commitId: ctx.review.headSha,
315
+ body: review.userBody ?? '',
316
+ event: userVerdict,
317
+ includedFindings: review.findings.filter((f) => f.included),
318
+ diff,
319
+ });
320
+ if (dryRun)
321
+ return built.preview;
322
+ const { reviewId: ghReviewId } = await submitGithubReview({
323
+ owner: ctx.owner,
324
+ name: ctx.name,
325
+ prNumber: ctx.prNumber,
326
+ commitId: ctx.review.headSha,
327
+ body: review.userBody ?? '',
328
+ event: userVerdict,
329
+ comments: built.preview.comments,
330
+ });
331
+ await markReviewPosted(reviewId, ghReviewId, built.postedFindingIds);
332
+ const result = {
333
+ postedReviewId: ghReviewId,
334
+ postedAt: new Date().toISOString(),
335
+ postedCommentCount: built.preview.comments.length,
336
+ skippedUnanchored: built.preview.skippedUnanchored,
337
+ };
338
+ return result;
339
+ }
340
+ catch (err) {
341
+ reply.status(502);
342
+ return {
343
+ error: 'GitHubError',
344
+ message: err instanceof Error ? err.message : String(err),
345
+ };
346
+ }
347
+ });
348
+ }
349
+ //# sourceMappingURL=claude-review.js.map
@@ -1,4 +1,6 @@
1
- import { ensureLocalUser } from '../../github/local-user.js';
1
+ import { config } from '../../config.js';
2
+ import { accountToLocalUser } from '../../auth/account.js';
3
+ import { accountIdOf } from '../plugins/auth.js';
2
4
  import { dismissMyTurn, getMyTurn } from '../../db/queries.js';
3
5
  const dismissSchema = {
4
6
  body: {
@@ -12,9 +14,9 @@ const dismissSchema = {
12
14
  },
13
15
  };
14
16
  export async function meRoutes(app) {
15
- app.get('/api/me', async () => {
16
- const user = ensureLocalUser();
17
- const myTurn = getMyTurn();
17
+ app.get('/api/me', async (req) => {
18
+ const user = accountToLocalUser(req.account);
19
+ const myTurn = await getMyTurn(accountIdOf(req));
18
20
  return {
19
21
  user,
20
22
  counts: {
@@ -22,12 +24,14 @@ export async function meRoutes(app) {
22
24
  yourPrsActivity: myTurn.yourPrs.length,
23
25
  threadsAwaiting: myTurn.threadsAwaiting.length,
24
26
  },
27
+ claudeReviewEnabled: config.claudeReviewEnabled,
28
+ deploymentMode: config.deploymentMode,
25
29
  };
26
30
  });
27
- app.get('/api/my-turn', async () => getMyTurn());
31
+ app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
28
32
  app.post('/api/my-turn/dismiss', { schema: dismissSchema }, async (req) => {
29
33
  const { kind, refId } = req.body;
30
- dismissMyTurn(kind, refId);
34
+ await dismissMyTurn(accountIdOf(req), kind, refId);
31
35
  return { status: 'ok' };
32
36
  });
33
37
  }
@@ -1,7 +1,8 @@
1
1
  import { getMergers } from '../../db/queries.js';
2
+ import { accountIdOf } from '../plugins/auth.js';
2
3
  // Per-repo "merge rights" inference (distinct users who've merged a PR there).
3
4
  // Reference data — no filters; the frontend looks it up per timeline row.
4
5
  export async function mergersRoutes(app) {
5
- app.get('/api/mergers', async () => getMergers());
6
+ app.get('/api/mergers', async (req) => getMergers(accountIdOf(req)));
6
7
  }
7
8
  //# sourceMappingURL=mergers.js.map
@@ -1,4 +1,5 @@
1
1
  import { getOpenPrs } from '../../db/queries.js';
2
+ import { accountIdOf } from '../plugins/auth.js';
2
3
  function parseIntList(raw) {
3
4
  if (!raw)
4
5
  return null;
@@ -11,7 +12,8 @@ function parseIntList(raw) {
11
12
  export async function openPrsRoutes(app) {
12
13
  app.get('/api/open-prs', async (req) => {
13
14
  const q = req.query;
14
- const prs = getOpenPrs({
15
+ const prs = await getOpenPrs({
16
+ accountId: accountIdOf(req),
15
17
  repoIds: parseIntList(q.repoIds),
16
18
  userIds: parseIntList(q.userIds),
17
19
  });