pierre-review 0.1.81 → 0.1.83

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.
@@ -33,6 +33,7 @@ export function registerAccountContext(app) {
33
33
  plan: 'free',
34
34
  stripeCustomerId: null,
35
35
  aiCreditAllowance: null,
36
+ benchmarkOptIn: false,
36
37
  };
37
38
  }
38
39
  });
@@ -1,5 +1,16 @@
1
1
  import { addBotMuteRule, deleteBotMuteRule, getBotAnalytics, getBotVendorPrs, getBotDedupClusters, listBotMuteRules, listDetectedReviewers, resolveScopeRepoIds, setReviewerOverride, } from '../../db/queries.js';
2
2
  import { accountIdOf } from '../plugins/auth.js';
3
+ // Parse a comma-separated id list into a positive-int array, or null when empty/absent (so
4
+ // the query layer treats it as "all repos"). Mirrors the parser in activity.ts.
5
+ function parseIntList(raw) {
6
+ if (raw == null || raw.trim() === '')
7
+ return null;
8
+ const ids = raw
9
+ .split(',')
10
+ .map((s) => Number.parseInt(s.trim(), 10))
11
+ .filter((n) => Number.isInteger(n) && n > 0);
12
+ return ids.length > 0 ? ids : null;
13
+ }
3
14
  // PATCH /api/bot-reviewers/:userId — the two-way manual override. `kind`/`label` are left as
4
15
  // open strings (nullable) rather than an enum: AutomatedReviewerKind is defined in the
5
16
  // types-only shared package the backend can't import at runtime, and the query layer coerces
@@ -34,6 +45,9 @@ const analyticsSchema = {
34
45
  },
35
46
  // Team scope: 'all' | 'none' | '<teamId>' (see resolveScopeRepoIds). Absent = all.
36
47
  scope: { type: 'string' },
48
+ // Explicit repo scope (comma-separated ids) — the per-repo Bots tab. When present it
49
+ // WINS over `scope` (a specific repo is the more specific selection).
50
+ repoIds: { type: 'string' },
37
51
  },
38
52
  },
39
53
  };
@@ -56,6 +70,7 @@ const vendorPrsSchema = {
56
70
  default: 'rolling_14',
57
71
  },
58
72
  scope: { type: 'string' },
73
+ repoIds: { type: 'string' },
59
74
  },
60
75
  },
61
76
  };
@@ -116,20 +131,23 @@ export async function botTriageRoutes(app) {
116
131
  // untouched + verdict + trend + deterministic tuning suggestions. Cost fields are null here
117
132
  // (the client overlays per-vendor cost from Pro settings).
118
133
  app.get('/api/bot-analytics', { schema: analyticsSchema }, async (req) => {
119
- const { window, scope } = req.query;
134
+ const { window, scope, repoIds } = req.query;
120
135
  const accountId = accountIdOf(req);
121
- const repoIds = scope ? await resolveScopeRepoIds(accountId, scope) : null;
122
- const resp = await getBotAnalytics(accountId, window, repoIds);
136
+ // An explicit repoIds list (the per-repo Bots tab) wins over the team scope.
137
+ const explicit = parseIntList(repoIds);
138
+ const scopeRepoIds = explicit ?? (scope ? await resolveScopeRepoIds(accountId, scope) : null);
139
+ const resp = await getBotAnalytics(accountId, window, scopeRepoIds);
123
140
  return resp;
124
141
  });
125
142
  // The per-vendor PR drill-down behind one Bot-ROI row: the PRs that automated reviewer kind
126
143
  // touched in the window (threads/comments/acted-on/untouched/bot-only), newest-activity first.
127
144
  app.get('/api/bot-analytics/:kind/prs', { schema: vendorPrsSchema }, async (req) => {
128
145
  const { kind } = req.params;
129
- const { window, scope } = req.query;
146
+ const { window, scope, repoIds } = req.query;
130
147
  const accountId = accountIdOf(req);
131
- const repoIds = scope ? await resolveScopeRepoIds(accountId, scope) : null;
132
- const resp = await getBotVendorPrs(accountId, kind, window, repoIds);
148
+ const explicit = parseIntList(repoIds);
149
+ const scopeRepoIds = explicit ?? (scope ? await resolveScopeRepoIds(accountId, scope) : null);
150
+ const resp = await getBotVendorPrs(accountId, kind, window, scopeRepoIds);
133
151
  return resp;
134
152
  });
135
153
  // Cross-bot dedup clusters for one PR (≥2 automated reviewers on the same path/line window).
@@ -1,5 +1,6 @@
1
1
  import { config } from '../../config.js';
2
- import { accountToLocalUser } from '../../auth/account.js';
2
+ import { accountToLocalUser, setBenchmarkConsent } from '../../auth/account.js';
3
+ import { runBenchmarkRollupForAccount } from '../../sync/benchmark-rollup.js';
3
4
  import { accountIdOf } from '../plugins/auth.js';
4
5
  import { EMPTY_CAPABILITIES, entitledProCapabilities, } from '../../pro/contract.js';
5
6
  import { countNewMyTurnFeedItems } from '../../feed/my-turn.js';
@@ -19,6 +20,14 @@ const dismissSchema = {
19
20
  },
20
21
  },
