pierre-review 0.1.77 → 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 (38) 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/prs.js +49 -46
  5. package/dist/api/routes/repos.js +7 -6
  6. package/dist/api/routes/teams.js +150 -0
  7. package/dist/api/routes/webhooks.js +181 -0
  8. package/dist/app.js +8 -0
  9. package/dist/coding/agent.js +80 -70
  10. package/dist/config.js +34 -2
  11. package/dist/db/migrations/0030_teams.sql +32 -0
  12. package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
  13. package/dist/db/migrations/meta/_journal.json +14 -0
  14. package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
  15. package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
  16. package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
  17. package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
  18. package/dist/db/migrations-pg/meta/_journal.json +14 -0
  19. package/dist/db/queries.js +438 -66
  20. package/dist/db/schema.pg.js +39 -0
  21. package/dist/db/schema.sqlite.js +44 -0
  22. package/dist/github/client.js +25 -0
  23. package/dist/github/queries.js +211 -185
  24. package/dist/pro/bind.js +13 -5
  25. package/dist/review/agent.js +3 -1
  26. package/dist/review/local-settings.js +35 -0
  27. package/dist/sync/adaptive.js +107 -0
  28. package/dist/sync/sync-manager.js +19 -0
  29. package/dist/sync/sync-one-pr.js +156 -0
  30. package/package.json +1 -1
  31. package/public/assets/{AiFixTab-CMJeCtop.js → AiFixTab-BxRIMl9R.js} +1 -1
  32. package/public/assets/ClaudeReviewTab-BTBkfp6H.js +11 -0
  33. package/public/assets/index-Bwrc3KJr.css +1 -0
  34. package/public/assets/index-Cv9-yWxl.js +53 -0
  35. package/public/index.html +2 -2
  36. package/public/assets/ClaudeReviewTab-zKtMbB1-.js +0 -11
  37. package/public/assets/index-CxFDrc36.js +0 -53
  38. 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).
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { config } from '../../config.js';
3
3
  import { getAccessToken, getAccountUserId } from '../../auth/account.js';
4
4
  import { fetchActionsJobLog } from '../../github/actions-logs.js';
5
- import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, getResolvableBotThreads, getUsersByLogins, markAllViewed, markPrMergedLocally, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
5
+ import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, getResolvableBotThreads, getSuggestedReviewersBasis, getUsersByLogins, markAllViewed, markPrMergedLocally, markPrViewed, stampReviewRequests, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
6
6
  import { getCodeownersMatch } from '../../github/codeowners.js';
7
7
  import { suggestTeamsFromHistory } from '../../github/team-reviewers.js';
8
8
  import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
@@ -155,29 +155,25 @@ const requestReviewersSchema = {
155
155
  },
156
156
  },
157
157
  };
158
- // Layer network-derived reviewer suggestions on top of the history-USER ones getPrDetail
159
- // already computed. Runs ONLY when the PR still warrants suggestions (same trigger getPrDetail
160
- // used). Two best-effort sources, fetched in parallel and both cached per-repo:
158
+ // Build the CORE "Suggested reviewers" set for a PR served as its OWN live query so it's
159
+ // never frozen inside the cached PR detail (it must empty the instant a reviewer is
160
+ // requested). Combines the history-USER basis (from synced data) with two best-effort,
161
+ // per-repo-cached network sources fetched in parallel:
161
162
  // • CODEOWNERS — declared ownership for the touched paths (users + teams).
162
163
  // • Team history — which team(s) are usually REQUESTED to review this repo (the behavioural
163
164
  // fallback when CODEOWNERS declares no team; repo-level, so it runs even when the PR
164
165
  // touches no owned path). See github/team-reviewers.ts.
