limn-review 0.1.138 → 0.1.140

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 (54) hide show
  1. package/dist/ai/result-schemas.js +3 -0
  2. package/dist/api/plugins/rate-limit.js +5 -0
  3. package/dist/api/routes/prs.js +3 -2
  4. package/dist/db/limn-finding-severity.js +126 -0
  5. package/dist/db/migrations/0134_team_claims.sql +10 -0
  6. package/dist/db/migrations/0135_review_pairs.sql +12 -0
  7. package/dist/db/migrations/0136_review_kind.sql +5 -0
  8. package/dist/db/migrations/meta/_journal.json +21 -0
  9. package/dist/db/pr-liveness.js +11 -2
  10. package/dist/db/queries.js +68 -6
  11. package/dist/db/review-threads-for-review.js +5 -2
  12. package/dist/db/schema.sqlite.js +23 -0
  13. package/dist/github/branch-queries.js +15 -1
  14. package/dist/github/check-context-state.js +42 -0
  15. package/dist/github/commit-checks.js +13 -2
  16. package/dist/github/mutations.js +32 -1
  17. package/dist/github/queries.js +6 -0
  18. package/dist/review/agentic.js +2 -0
  19. package/dist/review/claude-review/auto-post.js +109 -39
  20. package/dist/review/claude-review/auto-settings.js +35 -1
  21. package/dist/review/claude-review/auto.js +104 -11
  22. package/dist/review/claude-review/finding-thread.js +5 -1
  23. package/dist/review/claude-review/manager.js +341 -53
  24. package/dist/review/claude-review/pair.js +110 -0
  25. package/dist/review/claude-review/persist.js +149 -23
  26. package/dist/review/claude-review/prompts.js +42 -4
  27. package/dist/review/claude-review/replies-check-agent.js +154 -0
  28. package/dist/review/claude-review/replies-check-prompt.js +82 -0
  29. package/dist/review/claude-review/replies-check-routes.js +76 -0
  30. package/dist/review/claude-review/replies-check-schema.js +30 -0
  31. package/dist/review/claude-review/replies-check.js +638 -0
  32. package/dist/review/claude-review/replies-outcome.js +322 -0
  33. package/dist/review/claude-review/replies-pass.js +267 -0
  34. package/dist/review/claude-review/routes.js +195 -7
  35. package/dist/review/claude-review/team-claim.js +465 -0
  36. package/dist/review/post-review.js +14 -3
  37. package/dist/review/post-seam.js +12 -8
  38. package/dist/shared/ai-provider-words.js +3 -1
  39. package/dist/shared/index.js +2 -0
  40. package/dist/shared/limn-severity.js +52 -0
  41. package/dist/shared/limn-status.js +70 -0
  42. package/dist/sync/backfill-ci-history.js +32 -2
  43. package/dist/sync/branch-status.js +42 -4
  44. package/dist/sync/limn-ci.js +59 -0
  45. package/dist/sync/unsettled-prs.js +2 -0
  46. package/dist/sync/upsert.js +47 -45
  47. package/package.json +1 -1
  48. package/public/assets/{AiFixTab-DwAzsycp.js → AiFixTab-D8HrsNZV.js} +3 -3
  49. package/public/assets/ClaudeReviewTab-CJpvYk_E.js +20 -0
  50. package/public/assets/{PrBotBehaviourTab-CtfRiaES.js → PrBotBehaviourTab-Bwu839bx.js} +1 -1
  51. package/public/assets/index-BoS_VHWj.js +81 -0
  52. package/public/index.html +1 -1
  53. package/public/assets/ClaudeReviewTab-CGFvyUOI.js +0 -20
  54. package/public/assets/index-BHxZquEA.js +0 -81
