pierre-review 0.1.37 → 0.1.39
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/routes/claude-review.js +48 -1
- package/dist/api/routes/inbox.js +28 -0
- package/dist/api/routes/me.js +2 -0
- package/dist/app.js +2 -0
- package/dist/config.js +13 -0
- package/dist/db/queries.js +139 -0
- package/dist/index.js +6 -0
- package/dist/pro/bind.js +77 -0
- package/dist/pro/contract.js +12 -0
- package/dist/pro/migrate.js +64 -0
- package/dist/review/agent.js +1 -0
- package/dist/review/events.js +31 -0
- package/dist/review/llm.js +92 -0
- package/dist/review/prompt.js +11 -1
- package/dist/review/review-manager.js +10 -0
- package/package.json +1 -1
- package/public/assets/index-BLhZKzDf.css +10 -0
- package/public/assets/{index-Dtly4BVo.js → index-Duzg3Uet.js} +94 -94
- package/public/index.html +2 -2
- package/public/assets/index-BwYHoTeQ.css +0 -10
|
@@ -7,6 +7,7 @@ import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-se
|
|
|
7
7
|
import { buildAnchorIndex, buildReview, fallbackAnchor, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, prLevelFindingBody, stripNoiseFromDiff, submitGithubComment, submitGithubIssueComment, submitGithubReview, } from '../../review/post-review.js';
|
|
8
8
|
import { isNoiseFile } from '../../review/prompt.js';
|
|
9
9
|
import { accountIdOf } from '../plugins/auth.js';
|
|
10
|
+
import { reviewEvents } from '../../review/events.js';
|
|
10
11
|
const MODELS = ['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
|
|
11
12
|
const VERDICTS = ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'];
|
|
12
13
|
const REVIEW_MODES = ['auto', 'diff_only', 'worktree'];
|
|
@@ -158,6 +159,13 @@ export async function claudeReviewRoutes(app) {
|
|
|
158
159
|
: 'The review queue is full; try again once some finish.',
|
|
159
160
|
};
|
|
160
161
|
}
|
|
162
|
+
reviewEvents.emit({
|
|
163
|
+
type: 'review.requested',
|
|
164
|
+
accountId: accountIdOf(req),
|
|
165
|
+
prId: id,
|
|
166
|
+
model,
|
|
167
|
+
requestedMode: mode ?? 'auto',
|
|
168
|
+
});
|
|
161
169
|
reply.status(202);
|
|
162
170
|
return { reviewId: result.reviewId, status: 'queued' };
|
|
163
171
|
});
|
|
@@ -218,6 +226,12 @@ export async function claudeReviewRoutes(app) {
|
|
|
218
226
|
reply.status(404);
|
|
219
227
|
return { error: 'NotFound', message: `Review ${reviewId} not found` };
|
|
220
228
|
}
|
|
229
|
+
reviewEvents.emit({
|
|
230
|
+
type: 'review.draftUpdated',
|
|
231
|
+
accountId: accountIdOf(req),
|
|
232
|
+
reviewId,
|
|
233
|
+
change: { userBody: body.userBody, userVerdict: body.userVerdict },
|
|
234
|
+
});
|
|
221
235
|
return { status: 'ok' };
|
|
222
236
|
});
|
|
223
237
|
// Tick a finding for inline posting and/or save the user's reworded body.
|
|
@@ -231,6 +245,12 @@ export async function claudeReviewRoutes(app) {
|
|
|
231
245
|
reply.status(404);
|
|
232
246
|
return { error: 'NotFound', message: `Finding ${findingId} not found` };
|
|
233
247
|
}
|
|
248
|
+
reviewEvents.emit({
|
|
249
|
+
type: 'finding.updated',
|
|
250
|
+
accountId: accountIdOf(req),
|
|
251
|
+
findingId,
|
|
252
|
+
change: { included: body.included, editedBody: body.editedBody },
|
|
253
|
+
});
|
|
234
254
|
return { status: 'ok' };
|
|
235
255
|
});
|
|
236
256
|
// Post a single finding as a standalone comment (no review submitted). The
|
|
@@ -243,7 +263,8 @@ export async function claudeReviewRoutes(app) {
|
|
|
243
263
|
if (!config.claudeReviewEnabled)
|
|
244
264
|
return featureOff(reply);
|
|
245
265
|
const { findingId } = req.params;
|
|
246
|
-
const
|
|
266
|
+
const accountId = accountIdOf(req);
|
|
267
|
+
const ctx = await getFindingPostContext(findingId, accountId);
|
|
247
268
|
if (!ctx) {
|
|
248
269
|
reply.status(404);
|
|
249
270
|
return { error: 'NotFound', message: `Finding ${findingId} not found` };
|
|
@@ -276,6 +297,12 @@ export async function claudeReviewRoutes(app) {
|
|
|
276
297
|
}),
|
|
277
298
|
});
|
|
278
299
|
await markFindingPosted(findingId, commentId, 'inline');
|
|
300
|
+
reviewEvents.emit({
|
|
301
|
+
type: 'finding.posted',
|
|
302
|
+
accountId,
|
|
303
|
+
findingId,
|
|
304
|
+
postedCommentKind: 'inline',
|
|
305
|
+
});
|
|
279
306
|
const result = { githubCommentId: commentId, postedAt };
|
|
280
307
|
return result;
|
|
281
308
|
}
|
|
@@ -295,6 +322,12 @@ export async function claudeReviewRoutes(app) {
|
|
|
295
322
|
body: findingCommentBody({ body: f.body, editedBody: f.editedBody, suggestion: f.suggestion }, { fallbackNote: true }),
|
|
296
323
|
});
|
|
297
324
|
await markFindingPosted(findingId, commentId, 'inline');
|
|
325
|
+
reviewEvents.emit({
|
|
326
|
+
type: 'finding.posted',
|
|
327
|
+
accountId,
|
|
328
|
+
findingId,
|
|
329
|
+
postedCommentKind: 'inline',
|
|
330
|
+
});
|
|
298
331
|
const result = { githubCommentId: commentId, postedAt };
|
|
299
332
|
return result;
|
|
300
333
|
}
|
|
@@ -312,6 +345,12 @@ export async function claudeReviewRoutes(app) {
|
|
|
312
345
|
}),
|
|
313
346
|
});
|
|
314
347
|
await markFindingPosted(findingId, commentId, 'pr_comment');
|
|
348
|
+
reviewEvents.emit({
|
|
349
|
+
type: 'finding.posted',
|
|
350
|
+
accountId,
|
|
351
|
+
findingId,
|
|
352
|
+
postedCommentKind: 'pr_comment',
|
|
353
|
+
});
|
|
315
354
|
const result = { githubCommentId: commentId, postedAt };
|
|
316
355
|
return result;
|
|
317
356
|
}
|
|
@@ -398,6 +437,14 @@ export async function claudeReviewRoutes(app) {
|
|
|
398
437
|
}
|
|
399
438
|
}
|
|
400
439
|
await markReviewPosted(reviewId, ghReviewId, built.inlineFindingIds, prCommentResults);
|
|
440
|
+
reviewEvents.emit({
|
|
441
|
+
type: 'review.posted',
|
|
442
|
+
accountId,
|
|
443
|
+
reviewId,
|
|
444
|
+
userVerdict,
|
|
445
|
+
inlineFindingIds: built.inlineFindingIds,
|
|
446
|
+
prCommentFindingIds: prCommentResults.map((r) => r.findingId),
|
|
447
|
+
});
|
|
401
448
|
const result = {
|
|
402
449
|
postedReviewId: ghReviewId,
|
|
403
450
|
postedAt: new Date().toISOString(),
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getInbox, listClaudeReviewsByRepo } from '../../db/queries.js';
|
|
2
|
+
import { accountIdOf } from '../plugins/auth.js';
|
|
3
|
+
function parseIntList(raw) {
|
|
4
|
+
if (!raw)
|
|
5
|
+
return null;
|
|
6
|
+
const ids = raw
|
|
7
|
+
.split(',')
|
|
8
|
+
.map((s) => Number.parseInt(s.trim(), 10))
|
|
9
|
+
.filter((n) => Number.isFinite(n));
|
|
10
|
+
return ids.length > 0 ? ids : null;
|
|
11
|
+
}
|
|
12
|
+
export async function inboxRoutes(app) {
|
|
13
|
+
// The Inbox aggregate: per watched repo, current-state stats + thread totals +
|
|
14
|
+
// attention/unread flags + open PRs. Scoped to the account; `repoIds` narrows to
|
|
15
|
+
// the active watched-repo selection. Pure DB read — no GitHub sync, no AI.
|
|
16
|
+
app.get('/api/inbox', async (req) => {
|
|
17
|
+
const q = req.query;
|
|
18
|
+
return getInbox(accountIdOf(req), parseIntList(q.repoIds));
|
|
19
|
+
});
|
|
20
|
+
// Repo-scoped Claude-review history for the Inbox single-repo console. Ownership +
|
|
21
|
+
// feature-gating are handled inside the query (an unowned repo / disabled feature
|
|
22
|
+
// both return an empty list), so the caller never leaks another account's data.
|
|
23
|
+
app.get('/api/repos/:id/claude-reviews', async (req) => {
|
|
24
|
+
const { id } = req.params;
|
|
25
|
+
return listClaudeReviewsByRepo(Number.parseInt(id, 10), accountIdOf(req));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=inbox.js.map
|
package/dist/api/routes/me.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { config } from '../../config.js';
|
|
2
2
|
import { accountToLocalUser } from '../../auth/account.js';
|
|
3
3
|
import { accountIdOf } from '../plugins/auth.js';
|
|
4
|
+
import { getProCapabilities } from '../../pro/contract.js';
|
|
4
5
|
import { dismissMyTurn, getCompletedDismissals, getMyTurn, undismissMyTurn, } from '../../db/queries.js';
|
|
5
6
|
const dismissSchema = {
|
|
6
7
|
body: {
|
|
@@ -32,6 +33,7 @@ export async function meRoutes(app) {
|
|
|
32
33
|
},
|
|
33
34
|
claudeReviewEnabled: config.claudeReviewEnabled,
|
|
34
35
|
deploymentMode: config.deploymentMode,
|
|
36
|
+
pro: getProCapabilities(),
|
|
35
37
|
};
|
|
36
38
|
});
|
|
37
39
|
app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
|
package/dist/app.js
CHANGED
|
@@ -18,6 +18,7 @@ import { openPrsRoutes } from './api/routes/open-prs.js';
|
|
|
18
18
|
import { feedRoutes } from './api/routes/feed.js';
|
|
19
19
|
import { mergersRoutes } from './api/routes/mergers.js';
|
|
20
20
|
import { insightsRoutes } from './api/routes/insights.js';
|
|
21
|
+
import { inboxRoutes } from './api/routes/inbox.js';
|
|
21
22
|
import { claudeReviewRoutes } from './api/routes/claude-review.js';
|
|
22
23
|
export async function buildApp() {
|
|
23
24
|
const app = Fastify({
|
|
@@ -130,6 +131,7 @@ export async function buildApp() {
|
|
|
130
131
|
await app.register(feedRoutes);
|
|
131
132
|
await app.register(mergersRoutes);
|
|
132
133
|
await app.register(insightsRoutes);
|
|
134
|
+
await app.register(inboxRoutes);
|
|
133
135
|
// Claude Review is local-only + opt-in. Only register its routes when enabled,
|
|
134
136
|
// so the clone-manager / gh-CLI dependency is unreachable in cloud mode.
|
|
135
137
|
if (config.claudeReviewEnabled)
|
package/dist/config.js
CHANGED
|
@@ -118,6 +118,19 @@ export const config = {
|
|
|
118
118
|
// HSTS_MAX_AGE=0 as a kill switch. `preload` is intentionally NOT sent — it is
|
|
119
119
|
// hard to undo; opt in manually once the domain is proven.
|
|
120
120
|
hstsMaxAge: intFromEnv('HSTS_MAX_AGE', 31536000),
|
|
121
|
+
// Pro plugin master gate. Pro is local-only for now; bind.ts skips the dynamic
|
|
122
|
+
// import entirely when false.
|
|
123
|
+
proEnabled: !isCloud,
|
|
124
|
+
// Per-repo digest (Pro, Workstream 2) config — consumed by @pierre/pro, kept in
|
|
125
|
+
// core so the model id / budgets live in one place and aren't hardcoded at call
|
|
126
|
+
// sites. Inert until the plugin is present AND digestEnabled.
|
|
127
|
+
pro: {
|
|
128
|
+
digestModel: process.env.PRO_DIGEST_MODEL ?? 'claude-haiku-4-5',
|
|
129
|
+
digestEnabled: process.env.PRO_DIGEST_ENABLED === 'true',
|
|
130
|
+
digestMaxUsdPerRefresh: floatFromEnv('PRO_DIGEST_MAX_USD', 0.5),
|
|
131
|
+
digestMaxReposPerRefresh: intFromEnv('PRO_DIGEST_MAX_REPOS', 30),
|
|
132
|
+
digestMinIntervalSec: intFromEnv('PRO_DIGEST_MIN_INTERVAL_SEC', 60),
|
|
133
|
+
},
|
|
121
134
|
// ---- Claude Review (agentic PR review; opt-in, LOCAL-ONLY) ----
|
|
122
135
|
// OFF by default: the feature spends real money / Agent-SDK credits per run.
|
|
123
136
|
// Enable with ENABLE_CLAUDE_REVIEW=true. FORCE-DISABLED in cloud mode (it
|
package/dist/db/queries.js
CHANGED
|
@@ -1015,6 +1015,86 @@ export async function getMergers(accountId) {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
return [...byRepo.entries()].map(([repoId, userIds]) => ({ repoId, userIds }));
|
|
1017
1017
|
}
|
|
1018
|
+
// ---- inbox (CORE, always-on, no AI) ----
|
|
1019
|
+
// Reason tags that, on an open PR, count as "needs attention" for the Inbox rail
|
|
1020
|
+
// badge (a my-turn-shaped triage reason). Stalled + untouched threads are folded
|
|
1021
|
+
// in separately via the per-PR flags. Keep in sync with REASON_PRIORITY values.
|
|
1022
|
+
const INBOX_ATTENTION_REASONS = new Set([
|
|
1023
|
+
'awaiting_your_review',
|
|
1024
|
+
'your_pr_new_comments',
|
|
1025
|
+
'ci_failing',
|
|
1026
|
+
'merge_conflicts',
|
|
1027
|
+
'untouched_threads',
|
|
1028
|
+
]);
|
|
1029
|
+
// The Inbox aggregate: per watched repo, current-state stats + a per-repo thread
|
|
1030
|
+
// total + maintainer ids + attention/unread flags + the open PRs (caller groups by
|
|
1031
|
+
// author). Composes EXISTING accountId-scoped readers — getInsights / getOpenPrs /
|
|
1032
|
+
// getMergers / listRepos — so isolation + triage logic stay single-sourced. The
|
|
1033
|
+
// one genuinely new aggregation is `threadTotals` (sum each open PR's threadCounts
|
|
1034
|
+
// per repo). Every watched repo is included (a quiet repo → empty prs, zeroed stats).
|
|
1035
|
+
export async function getInbox(accountId, repoIds) {
|
|
1036
|
+
const reposAll = await listRepos(accountId);
|
|
1037
|
+
const reposScoped = repoIds ? reposAll.filter((r) => repoIds.includes(r.id)) : reposAll;
|
|
1038
|
+
const [insights, openPrs, mergers] = await Promise.all([
|
|
1039
|
+
getInsights({ accountId, repoIds }),
|
|
1040
|
+
getOpenPrs({ accountId, repoIds, userIds: null }),
|
|
1041
|
+
getMergers(accountId),
|
|
1042
|
+
]);
|
|
1043
|
+
const insightsByRepo = new Map(insights.repos.map((r) => [r.repoId, r]));
|
|
1044
|
+
const mergersByRepo = new Map(mergers.map((m) => [m.repoId, m.userIds]));
|
|
1045
|
+
const prsByRepo = new Map();
|
|
1046
|
+
for (const pr of openPrs) {
|
|
1047
|
+
const arr = prsByRepo.get(pr.repoId);
|
|
1048
|
+
if (arr)
|
|
1049
|
+
arr.push(pr);
|
|
1050
|
+
else
|
|
1051
|
+
prsByRepo.set(pr.repoId, [pr]);
|
|
1052
|
+
}
|
|
1053
|
+
// Preserve listRepos order (stable, not jumpy across loads).
|
|
1054
|
+
const inboxRepos = reposScoped.map((repo) => {
|
|
1055
|
+
const repoPrs = prsByRepo.get(repo.id) ?? [];
|
|
1056
|
+
const ins = insightsByRepo.get(repo.id);
|
|
1057
|
+
const stats = ins
|
|
1058
|
+
? {
|
|
1059
|
+
openPrs: ins.openPrs,
|
|
1060
|
+
draftPrs: ins.draftPrs,
|
|
1061
|
+
mergedLast7d: ins.mergedLast7d,
|
|
1062
|
+
stalledPrs: ins.stalledPrs,
|
|
1063
|
+
medianHoursToFirstReview: ins.medianHoursToFirstReview,
|
|
1064
|
+
oldestUnreviewed: ins.oldestUnreviewed,
|
|
1065
|
+
}
|
|
1066
|
+
: {
|
|
1067
|
+
openPrs: 0,
|
|
1068
|
+
draftPrs: 0,
|
|
1069
|
+
mergedLast7d: 0,
|
|
1070
|
+
stalledPrs: 0,
|
|
1071
|
+
medianHoursToFirstReview: null,
|
|
1072
|
+
oldestUnreviewed: null,
|
|
1073
|
+
};
|
|
1074
|
+
const threadTotals = emptyCounts();
|
|
1075
|
+
for (const pr of repoPrs) {
|
|
1076
|
+
threadTotals.untouched += pr.threadCounts.untouched;
|
|
1077
|
+
threadTotals.replied_unresolved += pr.threadCounts.replied_unresolved;
|
|
1078
|
+
threadTotals.likely_addressed += pr.threadCounts.likely_addressed;
|
|
1079
|
+
threadTotals.resolved += pr.threadCounts.resolved;
|
|
1080
|
+
}
|
|
1081
|
+
const attentionCount = repoPrs.filter((pr) => pr.isStalled ||
|
|
1082
|
+
pr.threadCounts.untouched > 0 ||
|
|
1083
|
+
INBOX_ATTENTION_REASONS.has(pr.reasonTag)).length;
|
|
1084
|
+
const hasUnread = repoPrs.some((pr) => pr.newSinceLastViewed != null);
|
|
1085
|
+
return {
|
|
1086
|
+
repoId: repo.id,
|
|
1087
|
+
repoFullName: repo.fullName,
|
|
1088
|
+
stats,
|
|
1089
|
+
threadTotals,
|
|
1090
|
+
maintainerIds: mergersByRepo.get(repo.id) ?? [],
|
|
1091
|
+
attentionCount,
|
|
1092
|
+
hasUnread,
|
|
1093
|
+
prs: repoPrs,
|
|
1094
|
+
};
|
|
1095
|
+
});
|
|
1096
|
+
return { repos: inboxRepos, generatedAt: new Date().toISOString() };
|
|
1097
|
+
}
|
|
1018
1098
|
// ---- incremental review: pr_views ----
|
|
1019
1099
|
export async function markPrViewed(prId, accountId, sha) {
|
|
1020
1100
|
const prRows = await db
|
|
@@ -2213,6 +2293,65 @@ export async function listClaudeReviewHistory(prId, accountId) {
|
|
|
2213
2293
|
finishedAt: iso(r.finishedAt),
|
|
2214
2294
|
}));
|
|
2215
2295
|
}
|
|
2296
|
+
// Repo-oriented Claude-review retrieval for the Inbox single-repo console: ALL runs
|
|
2297
|
+
// for a repo's PRs, grouped by PR (newest run first within each), PRs ordered by
|
|
2298
|
+
// most-recent run desc. Richer than listAllClaudeReviews (which keeps only one
|
|
2299
|
+
// latest-succeeded run per PR). IDOR-sensitive id getter: scoped by accountId, and
|
|
2300
|
+
// gated on config.claudeReviewEnabled. An unowned repo (cross-account) → empty list.
|
|
2301
|
+
export async function listClaudeReviewsByRepo(repoId, accountId) {
|
|
2302
|
+
if (!config.claudeReviewEnabled)
|
|
2303
|
+
return { enabled: false, prs: [] };
|
|
2304
|
+
// Ownership: a repo not owned by this account leaks nothing (404-equivalent).
|
|
2305
|
+
const owned = await getRepo(repoId, accountId);
|
|
2306
|
+
if (!owned)
|
|
2307
|
+
return { enabled: config.claudeReviewEnabled, prs: [] };
|
|
2308
|
+
const rows = await db
|
|
2309
|
+
.select({
|
|
2310
|
+
review: claudeReviews,
|
|
2311
|
+
prNumber: pullRequests.number,
|
|
2312
|
+
prTitle: pullRequests.title,
|
|
2313
|
+
prState: pullRequests.state,
|
|
2314
|
+
authorId: pullRequests.authorId,
|
|
2315
|
+
})
|
|
2316
|
+
.from(claudeReviews)
|
|
2317
|
+
.innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
|
|
2318
|
+
.where(and(eq(claudeReviews.accountId, accountId), eq(pullRequests.repoId, repoId)))
|
|
2319
|
+
.orderBy(desc(claudeReviews.id))
|
|
2320
|
+
.execute();
|
|
2321
|
+
// Rows are globally id-desc (newest-first). The first row seen for each PR is its
|
|
2322
|
+
// newest run, so map-insertion order = PRs by most-recent run desc, and each PR's
|
|
2323
|
+
// runs[] accumulate newest-first — no extra sort needed.
|
|
2324
|
+
const byPr = new Map();
|
|
2325
|
+
for (const { review: r, prNumber, prTitle, prState, authorId } of rows) {
|
|
2326
|
+
const summary = {
|
|
2327
|
+
id: r.id,
|
|
2328
|
+
headSha: r.headSha,
|
|
2329
|
+
status: r.status,
|
|
2330
|
+
model: r.model,
|
|
2331
|
+
scope: r.scope,
|
|
2332
|
+
reviewMode: r.reviewMode,
|
|
2333
|
+
verdict: r.verdict,
|
|
2334
|
+
userVerdict: r.userVerdict,
|
|
2335
|
+
costUsd: r.costUsd,
|
|
2336
|
+
postedAt: iso(r.postedAt),
|
|
2337
|
+
createdAt: r.createdAt.toISOString(),
|
|
2338
|
+
finishedAt: iso(r.finishedAt),
|
|
2339
|
+
};
|
|
2340
|
+
const existing = byPr.get(r.prId);
|
|
2341
|
+
if (existing)
|
|
2342
|
+
existing.runs.push(summary);
|
|
2343
|
+
else
|
|
2344
|
+
byPr.set(r.prId, {
|
|
2345
|
+
prId: r.prId,
|
|
2346
|
+
prNumber,
|
|
2347
|
+
prTitle,
|
|
2348
|
+
prState,
|
|
2349
|
+
authorId,
|
|
2350
|
+
runs: [summary],
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
return { enabled: true, prs: [...byPr.values()] };
|
|
2354
|
+
}
|
|
2216
2355
|
// Cross-PR list of prior Claude reviews: ONE entry per PR = that PR's most-recent
|
|
2217
2356
|
// SUCCEEDED run, accountId-scoped, restricted to PRs still within the timeline
|
|
2218
2357
|
// window (open, or last touched within `backfillDays`), newest-first by finish
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,12 @@ export async function start() {
|
|
|
34
34
|
const { reconcileReviewsOnStartup } = await import('./review/review-manager.js');
|
|
35
35
|
await reconcileReviewsOnStartup(app.log);
|
|
36
36
|
}
|
|
37
|
+
// Bind the optional Pro plugin (dynamic import; no-ops in OSS mode). Same
|
|
38
|
+
// "optional subsystem, degrade gracefully" posture as the scheduler below.
|
|
39
|
+
{
|
|
40
|
+
const { bindProPlugin } = await import('./pro/bind.js');
|
|
41
|
+
await bindProPlugin(app);
|
|
42
|
+
}
|
|
37
43
|
// Scheduler is wired in Phase 3; guarded so the skeleton runs without it.
|
|
38
44
|
if (!config.disableScheduler) {
|
|
39
45
|
try {
|
package/dist/pro/bind.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import { accountIdOf } from '../api/plugins/auth.js';
|
|
6
|
+
import { db, schema, runTransaction, isPg } from '../db/client.js';
|
|
7
|
+
import * as hostQueries from '../db/queries.js';
|
|
8
|
+
import { reviewEvents, registerLearningsProvider } from '../review/events.js';
|
|
9
|
+
import { cheapComplete } from '../review/llm.js';
|
|
10
|
+
import { runPluginMigrations } from './migrate.js';
|
|
11
|
+
import { setProCapabilities } from './contract.js';
|
|
12
|
+
// Boot binding for the optional @pierre/pro plugin. The plugin lives in a PRIVATE
|
|
13
|
+
// git submodule at packages/pro (often absent — a pure-OSS checkout never has it).
|
|
14
|
+
// It is deliberately NOT a declared dependency, so `pnpm install` succeeds without
|
|
15
|
+
// it; we resolve it by FILESYSTEM PATH rather than a bare specifier (no package.json
|
|
16
|
+
// coupling). Submodule absent → no entry file on disk → clean OSS no-op.
|
|
17
|
+
export async function bindProPlugin(app) {
|
|
18
|
+
if (!config.proEnabled)
|
|
19
|
+
return; // master gate (Pro is local-only for now)
|
|
20
|
+
// here = apps/backend/{src|dist}/pro/bind.{ts|js} → repo root is four levels up.
|
|
21
|
+
const here = fileURLToPath(import.meta.url);
|
|
22
|
+
const repoRoot = resolve(dirname(here), '../../../..');
|
|
23
|
+
const entry = [
|
|
24
|
+
join(repoRoot, 'packages/pro/dist/index.js'), // built plugin (node)
|
|
25
|
+
join(repoRoot, 'packages/pro/src/index.ts'), // source (tsx dev)
|
|
26
|
+
].find(existsSync);
|
|
27
|
+
if (!entry) {
|
|
28
|
+
app.log.debug('pro plugin submodule absent — OSS mode');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const mod = await import(pathToFileURL(entry).href).catch((err) => {
|
|
32
|
+
app.log.warn({ err }, 'pro plugin present but failed to load — OSS mode');
|
|
33
|
+
return null;
|
|
34
|
+
});
|
|
35
|
+
if (!mod)
|
|
36
|
+
return;
|
|
37
|
+
const plugin = (mod.default ?? mod);
|
|
38
|
+
if (plugin?.apiVersion !== 1 || typeof plugin.register !== 'function') {
|
|
39
|
+
app.log.warn({ apiVersion: plugin?.apiVersion }, 'pro contract mismatch — skipped');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const ctx = {
|
|
43
|
+
log: app.log,
|
|
44
|
+
host: {
|
|
45
|
+
version: process.env.npm_package_version ?? '0.0.0',
|
|
46
|
+
deploymentMode: config.deploymentMode,
|
|
47
|
+
isCloud: config.isCloud,
|
|
48
|
+
},
|
|
49
|
+
accountIdOf,
|
|
50
|
+
db,
|
|
51
|
+
schema,
|
|
52
|
+
runTransaction,
|
|
53
|
+
isPg,
|
|
54
|
+
registerMigrations: (sqliteFolder, pgFolder) => runPluginMigrations(sqliteFolder, pgFolder),
|
|
55
|
+
llm: { complete: cheapComplete },
|
|
56
|
+
queries: {
|
|
57
|
+
getInsights: (accountId, repoIds) => hostQueries.getInsights({ accountId, repoIds: repoIds ?? null }),
|
|
58
|
+
getRepoAnalytics: (accountId, repoId) => hostQueries.getRepoAnalytics(accountId, repoId),
|
|
59
|
+
getOpenPrs: (args) => hostQueries.getOpenPrs({
|
|
60
|
+
accountId: args.accountId,
|
|
61
|
+
repoIds: args.repoIds ?? null,
|
|
62
|
+
userIds: null,
|
|
63
|
+
}),
|
|
64
|
+
getInbox: (accountId, repoIds) => hostQueries.getInbox(accountId, repoIds ?? null),
|
|
65
|
+
},
|
|
66
|
+
reviewEvents,
|
|
67
|
+
registerLearningsProvider,
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
setProCapabilities(await plugin.register(app, ctx));
|
|
71
|
+
app.log.info('pro plugin active');
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
app.log.warn({ err }, 'pro register() failed — OSS mode');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=bind.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// The live capability singleton, mirrored to the frontend via /api/me exactly
|
|
2
|
+
// like claudeReviewEnabled. All-false in OSS mode (no plugin ever calls the
|
|
3
|
+
// setter).
|
|
4
|
+
const EMPTY = { inboxDigest: false, reviewMemory: false };
|
|
5
|
+
let active = EMPTY;
|
|
6
|
+
export function setProCapabilities(c) {
|
|
7
|
+
active = c;
|
|
8
|
+
}
|
|
9
|
+
export function getProCapabilities() {
|
|
10
|
+
return active;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { db, isPg } from '../db/client.js';
|
|
4
|
+
// Plugin migration runner (core-owned). The optional @pierre/pro plugin ships its
|
|
5
|
+
// OWN dual-dialect *.sql migrations and calls ctx.registerMigrations(sqliteFolder,
|
|
6
|
+
// pgFolder); core executes them here against the live connection.
|
|
7
|
+
//
|
|
8
|
+
// ⚠ THIS IS THE ONLY PLACE the raw DB driver / non-portable terminals are used,
|
|
9
|
+
// and it is strictly DDL + bookkeeping (CREATE TABLE / INSERT into pro_migrations)
|
|
10
|
+
// — never a data query. Everything else in the codebase MUST go through the
|
|
11
|
+
// portable async drizzle surface (.execute()/.returning().execute()).
|
|
12
|
+
export async function runPluginMigrations(sqliteFolder, pgFolder) {
|
|
13
|
+
const folder = isPg ? pgFolder : sqliteFolder;
|
|
14
|
+
// The raw driver behind drizzle: a node-postgres Pool (pg) or a better-sqlite3
|
|
15
|
+
// Database (sqlite). drizzle exposes it as `$client`.
|
|
16
|
+
const client = db.$client;
|
|
17
|
+
// Run a raw SQL string (possibly several statements). pg's Pool.query and
|
|
18
|
+
// better-sqlite3's Database.exec both accept multiple semicolon-separated
|
|
19
|
+
// statements in a single call.
|
|
20
|
+
const execRaw = async (sqlText) => {
|
|
21
|
+
if (isPg)
|
|
22
|
+
await client.query(sqlText);
|
|
23
|
+
else
|
|
24
|
+
client.exec(sqlText);
|
|
25
|
+
};
|
|
26
|
+
// Bookkeeping table — which plugin migrations have been applied.
|
|
27
|
+
await execRaw('CREATE TABLE IF NOT EXISTS pro_migrations (name text PRIMARY KEY, applied_at ' +
|
|
28
|
+
(isPg ? 'timestamptz' : 'integer') +
|
|
29
|
+
' NOT NULL)');
|
|
30
|
+
const files = readdirSync(folder)
|
|
31
|
+
.filter((f) => f.endsWith('.sql'))
|
|
32
|
+
.sort();
|
|
33
|
+
for (const name of files) {
|
|
34
|
+
try {
|
|
35
|
+
let applied;
|
|
36
|
+
if (isPg) {
|
|
37
|
+
const r = await client.query('SELECT 1 FROM pro_migrations WHERE name=$1', [name]);
|
|
38
|
+
applied = r.rowCount > 0;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const row = client
|
|
42
|
+
.prepare('SELECT 1 FROM pro_migrations WHERE name=?')
|
|
43
|
+
.get(name);
|
|
44
|
+
applied = !!row;
|
|
45
|
+
}
|
|
46
|
+
if (applied)
|
|
47
|
+
continue;
|
|
48
|
+
const text = readFileSync(join(folder, name), 'utf8');
|
|
49
|
+
await execRaw(text);
|
|
50
|
+
if (isPg) {
|
|
51
|
+
await client.query('INSERT INTO pro_migrations(name,applied_at) VALUES($1,$2)', [name, new Date()]);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
client
|
|
55
|
+
.prepare('INSERT INTO pro_migrations(name,applied_at) VALUES(?,?)')
|
|
56
|
+
.run(name, Date.now());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
throw new Error(`pro migration '${name}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=migrate.js.map
|
package/dist/review/agent.js
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const listeners = new Set();
|
|
2
|
+
export const reviewEvents = {
|
|
3
|
+
on(listener) {
|
|
4
|
+
listeners.add(listener);
|
|
5
|
+
return () => {
|
|
6
|
+
listeners.delete(listener);
|
|
7
|
+
};
|
|
8
|
+
},
|
|
9
|
+
emit(e) {
|
|
10
|
+
// A subscriber error must NEVER break the review route that emitted, so each
|
|
11
|
+
// call is isolated; we deliberately swallow (log nothing) rather than throw.
|
|
12
|
+
for (const listener of listeners) {
|
|
13
|
+
try {
|
|
14
|
+
listener(e);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* a subscriber failure must not surface to the emitter */
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
// Single nullable provider registry. Null in OSS mode ⇒ the review prompt is
|
|
23
|
+
// byte-identical to today (review-manager passes priorReviewContext: undefined).
|
|
24
|
+
let _provider = null;
|
|
25
|
+
export function registerLearningsProvider(p) {
|
|
26
|
+
_provider = p;
|
|
27
|
+
}
|
|
28
|
+
export function getLearningsProvider() {
|
|
29
|
+
return _provider;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=events.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { getUserAnthropicKey } from './local-settings.js';
|
|
2
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5';
|
|
3
|
+
export async function cheapComplete(opts) {
|
|
4
|
+
const model = opts.model ?? DEFAULT_MODEL;
|
|
5
|
+
// A real API key (user-supplied local key wins, then ANTHROPIC_API_KEY) takes the
|
|
6
|
+
// cheap, metered, exact-cost raw path.
|
|
7
|
+
const apiKey = getUserAnthropicKey() ?? process.env.ANTHROPIC_API_KEY;
|
|
8
|
+
if (apiKey)
|
|
9
|
+
return rawComplete(apiKey, model, opts);
|
|
10
|
+
// No explicit key → use the Agent SDK, which resolves a CLAUDE_CODE_OAUTH_TOKEN
|
|
11
|
+
// or an ambient logged-in Claude session exactly as Claude Review does.
|
|
12
|
+
return agentComplete(model, opts);
|
|
13
|
+
}
|
|
14
|
+
// ---- raw @anthropic-ai/sdk path (explicit API key) ----------------------------
|
|
15
|
+
async function rawComplete(apiKey, model, opts) {
|
|
16
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
17
|
+
const client = new Anthropic({ apiKey });
|
|
18
|
+
const resp = await client.messages.create({
|
|
19
|
+
model,
|
|
20
|
+
max_tokens: opts.maxTokens ?? 700,
|
|
21
|
+
system: opts.system,
|
|
22
|
+
messages: [{ role: 'user', content: opts.prompt }],
|
|
23
|
+
});
|
|
24
|
+
const blocks = resp.content;
|
|
25
|
+
const text = (Array.isArray(blocks) ? blocks : [])
|
|
26
|
+
.map((b) => {
|
|
27
|
+
const block = b;
|
|
28
|
+
return block?.type === 'text' && typeof block.text === 'string'
|
|
29
|
+
? block.text
|
|
30
|
+
: '';
|
|
31
|
+
})
|
|
32
|
+
.join('');
|
|
33
|
+
return {
|
|
34
|
+
text: text.trim(),
|
|
35
|
+
usage: {
|
|
36
|
+
inputTokens: resp.usage.input_tokens,
|
|
37
|
+
outputTokens: resp.usage.output_tokens,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ---- Claude Agent SDK path (OAuth token / ambient session) --------------------
|
|
42
|
+
async function agentComplete(model, opts) {
|
|
43
|
+
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
|
44
|
+
// A single-turn, tool-less completion: the system prompt is folded into the user
|
|
45
|
+
// prompt (the SDK's systemPrompt is also passed for models that honour it). No
|
|
46
|
+
// tools, no settings, one turn — just text out.
|
|
47
|
+
const prompt = opts.system
|
|
48
|
+
? `${opts.system}\n\n---\n\n${opts.prompt}`
|
|
49
|
+
: opts.prompt;
|
|
50
|
+
const q = query({
|
|
51
|
+
prompt,
|
|
52
|
+
options: {
|
|
53
|
+
model,
|
|
54
|
+
systemPrompt: opts.system,
|
|
55
|
+
allowedTools: [],
|
|
56
|
+
maxTurns: 1,
|
|
57
|
+
permissionMode: 'bypassPermissions',
|
|
58
|
+
// Don't load the user's ~/.claude settings / MCP servers for a plain
|
|
59
|
+
// completion (mirrors review/agent.ts).
|
|
60
|
+
settingSources: [],
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
let text = '';
|
|
64
|
+
let inputTokens = 0;
|
|
65
|
+
let outputTokens = 0;
|
|
66
|
+
let errored = null;
|
|
67
|
+
for await (const message of q) {
|
|
68
|
+
const type = message.type;
|
|
69
|
+
if (type === 'assistant') {
|
|
70
|
+
const inner = (message.message ?? {});
|
|
71
|
+
for (const b of inner.content ?? []) {
|
|
72
|
+
if (b?.type === 'text' && typeof b.text === 'string')
|
|
73
|
+
text += b.text;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (type === 'result') {
|
|
77
|
+
const usage = (message.usage ?? {});
|
|
78
|
+
inputTokens = usage.input_tokens ?? inputTokens;
|
|
79
|
+
outputTokens = usage.output_tokens ?? outputTokens;
|
|
80
|
+
const subtype = String(message.subtype ?? '');
|
|
81
|
+
if (subtype && subtype !== 'success' && !text) {
|
|
82
|
+
errored = subtype;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const trimmed = text.trim();
|
|
87
|
+
if (!trimmed) {
|
|
88
|
+
throw new Error(`Agent SDK completion returned no text${errored ? ` (${errored})` : ''} — check Claude auth (ANTHROPIC_API_KEY, or run \`claude\` to sign in).`);
|
|
89
|
+
}
|
|
90
|
+
return { text: trimmed, usage: { inputTokens, outputTokens } };
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=llm.js.map
|