limn-review 0.1.174 → 0.1.176
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/dependency-merge.js +2 -1
- package/dist/db/ai-usage-report.js +5 -2
- package/dist/db/limn-finding-severity.js +15 -1
- package/dist/db/migrations/0160_teammate_reviews.sql +21 -0
- package/dist/db/migrations/0161_review_post_on_finish.sql +7 -0
- package/dist/db/migrations/0162_dependency_ai_fix.sql +12 -0
- package/dist/db/migrations/meta/_journal.json +21 -0
- package/dist/db/pending-first-landing.js +3 -1
- package/dist/db/queries.js +3 -1
- package/dist/db/review-threads-for-review.js +3 -1
- package/dist/db/schema.sqlite.js +32 -3
- package/dist/db/teammate-reviews-import.js +279 -0
- package/dist/db/teammate-reviews.js +209 -0
- package/dist/merge/auto-merge-runner.js +14 -0
- package/dist/merge/dependency-automation.js +457 -64
- package/dist/merge/dependency-policy.js +26 -2
- package/dist/merge/dependency-rules.js +52 -0
- package/dist/merge/deps-claim.js +410 -0
- package/dist/review/claude-review/auto-post.js +51 -9
- package/dist/review/claude-review/auto-pushback.js +1 -1
- package/dist/review/claude-review/auto-resolve.js +1 -1
- package/dist/review/claude-review/chat.js +3 -1
- package/dist/review/claude-review/finding-replies.js +13 -5
- package/dist/review/claude-review/manager.js +49 -4
- package/dist/review/claude-review/manual-reply.js +1 -1
- package/dist/review/claude-review/persist.js +92 -12
- package/dist/review/claude-review/post-meta.js +16 -0
- package/dist/review/claude-review/replies-outcome.js +3 -0
- package/dist/review/claude-review/routes.js +24 -1
- package/dist/review/claude-review/team-claim-release.js +2 -2
- package/dist/review/claude-review/team-claim.js +32 -3
- package/dist/review/claude-review/team-view.js +265 -0
- package/dist/review/claude-review/teammate-anchor.js +109 -0
- package/dist/review/post-seam.js +14 -1
- package/dist/shared/dependency-merge.js +6 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/limn-review-meta.js +157 -0
- package/dist/shared/types.js +2 -0
- package/dist/sync/upsert.js +17 -1
- package/dist/version.js +35 -0
- package/package.json +1 -1
- package/public/assets/AiFixTab-D_r2JEy_.js +1 -0
- package/public/assets/ClaudeReviewTab-ncqvtble.js +20 -0
- package/public/assets/{PrBotBehaviourTab-EgZ8mnoW.js → PrBotBehaviourTab-Da_67MPm.js} +1 -1
- package/public/assets/index-DIq-VB3X.css +1 -0
- package/public/assets/index-DVYEmdnp.js +87 -0
- package/public/index.html +2 -2
- package/public/assets/AiFixTab-B7AIpa7v.js +0 -1
- package/public/assets/ClaudeReviewTab-BWIpd_Ft.js +0 -20
- package/public/assets/index-AIhwoS_J.css +0 -1
- package/public/assets/index-Bcjt3Gbz.js +0 -87
|
@@ -126,6 +126,7 @@ export async function dependencyMergeRoutes(app) {
|
|
|
126
126
|
autoMerge: { type: 'boolean' },
|
|
127
127
|
autoApprove: { type: 'boolean' },
|
|
128
128
|
autoResolveConflicts: { type: 'boolean' },
|
|
129
|
+
autoFix: { type: 'boolean' },
|
|
129
130
|
maxLevel: { type: 'string', enum: [...DEPENDENCY_UPDATE_LEVELS] },
|
|
130
131
|
securityOnly: { type: 'boolean' },
|
|
131
132
|
ecosystems: {
|
|
@@ -141,7 +142,7 @@ export async function dependencyMergeRoutes(app) {
|
|
|
141
142
|
// Fastify's validator COERCES types, so `"true"` or 1 would read as a switch. Raw check.
|
|
142
143
|
preValidation: async (req, reply) => {
|
|
143
144
|
const raw = (req.body ?? {});
|
|
144
|
-
for (const k of ['autoMerge', 'autoApprove', 'autoResolveConflicts', 'securityOnly']) {
|
|
145
|
+
for (const k of ['autoMerge', 'autoApprove', 'autoResolveConflicts', 'autoFix', 'securityOnly']) {
|
|
145
146
|
if (k in raw && typeof raw[k] !== 'boolean') {
|
|
146
147
|
reply.status(400);
|
|
147
148
|
return reply.send({ error: 'BadRequest', message: `\`${k}\` must be true or false.` });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { and, eq, gte, inArray, lt } from 'drizzle-orm';
|
|
2
2
|
import { AI_USAGE_FEATURE_GROUPS, aiModelName, aiUsageFeatureGroup, claudeModelRole, emptyAiUsageFigures, formatTokens, isSpecialistFeature, trackerTicketIdent, AI_MODEL_FOR_ROLE, } from '../shared/index.js';
|
|
3
3
|
import { db, schema } from './client.js';
|
|
4
|
+
import { ownRunsOnly } from './teammate-reviews.js';
|
|
4
5
|
import { measuredTo, reportingWindowInfo } from './reporting-window.js';
|
|
5
6
|
// REPORTS → AI USAGE — the fold behind GET /api/ai/usage-report (docs/API.md; types in packages/shared
|
|
6
7
|
// ai-usage-report.ts). DB only: no model, no GitHub, no tracker.
|
|
@@ -488,13 +489,15 @@ export async function getAiUsageReport(accountId, scope, window, nowMs, rate) {
|
|
|
488
489
|
})
|
|
489
490
|
.from(cr)
|
|
490
491
|
.innerJoin(pr, and(eq(pr.id, cr.prId), eq(pr.accountId, accountId)))
|
|
491
|
-
.where(and(eq(cr.accountId, accountId), inArray(pr.repoId, scope.repoIds), gte(cr.createdAt, new Date(fromMs)), lt(cr.createdAt, new Date(toMs))
|
|
492
|
+
.where(and(eq(cr.accountId, accountId), inArray(pr.repoId, scope.repoIds), gte(cr.createdAt, new Date(fromMs)), lt(cr.createdAt, new Date(toMs)),
|
|
493
|
+
// This machine's runs only: a teammate's imported review cost the reader nothing.
|
|
494
|
+
ownRunsOnly(cr.kind)))
|
|
492
495
|
.execute();
|
|
493
496
|
const reviews = reviewRaw.map((r) => ({
|
|
494
497
|
prId: r.prId,
|
|
495
498
|
status: r.status,
|
|
496
499
|
trigger: r.trigger,
|
|
497
|
-
kind: r.kind
|
|
500
|
+
kind: r.kind === 'replies' ? 'replies' : null, // 'teammate' rows are filtered out above
|
|
498
501
|
provider: r.provider,
|
|
499
502
|
model: r.model,
|
|
500
503
|
cost: toCost(r.provider, r.costUsd),
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// comment builders make, so the chip always says what GitHub says. Praise is never posted: no chip.
|
|
14
14
|
import { and, eq, inArray, isNotNull } from 'drizzle-orm';
|
|
15
15
|
import { severityForPostedFinding, } from '../shared/index.js';
|
|
16
|
+
import { TEAMMATE_KIND } from './teammate-reviews.js';
|
|
16
17
|
import { db, schema } from './client.js';
|
|
17
18
|
import { findFindingThread } from '../review/claude-review/finding-thread.js';
|
|
18
19
|
import { accountGithubLogin } from './review-threads-for-review.js';
|
|
@@ -69,6 +70,12 @@ export function limnSeverityTargets(input) {
|
|
|
69
70
|
}
|
|
70
71
|
// Who posted a comment: the poster of a Limn finding, else its author.
|
|
71
72
|
const posterOf = (c) => c.limnPostedByUserId ?? c.authorId;
|
|
73
|
+
/** A teammate's imported finding whose comment carried no severity lead (`teammate.unlabelled`). */
|
|
74
|
+
export function isUnlabelledTeammateFinding(f) {
|
|
75
|
+
if (f.runKind !== TEAMMATE_KIND || f.sourceNodeId == null)
|
|
76
|
+
return false;
|
|
77
|
+
return (f.teammate?.unlabelled ?? []).includes(f.sourceNodeId);
|
|
78
|
+
}
|
|
72
79
|
/**
|
|
73
80
|
* Load this PR's posted findings and fold them over the thread / comment rows getPrDetail already
|
|
74
81
|
* read. One indexed query when nothing of Limn's is posted; the account login and the comment
|
|
@@ -87,11 +94,18 @@ export async function loadLimnSeverityTargets(accountId, prId, rows) {
|
|
|
87
94
|
githubCommentId: crf.githubCommentId,
|
|
88
95
|
postedCommentKind: crf.postedCommentKind,
|
|
89
96
|
reminder: crf.reminder,
|
|
97
|
+
runKind: cr.kind,
|
|
98
|
+
teammate: cr.teammate,
|
|
99
|
+
sourceNodeId: crf.sourceCommentNodeId,
|
|
90
100
|
})
|
|
91
101
|
.from(crf)
|
|
92
102
|
.innerJoin(cr, eq(cr.id, crf.reviewId))
|
|
93
103
|
.where(and(eq(cr.accountId, accountId), eq(cr.prId, prId), isNotNull(crf.postedAt)))
|
|
94
|
-
.execute())
|
|
104
|
+
.execute())
|
|
105
|
+
// A teammate's UNLABELLED post (a legacy comment with no severity lead) is stored as 'warning'
|
|
106
|
+
// only because the column is NOT NULL: GitHub shows no label, so neither does the chip.
|
|
107
|
+
.filter((f) => !isUnlabelledTeammateFinding(f))
|
|
108
|
+
.map(({ runKind: _k, teammate: _t, sourceNodeId: _n, ...f }) => f);
|
|
95
109
|
if (findings.length === 0)
|
|
96
110
|
return { byThreadId: new Map(), byPrCommentId: new Map() };
|
|
97
111
|
const needsMatcher = findings.some((f) => f.githubCommentId == null && f.postedCommentKind !== 'pr_comment');
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- TEAMMATES' POSTED REVIEWS (docs/CLAUDE-REVIEW.md § Teammates' reviews). A review a teammate's Limn
|
|
2
|
+
-- POSTED to GitHub (summary + finding comments) is imported as a read-only `claude_reviews` row with
|
|
3
|
+
-- `kind = 'teammate'` (plain text, no CHECK — the column already exists), so every reader that means
|
|
4
|
+
-- "the reader's own runs" (`kind IS NULL`, or db/teammate-reviews.ts `ownRunsOnly`) leaves it out.
|
|
5
|
+
-- source_user_id the teammate who posted it (users.id, the TRUE poster)
|
|
6
|
+
-- source_review_node_id the GitHub review's node id — the import's idempotency key
|
|
7
|
+
-- teammate JSON: what the hidden summary marker said (run kind, pair, runs, Limn
|
|
8
|
+
-- version), the GitHub review's numeric id, and whether the head was inferred
|
|
9
|
+
-- Each finding is keyed on its GitHub comment's node id. Additive and nullable: no backfill (the next
|
|
10
|
+
-- walk of a PR imports what is there).
|
|
11
|
+
ALTER TABLE `claude_reviews` ADD `source_user_id` integer REFERENCES users(id);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
ALTER TABLE `claude_reviews` ADD `source_review_node_id` text;
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
ALTER TABLE `claude_reviews` ADD `teammate` text;
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE UNIQUE INDEX `cr_account_pr_source_review` ON `claude_reviews` (`account_id`,`pr_id`,`source_review_node_id`) WHERE `source_review_node_id` IS NOT NULL;
|
|
18
|
+
--> statement-breakpoint
|
|
19
|
+
ALTER TABLE `claude_review_findings` ADD `source_comment_node_id` text;
|
|
20
|
+
--> statement-breakpoint
|
|
21
|
+
CREATE UNIQUE INDEX `crf_review_source_comment` ON `claude_review_findings` (`review_id`,`source_comment_node_id`) WHERE `source_comment_node_id` IS NOT NULL;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- "REVIEW AND POST" (docs/CLAUDE-REVIEW.md § Auto-posting, "A manual Review and post"). A MANUAL run
|
|
2
|
+
-- the reader started with the split button's "Review and post" posts itself when it succeeds (a pair:
|
|
3
|
+
-- when both halves have ended), under the auto-post rules. Stored on the run so the post happens at
|
|
4
|
+
-- its end, and so a pair a restart interrupted still posts when the boot ends it.
|
|
5
|
+
-- claude_reviews.post_on_finish 1 = post when the run ends; NULL = keep it in Limn (every other run).
|
|
6
|
+
-- Additive and nullable: no backfill.
|
|
7
|
+
ALTER TABLE `claude_reviews` ADD `post_on_finish` integer;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- DEPENDENCY AUTOMATION FOR TEAMS (docs/MERGE-CI-TRUNK.md § Dependency automation). Additive; no data to move.
|
|
2
|
+
--
|
|
3
|
+
-- workspaces.dependency_auto_fix "Fix dependency PRs with AI"; NULL/false = off (the default).
|
|
4
|
+
-- dependency_automation_actions.teammate_login a 'skipped' row's teammate (their `limn/deps` line holds the head).
|
|
5
|
+
-- dependency_automation_actions.teammate_handled 1 = their line said they finished an action here; else handling now.
|
|
6
|
+
-- The `action` ('ai_fix', 'arm_merge', 'merge_landed') and `state` ('skipped') columns are plain
|
|
7
|
+
-- text with no CHECK, so their new values need no DDL.
|
|
8
|
+
ALTER TABLE `workspaces` ADD `dependency_auto_fix` integer;
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
ALTER TABLE `dependency_automation_actions` ADD `teammate_login` text;
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
ALTER TABLE `dependency_automation_actions` ADD `teammate_handled` integer;
|
|
@@ -1121,6 +1121,27 @@
|
|
|
1121
1121
|
"when": 1792224062016,
|
|
1122
1122
|
"tag": "0159_team_claims_default_on",
|
|
1123
1123
|
"breakpoints": true
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
"idx": 160,
|
|
1127
|
+
"version": "6",
|
|
1128
|
+
"when": 1792224066016,
|
|
1129
|
+
"tag": "0160_teammate_reviews",
|
|
1130
|
+
"breakpoints": true
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
"idx": 161,
|
|
1134
|
+
"version": "6",
|
|
1135
|
+
"when": 1792224067016,
|
|
1136
|
+
"tag": "0161_review_post_on_finish",
|
|
1137
|
+
"breakpoints": true
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
"idx": 162,
|
|
1141
|
+
"version": "6",
|
|
1142
|
+
"when": 1792224068016,
|
|
1143
|
+
"tag": "0162_dependency_ai_fix",
|
|
1144
|
+
"breakpoints": true
|
|
1124
1145
|
}
|
|
1125
1146
|
]
|
|
1126
1147
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { PENDING_OWN_PRS_SHOWN, PENDING_SUMMARY_DAYS, } from '../shared/index.js';
|
|
28
28
|
import { and, count, desc, eq, inArray, isNotNull, min, sql } from 'drizzle-orm';
|
|
29
29
|
import { db, schema } from './client.js';
|
|
30
|
+
import { ownRunsOnly } from './teammate-reviews.js';
|
|
30
31
|
import { config } from '../config.js';
|
|
31
32
|
import { getAccountUserId } from '../auth/account.js';
|
|
32
33
|
import { docsOffersFor } from '../reference-docs/repo-offers-store.js';
|
|
@@ -125,7 +126,8 @@ async function reviewSuggestion(accountId, userId, repoIds) {
|
|
|
125
126
|
const ran = await db
|
|
126
127
|
.select({ id: schema.claudeReviews.id })
|
|
127
128
|
.from(schema.claudeReviews)
|
|
128
|
-
.
|
|
129
|
+
// The reader's own run: a teammate's imported review is not "a review ran here".
|
|
130
|
+
.where(and(eq(schema.claudeReviews.accountId, accountId), ownRunsOnly(schema.claudeReviews.kind)))
|
|
129
131
|
.limit(1)
|
|
130
132
|
.execute();
|
|
131
133
|
if (!reviewSuggestionAllowed({
|
package/dist/db/queries.js
CHANGED
|
@@ -51,6 +51,7 @@ import { dormantBotUserIds } from './bot-dormancy.js';
|
|
|
51
51
|
import { newestReviewCommentTimes } from './review-threads-for-review.js';
|
|
52
52
|
import { loadLimnSeverityTargets } from './limn-finding-severity.js';
|
|
53
53
|
import { limnBotUserIdForUser, limnBotUserIds } from './limn-bots.js';
|
|
54
|
+
import { ownRunsOnly } from './teammate-reviews.js';
|
|
54
55
|
import { automationVendorUserIds, exactAutomationVendorUsers, githubTypeBotUserIds, globalAutomationUserIds, } from './automation-ids.js';
|
|
55
56
|
import { forwardKindOf, isConflicting, isRedCiStatus, RED_CI_STATUSES, } from './pending-classify.js';
|
|
56
57
|
import { authorAutomationFor, dependencyPrState, dependencyStateDetail, MERGE_QUEUE_CARD_DETAIL, securityFixDetail, unionAdvisoryIds, } from './dependency-cards.js';
|
|
@@ -8018,7 +8019,8 @@ export async function listClaudeReviewsByRepo(repoId, accountId) {
|
|
|
8018
8019
|
})
|
|
8019
8020
|
.from(claudeReviews)
|
|
8020
8021
|
.innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
|
|
8021
|
-
|
|
8022
|
+
// The reader's own runs: a teammate's posted review shows only on its PR's AI Review tab.
|
|
8023
|
+
.where(and(eq(claudeReviews.accountId, accountId), eq(pullRequests.repoId, repoId), ownRunsOnly(claudeReviews.kind)))
|
|
8022
8024
|
.orderBy(desc(claudeReviews.id))
|
|
8023
8025
|
.execute();
|
|
8024
8026
|
// Rows are globally id-desc (newest-first). The first row seen for each PR is its
|
|
@@ -40,6 +40,7 @@ import { and, eq, gt, inArray, isNotNull } from 'drizzle-orm';
|
|
|
40
40
|
import { db, schema } from './client.js';
|
|
41
41
|
import { globalAutomationUserIds } from './automation-ids.js';
|
|
42
42
|
import { trueAuthorId } from './limn-bots.js';
|
|
43
|
+
import { ownRunsOnly } from './teammate-reviews.js';
|
|
43
44
|
const LIMN_MARKER = /<!--\s*pierre:claude-review/i;
|
|
44
45
|
const norm = (s) => s.replace(/\r\n?/g, '\n').trim();
|
|
45
46
|
/** The account's own GitHub login — the author half of `isLimnPostedComment`. */
|
|
@@ -85,7 +86,8 @@ export async function ownPostedCommentsForPrs(accountId, prIds) {
|
|
|
85
86
|
})
|
|
86
87
|
.from(crf)
|
|
87
88
|
.innerJoin(cr, eq(cr.id, crf.reviewId))
|
|
88
|
-
|
|
89
|
+
// The reader's OWN posted findings (a teammate's imported ones are their posts, not ours).
|
|
90
|
+
.where(and(eq(cr.accountId, accountId), inArray(cr.prId, [...prIds]), isNotNull(crf.postedAt), ownRunsOnly(cr.kind)))
|
|
89
91
|
.execute();
|
|
90
92
|
for (const r of rows) {
|
|
91
93
|
let o = out.get(r.prId);
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -911,10 +911,19 @@ export const dependencyAutomationActions = sqliteTable('dependency_automation_ac
|
|
|
911
911
|
// No single-column FK: the COMPOSITE declaration below (tenancy as a constraint).
|
|
912
912
|
prId: integer('pr_id').notNull(),
|
|
913
913
|
headSha: text('head_sha').notNull(),
|
|
914
|
-
|
|
915
|
-
|
|
914
|
+
// 0162 added 'ai_fix', 'arm_merge', 'merge_landed' (plain text, no CHECK).
|
|
915
|
+
action: text('action', {
|
|
916
|
+
enum: ['approve', 'rebase_request', 'ai_resolve', 'ai_fix', 'arm_merge', 'merge_landed'],
|
|
917
|
+
}).notNull(),
|
|
918
|
+
// 0162 added 'skipped': a teammate's Limn holds the head (`limn/deps`). Not an attempt — a later
|
|
919
|
+
// claim at the same head TAKES the row over (`claimAction`).
|
|
920
|
+
state: text('state', { enum: ['running', 'done', 'failed', 'skipped'] }).notNull(),
|
|
916
921
|
detail: text('detail'),
|
|
917
922
|
resultSha: text('result_sha'),
|
|
923
|
+
// 'skipped' rows (migration 0162): the teammate's GitHub login, and whether their line said they
|
|
924
|
+
// HANDLED this head (a finished action) rather than are handling it now.
|
|
925
|
+
teammateLogin: text('teammate_login'),
|
|
926
|
+
teammateHandled: integer('teammate_handled', { mode: 'boolean' }),
|
|
918
927
|
createdAt: integer('created_at', { mode: 'timestamp' })
|
|
919
928
|
.notNull()
|
|
920
929
|
.default(sql `(unixepoch())`),
|
|
@@ -1183,6 +1192,10 @@ export const claudeReviews = sqliteTable('claude_reviews', {
|
|
|
1183
1192
|
// A FROM-SCRATCH run (`GenerateReviewBody.fresh`): it read no earlier review — no follow-up, no
|
|
1184
1193
|
// settled list, no carried thread judgements. true on such a run; NULL otherwise (migration 0133).
|
|
1185
1194
|
fresh: integer('fresh', { mode: 'boolean' }),
|
|
1195
|
+
// "REVIEW AND POST" (migration 0161): a MANUAL run the reader started with "Review and post" —
|
|
1196
|
+
// posted under the auto-post rules when it (a pair: both halves) ends. true on such a run (both
|
|
1197
|
+
// halves of a pair); NULL otherwise ("Review only", every auto run).
|
|
1198
|
+
postOnFinish: integer('post_on_finish', { mode: 'boolean' }),
|
|
1186
1199
|
// WHAT THE TEAM CLAIM DID FOR THIS RUN (migration 0134; review/claude-review/team-claim.ts): the
|
|
1187
1200
|
// GitHub status line it posted ("Marked on GitHub"), or why it ran without one. NULL = no claim
|
|
1188
1201
|
// was attempted (a manual run, or "Mark auto reviews on GitHub" off).
|
|
@@ -1202,12 +1215,21 @@ export const claudeReviews = sqliteTable('claude_reviews', {
|
|
|
1202
1215
|
// A REPLIES PASS (review/claude-review/replies-check.ts): 'replies' on a run that judged only
|
|
1203
1216
|
// people's replies on Limn's own posted findings; NULL on every full review (migration 0136).
|
|
1204
1217
|
// ⚠ Every "the latest review" / "a run at this head" reader filters `kind IS NULL`.
|
|
1205
|
-
|
|
1218
|
+
// 'teammate' (migration 0160; db/teammate-reviews.ts): a review a TEAMMATE'S Limn posted to GitHub,
|
|
1219
|
+
// imported read-only. Never a run of the reader's: every own-runs reader excludes it.
|
|
1220
|
+
kind: text('kind', { enum: ['replies', 'teammate'] }),
|
|
1206
1221
|
// A REPLIES PASS's not-approved reason (migration 0145; review/claude-review/replies-outcome.ts):
|
|
1207
1222
|
// `{heldReason, text}`, written when the pass ends while the workspace's auto verdict is on and
|
|
1208
1223
|
// the outcome is not an approval. NULL on every full review (its reason rides `auto_post`, or is
|
|
1209
1224
|
// computed on read).
|
|
1210
1225
|
verdictReason: text('verdict_reason', { mode: 'json' }).$type(),
|
|
1226
|
+
// A TEAMMATE'S POSTED REVIEW (`kind = 'teammate'`, migration 0160; db/teammate-reviews.ts): the
|
|
1227
|
+
// teammate who posted it, the GitHub review's node id (the import's key; a partial unique index on
|
|
1228
|
+
// (account_id, pr_id, source_review_node_id) in the .sql), and what its hidden marker said. NULL on
|
|
1229
|
+
// every run of the reader's own.
|
|
1230
|
+
sourceUserId: integer('source_user_id').references(() => users.id),
|
|
1231
|
+
sourceReviewNodeId: text('source_review_node_id'),
|
|
1232
|
+
teammate: text('teammate', { mode: 'json' }).$type(),
|
|
1211
1233
|
}, (t) => ({
|
|
1212
1234
|
prIdx: index('cr_pr_idx').on(t.prId),
|
|
1213
1235
|
prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
|
|
@@ -1304,6 +1326,9 @@ export const claudeReviewFindings = sqliteTable('claude_review_findings', {
|
|
|
1304
1326
|
// but is NEVER shown, posted, counted in a verdict or sent to AI Fix; the next follow-up of its
|
|
1305
1327
|
// provider follows up the kept one instead. NULL on every other finding.
|
|
1306
1328
|
mergedInto: integer('merged_into'),
|
|
1329
|
+
// A TEAMMATE'S POSTED FINDING (migration 0160): the GitHub comment's node id it was rebuilt from —
|
|
1330
|
+
// the import's key (partial unique (review_id, source_comment_node_id) in the .sql). NULL otherwise.
|
|
1331
|
+
sourceCommentNodeId: text('source_comment_node_id'),
|
|
1307
1332
|
}, (t) => ({ reviewIdx: index('crf_review_idx').on(t.reviewId) }));
|
|
1308
1333
|
// ---- Claude Review chat (CORE table, written by the Pro plugin) ----
|
|
1309
1334
|
// One row per chat MESSAGE about one succeeded review: a general thread (`finding_id` NULL) and
|
|
@@ -1919,6 +1944,10 @@ export const workspaces = sqliteTable('workspaces', {
|
|
|
1919
1944
|
// every ecosystem. The ONE writer is `setDependencyAutomation` (merge/dependency-automation.ts).
|
|
1920
1945
|
dependencyAutoApprove: integer('dependency_auto_approve', { mode: 'boolean' }),
|
|
1921
1946
|
dependencyAutoResolve: integer('dependency_auto_resolve', { mode: 'boolean' }),
|
|
1947
|
+
// "Fix dependency PRs with AI" (migration 0162), NULL/false = OFF: an unattended AI Fix on a
|
|
1948
|
+
// TRUSTED bot's PR whose CI failed (proven live), pushed to its branch; also runs the conflict
|
|
1949
|
+
// steps of `dependencyAutoResolve`. merge/dependency-automation.ts.
|
|
1950
|
+
dependencyAutoFix: integer('dependency_auto_fix', { mode: 'boolean' }),
|
|
1922
1951
|
dependencyMaxLevel: text('dependency_max_level'),
|
|
1923
1952
|
dependencySecurityOnly: integer('dependency_security_only', { mode: 'boolean' }),
|
|
1924
1953
|
dependencyEcosystems: text('dependency_ecosystems', { mode: 'json' }).$type(),
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// TEAMMATES' POSTED REVIEWS — THE IMPORT (docs/CLAUDE-REVIEW.md § Teammates' reviews). The read side
|
|
2
|
+
// (listing, the own-runs filter, the follow-up anchor) is db/teammate-reviews.ts, which opens no
|
|
3
|
+
// database at import.
|
|
4
|
+
//
|
|
5
|
+
// The reader and a teammate both run Limn on the same repos. When the TEAMMATE'S Limn posts a review
|
|
6
|
+
// to GitHub — a review whose body ends in the hidden summary marker (shared `parseLimnReviewMarker`),
|
|
7
|
+
// plus that teammate's Limn finding comments — the reader's Limn imports it as a READ-ONLY run:
|
|
8
|
+
//
|
|
9
|
+
// claude_reviews kind 'teammate', status 'succeeded', trigger 'manual' (never 'auto': it
|
|
10
|
+
// is nobody's auto run here), cost/tokens NULL, posted_review_id NULL
|
|
11
|
+
// (the provenance readers key on it and this is not the reader's post),
|
|
12
|
+
// posted_at = when GitHub got it, source_user_id = the teammate,
|
|
13
|
+
// source_review_node_id = the GitHub review (the import's key),
|
|
14
|
+
// teammate = what the marker said (TeammateReviewRecord).
|
|
15
|
+
// claude_review_findings one per finding comment, keyed on the comment's node id, included false,
|
|
16
|
+
// posted_at / github_comment_id / posted_comment_kind from the comment.
|
|
17
|
+
//
|
|
18
|
+
// ONLY WHAT REACHED GITHUB: a review a teammate kept in their own Limn is never seen. The reader's own
|
|
19
|
+
// posts are never imported (the TRUE poster is compared with the account's own login). A replies-pass
|
|
20
|
+
// outcome (`k=replies`) is not a review and is skipped.
|
|
21
|
+
//
|
|
22
|
+
// ⚠ EVERY READER THAT MEANS "THE READER'S OWN RUNS" EXCLUDES THESE ROWS — `kind IS NULL` already
|
|
23
|
+
// does; a reader with no kind filter uses `ownRunsOnly` below. The full list of decisions is in
|
|
24
|
+
// docs/CLAUDE-REVIEW.md § Teammates' reviews.
|
|
25
|
+
//
|
|
26
|
+
// WHICH COMMENTS BELONG TO WHICH REVIEW. A synced comment does not record its GitHub review, so a
|
|
27
|
+
// finding comment is given to the teammate's Limn review submitted at or just before it (GitHub
|
|
28
|
+
// stamps a review's inline comments with the review's own time; PR-level comments follow within
|
|
29
|
+
// seconds). A finding that teammate posted later on its own joins their latest review before it.
|
|
30
|
+
//
|
|
31
|
+
// THE HEAD. The marker's `h=`; on a legacy post (no metadata) the PR's newest synced commit at the
|
|
32
|
+
// post time, flagged `headInferred`.
|
|
33
|
+
//
|
|
34
|
+
// DB-only. Idempotent: a second run over the same rows writes the same values.
|
|
35
|
+
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
36
|
+
import { isLimnBotLogin, limnFindingPost, parseLimnReviewMarker, parsePostedLimnFinding, stripLimnReviewMarker, } from '../shared/index.js';
|
|
37
|
+
import { db, runTransaction, schema } from './client.js';
|
|
38
|
+
import { TEAMMATE_KIND } from './teammate-reviews.js';
|
|
39
|
+
const { accounts, users, reviews, reviewComments, reviewThreads, prComments, commits, pullRequests } = schema;
|
|
40
|
+
const cr = schema.claudeReviews;
|
|
41
|
+
const crf = schema.claudeReviewFindings;
|
|
42
|
+
// A comment is given to a review submitted no later than this after it (clock jitter between a
|
|
43
|
+
// review and its inline comments).
|
|
44
|
+
const GRACE_MS = 60_000;
|
|
45
|
+
const STATE_VERDICT = {
|
|
46
|
+
approved: 'APPROVE',
|
|
47
|
+
changes_requested: 'REQUEST_CHANGES',
|
|
48
|
+
commented: 'COMMENT',
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Import every review a teammate's Limn posted on this PR (see the header). Returns what it wrote.
|
|
52
|
+
* Safe to call after every walk of the PR; one transaction.
|
|
53
|
+
*/
|
|
54
|
+
export async function importTeammateReviews(accountId, prId) {
|
|
55
|
+
const none = { reviews: 0, findings: 0, removedFindings: 0 };
|
|
56
|
+
const pr = (await db
|
|
57
|
+
.select({ id: pullRequests.id, headSha: pullRequests.headSha })
|
|
58
|
+
.from(pullRequests)
|
|
59
|
+
.where(and(eq(pullRequests.id, prId), eq(pullRequests.accountId, accountId)))
|
|
60
|
+
.limit(1)
|
|
61
|
+
.execute())[0];
|
|
62
|
+
if (!pr)
|
|
63
|
+
return none;
|
|
64
|
+
const readerLogin = (await db.select({ login: accounts.githubLogin }).from(accounts).where(eq(accounts.id, accountId)).limit(1).execute())[0]?.login?.toLowerCase();
|
|
65
|
+
if (!readerLogin)
|
|
66
|
+
return none;
|
|
67
|
+
// Limn review summaries on this PR, by anyone but the reader.
|
|
68
|
+
const posted = (await db
|
|
69
|
+
.select({
|
|
70
|
+
nodeId: reviews.githubNodeId,
|
|
71
|
+
databaseId: reviews.databaseId,
|
|
72
|
+
authorId: reviews.authorId,
|
|
73
|
+
login: users.githubLogin,
|
|
74
|
+
state: reviews.state,
|
|
75
|
+
body: reviews.body,
|
|
76
|
+
submittedAt: reviews.submittedAt,
|
|
77
|
+
})
|
|
78
|
+
.from(reviews)
|
|
79
|
+
.innerJoin(users, eq(users.id, reviews.authorId))
|
|
80
|
+
.where(and(eq(reviews.prId, prId), sql `${reviews.body} like '%pierre:claude-review%'`))
|
|
81
|
+
.orderBy(asc(reviews.submittedAt), asc(reviews.id))
|
|
82
|
+
.execute())
|
|
83
|
+
.map((r) => ({ ...r, meta: parseLimnReviewMarker(r.body) }))
|
|
84
|
+
.filter((r) => r.meta != null &&
|
|
85
|
+
r.meta.run !== 'replies' &&
|
|
86
|
+
r.state !== 'pending' &&
|
|
87
|
+
r.authorId != null &&
|
|
88
|
+
!isLimnBotLogin(r.login) &&
|
|
89
|
+
r.login.toLowerCase() !== readerLogin);
|
|
90
|
+
if (posted.length === 0)
|
|
91
|
+
return none;
|
|
92
|
+
const posterIds = [...new Set(posted.map((r) => r.authorId))];
|
|
93
|
+
// Those posters' Limn FINDING comments on this PR (sync re-attributed them to their bot and kept
|
|
94
|
+
// the poster in `limn_posted_by_user_id`). Inline: thread roots only.
|
|
95
|
+
const inline = (await db
|
|
96
|
+
.select({
|
|
97
|
+
nodeId: reviewComments.githubNodeId,
|
|
98
|
+
databaseId: reviewComments.databaseId,
|
|
99
|
+
posterId: reviewComments.limnPostedByUserId,
|
|
100
|
+
createdAt: reviewComments.createdAt,
|
|
101
|
+
body: reviewComments.body,
|
|
102
|
+
path: reviewThreads.path,
|
|
103
|
+
line: reviewThreads.line,
|
|
104
|
+
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})))`,
|
|
105
|
+
})
|
|
106
|
+
.from(reviewComments)
|
|
107
|
+
.innerJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
|
|
108
|
+
.where(and(eq(reviewComments.prId, prId), inArray(reviewComments.limnPostedByUserId, posterIds)))
|
|
109
|
+
.execute());
|
|
110
|
+
const prLevel = (await db
|
|
111
|
+
.select({
|
|
112
|
+
nodeId: prComments.githubNodeId,
|
|
113
|
+
databaseId: prComments.databaseId,
|
|
114
|
+
posterId: prComments.limnPostedByUserId,
|
|
115
|
+
createdAt: prComments.createdAt,
|
|
116
|
+
body: prComments.body,
|
|
117
|
+
})
|
|
118
|
+
.from(prComments)
|
|
119
|
+
.where(and(eq(prComments.prId, prId), inArray(prComments.limnPostedByUserId, posterIds)))
|
|
120
|
+
.execute());
|
|
121
|
+
const candidates = [];
|
|
122
|
+
for (const c of inline) {
|
|
123
|
+
if (!c.isRoot)
|
|
124
|
+
continue;
|
|
125
|
+
const post = limnFindingPost(c.body, { threadRoot: true });
|
|
126
|
+
if (post == null || post.reminder)
|
|
127
|
+
continue;
|
|
128
|
+
candidates.push({ ...c, body: c.body ?? '', kind: 'inline' });
|
|
129
|
+
}
|
|
130
|
+
for (const c of prLevel) {
|
|
131
|
+
const post = limnFindingPost(c.body);
|
|
132
|
+
if (post == null || post.reminder)
|
|
133
|
+
continue;
|
|
134
|
+
candidates.push({ ...c, body: c.body ?? '', kind: 'pr_comment', path: null, line: null });
|
|
135
|
+
}
|
|
136
|
+
// Each comment → the poster's review submitted at or just before it.
|
|
137
|
+
const byPoster = new Map();
|
|
138
|
+
for (const r of posted) {
|
|
139
|
+
const list = byPoster.get(r.authorId) ?? [];
|
|
140
|
+
list.push(r);
|
|
141
|
+
byPoster.set(r.authorId, list);
|
|
142
|
+
}
|
|
143
|
+
const findingsOf = new Map();
|
|
144
|
+
for (const c of candidates) {
|
|
145
|
+
const list = byPoster.get(c.posterId) ?? [];
|
|
146
|
+
let owner = null;
|
|
147
|
+
for (const r of list)
|
|
148
|
+
if (r.submittedAt.getTime() <= c.createdAt.getTime() + GRACE_MS)
|
|
149
|
+
owner = r;
|
|
150
|
+
if (owner == null)
|
|
151
|
+
continue;
|
|
152
|
+
const got = findingsOf.get(owner.nodeId) ?? [];
|
|
153
|
+
got.push(c);
|
|
154
|
+
findingsOf.set(owner.nodeId, got);
|
|
155
|
+
}
|
|
156
|
+
// A legacy post's head: the newest synced commit at or before it.
|
|
157
|
+
const prCommits = await db
|
|
158
|
+
.select({ sha: commits.sha, committedAt: commits.committedAt })
|
|
159
|
+
.from(commits)
|
|
160
|
+
.where(eq(commits.prId, prId))
|
|
161
|
+
.orderBy(desc(commits.committedAt))
|
|
162
|
+
.execute();
|
|
163
|
+
const inferHead = (at) => prCommits.find((c) => c.committedAt.getTime() <= at.getTime())?.sha ?? pr.headSha ?? null;
|
|
164
|
+
return runTransaction(async (tx) => {
|
|
165
|
+
const out = { reviews: 0, findings: 0, removedFindings: 0 };
|
|
166
|
+
for (const r of posted) {
|
|
167
|
+
const meta = r.meta;
|
|
168
|
+
const head = meta.headSha ?? inferHead(r.submittedAt);
|
|
169
|
+
if (head == null)
|
|
170
|
+
continue;
|
|
171
|
+
const found = (findingsOf.get(r.nodeId) ?? []).sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.nodeId.localeCompare(b.nodeId));
|
|
172
|
+
const summary = stripLimnReviewMarker(r.body);
|
|
173
|
+
// A legacy post with no text and no finding is nothing to show (an old replies-pass outcome).
|
|
174
|
+
if (meta.legacy && summary === '' && found.length === 0)
|
|
175
|
+
continue;
|
|
176
|
+
const parsed = found.map((f) => ({ c: f, p: parsePostedLimnFinding(f.body) }));
|
|
177
|
+
const record = {
|
|
178
|
+
githubReviewId: r.databaseId ?? null,
|
|
179
|
+
run: meta.run === 'auto' || meta.run === 'manual' ? meta.run : null,
|
|
180
|
+
pair: meta.pair,
|
|
181
|
+
runs: meta.runs,
|
|
182
|
+
limnVersion: meta.limnVersion,
|
|
183
|
+
headInferred: meta.headSha == null,
|
|
184
|
+
...(parsed.some((x) => x.p.severity == null)
|
|
185
|
+
? { unlabelled: parsed.filter((x) => x.p.severity == null).map((x) => x.c.nodeId) }
|
|
186
|
+
: {}),
|
|
187
|
+
};
|
|
188
|
+
const first = meta.runs[0];
|
|
189
|
+
const values = {
|
|
190
|
+
headSha: head,
|
|
191
|
+
status: 'succeeded',
|
|
192
|
+
model: first?.model ?? '',
|
|
193
|
+
provider: first?.provider ?? null,
|
|
194
|
+
effort: first?.effort ?? null,
|
|
195
|
+
summary,
|
|
196
|
+
verdict: meta.verdict ?? STATE_VERDICT[r.state] ?? null,
|
|
197
|
+
postedAt: r.submittedAt,
|
|
198
|
+
finishedAt: r.submittedAt,
|
|
199
|
+
sourceUserId: r.authorId,
|
|
200
|
+
teammate: record,
|
|
201
|
+
};
|
|
202
|
+
const existing = (await tx
|
|
203
|
+
.select({ id: cr.id })
|
|
204
|
+
.from(cr)
|
|
205
|
+
.where(and(eq(cr.accountId, accountId), eq(cr.prId, prId), eq(cr.sourceReviewNodeId, r.nodeId)))
|
|
206
|
+
.limit(1)
|
|
207
|
+
.execute())[0];
|
|
208
|
+
let reviewId;
|
|
209
|
+
if (existing) {
|
|
210
|
+
reviewId = existing.id;
|
|
211
|
+
await tx.update(cr).set(values).where(eq(cr.id, reviewId)).execute();
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
reviewId = (await tx
|
|
215
|
+
.insert(cr)
|
|
216
|
+
.values({
|
|
217
|
+
...values,
|
|
218
|
+
accountId,
|
|
219
|
+
prId,
|
|
220
|
+
kind: TEAMMATE_KIND,
|
|
221
|
+
trigger: 'manual',
|
|
222
|
+
sourceReviewNodeId: r.nodeId,
|
|
223
|
+
createdAt: r.submittedAt,
|
|
224
|
+
})
|
|
225
|
+
.returning({ id: cr.id })
|
|
226
|
+
.execute())[0].id;
|
|
227
|
+
}
|
|
228
|
+
out.reviews += 1;
|
|
229
|
+
const have = await tx
|
|
230
|
+
.select({ id: crf.id, nodeId: crf.sourceCommentNodeId })
|
|
231
|
+
.from(crf)
|
|
232
|
+
.where(eq(crf.reviewId, reviewId))
|
|
233
|
+
.execute();
|
|
234
|
+
const haveByNode = new Map(have.map((h) => [h.nodeId, h.id]));
|
|
235
|
+
const keep = new Set();
|
|
236
|
+
for (const { c, p } of parsed) {
|
|
237
|
+
const fields = {
|
|
238
|
+
path: c.kind === 'inline' ? (c.path ?? '') : (p.refPath ?? ''),
|
|
239
|
+
line: c.kind === 'inline' ? c.line : p.refLine,
|
|
240
|
+
side: 'RIGHT',
|
|
241
|
+
// The severity column is NOT NULL: an unlabelled finding stores 'warning', named in
|
|
242
|
+
// `teammate.unlabelled` and read back as null.
|
|
243
|
+
severity: p.severity ?? 'warning',
|
|
244
|
+
title: p.title || '(no text)',
|
|
245
|
+
body: p.body,
|
|
246
|
+
anchored: c.kind === 'inline',
|
|
247
|
+
fileInDiff: c.kind === 'inline',
|
|
248
|
+
included: false,
|
|
249
|
+
postedAt: c.createdAt,
|
|
250
|
+
githubCommentId: c.databaseId,
|
|
251
|
+
postedCommentKind: c.kind,
|
|
252
|
+
};
|
|
253
|
+
const id = haveByNode.get(c.nodeId);
|
|
254
|
+
if (id != null) {
|
|
255
|
+
keep.add(id);
|
|
256
|
+
await tx.update(crf).set(fields).where(eq(crf.id, id)).execute();
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const ins = await tx
|
|
260
|
+
.insert(crf)
|
|
261
|
+
.values({ ...fields, reviewId, sourceCommentNodeId: c.nodeId, createdAt: c.createdAt })
|
|
262
|
+
.returning({ id: crf.id })
|
|
263
|
+
.execute();
|
|
264
|
+
keep.add(ins[0].id);
|
|
265
|
+
}
|
|
266
|
+
out.findings += 1;
|
|
267
|
+
}
|
|
268
|
+
// A comment no longer there (deleted on GitHub, or now another review's) leaves the review.
|
|
269
|
+
const gone = have.filter((h) => !keep.has(h.id)).map((h) => h.id);
|
|
270
|
+
if (gone.length > 0) {
|
|
271
|
+
await tx.delete(schema.claudeReviewChatMessages).where(inArray(schema.claudeReviewChatMessages.findingId, gone)).execute();
|
|
272
|
+
await tx.delete(crf).where(inArray(crf.id, gone)).execute();
|
|
273
|
+
out.removedFindings += gone.length;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=teammate-reviews-import.js.map
|