21
22
  };
23
+ const benchmarkConsentSchema = {
24
+ body: {
25
+ type: 'object',
26
+ required: ['optIn'],
27
+ additionalProperties: false,
28
+ properties: { optIn: { type: 'boolean' } },
29
+ },
30
+ };
22
31
  export async function meRoutes(app) {
23
32
  app.get('/api/me', async (req) => {
24
33
  const accountId = accountIdOf(req);
@@ -52,10 +61,29 @@ export async function meRoutes(app) {
52
61
  // Claude Review is now the Pro `claudeReview` capability (in `pro` below).
53
62
  deploymentMode: config.deploymentMode,
54
63
  pro: entitled,
64
+ // Cross-org benchmark consent (cloud-only; always false in local). Drives the Settings toggle.
65
+ benchmarkOptIn: config.isCloud ? req.account?.benchmarkOptIn ?? false : false,
55
66
  // Orgs currently SAML-blocked for this account (empty in the normal case + in local).
56
67
  authNotices: getAuthNotices(accountId),
57
68
  };
58
69
  });
70
+ // Cross-org benchmark consent (CLOUD-ONLY, opt-in). Setting it true seeds the account's
71
+ // contributions immediately (best-effort, in the background); false withdraws + deletes them
72
+ // (handled in setBenchmarkConsent). Available to every cloud account — free or paid — because
73
+ // the network needs volume to be worth anything (viewing the benchmark is the paid part, later).
74
+ app.post('/api/me/benchmark-consent', { schema: benchmarkConsentSchema }, async (req, reply) => {
75
+ if (!config.isCloud) {
76
+ return reply.code(400).send({ error: 'BadRequest', message: 'Benchmark is cloud-only' });
77
+ }
78
+ const accountId = accountIdOf(req);
79
+ const { optIn } = req.body;
80
+ await setBenchmarkConsent(accountId, optIn);
81
+ if (optIn) {
82
+ // Fire-and-forget: don't block the response on the rollup; a failure is logged, not fatal.
83
+ void runBenchmarkRollupForAccount(accountId, req.log).catch((err) => req.log.error({ err, accountId }, 'benchmark seed after opt-in failed'));
84
+ }
85
+ return { status: 'ok', benchmarkOptIn: optIn };
86
+ });
59
87
  app.get('/api/my-turn', async (req) => getMyTurn(accountIdOf(req)));
60
88
  app.post('/api/my-turn/dismiss', { schema: dismissSchema }, async (req) => {
61
89
  const { kind, refId } = req.body;
@@ -35,6 +35,7 @@ function rowToAccount(row) {
35
35
  plan: row.plan === 'pro' ? 'pro' : 'free',
36
36
  stripeCustomerId: row.stripeCustomerId,
37
37
  aiCreditAllowance: row.aiCreditAllowance ?? null,
38
+ benchmarkOptIn: row.benchmarkOptIn ?? false,
38
39
  };
39
40
  }
40
41
  // Module cache of the local account, set by ensureLocalAccount() at startup so
@@ -192,6 +193,23 @@ export async function setAccountPlan(accountId, plan, stripeCustomerId) {
192
193
  set.stripeCustomerId = stripeCustomerId;
193
194
  await db.update(accounts).set(set).where(eq(accounts.id, accountId)).execute();
194
195
  }
196
+ /**
197
+ * Set an account's cross-org benchmark consent (cloud-only feature). Withdrawing (optIn=false)
198
+ * DELETES the account's contributions — one-click, complete removal, honouring the consent
199
+ * promise. The caller (the /api/me/benchmark-consent route) seeds contributions on opt-in.
200
+ */
201
+ export async function setBenchmarkConsent(accountId, optIn) {
202
+ const { accounts } = schema;
203
+ await db
204
+ .update(accounts)
205
+ .set({ benchmarkOptIn: optIn })
206
+ .where(eq(accounts.id, accountId))
207
+ .execute();
208
+ if (!optIn) {
209
+ const { deleteBenchmarkContributions } = await import('../db/queries.js');
210
+ await deleteBenchmarkContributions(accountId);
211
+ }
212
+ }
195
213
  /** Resolve an account by its Stripe customer id (subscription webhooks). */
196
214
  export async function getAccountByStripeCustomerId(customerId) {
197
215
  const { accounts } = schema;
package/dist/config.js CHANGED
@@ -133,6 +133,9 @@ export const config = {
133
133
  // When the retention sweep runs (node-cron). Daily at 03:00 by default; off-peak so a
134
134
  // large delete doesn't contend with the 5-minute sync. RETENTION_CRON overrides.
135
135
  retentionCron: process.env.RETENTION_CRON ?? '0 3 * * *',
136
+ // Cross-org benchmark rollup (CLOUD-ONLY; inert unless an account has opted in). Weekly,
137
+ // Monday 04:00 UTC — after a fresh ISO week starts, off-peak. BENCHMARK_ROLLUP_CRON overrides.
138
+ benchmarkRollupCron: process.env.BENCHMARK_ROLLUP_CRON ?? '0 4 * * 1',
136
139
  // Disable the periodic scheduler (used by scripts/tests).
137
140
  disableScheduler: process.env.DISABLE_SCHEDULER === 'true',
138
141
  // ---- Cloud (Railway) — GitHub sign-in + sessions + token encryption ----
@@ -0,0 +1,29 @@
1
+ -- Cross-org benchmark network (CORE, cloud-only, opt-in), Phase 0: the opt-in flag on
2
+ -- accounts + the aggregate-only weekly contributions table. See schema.sqlite.ts for the
3
+ -- full rationale (no PII, in_house/pierre excluded, written only by the firewalled rollup).
4
+ -- The Postgres baseline is maintained separately (migrations-pg/0019_*).
5
+ ALTER TABLE `accounts` ADD `benchmark_opt_in` integer DEFAULT false NOT NULL;
6
+ --> statement-breakpoint
7
+ CREATE TABLE `benchmark_contributions` (
8
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
9
+ `account_id` integer NOT NULL,
10
+ `vendor_kind` text NOT NULL,
11
+ `week_start` integer NOT NULL,
12
+ `threads` integer DEFAULT 0 NOT NULL,
13
+ `comments` integer DEFAULT 0 NOT NULL,
14
+ `acted_on` integer DEFAULT 0 NOT NULL,
15
+ `untouched` integer DEFAULT 0 NOT NULL,
16
+ `human_follow` integer DEFAULT 0 NOT NULL,
17
+ `oldest_untouched_days` integer,
18
+ `org_size_bucket` text NOT NULL,
19
+ `ml_metrics` text,
20
+ `schema_version` integer DEFAULT 1 NOT NULL,
21
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
22
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE UNIQUE INDEX `bench_contrib_uniq` ON `benchmark_contributions` (`account_id`,`vendor_kind`,`week_start`);
26
+ --> statement-breakpoint
27
+ CREATE INDEX `bench_contrib_account_idx` ON `benchmark_contributions` (`account_id`);
28
+ --> statement-breakpoint
29
+ CREATE INDEX `bench_contrib_cohort_idx` ON `benchmark_contributions` (`vendor_kind`,`week_start`);
@@ -225,6 +225,13 @@
225
225
  "when": 1783820880004,
226
226
  "tag": "0031_events_pr_idx",
227
227
  "breakpoints": true
228
+ },
229
+ {
230
+ "idx": 32,
231
+ "version": "6",
232
+ "when": 1783820880005,
233
+ "tag": "0032_benchmark_contributions",
234
+ "breakpoints": true
228
235
  }
229
236
  ]
230
237
  }
@@ -0,0 +1,22 @@
1
+ ALTER TABLE "accounts" ADD COLUMN "benchmark_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
2
+ CREATE TABLE "benchmark_contributions" (
3
+ "id" serial PRIMARY KEY NOT NULL,
4
+ "account_id" integer NOT NULL,
5
+ "vendor_kind" text NOT NULL,
6
+ "week_start" timestamp with time zone NOT NULL,
7
+ "threads" integer DEFAULT 0 NOT NULL,
8
+ "comments" integer DEFAULT 0 NOT NULL,
9
+ "acted_on" integer DEFAULT 0 NOT NULL,
10
+ "untouched" integer DEFAULT 0 NOT NULL,
11
+ "human_follow" integer DEFAULT 0 NOT NULL,
12
+ "oldest_untouched_days" integer,
13
+ "org_size_bucket" text NOT NULL,
14
+ "ml_metrics" text,
15
+ "schema_version" integer DEFAULT 1 NOT NULL,
16
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
17
+ );
18
+ --> statement-breakpoint
19
+ ALTER TABLE "benchmark_contributions" ADD CONSTRAINT "benchmark_contributions_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
20
+ CREATE UNIQUE INDEX "bench_contrib_uniq" ON "benchmark_contributions" USING btree ("account_id","vendor_kind","week_start");--> statement-breakpoint
21
+ CREATE INDEX "bench_contrib_account_idx" ON "benchmark_contributions" USING btree ("account_id");--> statement-breakpoint
22
+ CREATE INDEX "bench_contrib_cohort_idx" ON "benchmark_contributions" USING btree ("vendor_kind","week_start");
@@ -134,6 +134,13 @@
134
134
  "when": 1784032874644,
135
135
  "tag": "0018_fancy_miss_america",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1784032874645,
142
+ "tag": "0019_benchmark_contributions",
143
+ "breakpoints": true
137
144
  }
138
145
  ]