@@ -9,6 +9,8 @@ export const RESULT_TOOL_NAMES = [
9
9
  'submit_pr_card',
10
10
  'submit_ci_review',
11
11
  'submit_threads',
12
+ // The replies pass (review/claude-review/replies-check.ts): judgements of replies on Limn's findings.
13
+ 'submit_replies',
12
14
  'submit_design',
13
15
  'submit_paths',
14
16
  'submit_alignment',
@@ -30,6 +32,7 @@ const BUILDERS = {
30
32
  submit_pr_card: async (z) => (await import('../review/ticket-review/schema.js')).buildSubmitPrCardShape(z),
31
33
  submit_ci_review: async (z) => (await import('../review/ci-review/schema.js')).buildSubmitCiReviewShape(z),
32
34
  submit_threads: async (z) => (await import('../review/claude-review/thread-check-schema.js')).buildSubmitThreadsShape(z),
35
+ submit_replies: async (z) => (await import('../review/claude-review/replies-check-schema.js')).buildSubmitRepliesShape(z),
33
36
  submit_design: async (z) => (await import('../review/diagrams/schema.js')).buildSubmitDesignShape(z),
34
37
  submit_paths: async (z) => (await import('../review/sprint-paths/schema.js')).buildSubmitPathsShape(z),
35
38
  submit_alignment: async (z) => (await import('../review/story-docs/schema.js')).buildSubmitAlignmentShape(z),
@@ -529,6 +529,11 @@ function tierFor(method, path) {
529
529
  // "Post reply" on a follow-up reply status: ONE GitHub thread reply (+ a resolve), no model.
530
530
  if (mutating && /^\/api\/claude-reviews\/\d+\/follow-up\/\d+\/reply$/.test(path))
531
531
  return [TIERS.githubWrite];
532
+ // TEAM CLAIMS "Clear" (review/claude-review/routes.ts): ONE commit-status write, no model. Spelled
533
+ // EXACTLY here, before the Claude Review family below would park it on the AI bucket (the
534
+ // `hitsGithub` alternation further down is never reached by a `/claude-review` path).
535
+ if (mutating && /^\/api\/prs\/\d+\/claude-review\/team-claim\/clear$/.test(path))
536
+ return [TIERS.githubWrite];
532
537
  // AI Fix PLAN MODE (coding/ai-fix/routes.ts, LOCAL ONLY). Spelled out so the family cannot drift
533
538
  // with the /api/pro/ catch-all below, which would also have parked the DB-only discard on `ai`:
534
539
  // POST /api/pro/prs/:id/ai-fix/plan starts a planning agent pass → ai + ai_hourly
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { REQUEST_CHANGES_DEFAULT_BODY, WITHDRAW_APPROVAL_MESSAGE } from '../../shared/index.js';
2
+ import { REQUEST_CHANGES_DEFAULT_BODY, WITHDRAW_APPROVAL_MESSAGE, mergeStateWithoutLimn } from '../../shared/index.js';
3
3
  import { accountGithubLogin } from '../../db/review-threads-for-review.js';
4
4
  import { config } from '../../config.js';
5
5
  import { stampPrMergeQueueStateNonFatal } from '../../db/pr-merge-queue-stamp.js';
@@ -724,7 +724,8 @@ export async function prRoutes(app) {
724
724
  allowedMethods: [...allowedMethods],
725
725
  defaultMethod: allowedMethods[0] ?? 'merge',
726
726
  mergeable: m.mergeable,
727
- mergeStateStatus: m.mergeableState,
727
+ // ⚠ An UNSTABLE only Limn's own status lines caused reads CLEAN (shared limn-status.ts).
728
+ mergeStateStatus: mergeStateWithoutLimn(m.mergeableState, ctx.ciStatus),
728
729
  conflicts,
729
730
  behind,
730
731
  blocked: m.mergeableState === 'blocked',
@@ -0,0 +1,126 @@
1
+ // THE SEVERITY CHIP ON LIMN'S OWN POSTED FINDINGS — which review thread / PR-level comment of a PR
2
+ // is a finding Limn posted, and the severity it was posted with. Read inside getPrDetail (the
3
+ // payload the PR details pane already loads), so the chip costs no request of its own.
4
+ //
5
+ // The link is the thread-owner link the rest of Claude Review uses:
6
+ // - a finding's stored `github_comment_id` is the ROOT comment of the thread it started
7
+ // (`posted_comment_kind = 'inline'`) or the PR-level comment it became ('pr_comment');
8
+ // - its latest REMINDER's comment is the same finding, posted again;
9
+ // - an inline-posted finding with no stored id (its review read-back failed) is matched by the ONE
10
+ // shared matcher, `findFindingThread` (same file, the account's own login, the hidden marker,
11
+ // the finding's text at the start).
12
+ // The severity goes through the shared grader seam `severityForPostedFinding`, the same call the
13
+ // comment builders make, so the chip always says what GitHub says. Praise is never posted: no chip.
14
+ import { and, eq, inArray, isNotNull } from 'drizzle-orm';
15
+ import { severityForPostedFinding, } from '../shared/index.js';
16
+ import { db, schema } from './client.js';
17
+ import { findFindingThread } from '../review/claude-review/finding-thread.js';
18
+ import { accountGithubLogin } from './review-threads-for-review.js';
19
+ /** The pure fold (see the header). `threadComments` are every comment of the PR's threads. */
20
+ export function limnSeverityTargets(input) {
21
+ const byThreadId = new Map();
22
+ const byPrCommentId = new Map();
23
+ const inline = new Map();
24
+ const prLevel = new Map();
25
+ const unlinked = [];
26
+ // Oldest first, so the finding that really posted owns its thread before a later one is tried.
27
+ for (const f of [...input.findings].sort((a, b) => a.id - b.id)) {
28
+ const sev = severityForPostedFinding(f);
29
+ if (sev == null)
30
+ continue;
31
+ if (f.githubCommentId != null) {
32
+ (f.postedCommentKind === 'pr_comment' ? prLevel : inline).set(f.githubCommentId, sev);
33
+ }
34
+ else if (f.postedCommentKind !== 'pr_comment') {
35
+ unlinked.push({ f, sev });
36
+ }
37
+ const r = f.reminder;
38
+ if (r?.githubCommentId)
39
+ (r.postedCommentKind === 'pr_comment' ? prLevel : inline).set(r.githubCommentId, sev);
40
+ }
41
+ if (inline.size + prLevel.size + unlinked.length === 0)
42
+ return { byThreadId, byPrCommentId };
43
+ // Each thread's ROOT comment (the earliest).
44
+ const roots = new Map();
45
+ for (const c of input.threadComments) {
46
+ const r = roots.get(c.threadId);
47
+ if (!r || c.createdAt < r.createdAt)
48
+ roots.set(c.threadId, c);
49
+ }
50
+ for (const [threadId, root] of roots) {
51
+ const sev = root.databaseId != null ? inline.get(root.databaseId) : undefined;
52
+ if (sev != null)
53
+ byThreadId.set(threadId, sev);
54
+ }
55
+ const taken = new Set(byThreadId.keys());
56
+ for (const { f, sev } of unlinked) {
57
+ const t = findFindingThread(f, input.threads, input.threadComments, input.accountLogin, taken);
58
+ if (t) {
59
+ byThreadId.set(t.id, sev);
60
+ taken.add(t.id);
61
+ }
62
+ }
63
+ for (const c of input.prComments) {
64
+ const sev = c.databaseId != null ? prLevel.get(c.databaseId) : undefined;
65
+ if (sev != null)
66
+ byPrCommentId.set(c.id, sev);
67
+ }
68
+ return { byThreadId, byPrCommentId };
69
+ }
70
+ /**
71
+ * Load this PR's posted findings and fold them over the thread / comment rows getPrDetail already
72
+ * read. One indexed query when nothing of Limn's is posted; the account login and the comment
73
+ * authors are read only when a posted inline finding has no stored comment id.
74
+ */
75
+ export async function loadLimnSeverityTargets(accountId, prId, rows) {
76
+ const { claudeReviews: cr, claudeReviewFindings: crf } = schema;
77
+ const findings = (await db
78
+ .select({
79
+ id: crf.id,
80
+ severity: crf.severity,
81
+ path: crf.path,
82
+ line: crf.line,
83
+ body: crf.body,
84
+ editedBody: crf.editedBody,
85
+ githubCommentId: crf.githubCommentId,
86
+ postedCommentKind: crf.postedCommentKind,
87
+ reminder: crf.reminder,
88
+ })
89
+ .from(crf)
90
+ .innerJoin(cr, eq(cr.id, crf.reviewId))
91
+ .where(and(eq(cr.accountId, accountId), eq(cr.prId, prId), isNotNull(crf.postedAt)))
92
+ .execute());
93
+ if (findings.length === 0)
94
+ return { byThreadId: new Map(), byPrCommentId: new Map() };
95
+ const needsMatcher = findings.some((f) => f.githubCommentId == null && f.postedCommentKind !== 'pr_comment');
96
+ let accountLogin = null;
97
+ const loginById = new Map();
98
+ if (needsMatcher) {
99
+ accountLogin = await accountGithubLogin(accountId);
100
+ const authorIds = [...new Set(rows.threadComments.map((c) => c.authorId).filter((x) => x != null))];
101
+ if (authorIds.length > 0) {
102
+ const us = await db
103
+ .select({ id: schema.users.id, login: schema.users.githubLogin })
104
+ .from(schema.users)
105
+ .where(inArray(schema.users.id, authorIds))
106
+ .execute();
107
+ for (const u of us)
108
+ if (u.login)
109
+ loginById.set(u.id, u.login);
110
+ }
111
+ }
112
+ return limnSeverityTargets({
113
+ findings,
114
+ threads: rows.threads,
115
+ threadComments: rows.threadComments.map((c) => ({
116
+ threadId: c.threadId,
117
+ databaseId: c.databaseId,
118
+ body: c.body,
119
+ authorLogin: c.authorId != null ? (loginById.get(c.authorId) ?? null) : null,
120
+ createdAt: c.createdAt.getTime(),
121
+ })),
122
+ prComments: rows.prComments,
123
+ accountLogin,
124
+ });
125
+ }
126
+ //# sourceMappingURL=limn-finding-severity.js.map
@@ -0,0 +1,10 @@
1
+ -- TEAM CLAIMS (docs/CLAUDE-REVIEW.md § Team claims). Two additive columns:
2
+ -- workspaces.team_claims_enabled "Mark auto reviews on GitHub": an auto review posts a commit
3
+ -- status (limn/review-claude | limn/review-codex) on the PR's head
4
+ -- so teammates' Limn skips a commit someone is already reviewing.
5
+ -- NULL/0 = OFF, the default for every workspace.
6
+ -- claude_reviews.team_claim what the claim did for THIS run (JSON, ClaudeTeamClaimRecord).
7
+ -- NULL = no claim was attempted (a manual run, or the switch off).
8
+ ALTER TABLE `workspaces` ADD `team_claims_enabled` integer;
9
+ --> statement-breakpoint
10
+ ALTER TABLE `claude_reviews` ADD `team_claim` text;
@@ -0,0 +1,12 @@
1
+ -- CLAUDE + CODEX PAIRS (docs/CLAUDE-REVIEW.md § Claude + Codex). A pair is two ordinary runs: Claude
2
+ -- first, then Codex with Claude's findings as already raised. `pair_id` = the FIRST run's id, on both
3
+ -- rows; `pair_role` = 'first' | 'second'; `pair_dropped` = how many of the second run's findings
4
+ -- repeated a first-run finding and were dropped in code (second run only). NULL on every other row.
5
+ ALTER TABLE `claude_reviews` ADD `pair_id` integer;
6
+ --> statement-breakpoint
7
+ ALTER TABLE `claude_reviews` ADD `pair_role` text;
8
+ --> statement-breakpoint
9
+ ALTER TABLE `claude_reviews` ADD `pair_dropped` integer;
10
+ --> statement-breakpoint
11
+ -- Who runs a workspace's AUTO reviews: NULL = the default provider (Settings → AI); 'both' = a pair.
12
+ ALTER TABLE `workspaces` ADD `auto_review_providers` text;
@@ -0,0 +1,5 @@
1
+ -- A REPLIES PASS (docs/CLAUDE-REVIEW.md § The replies pass): a light run that judges only people's
2
+ -- replies on Limn's own posted findings, stored as a `claude_reviews` row so history shows it and
3
+ -- its follow-up items read like any review's. 'replies' on such a row; NULL on every full review,
4
+ -- older ones included. Additive only.
5
+ ALTER TABLE `claude_reviews` ADD `kind` text;
@@ -939,6 +939,27 @@
939
939
  "when": 1792137620016,
940
940
  "tag": "0133_review_fresh",
941
941
  "breakpoints": true
942
+ },
943
+ {
944
+ "idx": 134,
945
+ "version": "6",
946
+ "when": 1792137621016,
947
+ "tag": "0134_team_claims",
948
+ "breakpoints": true
949
+ },
950
+ {
951
+ "idx": 135,
952
+ "version": "6",
953
+ "when": 1792137622016,
954
+ "tag": "0135_review_pairs",
955
+ "breakpoints": true
956
+ },
957
+ {
958
+ "idx": 136,
959
+ "version": "6",
960
+ "when": 1792137623016,
961
+ "tag": "0136_review_kind",
962
+ "breakpoints": true
942
963
  }
943
964
  ]
944
965
  }
@@ -14,6 +14,7 @@
14
14
  // in this batch: queries.ts is already 9k lines and carries literal NUL bytes that make `rg`/
15
15
  // `grep` under-report against it, so new folds go in a file a search can actually find.
16
16
  import { and, eq, inArray } from 'drizzle-orm';
17
+ import { mergeStateWithoutLimn } from '../shared/index.js';
17
18
  import { db, schema } from './client.js';
18
19
  import { READY_MERGE_STATES } from './triage.js';
19
20
  import { noteLiveMergeTransition } from '../sync/merge-notice.js';
@@ -44,6 +45,7 @@ export async function getPrLivenessTargets(accountId, repoIds, prIds) {
44
45
  inMergeQueue: pullRequests.inMergeQueue,
45
46
  mergeQueueEntryState: pullRequests.mergeQueueEntryState,
46
47
  updatedAt: pullRequests.updatedAt,
48
+ ciStatus: pullRequests.ciStatus,
47
49
  })
