pierre-review 0.1.78 → 0.1.79

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 (37) hide show
  1. package/dist/api/plugins/auth.js +4 -0
  2. package/dist/api/routes/activity.js +2 -0
  3. package/dist/api/routes/bot-triage.js +12 -5
  4. package/dist/api/routes/repos.js +7 -6
  5. package/dist/api/routes/teams.js +150 -0
  6. package/dist/api/routes/webhooks.js +181 -0
  7. package/dist/app.js +8 -0
  8. package/dist/coding/agent.js +80 -70
  9. package/dist/config.js +34 -2
  10. package/dist/db/migrations/0030_teams.sql +32 -0
  11. package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
  12. package/dist/db/migrations/meta/_journal.json +14 -0
  13. package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
  14. package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
  15. package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
  16. package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
  17. package/dist/db/migrations-pg/meta/_journal.json +14 -0
  18. package/dist/db/queries.js +357 -51
  19. package/dist/db/schema.pg.js +39 -0
  20. package/dist/db/schema.sqlite.js +44 -0
  21. package/dist/github/client.js +25 -0
  22. package/dist/github/queries.js +211 -185
  23. package/dist/pro/bind.js +13 -5
  24. package/dist/review/agent.js +3 -1
  25. package/dist/review/local-settings.js +35 -0
  26. package/dist/sync/adaptive.js +107 -0
  27. package/dist/sync/sync-manager.js +19 -0
  28. package/dist/sync/sync-one-pr.js +156 -0
  29. package/package.json +1 -1
  30. package/public/assets/{AiFixTab-DMoHaevz.js → AiFixTab-BxRIMl9R.js} +1 -1
  31. package/public/assets/ClaudeReviewTab-BTBkfp6H.js +11 -0
  32. package/public/assets/index-Bwrc3KJr.css +1 -0
  33. package/public/assets/index-Cv9-yWxl.js +53 -0
  34. package/public/index.html +2 -2
  35. package/public/assets/ClaudeReviewTab-CBzfgOYF.js +0 -11
  36. package/public/assets/index-C1obqq3D.js +0 -53
  37. package/public/assets/index-X4Ro8Jjo.css +0 -1
@@ -87,6 +87,10 @@ export function registerAuthGate(app) {
87
87
  return;
88
88
  if (path === '/api/billing/webhook')
89
89
  return;
90
+ // GitHub App webhook: GitHub posts unauthenticated — authenticity is the
91
+ // X-Hub-Signature-256 HMAC, verified in the route (see api/routes/webhooks.ts).
92
+ if (path === '/api/webhooks/github')
93
+ return;
90
94
  // The pricing page's "Get Pro" is a plain browser navigation from the public
91
95
  // landing; the route itself bounces anonymous visitors to sign-in instead of
92
96
  // dead-ending them on a JSON 401.
@@ -26,9 +26,11 @@ export async function activityRoutes(app) {
26
26
  const q = req.query;
27
27
  const limit = q.limit != null ? Number(q.limit) : null;
28
28
  const offset = q.offset != null ? Number(q.offset) : 0;
29
+ const prId = q.prId != null ? Number(q.prId) : null;
29
30
  return getConsolidatedFeed(accountIdOf(req), {
30
31
  repoIds: parseIntList(q.repoIds),
31
32
  userIds: parseIntList(q.userIds),
33
+ prId: prId != null && Number.isFinite(prId) ? prId : null,
32
34
  limit: Number.isFinite(limit) && limit != null && limit > 0 ? limit : null,
33
35
  offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
34
36
  excludeBots: q.excludeBots === 'true',
@@ -1,4 +1,4 @@
1
- import { addBotMuteRule, deleteBotMuteRule, getBotAnalytics, getBotVendorPrs, getBotDedupClusters, listBotMuteRules, listDetectedReviewers, setReviewerOverride, } from '../../db/queries.js';
1
+ import { addBotMuteRule, deleteBotMuteRule, getBotAnalytics, getBotVendorPrs, getBotDedupClusters, listBotMuteRules, listDetectedReviewers, resolveScopeRepoIds, setReviewerOverride, } from '../../db/queries.js';
2
2
  import { accountIdOf } from '../plugins/auth.js';
3
3
  // PATCH /api/bot-reviewers/:userId — the two-way manual override. `kind`/`label` are left as
4
4
  // open strings (nullable) rather than an enum: AutomatedReviewerKind is defined in the
@@ -32,6 +32,8 @@ const analyticsSchema = {
32
32
  enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
33
33
  default: 'rolling_14',
34
34
  },
35
+ // Team scope: 'all' | 'none' | '<teamId>' (see resolveScopeRepoIds). Absent = all.
36
+ scope: { type: 'string' },
35
37
  },
36
38
  },
37
39
  };
@@ -53,6 +55,7 @@ const vendorPrsSchema = {
53
55
  enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
54
56
  default: 'rolling_14',
55
57
  },
58
+ scope: { type: 'string' },
56
59
  },