139
- }
146
+ }
@@ -30,7 +30,7 @@ import { fingerprintReview } from '../sync/review-fingerprint.js';
30
30
  // timestamptz (drizzle binds the Date through the codec), whereas SQLite columns
31
31
  // are integer unix-epoch seconds (`mode:'timestamp'`), so we hand it the int.
32
32
  const tsBound = (d) => isPg ? d : Math.floor(d.getTime() / 1000);
33
- const { accounts, repos, users, pullRequests, reviewRequests, reviewThreads, reviewComments, prComments, reviews, commits, commitFiles, events, syncState, prViews, myTurnDismissals, claudeReviews, claudeReviewFindings, ciStatusEvents, botReviewClassification, botMuteRules, teams, teamRepos, } = schema;
33
+ const { accounts, repos, users, pullRequests, reviewRequests, reviewThreads, reviewComments, prComments, reviews, commits, commitFiles, events, syncState, prViews, myTurnDismissals, claudeReviews, claudeReviewFindings, ciStatusEvents, botReviewClassification, botMuteRules, teams, teamRepos, benchmarkContributions, } = schema;
34
34
  function iso(d) {
35
35
  return d ? d.toISOString() : null;
36
36
  }
@@ -5826,6 +5826,230 @@ scopeRepoIds) {
5826
5826
  };
