limn-review 0.1.125 → 0.1.126

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/api/plugins/rate-limit.js +10 -0
  2. package/dist/coding/ai-fix/auto-fix.js +6 -0
  3. package/dist/coding/ai-fix/manager.js +8 -1
  4. package/dist/coding/ai-fix/review-seed.js +17 -13
  5. package/dist/config.js +8 -0
  6. package/dist/db/ci-review-prune.js +22 -0
  7. package/dist/db/erase-account.js +11 -0
  8. package/dist/db/migrations/0082_ci_reviews.sql +73 -0
  9. package/dist/db/migrations/0083_workspace_auto_fix.sql +9 -0
  10. package/dist/db/migrations/meta/_journal.json +14 -0
  11. package/dist/db/migrations-pg/0069_ci_reviews.sql +65 -0
  12. package/dist/db/migrations-pg/0070_workspace_auto_fix.sql +3 -0
  13. package/dist/db/migrations-pg/meta/_journal.json +14 -0
  14. package/dist/db/my-turn-dismissals.js +8 -11
  15. package/dist/db/queries.js +43 -45
  16. package/dist/db/retention.js +3 -0
  17. package/dist/db/schema.pg.js +91 -1
  18. package/dist/db/schema.sqlite.js +111 -0
  19. package/dist/review/agentic.js +15 -2
  20. package/dist/review/ci-review/agent.js +143 -0
  21. package/dist/review/ci-review/currency.js +143 -0
  22. package/dist/review/ci-review/manager.js +363 -0
  23. package/dist/review/ci-review/persist.js +486 -0
  24. package/dist/review/ci-review/prepare.js +49 -0
  25. package/dist/review/ci-review/prompts.js +103 -0
  26. package/dist/review/ci-review/reconcile.js +78 -0
  27. package/dist/review/ci-review/routes.js +197 -0
  28. package/dist/review/ci-review/schema.js +44 -0
  29. package/dist/review/ci-review/sweep.js +172 -0
  30. package/dist/review/claude-review/auto-settings.js +63 -21
  31. package/dist/review/claude-review/auto.js +42 -54
  32. package/dist/review/claude-review/ci-failures.js +0 -42
  33. package/dist/review/claude-review/manager.js +40 -70
  34. package/dist/review/claude-review/persist.js +149 -5
  35. package/dist/review/claude-review/prompts.js +42 -19
  36. package/dist/review/claude-review/settled-by-reply.js +146 -0
  37. package/dist/review/claude-review/specialists.js +1 -1
  38. package/dist/review/clone-hygiene.js +1 -1
  39. package/dist/review/clone-manager.js +24 -1
  40. package/dist/review/post-review.js +19 -2
  41. package/dist/review/schema.js +9 -29
  42. package/dist/review/submit-map.js +5 -3
  43. package/dist/review/ticket-review/sweep.js +56 -65
  44. package/dist/shared/types.js +13 -0
  45. package/package.json +1 -1
  46. package/public/assets/{AiFixTab-B1OazE4O.js → AiFixTab-CzugrcNK.js} +2 -2
  47. package/public/assets/ClaudeReviewTab-OGu_QicR.js +16 -0
  48. package/public/assets/{PrBotBehaviourTab-h7Nujxom.js → PrBotBehaviourTab-Cc1F4O6z.js} +1 -1
  49. package/public/assets/index-1ieo_9fr.js +77 -0
  50. package/public/assets/index-C9m8ITkB.css +1 -0
  51. package/public/index.html +2 -2
  52. package/public/assets/ClaudeReviewTab-7GZdYcOv.js +0 -8
  53. package/public/assets/index-DTRruvsb.css +0 -1
  54. package/public/assets/index-q7WCDbAA.js +0 -77
