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.
- package/dist/api/routes/bot-triage.js +8 -17
- package/dist/db/bot-overlap.js +9 -2
- package/dist/db/limn-bots.js +274 -0
- package/dist/db/limn-finding-labels.js +173 -0
- package/dist/db/limn-finding-severity.js +4 -2
- package/dist/db/migrations/0151_limn_finding_bots.sql +10 -0
- package/dist/db/migrations/meta/_journal.json +7 -0
- package/dist/db/ml-labels.js +51 -29
- package/dist/db/queries.js +40 -48
- package/dist/db/review-threads-for-review.js +7 -3
- package/dist/db/schema.sqlite.js +11 -0
- package/dist/feed/my-turn.js +5 -3
- package/dist/index.js +15 -0
- package/dist/review/claude-review/auto-post.js +6 -4
- package/dist/review/claude-review/finding-replies.js +4 -2
- package/dist/review/claude-review/persist.js +3 -3
- package/dist/review/claude-review/routes.js +3 -3
- package/dist/review/post-seam.js +5 -5
- package/dist/shared/limn-severity.js +93 -5
- package/dist/shared/product.js +2 -1
- package/dist/sync/reviewer-classify.js +21 -2
- package/dist/sync/upsert.js +48 -5
- package/package.json +1 -1
- package/public/assets/{AiFixTab-DLwrZHlH.js → AiFixTab-RRZCvJa-.js} +1 -1
- package/public/assets/{ClaudeReviewTab-BK6Iz-IB.js → ClaudeReviewTab-Dorz2I8S.js} +1 -1
- package/public/assets/{PrBotBehaviourTab-ryP1DuAt.js → PrBotBehaviourTab-COf3vJXP.js} +1 -1
- package/public/assets/{index-DWLpcq8D.js → index-BezBYXmy.js} +62 -61
- package/public/index.html +1 -1
|
@@ -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
|
|
270
|
-
//
|
|
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
|
|
700
|
-
//
|
|
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
|
-
//
|
|
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}` };
|
package/dist/db/bot-overlap.js
CHANGED
|
@@ -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) =>
|
|
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(
|
|
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(
|
|
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
|
|
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
|
}
|