57
60
  },
58
61
  };
@@ -113,16 +116,20 @@ export async function botTriageRoutes(app) {
113
116
  // untouched + verdict + trend + deterministic tuning suggestions. Cost fields are null here
114
117
  // (the client overlays per-vendor cost from Pro settings).
115
118
  app.get('/api/bot-analytics', { schema: analyticsSchema }, async (req) => {
116
- const { window } = req.query;
117
- const resp = await getBotAnalytics(accountIdOf(req), window);
119
+ const { window, scope } = req.query;
120
+ const accountId = accountIdOf(req);
121
+ const repoIds = scope ? await resolveScopeRepoIds(accountId, scope) : null;
122
+ const resp = await getBotAnalytics(accountId, window, repoIds);
118
123
  return resp;
119
124
  });
120
125
  // The per-vendor PR drill-down behind one Bot-ROI row: the PRs that automated reviewer kind
121
126
  // touched in the window (threads/comments/acted-on/untouched/bot-only), newest-activity first.
122
127
  app.get('/api/bot-analytics/:kind/prs', { schema: vendorPrsSchema }, async (req) => {
123
128
  const { kind } = req.params;
124
- const { window } = req.query;
125
- const resp = await getBotVendorPrs(accountIdOf(req), kind, window);
129
+ const { window, scope } = req.query;
130
+ const accountId = accountIdOf(req);
131
+ const repoIds = scope ? await resolveScopeRepoIds(accountId, scope) : null;
132
+ const resp = await getBotVendorPrs(accountId, kind, window, repoIds);
126
133
  return resp;
127
134
  });
128
135
  // Cross-bot dedup clusters for one PR (≥2 automated reviewers on the same path/line window).
@@ -8,7 +8,7 @@ import { accountIdOf } from '../plugins/auth.js';
8
8
  // Local copy of the shared MAX_REPOS_PER_ACCOUNT value. `@pierre-review/shared` is
9
9
  // a types-only package (not shipped in the published tarball), so the backend must
10
10
  // only `import type` from it — runtime constants are duplicated here. Keep in sync.
11
- const MAX_REPOS_PER_ACCOUNT = 15;
11
+ const MAX_REPOS_PER_ACCOUNT = 100;
12
12
  const createRepoSchema = {
13
13
  body: {
14
14
  type: 'object',
@@ -189,7 +189,7 @@ export async function repoRoutes(app) {
189
189
  return body;
190
190
  });
191
191
  app.post('/api/repos', { schema: createRepoSchema }, async (req, reply) => {
192
- const { owner, name, watch } = req.body;
192
+ const { owner, name } = req.body;
193
193
  const accountId = accountIdOf(req);
194
194
  let resp;
195
195
  try {
@@ -225,10 +225,11 @@ export async function repoRoutes(app) {
225
225
  const canonOwner = resp.repository.owner.login;
226
226
  const canonName = resp.repository.name;
227
227
  const repoId = await upsertRepo(canonOwner, canonName, resp.repository.id, null, accountId);
228
- // Auto-watch "yours" repos for the inbox. Idempotent on re-add and preserves an
229
- // existing watch-start (setRepoInboxWatch only stamps the start when unset).
230
- if (watch === true)
231
- await setRepoInboxWatch(accountId, repoId, true);
228
+ // Auto-watch every newly-added repo for the inbox (so its activity flows into the feed +
229
+ // team scopes by default). Idempotent on re-add and preserves an existing watch-start
230
+ // (setRepoInboxWatch only stamps the start when unset). The `watch` body field is now
231
+ // vestigial every add watches — but the schema still accepts it for back-compat.
232
+ await setRepoInboxWatch(accountId, repoId, true);
232
233
  // Kick off the initial backfill in the background.
233
234
  runSyncForRepo(repoId, app.log, { background: true });
234
235
  reply.status(201);
@@ -0,0 +1,150 @@
1
+ import { assignReposToTeam, createTeam, deleteTeam, getTeamRepoIds, listTeams, removeRepoFromTeam, renameTeam, } from '../../db/queries.js';
2
+ import { accountIdOf } from '../plugins/auth.js';
3
+ // Teams (CORE): group an account's repos into named teams. Every handler is accountId-scoped;
4
+ // id-addressed routes verify ownership (→ 404). Assigning a repo to a team auto-Watches it.
5
+ const nameBodySchema = {
6
+ body: {
7
+ type: 'object',
8
+ required: ['name'],
9
+ additionalProperties: false,
10
+ properties: { name: { type: 'string', minLength: 1, maxLength: 120 } },
11
+ },
12
+ };
13
+ const idParamSchema = {
14
+ params: {
15
+ type: 'object',
16
+ required: ['id'],
17
+ properties: { id: { type: 'integer' } },
18
+ },
19
+ };
20
+ const patchSchema = {
21
+ ...idParamSchema,
22
+ body: {
23
+ type: 'object',
24
+ additionalProperties: false,
25
+ properties: {
26
+ name: { type: 'string', minLength: 1, maxLength: 120 },
27
+ repoIds: { type: 'array', items: { type: 'integer' } },
28
+ },
29
+ },
30
+ };
31
+ const addRepoSchema = {
32
+ ...idParamSchema,
33
+ body: {
34
+ type: 'object',
35
+ required: ['repoId'],
36
+ additionalProperties: false,
37
+ properties: { repoId: { type: 'integer' } },
38
+ },
39
+ };
40
+ const removeRepoSchema = {
41
+ params: {
42
+ type: 'object',
43
+ required: ['id', 'repoId'],
44
+ properties: { id: { type: 'integer' }, repoId: { type: 'integer' } },
45
+ },
46
+ };
47
+ export async function teamRoutes(app) {
48
+ // Account-scoped ownership lookup (→ null for a foreign/unknown team).
49
+ const findTeam = async (accountId, id) => (await listTeams(accountId)).find((t) => t.id === id) ?? null;
50
+ app.get('/api/teams', async (req) => ({
51
+ teams: await listTeams(accountIdOf(req)),
52
+ }));
53
+ app.post('/api/teams', { schema: nameBodySchema }, async (req, reply) => {
54
+ const accountId = accountIdOf(req);
55
+ const name = req.body.name.trim();
56
+ if (!name) {
57
+ reply.status(400);
58
+ return { error: 'BadRequest', message: 'Team name must not be empty' };
59
+ }
60
+ const existing = await listTeams(accountId);
61
+ if (existing.some((t) => t.name === name)) {
62
+ reply.status(400);
63
+ return { error: 'BadRequest', message: `A team named "${name}" already exists` };
64
+ }
65
+ try {
66
+ const team = await createTeam(accountId, name);
67
+ reply.status(201);
68
+ return { team };
69
+ }
70
+ catch {
71
+ // Unique-constraint fallback (a concurrent create raced us to the same name).
72
+ reply.status(400);
73
+ return { error: 'BadRequest', message: `A team named "${name}" already exists` };
74
+ }
75
+ });
76
+ // Rename and/or REPLACE membership (set the team's repos to exactly `repoIds`). Ownership → 404.
77
+ app.patch('/api/teams/:id', { schema: patchSchema }, async (req, reply) => {
78
+ const accountId = accountIdOf(req);
79
+ const { id } = req.params;
80
+ const { name, repoIds } = req.body;
81
+ if (!(await findTeam(accountId, id))) {
82
+ reply.status(404);
83
+ return { error: 'NotFound', message: `Team ${id} not found` };
84
+ }
85
+ if (name !== undefined) {
86
+ const trimmed = name.trim();
87
+ if (!trimmed) {
88
+ reply.status(400);
89
+ return { error: 'BadRequest', message: 'Team name must not be empty' };
90
+ }
91
+ // Reject a rename that collides with another team's name.
92
+ const others = (await listTeams(accountId)).filter((t) => t.id !== id);
93
+ if (others.some((t) => t.name === trimmed)) {
94
+ reply.status(400);
95
+ return { error: 'BadRequest', message: `A team named "${trimmed}" already exists` };
96
+ }
97
+ await renameTeam(id, accountId, trimmed);
98
+ }
99
+ if (repoIds !== undefined) {
100
+ // Diff current membership → target: assign the new ones (auto-watch), remove the missing.
101
+ const current = await getTeamRepoIds(id, accountId);
102
+ const target = new Set(repoIds);
103
+ const toAdd = repoIds.filter((r) => !current.includes(r));
104
+ const toRemove = current.filter((r) => !target.has(r));
105
+ if (toAdd.length > 0)
106
+ await assignReposToTeam(id, accountId, toAdd);
107
+ for (const repoId of toRemove)
108
+ await removeRepoFromTeam(id, repoId, accountId);
109
+ }
110
+ const team = await findTeam(accountId, id);
111
+ return { team };
112
+ });
113
+ app.delete('/api/teams/:id', { schema: idParamSchema }, async (req, reply) => {
114
+ const accountId = accountIdOf(req);
115
+ const { id } = req.params;
116
+ const ok = await deleteTeam(id, accountId);
117
+ if (!ok) {
118
+ reply.status(404);
119
+ return { error: 'NotFound', message: `Team ${id} not found` };
120
+ }
121
+ reply.status(204);
122
+ return null;
123
+ });
124
+ // Assign ONE repo to a team (auto-watch). Ownership → 404.
125
+ app.post('/api/teams/:id/repos', { schema: addRepoSchema }, async (req, reply) => {
126
+ const accountId = accountIdOf(req);
127
+ const { id } = req.params;
128
+ const { repoId } = req.body;
129
+ if (!(await findTeam(accountId, id))) {
130
+ reply.status(404);
131
+ return { error: 'NotFound', message: `Team ${id} not found` };
132
+ }
133
+ await assignReposToTeam(id, accountId, [repoId]);
134
+ const team = await findTeam(accountId, id);
135
+ return { team };
136
+ });
137
+ // Unassign ONE repo from a team. Ownership → 404; idempotent (unassigning a non-member 204s).
138
+ app.delete('/api/teams/:id/repos/:repoId', { schema: removeRepoSchema }, async (req, reply) => {
139
+ const accountId = accountIdOf(req);
140
+ const { id, repoId } = req.params;
141
+ if (!(await findTeam(accountId, id))) {
142
+ reply.status(404);
143
+ return { error: 'NotFound', message: `Team ${id} not found` };
144
+ }
145
+ await removeRepoFromTeam(id, repoId, accountId);
146
+ reply.status(204);
147
+ return null;
148
+ });
149
+ }
150
+ //# sourceMappingURL=teams.js.map
@@ -0,0 +1,181 @@
1
+ // GitHub App webhook receiver — real-time sync Phase 1 (see docs/REALTIME-SYNC.md).
2
+ //
3
+ // A GitHub App has ONE webhook URL that receives events for every installation, so this
4
+ // single route serves every tenant. On a PR-affecting event it resolves which watched
5
+ // repos the change touches and fires a debounced, targeted single-PR sync (Phase 0's
6
+ // enqueuePrSync) — near-real-time freshness at ~1 GraphQL point per change, no window
7
+ // re-walk. It is ADDITIVE: the periodic poll (SYNC.md) stays as the backstop for dropped
8
+ // deliveries and for accounts signed in via the OAuth App (no App install → no webhooks).
9
+ //
10
+ // Structure mirrors the Stripe webhook (api/routes/billing.ts): authenticity is the
11
+ // signature, not a session (so the route is exempted from the cloud auth gate); the raw
12
+ // body is read via an ENCAPSULATED content-type parser so the rest of the API keeps
13
+ // normal JSON parsing. Registered in both modes; inert until GITHUB_APP_WEBHOOK_SECRET is
14
+ // set (replies 501 unconfigured).
15
+ import { createHmac, timingSafeEqual } from 'node:crypto';
16
+ import { and, eq } from 'drizzle-orm';
17
+ import { config } from '../../config.js';
18
+ import { db, schema } from '../../db/client.js';
19
+ import { enqueuePrSync } from '../../sync/sync-one-pr.js';
20
+ const { repos } = schema;
21
+ /**
22
+ * Verify a GitHub webhook signature (the `X-Hub-Signature-256` header) without a
23
+ * dependency. Format: `sha256=<hex>`, where the hex is HMAC-SHA256(secret) over the raw
24
+ * request bytes. Compared timing-safely. Pure so it's unit-testable without Fastify.
25
+ */
26
+ export function verifyGithubSignature(rawBody, header, secret) {
27
+ const prefix = 'sha256=';
28
+ if (!header.startsWith(prefix))
29
+ return false;
30
+ const provided = header.slice(prefix.length);
31
+ const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
32
+ const providedBuf = Buffer.from(provided, 'utf-8');
33
+ const expectedBuf = Buffer.from(expected, 'utf-8');
34
+ return (providedBuf.length === expectedBuf.length &&
35
+ timingSafeEqual(providedBuf, expectedBuf));
36
+ }
37
+ function asRecord(v) {
38
+ return v !== null && typeof v === 'object' ? v : null;
39
+ }
40
+ // A positive integer `number` field off an object (a PR reference), else null.
41
+ function prNumberOf(v) {
42
+ const n = asRecord(v)?.['number'];
43
+ return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : null;
44
+ }
45
+ /**
46
+ * Map a GitHub webhook (event type + payload) to the repo + PR numbers it should refresh.
47
+ * Pure — no DB, no Fastify — so the per-event extraction is unit-testable. Every handled
48
+ * event carries `repository.owner.login` + `repository.name`; the PR number(s) live in a
49
+ * per-event slot. Returns null when there's nothing to sync.
50
+ */
51
+ export function extractPrTargets(eventType, payload) {
52
+ const p = asRecord(payload);
53
+ if (!p)
54
+ return null;
55
+ const repo = asRecord(p['repository']);
56
+ const owner = asRecord(repo?.['owner'])?.['login'];
57
+ const name = repo?.['name'];
58
+ if (typeof owner !== 'string' || typeof name !== 'string')
59
+ return null;
60
+ const numbers = new Set();
61
+ const addNum = (v) => {
62
+ const n = prNumberOf(v);
63
+ if (n != null)
64
+ numbers.add(n);
65
+ };
66
+ const addArray = (v) => {
67
+ if (Array.isArray(v))
68
+ for (const e of v)
69
+ addNum(e);
70
+ };
71
+ switch (eventType) {
72
+ // All the PR-scoped events carry the PR directly.
73
+ case 'pull_request':
74
+ case 'pull_request_review':
75
+ case 'pull_request_review_comment':
76
+ case 'pull_request_review_thread':
77
+ addNum(p['pull_request']);
78
+ break;
79
+ // issue_comment also fires on plain issues; only PRs carry `issue.pull_request`.
80
+ case 'issue_comment': {
81
+ const issue = asRecord(p['issue']);
82
+ if (issue && issue['pull_request'] != null)
83
+ addNum(issue);
84
+ break;
85
+ }
86
+ // CI events carry the associated PRs (same-repo only) — this is how a check
87
+ // finishing (which never bumps a PR's updatedAt) drives a refresh.
88
+ case 'check_run':
89
+ addArray(asRecord(p['check_run'])?.['pull_requests']);
90
+ break;
91
+ case 'check_suite':
92
+ addArray(asRecord(p['check_suite'])?.['pull_requests']);
93
+ break;
94
+ // ping / push / installation / anything else → no targeted PR (a push to a PR's head
95
+ // arrives separately as pull_request `synchronize`, which the case above handles).
96
+ default:
97
+ return null;
98
+ }
99
+ if (numbers.size === 0)
100
+ return null;
101
+ return { owner, name, prNumbers: [...numbers] };
102
+ }
103
+ export async function webhookRoutes(app) {
104
+ // A STABLE logger for the detached (debounced) syncs — the request-scoped req.log is
105
+ // gone by the time enqueuePrSync's timer fires. Mirrors scheduler.ts's adapter.
106
+ const log = {
107
+ info: (m, ...a) => app.log.info(a.length ? { a } : {}, m),
108
+ warn: (m, ...a) => app.log.warn(a.length ? { a } : {}, m),
109
+ error: (m, ...a) => app.log.error(a.length ? { a } : {}, m),
110
+ };
111
+ // The signature signs the exact bytes, so this route needs the RAW body. Fastify's
112
+ // content-type parsers are ENCAPSULATED, so registering the buffer parser in this
113
+ // nested scope affects ONLY /api/webhooks/github — the rest of the API keeps normal
114
+ // JSON parsing (proven by webhooks.test.ts's sibling-route case).
115
+ await app.register(async (scope) => {
116
+ scope.addContentTypeParser('application/json', { parseAs: 'buffer' }, (_req, body, done) => {
117
+ done(null, body);
118
+ });
119
+ // GitHub posts unauthenticated (exempted from the auth gate); authenticity is the
120
+ // signature. Never 500 on a data mismatch — GitHub retries non-2xx.
121
+ scope.post('/api/webhooks/github', async (req, reply) => {
122
+ if (!config.githubAppWebhookSecret) {
123
+ return reply.code(501).send({ error: 'github webhooks not configured' });
124
+ }
125
+ const raw = req.body;
126
+ if (!Buffer.isBuffer(raw)) {
127
+ return reply.code(400).send({ error: 'expected a JSON body' });
128
+ }
129
+ const sig = req.headers['x-hub-signature-256'];
130
+ if (typeof sig !== 'string' ||
131
+ !verifyGithubSignature(raw, sig, config.githubAppWebhookSecret)) {
132
+ return reply.code(401).send({ error: 'invalid signature' });
133
+ }
134
+ const eventType = req.headers['x-github-event'];
135
+ if (typeof eventType !== 'string') {
136
+ return reply.code(400).send({ error: 'missing X-GitHub-Event' });
137
+ }
138
+ // GitHub pings a newly-configured webhook once — ack it.
139
+ if (eventType === 'ping')
140
+ return { received: true, queued: 0 };
141
+ let payload;
142
+ try {
143
+ payload = JSON.parse(raw.toString('utf-8'));
144
+ }
145
+ catch {
146
+ return reply.code(400).send({ error: 'invalid JSON' });
147
+ }
148
+ const target = extractPrTargets(eventType, payload);
149
+ if (!target)
150
+ return { received: true, queued: 0 };
151
+ // Route by (owner, name) across ALL accounts watching this repo. `repos` is keyed
152
+ // (accountId, owner, name), so one webhook fans out to every tenant's copy — each
153
+ // synced with ITS OWN token inside syncOnePr (isolation is structural). No
154
+ // installation→account table is needed.
155
+ const rows = await db
156
+ .select({ id: repos.id })
157
+ .from(repos)
158
+ .where(and(eq(repos.owner, target.owner), eq(repos.name, target.name)))
159
+ .execute();
160
+ let queued = 0;
161
+ for (const row of rows) {
162
+ for (const prNumber of target.prNumbers) {
163
+ // Debounced + coalesced + reservation-guarded: a burst for one PR collapses to
164
+ // a single targeted sync (Phase 0). Fire-and-forget; syncOnePr swallows errors.
165
+ enqueuePrSync(row.id, prNumber, log);
166
+ queued += 1;
167
+ }
168
+ }
169
+ if (queued > 0) {
170
+ req.log.info({
171
+ repo: `${target.owner}/${target.name}`,
172
+ event: eventType,
173
+ prs: target.prNumbers,
174
+ watchers: rows.length,
175
+ }, 'github webhook → queued targeted sync');
176
+ }
177
+ return { received: true, queued };
178
+ });
179
+ });
180
+ }
181
+ //# sourceMappingURL=webhooks.js.map
package/dist/app.js CHANGED
@@ -9,6 +9,7 @@ import { registerAccountContext, registerAuthGate, registerSession, } from './ap
9
9
  import { authRoutes } from './api/routes/auth.js';
10
10
  import { healthRoutes } from './api/routes/health.js';
11
11
  import { repoRoutes } from './api/routes/repos.js';
12
+ import { teamRoutes } from './api/routes/teams.js';
12
13
  import { userRoutes } from './api/routes/users.js';
13
14
  import { timelineRoutes } from './api/routes/timeline.js';
14
15
  import { prRoutes } from './api/routes/prs.js';
@@ -21,6 +22,7 @@ import { insightsRoutes } from './api/routes/insights.js';
21
22
  import { activityRoutes } from './api/routes/activity.js';
22
23
  import { billingRoutes } from './api/routes/billing.js';
23
24
  import { botTriageRoutes } from './api/routes/bot-triage.js';
25
+ import { webhookRoutes } from './api/routes/webhooks.js';
24
26
  export async function buildApp() {
25
27
  const app = Fastify({
26
28
  // In production (the installed CLI) the per-request "incoming request" /
@@ -123,6 +125,8 @@ export async function buildApp() {
123
125
  await app.register(authRoutes);
124
126
  await app.register(healthRoutes);
125
127
  await app.register(repoRoutes);
128
+ // Teams (CORE, always registered): group repos into named teams; account-scoped, no AI.
129
+ await app.register(teamRoutes);
126
130
  await app.register(userRoutes);
127
131
  await app.register(timelineRoutes);
128
132
  await app.register(prRoutes);
@@ -139,6 +143,10 @@ export async function buildApp() {
139
143
  // Stripe billing seam (checkout redirect + webhook). Registered in both modes;
140
144
  // inert until the STRIPE_* env vars are set (webhook 501s unconfigured).
141
145
  await app.register(billingRoutes);
146
+ // GitHub App webhook receiver (real-time sync Phase 1). Registered in both modes;
147
+ // inert until GITHUB_APP_WEBHOOK_SECRET is set (501s unconfigured). Additive on top of
148
+ // the periodic poll — see docs/REALTIME-SYNC.md.
149
+ await app.register(webhookRoutes);
142
150
  // Claude Review moved into the @pierre/pro plugin (its routes register there, gated on the
143
151
  // `claudeReview` capability). The SDK-run / diff-prep / GitHub-post infra + the tables stay
144
152
  // in core behind the ctx.review seam; nothing to register here.
@@ -1,6 +1,7 @@
1
1
  import { createSdkMcpServer, query, tool, } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { config } from '../config.js';
3
3
  import { getAccessToken } from '../auth/account.js';
4
+ import { applyClaudeReviewAuth } from '../review/auth.js';
4
5
  import { cleanupCloneCache, prepWorktree, removeWorktreeLocked, } from '../review/clone-manager.js';
5
6
  import { estimateCostUsd } from '../review/pricing.js';
6
7
  import { recordUsage, sumModelUsage, sumUsageMap, } from '../review/usage.js';
@@ -63,9 +64,11 @@ function emptyUsage() {
63
64
  * `settingSources:[]`, budget/turn caps, whitelisted tools) and streams activity, but
64
65
  * knows nothing about worktree prep, diff capture, or git — the callers own those.
65
66
  *
66
- * Auth: runs on the AMBIENT Claude env WITHOUT mutating process.env (unlike Claude
67
- * Review's applyClaudeReviewAuth) — the AI-Fix jobs are concurrency 1, and mutating
68
- * env would race a concurrent review manager. Whatever credential is ambient is used.
67
+ * Auth: prefers the ambient Claude session, else falls back to the user's local BYO
68
+ * Anthropic key — the SAME advanced-AI credential policy as Claude Review
69
+ * (applyClaudeReviewAuth), restored in `finally`. AI-Fix jobs are concurrency 1; the env
70
+ * mutation only bites when there's no ambient auth (a BYO-key-only setup), where it and a
71
+ * concurrent review would set the identical key.
69
72
  */
70
73
  export async function runAgentInWorktree(opts) {
71
74
  const model = opts.model;
@@ -78,81 +81,88 @@ export async function runAgentInWorktree(opts) {
78
81
  const effort = EFFORT_CAPABLE_MODELS.has(model)
79
82
  ? config.reviewEffort
80
83
  : undefined;
81
- const q = query({
82
- prompt: opts.prompt,
83
- options: {
84
- model,
85
- ...(effort ? { effort } : {}),
86
- ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
87
- cwd: opts.worktreePath,
88
- permissionMode: 'bypassPermissions',
89
- allowedTools: opts.allowedTools,
90
- disallowedTools: opts.disallowedTools,
91
- maxTurns,
92
- maxBudgetUsd: opts.maxBudgetUsd,
93
- settingSources: [],
94
- mcpServers: opts.mcpServers,
95
- abortController: opts.abortController,
96
- },
97
- });
98
- const activity = [];
99
- const pushActivity = (line) => {
100
- const trimmed = line.trim();
101
- if (!trimmed)
102
- return;
103
- activity.push(trimmed);
104
- if (activity.length > ACTIVITY_LOG_CAP)
105
- activity.shift();
106
- };
107
- for await (const message of q) {
108
- if (message.type === 'assistant') {
109
- try {
110
- recordUsage(usageByUuid, message);
111
- }
112
- catch {
113
- /* usage shape varies — never let it break the run */
114
- }
115
- try {
116
- for (const line of describeAssistantBlocks(message))
117
- pushActivity(line);
84
+ // Advanced-AI credential: ambient session preferred, else the local BYO key. Restored below.
85
+ const restoreEnv = applyClaudeReviewAuth(true);
86
+ try {
87
+ const q = query({
88
+ prompt: opts.prompt,
89
+ options: {
90
+ model,
91
+ ...(effort ? { effort } : {}),
92
+ ...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
93
+ cwd: opts.worktreePath,
94
+ permissionMode: 'bypassPermissions',
95
+ allowedTools: opts.allowedTools,
96
+ disallowedTools: opts.disallowedTools,
97
+ maxTurns,
98
+ maxBudgetUsd: opts.maxBudgetUsd,
99
+ settingSources: [],
100
+ mcpServers: opts.mcpServers,
101
+ abortController: opts.abortController,
102
+ },
103
+ });
104
+ const activity = [];
105
+ const pushActivity = (line) => {
106
+ const trimmed = line.trim();
107
+ if (!trimmed)
108
+ return;
109
+ activity.push(trimmed);
110
+ if (activity.length > ACTIVITY_LOG_CAP)
111
+ activity.shift();
112
+ };
113
+ for await (const message of q) {
114
+ if (message.type === 'assistant') {
115
+ try {
116
+ recordUsage(usageByUuid, message);
117
+ }
118
+ catch {
119
+ /* usage shape varies — never let it break the run */
120
+ }
121
+ try {
122
+ for (const line of describeAssistantBlocks(message))
123
+ pushActivity(line);
124
+ }
125
+ catch {
126
+ /* never let progress derivation break the run */
127
+ }
128
+ const live = sumUsageMap(usageByUuid);
129
+ opts.onActivity?.([...activity], {
130
+ ...live,
131
+ estCostUsd: estimateCostUsd(model, live),
132
+ });
118
133
  }
119
- catch {
120
- /* never let progress derivation break the run */
134
+ else if (message.type === 'result') {
135
+ result = message;
121
136
  }
122
- const live = sumUsageMap(usageByUuid);
123
- opts.onActivity?.([...activity], {
124
- ...live,
125
- estCostUsd: estimateCostUsd(model, live),
126
- });
127
137
  }
128
- else if (message.type === 'result') {
129
- result = message;
138
+ if (opts.abortController.signal.aborted) {
139
+ return {
140
+ result,
141
+ usage: emptyUsage(),
142
+ costUsd: null,
143
+ numTurns: null,
144
+ aborted: true,
145
+ };
130
146
  }
131
- }
132
- if (opts.abortController.signal.aborted) {
147
+ const usage = sumModelUsage(result) ?? sumUsageMap(usageByUuid);
148
+ const hasUsage = usage.inputTokens + usage.outputTokens + usage.cacheReadTokens > 0;
149
+ const costUsd = result?.total_cost_usd ?? (hasUsage ? estimateCostUsd(model, usage) : null);
133
150
  return {
134
151
  result,
135
- usage: emptyUsage(),
136
- costUsd: null,
137
- numTurns: null,
138
- aborted: true,
152
+ usage: {
153
+ inputTokens: usage.inputTokens,
154
+ outputTokens: usage.outputTokens,
155
+ cacheReadTokens: usage.cacheReadTokens,
156
+ cacheCreationTokens: usage.cacheCreationTokens,
157
+ },
158
+ costUsd,
159
+ numTurns: result?.num_turns ?? null,
160
+ aborted: false,
139
161
  };
140
162
  }
141
- const usage = sumModelUsage(result) ?? sumUsageMap(usageByUuid);
142
- const hasUsage = usage.inputTokens + usage.outputTokens + usage.cacheReadTokens > 0;
143
- const costUsd = result?.total_cost_usd ?? (hasUsage ? estimateCostUsd(model, usage) : null);
144
- return {
145
- result,
146
- usage: {
147
- inputTokens: usage.inputTokens,
148
- outputTokens: usage.outputTokens,
149
- cacheReadTokens: usage.cacheReadTokens,
150
- cacheCreationTokens: usage.cacheCreationTokens,
151
- },
152
- costUsd,
153
- numTurns: result?.num_turns ?? null,
154
- aborted: false,
155
- };
163
+ finally {
164
+ restoreEnv();
165
+ }
156
166
  }
157
167
  /**
158
168
  * Run the write-capable coding agent against a PR's head in an ephemeral worktree and