@@ -468,6 +468,16 @@ function tierFor(method, path) {
468
468
  return [TIERS.githubWrite];
469
469
  if (path.startsWith('/api/ticket-reviews'))
470
470
  return [TIERS.read];
471
+ // CI REVIEW (review/ci-review/routes.ts), matched EXPLICITLY by exact path for the same reason:
472
+ // POST /api/ci-reviews starts an agent run (reads the head's checks + logs) → ai + ai_hourly
473
+ // POST /api/ci-reviews/states batched DB read of the SYNCED head + failing names → read
474
+ // GET …/:id, …/:id/stream, /api/prs/:id/ci-review → read
475
+ if (path === '/api/ci-reviews' && mutating)
476
+ return [TIERS.ai, TIERS.aiHourly];
477
+ if (path === '/api/ci-reviews/states')
478
+ return [TIERS.read];
479
+ if (path.startsWith('/api/ci-reviews'))
480
+ return [TIERS.read];
471
481
  if (/^\/api\/claude-reviews\/[^/]+\/chat$/.test(path)) {
472
482
  return mutating ? [TIERS.ai, TIERS.aiHourly] : [TIERS.read];
473
483
  }
@@ -6,6 +6,9 @@
6
6
  // 0. the PR's author IS the account's own GitHub user (pull_requests.author_id → users.github_login
7
7
  // vs accounts.github_login, case-insensitive). Anyone else's PR: nothing happens and nothing is
8
8
  // recorded — fixing someone else's branch is never automatic;
9
+ // ½. `off` — auto AI Fix is switched off for the workspace holding the PR's repo
10
+ // (`workspaces.auto_fix_enabled`, migration 0083 / pg 0070, Settings →
11
+ // Auto review). ON by default; a repo with no membership row reads ON;
9
12
  // 1. `nothing_to_fix` — the review's seed is empty (the same refusal as the button's NothingToFix);
10
13
  // 2. `head_moved` — the PR's synced head is no longer the reviewed one (the next review decides);
11
14
  // 3. `fix_in_progress` — a fix for this PR (auto or manual) is queued or running;
@@ -33,6 +36,7 @@ import { AUTO_FIX_DAILY_CAP, CLAUDE_REVIEW_MODELS, DEFAULT_AI_FIX_MODEL, } from
33
36
  import { isFixRunning, loadReviewSeed, startReviewFix } from './manager.js';
34
37
  import { parseChangeReport, parseReviewItems } from './persist.js';
35
38
  import { getFixPrContext } from './pr-context.js';
39
+ import { readWorkspaceAutoFixForPr } from '../../review/claude-review/auto-settings.js';
36
40
  // At most AUTO_FIX_DAILY_CAP (shared) AUTO fixes per PR in any rolling AUTO_FIX_WINDOW_MS.
37
41
  export { AUTO_FIX_DAILY_CAP };
38
42
  export const AUTO_FIX_WINDOW_MS = 24 * 60 * 60 * 1000;
@@ -119,6 +123,8 @@ export async function maybeStartAutoFix(ctx, input, deps = defaultDeps, nowMs =
119
123
  try {
120
124
  if (!(await prAuthorIsAccount(ctx, accountId, prId)))
121
125
  return { status: 'not_own' };
126
+ if (!(await readWorkspaceAutoFixForPr(ctx, accountId, prId)))
127
+ return skip('off');
122
128
  const loaded = await deps.loadReviewSeed(ctx, { accountId, prId, reviewId });
123
129
  if (!loaded)
124
130
  return skip('not_started');
@@ -4,6 +4,7 @@ import { getClaudeReviewById } from '../../review/claude-review/persist.js';
4
4
  import { pickReviewNonce } from '../../review/claude-review/prompts.js';
5
5
  import { buildReviewSeed } from './review-seed.js';
6
6
  import { getOwnedTicketItemsForPr } from '../../review/ticket-review/persist.js';
7
+ import { getFixableCiItemsForPr } from '../../review/ci-review/persist.js';
7
8
  import { buildFixSystemPrompt, buildFixUserPrompt } from './prompts.js';
8
9
  import { insertQueuedFix, markFixCancelled, markFixFailed, markFixRunning, reconcileOrphanedFixes, saveFixSuccess, } from './persist.js';
9
10
  // In-memory job manager for AI Fix's agentic fixer (CORE, free, local-only — it left the plugin
@@ -102,7 +103,13 @@ export async function loadReviewSeed(ctx, input) {
102
103
  return [];
103
104
  })
104
105
  : [];
105
- return { review, seed: buildReviewSeed(review, { nonce: pickReviewNonce, ticketItems }) };
106
+ // The CI review's fixable items at the PR's CURRENT head (manual and auto fixes alike). A failed
107
+ // read costs the CI items only.
108
+ const ciItems = await getFixableCiItemsForPr(ctx, input.accountId, input.prId).catch((err) => {
109
+ ctx.log.warn({ err }, 'ai-fix: loading CI review items failed');
110
+ return [];
111
+ });
112
+ return { review, seed: buildReviewSeed(review, { nonce: pickReviewNonce, ticketItems, ciItems }) };
106
113
  }
107
114
  catch (err) {
108
115
  ctx.log.warn({ err }, 'ai-fix: loading the review seed failed');
@@ -15,7 +15,9 @@ import { isThreadToFix } from '../../shared/index.js';
15
15
  // addressed — unless this run re-raised it as a finding (then F covers it).
16
16
  // T<n> another reviewer's thread the review judged still needs a fix (shared
17
17
  // `isThreadToFix`: valid / partly valid AND not / partly addressed).
18
- // C<n> a CI failure the review judged fixable in this PR (`ciFailures`, when present).
18
+ // C<n> a CI failure the CI REVIEW (review/ci-review/) explained and judged fixable in this PR —
19
+ // from its latest succeeded run AT THE PR'S CURRENT HEAD (`ciItems`, the caller's
20
+ // `getFixableCiItemsForPr`), never from the code review's own (legacy) `ciFailures`.
19
21
  // S<t>-AC<n> an acceptance criterion of ticket t judged not met or partly met, and
20
22
  // S<t>-M<n> something ticket t asked for that is missing — BOTH from the TICKET review
21
23
  // (review/ticket-review/), never from the PR review, and ONLY the items whose owner is
@@ -72,21 +74,21 @@ function mkItem(ref, kind, title, over = {}) {
72
74
  ...over,
73
75
  };
74
76
  }
75
- // ---- CI failures (the review's `ciFailures`, from the Claude Review CI diagnosis) ----
76
- // Only a DIAGNOSED failure the review judged fixable in this PR (`fixableInPr === true`). Anything
77
- // else — infrastructure, flaky, unclear, not checked — is not the fixer's. Older rows carry none.
78
- export function readFixableCiFailures(review) {
79
- const raw = review.ciFailures;
80
- if (!Array.isArray(raw))
81
- return [];
82
- return raw.filter((f) => f != null && f.status === 'diagnosed' && f.fixableInPr === true);
77
+ // ---- CI failures (the CI review's items) ----
78
+ // Only a DIAGNOSED failure judged fixable in this PR (`fixableInPr === true`). Anything else —
79
+ // infrastructure, flaky, unclear, not checked — is not the fixer's. The code review's own legacy
80
+ // `ciFailures` are NEVER read: the CI review replaced them, and they describe an older head.
81
+ export function fixableCiItems(items) {
82
+ return items.filter((f) => f != null && f.status === 'diagnosed' && f.fixableInPr === true);
83
83
  }
84
84
  /**
85
85
  * Collect every fixable item of a review, refs assigned. Pure — the review is the stored run.
86
86
  */
87
87
  export function collectReviewItems(review,
88
88
  // The ticket review's items THIS PR owns. A MANUAL fix only; absent/[] ⇒ none (an auto fix).
89
- ticketItems = []) {
89
+ ticketItems = [],
90
+ // The CI review's items at the PR's current head (both manual and auto fixes); absent/[] ⇒ none.
91
+ ciItems = []) {
90
92
  const out = [];
91
93
  // F — the review's findings. A RE-RAISE (`priorFindingId` set) left out only because the same
92
94
  // comment is already on this commit (follow-up.ts `isAlreadyOnThisCommit`) is NOT a reader's
@@ -161,13 +163,15 @@ ticketItems = []) {
161
163
  });
162
164
  });
