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.
- package/dist/api/plugins/auth.js +4 -0
- package/dist/api/routes/activity.js +2 -0
- package/dist/api/routes/bot-triage.js +12 -5
- package/dist/api/routes/prs.js +49 -46
- package/dist/api/routes/repos.js +7 -6
- package/dist/api/routes/teams.js +150 -0
- package/dist/api/routes/webhooks.js +181 -0
- package/dist/app.js +8 -0
- package/dist/coding/agent.js +80 -70
- package/dist/config.js +34 -2
- package/dist/db/migrations/0030_teams.sql +32 -0
- package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
- package/dist/db/migrations/meta/_journal.json +14 -0
- package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
- package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
- package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
- package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
- package/dist/db/migrations-pg/meta/_journal.json +14 -0
- package/dist/db/queries.js +438 -66
- package/dist/db/schema.pg.js +39 -0
- package/dist/db/schema.sqlite.js +44 -0
- package/dist/github/client.js +25 -0
- package/dist/github/queries.js +211 -185
- package/dist/pro/bind.js +13 -5
- package/dist/review/agent.js +3 -1
- package/dist/review/local-settings.js +35 -0
- package/dist/sync/adaptive.js +107 -0
- package/dist/sync/sync-manager.js +19 -0
- package/dist/sync/sync-one-pr.js +156 -0
- package/package.json +1 -1
- package/public/assets/{AiFixTab-CMJeCtop.js → AiFixTab-BxRIMl9R.js} +1 -1
- package/public/assets/ClaudeReviewTab-BTBkfp6H.js +11 -0
- package/public/assets/index-Bwrc3KJr.css +1 -0
- package/public/assets/index-Cv9-yWxl.js +53 -0
- package/public/index.html +2 -2
- package/public/assets/ClaudeReviewTab-zKtMbB1-.js +0 -11
- package/public/assets/index-CxFDrc36.js +0 -53
- package/public/assets/index-X4Ro8Jjo.css +0 -1
package/dist/api/plugins/auth.js
CHANGED
|
@@ -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
|
|
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
|
|
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).
|
package/dist/api/routes/prs.js
CHANGED
|
@@ -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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// inferred team(s), then history users,
|
|
168
|
-
async function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
238
|
+
extraUsers.push(u);
|
|
257
239
|
}
|
|
258
240
|
catch {
|
|
259
|
-
/* best-effort:
|
|
241
|
+
/* best-effort: fall back to the history-user suggestions */
|
|
260
242
|
}
|
|
261
|
-
return
|
|
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
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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,
|
package/dist/api/routes/repos.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
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.
|