48
50
  .from(pullRequests)
49
51
  .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), inArray(pullRequests.id, prIds)))
@@ -60,6 +62,7 @@ export async function getPrLivenessTargets(accountId, repoIds, prIds) {
60
62
  inMergeQueue: r.inMergeQueue ?? null,
61
63
  mergeQueueEntryState: r.mergeQueueEntryState ?? null,
62
64
  updatedAt: r.updatedAt,
65
+ ciStatus: r.ciStatus ?? null,
63
66
  }));
64
67
  }
65
68
  /**
@@ -221,8 +224,14 @@ export async function applyPrLiveness(accountId, target, obs) {
221
224
  // computation) keeps its previous state until the next full walk. That is the same staleness
222
225
  // every card had before this route existed, on a narrow slice of PRs, for a couple of minutes.
223
226
  const authoritative = (next, stored, unknown) => next !== undefined && next !== stored && !(next === unknown && stored != null);
224
- if (authoritative(obs.mergeStateStatus, target.mergeStateStatus, 'unknown')) {
225
- set.mergeStateStatus = obs.mergeStateStatus;
227
+ // ⚠ LIMN'S OWN LINES ARE NOT CI: an UNSTABLE that only they caused (the stored CI, which never
228
+ // counts them, is green) is written CLEAN — exactly as `persistPr` writes it. A stored CLEAN over
229
+ // a stored 'unknown' CI is `persistPr`'s "only Limn's lines, no other CI" answer (it read the
230
+ // contexts; this probe does not), so an UNSTABLE here does not flip it back.
231
+ const ciForMergeState = target.ciStatus === 'unknown' && target.mergeStateStatus === 'clean' ? 'none' : target.ciStatus;
232
+ const observedMergeState = mergeStateWithoutLimn(obs.mergeStateStatus, ciForMergeState);
233
+ if (authoritative(observedMergeState, target.mergeStateStatus, 'unknown')) {
234
+ set.mergeStateStatus = observedMergeState;
226
235
  change(true);
227
236
  }
228
237
  if (authoritative(obs.mergeable, target.mergeable, 'unknown')) {
@@ -47,6 +47,7 @@ import { hubReadingFor, loadRepoCoupling } from './file-coupling.js';
47
47
  import { DAYS_PER_MONTH, botWindowMs } from './bot-window.js';
48
48
  import { dormantBotUserIds } from './bot-dormancy.js';
49
49
  import { newestReviewCommentTimes } from './review-threads-for-review.js';
50
+ import { loadLimnSeverityTargets } from './limn-finding-severity.js';
50
51
  import { automationVendorUserIds, exactAutomationVendorUsers, githubTypeBotUserIds, globalAutomationUserIds, } from './automation-ids.js';
51
52
  import { forwardKindOf, isConflicting, isRedCiStatus, RED_CI_STATUSES, } from './pending-classify.js';
52
53
  import { authorAutomationFor, dependencyPrState, dependencyStateDetail, MERGE_QUEUE_CARD_DETAIL, securityFixDetail, unionAdvisoryIds, } from './dependency-cards.js';
@@ -7289,6 +7290,12 @@ export async function getPrDetail(id, accountId) {
7289
7290
  arr.push(c);
7290
7291
  commentsByThread.set(c.threadId, arr);
7291
7292
  }
7293
+ // Limn's own posted findings: the severity chip on their threads and PR-level comments.
7294
+ const limnSeverity = await loadLimnSeverityTargets(accountId, id, {
7295
+ threads: threadRows,
7296
+ threadComments: commentRows,
7297
+ prComments: prCommentRows,
7298
+ });
7292
7299
  const threads = threadRows.map((t) => {
7293
7300
  const tComments = commentsByThread.get(t.id) ?? [];
7294
7301
  return {
@@ -7317,6 +7324,7 @@ export async function getPrDetail(id, accountId) {
7317
7324
  url: tComments[0]?.databaseId
7318
7325
  ? `${prUrl}#discussion_r${tComments[0].databaseId}`
7319
7326
  : null,
7327
+ limnSeverity: limnSeverity.byThreadId.get(t.id) ?? null,
7320
7328
  };
7321
7329
  });
7322
7330
  // Bot-triage provenance (compute-on-read): which reviews were posted by an automated
@@ -7360,6 +7368,7 @@ export async function getPrDetail(id, accountId) {
7360
7368
  body: c.body ?? '',
7361
7369
  createdAt: c.createdAt.toISOString(),
7362
7370
  url: c.databaseId ? `${prUrl}#issuecomment-${c.databaseId}` : null,
7371
+ limnSeverity: limnSeverity.byPrCommentId.get(c.id) ?? null,
7363
7372
  }));
7364
7373
  const commitsOut = commitRows.map((c) => ({
7365
7374
  id: c.id,
@@ -8033,7 +8042,9 @@ repoIds) {
8033
8042
  .from(claudeReviews)
8034
8043
  .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
8035
8044
  .innerJoin(repos, eq(repos.id, pullRequests.repoId))
8036
- .where(and(eq(repos.accountId, accountId), repoIds == null ? undefined : inArray(pullRequests.repoId, repoIds), eq(claudeReviews.status, 'succeeded'), eq(pullRequests.state, 'open')))
8045
+ .where(and(eq(repos.accountId, accountId), repoIds == null ? undefined : inArray(pullRequests.repoId, repoIds), eq(claudeReviews.status, 'succeeded'), eq(pullRequests.state, 'open'),
8046
+ // A REPLIES PASS (kind 'replies') is not a review to action: the card follows the full one.
8047
+ isNull(claudeReviews.kind)))
8037
8048
  .orderBy(desc(claudeReviews.finishedAt), desc(claudeReviews.createdAt))
8038
8049
  .execute();
8039
8050
  const seen = new Set(); // most-recent succeeded run per PR only
@@ -8145,7 +8156,12 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8145
8156
  .select({ id: claudeReviews.id })
8146
8157
  .from(claudeReviews)
8147
8158
  .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
8148
- .where(and(eq(claudeReviews.accountId, accountId), eq(claudeReviews.trigger, 'auto'), gte(claudeReviews.createdAt, new Date(opts.dayStartMs)), inArray(pullRequests.repoId, repoIds)))
8159
+ .where(and(eq(claudeReviews.accountId, accountId), eq(claudeReviews.trigger, 'auto'), gte(claudeReviews.createdAt, new Date(opts.dayStartMs)), inArray(pullRequests.repoId, repoIds),
8160
+ // Replies passes have their own per-PR cap (review/claude-review/replies-check.ts).
8161
+ isNull(claudeReviews.kind),
8162
+ // A CLAUDE + CODEX PAIR is ONE review of the PR: it counts once toward the daily cap (its
8163
+ // first half's row), never twice.
8164
+ or(isNull(claudeReviews.pairRole), ne(claudeReviews.pairRole, 'second'))))
8149
8165
  .execute();
8150
8166
  const autoToday = autoRows.length;
8151
8167
  if (opts.limit <= 0)
@@ -8163,7 +8179,7 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8163
8179
  .execute();
8164
8180
  if (open.length === 0)
8165
8181
  return { prIds: [], autoToday, reReview: [], inFlightMoved: [], retry: [] };
8166
- const [bots, reviewed, reviewerBots] = await Promise.all([
8182
+ const [bots, allRuns, reviewerBots] = await Promise.all([
8167
8183
  hiddenBotUserIds(accountId, workspaceId),
8168
8184
  db
8169
8185
  .select({
@@ -8178,6 +8194,8 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8178
8194
  error: claudeReviews.error,
8179
8195
  provider: claudeReviews.provider,
8180
8196
  model: claudeReviews.model,
8197
+ kind: claudeReviews.kind,
8198
+ pairId: claudeReviews.pairId,
8181
8199
  })
8182
8200
  .from(claudeReviews)
8183
8201
  .where(and(eq(claudeReviews.accountId, accountId), inArray(claudeReviews.prId, open.map((r) => r.id))))
@@ -8185,6 +8203,11 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8185
8203
  automatedReviewerUserIds(accountId, workspaceId, 'review'),
8186
8204
  ]);
8187
8205
  const botSet = new Set(bots);
8206
+ // ⚠ A REPLIES PASS (kind 'replies') is NOT a review: it neither settles a head nor counts as a
8207
+ // first review, a retry or a run in flight. Only its coverage of the own-finding replies is read
8208
+ // (`repliesCoveredMs`, below). Every rule from here on reads full reviews alone.
8209
+ const repliesRows = allRuns.filter((r) => r.kind === 'replies');
8210
+ const reviewed = allRuns.filter((r) => r.kind == null);
8188
8211
  const hasRun = new Set(reviewed.map((r) => r.prId));
8189
8212
  const human = open.filter((r) => r.authorId != null && !botSet.has(r.authorId));
8190
8213
  // ⚠ THE ONBOARDING FLOOR GATES FIRST REVIEWS ONLY. A PR opened before the switch is never
@@ -8290,7 +8313,15 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8290
8313
  // else its START (`created_at`) for a row that never recorded one — never 0, or a deploy would
8291
8314
  // re-review every PR that already has comments.
8292
8315
  const coveredMs = Math.max(...atHead.map((r) => (r.commentsThrough ?? r.createdAt).getTime()));
8293
- sameHead.push({ prId: pr.id, headSha: pr.headSha, coveredMs });
8316
+ // How far the replies passes AT THIS HEAD reached (any status: one attempt per burst).
8317
+ const passes = repliesRows.filter((r) => r.prId === pr.id && r.headSha === pr.headSha);
8318
+ const repliesCoveredMs = passes.length > 0 ? Math.max(...passes.map((r) => (r.commentsThrough ?? r.createdAt).getTime())) : null;
8319
+ // ⚠ OWN-FINDING REPLIES and a CLAUDE + CODEX PAIR: each half follows up ONLY its own provider's
8320
+ // findings, so a reply on a Claude finding that landed while the Claude half ran is NOT judged by
8321
+ // the Codex half, though that half's coverage reaches past it. A pair therefore covers own-thread
8322
+ // replies only as far as its EARLIER-reaching half (`pairOwnCoveredMs`); an ordinary run as above.
8323
+ const ownCoveredMs = pairOwnCoveredMs(atHead);
8324
+ sameHead.push({ prId: pr.id, headSha: pr.headSha, coveredMs, ownCoveredMs, repliesCoveredMs });
8294
8325
  }
8295
8326
  // RE-REVIEW ON NEW COMMENTS: a qualifying review-thread comment (a person's or a bot's, in an
8296
8327
  // unresolved thread, never one Limn posted — db/review-threads-for-review.ts) newer than what
@@ -8334,8 +8365,13 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8334
8365
  const ck = latestCheck.get(p.prId);
8335
8366
  const checkCoveredMs = ck && ck.headSha === p.headSha ? (ck.commentsThrough ?? ck.createdAt).getTime() : null;
8336
8367
  const ownMs = t.newestOwn?.getTime() ?? null;
8337
- const ownNeedsReview = ownMs != null && ownMs > p.coveredMs;
8338
- if (at <= Math.max(p.coveredMs, checkCoveredMs ?? 0) && !ownNeedsReview)
8368
+ // A reply on Limn's own finding goes to a REPLIES PASS (review/claude-review/auto.ts
8369
+ // `commentBurstRoute`), which covers it once a pass at this head reached it.
8370
+ const ownNeedsPass = ownMs != null && ownMs > Math.max(p.ownCoveredMs, p.repliesCoveredMs ?? 0);
8371
+ // Everything else goes to Check threads, as before.
8372
+ const otherMs = t.newestOther?.getTime() ?? null;
8373
+ const otherNeedsCheck = otherMs != null && otherMs > Math.max(p.coveredMs, checkCoveredMs ?? 0);
8374
+ if (!ownNeedsPass && !otherNeedsCheck)
8339
8375
  continue;
8340
8376
  reReview.push({
8341
8377
  prId: p.prId,
@@ -8344,8 +8380,11 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8344
8380
  reason: 'comments',
8345
8381
  lastRunAtMs: null,
8346
8382
  coveredMs: p.coveredMs,
8383
+ ownCoveredMs: p.ownCoveredMs,
8347
8384
  checkCoveredMs,
8348
8385
  newestOwnThreadCommentAtMs: ownMs,
8386
+ repliesCoveredMs: p.repliesCoveredMs,
8387
+ newestOtherCommentAtMs: otherMs,
8349
8388
  });
8350
8389
  if (reReview.length >= opts.limit)
8351
8390
  break;
@@ -8353,6 +8392,27 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8353
8392
  }
8354
8393
  return { prIds, autoToday, reReview, inFlightMoved, retry };
8355
8394
  }
8395
+ /**
8396
+ * How far the full reviews at one head covered replies on Limn's OWN findings: the max over runs of
8397
+ * (comments_through ?? created_at), except that the two halves of a Claude + Codex pair (same
8398
+ * `pairId`) count as their EARLIER-reaching half — each half follows up only its own provider's
8399
+ * findings, so the later half's coverage says nothing about the other half's threads. Pure.
8400
+ */
8401
+ export function pairOwnCoveredMs(runs) {
8402
+ const byPair = new Map();
8403
+ let best = 0;
8404
+ for (const r of runs) {
8405
+ const t = (r.commentsThrough ?? r.createdAt).getTime();
8406
+ if (r.pairId == null) {
8407
+ best = Math.max(best, t);
8408
+ continue;
8409
+ }
8410
+ byPair.set(r.pairId, Math.min(byPair.get(r.pairId) ?? Number.POSITIVE_INFINITY, t));
8411
+ }
8412
+ for (const t of byPair.values())
8413
+ best = Math.max(best, t);
8414
+ return best;
8415
+ }
8356
8416
  // WHO MAY RE-TRIGGER AN AUTO REVIEW BY COMMENTING (the comment half of the re-review key). A