163
165
  // C — CI failures fixable in this PR.
164
- readFixableCiFailures(review).forEach((f, i) => {
166
+ fixableCiItems(ciItems).forEach((f, i) => {
165
167
  const ref = `C${i + 1}`;
166
- const first = f.relatedFiles?.[0] ?? null;
168
+ const first = f.path ? { path: f.path, line: f.line } : (f.relatedFiles?.[0] ?? null);
167
169
  const body = [
168
170
  `Check: ${f.checkName}${f.step ? ` (step: ${f.step})` : ''}`,
169
171
  f.cause ? `Cause: ${f.cause}` : '',
170
172
  f.explanation ? `Detail: ${f.explanation}` : '',
173
+ f.path ? `Where: ${where(f.path, f.line)}` : '',
174
+ f.suggestion ? `Suggested change: ${f.suggestion}` : '',
171
175
  f.relatedFiles?.length
172
176
  ? `Related files: ${f.relatedFiles.map((r) => where(r.path, r.line)).join(', ')}`
173
177
  : '',
@@ -226,7 +230,7 @@ function fencedTexts(items, summary) {
226
230
  */
227
231
  export function buildReviewSeed(review, opts) {
228
232
  const budget = opts.budgetChars ?? REVIEW_SEED_CHAR_BUDGET;
229
- const all = collectReviewItems(review, opts.ticketItems ?? []);
233
+ const all = collectReviewItems(review, opts.ticketItems ?? [], opts.ciItems ?? []);
230
234
  if (all.length === 0)
231
235
  return { items: [], sentRefs: [], text: '' };
232
236
  const summary = clip(review.userBody?.trim() || review.summary?.trim() || '', SUMMARY_MAX);
package/dist/config.js CHANGED
@@ -539,6 +539,14 @@ export const config = {
539
539
  // AUTOMATIC ticket reviews (trigger 'auto' / 'cascade') per workspace per UTC day. Not shared with
540
540
  // the PR review's own auto cap. Manual runs never count. Default = shared TICKET_REVIEW_DAILY_CAP.
541
541
  ticketReviewDailyCap: intFromEnv('TICKET_REVIEW_DAILY_CAP', 20),
542
+ // ---- CI review (one run per (PR, head, failing-check set) — review/ci-review/) ----
543
+ // Its OWN spend cap and turn cap: a CI run reads log excerpts and the files they point at, nothing
544
+ // more. Env only — no route sets it.
545
+ ciReviewBudgetUsd: floatFromEnv('CI_REVIEW_BUDGET_USD', 2),
546
+ ciReviewMaxTurns: intFromEnv('CI_REVIEW_MAX_TURNS', 25),
547
+ // AUTOMATIC CI reviews per workspace per UTC day. Not shared with the PR or ticket review caps.
548
+ // Manual runs never count. Default = shared CI_REVIEW_DAILY_CAP.
549
+ ciReviewDailyCap: intFromEnv('CI_REVIEW_DAILY_CAP', 20),
542
550
  // ---- Diff-size cap ----
543
551
  // A very large inlined diff is the dominant cost on a big PR (it's the cached
544
552
  // prefix re-read every turn). The diff shown IN THE PROMPT is truncated at a
@@ -0,0 +1,22 @@
1
+ import { inArray } from 'drizzle-orm';
2
+ import { schema } from './client.js';
3
+ // THE CI-REVIEW HALF OF BOTH PR DELETE PATHS (deleteRepo in queries.ts, retention's
4
+ // deletePrSubtree). A CI review belongs to exactly one PR, so deleting the PR deletes its runs and
5
+ // their items. Children before parents, explicitly — the composite FKs cascade, but these paths run
6
+ // parent-last by hand on both dialects. Runs inside the caller's transaction.
7
+ export async function pruneCiReviewsForPrs(tx, prIds) {
8
+ if (prIds.length === 0)
9
+ return;
10
+ const runs = schema.ciReviews;
11
+ const rows = await tx
12
+ .select({ id: runs.id })
13
+ .from(runs)
14
+ .where(inArray(runs.prId, [...prIds]))
15
+ .execute();
16
+ const runIds = rows.map((r) => r.id);
17
+ if (runIds.length > 0) {
18
+ await tx.delete(schema.ciReviewItems).where(inArray(schema.ciReviewItems.ciReviewId, runIds)).execute();
19
+ }
20
+ await tx.delete(runs).where(inArray(runs.prId, [...prIds])).execute();
21
+ }
22
+ //# sourceMappingURL=ci-review-prune.js.map
@@ -128,6 +128,13 @@ export async function eraseAccountData(accountId) {
128
128
  .delete(schema.ticketReviews)
129
129
  .where(eq(schema.ticketReviews.accountId, accountId))
130
130
  .execute();
131
+ // CI reviews (migration 0082 / pg 0069), items first. The repo loop has normally taken them with
132
+ // their PRs; explicit, like every agentic table.
133
+ await tx
134
+ .delete(schema.ciReviewItems)
135
+ .where(eq(schema.ciReviewItems.accountId, accountId))
136
+ .execute();
137
+ await tx.delete(schema.ciReviews).where(eq(schema.ciReviews.accountId, accountId)).execute();
131
138
  // AI Fix runs (patches of the user's code + the prompts they were built from). Core since
132
139
  // migration 0074 / pg 0061 — the plugin's erasure hook used to own them.
133
140
  await tx.delete(schema.aiFixes).where(eq(schema.aiFixes.accountId, accountId)).execute();
@@ -211,6 +218,10 @@ export function accountScopedTables() {
211
218
  col: schema.ticketReviewItems.accountId,
212
219
  table: schema.ticketReviewItems,
213
220
  },
221
+ // CI reviews (migration 0082 / pg 0069) — the runs and their per-check items. Both carry their
222
+ // own accountId; both are erased explicitly above.
223
+ { name: 'ciReviews', col: schema.ciReviews.accountId, table: schema.ciReviews },
224
+ { name: 'ciReviewItems', col: schema.ciReviewItems.accountId, table: schema.ciReviewItems },
214
225
  { name: 'aiUsage', col: aiUsage.accountId, table: aiUsage },
215
226
  // Adopted from the plugin (migration 0074 / pg 0061) with AI Fix's fixer. No FKs, so nothing
216
227
  // cascades: the explicit delete above is the whole guarantee. (`reviewLearnings`, review
@@ -0,0 +1,73 @@
1
+ -- CI REVIEW (local-only feature, like the rest of Claude Review): a separate Claude run that explains
2
+ -- why checks FAILED on a PR's head, split out of the PR review's own run. Two tables:
3
+ -- ci_reviews one row per run (history kept). Keyed by (pr, head, sorted failing check names):
4
+ -- `failing_key` is what the run read live, `trigger_key` the synced set that
5
+ -- started it.
6
+ -- ci_review_items one row per failing check: Claude's cause, or the server's not-checked reason.
7
+ -- Run ids arrive in request paths, so the item table's tenancy is STRUCTURAL: a composite FK against
8
+ -- ci_reviews(id, account_id), whose parent key `ci_reviews_id_account` is created here; the run's PR
9
+ -- against pull_requests(id, account_id). Both delete paths (deleteRepo, retention's deletePrSubtree)
10
+ -- and eraseAccountData delete these rows explicitly. `claude_reviews.ci_failures` becomes LEGACY
11
+ -- (read-only); nothing here touches it. The Postgres twin is migrations-pg/0069_ci_reviews.sql.
12
+ CREATE TABLE IF NOT EXISTS `ci_reviews` (
13
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
14
+ `account_id` integer NOT NULL,
15
+ `workspace_id` integer NOT NULL,
16
+ `pr_id` integer NOT NULL,
17
+ `repo_id` integer NOT NULL,
18
+ `head_sha` text NOT NULL,
19
+ `failing_key` text,
20
+ `trigger_key` text,
21
+ `failing_checks` text,
22
+ `trigger` text DEFAULT 'manual' NOT NULL,
23
+ `status` text NOT NULL,
24
+ `model` text NOT NULL,
25
+ `cost_usd` real,
26
+ `input_tokens` integer,
27
+ `output_tokens` integer,
28
+ `num_turns` integer,
29
+ `error` text,
30
+ `refused` text,
31
+ `ci_state` text,
32
+ `summary` text,
33
+ `started_at` integer,
34
+ `completed_at` integer,
35
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
36
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
37
+ CONSTRAINT `cir_pr_account_fk` FOREIGN KEY (`pr_id`,`account_id`) REFERENCES `pull_requests`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
38
+ );
39
+ --> statement-breakpoint
40
+ CREATE INDEX IF NOT EXISTS `cir_account_pr_created_idx` ON `ci_reviews` (`account_id`,`pr_id`,`created_at`);
41
+ --> statement-breakpoint
42
+ CREATE INDEX IF NOT EXISTS `cir_account_ws_created_idx` ON `ci_reviews` (`account_id`,`workspace_id`,`created_at`);
43
+ --> statement-breakpoint
44
+ CREATE UNIQUE INDEX IF NOT EXISTS `ci_reviews_id_account` ON `ci_reviews` (`id`,`account_id`);
45
+ --> statement-breakpoint
46
+ CREATE TABLE IF NOT EXISTS `ci_review_items` (
47
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
48
+ `ci_review_id` integer NOT NULL,
49
+ `account_id` integer NOT NULL,
50
+ `ref` text,
51
+ `check_name` text NOT NULL,
52
+ `job_id` integer,
53
+ `step` text,
54
+ `url` text,
55
+ `sent` integer DEFAULT false NOT NULL,
56
+ `carried` integer DEFAULT false NOT NULL,
57
+ `status` text NOT NULL,
58
+ `not_checked_reason` text,
59
+ `cause` text,
60
+ `explanation` text,
61
+ `category` text,
62
+ `fixable_in_pr` integer,
63
+ `path` text,
64
+ `line` integer,
65
+ `suggestion` text,
66
+ `related_files` text,
67
+ `assessed_at_head` text NOT NULL,
68
+ `created_at` integer DEFAULT (unixepoch()) NOT NULL,
69
+ FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
70
+ CONSTRAINT `ciri_review_account_fk` FOREIGN KEY (`ci_review_id`,`account_id`) REFERENCES `ci_reviews`(`id`,`account_id`) ON UPDATE no action ON DELETE cascade
71
+ );
72
+ --> statement-breakpoint
73
+ CREATE INDEX IF NOT EXISTS `ciri_review_idx` ON `ci_review_items` (`ci_review_id`);
@@ -0,0 +1,9 @@
1
+ -- AUTO AI FIX, PER WORKSPACE — `workspaces.auto_fix_enabled`. After a succeeded AUTO Claude review
2
+ -- of the reader's OWN pull request, Limn may prepare one review-seeded AI Fix (never pushed;
3
+ -- coding/ai-fix/auto-fix.ts `maybeStartAutoFix`). This is that step's switch, edited in Settings
4
+ -- beside auto review and written by `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
5
+ -- ⚠ DEFAULT TRUE (1) and NOT NULL: auto fix ran unconditionally before this column existed, so
6
+ -- every existing workspace keeps today's behaviour until someone switches it off. It only matters
7
+ -- while auto review is on, because it only runs after an auto review. The Postgres twin is
8
+ -- migrations-pg/0070_workspace_auto_fix.sql.
9
+ ALTER TABLE `workspaces` ADD `auto_fix_enabled` integer DEFAULT 1 NOT NULL;
@@ -575,6 +575,20 @@
575
575
  "when": 1790658000000,
576
576
  "tag": "0081_review_request_requester",
577
577
  "breakpoints": true
578
+ },
579
+ {
580
+ "idx": 82,
581
+ "version": "6",
582
+ "when": 1790661600000,
583
+ "tag": "0082_ci_reviews",
584
+ "breakpoints": true
585
+ },
586
+ {
587
+ "idx": 83,
588
+ "version": "6",
589
+ "when": 1790665200000,
590
+ "tag": "0083_workspace_auto_fix",
591
+ "breakpoints": true
578
592
  }
579
593
  ]
580
594
  }
@@ -0,0 +1,65 @@
1
+ -- Postgres twin of migrations/0082_ci_reviews.sql, where the contract lives. CI review is
2
+ -- local-only, so the tables exist in Postgres for schema parity. `job_id` is BIGINT: GitHub Actions
3
+ -- job ids have passed 2^31.
4
+ CREATE TABLE IF NOT EXISTS "ci_reviews" (
5
+ "id" serial PRIMARY KEY NOT NULL,
6
+ "account_id" integer NOT NULL,
7
+ "workspace_id" integer NOT NULL,
8
+ "pr_id" integer NOT NULL,
9
+ "repo_id" integer NOT NULL,
10
+ "head_sha" text NOT NULL,
11
+ "failing_key" text,
12
+ "trigger_key" text,
13
+ "failing_checks" jsonb,
14
+ "trigger" text DEFAULT 'manual' NOT NULL,
15
+ "status" text NOT NULL,
16
+ "model" text NOT NULL,
17
+ "cost_usd" double precision,
18
+ "input_tokens" integer,
19
+ "output_tokens" integer,
20
+ "num_turns" integer,
21
+ "error" text,
22
+ "refused" text,
23
+ "ci_state" jsonb,
24
+ "summary" text,
25
+ "started_at" timestamp with time zone,
26
+ "completed_at" timestamp with time zone,
27
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
28
+ CONSTRAINT "ci_reviews_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE cascade,
29
+ CONSTRAINT "cir_pr_account_fk" FOREIGN KEY ("pr_id","account_id") REFERENCES "pull_requests"("id","account_id") ON DELETE cascade
30
+ );
31
+ --> statement-breakpoint
32
+ CREATE INDEX IF NOT EXISTS "cir_account_pr_created_idx" ON "ci_reviews" USING btree ("account_id","pr_id","created_at");
33
+ --> statement-breakpoint
34
+ CREATE INDEX IF NOT EXISTS "cir_account_ws_created_idx" ON "ci_reviews" USING btree ("account_id","workspace_id","created_at");
35
+ --> statement-breakpoint
36
+ CREATE UNIQUE INDEX IF NOT EXISTS "ci_reviews_id_account" ON "ci_reviews" USING btree ("id","account_id");
37
+ --> statement-breakpoint
38
+ CREATE TABLE IF NOT EXISTS "ci_review_items" (
39
+ "id" serial PRIMARY KEY NOT NULL,
40
+ "ci_review_id" integer NOT NULL,
41
+ "account_id" integer NOT NULL,
42
+ "ref" text,
43
+ "check_name" text NOT NULL,
44
+ "job_id" bigint,
45
+ "step" text,
46
+ "url" text,
47
+ "sent" boolean DEFAULT false NOT NULL,
48
+ "carried" boolean DEFAULT false NOT NULL,
49
+ "status" text NOT NULL,
50
+ "not_checked_reason" text,
51
+ "cause" text,
52
+ "explanation" text,
53
+ "category" text,
54
+ "fixable_in_pr" boolean,
55
+ "path" text,
56
+ "line" integer,
57
+ "suggestion" text,
58
+ "related_files" jsonb,
59
+ "assessed_at_head" text NOT NULL,
60
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
61
+ CONSTRAINT "ci_review_items_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE cascade,
62
+ CONSTRAINT "ciri_review_account_fk" FOREIGN KEY ("ci_review_id","account_id") REFERENCES "ci_reviews"("id","account_id") ON DELETE cascade
63
+ );
64
+ --> statement-breakpoint
65
+ CREATE INDEX IF NOT EXISTS "ciri_review_idx" ON "ci_review_items" USING btree ("ci_review_id");
@@ -0,0 +1,3 @@
1
+ -- Postgres twin of migrations/0083_workspace_auto_fix.sql — the per-workspace auto AI Fix switch.
2
+ -- DEFAULT true so every existing workspace keeps today's behaviour. See the sqlite original.
3
+ ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "auto_fix_enabled" boolean DEFAULT true NOT NULL;
@@ -484,6 +484,20 @@
484
484
  "when": 1790658000000,
485
485
  "tag": "0068_review_request_requester",
486
486
  "breakpoints": true
487
+ },
488
+ {
489
+ "idx": 69,
490
+ "version": "7",
491
+ "when": 1790661600000,
492
+ "tag": "0069_ci_reviews",
493
+ "breakpoints": true
494
+ },
495
+ {
496
+ "idx": 70,
497
+ "version": "7",
498
+ "when": 1790665200000,
499
+ "tag": "0070_workspace_auto_fix",
500
+ "breakpoints": true
487
501
  }
488
502
  ]
489
503
  }
@@ -30,6 +30,12 @@
30
30
  // clock scores higher and wins `compareScored`'s age tie-break). Ties beyond that break on the
31
31
  // item's own id, so two reads of unchanged data keep the same card.
32
32
  //
33
+ // ⚠ A CLAUDE REVIEW IS EXEMPT: it never competes for the PR's one card and is never removed by
34
+ // it. A finished run, and above all an auto RE-review on a PR that already holds another job (a
35
+ // reply in your thread), is new work of its own; folding it under that job hid every re-review on
36
+ // a busy PR. Each run is its own item (its own id and `finishedAt` clock), so a newer run shows
37
+ // even after an older one's card was dismissed.
38
+ //
33
39
  // ⚠ IT IS A BOARD RULE, NOT A NOTIFICATION RULE. `getWorkspaceInsights` asks for it; the
34
40
  // account-wide `GET /api/my-turn` does not, because the browser-notification watcher diffs item
35
41
  // ids — a deduplicated list would flip the winner whenever the top job cleared and announce the
@@ -285,14 +291,6 @@ export function onePerPr(mt, order) {
285
291
  ref: `${i.threadId}`,
286
292
  }),
287
293
  };
288
- const claude = {
289
- of: (i) => ({
290
- prId: i.prId,
291
- reason: 'claude_review',
292
- clockMs: clockMsOf(i.finishedAt),
293
- ref: `${i.reviewId}`,
294
- }),
295
- };
296
294
  for (const i of mt.awaitingReview)
297
295
  offer(S.awaitingReview.of(i));
298
296
  for (const i of mt.mentions)
@@ -319,8 +317,6 @@ export function onePerPr(mt, order) {
319
317
  offer(threadReplies.of(i));
320
318
  for (const i of mt.ownThreads)
321
319
  offer(ownThreads.of(i));
322
- for (const i of mt.claudeReviewsToAction)
323
- offer(claude.of(i));
324
320
  return {
325
321
  awaitingReview: mt.awaitingReview.filter((i) => winnerIs(S.awaitingReview.of(i))),
326
322
  mentions: mt.mentions.filter((i) => winnerIs(S.mentions.of(i))),
@@ -335,7 +331,8 @@ export function onePerPr(mt, order) {
335
331
  threadsAwaiting: mt.threadsAwaiting.filter((i) => winnerIs(threadsAwaiting.of(i))),
336
332
  threadReplies: mt.threadReplies.filter((i) => winnerIs(threadReplies.of(i))),
337
333
  ownThreads: mt.ownThreads.filter((i) => winnerIs(ownThreads.of(i))),
338
- claudeReviewsToAction: mt.claudeReviewsToAction.filter((i) => winnerIs(claude.of(i))),
334
+ // Exempt (header, rule 2): every Claude review keeps its own card.
335
+ claudeReviewsToAction: mt.claudeReviewsToAction,
339
336
  // Repo-grained: a branch is not a pull request, and never shares a key with one.
340
337
  redTrunks: mt.redTrunks,
341
338
  };
@@ -50,6 +50,7 @@ import { forwardKindOf, isConflicting, isRedCiStatus, RED_CI_STATUSES, } from '.
50
50
  import { authorAutomationFor, dependencyPrState, dependencyStateDetail, MERGE_QUEUE_CARD_DETAIL, securityFixDetail, unionAdvisoryIds, } from './dependency-cards.js';
51
51
  import { deriveSecurityAlerts } from './security-alerts.js';
52
52
  import { pruneTicketReviewsForPrs } from './ticket-review-prune.js';
53
+ import { pruneCiReviewsForPrs } from './ci-review-prune.js';
53
54
  import { failingChecksFields, prFailingChecks, trunkFailingChecks, trunkFailingFor, } from './failing-checks.js';
54
55
  import { addressedCommitAuthors, mentionExcerpts, reviewRequesters, threadFirstComments, yourPrNewActors, } from './my-turn-card-facts.js';
55
56
  // Bind a JS Date into a raw-`sql` epoch comparison portably: Postgres columns are
@@ -8329,6 +8330,8 @@ export async function deleteRepo(id, accountId) {
8329
8330
  // Ticket reviews (migration 0080 / pg 0067): this repo's member rows, then any run left with
8330
8331
  // no members — a run whose other PRs live in another repo keeps its history.
8331
8332
  await pruneTicketReviewsForPrs(tx, prIds);
8333
+ // CI reviews (migration 0082 / pg 0069): one PR each, so its runs and items go with it.
8334
+ await pruneCiReviewsForPrs(tx, prIds);
8332
8335
  // The agentic table core ADOPTED from the plugin (migration 0074 / pg 0061): AI Fix runs
8333
8336
  // (~MiB patches). No FKs, so nothing would cascade; before this the plugin had no deleteRepo
8334
8337
  // hook at all and they were orphaned on a repo removal.
@@ -8561,19 +8564,21 @@ repoIds) {
8561
8564
  // based: every tick it asks this read which PRs qualify, so the DATABASE is the queue and a restart
8562
8565
  // loses nothing. A PR qualifies when it is, in ONE workspace:
8563
8566
  // - OPEN and NOT A DRAFT (a draft qualifies once it is marked ready, if it was opened late enough);
8564
- // - OPENED at or after `openedSinceMs` - the moment the workspace switched the feature on. That
8565
- // floor is what makes a first sync, a 90-day backfill or adding a repo safe: none of them can
8566
- // make an OLD PR look new;
8567
+ // - for a FIRST review: OPENED at or after `openedSinceMs` - the moment the workspace switched the
8568
+ // feature on. That floor is what makes a first sync, a 90-day backfill or adding a repo safe:
8569
+ // none of them can make an OLD PR look new. A RE-review also qualifies an older PR once it has a
8570
+ // succeeded run created at or after the floor (someone chose to review it since the switch);
8567
8571
  // - by a PERSON. The workspace-aware resolver `hiddenBotUserIds` - the SAME set that decides
8568
8572
  // `InsightPrRef.authorIsBot` and the "hide bots" lens, a manual workspace judgement winning both
8569
8573
  // ways - never the global set alone. An unmapped author (`author_id` NULL) is not a proven
8570
8574
  // person and is skipped;
8571
8575
  // - with NO `claude_reviews` row at all, whatever its head, status or trigger: ONE review per PR,
8572
8576
  // ever, from auto. A manual run (or a failed auto one) settles it too.
8573
- // `ci` carries, for every PR offered (first review or re-review), whether the synced CI of its head
8574
- // is still RUNNING (`ci_status` pending / expected) and when that head was first observed
8575
- // (`ci_status_events`, the earliest row for that head; null = no row). The sweeper holds a run
8576
- // while CI runs, for at most AUTO_REVIEW_CI_WAIT_MS from that moment (review/claude-review/auto.ts).
8577
+ // Each re-review carries `lastRunAtMs` (the latest start or finish of any run of the PR) for the
8578
+ // sweeper's "start a new head on receipt unless a run was within 5 minutes" test, and
8579
+ // `inFlightMoved` lists PRs whose run is queued or running while their head moved past it (never
8580
+ // cancelled; the sweeper only starts that head's settle clock). There is NO CI reading: auto review
8581
+ // does not wait for CI (review/claude-review/auto.ts).
8577
8582
  // `autoToday` counts this workspace's AUTO runs created at or after `dayStartMs`, for the daily cap.
8578
8583
  // Returns null when the workspace is not this account's - never another workspace's answer (the
8579
8584
  // request-side resolver's "fall back to Default" would review the wrong repos here).
@@ -8588,7 +8593,7 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8588
8593
  return null;
8589
8594
  const repoIds = await getWorkspaceRepoIds(workspaceId, accountId);
8590
8595
  if (repoIds.length === 0)
8591
- return { prIds: [], autoToday: 0, reReview: [], ci: [] };
8596
+ return { prIds: [], autoToday: 0, reReview: [], inFlightMoved: [] };
8592
8597
  const autoRows = await db
8593
8598
  .select({ id: claudeReviews.id })
8594
8599
  .from(claudeReviews)
@@ -8597,21 +8602,20 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8597
8602
  .execute();
8598
8603
  const autoToday = autoRows.length;
8599
8604
  if (opts.limit <= 0)
8600
- return { prIds: [], autoToday, reReview: [], ci: [] };
8605
+ return { prIds: [], autoToday, reReview: [], inFlightMoved: [] };
8601
8606
  const open = await db
8602
8607
  .select({
8603
8608
  id: pullRequests.id,
8604
8609
  authorId: pullRequests.authorId,
8605
8610
  openedAt: pullRequests.openedAt,
8606
8611
  headSha: pullRequests.headSha,
8607
- ciStatus: pullRequests.ciStatus,
8608
8612
  })
8609
8613
  .from(pullRequests)
8610
- .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false), gte(pullRequests.openedAt, new Date(opts.openedSinceMs))))
8614
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false)))
8611
8615
  .orderBy(asc(pullRequests.openedAt), asc(pullRequests.id))
8612
8616
  .execute();
8613
8617
  if (open.length === 0)
8614
- return { prIds: [], autoToday, reReview: [], ci: [] };
8618
+ return { prIds: [], autoToday, reReview: [], inFlightMoved: [] };
8615
8619
  const [bots, reviewed, reviewerBots] = await Promise.all([
8616
8620
  hiddenBotUserIds(accountId, workspaceId),
8617
8621
  db
@@ -8621,6 +8625,7 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8621
8625
  headSha: claudeReviews.headSha,
8622
8626
  status: claudeReviews.status,
8623
8627
  createdAt: claudeReviews.createdAt,
8628
+ finishedAt: claudeReviews.finishedAt,
8624
8629
  commentsThrough: claudeReviews.commentsThrough,
8625
8630
  })
8626
8631
  .from(claudeReviews)
@@ -8631,8 +8636,13 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8631
8636
  const botSet = new Set(bots);
8632
8637
  const hasRun = new Set(reviewed.map((r) => r.prId));
8633
8638
  const human = open.filter((r) => r.authorId != null && !botSet.has(r.authorId));
8639
+ // ⚠ THE ONBOARDING FLOOR GATES FIRST REVIEWS ONLY. A PR opened before the switch is never
8640
+ // reviewed unasked (switching on must not bill a backlog), but once someone has run a review on
8641
+ // it SINCE the switch, it is followed like any other: a manual run on an older PR used to leave
8642
+ // its later pushes un-re-reviewed for ever, because the floor filtered the whole candidate read.
8643
+ const afterFloor = (r) => r.openedAt != null && r.openedAt.getTime() >= opts.openedSinceMs;
8634
8644
  const prIds = human
8635
- .filter((r) => !hasRun.has(r.id))
8645
+ .filter((r) => afterFloor(r) && !hasRun.has(r.id))
8636
8646
  .slice(0, opts.limit)
8637
8647
  .map((r) => r.id);
8638
8648
  // RE-REVIEW: a PR already reviewed (a SUCCEEDED run, manual or auto) whose synced head has
@@ -8646,20 +8656,31 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8646
8656
  runsByPr.set(r.prId, list);
8647
8657
  }
8648
8658
  const reReview = [];
8659
+ const inFlightMoved = [];
8649
8660
  // Same head, but maybe NEW REVIEW COMMENTS: the PR's runs at its head and how far they saw.
8650
8661
  const sameHead = [];
8651
8662
  for (const pr of human) {
8652
8663
  const runs = runsByPr.get(pr.id);
8653
8664
  if (!runs || !pr.headSha)
8654
8665
  continue;
8666
+ const newest = runs.reduce((a, b) => (b.id > a.id ? b : a));
8667
+ if ((newest.status === 'queued' || newest.status === 'running') &&
8668
+ !runs.some((r) => r.headSha === pr.headSha)) {
8669
+ inFlightMoved.push({ prId: pr.id, headSha: pr.headSha });
8670
+ continue;
8671
+ }
8655
8672
  if (!runs.some((r) => r.status === 'succeeded'))
8656
8673
  continue;
8674
+ if (!afterFloor(pr) &&
8675
+ !runs.some((r) => r.status === 'succeeded' && r.createdAt.getTime() >= opts.openedSinceMs))
8676
+ continue;
8657
8677
  const latest = runs.reduce((a, b) => (b.id > a.id ? b : a));
8658
8678
  if (latest.status === 'queued' || latest.status === 'running')
8659
8679
  continue;
8660
8680
  const atHead = runs.filter((r) => r.headSha === pr.headSha);
8661
8681
  if (atHead.length === 0) {
8662
- reReview.push({ prId: pr.id, headSha: pr.headSha, commentsAtMs: null, reason: 'head' });
8682
+ const lastRunAtMs = Math.max(...runs.map((r) => Math.max(r.createdAt.getTime(), r.finishedAt?.getTime() ?? 0)));
8683
+ reReview.push({ prId: pr.id, headSha: pr.headSha, commentsAtMs: null, reason: 'head', lastRunAtMs });
8663
8684
  if (reReview.length >= opts.limit)
8664
8685
  break;
8665
8686
  continue;
@@ -8683,41 +8704,18 @@ export async function getAutoReviewCandidates(accountId, workspaceId, opts) {
8683
8704
  const at = newest.get(p.prId);
8684
8705
  if (!at || at.getTime() <= p.coveredMs)
8685
8706
  continue;
8686
- reReview.push({ prId: p.prId, headSha: p.headSha, commentsAtMs: at.getTime(), reason: 'comments' });
8707
+ reReview.push({
8708
+ prId: p.prId,
8709
+ headSha: p.headSha,
8710
+ commentsAtMs: at.getTime(),
8711
+ reason: 'comments',
8712
+ lastRunAtMs: null,
8713
+ });
8687
8714
  if (reReview.length >= opts.limit)
8688
8715
  break;
8689
8716
  }
8690
8717
  }
8691
- const ci = await autoReviewCiStates(accountId, open, new Set([...prIds, ...reReview.map((r) => r.prId)]));
8692
- return { prIds, autoToday, reReview, ci };
8693
- }
8694
- async function autoReviewCiStates(accountId, open, offered) {
8695
- const rows = open.filter((r) => offered.has(r.id));
8696
- if (rows.length === 0)
8697
- return [];
8698
- const events = (await db
8699
- .select({
8700
- prId: ciStatusEvents.prId,
8701
- headSha: ciStatusEvents.headSha,
8702
- observedAt: ciStatusEvents.observedAt,
8703
- })
8704
- .from(ciStatusEvents)
8705
- .where(and(eq(ciStatusEvents.accountId, accountId), inArray(ciStatusEvents.prId, rows.map((r) => r.id))))
8706
- .execute());
8707
- const firstSeen = new Map();
8708
- for (const e of events) {
8709
- const k = `${e.prId}|${e.headSha}`;
8710
- const t = e.observedAt.getTime();
8711
- const prev = firstSeen.get(k);
8712
- if (prev == null || t < prev)
8713
- firstSeen.set(k, t);
8714
- }
8715
- return rows.map((r) => ({
8716
- prId: r.id,
8717
- headSha: r.headSha,
8718
- running: r.ciStatus === 'pending' || r.ciStatus === 'expected',
8719
- headSeenAtMs: r.headSha ? (firstSeen.get(`${r.id}|${r.headSha}`) ?? null) : null,
8720
- }));
8718
+ return { prIds, autoToday, reReview, inFlightMoved };
8721
8719
  }
8722
8720
  // WHO MAY RE-TRIGGER AN AUTO REVIEW BY COMMENTING (the comment half of the re-review key). A
8723
8721
  // PERSON — not in the workspace's bot union `hiddenBotUserIds`, where a manual "this is a human"