limn-review 0.1.164 → 0.1.166

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.
@@ -266,8 +266,8 @@ const analyticsSchema = {
266
266
  },
267
267
  };
268
268
  // GET /api/bot-analytics/vendor/:key/prs?window= — per-REVIEWER PR drill-down. `key` is the
269
- // analytics-row identity: `u<userId>` (a single reviewer) or the 'pierre' sentinel; the handler
270
- // parses it (anything else → 400). The window/workspace/repoIds resolution is identical to
269
+ // analytics-row identity: `u<userId>`, a single reviewer; the handler parses it (anything else →
270
+ // 400). The window/workspace/repoIds resolution is identical to
271
271
  // /api/bot-analytics, because this list must reproduce ONE of that panel's rows.
272
272
  const vendorPrsSchema = {
273
273
  params: {
@@ -696,17 +696,13 @@ export async function botTriageRoutes(app) {
696
696
  });
697
697
  // The per-REVIEWER PR drill-down behind one Bot-ROI row: the PRs that one automated reviewer
698
698
  // touched in the window (threads/comments/acted-on/untouched/bot-only), newest-activity first.
699
- // `key` is the analytics row identity — `u<userId>` (a single reviewer) or the 'pierre' sentinel;
700
- // anything else is a client bug → 400.
699
+ // `key` is the analytics row identity — `u<userId>`, one reviewer (a Limn bot included); anything
700
+ // else, the retired 'pierre' sentinel among them, is a client bug → 400.
701
701
  app.get('/api/bot-analytics/vendor/:key/prs', { schema: vendorPrsSchema }, async (req, reply) => {
702
702
  const { key } = req.params;
703
703
  const { window, workspace, repoIds } = req.query;
704
704
  const m = /^u(\d+)$/.exec(key);
705
- const target = m
706
- ? { userId: Number(m[1]) }
707
- : key === 'pierre'
708
- ? { kind: 'pierre' }
709
- : null;
705
+ const target = m ? { userId: Number(m[1]) } : null;
710
706
  if (!target) {
711
707
  reply.status(400);
712
708
  return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
@@ -722,19 +718,14 @@ export async function botTriageRoutes(app) {
722
718
  // The per-REVIEWER COMMENTS drill-down behind the same Bot-ROI row: everything the reviewer
723
719
  // said in the window (inline review comments, PR comments, review bodies), each row with its
724
720
  // stored ML label INLINE — one response, never the per-PR label index per row. Same key parse
725
- // and the same scope resolution as the /prs sibling, so the list reproduces the same ROI row;
726
- // the 'pierre' sentinel answers empty (verbatim reviews are human-posted — no per-comment
727
- // rows to attribute). ⚠ Rate tier `search`, pinned in rate-limit.test.ts — this ships up to
721
+ // and the same scope resolution as the /prs sibling, so the list reproduces the same ROI row.
722
+ // ⚠ Rate tier `search`, pinned in rate-limit.test.ts — this ships up to
728
723
  // 3000 comment BODIES per source plus a three-way label join per request.
729
724
  app.get('/api/bot-analytics/vendor/:key/comments', { schema: vendorCommentsSchema }, async (req, reply) => {
730
725
  const { key } = req.params;
731
726
  const { window, workspace, repoIds, fromMs, toMs } = req.query;
732
727
  const m = /^u(\d+)$/.exec(key);
733
- const target = m
734
- ? { userId: Number(m[1]) }
735
- : key === 'pierre'
736
- ? { kind: 'pierre' }
737
- : null;
728
+ const target = m ? { userId: Number(m[1]) } : null;
738
729
  if (!target) {
739
730
  reply.status(400);
740
731
  return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
@@ -20,6 +20,7 @@
20
20
  // ml-labels.ts does not import, and adding it there would deepen the deliberate
21
21
  // queries ⇄ ml-labels module cycle. Nothing imports this file back, so no new cycle.
22
22
  import { and, eq, gte, inArray } from 'drizzle-orm';
23
+ import { LIMN_LABEL_BACKEND } from '../shared/index.js';
23
24
  import { db, schema } from './client.js';
24
25
  import { botWindowMs } from './bot-window.js';
25
26
  import { clusterThreadsByLine } from './line-overlap.js';
@@ -413,8 +414,14 @@ scope, page) {
413
414
  // (the commentFacetCounts rule). ⚠ Its `total` is the count of LABELLED origin comments, NOT
414
415
  // the cluster `total` above: a cluster is not an ML row, and only a labelled comment can be
415
416
  // placed on either axis. `declared`/`undeclared` state the sparsity honestly.
417
+ // ⚠ A Limn finding's label is its own review's severity, never a model score, so it sits on
418
+ // neither axis: left out of the matrix AND of any cell/disagreement refine.
419
+ const matrixLabelForThread = (threadId) => {
420
+ const l = labelForThread(threadId);
421
+ return l && l.backend !== LIMN_LABEL_BACKEND ? l : undefined;
422
+ };
416
423
  const matrix = buildMatrix(capped.flatMap((c) => c.items
417
- .map((t) => labelForThread(t.threadId))
424
+ .map((t) => matrixLabelForThread(t.threadId))
418
425
  .filter((l) => l !== undefined)));
419
426
  // ── 8. Refine → slice ─────────────────────────────────────────────────────────────────────
420
427
  // A cluster survives when ≥1 of its threads matches. (The wire member shape carries no
@@ -435,7 +442,7 @@ scope, page) {
435
442
  return false;
436
443
  if (!refine.cell && !refine.disagree)
437
444
  return true;
438
- return labelMatchesRefine(labelForThread(t.threadId), refine);
445
+ return labelMatchesRefine(matrixLabelForThread(t.threadId), refine);
439
446
  };
440
447
  const narrowed = refine.cell || refine.disagree || refine.authorUserIds != null
441
448
  ? capped.filter((c) => c.items.some(memberMatches))
@@ -0,0 +1,274 @@
1
+ // LIMN FINDINGS ARE BOTS (docs/CLAUDE-REVIEW.md § Limn findings are bots).
2
+ //
3
+ // Limn posts a review finding as the READER (their own GitHub account). Such a comment is the
4
+ // review's words, not the person's, so it is stored under a per-poster BOT user:
5
+ //
6
+ // users login `limn:<poster>` ("Limn · alice"), is_bot = 1 and pinned
7
+ // (is_bot_overridden = 1), no node id. ':' never occurs in a GitHub login,
8
+ // so the row never collides with a real account. ⚠ Never send it to GitHub.
9
+ // review_comments / author_id = the bot; limn_posted_by_user_id = the person who posted it.
10
+ // pr_comments
11
+ // workspace_reviewers one row per workspace the bot appears in: automated, role 'review',
12
+ // kind 'pierre', label "Limn · alice" (an 'auto' row the classifier's
13
+ // step 0 re-derives identically; a reader's rename pins it as usual).
14
+ //
15
+ // ⚠ "IS THIS MY OWN POST" READS THE TRUE POSTER — `trueAuthorId(table)`, never `author_id`.
16
+ //
17
+ // Which comment is a finding is ONE predicate, shared `limnFindingPost` (marker + a severity or
18
+ // reminder lead). Sync applies it on every walk (sync/upsert.ts `persistPr`), so a body edited to
19
+ // drop the marker goes back to its person; `reconcileLimnFindingAuthors` applies the same rule to
20
+ // rows already stored (boot, and the demo seed).
21
+ import { and, eq, inArray, isNotNull, like, or, sql } from 'drizzle-orm';
22
+ import { isLimnBotLogin, limnBotLabel, limnBotLogin, limnFindingPost, LIMN_BOT_LOGIN_PREFIX, } from '../shared/index.js';
23
+ import { db, runTransaction, schema } from './client.js';
24
+ import { syncLimnFindingLabels } from './limn-finding-labels.js';
25
+ const { users, pullRequests, reviewComments, prComments, reviewThreads, events, searchIndex, workspaceRepos, workspaceReviewers, } = schema;
26
+ /** The person who wrote a comment row: the poster of a Limn finding, else its author. */
27
+ export function trueAuthorId(t) {
28
+ return sql `coalesce(${t.limnPostedByUserId}, ${t.authorId})`;
29
+ }
30
+ /** The SQL LIKE pattern every `limn:` login matches. */
31
+ const LIMN_LOGIN_LIKE = `${LIMN_BOT_LOGIN_PREFIX}%`;
32
+ /** The per-poster Limn bot user for `posterLogin`: created on first use, always is_bot. */
33
+ export async function ensureLimnBotUser(exec, posterLogin) {
34
+ const row = (await exec
35
+ .insert(users)
36
+ .values({
37
+ githubLogin: limnBotLogin(posterLogin),
38
+ githubNodeId: null,
39
+ displayName: limnBotLabel(posterLogin),
40
+ avatarUrl: null,
41
+ githubType: null,
42
+ isBot: true,
43
+ isBotOverridden: true,
44
+ })
45
+ .onConflictDoUpdate({
46
+ target: users.githubLogin,
47
+ set: { isBot: true, isBotOverridden: true, displayName: limnBotLabel(posterLogin) },
48
+ })
49
+ .returning({ id: users.id })
50
+ .execute())[0];
51
+ return row.id;
52
+ }
53
+ /**
54
+ * The bot's row in one workspace — written once, never overwritten here (`DO NOTHING`), so a
55
+ * reader's rename or judgement stands. Names no price or trust column. The values are exactly what
56
+ * `classifyReviewer`'s step 0 derives, so a later classification pass changes nothing.
57
+ */
58
+ export async function ensureLimnReviewerRow(exec, accountId, workspaceId, botUserId, posterLogin) {
59
+ await exec
60
+ .insert(workspaceReviewers)
61
+ .values({
62
+ accountId,
63
+ workspaceId,
64
+ authorUserId: botUserId,
65
+ automated: true,
66
+ role: 'review',
67
+ confidence: 'high',
68
+ source: 'fingerprint',
69
+ reasonsJson: [LIMN_BOT_REASON],
70
+ kind: 'pierre',
71
+ label: limnBotLabel(posterLogin),
72
+ updatedAt: new Date(),
73
+ })
74
+ .onConflictDoNothing({
75
+ target: [
76
+ workspaceReviewers.accountId,
77
+ workspaceReviewers.workspaceId,
78
+ workspaceReviewers.authorUserId,
79
+ ],
80
+ })
81
+ .execute();
82
+ }
83
+ export const LIMN_BOT_REASON = 'posts Limn review findings';
84
+ /** Every `limn:` bot user id (global, like `users`). */
85
+ export async function limnBotUserIds(exec = db) {
86
+ const rows = await exec
87
+ .select({ id: users.id })
88
+ .from(users)
89
+ .where(like(users.githubLogin, LIMN_LOGIN_LIKE))
90
+ .execute();
91
+ return rows.map((r) => r.id);
92
+ }
93
+ /** The reader's own Limn bot (`limn:<their login>`), or null while they have posted no finding. */
94
+ export async function limnBotUserIdFor(posterLogin, exec = db) {
95
+ const row = (await exec
96
+ .select({ id: users.id })
97
+ .from(users)
98
+ .where(eq(users.githubLogin, limnBotLogin(posterLogin)))
99
+ .limit(1)
100
+ .execute())[0];
101
+ return row?.id ?? null;
102
+ }
103
+ /** The Limn bot of the person `userId` (by their login), or null when they have none. */
104
+ export async function limnBotUserIdForUser(userId, exec = db) {
105
+ const row = (await exec
106
+ .select({ login: users.githubLogin })
107
+ .from(users)
108
+ .where(eq(users.id, userId))
109
+ .limit(1)
110
+ .execute())[0];
111
+ if (!row?.login || isLimnBotLogin(row.login))
112
+ return null;
113
+ return limnBotUserIdFor(row.login, exec);
114
+ }
115
+ /**
116
+ * Apply the sync's rule to every stored comment that is, or was, a Limn finding: re-attribute it to
117
+ * the poster's bot, or give it back to its person. Fixes the comment's event actor, its search-index
118
+ * author and its thread's root author with it, and ensures the bot's reviewer rows. Idempotent: a
119
+ * second run writes no comment, event, search or thread row. DB only, one transaction.
120
+ */
121
+ export async function reconcileLimnFindingAuthors(accountId) {
122
+ const markerLike = '%pierre:claude-review-finding%';
123
+ const poster = sql `coalesce(${reviewComments.limnPostedByUserId}, ${reviewComments.authorId})`;
124
+ const rcRows = await db
125
+ .select({
126
+ id: reviewComments.id,
127
+ prId: reviewComments.prId,
128
+ nodeId: reviewComments.githubNodeId,
129
+ threadId: reviewComments.threadId,
130
+ repoId: pullRequests.repoId,
131
+ body: reviewComments.body,
132
+ authorId: reviewComments.authorId,
133
+ limnPostedBy: reviewComments.limnPostedByUserId,
134
+ posterId: poster,
135
+ // A finding is always its thread's ROOT (the earliest comment); a reply never is one.
136
+ isRoot: sql `not exists (select 1 from review_comments rc2 where rc2.thread_id = ${reviewComments.threadId} and (rc2.created_at < ${reviewComments.createdAt} or (rc2.created_at = ${reviewComments.createdAt} and rc2.id < ${reviewComments.id})))`,
137
+ })
138
+ .from(reviewComments)
139
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
140
+ .where(and(eq(pullRequests.accountId, accountId), or(like(reviewComments.body, markerLike), isNotNull(reviewComments.limnPostedByUserId))))
141
+ .execute();
142
+ const pcPoster = sql `coalesce(${prComments.limnPostedByUserId}, ${prComments.authorId})`;
143
+ const pcRows = await db
144
+ .select({
145
+ id: prComments.id,
146
+ prId: prComments.prId,
147
+ nodeId: prComments.githubNodeId,
148
+ repoId: pullRequests.repoId,
149
+ body: prComments.body,
150
+ authorId: prComments.authorId,
151
+ limnPostedBy: prComments.limnPostedByUserId,
152
+ posterId: pcPoster,
153
+ })
154
+ .from(prComments)
155
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
156
+ .where(and(eq(pullRequests.accountId, accountId), or(like(prComments.body, markerLike), isNotNull(prComments.limnPostedByUserId))))
157
+ .execute();
158
+ const posterIds = [
159
+ ...new Set([...rcRows, ...pcRows].map((r) => r.posterId).filter((id) => id != null)),
160
+ ];
161
+ const posterLogin = new Map();
162
+ if (posterIds.length > 0) {
163
+ for (const u of await db
164
+ .select({ id: users.id, login: users.githubLogin })
165
+ .from(users)
166
+ .where(inArray(users.id, posterIds))
167
+ .execute())
168
+ posterLogin.set(u.id, u.login);
169
+ }
170
+ const workspaceOfRepo = new Map();
171
+ for (const m of await db
172
+ .select({ repoId: workspaceRepos.repoId, workspaceId: workspaceRepos.workspaceId })
173
+ .from(workspaceRepos)
174
+ .where(eq(workspaceRepos.accountId, accountId))
175
+ .execute())
176
+ workspaceOfRepo.set(m.repoId, m.workspaceId);
177
+ return runTransaction(async (tx) => {
178
+ const botIdByLogin = new Map();
179
+ const botFor = async (login) => {
180
+ let id = botIdByLogin.get(login);
181
+ if (id === undefined) {
182
+ id = await ensureLimnBotUser(tx, login);
183
+ botIdByLogin.set(login, id);
184
+ }
185
+ return id;
186
+ };
187
+ // The rule, per row: { authorId, limnPostedBy } as sync would write it now.
188
+ const desired = async (r, threadRoot = false) => {
189
+ const login = r.posterId != null ? posterLogin.get(r.posterId) : undefined;
190
+ if (r.posterId == null || login == null || isLimnBotLogin(login) || limnFindingPost(r.body, { threadRoot }) == null) {
191
+ return { authorId: r.posterId, limnPostedBy: null };
192
+ }
193
+ const botId = await botFor(login);
194
+ const ws = workspaceOfRepo.get(r.repoId);
195
+ if (ws != null)
196
+ await ensureLimnReviewerRow(tx, accountId, ws, botId, login);
197
+ return { authorId: botId, limnPostedBy: r.posterId };
198
+ };
199
+ let comments = 0;
200
+ const touchedThreads = new Set();
201
+ for (const r of rcRows) {
202
+ const d = await desired(r.isRoot ? r : { ...r, body: null }, Boolean(r.isRoot));
203
+ if (d.authorId === r.authorId && d.limnPostedBy === r.limnPostedBy)
204
+ continue;
205
+ comments++;
206
+ touchedThreads.add(r.threadId);
207
+ await tx
208
+ .update(reviewComments)
209
+ .set({ authorId: d.authorId, limnPostedByUserId: d.limnPostedBy })
210
+ .where(eq(reviewComments.id, r.id))
211
+ .execute();
212
+ await retagActor(tx, accountId, 'review_comment', r.prId, r.id, r.nodeId, d.authorId);
213
+ }
214
+ for (const r of pcRows) {
215
+ const d = await desired(r);
216
+ if (d.authorId === r.authorId && d.limnPostedBy === r.limnPostedBy)
217
+ continue;
218
+ comments++;
219
+ await tx
220
+ .update(prComments)
221
+ .set({ authorId: d.authorId, limnPostedByUserId: d.limnPostedBy })
222
+ .where(eq(prComments.id, r.id))
223
+ .execute();
224
+ await retagActor(tx, accountId, 'pr_comment', r.prId, r.id, r.nodeId, d.authorId);
225
+ }
226
+ // A thread's root author follows its first comment (sync takes GitHub's first; the earliest
227
+ // stored comment is the same one).
228
+ let threads = 0;
229
+ for (const threadId of touchedThreads) {
230
+ const root = (await tx
231
+ .select({ authorId: reviewComments.authorId })
232
+ .from(reviewComments)
233
+ .where(eq(reviewComments.threadId, threadId))
234
+ .orderBy(reviewComments.createdAt, reviewComments.id)
235
+ .limit(1)
236
+ .execute())[0];
237
+ if (!root)
238
+ continue;
239
+ threads += (await tx
240
+ .update(reviewThreads)
241
+ .set({ originalCommenterId: root.authorId })
242
+ .where(and(eq(reviewThreads.id, threadId), sql `${reviewThreads.originalCommenterId} is not ${root.authorId}`))
243
+ .returning({ id: reviewThreads.id })
244
+ .execute()).length;
245
+ }
246
+ // REPAIR: an older classifier could class a PERSON as Limn (kind 'pierre') from the marker on
247
+ // their posts. Only the `limn:` bots are Limn. Rows nobody touched by hand (auto judgement,
248
+ // auto identity, no price, no trust) are deleted; the next Bots read re-derives them.
249
+ const repaired = await tx
250
+ .delete(workspaceReviewers)
251
+ .where(and(eq(workspaceReviewers.accountId, accountId), eq(workspaceReviewers.kind, 'pierre'), eq(workspaceReviewers.source, 'fingerprint'), eq(workspaceReviewers.identitySource, 'auto'), sql `${workspaceReviewers.monthlyCents} is null`, sql `${workspaceReviewers.dependencyTrusted} is null`, sql `${workspaceReviewers.authorUserId} not in (select ${users.id} from ${users} where ${users.githubLogin} like ${LIMN_LOGIN_LIKE})`))
252
+ .returning({ id: workspaceReviewers.id })
253
+ .execute();
254
+ // The severity badges follow the attribution (db/limn-finding-labels.ts).
255
+ await syncLimnFindingLabels(tx, accountId);
256
+ return { comments, threads, repairedReviewerRows: repaired.length };
257
+ });
258
+ }
259
+ // A comment's event actor and search-index author follow its author.
260
+ async function retagActor(tx, accountId, kind, prId, rowId, nodeId, authorId) {
261
+ await tx
262
+ .update(events)
263
+ .set({ actorId: authorId })
264
+ .where(and(eq(events.accountId, accountId), eq(events.dedupeKey, `${kind}:${nodeId}`)))
265
+ .execute();
266
+ await tx
267
+ .update(searchIndex)
268
+ .set({ authorId })
269
+ .where(
270
+ // `pr_id` first: it is what the index covers (search_pr_idx).
271
+ and(eq(searchIndex.prId, prId), eq(searchIndex.accountId, accountId), eq(searchIndex.kind, kind), eq(searchIndex.refId, rowId)))
272
+ .execute();
273
+ }
274
+ //# sourceMappingURL=limn-bots.js.map
@@ -0,0 +1,173 @@
1
+ // THE SEVERITY BADGE OF A LIMN FINDING (docs/ML-SEVERITY.md § Limn findings).
2
+ //
3
+ // A Limn finding comment is a bot comment (db/limn-bots.ts), so Bots Monitoring rolls up its
4
+ // severity like any other bot's. That severity is the REVIEW'S OWN — the lead line Limn posted
5
+ // ("**Warning**") — and NEVER the ML scorer's: a Limn finding is not an ML candidate
6
+ // (ml-labels.ts `listMlCandidates` + `countBotText` leave out `limn_posted_by_user_id IS NOT
7
+ // NULL`, in lockstep).
8
+ //
9
+ // This writer stores it in `ml_comment_labels` with its own backend (`LIMN_LABEL_BACKEND`), so
10
+ // every badge and rollup reads it unchanged:
11
+ // severity `LIMN_ML_SEVERITY[lead]` (blocker→critical, warning→major, nit→nit,
12
+ // question→minor), probability 1
13
+ // categories nit → ['nitpick'], else ['correctness_bug'] (a finding has no lens on GitHub)
14
+ // vendor claim NULL — Limn is not a vendor, so the inflation index OMITS and NAMES it
15
+ // no severity (a reminder without one) → no row
16
+ //
17
+ // ⚠ THE SCORER NEVER OVERWRITES THESE ROWS (`upsertMlLabels` guards its update on the backend),
18
+ // and a deep re-sync / `ml:enrich --reset` keeps them (`deleteMlLabelsForRepo` and the script
19
+ // skip the backend). The writer is idempotent and runs inside `persistPr`'s transaction (DB only),
20
+ // at the end of the boot reconcile, and after a deep re-sync's label delete.
21
+ import { createHash } from 'node:crypto';
22
+ import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm';
23
+ import { LIMN_LABEL_BACKEND, LIMN_LABEL_MODEL_VERSION, LIMN_ML_SEVERITY, limnFindingPost, } from '../shared/index.js';
24
+ import { db, schema } from './client.js';
25
+ const { mlCommentLabels, pullRequests, reviewComments, prComments } = schema;
26
+ const SEVERITY_ORD = { nit: 0, minor: 1, major: 2, critical: 3 };
27
+ /** The label a Limn finding body carries, or null when it has none (reminder, not a finding). */
28
+ export function limnFindingLabel(body) {
29
+ const post = limnFindingPost(body);
30
+ if (post?.severity == null)
31
+ return null;
32
+ const severity = LIMN_ML_SEVERITY[post.severity];
33
+ return {
34
+ severity,
35
+ severityOrd: SEVERITY_ORD[severity],
36
+ categories: severity === 'nit' ? ['nitpick'] : ['correctness_bug'],
37
+ };
38
+ }
39
+ /**
40
+ * Make the stored Limn labels match the stored Limn finding comments, for the whole account or
41
+ * for `prIds` only. Writes a row only when it differs (body, author or severity), and deletes a
42
+ * Limn-backend row whose comment is no longer a Limn finding with a severity. Idempotent.
43
+ */
44
+ export async function syncLimnFindingLabels(exec, accountId, opts = {}) {
45
+ const prIds = opts.prIds;
46
+ if (prIds && prIds.length === 0)
47
+ return { written: 0, deleted: 0 };
48
+ const prFilter = (t) => prIds ? [inArray(t.prId, prIds)] : [];
49
+ const rc = await exec
50
+ .select({
51
+ id: reviewComments.id,
52
+ prId: reviewComments.prId,
53
+ repoId: pullRequests.repoId,
54
+ authorId: reviewComments.authorId,
55
+ body: reviewComments.body,
56
+ createdAt: reviewComments.createdAt,
57
+ })
58
+ .from(reviewComments)
59
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
60
+ .where(and(eq(pullRequests.accountId, accountId), isNotNull(reviewComments.limnPostedByUserId), ...prFilter(reviewComments)))
61
+ .execute();
62
+ const pc = await exec
63
+ .select({
64
+ id: prComments.id,
65
+ prId: prComments.prId,
66
+ repoId: pullRequests.repoId,
67
+ authorId: prComments.authorId,
68
+ body: prComments.body,
69
+ createdAt: prComments.createdAt,
70
+ })
71
+ .from(prComments)
72
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
73
+ .where(and(eq(pullRequests.accountId, accountId), isNotNull(prComments.limnPostedByUserId), ...prFilter(prComments)))
74
+ .execute();
75
+ // Every label on these targets that exists now: a Limn row to keep or drop, or (from before this
76
+ // writer) a scorer row to replace.
77
+ const existing = await exec
78
+ .select({
79
+ id: mlCommentLabels.id,
80
+ targetKind: mlCommentLabels.targetKind,
81
+ targetId: mlCommentLabels.targetId,
82
+ authorUserId: mlCommentLabels.authorUserId,
83
+ severity: mlCommentLabels.severity,
84
+ backend: mlCommentLabels.backend,
85
+ bodyHash: mlCommentLabels.bodyHash,
86
+ })
87
+ .from(mlCommentLabels)
88
+ .where(and(eq(mlCommentLabels.accountId, accountId), ...(prIds ? [inArray(mlCommentLabels.prId, prIds)] : []),
89
+ // Subqueries, not id lists: an account's Limn findings can outgrow SQLite's bound-variable cap.
90
+ sql `(${mlCommentLabels.backend} = ${LIMN_LABEL_BACKEND}
91
+ or (${mlCommentLabels.targetKind} = 'review_comment' and ${mlCommentLabels.targetId} in
92
+ (select ${reviewComments.id} from ${reviewComments} where ${reviewComments.limnPostedByUserId} is not null))
93
+ or (${mlCommentLabels.targetKind} = 'pr_comment' and ${mlCommentLabels.targetId} in
94
+ (select ${prComments.id} from ${prComments} where ${prComments.limnPostedByUserId} is not null)))`))
95
+ .execute();
96
+ const key = (kind, id) => `${kind}:${id}`;
97
+ const existingByKey = new Map(existing.map((e) => [key(e.targetKind, e.targetId), e]));
98
+ let written = 0;
99
+ const keep = new Set();
100
+ const now = new Date();
101
+ const write = async (kind, r) => {
102
+ if (r.authorId == null)
103
+ return;
104
+ const label = limnFindingLabel(r.body);
105
+ if (!label)
106
+ return;
107
+ const k = key(kind, r.id);
108
+ keep.add(k);
109
+ const bodyHash = createHash('sha256').update(r.body ?? '').digest('hex');
110
+ const prior = existingByKey.get(k);
111
+ if (prior &&
112
+ prior.backend === LIMN_LABEL_BACKEND &&
113
+ prior.bodyHash === bodyHash &&
114
+ prior.authorUserId === r.authorId &&
115
+ prior.severity === label.severity)
116
+ return;
117
+ const values = {
118
+ accountId,
119
+ repoId: r.repoId,
120
+ prId: r.prId,
121
+ targetKind: kind,
122
+ targetId: r.id,
123
+ authorUserId: r.authorId,
124
+ severity: label.severity,
125
+ severityOrd: label.severityOrd,
126
+ severityProb: 1,
127
+ vendorSeverity: null,
128
+ vendorSeverityConfidence: null,
129
+ categories: label.categories,
130
+ categoryProbs: Object.fromEntries(label.categories.map((c) => [c, 1])),
131
+ isSummary: false,
132
+ backend: LIMN_LABEL_BACKEND,
133
+ modelVersion: LIMN_LABEL_MODEL_VERSION,
134
+ bodyHash,
135
+ targetCreatedAt: r.createdAt,
136
+ updatedAt: now,
137
+ };
138
+ // ⚠ Same conflict target as the scorer's upsert (`mcl_account_target`), and NO backend guard:
139
+ // a Limn finding's label is always Limn's.
140
+ const { accountId: _a, targetKind: _k, targetId: _t, ...set } = values;
141
+ written += (await exec
142
+ .insert(mlCommentLabels)
143
+ .values(values)
144
+ .onConflictDoUpdate({
145
+ target: [mlCommentLabels.accountId, mlCommentLabels.targetKind, mlCommentLabels.targetId],
146
+ set,
147
+ })
148
+ .returning({ id: mlCommentLabels.id })
149
+ .execute()).length;
150
+ };
151
+ for (const r of rc)
152
+ await write('review_comment', r);
153
+ for (const r of pc)
154
+ await write('pr_comment', r);
155
+ // A Limn row whose comment is no longer a Limn finding with a severity (marker edited away, or
156
+ // a reminder) is removed; a scorer row on a Limn finding is removed too — never ML-graded.
157
+ // (Every `existing` row is one or the other, by its query.)
158
+ const stale = existing.filter((e) => !keep.has(key(e.targetKind, e.targetId))).map((e) => e.id);
159
+ let deleted = 0;
160
+ for (let i = 0; i < stale.length; i += 500) {
161
+ deleted += (await exec
162
+ .delete(mlCommentLabels)
163
+ .where(inArray(mlCommentLabels.id, stale.slice(i, i + 500)))
164
+ .returning({ id: mlCommentLabels.id })
165
+ .execute()).length;
166
+ }
167
+ return { written, deleted };
168
+ }
169
+ /** Run the writer outside any transaction (deep re-sync, scripts). */
170
+ export function syncLimnFindingLabelsNow(accountId, prIds) {
171
+ return syncLimnFindingLabels(db, accountId, prIds ? { prIds } : {});
172
+ }
173
+ //# sourceMappingURL=limn-finding-labels.js.map
@@ -67,6 +67,8 @@ export function limnSeverityTargets(input) {
67
67
  }
68
68
  return { byThreadId, byPrCommentId };
69
69
  }
70
+ // Who posted a comment: the poster of a Limn finding, else its author.
71
+ const posterOf = (c) => c.limnPostedByUserId ?? c.authorId;
70
72
  /**
71
73
  * Load this PR's posted findings and fold them over the thread / comment rows getPrDetail already
72
74
  * read. One indexed query when nothing of Limn's is posted; the account login and the comment
@@ -97,7 +99,7 @@ export async function loadLimnSeverityTargets(accountId, prId, rows) {
97
99
  const loginById = new Map();
98
100
  if (needsMatcher) {
99
101
  accountLogin = await accountGithubLogin(accountId);
100
- const authorIds = [...new Set(rows.threadComments.map((c) => c.authorId).filter((x) => x != null))];
102
+ const authorIds = [...new Set(rows.threadComments.map(posterOf).filter((x) => x != null))];
101
103
  if (authorIds.length > 0) {
102
104
  const us = await db
103
105
  .select({ id: schema.users.id, login: schema.users.githubLogin })
@@ -116,7 +118,7 @@ export async function loadLimnSeverityTargets(accountId, prId, rows) {
116
118
  threadId: c.threadId,
117
119
  databaseId: c.databaseId,
118
120
  body: c.body,
119
- authorLogin: c.authorId != null ? (loginById.get(c.authorId) ?? null) : null,
121
+ authorLogin: posterOf(c) != null ? (loginById.get(posterOf(c)) ?? null) : null,
120
122
  createdAt: c.createdAt.getTime(),
121
123
  })),
122
124
  prComments: rows.prComments,
@@ -0,0 +1,10 @@
1
+ -- LIMN FINDINGS ARE BOTS (2026-11-01; docs/CLAUDE-REVIEW.md § Limn findings are bots). Additive.
2
+ -- review_comments.limn_posted_by_user_id the person who posted a Limn finding comment; the
3
+ -- pr_comments.limn_posted_by_user_id row's author_id then holds the per-poster bot user
4
+ -- `limn:<login>`. NULL on every other comment.
5
+ -- No backfill here: the boot reconcile (db/limn-bots.ts `reconcileLimnFindingAuthors`) re-attributes
6
+ -- existing rows, idempotently, because the predicate (shared `limnFindingPost`) is code.
7
+ ALTER TABLE `review_comments` ADD `limn_posted_by_user_id` integer REFERENCES users(id);--> statement-breakpoint
8
+ ALTER TABLE `pr_comments` ADD `limn_posted_by_user_id` integer REFERENCES users(id);--> statement-breakpoint
9
+ CREATE INDEX IF NOT EXISTS `rc_limn_poster_idx` ON `review_comments` (`limn_posted_by_user_id`);--> statement-breakpoint
10
+ CREATE INDEX IF NOT EXISTS `prc_limn_poster_idx` ON `pr_comments` (`limn_posted_by_user_id`);
@@ -1058,6 +1058,13 @@
1058
1058
  "when": 1792224053016,
1059
1059
  "tag": "0150_review_coverage",
1060
1060
  "breakpoints": true
1061
+ },
1062
+ {
1063
+ "idx": 151,
1064
+ "version": "6",
1065
+ "when": 1792224054016,
1066
+ "tag": "0151_limn_finding_bots",
1067
+ "breakpoints": true
1061
1068
  }
1062
1069
  ]
1063
1070
  }