8357
8417
  // PERSON — not in the workspace's bot union `hiddenBotUserIds`, where a manual "this is a human"
8358
8418
  // wins both ways — or a bot whose role in THIS workspace is `'review'` (`automatedReviewerUserIds`
@@ -8716,6 +8776,7 @@ export async function getPrWriteContext(prId, accountId) {
8716
8776
  viewerPermission: repos.viewerPermission,
8717
8777
  mergeStateStatus: pullRequests.mergeStateStatus,
8718
8778
  reviewDecision: pullRequests.reviewDecision,
8779
+ ciStatus: pullRequests.ciStatus,
8719
8780
  })
8720
8781
  .from(pullRequests)
8721
8782
  .innerJoin(repos, eq(repos.id, pullRequests.repoId))
@@ -8738,6 +8799,7 @@ export async function getPrWriteContext(prId, accountId) {
8738
8799
  prUrl: `https://github.com/${row.owner}/${row.name}/pull/${row.number}`,
8739
8800
  mergeStateStatus: row.mergeStateStatus ?? null,
8740
8801
  reviewDecision: row.reviewDecision ?? null,
8802
+ ciStatus: row.ciStatus ?? null,
8741
8803
  };
8742
8804
  }
8743
8805
  // Optimistically stamp a PR as merged after a successful GitHub merge, so the UI reflects it
