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.
Files changed (51) hide show
  1. package/dist/api/routes/dependency-merge.js +2 -1
  2. package/dist/db/ai-usage-report.js +5 -2
  3. package/dist/db/limn-finding-severity.js +15 -1
  4. package/dist/db/migrations/0160_teammate_reviews.sql +21 -0
  5. package/dist/db/migrations/0161_review_post_on_finish.sql +7 -0
  6. package/dist/db/migrations/0162_dependency_ai_fix.sql +12 -0
  7. package/dist/db/migrations/meta/_journal.json +21 -0
  8. package/dist/db/pending-first-landing.js +3 -1
  9. package/dist/db/queries.js +3 -1
  10. package/dist/db/review-threads-for-review.js +3 -1
  11. package/dist/db/schema.sqlite.js +32 -3
  12. package/dist/db/teammate-reviews-import.js +279 -0
  13. package/dist/db/teammate-reviews.js +209 -0
  14. package/dist/merge/auto-merge-runner.js +14 -0
  15. package/dist/merge/dependency-automation.js +457 -64
  16. package/dist/merge/dependency-policy.js +26 -2
  17. package/dist/merge/dependency-rules.js +52 -0
  18. package/dist/merge/deps-claim.js +410 -0
  19. package/dist/review/claude-review/auto-post.js +51 -9
  20. package/dist/review/claude-review/auto-pushback.js +1 -1
  21. package/dist/review/claude-review/auto-resolve.js +1 -1
  22. package/dist/review/claude-review/chat.js +3 -1
  23. package/dist/review/claude-review/finding-replies.js +13 -5
  24. package/dist/review/claude-review/manager.js +49 -4
  25. package/dist/review/claude-review/manual-reply.js +1 -1
  26. package/dist/review/claude-review/persist.js +92 -12
  27. package/dist/review/claude-review/post-meta.js +16 -0
  28. package/dist/review/claude-review/replies-outcome.js +3 -0
  29. package/dist/review/claude-review/routes.js +24 -1
  30. package/dist/review/claude-review/team-claim-release.js +2 -2
  31. package/dist/review/claude-review/team-claim.js +32 -3
  32. package/dist/review/claude-review/team-view.js +265 -0
  33. package/dist/review/claude-review/teammate-anchor.js +109 -0
  34. package/dist/review/post-seam.js +14 -1
  35. package/dist/shared/dependency-merge.js +6 -0
  36. package/dist/shared/index.js +1 -0
  37. package/dist/shared/limn-review-meta.js +157 -0
  38. package/dist/shared/types.js +2 -0
  39. package/dist/sync/upsert.js +17 -1
  40. package/dist/version.js +35 -0
  41. package/package.json +1 -1
  42. package/public/assets/AiFixTab-D_r2JEy_.js +1 -0
  43. package/public/assets/ClaudeReviewTab-ncqvtble.js +20 -0
  44. package/public/assets/{PrBotBehaviourTab-EgZ8mnoW.js → PrBotBehaviourTab-Da_67MPm.js} +1 -1
  45. package/public/assets/index-DIq-VB3X.css +1 -0
  46. package/public/assets/index-DVYEmdnp.js +87 -0
  47. package/public/index.html +2 -2
  48. package/public/assets/AiFixTab-B7AIpa7v.js +0 -1
  49. package/public/assets/ClaudeReviewTab-BWIpd_Ft.js +0 -20
  50. package/public/assets/index-AIhwoS_J.css +0 -1
  51. 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 ?? null,
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
- .where(eq(schema.claudeReviews.accountId, accountId))
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({
@@ -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
- .where(and(eq(claudeReviews.accountId, accountId), eq(pullRequests.repoId, repoId)))
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
- .where(and(eq(cr.accountId, accountId), inArray(cr.prId, [...prIds]), isNotNull(crf.postedAt)))
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);
@@ -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
- action: text('action', { enum: ['approve', 'rebase_request', 'ai_resolve'] }).notNull(),
915
- state: text('state', { enum: ['running', 'done', 'failed'] }).notNull(),
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
- kind: text('kind', { enum: ['replies'] }),
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