5827
5827
  return { enabled: true, generatedAt, window: win, vendors, totals, suggestions };
5828
5828
  }
5829
+ // UTC Monday 00:00 of the ISO week containing `d`.
5830
+ export function isoWeekStartUtc(d) {
5831
+ const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
5832
+ const backToMonday = (t.getUTCDay() + 6) % 7; // getUTCDay: 0=Sun..6=Sat
5833
+ t.setUTCDate(t.getUTCDate() - backToMonday);
5834
+ return t;
5835
+ }
5836
+ // Contributor-size covariate bucket from a distinct active-author count.
5837
+ export function orgSizeBucket(contributors) {
5838
+ if (contributors <= 1)
5839
+ return '1';
5840
+ if (contributors <= 5)
5841
+ return '2-5';
5842
+ if (contributors <= 20)
5843
+ return '6-20';
5844
+ if (contributors <= 50)
5845
+ return '21-50';
5846
+ if (contributors <= 200)
5847
+ return '51-200';
5848
+ return '200+';
5849
+ }
5850
+ // Distinct PR authors across the account's repos over the last `days` — the org-size proxy
5851
+ // (the covariate a cohort conditions on). accountId-scoped.
5852
+ export async function getAccountContributorCount(accountId, days = 90) {
5853
+ const since = new Date(Date.now() - days * 86_400_000);
5854
+ const rows = await db
5855
+ .selectDistinct({ id: pullRequests.authorId })
5856
+ .from(pullRequests)
5857
+ .where(and(eq(pullRequests.accountId, accountId), gte(pullRequests.openedAt, since)))
5858
+ .execute();
5859
+ return rows.filter((r) => r.id != null).length;
5860
+ }
5861
+ // Accounts that have consented to contribute (cloud-only; local never contributes). The rollup
5862
+ // loops these. NOT a cross-tenant read of tenant data — just the consent roster.
5863
+ export async function getBenchmarkOptedInAccountIds() {
5864
+ const rows = await db
5865
+ .select({ id: accounts.id })
5866
+ .from(accounts)
5867
+ .where(and(eq(accounts.benchmarkOptIn, true), eq(accounts.isLocal, false)))
5868
+ .execute();
5869
+ return rows.map((r) => r.id);
5870
+ }
5871
+ // Weekly per-known-vendor benchmark aggregates for ONE account over [from, to).
5872
+ export async function getBenchmarkContributions(accountId, from, to) {
5873
+ const automatedIds = await automatedReviewerUserIds(accountId);
5874
+ if (automatedIds.length === 0)
5875
+ return [];
5876
+ const kindMap = await classificationKindForUser(accountId);
5877
+ const nowMs = Date.now();
5878
+ // Only KNOWN vendors are comparable across orgs — skip in_house/pierre/unclassified.
5879
+ const vendorKindOf = (userId) => {
5880
+ const k = kindMap.get(userId);
5881
+ if (!k || k === 'in_house' || k === 'pierre')
5882
+ return null;
5883
+ return k;
5884
+ };
5885
+ const acc = new Map();
5886
+ const bucketFor = (vendorKind, at) => {
5887
+ const weekMs = isoWeekStartUtc(at).getTime();
5888
+ const key = `${vendorKind}${weekMs}`;
5889
+ let a = acc.get(key);
5890
+ if (!a) {
5891
+ a = {
5892
+ vendorKind,
5893
+ weekMs,
5894
+ threads: 0,
5895
+ comments: 0,
5896
+ actedOn: 0,
5897
+ untouched: 0,
5898
+ humanFollow: 0,
5899
+ oldestUntouchedMs: null,
5900
+ };
5901
+ acc.set(key, a);
5902
+ }
5903
+ return a;
5904
+ };
5905
+ // Threads opened by an automated reviewer in [from, to).
5906
+ const threadRows = await db
5907
+ .select({
5908
+ id: reviewThreads.id,
5909
+ userId: reviewThreads.originalCommenterId,
5910
+ state: reviewThreads.derivedState,
5911
+ createdAt: reviewThreads.createdAt,
5912
+ })
5913
+ .from(reviewThreads)
5914
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
5915
+ .where(and(eq(pullRequests.accountId, accountId), inArray(reviewThreads.originalCommenterId, automatedIds), gte(reviewThreads.createdAt, from), lt(reviewThreads.createdAt, to)))
5916
+ .execute();
5917
+ // Pass 1: volume / untouched / oldest-untouched per (vendor, week); remember each thread's
5918
+ // bucket + base-acted for the merged acted-on definition below.
5919
+ const windowThreads = [];
5920
+ for (const t of threadRows) {
5921
+ if (t.userId == null)
5922
+ continue;
5923
+ const kind = vendorKindOf(t.userId);
5924
+ if (!kind)
5925
+ continue;
5926
+ const bucket = bucketFor(kind, t.createdAt);
5927
+ bucket.threads += 1;
5928
+ const baseActed = t.state === 'resolved' || t.state === 'likely_addressed';
5929
+ if (t.state === 'untouched') {
5930
+ bucket.untouched += 1;
5931
+ const ms = t.createdAt.getTime();
5932
+ if (bucket.oldestUntouchedMs == null || ms < bucket.oldestUntouchedMs) {
5933
+ bucket.oldestUntouchedMs = ms;
5934
+ }
5935
+ }
5936
+ windowThreads.push({ id: t.id, bucket, baseActed });
5937
+ }
5938
+ // Pass 2: human follow-through — a human commented after the bot's last comment on the thread.
5939
+ const wtIds = windowThreads.map((w) => w.id);
5940
+ const humanFollow = new Set();
5941
+ if (wtIds.length > 0) {
5942
+ const ftRows = await db
5943
+ .select({
5944
+ threadId: reviewComments.threadId,
5945
+ authorId: reviewComments.authorId,
5946
+ createdAt: reviewComments.createdAt,
5947
+ })
5948
+ .from(reviewComments)
5949
+ .where(inArray(reviewComments.threadId, wtIds))
5950
+ .execute();
5951
+ const byThread = new Map();
5952
+ for (const r of ftRows) {
5953
+ const arr = byThread.get(r.threadId) ?? [];
5954
+ arr.push({ authorId: r.authorId, at: r.createdAt.getTime() });
5955
+ byThread.set(r.threadId, arr);
5956
+ }
5957
+ const autoSet = new Set(automatedIds);
5958
+ for (const [threadId, comments] of byThread) {
5959
+ let botLastAt = -Infinity;
5960
+ for (const c of comments) {
5961
+ if (c.authorId != null && autoSet.has(c.authorId) && c.at > botLastAt)
5962
+ botLastAt = c.at;
5963
+ }
5964
+ if (comments.some((c) => c.authorId != null && !autoSet.has(c.authorId) && c.at > botLastAt)) {
5965
+ humanFollow.add(threadId);
5966
+ }
5967
+ }
5968
+ }
5969
+ for (const w of windowThreads) {
5970
+ if (humanFollow.has(w.id))
5971
+ w.bucket.humanFollow += 1;
5972
+ if (w.baseActed || humanFollow.has(w.id))
5973
+ w.bucket.actedOn += 1;
5974
+ }
5975
+ // Comments authored by an automated reviewer in [from, to), per (vendor, week).
5976
+ const commentRows = await db
5977
+ .select({
5978
+ authorId: reviewComments.authorId,
5979
+ createdAt: reviewComments.createdAt,
5980
+ })
5981
+ .from(reviewComments)
5982
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
5983
+ .where(and(eq(pullRequests.accountId, accountId), inArray(reviewComments.authorId, automatedIds), gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to)))
5984
+ .execute();
5985
+ for (const c of commentRows) {
5986
+ if (c.authorId == null)
5987
+ continue;
5988
+ const kind = vendorKindOf(c.authorId);
5989
+ if (!kind)
5990
+ continue;
5991
+ bucketFor(kind, c.createdAt).comments += 1;
5992
+ }
5993
+ return [...acc.values()]
5994
+ .filter((a) => a.threads > 0 || a.comments > 0)
5995
+ .map((a) => ({
5996
+ vendorKind: a.vendorKind,
5997
+ weekStart: new Date(a.weekMs),
5998
+ threads: a.threads,
5999
+ comments: a.comments,
6000
+ actedOn: a.actedOn,
6001
+ untouched: a.untouched,
6002
+ humanFollow: a.humanFollow,
6003
+ oldestUntouchedDays: a.oldestUntouchedMs == null
6004
+ ? null
6005
+ : Math.floor((nowMs - a.oldestUntouchedMs) / 86_400_000),
6006
+ }));
6007
+ }
6008
+ // Idempotent upsert of an account's weekly contributions (one row per vendor+week). The
6009
+ // org-size bucket is the account's size at contribution time (denormalized per row).
6010
+ export async function upsertBenchmarkContributions(accountId, sizeBucket, rows) {
6011
+ for (const r of rows) {
6012
+ await db
6013
+ .insert(benchmarkContributions)
6014
+ .values({
6015
+ accountId,
6016
+ vendorKind: r.vendorKind,
6017
+ weekStart: r.weekStart,
6018
+ threads: r.threads,
6019
+ comments: r.comments,
6020
+ actedOn: r.actedOn,
6021
+ untouched: r.untouched,
6022
+ humanFollow: r.humanFollow,
6023
+ oldestUntouchedDays: r.oldestUntouchedDays,
6024
+ orgSizeBucket: sizeBucket,
6025
+ schemaVersion: 1,
6026
+ })
6027
+ .onConflictDoUpdate({
6028
+ target: [
6029
+ benchmarkContributions.accountId,
6030
+ benchmarkContributions.vendorKind,
6031
+ benchmarkContributions.weekStart,
6032
+ ],
6033
+ set: {
6034
+ threads: r.threads,
6035
+ comments: r.comments,
6036
+ actedOn: r.actedOn,
6037
+ untouched: r.untouched,
6038
+ humanFollow: r.humanFollow,
6039
+ oldestUntouchedDays: r.oldestUntouchedDays,
6040
+ orgSizeBucket: sizeBucket,
6041
+ },
6042
+ })
6043
+ .execute();
6044
+ }
6045
+ }
6046
+ // Withdraw consent → delete the account's contributions (one-click, complete removal).
6047
+ export async function deleteBenchmarkContributions(accountId) {
6048
+ await db
6049
+ .delete(benchmarkContributions)
6050
+ .where(eq(benchmarkContributions.accountId, accountId))
6051
+ .execute();
6052
+ }
5829
6053
  // Item 7 — the per-PR drill-down behind a vendor's Bot-ROI row (GET /api/bot-analytics/:kind/prs).