@@ -112,7 +112,8 @@ countsAuthor) {
112
112
  /**
113
113
  * `newestReviewCommentAt`, plus `newestOwn`: the newest of those comments inside a thread ROOTED on
114
114
  * one of Limn's own posted findings (the loader's `newestOwnThreadCommentAt`, same filter) — the
115
- * sweeper's "a reply on a Limn finding needs a full review" test, read in the same scan.
115
+ * sweeper's "a reply on a Limn finding goes to a replies pass" test, read in the same scan — and
116
+ * `newestOther`: the newest one in any OTHER thread (Check threads' half).
116
117
  */
117
118
  export async function newestReviewCommentTimes(accountId, prIds, since, countsAuthor) {
118
119
  const out = new Map();
@@ -183,13 +184,15 @@ export async function newestReviewCommentTimes(accountId, prIds, since, countsAu
183
184
  const prev = out.get(r.prId);
184
185
  const isOwn = r.threadId != null && ownRooted.has(r.threadId);
185
186
  if (!prev) {
186
- out.set(r.prId, { newest: r.createdAt, newestOwn: isOwn ? r.createdAt : null });
187
+ out.set(r.prId, { newest: r.createdAt, newestOwn: isOwn ? r.createdAt : null, newestOther: isOwn ? null : r.createdAt });
187
188
  continue;
188
189
  }
189
190
  if (r.createdAt.getTime() > prev.newest.getTime())
190
191
  prev.newest = r.createdAt;
191
192
  if (isOwn && (!prev.newestOwn || r.createdAt.getTime() > prev.newestOwn.getTime()))
192
193
  prev.newestOwn = r.createdAt;
194
+ if (!isOwn && (!prev.newestOther || r.createdAt.getTime() > prev.newestOther.getTime()))
195
+ prev.newestOther = r.createdAt;
193
196
  }
194
197
  return out;
195
198
  }
@@ -1103,6 +1103,20 @@ export const claudeReviews = sqliteTable('claude_reviews', {
1103
1103
  // A FROM-SCRATCH run (`GenerateReviewBody.fresh`): it read no earlier review — no follow-up, no
1104
1104
  // settled list, no carried thread judgements. true on such a run; NULL otherwise (migration 0133).
1105
1105
  fresh: integer('fresh', { mode: 'boolean' }),
1106
+ // WHAT THE TEAM CLAIM DID FOR THIS RUN (migration 0134; review/claude-review/team-claim.ts): the
1107
+ // GitHub status line it posted ("Marked on GitHub"), or why it ran without one. NULL = no claim
1108
+ // was attempted (a manual run, or "Mark auto reviews on GitHub" off).
1109
+ teamClaim: text('team_claim', { mode: 'json' }).$type(),
1110
+ // A CLAUDE + CODEX PAIR (migration 0135; docs/CLAUDE-REVIEW.md § Claude + Codex): `pairId` = the
1111
+ // FIRST run's id, on both halves; `pairRole` 'first' | 'second'; `pairDropped` = the second run's
1112
+ // findings dropped in code as repeats of the first's. NULL on every ordinary run.
1113
+ pairId: integer('pair_id'),
1114
+ pairRole: text('pair_role', { enum: ['first', 'second'] }),
1115
+ pairDropped: integer('pair_dropped'),
1116
+ // A REPLIES PASS (review/claude-review/replies-check.ts): 'replies' on a run that judged only
1117
+ // people's replies on Limn's own posted findings; NULL on every full review (migration 0136).
1118
+ // ⚠ Every "the latest review" / "a run at this head" reader filters `kind IS NULL`.
1119
+ kind: text('kind', { enum: ['replies'] }),
1106
1120
  }, (t) => ({
1107
1121
  prIdx: index('cr_pr_idx').on(t.prId),
1108
1122
  prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
@@ -1768,6 +1782,15 @@ export const workspaces = sqliteTable('workspaces', {
1768
1782
  // is off. Off stops only the automatic draw; Draw / Redraw still works. The ONE writer is
1769
1783
  // `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
1770
1784
  autoDiagramsEnabled: integer('auto_diagrams_enabled', { mode: 'boolean' }).notNull().default(true),
1785
+ // TEAM CLAIMS (migration 0134; docs/CLAUDE-REVIEW.md § Team claims): "Mark auto reviews on
1786
+ // GitHub". An auto review posts a commit status (limn/review-claude | limn/review-codex) on the
1787
+ // PR's head so teammates' Limn skips a commit someone is already reviewing with the same
1788
+ // provider. NULL/false = OFF, the default. The ONE writer is `setWorkspaceAutoReview`.
1789
+ teamClaimsEnabled: integer('team_claims_enabled', { mode: 'boolean' }),
1790
+ // WHO RUNS AUTO REVIEWS (migration 0135): NULL = the default provider (Settings → AI); 'both' =
1791
+ // Claude then Codex as one pair (docs/CLAUDE-REVIEW.md § Claude + Codex). The ONE writer is
1792
+ // `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
1793
+ autoReviewProviders: text('auto_review_providers'),
1771
1794
  // DEPENDENCY AUTO-MERGE (migration 0093 / pg 0080): may the server arm "merge when ready" on
1772
1795
  // every open dependency-automation PR in this workspace's repos (merge/dependency-policy.ts)?
1773
1796
  // NULL/false = OFF, the default for every workspace; nullable with no default, so no insert
@@ -37,6 +37,16 @@
37
37
  // re-fires every 120s for a hot repo. So the failing-check DETAIL is a SECOND query
38
38
  // (`buildCommitChecksQuery`) issued only for the commits whose rollup phase 1 already reported
39
39
  // as failure/error/pending. A green trunk therefore costs exactly the phase-1 walk.
40
+ // LIMN'S OWN STATUS LINES ARE NOT CI (shared limn-status.ts). The commit's legacy STATUS names — a
41
+ // plain list, NOT a connection, so it costs ZERO rate-limit points at any alias count — say whether
42
+ // one of Limn's `limn/review-*` lines is folded into the rollup state beside it. Only then is the
43
+ // state recomputed from the commit's contexts (sync/limn-ci.ts), so a commit without one costs
44
+ // exactly what it did.
45
+ export const LIMN_MARK_FIELDS = /* GraphQL */ `status {
46
+ contexts {
47
+ context
48
+ }
49
+ }`;
40
50
  // One trunk-commit node, shared VERBATIM between the live snapshot query and the history
41
51
  // backfill below — the two walks must store identical rows, so the selection is written once.
42
52
  //
@@ -54,6 +64,7 @@ const BRANCH_COMMIT_FIELDS = /* GraphQL */ `
54
64
  statusCheckRollup {
55
65
  state
56
66
  }
67
+ ${LIMN_MARK_FIELDS}
57
68
  author {
58
69
  name
59
70
  avatarUrl
@@ -152,6 +163,9 @@ const COMMIT_CHECKS_SELECTION = /* GraphQL */ `
152
163
  statusCheckRollup {
153
164
  state
154
165
  contexts(first: 100) {
166
+ pageInfo {
167
+ hasNextPage
168
+ }
155
169
  nodes {
156
170
  __typename
157
171
  ... on CheckRun {
@@ -215,7 +229,7 @@ export const COMMIT_STATES_ALIAS_CAP = 100;
215
229
  * `GitObjectID!` variables, never interpolated into the query text. */
216
230
  export function buildCommitStatesQuery(count) {
217
231
  const vars = Array.from({ length: count }, (_, i) => `$s${i}: GitObjectID!`).join(', ');
218
- const aliases = Array.from({ length: count }, (_, i) => ` c${i}: object(oid: $s${i}) { ... on Commit { oid statusCheckRollup { state } } }`).join('\n');
232
+ const aliases = Array.from({ length: count }, (_, i) => ` c${i}: object(oid: $s${i}) { ... on Commit { oid statusCheckRollup { state } ${LIMN_MARK_FIELDS} } }`).join('\n');
219
233
  return `query CommitStates($owner: String!, $name: String!, ${vars}) {
220
234
  rateLimit {
221
235
  cost
@@ -0,0 +1,42 @@
1
+ // Exported because sync/branch-status.ts maps the SAME contexts union for default-branch
2
+ // commits. The CheckRun status/conclusion → CheckRunState table must not exist twice: if trunk
3
+ // and PR surfaces disagreed about what "failing" means, the same check would render differently
4
+ // depending on where you looked at it.
5
+ export function checkContextState(c) {
6
+ if (c.__typename === 'StatusContext') {
7
+ switch ((c.state ?? '').toUpperCase()) {
8
+ case 'SUCCESS':
9
+ return 'success';
10
+ case 'FAILURE':
11
+ return 'failure';
12
+ case 'ERROR':
13
+ return 'error';
14
+ case 'PENDING':
15
+ case 'EXPECTED':
16
+ return 'pending';
17
+ default:
18
+ return 'unknown';
19
+ }
20
+ }
21
+ // CheckRun: outcome is meaningful only once completed.
22
+ if ((c.status ?? '').toUpperCase() !== 'COMPLETED')
23
+ return 'pending';
24
+ switch ((c.conclusion ?? '').toUpperCase()) {
25
+ case 'SUCCESS':
26
+ return 'success';
27
+ case 'FAILURE':
28
+ case 'TIMED_OUT':
29
+ case 'STARTUP_FAILURE':
30
+ case 'ACTION_REQUIRED':
31
+ return 'failure';
32
+ case 'CANCELLED':
33
+ case 'STALE':
34
+ case 'NEUTRAL':
35
+ return 'neutral';
36
+ case 'SKIPPED':
37
+ return 'skipped';
38
+ default:
39
+ return 'unknown';
40
+ }
41
+ }
42
+ //# sourceMappingURL=check-context-state.js.map
@@ -1,4 +1,5 @@
1
1
  import { checkRunsFrom } from '../sync/upsert.js';
2
+ import { rollupStateWithoutLimn } from '../shared/index.js';
2
3
  import { getGraphqlClientFor, ghRestGetFor, graphqlTolerant, isRateLimitError } from './client.js';
3
4
  import { isLimited, noteLimited } from './rate-budget.js';
4
5
  const SHA_RE = /^[0-9a-fA-F]{7,64}$/;
@@ -12,6 +13,9 @@ export const COMMIT_CHECKS_QUERY = /* GraphQL */ `
12
13
  statusCheckRollup {
13
14
  state
14
15
  contexts(first: 100) {
16
+ pageInfo {
17
+ hasNextPage
18
+ }
15
19
  nodes {
16
20
  __typename
17
21
  ... on CheckRun {
@@ -55,11 +59,18 @@ export async function fetchCommitChecks(token, args) {
55
59
  if (rollup.contexts == null && partial)
56
60
  return { ok: false, reason: 'no_check_access' };
57
61
  const nodes = (rollup.contexts?.nodes ?? []).filter((n) => n != null);
58
- const checks = checkRunsFrom({
62
+ const all = checkRunsFrom({
59
63
  oid: obj.oid ?? args.sha,
60
64
  statusCheckRollup: { state: rollup.state ?? '', contexts: { nodes } },
61
65
  });
62
- return { ok: true, rollupState: rollup.state ?? null, checks };
66
+ // ⚠ LIMN'S OWN STATUS LINES ARE NOT CI (shared limn-status.ts): the CI review and every other
67
+ // reader of this read see neither the lines nor their effect on the rollup.
68
+ const checks = all.filter((c) => !c.limnOwned);
69
+ return {
70
+ ok: true,
71
+ rollupState: rollupStateWithoutLimn(rollup.state, all, rollup.contexts?.pageInfo?.hasNextPage !== true),
72
+ checks,
73
+ };
63
74
  }
64
75
  catch (err) {
65
76
  const rl = isRateLimitError(err);