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.
- 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/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 +357 -51
- 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-DMoHaevz.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-CBzfgOYF.js +0 -11
- package/public/assets/index-C1obqq3D.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/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.
|
package/dist/coding/agent.js
CHANGED
|
@@ -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:
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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:
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|