5830
6054
  // Lists the PRs one automated reviewer KIND touched in the window (its review threads + comments),
5831
6055
  // with per-PR volume, the merged "acted-on" count (item 6: resolved | likely_addressed | a human
@@ -45,6 +45,9 @@ export const accounts = pgTable('accounts', {
45
45
  // plan default (2,500 for paid cloud); local/unlimited accounts ignore it. See the sqlite
46
46
  // twin. Kept in sync by hand (schema-parity.test.ts).
47
47
  aiCreditAllowance: integer('ai_credit_allowance'),
48
+ // CLOUD-ONLY, opt-in (default OFF): contribute de-identified aggregate weekly review-bot
49
+ // outcome stats to the cross-org benchmark network. See the sqlite twin + benchmarkContributions.
50
+ benchmarkOptIn: boolean('benchmark_opt_in').notNull().default(false),
48
51
  });
49
52
  export const repos = pgTable('repos', {
50
53
  id: serial('id').primaryKey(),
@@ -560,4 +563,31 @@ export const teamRepos = pgTable('team_repos', {
560
563
  accountIdx: index('team_repos_account_idx').on(t.accountId),
561
564
  repoIdx: index('team_repos_repo_idx').on(t.repoId),
562
565
  }));
566
+ // ── Cross-org benchmark network (CORE, cloud-only, opt-in) ── the pg twin of the sqlite
567
+ // benchmarkContributions table. See that file for the full rationale (aggregate-only, no PII,
568
+ // in_house/pierre excluded, written only by the firewalled weekly rollup; serving is Phase 1).
569
+ export const benchmarkContributions = pgTable('benchmark_contributions', {
570
+ id: serial('id').primaryKey(),
571
+ accountId: integer('account_id')
572
+ .notNull()
573
+ .references(() => accounts.id, { onDelete: 'cascade' }),
574
+ vendorKind: text('vendor_kind').notNull(),
575
+ weekStart: timestamp('week_start', { withTimezone: true, mode: 'date' }).notNull(),
576
+ threads: integer('threads').notNull().default(0),
577
+ comments: integer('comments').notNull().default(0),
578
+ actedOn: integer('acted_on').notNull().default(0),
579
+ untouched: integer('untouched').notNull().default(0),
580
+ humanFollow: integer('human_follow').notNull().default(0),
581
+ oldestUntouchedDays: integer('oldest_untouched_days'),
582
+ orgSizeBucket: text('org_size_bucket').notNull(),
583
+ mlMetrics: text('ml_metrics'),
584
+ schemaVersion: integer('schema_version').notNull().default(1),
585
+ createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
586
+ .notNull()
587
+ .defaultNow(),
588
+ }, (t) => ({
589
+ uniq: uniqueIndex('bench_contrib_uniq').on(t.accountId, t.vendorKind, t.weekStart),
590
+ accountIdx: index('bench_contrib_account_idx').on(t.accountId),
591
+ cohortIdx: index('bench_contrib_cohort_idx').on(t.vendorKind, t.weekStart),
592
+ }));
563
593
  //# sourceMappingURL=schema.pg.js.map
@@ -57,6 +57,12 @@ export const accounts = sqliteTable('accounts', {
57
57
  // use the plan default (2,500 for a paid cloud account); local/unlimited accounts ignore
58
58
  // it entirely. A forward hook for top-ups / alternate plans without another migration.
59
59
  aiCreditAllowance: integer('ai_credit_allowance'),
60
+ // CLOUD-ONLY, opt-in (default OFF): whether this account contributes de-identified,
61
+ // aggregate weekly review-bot outcome stats to the cross-org benchmark network (see
62
+ // `benchmarkContributions`). Consent is a distinct PURPOSE from running the dashboard,
63
+ // so it must be explicit + reversible; withdrawing (set false) deletes the account's
64
+ // contributions. Local accounts never contribute (never phone home) — always false.
65
+ benchmarkOptIn: integer('benchmark_opt_in', { mode: 'boolean' }).notNull().default(false),
60
66
  });
61
67
  export const repos = sqliteTable('repos', {
62
68
  id: integer('id').primaryKey({ autoIncrement: true }),
@@ -637,4 +643,52 @@ export const teamRepos = sqliteTable('team_repos', {
637
643
  accountIdx: index('team_repos_account_idx').on(t.accountId),
638
644
  repoIdx: index('team_repos_repo_idx').on(t.repoId),
639
645
  }));
646
+ // ── Cross-org benchmark network (CORE, cloud-only, opt-in) ──────────────────────────────
647
+ // Phase 0 of the neutral review-bot benchmark: de-identified, AGGREGATE-ONLY weekly outcome
648
+ // stats per known vendor, contributed by opted-in cloud accounts (see accounts.benchmarkOptIn),
649
+ // so a later paid feature can show "your CodeRabbit is 38% acted-on vs a 45% peer median".
650
+ // NO raw text / logins / repo names / PR titles ever land here — counts only. `in_house` /
651
+ // `pierre` are EXCLUDED at collection (not comparable across orgs / identifying). Written ONLY
652
+ // by the firewalled weekly rollup (sync/benchmark-rollup.ts) from each account's OWN data, so
653
+ // collection stays accountId-scoped; the CROSS-account read is the future serving job (Phase 1),
654
+ // which applies k-anonymity (K>=5). Serving (percentiles/cohorts) lives in the Pro plugin later.
655
+ export const benchmarkContributions = sqliteTable('benchmark_contributions', {
656
+ id: integer('id').primaryKey({ autoIncrement: true }),
657
+ accountId: integer('account_id')
658
+ .notNull()
659
+ .references(() => accounts.id, { onDelete: 'cascade' }),
660
+ // A known review-bot vendor kind (shared ReviewBotKind). Never in_house/pierre.
661
+ vendorKind: text('vendor_kind').notNull(),
662
+ // ISO-week start (UTC Monday 00:00) the aggregates cover.
663
+ weekStart: integer('week_start', { mode: 'timestamp' }).notNull(),
664
+ // Deterministic weekly outcome aggregates — counts only.
665
+ threads: integer('threads').notNull().default(0),
666
+ comments: integer('comments').notNull().default(0),
667
+ // "acted-on" = resolved | likely_addressed | a human replied after the bot (the same
668
+ // heuristic the ROI panel uses; approximate — the UI says so).
669
+ actedOn: integer('acted_on').notNull().default(0),
670
+ untouched: integer('untouched').notNull().default(0),
671
+ humanFollow: integer('human_follow').notNull().default(0),
672
+ // Oldest still-untouched thread age (days) in the week; null when none untouched.
673
+ oldestUntouchedDays: integer('oldest_untouched_days'),
674
+ // Covariate: the account's active-contributor size bucket at contribution time
675
+ // ('1' | '2-5' | '6-20' | '21-50' | '51-200' | '200+'), so cohorts can condition on size.
676
+ orgSizeBucket: text('org_size_bucket').notNull(),
677
+ // RESERVED for FUTURE ML-derived aggregate distributions (category mix / severity mix /
678
+ // precision-by-category) — populated by the SAME rollup once the classifier ships, still
679
+ // aggregate-only JSON. Null in Phase 0. Forward hook so ML enrichment needs no new migration.
680
+ mlMetrics: text('ml_metrics'),
681
+ // Contribution-format version so the row shape can evolve unambiguously.
682
+ schemaVersion: integer('schema_version').notNull().default(1),
683
+ createdAt: integer('created_at', { mode: 'timestamp' })
684
+ .notNull()
685
+ .default(sql `(unixepoch())`),
686
+ }, (t) => ({
687
+ // One row per (account, vendor, week) — the idempotent upsert target. accountId-first so
688
+ // uniqueness is per-tenant (two accounts contribute the same vendor+week independently).
689
+ uniq: uniqueIndex('bench_contrib_uniq').on(t.accountId, t.vendorKind, t.weekStart),
690
+ accountIdx: index('bench_contrib_account_idx').on(t.accountId),
691
+ // Serving-side cohort scans (Phase 1): all accounts' rows for a vendor across weeks.
692
+ cohortIdx: index('bench_contrib_cohort_idx').on(t.vendorKind, t.weekStart),
693
+ }));
640
694
  //# sourceMappingURL=schema.sqlite.js.map
@@ -0,0 +1,50 @@
1
+ import { config } from '../config.js';
2
+ import { getAccountContributorCount, getBenchmarkContributions, getBenchmarkOptedInAccountIds, isoWeekStartUtc, orgSizeBucket, upsertBenchmarkContributions, } from '../db/queries.js';
3
+ // Cross-org benchmark rollup (CORE, CLOUD-ONLY, Phase 0). For each account that has CONSENTED
4
+ // (accounts.benchmark_opt_in; local accounts never contribute) it computes de-identified,
5
+ // AGGREGATE-ONLY weekly review-bot outcome stats over the last N complete ISO weeks and upserts
6
+ // them into benchmark_contributions. Idempotent + self-healing: re-processing the same weeks
7
+ // overwrites (so a missed run just backfills on the next). Each iteration reads only that
8
+ // account's OWN data — collection stays accountId-scoped; the cross-account read is the future
9
+ // serving job (Phase 1). Errors are caught per-account so one bad tenant can't abort the sweep.
10
+ const ROLLUP_WEEKS = 12;
11
+ async function rollupAccounts(accountIds, log) {
12
+ if (accountIds.length === 0)
13
+ return;
14
+ // Upper bound = start of the CURRENT (in-progress) week, so we only ever contribute COMPLETE
15
+ // weeks (an in-progress week's counts would keep changing).
16
+ const to = isoWeekStartUtc(new Date());
17
+ const from = new Date(to.getTime() - ROLLUP_WEEKS * 7 * 86_400_000);
18
+ for (const accountId of accountIds) {
19
+ try {
20
+ const rows = await getBenchmarkContributions(accountId, from, to);
21
+ if (rows.length === 0)
22
+ continue;
23
+ const size = orgSizeBucket(await getAccountContributorCount(accountId));
24
+ await upsertBenchmarkContributions(accountId, size, rows);
25
+ log.info({ accountId, weeks: ROLLUP_WEEKS, rows: rows.length }, 'benchmark contributions rolled up');
26
+ }
27
+ catch (err) {
28
+ log.error({ err, accountId }, 'benchmark rollup failed for account');
29
+ }
30
+ }
31
+ }
32
+ // The scheduled sweep: all opted-in cloud accounts. Inert (returns early) in local mode, when
33
+ // scheduling is disabled, or when nobody has opted in.
34
+ export async function runBenchmarkRollup(log) {
35
+ if (!config.isCloud || config.disableScheduler)
36
+ return;
37
+ await rollupAccounts(await getBenchmarkOptedInAccountIds(), log);
38
+ }
39
+ // Immediate per-account seeding, fired (best-effort) right after an account opts in so the
40
+ // benchmark reflects them without waiting for the weekly cron. Re-checks opt-in (from the
41
+ // consent roster) so it can't contribute for an account that isn't opted in.
42
+ export async function runBenchmarkRollupForAccount(accountId, log) {
43
+ if (!config.isCloud)
44
+ return;
45
+ const optedIn = await getBenchmarkOptedInAccountIds();
46
+ if (!optedIn.includes(accountId))
47
+ return;
48
+ await rollupAccounts([accountId], log);
49
+ }
50
+ //# sourceMappingURL=benchmark-rollup.js.map