165
- // Any failure (no CODEOWNERS, org wall, a repo that doesn't use team requests) simply leaves
166
- // the history-user suggestions untouched. Precedence: declared CODEOWNERS owners, then the
167
- // inferred team(s), then history users, up to a small cap. Mutates + returns the detail.
168
- async function enrichSuggestions(detail, accountId) {
169
- const wants = detail.state === 'open' &&
170
- !detail.isDraft &&
171
- detail.requestedReviewers.length === 0 &&
172
- detail.reviews.length === 0;
173
- if (!wants)
174
- return detail;
175
- const [owner, name] = detail.repoFullName.split('/');
176
- if (!owner || !name)
177
- return detail;
166
+ // Returns empty when the PR doesn't warrant suggestions. Any network failure (no CODEOWNERS,
167
+ // org wall, a repo that doesn't use team requests) degrades to just the history-user set.
168
+ // Precedence: declared CODEOWNERS owners, then the inferred team(s), then history users, cap 5.
169
+ async function buildSuggestedReviewers(basis, accountId) {
170
+ if (!basis.wants)
171
+ return { suggestedReviewers: [], users: [] };
172
+ const { owner, name, authorLogin, paths, suggestions, users } = basis;
173
+ const extraUsers = [];
174
+ let merged = suggestions;
178
175
  try {
179
176
  const token = await getAccessToken(accountId);
180
- const paths = detail.files.map((f) => f.path);
181
177
  const emptyMatch = { logins: [], teams: [] };
182
178
  const [match, historyTeams] = await Promise.all([
183
179
  paths.length > 0
@@ -185,13 +181,6 @@ async function enrichSuggestions(detail, accountId) {
185
181
  : Promise.resolve(emptyMatch),
186
182
  suggestTeamsFromHistory(token, owner, name, accountId),
187
183
  ]);
188
- if (match.logins.length === 0 &&
189
- match.teams.length === 0 &&
190
- historyTeams.length === 0)
191
- return detail;
192
- const authorLogin = detail.authorId != null
193
- ? detail.users.find((u) => u.id === detail.authorId)?.githubLogin ?? null
194
- : null;
195
184
  // Resolve @user owners to synced users (avatar/link); unsynced owners show login-only.
196
185
  // Exclude the PR author (GitHub rejects self-review requests).
197
186
  const uniqueLogins = [...new Set(match.logins)].filter((l) => l !== authorLogin);
@@ -226,17 +215,11 @@ async function enrichSuggestions(detail, accountId) {
226
215
  }));
227
216
  // Merge = precedence: CODEOWNERS users, CODEOWNERS teams (declared), inferred teams, then
228
217
  // the history-user suggestions. Dedup users by login and teams by slug (so a team that's
229
- // both a CODEOWNER and historically requested shows once, as the CODEOWNER), capped so the
230
- // row stays digestible.
218
+ // both a CODEOWNER and historically requested shows once, as the CODEOWNER).
231
219
  const seenLogins = new Set();
232
220
  const seenTeams = new Set();
233
- const merged = [];
234
- for (const s of [
235
- ...codeownerUsers,
236
- ...codeownerTeams,
237
- ...historyTeamSuggestions,
238
- ...detail.suggestedReviewers,
239
- ]) {
221
+ merged = [];
222
+ for (const s of [...codeownerUsers, ...codeownerTeams, ...historyTeamSuggestions, ...suggestions]) {
240
223
  if (s.kind === 'team') {
241
224
  if (s.teamSlug && !seenTeams.has(s.teamSlug)) {
242
225
  seenTeams.add(s.teamSlug);
@@ -248,17 +231,16 @@ async function enrichSuggestions(detail, accountId) {
248
231
  merged.push(s);
249
232
  }
250
233
  }
251
- detail.suggestedReviewers = merged.slice(0, 5);
252
- // Surface any newly-resolved codeowner users so the client can render avatars/links.
253
- const known = new Set(detail.users.map((u) => u.id));
234
+ // The newly-resolved codeowner users the basis didn't already carry.
235
+ const known = new Set(users.map((u) => u.id));
254
236
  for (const u of resolved)
255
237
  if (!known.has(u.id))
256
- detail.users.push(u);
238
+ extraUsers.push(u);
257
239
  }
258
240
  catch {
259
- /* best-effort: keep the history suggestions */
241
+ /* best-effort: fall back to the history-user suggestions */
260
242
  }
261
- return detail;
243
+ return { suggestedReviewers: merged.slice(0, 5), users: [...users, ...extraUsers] };
262
244
  }
263
245
  export async function prRoutes(app) {
264
246
  // Bulk "mark all seen": stamp every open PR (optionally scoped to repoIds) viewed
@@ -279,11 +261,25 @@ export async function prRoutes(app) {
279
261
  }
280
262
  // Cloud lean mode: fill in bulky text from GitHub (no-op in local). The client
281
263
  // caches the result in IndexedDB keyed by updatedAt so unchanged PRs don't refetch.
282
- const detail = await hydratePrDetail(pr, accountId);
283
- // Layer CODEOWNERS ownership + inferred review team(s) onto the history-based suggestions
284
- // (best-effort; never blocks the response). getPrDetail already gated the history part on
285
- // the same trigger.
286
- return enrichSuggestions(detail, accountId);
264
+ // (Suggested reviewers are NOT here — they're a separate live query, see below — so the
265
+ // cached detail never freezes a stale suggestion.)
266
+ return hydratePrDetail(pr, accountId);
267
+ });
268
+ // Suggested reviewers — its OWN live query (not embedded in the cached PR detail) so it
269
+ // always reflects current state: it empties the instant a reviewer is requested (the assign
270
+ // route stamps review_requests locally), rather than staying frozen until the PR's updatedAt
271
+ // next bumps. Best-effort network enrichment (CODEOWNERS + inferred team) on top of the
272
+ // synced history basis. 404s when the PR isn't the caller's.
273
+ app.get('/api/prs/:id/suggested-reviewers', { schema: idParamSchema }, async (req, reply) => {
274
+ const { id } = req.params;
275
+ const accountId = accountIdOf(req);
276
+ const basis = await getSuggestedReviewersBasis(id, accountId);
277
+ if (!basis) {
278
+ reply.status(404);
279
+ return { error: 'NotFound', message: `PR ${id} not found` };
280
+ }
281
+ const result = await buildSuggestedReviewers(basis, accountId);
282
+ return result;
287
283
  });
288
284
  // Candidates for an @mention autocomplete, ranked by proximity to this PR
289
285
  // (participants first, then repo people), self + bots excluded. Account-scoped:
@@ -790,7 +786,8 @@ export async function prRoutes(app) {
790
786
  // resolve to logins (also drops bots + unknown ids). Union with any direct logins
791
787
  // (suggested reviewers we haven't synced as users), deduped.
792
788
  const wanted = userIds.filter((uid) => uid !== ctx.authorId);
793
- const resolvedLogins = (await getReviewerLogins(wanted)).map((r) => r.login);
789
+ const resolved = await getReviewerLogins(wanted); // [{ userId, login }]
790
+ const resolvedLogins = resolved.map((r) => r.login);
794
791
  const logins = [...new Set([...resolvedLogins, ...directLogins])];
795
792
  const teams = [...new Set(teamSlugs)];
796
793
  if (logins.length === 0 && teams.length === 0) {
@@ -803,6 +800,12 @@ export async function prRoutes(app) {
803
800
  try {
804
801
  const token = await getAccessToken(accountId);
805
802
  await requestReviewers(token, ctx.owner, ctx.name, ctx.number, logins, teams);
803
+ // Optimistically stamp the request locally (mirrors approve/comment/merge) so the
804
+ // "Requested" row + the suggestion gate reflect it immediately; the next sync
805
+ // re-derives review_requests idempotently. Team handle = `owner/slug` (matches how a
806
+ // CODEOWNERS team + the suggestion render). Only the synced-user ids are stamped;
807
+ // unsynced direct logins land on the next sync.
808
+ await stampReviewRequests(ctx.prId, resolved.map((r) => r.userId), teams.map((slug) => `${ctx.owner}/${slug}`));
806
809
  const result = {
807
810
  status: 'ok',
808
811
  requestedLogins: logins,
@@ -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.