limn-review 0.1.123

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 (474) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +133 -0
  3. package/dist/ai/runtime.js +535 -0
  4. package/dist/api/plugins/auth.js +169 -0
  5. package/dist/api/plugins/error-handler.js +114 -0
  6. package/dist/api/plugins/rate-limit.js +855 -0
  7. package/dist/api/plugins/security.js +321 -0
  8. package/dist/api/routes/activity.js +103 -0
  9. package/dist/api/routes/ai-runtime.js +67 -0
  10. package/dist/api/routes/auth.js +194 -0
  11. package/dist/api/routes/billing.js +209 -0
  12. package/dist/api/routes/bot-triage.js +1289 -0
  13. package/dist/api/routes/conflicts.js +730 -0
  14. package/dist/api/routes/contact.js +318 -0
  15. package/dist/api/routes/daily-brief.js +58 -0
  16. package/dist/api/routes/flow.js +71 -0
  17. package/dist/api/routes/health.js +7 -0
  18. package/dist/api/routes/insights.js +200 -0
  19. package/dist/api/routes/me.js +458 -0
  20. package/dist/api/routes/mentions.js +22 -0
  21. package/dist/api/routes/mergers.js +8 -0
  22. package/dist/api/routes/ml-labels.js +109 -0
  23. package/dist/api/routes/open-prs.js +35 -0
  24. package/dist/api/routes/prs.js +1633 -0
  25. package/dist/api/routes/reactions.js +217 -0
  26. package/dist/api/routes/repos.js +563 -0
  27. package/dist/api/routes/search.js +41 -0
  28. package/dist/api/routes/threads.js +118 -0
  29. package/dist/api/routes/timeline.js +150 -0
  30. package/dist/api/routes/users.js +67 -0
  31. package/dist/api/routes/webhooks.js +181 -0
  32. package/dist/api/routes/workspaces.js +322 -0
  33. package/dist/app.js +274 -0
  34. package/dist/ascii.js +19 -0
  35. package/dist/auth/account.js +482 -0
  36. package/dist/auth/crypto.js +46 -0
  37. package/dist/bot-triage/resolve.js +59 -0
  38. package/dist/cli.js +388 -0
  39. package/dist/coding/activity.js +80 -0
  40. package/dist/coding/agent.js +288 -0
  41. package/dist/coding/ai-fix/auto-fix.js +212 -0
  42. package/dist/coding/ai-fix/manager.js +370 -0
  43. package/dist/coding/ai-fix/persist.js +213 -0
  44. package/dist/coding/ai-fix/pr-context.js +58 -0
  45. package/dist/coding/ai-fix/prompts.js +138 -0
  46. package/dist/coding/ai-fix/review-seed.js +357 -0
  47. package/dist/coding/ai-fix/routes.js +425 -0
  48. package/dist/coding/git-ops.js +321 -0
  49. package/dist/coding/git.js +184 -0
  50. package/dist/coding/merge.js +262 -0
  51. package/dist/coding/safe-path.js +75 -0
  52. package/dist/coding/schema.js +43 -0
  53. package/dist/config.js +648 -0
  54. package/dist/conflict/diff.js +304 -0
  55. package/dist/conflict/git.js +118 -0
  56. package/dist/conflict/hash.js +73 -0
  57. package/dist/conflict/janitor.js +218 -0
  58. package/dist/conflict/land.js +675 -0
  59. package/dist/conflict/model-types.js +2 -0
  60. package/dist/conflict/model.js +1081 -0
  61. package/dist/conflict/parse.js +142 -0
  62. package/dist/conflict/seam.js +136 -0
  63. package/dist/conflict/session.js +532 -0
  64. package/dist/conflict/suggestion.js +398 -0
  65. package/dist/conflict/tokens.js +72 -0
  66. package/dist/data-dir.js +112 -0
  67. package/dist/db/actor-lanes.js +213 -0
  68. package/dist/db/automation-ids.js +97 -0
  69. package/dist/db/automation-output.js +349 -0
  70. package/dist/db/blast-radius-query.js +36 -0
  71. package/dist/db/blast-radius.js +226 -0
  72. package/dist/db/bot-dormancy.js +115 -0
  73. package/dist/db/bot-overlap.js +579 -0
  74. package/dist/db/bot-volume.js +722 -0
  75. package/dist/db/bot-window.js +35 -0
  76. package/dist/db/branch-queries.js +310 -0
  77. package/dist/db/change-shape.js +243 -0
  78. package/dist/db/changepoint.js +75 -0
  79. package/dist/db/cleanup.js +36 -0
  80. package/dist/db/client.js +102 -0
  81. package/dist/db/code-loc.js +388 -0
  82. package/dist/db/credits.js +79 -0
  83. package/dist/db/daily-brief.js +406 -0
  84. package/dist/db/dependency-cards.js +145 -0
  85. package/dist/db/erase-account.js +255 -0
  86. package/dist/db/export-account.js +189 -0
  87. package/dist/db/failing-checks.js +124 -0
  88. package/dist/db/file-coupling.js +197 -0
  89. package/dist/db/flow-detail.js +661 -0
  90. package/dist/db/flow-pointers.js +197 -0
  91. package/dist/db/flow-settings.js +67 -0
  92. package/dist/db/forecast.js +114 -0
  93. package/dist/db/line-overlap.js +81 -0
  94. package/dist/db/merge-queue.js +189 -0
  95. package/dist/db/migrate.js +17 -0
  96. package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
  97. package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
  98. package/dist/db/migrations/0002_famous_sersi.sql +9 -0
  99. package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
  100. package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
  101. package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
  102. package/dist/db/migrations/0006_cute_violations.sql +45 -0
  103. package/dist/db/migrations/0007_dark_anthem.sql +2 -0
  104. package/dist/db/migrations/0008_multitenant_accounts.sql +44 -0
  105. package/dist/db/migrations/0009_lean_bodies.sql +13 -0
  106. package/dist/db/migrations/0010_lean_bodies_nullable.sql +56 -0
  107. package/dist/db/migrations/0011_pr_diff_size.sql +10 -0
  108. package/dist/db/migrations/0012_findings_included_default.sql +7 -0
  109. package/dist/db/migrations/0013_review_routing.sql +9 -0
  110. package/dist/db/migrations/0014_account_last_active.sql +6 -0
  111. package/dist/db/migrations/0015_repos_inbox_watch.sql +9 -0
  112. package/dist/db/migrations/0016_repo_viewer_permission.sql +7 -0
  113. package/dist/db/migrations/0017_account_display_name.sql +7 -0
  114. package/dist/db/migrations/0018_finding_comment_kind.sql +7 -0
  115. package/dist/db/migrations/0019_finding_file_in_diff.sql +7 -0
  116. package/dist/db/migrations/0020_claude_review_cost_telemetry.sql +11 -0
  117. package/dist/db/migrations/0021_account_feed_last_seen.sql +7 -0
  118. package/dist/db/migrations/0022_ci_status_events.sql +21 -0
  119. package/dist/db/migrations/0023_ai_usage.sql +21 -0
  120. package/dist/db/migrations/0024_pr_head_ref_name.sql +6 -0
  121. package/dist/db/migrations/0025_account_billing.sql +7 -0
  122. package/dist/db/migrations/0026_ai_credit_allowance.sql +5 -0
  123. package/dist/db/migrations/0027_user_github_type.sql +6 -0
  124. package/dist/db/migrations/0028_bot_review_classification.sql +21 -0
  125. package/dist/db/migrations/0029_bot_mute_rules.sql +19 -0
  126. package/dist/db/migrations/0030_teams.sql +32 -0
  127. package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
  128. package/dist/db/migrations/0032_benchmark_contributions.sql +29 -0
  129. package/dist/db/migrations/0033_thread_addressed_confidence.sql +9 -0
  130. package/dist/db/migrations/0034_thread_resolved_at.sql +6 -0
  131. package/dist/db/migrations/0035_pr_first_review_requested_at.sql +5 -0
  132. package/dist/db/migrations/0036_search_index.sql +25 -0
  133. package/dist/db/migrations/0037_author_indexes.sql +15 -0
  134. package/dist/db/migrations/0038_auto_merge_requests.sql +37 -0
  135. package/dist/db/migrations/0039_branch_status.sql +44 -0
  136. package/dist/db/migrations/0040_pr_review_decision.sql +10 -0
  137. package/dist/db/migrations/0041_branch_commit_detail.sql +38 -0
  138. package/dist/db/migrations/0042_repo_reviewers.sql +176 -0
  139. package/dist/db/migrations/0043_account_reviewers.sql +131 -0
  140. package/dist/db/migrations/0044_workspaces.sql +138 -0
  141. package/dist/db/migrations/0045_workspace_reviewers.sql +180 -0
  142. package/dist/db/migrations/0046_drop_inbox_watch.sql +25 -0
  143. package/dist/db/migrations/0047_ml_comment_labels.sql +40 -0
  144. package/dist/db/migrations/0048_ml_vendor_severity.sql +31 -0
  145. package/dist/db/migrations/0049_seat_pricing_repo_description.sql +21 -0
  146. package/dist/db/migrations/0050_users_app_slug.sql +12 -0
  147. package/dist/db/migrations/0051_auto_merge_queue.sql +17 -0
  148. package/dist/db/migrations/0052_trunk_ci_status_events.sql +43 -0
  149. package/dist/db/migrations/0053_automation_roles.sql +79 -0
  150. package/dist/db/migrations/0054_automation_vendor_kinds.sql +514 -0
  151. package/dist/db/migrations/0055_auto_merge_phase.sql +12 -0
  152. package/dist/db/migrations/0056_pr_mentions.sql +22 -0
  153. package/dist/db/migrations/0057_large_pr_threshold.sql +14 -0
  154. package/dist/db/migrations/0058_pending_mute.sql +53 -0
  155. package/dist/db/migrations/0059_merge_queue_state.sql +33 -0
  156. package/dist/db/migrations/0060_drop_my_turn_dismissals.sql +33 -0
  157. package/dist/db/migrations/0061_auto_merge_durable_anchors.sql +27 -0
  158. package/dist/db/migrations/0062_blast_radius_config.sql +21 -0
  159. package/dist/db/migrations/0063_repo_file_coupling.sql +33 -0
  160. package/dist/db/migrations/0064_pr_content_kind.sql +21 -0
  161. package/dist/db/migrations/0065_workspace_flow_settings.sql +13 -0
  162. package/dist/db/migrations/0066_review_request_events.sql +31 -0
  163. package/dist/db/migrations/0067_pr_security_signals.sql +86 -0
  164. package/dist/db/migrations/0068_my_turn_settings.sql +18 -0
  165. package/dist/db/migrations/0069_my_turn_dismissals.sql +41 -0
  166. package/dist/db/migrations/0070_claude_review_follow_up_ticket.sql +22 -0
  167. package/dist/db/migrations/0071_claude_review_trigger.sql +11 -0
  168. package/dist/db/migrations/0072_commit_message_headline.sql +13 -0
  169. package/dist/db/migrations/0073_claude_review_chat.sql +31 -0
  170. package/dist/db/migrations/0074_adopt_agentic_tables.sql +98 -0
  171. package/dist/db/migrations/0075_review_lens_drop_learnings.sql +15 -0
  172. package/dist/db/migrations/0076_claude_review_threads.sql +17 -0
  173. package/dist/db/migrations/0077_claude_review_ci_failures.sql +10 -0
  174. package/dist/db/migrations/0078_ai_fix_change_report.sql +17 -0
  175. package/dist/db/migrations/0079_claude_finding_story.sql +16 -0
  176. package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
  177. package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
  178. package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
  179. package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
  180. package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
  181. package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
  182. package/dist/db/migrations/meta/0006_snapshot.json +1745 -0
  183. package/dist/db/migrations/meta/0007_snapshot.json +1759 -0
  184. package/dist/db/migrations/meta/_journal.json +566 -0
  185. package/dist/db/migrations-pg/0000_tired_alex_power.sql +268 -0
  186. package/dist/db/migrations-pg/0001_dizzy_mockingbird.sql +3 -0
  187. package/dist/db/migrations-pg/0002_glorious_ikaris.sql +4 -0
  188. package/dist/db/migrations-pg/0003_melted_secret_warriors.sql +2 -0
  189. package/dist/db/migrations-pg/0004_numerous_triton.sql +1 -0
  190. package/dist/db/migrations-pg/0005_thick_blockbuster.sql +2 -0
  191. package/dist/db/migrations-pg/0006_majestic_dracula.sql +1 -0
  192. package/dist/db/migrations-pg/0007_same_human_torch.sql +2 -0
  193. package/dist/db/migrations-pg/0008_slimy_toxin.sql +1 -0
  194. package/dist/db/migrations-pg/0009_fuzzy_jubilee.sql +4 -0
  195. package/dist/db/migrations-pg/0010_late_hammerhead.sql +1 -0
  196. package/dist/db/migrations-pg/0011_happy_leech.sql +16 -0
  197. package/dist/db/migrations-pg/0012_woozy_captain_midlands.sql +16 -0
  198. package/dist/db/migrations-pg/0013_yielding_nicolaos.sql +1 -0
  199. package/dist/db/migrations-pg/0014_wakeful_bill_hollister.sql +2 -0
  200. package/dist/db/migrations-pg/0015_aspiring_black_tom.sql +1 -0
  201. package/dist/db/migrations-pg/0016_cute_ghost_rider.sql +30 -0
  202. package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
  203. package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
  204. package/dist/db/migrations-pg/0019_benchmark_contributions.sql +22 -0
  205. package/dist/db/migrations-pg/0020_majestic_edwin_jarvis.sql +3 -0
  206. package/dist/db/migrations-pg/0021_green_salo.sql +1 -0
  207. package/dist/db/migrations-pg/0022_wise_baron_strucker.sql +1 -0
  208. package/dist/db/migrations-pg/0023_search_index.sql +19 -0
  209. package/dist/db/migrations-pg/0024_author_indexes.sql +9 -0
  210. package/dist/db/migrations-pg/0025_auto_merge_requests.sql +26 -0
  211. package/dist/db/migrations-pg/0026_branch_status.sql +32 -0
  212. package/dist/db/migrations-pg/0027_pr_review_decision.sql +5 -0
  213. package/dist/db/migrations-pg/0028_branch_commit_detail.sql +9 -0
  214. package/dist/db/migrations-pg/0029_repo_reviewers.sql +127 -0
  215. package/dist/db/migrations-pg/0030_account_reviewers.sql +77 -0
  216. package/dist/db/migrations-pg/0031_workspaces.sql +131 -0
  217. package/dist/db/migrations-pg/0032_workspace_reviewers.sql +165 -0
  218. package/dist/db/migrations-pg/0033_drop_inbox_watch.sql +14 -0
  219. package/dist/db/migrations-pg/0034_ml_comment_labels.sql +33 -0
  220. package/dist/db/migrations-pg/0035_ml_vendor_severity.sql +16 -0
  221. package/dist/db/migrations-pg/0036_seat_pricing_repo_description.sql +12 -0
  222. package/dist/db/migrations-pg/0037_users_app_slug.sql +9 -0
  223. package/dist/db/migrations-pg/0038_auto_merge_queue.sql +10 -0
  224. package/dist/db/migrations-pg/0039_trunk_ci_status_events.sql +27 -0
  225. package/dist/db/migrations-pg/0040_automation_roles.sql +63 -0
  226. package/dist/db/migrations-pg/0041_automation_vendor_kinds.sql +488 -0
  227. package/dist/db/migrations-pg/0042_auto_merge_phase.sql +8 -0
  228. package/dist/db/migrations-pg/0043_pr_mentions.sql +19 -0
  229. package/dist/db/migrations-pg/0044_large_pr_threshold.sql +6 -0
  230. package/dist/db/migrations-pg/0045_pending_mute.sql +26 -0
  231. package/dist/db/migrations-pg/0046_merge_queue_state.sql +18 -0
  232. package/dist/db/migrations-pg/0047_drop_my_turn_dismissals.sql +21 -0
  233. package/dist/db/migrations-pg/0048_auto_merge_durable_anchors.sql +27 -0
  234. package/dist/db/migrations-pg/0049_blast_radius_config.sql +8 -0
  235. package/dist/db/migrations-pg/0050_repo_file_coupling.sql +13 -0
  236. package/dist/db/migrations-pg/0051_pr_content_kind.sql +6 -0
  237. package/dist/db/migrations-pg/0052_workspace_flow_settings.sql +4 -0
  238. package/dist/db/migrations-pg/0053_review_request_events.sql +18 -0
  239. package/dist/db/migrations-pg/0054_pr_security_signals.sql +59 -0
  240. package/dist/db/migrations-pg/0055_my_turn_settings.sql +6 -0
  241. package/dist/db/migrations-pg/0056_my_turn_dismissals.sql +21 -0
  242. package/dist/db/migrations-pg/0057_claude_review_follow_up_ticket.sql +8 -0
  243. package/dist/db/migrations-pg/0058_claude_review_trigger.sql +4 -0
  244. package/dist/db/migrations-pg/0059_commit_message_headline.sql +3 -0
  245. package/dist/db/migrations-pg/0060_claude_review_chat.sql +24 -0
  246. package/dist/db/migrations-pg/0061_adopt_agentic_tables.sql +79 -0
  247. package/dist/db/migrations-pg/0062_review_lens_drop_learnings.sql +5 -0
  248. package/dist/db/migrations-pg/0063_claude_review_threads.sql +7 -0
  249. package/dist/db/migrations-pg/0064_claude_review_ci_failures.sql +4 -0
  250. package/dist/db/migrations-pg/0065_ai_fix_change_report.sql +9 -0
  251. package/dist/db/migrations-pg/0066_claude_finding_story.sql +7 -0
  252. package/dist/db/migrations-pg/meta/0000_snapshot.json +2137 -0
  253. package/dist/db/migrations-pg/meta/0001_snapshot.json +2143 -0
  254. package/dist/db/migrations-pg/meta/0002_snapshot.json +2170 -0
  255. package/dist/db/migrations-pg/meta/0003_snapshot.json +2182 -0
  256. package/dist/db/migrations-pg/meta/0004_snapshot.json +2188 -0
  257. package/dist/db/migrations-pg/meta/0005_snapshot.json +2201 -0
  258. package/dist/db/migrations-pg/meta/0006_snapshot.json +2207 -0
  259. package/dist/db/migrations-pg/meta/0007_snapshot.json +2219 -0
  260. package/dist/db/migrations-pg/meta/0008_snapshot.json +2226 -0
  261. package/dist/db/migrations-pg/meta/0009_snapshot.json +2250 -0
  262. package/dist/db/migrations-pg/meta/0010_snapshot.json +2256 -0
  263. package/dist/db/migrations-pg/meta/0011_snapshot.json +2412 -0
  264. package/dist/db/migrations-pg/meta/0012_snapshot.json +2528 -0
  265. package/dist/db/migrations-pg/meta/0013_snapshot.json +2534 -0
  266. package/dist/db/migrations-pg/meta/0014_snapshot.json +2547 -0
  267. package/dist/db/migrations-pg/meta/0015_snapshot.json +2553 -0
  268. package/dist/db/migrations-pg/meta/0016_snapshot.json +2774 -0
  269. package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
  270. package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
  271. package/dist/db/migrations-pg/meta/0020_snapshot.json +3228 -0
  272. package/dist/db/migrations-pg/meta/0021_snapshot.json +3234 -0
  273. package/dist/db/migrations-pg/meta/0022_snapshot.json +3240 -0
  274. package/dist/db/migrations-pg/meta/_journal.json +475 -0
  275. package/dist/db/ml-labels.js +1666 -0
  276. package/dist/db/my-turn-dismissals.js +343 -0
  277. package/dist/db/my-turn-settings.js +34 -0
  278. package/dist/db/pending-classify.js +46 -0
  279. package/dist/db/pending-mute.js +171 -0
  280. package/dist/db/pending-tabs.js +238 -0
  281. package/dist/db/period-metrics.js +820 -0
  282. package/dist/db/person-period.js +763 -0
  283. package/dist/db/pr-intervals.js +930 -0
  284. package/dist/db/pr-liveness.js +240 -0
  285. package/dist/db/pr-mentions.js +279 -0
  286. package/dist/db/pr-merge-queue-stamp.js +91 -0
  287. package/dist/db/queries.js +14631 -0
  288. package/dist/db/reaction-targets.js +94 -0
  289. package/dist/db/repo-activity.js +171 -0
  290. package/dist/db/retention.js +138 -0
  291. package/dist/db/review-threads-for-review.js +240 -0
  292. package/dist/db/run-migrations.js +24 -0
  293. package/dist/db/schema.pg.js +1220 -0
  294. package/dist/db/schema.sqlite.js +1801 -0
  295. package/dist/db/search.js +317 -0
  296. package/dist/db/security-alerts.js +218 -0
  297. package/dist/db/synthesis-input.js +565 -0
  298. package/dist/db/triage.js +320 -0
  299. package/dist/db/usage.js +63 -0
  300. package/dist/db/work-plan.js +1086 -0
  301. package/dist/db/working-hours.js +161 -0
  302. package/dist/feed/my-turn.js +122 -0
  303. package/dist/github/actions-logs.js +397 -0
  304. package/dist/github/auth.js +86 -0
  305. package/dist/github/branch-queries.js +229 -0
  306. package/dist/github/client.js +454 -0
  307. package/dist/github/codeowners.js +221 -0
  308. package/dist/github/commit-checks.js +105 -0
  309. package/dist/github/compare.js +110 -0
  310. package/dist/github/diff-anchor.js +227 -0
  311. package/dist/github/mutations.js +706 -0
  312. package/dist/github/pr-file-content.js +254 -0
  313. package/dist/github/pr-liveness.js +161 -0
  314. package/dist/github/queries.js +875 -0
  315. package/dist/github/rate-budget.js +129 -0
  316. package/dist/github/reactions.js +161 -0
  317. package/dist/github/reviewer-suggest.js +104 -0
  318. package/dist/github/team-reviewers.js +96 -0
  319. package/dist/index.js +184 -0
  320. package/dist/merge/auto-merge-runner.js +960 -0
  321. package/dist/ml/severity-client.js +218 -0
  322. package/dist/pr/detail-enricher.js +26 -0
  323. package/dist/pro/bind.js +302 -0
  324. package/dist/pro/contract.js +37 -0
  325. package/dist/pro/migrate.js +64 -0
  326. package/dist/review/agent-context.js +127 -0
  327. package/dist/review/agent.js +385 -0
  328. package/dist/review/agentic.js +59 -0
  329. package/dist/review/auth.js +94 -0
  330. package/dist/review/chat-agent.js +179 -0
  331. package/dist/review/claude-review/ai-ready.js +16 -0
  332. package/dist/review/claude-review/auto-settings.js +139 -0
  333. package/dist/review/claude-review/auto.js +244 -0
  334. package/dist/review/claude-review/chat.js +489 -0
  335. package/dist/review/claude-review/ci-failures.js +350 -0
  336. package/dist/review/claude-review/follow-up.js +310 -0
  337. package/dist/review/claude-review/manager.js +686 -0
  338. package/dist/review/claude-review/persist.js +996 -0
  339. package/dist/review/claude-review/prompts.js +357 -0
  340. package/dist/review/claude-review/routes.js +518 -0
  341. package/dist/review/claude-review/routing.js +61 -0
  342. package/dist/review/claude-review/specialists.js +190 -0
  343. package/dist/review/claude-review/threads.js +265 -0
  344. package/dist/review/claude-review/ticket.js +282 -0
  345. package/dist/review/clone-hygiene.js +380 -0
  346. package/dist/review/clone-manager.js +367 -0
  347. package/dist/review/llm.js +113 -0
  348. package/dist/review/model-options.js +48 -0
  349. package/dist/review/plugin-providers.js +12 -0
  350. package/dist/review/post-review.js +216 -0
  351. package/dist/review/post-seam.js +161 -0
  352. package/dist/review/prepare.js +168 -0
  353. package/dist/review/pricing.js +17 -0
  354. package/dist/review/schema.js +156 -0
  355. package/dist/review/submit-map.js +41 -0
  356. package/dist/review/usage.js +90 -0
  357. package/dist/shared/claude-review.js +335 -0
  358. package/dist/shared/conflict-fold.js +151 -0
  359. package/dist/shared/conflicts.js +30 -0
  360. package/dist/shared/flow-settings.js +191 -0
  361. package/dist/shared/index.js +8 -0
  362. package/dist/shared/my-turn-settings.js +238 -0
  363. package/dist/shared/pending-rules.js +281 -0
  364. package/dist/shared/product.js +54 -0
  365. package/dist/shared/types.js +896 -0
  366. package/dist/status.js +386 -0
  367. package/dist/sync/adaptive.js +185 -0
  368. package/dist/sync/app-attribution.js +66 -0
  369. package/dist/sync/auth-notices.js +36 -0
  370. package/dist/sync/backfill-ci-history.js +315 -0
  371. package/dist/sync/backfill-commit-headlines.js +136 -0
  372. package/dist/sync/backfill-pr-security.js +175 -0
  373. package/dist/sync/backfill-review-requests.js +97 -0
  374. package/dist/sync/benchmark-rollup.js +50 -0
  375. package/dist/sync/bot-detection.js +309 -0
  376. package/dist/sync/bot-resolution-markers.js +30 -0
  377. package/dist/sync/branch-status.js +634 -0
  378. package/dist/sync/classify-change-shape.js +150 -0
  379. package/dist/sync/commit-files.js +74 -0
  380. package/dist/sync/derive-thread-state.js +99 -0
  381. package/dist/sync/drain-review-threads.js +212 -0
  382. package/dist/sync/hydrate-detail.js +482 -0
  383. package/dist/sync/mention-scan.js +89 -0
  384. package/dist/sync/ml-enrichment.js +478 -0
  385. package/dist/sync/pr-change-signal.js +96 -0
  386. package/dist/sync/pr-liveness-sweep.js +139 -0
  387. package/dist/sync/pr-settle.js +381 -0
  388. package/dist/sync/refresh-pr.js +163 -0
  389. package/dist/sync/reparse-vendor-badges.js +495 -0
  390. package/dist/sync/resync-after-write.js +280 -0
  391. package/dist/sync/review-fingerprint.js +117 -0
  392. package/dist/sync/reviewer-behavior.js +103 -0
  393. package/dist/sync/reviewer-classify.js +503 -0
  394. package/dist/sync/routing-files.js +97 -0
  395. package/dist/sync/scheduled-jobs.js +12 -0
  396. package/dist/sync/scheduler.js +159 -0
  397. package/dist/sync/security-detect.js +521 -0
  398. package/dist/sync/sync-manager.js +750 -0
  399. package/dist/sync/sync-one-pr.js +205 -0
  400. package/dist/sync/sync-repo.js +400 -0
  401. package/dist/sync/unsettled-prs.js +506 -0
  402. package/dist/sync/upsert.js +1190 -0
  403. package/package.json +56 -0
  404. package/public/apple-touch-icon.png +0 -0
  405. package/public/assets/AiFixTab-CQP6PT5u.js +2 -0
  406. package/public/assets/ClaudeReviewTab-DQJ-58ZA.js +8 -0
  407. package/public/assets/PrBotBehaviourTab-CCnFrWbC.js +1 -0
  408. package/public/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
  409. package/public/assets/highlight-BEHUn5zE.css +10 -0
  410. package/public/assets/highlight-CIIZLa6Z.js +12 -0
  411. package/public/assets/index-BDnYs6zg.js +73 -0
  412. package/public/assets/index-mCuI--RC.css +1 -0
  413. package/public/assets/markdown-I_JLLVAZ.js +32 -0
  414. package/public/assets/perfProbe-Da13EhTE.js +1 -0
  415. package/public/assets/tanstack-5qsxxaLe.js +17 -0
  416. package/public/assets/vis-C3BCkP5U.js +1280 -0
  417. package/public/assets/vis-ziCVehMy.css +1 -0
  418. package/public/favicon-16.png +0 -0
  419. package/public/favicon-32.png +0 -0
  420. package/public/favicon-48.png +0 -0
  421. package/public/favicon.svg +5 -0
  422. package/public/index.html +41 -0
  423. package/public-landing/apple-touch-icon.png +0 -0
  424. package/public-landing/assets/archivo-latin-500-BWmvx2Mg.woff2 +0 -0
  425. package/public-landing/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
  426. package/public-landing/assets/index-CA3eEGXO.js +40 -0
  427. package/public-landing/assets/index-naQrQ6Fe.css +1 -0
  428. package/public-landing/assets/jetbrains-mono-latin-400-RruCX7RW.woff2 +0 -0
  429. package/public-landing/assets/newsreader-latin-400-DExrRRrN.woff2 +0 -0
  430. package/public-landing/assets/newsreader-latin-400-italic-b0I-AwbV.woff2 +0 -0
  431. package/public-landing/bots/index.html +156 -0
  432. package/public-landing/contact/index.html +156 -0
  433. package/public-landing/cookies/index.html +156 -0
  434. package/public-landing/demo/limn-walkthrough-poster.jpg +0 -0
  435. package/public-landing/demo/limn-walkthrough.cues.json +44 -0
  436. package/public-landing/demo/limn-walkthrough.mp4 +0 -0
  437. package/public-landing/favicon-16.png +0 -0
  438. package/public-landing/favicon-32.png +0 -0
  439. package/public-landing/favicon-48.png +0 -0
  440. package/public-landing/favicon.svg +5 -0
  441. package/public-landing/features/index.html +156 -0
  442. package/public-landing/for-developers/index.html +156 -0
  443. package/public-landing/for-managers/index.html +156 -0
  444. package/public-landing/how-it-works/index.html +156 -0
  445. package/public-landing/how-we-measure/index.html +156 -0
  446. package/public-landing/icon-192.png +0 -0
  447. package/public-landing/icon-512.png +0 -0
  448. package/public-landing/index.html +156 -0
  449. package/public-landing/insights/index.html +156 -0
  450. package/public-landing/og-image.png +0 -0
  451. package/public-landing/pricing/index.html +156 -0
  452. package/public-landing/privacy/index.html +156 -0
  453. package/public-landing/pro/index.html +156 -0
  454. package/public-landing/reviews/index.html +156 -0
  455. package/public-landing/robots.txt +8 -0
  456. package/public-landing/shots/benchmark.png +0 -0
  457. package/public-landing/shots/bot-roi.png +0 -0
  458. package/public-landing/shots/bot-settings.png +0 -0
  459. package/public-landing/shots/chronology.png +0 -0
  460. package/public-landing/shots/feed.png +0 -0
  461. package/public-landing/shots/flow-metrics.png +0 -0
  462. package/public-landing/shots/free-reports.png +0 -0
  463. package/public-landing/shots/pending-board.png +0 -0
  464. package/public-landing/shots/pending-card.png +0 -0
  465. package/public-landing/shots/period-report.png +0 -0
  466. package/public-landing/shots/pr-detail.png +0 -0
  467. package/public-landing/shots/pr-threads.png +0 -0
  468. package/public-landing/shots/reach.png +0 -0
  469. package/public-landing/shots/repo-rows.png +0 -0
  470. package/public-landing/shots/severity-findings.png +0 -0
  471. package/public-landing/shots/severity-strip.png +0 -0
  472. package/public-landing/site.webmanifest +14 -0
  473. package/public-landing/sitemap.xml +51 -0
  474. package/public-landing/terms/index.html +156 -0
@@ -0,0 +1,1801 @@
1
+ // SQLite schema (local / default deployment mode, via better-sqlite3).
2
+ //
3
+ // This is the canonical schema for LOCAL mode. Its Postgres twin lives in
4
+ // `schema.pg.ts` and MUST be kept structurally identical (same table + column
5
+ // names, same `$type`s) — the shared query layer is typed against ONE of them
6
+ // and cast in `client.ts`, so any drift breaks the cast's soundness. See the
7
+ // `assertSchemaParity` test for the structural guard.
8
+ //
9
+ // Multi-tenancy: every GitHub entity is owned by an `accounts` row. Locally
10
+ // there is exactly ONE synthesized account (id 1, isLocal=true). `accountId` is
11
+ // denormalized onto the tables that anchor list/feed isolation (repos,
12
+ // pullRequests, events, claudeReviews); everything else reaches its account
13
+ // transitively via repoId/prId. `users` and `commitFiles`
14
+ // stay GLOBAL (non-sensitive actor metadata / content-addressed cache).
15
+ import { sqliteTable, text, integer, real, index, uniqueIndex, foreignKey, } from 'drizzle-orm/sqlite-core';
16
+ import { sql } from 'drizzle-orm';
17
+ // A tenant. Local mode synthesizes exactly one row (id 1, isLocal=true) from
18
+ // `gh api user`; cloud mode creates one per signed-in GitHub user. Replaces the
19
+ // old singleton `local_user` table.
20
+ export const accounts = sqliteTable('accounts', {
21
+ id: integer('id').primaryKey({ autoIncrement: true }),
22
+ // GitHub user node id (the stable GraphQL id) — the login can change, this can't.
23
+ githubUserId: text('github_user_id').notNull().unique(),
24
+ githubLogin: text('github_login').notNull(),
25
+ // The user's GitHub display name (the `name` field from `gh api user` / OAuth
26
+ // `GET /user`). Nullable — GitHub `name` can be unset; the UI falls back to the
27
+ // login. Shown wherever the signed-in identity appears (header, greeting).
28
+ displayName: text('display_name'),
29
+ avatarUrl: text('avatar_url'),
30
+ // AES-256-GCM sealed access token (iv:tag:ciphertext, base64). Null for the
31
+ // local account, whose token comes live from `gh auth token`.
32
+ accessTokenEnc: text('access_token_enc'),
33
+ isLocal: integer('is_local', { mode: 'boolean' }).notNull().default(false),
34
+ createdAt: integer('created_at', { mode: 'timestamp' })
35
+ .notNull()
36
+ .default(sql `(unixepoch())`),
37
+ lastLoginAt: integer('last_login_at', { mode: 'timestamp' }),
38
+ // Last time a loaded frontend was seen talking to the backend (cloud: stamped,
39
+ // throttled, by the per-request account hook + an SPA heartbeat). The scheduler
40
+ // syncs only accounts active within config.syncActiveWindowMinutes, so a tenant
41
+ // with no open tab stops being re-synced. Null until first activity.
42
+ lastActiveAt: integer('last_active_at', { mode: 'timestamp' }),
43
+ // Last time this account VIEWED the Activity Feed — a server-side "seen" marker (the
44
+ // successor to the removed per-item "Done"). Makes "new My Turn since you were last here"
45
+ // server-truth, consistent across devices/sessions (vs the old client-only localStorage
46
+ // heuristic). Bumped by POST /api/activity/feed/mark-seen; null until the first view,
47
+ // so nothing counts as "new" until a baseline exists. One column, O(1) — no growth.
48
+ feedLastSeenAt: integer('feed_last_seen_at', { mode: 'timestamp' }),
49
+ // Billing plan: 'free' (default) or 'pro'. Set ONLY by the Stripe webhook
50
+ // (api/routes/billing.ts) — never by the OAuth upsert, so re-login can't reset
51
+ // a paid plan. Local accounts ignore it (isLocal is always fully entitled).
52
+ plan: text('plan').notNull().default('free'),
53
+ // Stripe customer id (cus_…), captured from checkout.session.completed so later
54
+ // subscription webhooks can resolve the account. Null until first checkout.
55
+ stripeCustomerId: text('stripe_customer_id'),
56
+ // Per-account monthly SUMMARY-AI credit allowance override (metered cloud plan). null =
57
+ // use the plan default (2,500 for a paid cloud account); local/unlimited accounts ignore
58
+ // it entirely. A forward hook for top-ups / alternate plans without another migration.
59
+ aiCreditAllowance: integer('ai_credit_allowance'),
60
+ // CLOUD-ONLY, opt-in (default OFF): whether this account contributes de-identified,
61
+ // aggregate weekly review-bot outcome stats to the cross-org benchmark network (see
62
+ // `benchmarkContributions`). Consent is a distinct PURPOSE from running the dashboard,
63
+ // so it must be explicit + reversible; withdrawing (set false) deletes the account's
64
+ // contributions. Local accounts never contribute (never phone home) — always false.
65
+ benchmarkOptIn: integer('benchmark_opt_in', { mode: 'boolean' }).notNull().default(false),
66
+ // The LARGE-PR FLAG's threshold: how many lines of CODE churn (additions + deletions over the
67
+ // PR's non-documentation, non-config files — see db/code-loc.ts) make a pull request "large"
68
+ // enough to warn about. ONE PER-ACCOUNT SETTING, deliberately not per-workspace: there is no
69
+ // inheritance chain to resolve and therefore no "which grain am I reading?" question.
70
+ // NULL = use the product default (1,500). Two states, nothing else — a stored value is always
71
+ // a positive integer the user typed (the route rejects anything else), so a reader that sees
72
+ // null knows the user never expressed an opinion, rather than guessing at a sentinel.
73
+ largePrCodeLocThreshold: integer('large_pr_code_loc_threshold'),
74
+ // BLAST RADIUS reading settings: `{sensitivity, surfacesOff[], overrides?}` — the dial, the
75
+ // per-account surface opt-out, and (stored, honoured, no UI in v1) numeric overrides. Same
76
+ // ACCOUNT grain and same two-state rule as the threshold above: NULL means the user has never
77
+ // expressed an opinion, and the SPA applies the product defaults. JSON rather than six columns
78
+ // because the shape is a settings blob with one writer (PUT /api/me/blast-radius-config, which
79
+ // validates it) and nothing joins or filters on any field inside it.
80
+ //
81
+ // ⚠ THE DEFAULTS ARE NOT MIRRORED HERE. `packages/shared`'s BLAST_THRESHOLDS is an 18-number
82
+ // table across three dial positions and `shared` is types-only on this side (PACKAGING), so
83
+ // the SPA resolves — `/api/me` echoes this column RAW, nulls and all.
84
+ blastRadiusConfig: text('blast_radius_config', { mode: 'json' }).$type(),
85
+ // MY TURN settings — which card types show, their order, and the Do next weights. OVERRIDES
86
+ // ONLY, NULL = the product defaults. One writer (PUT /api/me/my-turn-settings, which compacts
87
+ // before storing), resolved by packages/shared `resolveMyTurnSettings`. ACCOUNT grain: one
88
+ // account = one reader.
89
+ myTurnSettings: text('my_turn_settings', { mode: 'json' }).$type(),
90
+ });
91
+ export const repos = sqliteTable('repos', {
92
+ id: integer('id').primaryKey({ autoIncrement: true }),
93
+ accountId: integer('account_id')
94
+ .notNull()
95
+ .references(() => accounts.id),
96
+ owner: text('owner').notNull(),
97
+ name: text('name').notNull(),
98
+ githubNodeId: text('github_node_id').notNull(),
99
+ // The repo's GitHub "About" description, captured each activity sync. Grounding for the
100
+ // workspace-purpose sprint-chat preset — the app's only repo-purpose text (READMEs are
101
+ // never fetched or stored). Null until a sync populates it or when unset on GitHub.
102
+ description: text('description'),
103
+ // The repo's default branch (GraphQL defaultBranchRef.name), captured each
104
+ // activity sync. Used to scope the "maintainer" inference to PRs merged into
105
+ // the default branch. Null until a sync populates it.
106
+ defaultBranch: text('default_branch'),
107
+ // The viewer's permission on the repo (GraphQL Repository.viewerPermission,
108
+ // enum RepositoryPermission: ADMIN/MAINTAIN/WRITE/TRIAGE/READ), captured each
109
+ // activity sync. Drives whether the viewer may approve a PR (WRITE+). Null
110
+ // until a sync populates it (or when GitHub returns null).
111
+ viewerPermission: text('viewer_permission'),
112
+ backfillUntil: integer('backfill_until', { mode: 'timestamp' }),
113
+ // ---- Default-branch status ("is trunk green?") ----
114
+ // A snapshot of the repo's DEFAULT BRANCH head, distinct from `defaultBranch` above
115
+ // (which is just the name and has been here since the maintainer inference). All four
116
+ // are nullable — a freshly added repo has none until the branch sync runs, and rendering
117
+ // "unknown" is correct there. `defaultBranchName` is deliberately separate from
118
+ // `defaultBranch` rather than reusing it: the latter is written by the ACTIVITY sync from
119
+ // GraphQL defaultBranchRef.name, and conflating the two would make either sync silently
120
+ // clobber the other's freshness expectations.
121
+ defaultBranchName: text('default_branch_name'),
122
+ defaultBranchHeadSha: text('default_branch_head_sha'),
123
+ // Same enum as pullRequests.ciStatus (the shared CiStatus union).
124
+ defaultBranchCiStatus: text('default_branch_ci_status', {
125
+ enum: ['success', 'failure', 'pending', 'error', 'expected', 'unknown'],
126
+ }),
127
+ // When the branch snapshot was last refreshed (OUR observation time, not the commit time
128
+ // — that lives on the branch_commits rows).
129
+ defaultBranchUpdatedAt: integer('default_branch_updated_at', { mode: 'timestamp' }),
130
+ // When the repo was added to this account. LOAD-BEARING beyond bookkeeping: it is My Turn's
131
+ // clock. An open, non-draft PR by a human other than you qualifies for the "New PRs" section
132
+ // only when `openedAt >= createdAt`, so adding a repo with 400 open PRs does not dump them
133
+ // all into My Turn on day one. (It replaced `inbox_watch_started_at`, dropped in 0046 along
134
+ // with the whole "watched" concept — a Workspace IS the scope now, and a second visibility
135
+ // axis on top of it was just confusing.)
136
+ createdAt: integer('created_at', { mode: 'timestamp' })
137
+ .notNull()
138
+ .default(sql `(unixepoch())`),
139
+ }, (t) => ({
140
+ // Composite uniques so two accounts can watch the same GitHub repo (each
141
+ // gets its own row). The upsert conflict targets these.
142
+ ownerNameUx: uniqueIndex('repos_account_owner_name').on(t.accountId, t.owner, t.name),
143
+ nodeUx: uniqueIndex('repos_account_node').on(t.accountId, t.githubNodeId),
144
+ accountIdx: index('repos_account_idx').on(t.accountId),
145
+ // NOT a lookup index — `id` is already the primary key, so this is redundant for reads. It
146
+ // exists solely as the PARENT KEY of `workspace_repos`' composite FK
147
+ // `(repo_id, account_id) → repos(id, account_id)`, which is what makes tenancy structural for
148
+ // a table whose repo ids arrive in a request body. Both dialects require a unique index
149
+ // over the parent key columns before such an FK is legal (Postgres accepts a plain unique
150
+ // index — it does NOT need a named UNIQUE constraint; verified on 16.13). Drop it and the FK
151
+ // becomes unexpressible, so a cross-account write goes back to being one handler's predicate.
152
+ // `workspaces_id_account` is the exact same trick for the workspace half of those FKs.
153
+ idAccountUx: uniqueIndex('repos_id_account').on(t.id, t.accountId),
154
+ }));
155
+ export const users = sqliteTable('users', {
156
+ id: integer('id').primaryKey({ autoIncrement: true }),
157
+ githubLogin: text('github_login').notNull().unique(),
158
+ githubNodeId: text('github_node_id').unique(),
159
+ displayName: text('display_name'),
160
+ avatarUrl: text('avatar_url'),
161
+ isBot: integer('is_bot', { mode: 'boolean' }).notNull().default(false),
162
+ // Set when a user toggles is_bot by hand; auto-detection won't override it.
163
+ isBotOverridden: integer('is_bot_overridden', { mode: 'boolean' })
164
+ .notNull()
165
+ .default(false),
166
+ // GitHub GraphQL __typename of the actor ('Bot' | 'User' | 'Organization' | …),
167
+ // captured during sync. Plain text (no enum — the __typename set varies). Feeds the
168
+ // bot-triage classifier (a 'Bot' typename is a hard automated-reviewer signal).
169
+ // Nullable; stays GLOBAL like the rest of `users`.
170
+ githubType: text('github_type'),
171
+ // The GitHub App slug behind this actor's comments (`performed_via_github_app.slug`,
172
+ // REST-only), captured by the app-attribution probe (sync/app-attribution.ts) which used
173
+ // to receive and DISCARD it. A bot's App identity is a global fact about the actor, so it
174
+ // lives on the global `users` table — the advisor's discovery tier splits App-authored
175
+ // from Actions-authored comments on it. Nullable: null = never observed (a PAT/OAuth
176
+ // poster has no app), and an observed slug is never cleared by a later app-less comment.
177
+ appSlug: text('app_slug'),
178
+ });
179
+ export const pullRequests = sqliteTable('pull_requests', {
180
+ id: integer('id').primaryKey({ autoIncrement: true }),
181
+ githubNodeId: text('github_node_id').notNull(),
182
+ // Denormalized tenant owner (== repos.accountId) so list/feed isolation is a
183
+ // single indexed predicate instead of a join to repos.
184
+ accountId: integer('account_id')
185
+ .notNull()
186
+ .references(() => accounts.id),
187
+ repoId: integer('repo_id')
188
+ .notNull()
189
+ .references(() => repos.id),
190
+ number: integer('number').notNull(),
191
+ title: text('title').notNull(),
192
+ body: text('body'),
193
+ authorId: integer('author_id').references(() => users.id),
194
+ // Who actually merged the PR (GraphQL `mergedBy`), distinct from the author.
195
+ // Drives the "has merge rights / maintainer" inference. Null for non-merged
196
+ // PRs and until a (deep) sync backfills it on already-synced merged PRs.
197
+ mergedById: integer('merged_by_id').references(() => users.id),
198
+ // The branch this PR targets (GraphQL `baseRefName`). The maintainer
199
+ // inference only counts merges into the repo's default branch, so a merge
200
+ // into a feature/integration branch doesn't elevate the merger. Null until a
201
+ // (deep) sync backfills it on already-synced PRs.
202
+ baseRefName: text('base_ref_name'),
203
+ // The PR's SOURCE branch (GraphQL `headRefName`), e.g. `feature/PROJ-123-foo`. The standard
204
+ // carrier of a Jira/Linear ticket key — read by the Pro ticket-link enricher (compute-on-read).
205
+ // Null until a sync backfills it on already-synced PRs.
206
+ headRefName: text('head_ref_name'),
207
+ state: text('state', { enum: ['open', 'merged', 'closed'] }).notNull(),
208
+ isDraft: integer('is_draft', { mode: 'boolean' }).notNull().default(false),
209
+ openedAt: integer('opened_at', { mode: 'timestamp' }).notNull(),
210
+ firstReviewAt: integer('first_review_at', { mode: 'timestamp' }),
211
+ // Earliest review-request time (first ReviewRequestedEvent), null if never requested — the
212
+ // clock start for "review pickup time" (request→first review). Distinct from openedAt.
213
+ firstReviewRequestedAt: integer('first_review_requested_at', { mode: 'timestamp' }),
214
+ // When this PR's review-request HISTORY (`review_request_events`, migration 0066) was last
215
+ // written from a response that CARRIED the selection. NULL = we have never received it, which
216
+ // Chronology reports as "not known" rather than as "never requested" — the two read the same
217
+ // in the events table and must not on screen. Also the backfill's worklist predicate.
218
+ reviewRequestsSyncedAt: integer('review_requests_synced_at', { mode: 'timestamp' }),
219
+ lastCommitAt: integer('last_commit_at', { mode: 'timestamp' }),
220
+ mergedAt: integer('merged_at', { mode: 'timestamp' }),
221
+ closedAt: integer('closed_at', { mode: 'timestamp' }),
222
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
223
+ // ---- v1.1: CI / mergeability / labels (head-commit derived) ----
224
+ headSha: text('head_sha'),
225
+ ciStatus: text('ci_status', {
226
+ enum: ['success', 'failure', 'pending', 'error', 'expected', 'unknown'],
227
+ }),
228
+ mergeable: text('mergeable', {
229
+ enum: ['mergeable', 'conflicting', 'unknown'],
230
+ }),
231
+ mergeStateStatus: text('merge_state_status', {
232
+ enum: [
233
+ 'clean',
234
+ 'dirty',
235
+ 'unstable',
236
+ 'blocked',
237
+ 'behind',
238
+ 'has_hooks',
239
+ 'unknown',
240
+ ],
241
+ }),
242
+ // GitHub's overall review decision (GraphQL PullRequest.reviewDecision, lowercased).
243
+ // `mergeStateStatus: 'blocked'` says protection is unmet but never WHY; this names the
244
+ // review half of that ('review_required' / 'changes_requested'), which is what lets the
245
+ // merge verdict render a reason instead of a shrug. Null when the repo requires no
246
+ // review, or until a sync backfills it on already-synced PRs.
247
+ reviewDecision: text('review_decision', {
248
+ enum: ['approved', 'changes_requested', 'review_required'],
249
+ }),
250
+ // ---- GitHub's native merge QUEUE, as a SYNCED fact ----
251
+ //
252
+ // ⚠ NULL MEANS "NOT OBSERVED", NOT "NOT QUEUED". Both columns stay null on every PR
253
+ // synced before they landed, and on any walk whose response did not carry the selection.
254
+ // `false` is a positive statement from GitHub; null is the absence of one, and no surface
255
+ // may read the two as the same thing. sync/upsert.ts owns that three-state write.
256
+ //
257
+ // WHY THIS HAD TO BECOME STORED. GitHub's MergeStateStatus enum has NO QUEUED member
258
+ // (github/mutations.ts records the same fact where it explains why the queue verbs fork to
259
+ // GraphQL), so a queued PR reports its underlying state ('clean' and 'unknown' have both been
260
+ // seen on live queued PRs) and nothing in that status says "queued". That was survivable
261
+ // while the only reader was the click-gated merge control, which fetches live. The Pending
262
+ // board is NOT allowed to fetch on mount (fifty cards resolving their own state is ~150
263
+ // GitHub calls to paint a screen), so a card can only know a PR is queued if a column says
264
+ // so.
265
+ inMergeQueue: integer('in_merge_queue', { mode: 'boolean' }),
266
+ // GitHub's MergeQueueEntryState, lowercased like every other stored enum on this table.
267
+ // Null when the PR has no entry (a positive `inMergeQueue: false` clears it) and null when
268
+ // nothing was observed — same rule as above.
269
+ //
270
+ // `unmergeable` is the value that earns this column: GitHub EJECTS an entry whose checks
271
+ // failed, so without it a PR silently disappears from the queue and the reader is told
272
+ // nothing. With it, "it was removed from the queue" is a sentence someone can act on.
273
+ //
274
+ // The volatile halves of the entry — position and estimatedTimeToMerge — are deliberately
275
+ // NOT here and stay live-only in GET /api/prs/:id/merge-options. A stored position is
276
+ // wrong the moment it is written (the queue reorders as other PRs land), and nothing but
277
+ // the merge control renders either one.
278
+ mergeQueueEntryState: text('merge_queue_entry_state', {
279
+ enum: ['awaiting_checks', 'locked', 'mergeable', 'queued', 'unmergeable'],
280
+ }),
281
+ labels: text('labels', { mode: 'json' }).$type(),
282
+ // Per-job CI checks on the head commit (CheckRuns + StatusContexts).
283
+ checkRuns: text('check_runs', { mode: 'json' }).$type(),
284
+ // ---- Diff size (GraphQL additions/deletions/changedFiles + files connection) ----
285
+ // Small metadata (not bulky text), so ALWAYS stored — independent of lean mode —
286
+ // and served straight from the DB for the PR-detail LOC label + "Changes" tab.
287
+ additions: integer('additions').notNull().default(0),
288
+ deletions: integer('deletions').notNull().default(0),
289
+ changedFiles: integer('changed_files').notNull().default(0),
290
+ // Per-file breakdown (capped at 100 files by the sync query). Nullable; the
291
+ // API resolves it to [] and computes each file's GitHub deep link on read.
292
+ files: text('files', { mode: 'json' }).$type(),
293
+ // ---- BLAST RADIUS: what the code churn CONSISTS of (migration 0064) ----
294
+ // 'comments' | 'formatting' | 'code', read from the DIFF — see db/change-shape.ts. Knowing
295
+ // this needs a GitHub call per PR, so it is fetched only for the narrow population where it
296
+ // could change the level (measured: 1.7% of open PRs). NULL is "we did not look" and is the
297
+ // common case BY DESIGN; it must never be read as 'code' or as trivial.
298
+ contentKind: text('content_kind', { enum: ['comments', 'formatting', 'code'] }),
299
+ // The head sha the classification was computed against. ⚠ LOAD-BEARING: a diff read is only
300
+ // true of the commit it read. A PR that has since been pushed to carries a `content_kind`
301
+ // describing code that is no longer there, so every reader compares this against `head_sha`
302
+ // and treats a mismatch as NULL. Storing the verdict without the sha is how a stale
303
+ // "comments only" survives a force-push that added a schema change.
304
+ contentKindSha: text('content_kind_sha'),
305
+ // ---- DEPENDENCY + SECURITY SIGNALS (migration 0067) — read from the PR's own title / branch /
306
+ // labels / FULL bodyText at sync (sync/security-detect.ts). ⚠ Derived, small, NO BODIES (lean
307
+ // storage). ⚠ All four are written together or not at all: a response that did not carry
308
+ // `bodyText` (null/undefined) writes NOTHING; a classified PR writes all four, NULLs included —
309
+ // a positive statement. `security_checked_at` NULL is "never classified" and is the backfill's
310
+ // worklist (sync/backfill-pr-security.ts).
311
+ // The tool whose OWN marker is on the PR (a `DependencyMarkerVendor`). CONTENT ONLY, never
312
+ // the author login — a Snyk fix opened under a person's token still carries Snyk's marker.
313
+ dependencyVendor: text('dependency_vendor'),
314
+ // 'proven' = the tool's own security marker; 'inferred' = Dependabot's ecosystem-named group
315
+ // with its truncation note. NULL = not a known-advisory fix, including every ordinary bump
316
+ // whose release notes quote a CVE.
317
+ securityFix: text('security_fix', { enum: ['proven', 'inferred'] }),
318
+ // Canonical advisory ids named by the vendor-selected fields; NULL when there are none.
319
+ advisoryIds: text('advisory_ids', { mode: 'json' }).$type(),
320
+ securityCheckedAt: integer('security_checked_at', { mode: 'timestamp' }),
321
+ }, (t) => ({
322
+ repoIdx: index('pr_repo_idx').on(t.repoId),
323
+ openedIdx: index('pr_opened_idx').on(t.openedAt),
324
+ accountIdx: index('pr_account_idx').on(t.accountId),
325
+ // Drives getUserStats (the contributor popover) from the person rather than the account.
326
+ authorIdx: index('pr_author_idx').on(t.authorId),
327
+ // Serves resolving a PR NUMBER to a local id within a repo — the branch strip's
328
+ // commit → PR link, which looks up a batch of numbers on a route the Activity console
329
+ // hits on every mount. Without it the planner narrows to the repo via `pr_repo_idx` and
330
+ // then SCANS that repo's whole PR set, so the cost tracks tenant size rather than the
331
+ // handful of numbers asked for (the same trap the author indexes fixed).
332
+ accountRepoNumberIdx: index('pr_account_repo_number_idx').on(t.accountId, t.repoId, t.number),
333
+ nodeUx: uniqueIndex('pr_account_node').on(t.accountId, t.githubNodeId),
334
+ // NOT a lookup index (`id` is the primary key). It exists solely as the PARENT KEY of
335
+ // `my_turn_dismissals`' composite FK `(pr_id, account_id) → pull_requests(id, account_id)` —
336
+ // the `repos_id_account` trick, for a PR id that arrives in a request path.
337
+ idAccountUx: uniqueIndex('pull_requests_id_account').on(t.id, t.accountId),
338
+ }));
339
+ // Outstanding review requests on a PR. GitHub removes a request once the
340
+ // reviewer submits, so presence here == still awaiting. Re-derived each sync
341
+ // (delete + reinsert per PR). `userId` null for team requests (teamName set).
342
+ export const reviewRequests = sqliteTable('review_requests', {
343
+ id: integer('id').primaryKey({ autoIncrement: true }),
344
+ prId: integer('pr_id')
345
+ .notNull()
346
+ .references(() => pullRequests.id),
347
+ userId: integer('user_id').references(() => users.id),
348
+ teamName: text('team_name'),
349
+ }, (t) => ({
350
+ prIdx: index('rr_pr_idx').on(t.prId),
351
+ userIdx: index('rr_user_idx').on(t.userId),
352
+ }));
353
+ // ── REVIEW-REQUEST HISTORY (migration 0066 / pg 0053) ────────────────────────────────────────
354
+ // Every ReviewRequestedEvent / ReviewRequestRemovedEvent on a PR's timeline — who was asked (a
355
+ // person, or a team), when, and when a request was withdrawn. `review_requests` above holds only
356
+ // the OUTSTANDING requests and GitHub drops a request from it the moment the person reviews, so
357
+ // "was a person or a team asked, and how long until anyone looked" was unanswerable for a merged
358
+ // PR. This table is that history. Read by Chronology (`db/pr-intervals.ts` + `db/flow-detail.ts`).
359
+ //
360
+ // A PR CHILD, like `review_requests`: it reaches its account through `pr_id` and carries no
361
+ // `account_id`, so it is deleted by `deletePrSubtree` and `deleteRepo` (both — a child table missed
362
+ // by either FK-fails that sweep), and the account erasure reaches it through the repo loop.
363
+ //
364
+ // Immutable once written: GitHub's events do not change, so the upsert is DO NOTHING on the
365
+ // node id. The first 25 events per PR are kept (the GraphQL selection's cap): the question these
366
+ // answer is about the FIRST request, which is always inside it.
367
+ export const reviewRequestEvents = sqliteTable('review_request_events', {
368
+ id: integer('id').primaryKey({ autoIncrement: true }),
369
+ prId: integer('pr_id')
370
+ .notNull()
371
+ .references(() => pullRequests.id),
372
+ githubNodeId: text('github_node_id').notNull(),
373
+ kind: text('kind', { enum: ['requested', 'removed'] }).notNull(),
374
+ occurredAt: integer('occurred_at', { mode: 'timestamp' }).notNull(),
375
+ // 'user' covers GitHub Users AND Bots (a Copilot review request is a Bot): who is a PERSON is
376
+ // decided on READ through the lane resolver, never frozen here. 'unknown' = the token could
377
+ // not see the reviewer (a team needs org visibility) — a request, but not a person or a team.
378
+ reviewerKind: text('reviewer_kind', { enum: ['user', 'team', 'unknown'] }).notNull(),
379
+ reviewerUserId: integer('reviewer_user_id').references(() => users.id),
380
+ teamSlug: text('team_slug'),
381
+ }, (t) => ({
382
+ prNodeUx: uniqueIndex('review_request_events_pr_node').on(t.prId, t.githubNodeId),
383
+ }));
384
+ // Per-PR "last viewed" state for incremental review. One row per PR. Implicitly
385
+ // per-account (prId is account-specific).
386
+ export const prViews = sqliteTable('pr_views', {
387
+ prId: integer('pr_id')
388
+ .primaryKey()
389
+ .references(() => pullRequests.id),
390
+ lastViewedSha: text('last_viewed_sha'),
391
+ lastViewedAt: integer('last_viewed_at', { mode: 'timestamp' }).notNull(),
392
+ });
393
+ export const reviewThreads = sqliteTable('review_threads', {
394
+ id: integer('id').primaryKey({ autoIncrement: true }),
395
+ githubNodeId: text('github_node_id').notNull(),
396
+ prId: integer('pr_id')
397
+ .notNull()
398
+ .references(() => pullRequests.id),
399
+ path: text('path').notNull(),
400
+ line: integer('line'),
401
+ isResolved: integer('is_resolved', { mode: 'boolean' }).notNull(),
402
+ isOutdated: integer('is_outdated', { mode: 'boolean' })
403
+ .notNull()
404
+ .default(false),
405
+ derivedState: text('derived_state', {
406
+ enum: ['resolved', 'likely_addressed', 'replied_unresolved', 'untouched'],
407
+ }).notNull(),
408
+ // Deterministic "how sure are we it was addressed?" grade computed alongside derivedState
409
+ // (sync/derive-thread-state.ts). Additive to the 4-state contract — advisory only.
410
+ addressedConfidence: text('addressed_confidence', {
411
+ enum: ['none', 'low', 'medium', 'high'],
412
+ })
413
+ .notNull()
414
+ .default('none'),
415
+ // Compact machine tag explaining the grade (e.g. 'outdated+commit', 'bot-marker:coderabbit').
416
+ addressedReason: text('addressed_reason'),
417
+ // GitHub login of whoever resolved the thread (from `resolvedBy`), null when unresolved —
418
+ // lets us distinguish a bot self-resolve from a human resolve.
419
+ resolvedByLogin: text('resolved_by_login'),
420
+ // When we FIRST OBSERVED the thread flip unresolved→resolved (sync-observation time, ~5min
421
+ // granularity — GitHub's thread type exposes no resolve timestamp). Stamped only on a witnessed
422
+ // transition; a thread already resolved the first time we see it (backfill) stays null (unknown
423
+ // resolution time). Powers the resolution-latency trend. NOT a lean-gated field.
424
+ resolvedAt: integer('resolved_at', { mode: 'timestamp' }),
425
+ originalCommenterId: integer('original_commenter_id').references(() => users.id),
426
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
427
+ }, (t) => ({
428
+ prIdx: index('thread_pr_idx').on(t.prId),
429
+ nodeUx: uniqueIndex('thread_pr_node').on(t.prId, t.githubNodeId),
430
+ // "Which threads did this actor open?" — the per-repo reviewer FOOTPRINT counts
431
+ // (`RepoReviewerFootprint.threads`, on every bot listing) and migration 0042's backfill CTE
432
+ // both union on this column, and it had no index at all: every one of those was a full scan
433
+ // of `review_threads`. Added in 0042 (pg 0029).
434
+ originalCommenterIdx: index('thread_original_commenter_idx').on(t.originalCommenterId),
435
+ }));
436
+ export const reviewComments = sqliteTable('review_comments', {
437
+ id: integer('id').primaryKey({ autoIncrement: true }),
438
+ githubNodeId: text('github_node_id').notNull(),
439
+ threadId: integer('thread_id')
440
+ .notNull()
441
+ .references(() => reviewThreads.id),
442
+ prId: integer('pr_id')
443
+ .notNull()
444
+ .references(() => pullRequests.id),
445
+ authorId: integer('author_id').references(() => users.id),
446
+ // ALWAYS persisted (the Feed renders full comment markdown) — lean storage stopped
447
+ // gating comment/review bodies; only the PR description, diffHunk, commit message and
448
+ // checkRuns stay hydration-only. Nullable for legacy rows written during the 2026-06
449
+ // lean window (hydrated on demand). `excerpt` keeps a short (~160 char) preview for
450
+ // the lean triage path (getMyTurn) and graceful UI degradation.
451
+ body: text('body'),
452
+ excerpt: text('excerpt'),
453
+ diffHunk: text('diff_hunk'),
454
+ // GitHub numeric id (fullDatabaseId) for the #discussion_r<id> deep link.
455
+ databaseId: text('database_id'),
456
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
457
+ }, (t) => ({
458
+ threadIdx: index('rc_thread_idx').on(t.threadId),
459
+ authorIdx: index('rc_author_idx').on(t.authorId),
460
+ nodeUx: uniqueIndex('rc_pr_node').on(t.prId, t.githubNodeId),
461
+ }));
462
+ export const prComments = sqliteTable('pr_comments', {
463
+ id: integer('id').primaryKey({ autoIncrement: true }),
464
+ githubNodeId: text('github_node_id').notNull(),
465
+ prId: integer('pr_id')
466
+ .notNull()
467
+ .references(() => pullRequests.id),
468
+ authorId: integer('author_id').references(() => users.id),
469
+ // ALWAYS persisted (the Feed renders full markdown) — see reviewComments.body; nullable
470
+ // only for legacy rows from the 2026-06 lean window.
471
+ body: text('body'),
472
+ // GitHub numeric id (fullDatabaseId) for the #issuecomment-<id> deep link.
473
+ databaseId: text('database_id'),
474
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
475
+ }, (t) => ({
476
+ prIdx: index('prc_pr_idx').on(t.prId),
477
+ authorIdx: index('prc_author_idx').on(t.authorId),
478
+ nodeUx: uniqueIndex('prc_pr_node').on(t.prId, t.githubNodeId),
479
+ }));
480
+ export const reviews = sqliteTable('reviews', {
481
+ id: integer('id').primaryKey({ autoIncrement: true }),
482
+ githubNodeId: text('github_node_id').notNull(),
483
+ prId: integer('pr_id')
484
+ .notNull()
485
+ .references(() => pullRequests.id),
486
+ authorId: integer('author_id').references(() => users.id),
487
+ state: text('state', {
488
+ enum: ['approved', 'changes_requested', 'commented', 'dismissed', 'pending'],
489
+ }).notNull(),
490
+ body: text('body'),
491
+ // GitHub numeric id (fullDatabaseId) for the #pullrequestreview-<id> deep link.
492
+ databaseId: text('database_id'),
493
+ submittedAt: integer('submitted_at', { mode: 'timestamp' }).notNull(),
494
+ }, (t) => ({
495
+ prIdx: index('rv_pr_idx').on(t.prId),
496
+ authorIdx: index('rv_author_idx').on(t.authorId),
497
+ nodeUx: uniqueIndex('reviews_pr_node').on(t.prId, t.githubNodeId),
498
+ }));
499
+ export const commits = sqliteTable('commits', {
500
+ id: integer('id').primaryKey({ autoIncrement: true }),
501
+ sha: text('sha').notNull(),
502
+ prId: integer('pr_id')
503
+ .notNull()
504
+ .references(() => pullRequests.id),
505
+ authorId: integer('author_id').references(() => users.id),
506
+ committerId: integer('committer_id').references(() => users.id),
507
+ message: text('message'),
508
+ // GitHub's `messageHeadline` (first line, <= 200 chars). Fetched on EVERY walk, not lean-gated,
509
+ // and written only when received: NULL is "not synced yet". Read by My Turn's "Pushed since"
510
+ // card (migration 0072 / pg 0059).
511
+ messageHeadline: text('message_headline'),
512
+ committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(),
513
+ }, (t) => ({
514
+ prIdx: index('commit_pr_idx').on(t.prId),
515
+ shaPrUx: uniqueIndex('commit_sha_pr_ux').on(t.sha, t.prId),
516
+ }));
517
+ // SHA -> string[] of changed paths. Cached forever (SHAs are immutable). Stays
518
+ // GLOBAL — content-addressed, identical across tenants.
519
+ // ---- BLAST RADIUS: the per-repo CO-CHANGE index (P2) ----
520
+ //
521
+ // "Which files in this repository does everything else change with?" — the cross-file dependency
522
+ // reading the blast-radius fold uses, derived ENTIRELY from data already stored (`pull_requests.
523
+ // files` on MERGED pull requests). A real import graph would need file CONTENTS, i.e. a GitHub
524
+ // fetch per file, and no board is allowed to do that.
525
+ //
526
+ // One row per (account, repo). ⚠ Denormalized `accountId` even though it is reachable via
527
+ // repoId, like every other anchor table — and it MUST therefore be in `accountScopedTables()`.
528
+ // The row is derived, so erasing it loses nothing but a rebuild.
529
+ //
530
+ // ⚠ IT STORES ONLY THE HUBS, not every path. A path missing from `hubs` is below the bar by
531
+ // construction, so the map stays small (measured: 84 paths across 7 real repositories) and no
532
+ // consumer has to hold a 50,000-entry object to ask one question.
533
+ export const repoFileCoupling = sqliteTable('repo_file_coupling', {
534
+ id: integer('id').primaryKey({ autoIncrement: true }),
535
+ accountId: integer('account_id')
536
+ .notNull()
537
+ .references(() => accounts.id),
538
+ repoId: integer('repo_id')
539
+ .notNull()
540
+ .references(() => repos.id),
541
+ // The degree a path had to reach to be stored: max(this repo's p90, HUB_MIN_DEGREE).
542
+ // ⚠ BOTH HALVES ARE LOAD-BEARING. A p90 alone is exceeded by a tenth of paths BY
543
+ // CONSTRUCTION, so a repository with no coupling at all still manufactures "hubs" — measured,
544
+ // a config repo published 77 of them, eight per-environment copies of one service's .env. The
545
+ // absolute floor took that repo to zero while leaving `redis.go` and `bevy_render/src/lib.rs`
546
+ // standing. Stored per row because it is what the number on screen is compared against.
547
+ hubBar: integer('hub_bar').notNull(),
548
+ // Merged pull requests that CONTRIBUTED (after the per-PR file cap). Below the coverage
549
+ // floor no row is written at all — the index stays silent rather than guessing.
550
+ prCount: integer('pr_count').notNull(),
551
+ // path -> co-change degree, for paths at or above `hubBar` only.
552
+ hubs: text('hubs', { mode: 'json' }).$type().notNull(),
553
+ builtAt: integer('built_at', { mode: 'timestamp' })
554
+ .notNull()
555
+ .default(sql `(unixepoch())`),
556
+ }, (t) => ({
557
+ accountRepoUx: uniqueIndex('rfc_account_repo').on(t.accountId, t.repoId),
558
+ }));
559
+ export const commitFiles = sqliteTable('commit_files', {
560
+ sha: text('sha').primaryKey(),
561
+ paths: text('paths', { mode: 'json' }).$type().notNull(),
562
+ fetchedAt: integer('fetched_at', { mode: 'timestamp' })
563
+ .notNull()
564
+ .default(sql `(unixepoch())`),
565
+ });
566
+ // Unified events log -- what the timeline reads.
567
+ export const events = sqliteTable('events', {
568
+ id: integer('id').primaryKey({ autoIncrement: true }),
569
+ // Denormalized tenant owner (== the repo's accountId) — the lean timeline
570
+ // feed filters on this directly.
571
+ accountId: integer('account_id')
572
+ .notNull()
573
+ .references(() => accounts.id),
574
+ repoId: integer('repo_id')
575
+ .notNull()
576
+ .references(() => repos.id),
577
+ actorId: integer('actor_id').references(() => users.id),
578
+ prId: integer('pr_id').references(() => pullRequests.id),
579
+ type: text('type', {
580
+ enum: [
581
+ 'pr_opened',
582
+ 'pr_merged',
583
+ 'pr_closed',
584
+ 'pr_reopened',
585
+ 'pr_ready_for_review',
586
+ 'review_submitted',
587
+ 'review_comment',
588
+ 'pr_comment',
589
+ 'commit_pushed',
590
+ ],
591
+ }).notNull(),
592
+ occurredAt: integer('occurred_at', { mode: 'timestamp' }).notNull(),
593
+ refTable: text('ref_table'),
594
+ refId: integer('ref_id'),
595
+ // Stable identity for idempotent upserts: e.g. "pr_opened:<prNodeId>".
596
+ dedupeKey: text('dedupe_key').notNull(),
597
+ }, (t) => ({
598
+ timeIdx: index('events_time_idx').on(t.occurredAt),
599
+ repoTimeIdx: index('events_repo_time_idx').on(t.repoId, t.occurredAt),
600
+ actorIdx: index('events_actor_idx').on(t.actorId),
601
+ accountIdx: index('events_account_idx').on(t.accountId),
602
+ // Correlated "does this PR have an event since <cutoff>" EXISTS lookups (getWorkspaceInsights
603
+ // open-PR staleness, getOpenPrs, new-since checks, feed joins) filter by pr_id — WITHOUT
604
+ // this they fall back to events_account_idx and scan every account event per PR (O(open PRs
605
+ // × events)). Composite so the occurred_at bound resolves inside the index too.
606
+ prTimeIdx: index('events_pr_idx').on(t.prId, t.occurredAt),
607
+ // Composite so the same dedupeKey can exist once per account (two accounts
608
+ // watching the same repo share GitHub node ids). Upsert conflict target.
609
+ dedupeUx: uniqueIndex('events_account_dedupe').on(t.accountId, t.dedupeKey),
610
+ }));
611
+ // ---- CI status history (DORA-ish CI metrics) ----
612
+ // Append-only log of a PR head's CI-state TRANSITIONS, recorded during sync when a PR's
613
+ // CI rollup / failing-check set / head SHA changes vs the last row. The current
614
+ // pull_requests.ciStatus is only a snapshot (and checkRuns is lean-gated), so this table
615
+ // is what makes real CI failure-RESOLUTION time + failure-reason-by-stage-over-time
616
+ // computable. `failingChecks` = the names of the checks failing at that observation (the
617
+ // stage-level reasons). `observedAt` is when WE saw it (may lag the actual CI event by up
618
+ // to a sync interval).
619
+ export const ciStatusEvents = sqliteTable('ci_status_events', {
620
+ id: integer('id').primaryKey({ autoIncrement: true }),
621
+ accountId: integer('account_id')
622
+ .notNull()
623
+ .references(() => accounts.id),
624
+ repoId: integer('repo_id')
625
+ .notNull()
626
+ .references(() => repos.id),
627
+ prId: integer('pr_id')
628
+ .notNull()
629
+ .references(() => pullRequests.id),
630
+ headSha: text('head_sha').notNull(),
631
+ status: text('status', {
632
+ enum: ['success', 'failure', 'pending', 'error', 'expected', 'unknown'],
633
+ }).notNull(),
634
+ failingChecks: text('failing_checks', { mode: 'json' }).$type(),
635
+ observedAt: integer('observed_at', { mode: 'timestamp' }).notNull(),
636
+ }, (t) => ({
637
+ accountPrObservedIdx: index('cse_account_pr_observed').on(t.accountId, t.prId, t.observedAt),
638
+ accountRepoObservedIdx: index('cse_account_repo_observed').on(t.accountId, t.repoId, t.observedAt),
639
+ }));
640
+ // Append-only AI-spend ledger. Every billable AI operation (LLM completion or Agent-SDK
641
+ // run) records ONE row here, so month-to-date usage can be summed across features — the
642
+ // per-feature cost columns (claude_reviews / repo_digests / sprint_reports / ai_* in the
643
+ // Pro submodule) upsert-overwrite and can't be summed. Surfaced ONLY as credits (never
644
+ // dollars) via /api/pro/ai-usage. `seam` splits summary (cheap completions) vs agent
645
+ // (Agent-SDK runs). No FK on pr_id/repo_id — the ledger must survive PR/repo pruning.
646
+ export const aiUsage = sqliteTable('ai_usage', {
647
+ id: integer('id').primaryKey({ autoIncrement: true }),
648
+ accountId: integer('account_id')
649
+ .notNull()
650
+ .references(() => accounts.id),
651
+ seam: text('seam', { enum: ['summary', 'agent'] }).notNull(),
652
+ // Which feature spent it: digest / sprint_report / ai_analysis / ci_analysis /
653
+ // claude_review / ai_fix. Free-text so a new feature needs no migration.
654
+ feature: text('feature').notNull(),
655
+ model: text('model').notNull(),
656
+ costUsd: real('cost_usd').notNull(),
657
+ inputTokens: integer('input_tokens'),
658
+ outputTokens: integer('output_tokens'),
659
+ prId: integer('pr_id'),
660
+ repoId: integer('repo_id'),
661
+ occurredAt: integer('occurred_at', { mode: 'timestamp' })
662
+ .notNull()
663
+ .default(sql `(unixepoch())`),
664
+ }, (t) => ({
665
+ accountOccurredIdx: index('au_account_occurred').on(t.accountId, t.occurredAt),
666
+ }));
667
+ // ---- Auto-merge intents ("arm it and walk away") ----------------------------------------
668
+ // One row per (account, PR) recording a STANDING INTENT to merge once the blockers clear.
669
+ // Pierre-side, not GitHub's native auto-merge, so it works on repos that don't enable it and
670
+ // can offer a rebase-from-trunk step GitHub can't.
671
+ //
672
+ // The safety property lives in `expectedHeadOid`: arming is consent to merge THAT code. A new
673
+ // push moves the head, the watcher notices the mismatch, and the row goes to
674
+ // 'disarmed_head_moved' rather than merging commits the user never looked at. `expiresAt` is
675
+ // the second backstop — an intent that never becomes mergeable dies rather than lingering.
676
+ //
677
+ // FKs cascade so a repo/PR delete (deleteRepo) and an account erasure clean up automatically.
678
+ export const autoMergeRequests = sqliteTable('auto_merge_requests', {
679
+ id: integer('id').primaryKey({ autoIncrement: true }),
680
+ accountId: integer('account_id')
681
+ .notNull()
682
+ .references(() => accounts.id, { onDelete: 'cascade' }),
683
+ prId: integer('pr_id')
684
+ .notNull()
685
+ .references(() => pullRequests.id, { onDelete: 'cascade' }),
686
+ // shared MergeMethod: 'merge' | 'squash' | 'rebase'.
687
+ mergeMethod: text('merge_method', {
688
+ enum: ['merge', 'squash', 'rebase'],
689
+ }).notNull(),
690
+ // Whether to bring a merely-behind branch up to date first, and how. 'none' = wait.
691
+ updateStrategy: text('update_strategy', {
692
+ enum: ['rebase', 'merge', 'none'],
693
+ }).notNull(),
694
+ // The head SHA at arming time — the consent anchor (see above).
695
+ expectedHeadOid: text('expected_head_oid').notNull(),
696
+ // ---- TWO CONSENT/BOOKKEEPING ANCHORS THAT USED TO LIVE IN PROCESS MEMORY ----------------
697
+ //
698
+ // Both were module-level Maps in `merge/auto-merge-runner.ts`. A Map is fine for a hint; it
699
+ // is NOT fine for a fact the watcher DISARMS on, because a restart turns "I know why this
700
+ // happened" into "something unexplained happened" — and the safe direction for an unexplained
701
+ // head move is to disarm. Under `pnpm dev` that is every file save; in cloud it is every
702
+ // deploy.
703
+ //
704
+ // The base branch the user was looking at when they armed. The head pin cannot see a
705
+ // retarget (PATCH pulls/{n} with a new `base` leaves head.sha alone), so the watcher needs a
706
+ // second anchor — and it was reading the SYNCED `pull_requests.base_ref_name` at RUN time,
707
+ // which is not what the user consented to: any sync that corrects that column between arming
708
+ // and a tick reads as a retarget and disarms. NULL on rows armed before this column existed;
709
+ // the guard falls back to the synced ref for those, exactly as before.
710
+ expectedBaseRef: text('expected_base_ref'),
711
+ // The head SHA an in-flight `update-branch` was issued AGAINST. GitHub's update-branch
712
+ // returns 202 with no handle to poll, so the resulting head move lands on a later tick and
713
+ // `isOurUpdateMerge` needs this to tell it apart from a human push. NULL means no update is
714
+ // in flight — which is also the honest answer after a restart that happened before one.
715
+ updateIssuedAgainstOid: text('update_issued_against_oid'),
716
+ // Set at arm time when the base branch had a merge queue: the watcher's terminal action
717
+ // is then "add to the queue", not a direct merge (GitHub refuses PUT .../merge on a
718
+ // queue-protected branch). Re-checked live each tick — a queue disabled after arming
719
+ // falls back to the direct merge.
720
+ viaMergeQueue: integer('via_merge_queue', { mode: 'boolean' }).notNull().default(false),
721
+ // When the WATCHER added the PR to the merge queue; null until then (and always null for
722
+ // direct-merge intents). Load-bearing for attribution: a PR that merges while this is set
723
+ // resolves 'merged' (the watcher's doing — the toast fires); one a human queued resolves
724
+ // 'disarmed_blocked' like any outside merge.
725
+ enqueuedAt: integer('enqueued_at', { mode: 'timestamp' }),
726
+ // shared ArmedMergeState.
727
+ state: text('state', {
728
+ enum: [
729
+ 'armed',
730
+ 'merged',
731
+ 'disarmed_head_moved',
732
+ 'disarmed_blocked',
733
+ 'expired',
734
+ 'failed',
735
+ ],
736
+ }).notNull(),
737
+ armedAt: integer('armed_at', { mode: 'timestamp' }).notNull(),
738
+ expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
739
+ lastCheckedAt: integer('last_checked_at', { mode: 'timestamp' }),
740
+ // Why it is in its current state ('required reviews missing', 'head moved abc→def').
741
+ lastReason: text('last_reason'),
742
+ // shared ArmedMergePhase — the MACHINE-READABLE half of `lastReason`, written by the same
743
+ // watcher call that writes the prose so the two can never disagree. It describes a LIVE
744
+ // intent only: every terminal outcome is already a `state` member, and duplicating them
745
+ // here would give a finished card two sources of truth, so the watcher nulls the phase on
746
+ // every resolve. Null also means "we can't honestly characterise this wait" (an unknown
747
+ // merge state, an unconfirmable base) — the client falls back to `lastReason` there.
748
+ phase: text('phase', {
749
+ enum: [
750
+ 'pending_first_check',
751
+ 'waiting_conflicts',
752
+ 'waiting_behind',
753
+ 'updating_rebase',
754
+ 'updating_merge',
755
+ 'awaiting_checks',
756
+ 'awaiting_review',
757
+ 'blocked_protection',
758
+ 'enqueuing',
759
+ 'queued',
760
+ // Limn's own per-repo hold — NOT GitHub's merge queue. See ArmedMergePhase in shared.
761
+ 'queued_local',
762
+ 'merging',
763
+ 'retrying',
764
+ ],
765
+ }),
766
+ createdAt: integer('created_at', { mode: 'timestamp' })
767
+ .notNull()
768
+ .default(sql `(unixepoch())`),
769
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
770
+ .notNull()
771
+ .default(sql `(unixepoch())`),
772
+ }, (t) => ({
773
+ // One armed request per PR per tenant — the upsert conflict target. Re-arming a PR
774
+ // OVERWRITES the previous row (including a terminal one), so history is not kept here;
775
+ // an armed intent is current state, not a log.
776
+ accountPrUx: uniqueIndex('amr_account_pr').on(t.accountId, t.prId),
777
+ accountIdx: index('amr_account_idx').on(t.accountId),
778
+ // The watcher's scan: "every still-armed row", cheapest as an indexed state probe.
779
+ stateIdx: index('amr_state_idx').on(t.state),
780
+ }));
781
+ // ---- Default-branch commits ("is trunk green?") -----------------------------------------
782
+ // The recent commits on each repo's DEFAULT branch, with the CI state observed for each. Not
783
+ // derivable from `commits`, which is PR-scoped (a commit merged via squash never appears
784
+ // there under its trunk SHA). Small and bounded: the branch sync keeps only a recent window
785
+ // per repo. `accountId` is denormalized for isolation exactly like events/pullRequests.
786
+ //
787
+ // `authorUserId` is a nullable link to the global `users` row when the commit author maps to
788
+ // a GitHub account; `authorName`/`authorAvatarUrl` carry the raw git identity for the many
789
+ // trunk commits (merge commits, bot pushes, non-GitHub emails) that don't.
790
+ export const branchCommits = sqliteTable('branch_commits', {
791
+ id: integer('id').primaryKey({ autoIncrement: true }),
792
+ accountId: integer('account_id')
793
+ .notNull()
794
+ .references(() => accounts.id, { onDelete: 'cascade' }),
795
+ repoId: integer('repo_id')
796
+ .notNull()
797
+ .references(() => repos.id, { onDelete: 'cascade' }),
798
+ sha: text('sha').notNull(),
799
+ messageHeadline: text('message_headline').notNull(),
800
+ authorUserId: integer('author_user_id').references(() => users.id),
801
+ authorName: text('author_name'),
802
+ authorAvatarUrl: text('author_avatar_url'),
803
+ committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(),
804
+ // Same enum as pullRequests.ciStatus; nullable because a just-pushed commit has no
805
+ // status yet (distinct from an explicit 'unknown').
806
+ ciStatus: text('ci_status', {
807
+ enum: ['success', 'failure', 'pending', 'error', 'expected', 'unknown'],
808
+ }),
809
+ // The checks that were FAILING on this commit (state 'failure' | 'error' only) — never the
810
+ // passing contexts, so a green commit stores NULL and its row stays exactly as small as it
811
+ // is today. Same object the PR checks UI renders, plus the workflow name, so the two
812
+ // surfaces speak ONE vocabulary.
813
+ //
814
+ // NOT lean-gated (unlike pullRequests.checkRuns, which is): this is names-only metadata,
815
+ // capped per commit by the writer, and there is NO hydrate-on-demand path for a trunk
816
+ // commit — it belongs to no PR, so gating it would simply delete the feature in cloud
817
+ // rather than make it lazy. Follows the ci_status_events.failing_checks precedent, which is
818
+ // written unconditionally for the same reason.
819
+ //
820
+ // NOTE: the SAME column name as ci_status_events.failing_checks but a DIFFERENT shape (that
821
+ // one is string[] — bare names for the CI metrics log; this one is the full render payload).
822
+ // The $type<>() makes the difference a compile-time fact at every call site.
823
+ failingChecks: text('failing_checks', { mode: 'json' }).$type(),
824
+ // The PR this commit landed from (GraphQL Commit.associatedPullRequests), or null for a
825
+ // direct push to trunk. Stored as a plain NUMBER, deliberately NOT a pull_requests FK:
826
+ // (a) the PR is often not synced when the commit is observed (squash-merged before the
827
+ // backfill window, or the repo was added later),
828
+ // (b) a stored id would go stale the moment that PR's subtree is re-synced, and
829
+ // (c) a real FK would drag this table into BOTH delete paths.
830
+ // The read layer resolves the number to a local id per request, scoped by
831
+ // (accountId, repoId) — a PR number is unique only WITHIN a repo.
832
+ prNumber: integer('pr_number'),
833
+ createdAt: integer('created_at', { mode: 'timestamp' })
834
+ .notNull()
835
+ .default(sql `(unixepoch())`),
836
+ }, (t) => ({
837
+ // Idempotent upsert target — a re-sync of the same window must update CI state in place,
838
+ // not duplicate the commit.
839
+ accountRepoShaUx: uniqueIndex('bc_account_repo_sha').on(t.accountId, t.repoId, t.sha),
840
+ // The read: one repo's window, newest first.
841
+ accountRepoTimeIdx: index('bc_account_repo_time').on(t.accountId, t.repoId, t.committedAt),
842
+ accountIdx: index('bc_account_idx').on(t.accountId),
843
+ }));
844
+ // ---- Default-branch ("trunk") CI TRANSITION log ----
845
+ // The trunk twin of `ciStatusEvents`, and it exists for exactly the reason that table does:
846
+ // `branch_commits.ciStatus` is UPDATED IN PLACE by the branch snapshot's upsert, so a commit
847
+ // that turns red hours after it landed carries no record of WHEN it turned red — the only
848
+ // timestamps on that row are `committedAt` (git commit time) and `createdAt` (first insertion).
849
+ // Presenting either as "trunk CI failed at" would be a quiet lie, so the observation gets its
850
+ // own append-only row here.
851
+ //
852
+ // Written by `sync/branch-status.ts` at the end of every repo walk, ONLY on a transition (the
853
+ // status / head sha / failing-check name set differs from this repo's last row) and ONLY on a
854
+ // POSITIVE statement from GitHub — an `unknown` rollup, which is also what `graphqlTolerant`
855
+ // yields when a partial response NULLs the selection, records nothing. Strictly non-fatal: a
856
+ // failure here must never cost the branch snapshot that just succeeded.
857
+ //
858
+ // `observedAt` is when WE saw it, never GitHub's completion time (the branch query selects no
859
+ // `completedAt`), so any UI wording must say "detected", not "failed at".
860
+ //
861
+ // Bounded by its OWN per-repo trim, not by the retention sweep: `pruneOldData` anchors everything
862
+ // to a parent PR's `updatedAt`, and a trunk row has no PR. ⚠ That trim is HYBRID, like
863
+ // `branch_commits`' — the newest TRUNK_CI_EVENT_WINDOW rows ∪ everything inside FEED_WINDOW_DAYS
864
+ // (`staleTrunkCiEventIds`) — because a pure count bound silently evicted the failure rows the Feed
865
+ // reads on repos that sync faster than the read window elapses, and a pure age bound would wipe a
866
+ // dormant repo's whole log. FKs CASCADE so a repo delete / account erasure cleans up regardless
867
+ // (both also delete explicitly, so the guarantee never rests on `foreign_keys=ON`).
868
+ //
869
+ // NOTE: `failing_checks` is `BranchCheckRun[]` here — the SAME shape as `branch_commits`
870
+ // (the trunk vocabulary), and deliberately NOT the bare `string[]` of `ci_status_events`.
871
+ // The $type<>() makes the difference a compile-time fact at every call site.
872
+ export const trunkCiStatusEvents = sqliteTable('trunk_ci_status_events', {
873
+ id: integer('id').primaryKey({ autoIncrement: true }),
874
+ accountId: integer('account_id')
875
+ .notNull()
876
+ .references(() => accounts.id, { onDelete: 'cascade' }),
877
+ repoId: integer('repo_id')
878
+ .notNull()
879
+ .references(() => repos.id, { onDelete: 'cascade' }),
880
+ // The default branch this observation is about, as GitHub named it at the time. Nullable
881
+ // because a partial response can null `defaultBranchRef.name` while still carrying the
882
+ // target's oid + rollup — the branch NAME is display sugar, the sha is the identity.
883
+ branchName: text('branch_name'),
884
+ headSha: text('head_sha').notNull(),
885
+ status: text('status', {
886
+ enum: ['success', 'failure', 'pending', 'error', 'expected', 'unknown'],
887
+ }).notNull(),
888
+ failingChecks: text('failing_checks', { mode: 'json' }).$type(),
889
+ observedAt: integer('observed_at', { mode: 'timestamp' }).notNull(),
890
+ }, (t) => ({
891
+ // The read (the Feed builder) and the trim: one repo's log, newest first.
892
+ accountRepoObservedIdx: index('tcse_account_repo_observed').on(t.accountId, t.repoId, t.observedAt),
893
+ accountIdx: index('tcse_account_idx').on(t.accountId),
894
+ }));
895
+ export const syncState = sqliteTable('sync_state', {
896
+ repoId: integer('repo_id')
897
+ .primaryKey()
898
+ .references(() => repos.id),
899
+ lastFullSyncAt: integer('last_full_sync_at', { mode: 'timestamp' }),
900
+ lastIncrementalSyncAt: integer('last_incremental_sync_at', {
901
+ mode: 'timestamp',
902
+ }),
903
+ lastSyncStatus: text('last_sync_status'),
904
+ lastSyncError: text('last_sync_error'),
905
+ });
906
+ // ---- Claude Review (agentic PR review) ----
907
+ // One row per review run. Re-reviewing a PR inserts a new row (history kept,
908
+ // keyed by head SHA via cr_pr_sha_idx). Claude's summary/verdict are read-only
909
+ // reference; the user authors `userBody`/`userVerdict` which is what gets posted.
910
+ // Local-only feature (force-disabled in cloud), but `accountId` is stamped for
911
+ // consistency (always the local account).
912
+ export const claudeReviews = sqliteTable('claude_reviews', {
913
+ id: integer('id').primaryKey({ autoIncrement: true }),
914
+ accountId: integer('account_id')
915
+ .notNull()
916
+ .references(() => accounts.id),
917
+ prId: integer('pr_id')
918
+ .notNull()
919
+ .references(() => pullRequests.id),
920
+ headSha: text('head_sha').notNull(),
921
+ status: text('status', {
922
+ enum: ['queued', 'running', 'succeeded', 'failed', 'cancelled'],
923
+ }).notNull(),
924
+ // The model id the run was stored with. Deliberately NO drizzle `enum:` — the column has
925
+ // always been plain text in the database (no CHECK, no pg enum), and a TS-only enum made the
926
+ // row type lie about rows from a RETIRED model (the old Opus 4.8's runs are still here). The
927
+ // offered list lives once, in packages/shared `CLAUDE_REVIEW_MODELS`, which the generate route
928
+ // validates against; retiring a model no longer edits either schema. Twin: schema.pg.ts.
929
+ model: text('model').notNull(),
930
+ // Null until the agent decides whether it explored the worktree.
931
+ scope: text('scope', { enum: ['diff_only', 'worktree'] }),
932
+ // The deterministic router's decision (skip/diff_only/worktree) + its inputs,
933
+ // recorded BEFORE the agent runs (or when the user forces a mode). `scope` above
934
+ // is the AGENT's self-report; a row with reviewMode 'diff_only' but scope
935
+ // 'worktree' is the agent flagging it needed a deeper review. Null on pre-routing
936
+ // rows. See review/routing.ts.
937
+ reviewMode: text('review_mode', { enum: ['skip', 'diff_only', 'worktree'] }),
938
+ routeReason: text('route_reason', { mode: 'json' }).$type(),
939
+ // Claude's output (read-only; never edited in place).
940
+ summary: text('summary'),
941
+ verdict: text('verdict', {
942
+ enum: ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'],
943
+ }),
944
+ // The user-authored review body + verdict that actually get posted.
945
+ userBody: text('user_body'),
946
+ userVerdict: text('user_verdict', {
947
+ enum: ['COMMENT', 'REQUEST_CHANGES', 'APPROVE'],
948
+ }),
949
+ costUsd: real('cost_usd'),
950
+ inputTokens: integer('input_tokens'),
951
+ outputTokens: integer('output_tokens'),
952
+ // Cache-token split (a multi-turn run's input is mostly cache reads — the hidden
953
+ // cost driver the plain input_tokens column hid). Null when not captured.
954
+ cacheReadTokens: integer('cache_read_tokens'),
955
+ cacheCreationTokens: integer('cache_creation_tokens'),
956
+ numTurns: integer('num_turns'),
957
+ // Full (noise-stripped) diff size in chars + whether the diff-size cap truncated
958
+ // the prompt — for A/B cost comparison of capped vs uncapped runs.
959
+ diffBytes: integer('diff_bytes'),
960
+ diffCapped: integer('diff_capped', { mode: 'boolean' }),
961
+ error: text('error'),
962
+ // Noise files (lockfiles/generated) stripped from the diff before review.
963
+ excludedFiles: text('excluded_files', { mode: 'json' }).$type(),
964
+ // GitHub review id + when it was posted (null until posted).
965
+ postedReviewId: text('posted_review_id'),
966
+ postedAt: integer('posted_at', { mode: 'timestamp' }),
967
+ createdAt: integer('created_at', { mode: 'timestamp' })
968
+ .notNull()
969
+ .default(sql `(unixepoch())`),
970
+ finishedAt: integer('finished_at', { mode: 'timestamp' }),
971
+ // The optional user stories the run was given — stored at QUEUE time so a failed or
972
+ // cancelled run still prefills the panel. An ARRAY since several tickets (no migration: a run
973
+ // from before stored ONE object, and readers go through shared `storedList`). Null when none
974
+ // was given. Migration 0070 (pg 0057).
975
+ ticket: text('ticket', { mode: 'json' }).$type(),
976
+ // The server-validated assessment against `ticket` — an ARRAY index-aligned with it (one
977
+ // object on older runs). Each element also holds its `posted` record once that ticket's
978
+ // analysis was posted as a PR comment. Null without a ticket or on a run that did not succeed.
979
+ ticketAssessment: text('ticket_assessment', { mode: 'json' }).$type(),
980
+ // What this run found about the PREVIOUS succeeded review's findings (addressed / partly /
981
+ // not / no longer applies / not checked), with the prior review id + head. Null when there was
982
+ // no earlier review with findings to check.
983
+ followUp: text('follow_up', { mode: 'json' }).$type(),
984
+ // WHO STARTED THE RUN: 'manual' (a click) or 'auto' (the Pro per-workspace auto-review sweeper,
985
+ // for a newly opened human PR). Core because `getUnactionedClaudeReviews` carries it to the My
986
+ // Turn card's "Auto review" label (an auto run is owned like a manual one). Migration 0071
987
+ // (pg 0058); existing rows are 'manual'.
988
+ trigger: text('trigger', { enum: ['manual', 'auto'] }).notNull().default('manual'),
989
+ // Every OTHER open review thread on the PR (people and other review bots — never Limn's own
990
+ // posted findings, which the follow-up covers) and what the run found: is the comment right,
991
+ // and has the code dealt with it. Null on runs from before the field, or a run that did not
992
+ // succeed / skipped. Migration 0076 (pg 0063).
993
+ threadAssessments: text('thread_assessments', { mode: 'json' }).$type(),
994
+ // THE COMMENT HALF OF THE AUTO RE-REVIEW KEY: the newest qualifying review-thread comment (not
995
+ // Limn's own) this run saw when it loaded the threads. A newer one re-triggers an auto review on
996
+ // the same head. Null ⇒ read the row's `created_at` (older rows, or a run that failed before
997
+ // loading threads). Migration 0076 (pg 0063).
998
+ commentsThrough: integer('comments_through', { mode: 'timestamp' }),
999
+ // FAILED CI ON THE REVIEWED HEAD: the head's CI state when the run looked and, per failing
1000
+ // check, Claude's diagnosis (or the server's 'not_checked' and why). Null on runs from before
1001
+ // the field, a skip, a run that did not succeed, or one whose checks could not be read.
1002
+ // Migration 0077 (pg 0064).
1003
+ ciFailures: text('ci_failures', { mode: 'json' }).$type(),
1004
+ }, (t) => ({
1005
+ prIdx: index('cr_pr_idx').on(t.prId),
1006
+ prShaIdx: index('cr_pr_sha_idx').on(t.prId, t.headSha),
1007
+ accountIdx: index('cr_account_idx').on(t.accountId),
1008
+ // Parent key of `claude_review_chat_messages`' composite tenancy FK (migration 0073 / pg
1009
+ // 0060). `id` is the primary key, so this is never a lookup index.
1010
+ idAccountUx: uniqueIndex('claude_reviews_id_account').on(t.id, t.accountId),
1011
+ }));
1012
+ // One row per line-level finding. Claude's wording is read-only; only `included`
1013
+ // (the user's tick) mutates. `anchored` false ⇒ couldn't map onto an addable diff
1014
+ // line, so it can't post as an inline comment.
1015
+ export const claudeReviewFindings = sqliteTable('claude_review_findings', {
1016
+ id: integer('id').primaryKey({ autoIncrement: true }),
1017
+ reviewId: integer('review_id')
1018
+ .notNull()
1019
+ .references(() => claudeReviews.id),
1020
+ path: text('path').notNull(),
1021
+ line: integer('line'),
1022
+ side: text('side', { enum: ['LEFT', 'RIGHT'] })
1023
+ .notNull()
1024
+ .default('RIGHT'),
1025
+ severity: text('severity', {
1026
+ enum: ['blocker', 'warning', 'nit', 'question', 'praise'],
1027
+ }).notNull(),
1028
+ title: text('title').notNull(),
1029
+ body: text('body').notNull(),
1030
+ // The user's reworded version of the finding (markdown). When set and the
1031
+ // finding is included, this is posted verbatim instead of `body`.
1032
+ editedBody: text('edited_body'),
1033
+ suggestion: text('suggestion'),
1034
+ // The unified-diff hunk this finding covers, captured at review time, to show
1035
+ // the code in context in the UI. Null for older runs / unanchored findings.
1036
+ diffHunk: text('diff_hunk'),
1037
+ anchored: integer('anchored', { mode: 'boolean' }).notNull().default(true),
1038
+ // Whether the finding's file is part of the PR diff. true ⇒ an unanchored finding
1039
+ // posts inline on the file's first change; false ⇒ outside the diff → posts as a
1040
+ // standalone PR-level comment. Defaults true (back-compat: pre-existing findings).
1041
+ fileInDiff: integer('file_in_diff', { mode: 'boolean' }).notNull().default(true),
1042
+ included: integer('included', { mode: 'boolean' }).notNull().default(false),
1043
+ postedAt: integer('posted_at', { mode: 'timestamp' }),
1044
+ githubCommentId: text('github_comment_id'),
1045
+ // How a posted comment was attached: 'inline' (a review comment on a diff line)
1046
+ // or 'pr_comment' (a standalone PR-level issue comment, for an unanchored
1047
+ // finding posted individually). Null until posted; drives the correct GitHub
1048
+ // permalink (#discussion_r vs #issuecomment).
1049
+ postedCommentKind: text('posted_comment_kind', {
1050
+ enum: ['inline', 'pr_comment'],
1051
+ }),
1052
+ createdAt: integer('created_at', { mode: 'timestamp' })
1053
+ .notNull()
1054
+ .default(sql `(unixepoch())`),
1055
+ // When this finding RE-RAISES a still-open finding from the previous review: that finding's
1056
+ // id. ⚠ A SOFT REFERENCE, NO FK, ON PURPOSE: it always points at a finding of the SAME PR, and
1057
+ // retention.ts + deleteRepo (queries.ts) delete a PR's findings in ONE
1058
+ // `inArray(reviewId, …)` statement — an FK would add delete-ordering risk for no safety the
1059
+ // writer (the plugin, which only links ids it just loaded for this PR) does not already give.
1060
+ // Migration 0070 (pg 0057).
1061
+ priorFindingId: integer('prior_finding_id'),
1062
+ // The specialist angle that raised it on a DEEP (worktree) review — `ClaudeFindingLens`:
1063
+ // 'design' | 'tests' | 'impact' | 'accessibility' | 'security' | 'performance'. NULL is a
1064
+ // general finding (every diff-only finding and every row before migration 0075 / pg 0062).
1065
+ lens: text('lens'),
1066
+ // A STORY FINDING: made by the server from the run's user-story assessment (claude-review/
1067
+ // ticket.ts `storyFindingsFrom`), never by the model. `story_index` is the ticket's 0-based
1068
+ // position on the run; `story_ref` the criterion's ref ('AC2') or a not-done item's ('M1').
1069
+ // Both NULL on an ordinary finding and on every row before migration 0079 / pg 0066. Read
1070
+ // together: a row has both or neither.
1071
+ storyIndex: integer('story_index'),
1072
+ storyRef: text('story_ref'),
1073
+ }, (t) => ({ reviewIdx: index('crf_review_idx').on(t.reviewId) }));
1074
+ // ---- Claude Review chat (CORE table, written by the Pro plugin) ----
1075
+ // One row per chat MESSAGE about one succeeded review: a general thread (`finding_id` NULL) and
1076
+ // one thread per finding. The transcript a model sees is REBUILT SERVER-SIDE from these rows on
1077
+ // every turn, so a client can never inject a prior "assistant" turn. Chat is never carried across
1078
+ // reviews: a new review of the same PR starts empty, and an older review's threads stay with it.
1079
+ // ⚠ BOTH DELETE PATHS (deleteRepo in queries.ts, deletePrSubtree in retention.ts) delete these
1080
+ // rows BEFORE the findings and runs they reference, plus eraseAccountData + accountScopedTables().
1081
+ // `review_id` arrives in a request PATH, so tenancy is STRUCTURAL: a composite FK against
1082
+ // claude_reviews(id, account_id). Migration 0073 (pg 0060). Twin: schema.pg.ts.
1083
+ export const claudeReviewChatMessages = sqliteTable('claude_review_chat_messages', {
1084
+ id: integer('id').primaryKey({ autoIncrement: true }),
1085
+ accountId: integer('account_id')
1086
+ .notNull()
1087
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1088
+ reviewId: integer('review_id').notNull(),
1089
+ // NULL = the review's general thread; else the finding this thread is about (a finding of
1090
+ // THIS review — the route checks it before any write).
1091
+ findingId: integer('finding_id').references(() => claudeReviewFindings.id, {
1092
+ onDelete: 'cascade',
1093
+ }),
1094
+ role: text('role', { enum: ['user', 'assistant'] }).notNull(),
1095
+ content: text('content').notNull(),
1096
+ // Assistant rows only: the model that answered, what the turn cost, its tokens.
1097
+ model: text('model'),
1098
+ costUsd: real('cost_usd'),
1099
+ inputTokens: integer('input_tokens'),
1100
+ outputTokens: integer('output_tokens'),
1101
+ createdAt: integer('created_at', { mode: 'timestamp' })
1102
+ .notNull()
1103
+ .default(sql `(unixepoch())`),
1104
+ }, (t) => ({
1105
+ threadIdx: index('crcm_thread_idx').on(t.reviewId, t.findingId, t.id),
1106
+ accountIdx: index('crcm_account_idx').on(t.accountId),
1107
+ reviewAccountFk: foreignKey({
1108
+ name: 'crcm_review_account_fk',
1109
+ columns: [t.reviewId, t.accountId],
1110
+ foreignColumns: [claudeReviews.id, claudeReviews.accountId],
1111
+ }).onDelete('cascade'),
1112
+ }));
1113
+ // ---- AI Fix (CORE since migration 0074 / pg 0061) ----
1114
+ // Created by the private plugin (plugin migrations 0002 / 0003 / 0024) and ADOPTED IN PLACE by
1115
+ // 0074's CREATE … IF NOT EXISTS — same name, same columns, same index names, and NO foreign keys
1116
+ // (SQLite cannot add one to an existing table without a rebuild, so a fresh and an adopted install
1117
+ // would differ). Tenancy is the query layer's `account_id` predicate plus verify:isolation; no id
1118
+ // arrives in a request body. Both delete paths (deleteRepo, retention's deletePrSubtree) and
1119
+ // eraseAccountData + accountScopedTables() cover it. (Its sibling, review memory's
1120
+ // `review_learnings`, was DROPPED by 0075 / pg 0062.)
1121
+ //
1122
+ // aiFixes — one agentic fix run (history kept; a re-run is a new row). ⚠ An ADOPTED table may
1123
+ // carry eight more columns than this declares (plugin 0003's `resolved_*` / `resolve_error`, the
1124
+ // removed rebase artifact). Drizzle names only what it knows, so they are never read or written.
1125
+ export const aiFixes = sqliteTable('ai_fixes', {
1126
+ id: integer('id').primaryKey({ autoIncrement: true }),
1127
+ accountId: integer('account_id').notNull(),
1128
+ repoId: integer('repo_id').notNull(),
1129
+ prId: integer('pr_id').notNull(),
1130
+ // The Claude review this fix was seeded from, if any.
1131
+ sourceReviewId: integer('source_review_id'),
1132
+ // The commit the patch applies onto (the live PR head at generate time).
1133
+ baseSha: text('base_sha').notNull(),
1134
+ // 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
1135
+ status: text('status').notNull(),
1136
+ model: text('model').notNull(),
1137
+ // AiFixSeed: 'ci_analysis' | 'review' | 'plain' | 'comments'
1138
+ seed: text('seed').notNull(),
1139
+ prompt: text('prompt'),
1140
+ summary: text('summary'),
1141
+ commitMessage: text('commit_message'),
1142
+ // The captured unified-diff patch (includes new files; binary-safe).
1143
+ patch: text('patch'),
1144
+ // JSON string[] of changed paths.
1145
+ filesChanged: text('files_changed'),
1146
+ costUsd: real('cost_usd'),
1147
+ inputTokens: integer('input_tokens'),
1148
+ outputTokens: integer('output_tokens'),
1149
+ numTurns: integer('num_turns'),
1150
+ error: text('error'),
1151
+ pushedBranch: text('pushed_branch'),
1152
+ pushedPrNumber: integer('pushed_pr_number'),
1153
+ pushedPrUrl: text('pushed_pr_url'),
1154
+ pushedAt: integer('pushed_at', { mode: 'timestamp' }),
1155
+ createdAt: integer('created_at', { mode: 'timestamp' })
1156
+ .notNull()
1157
+ .default(sql `(unixepoch())`),
1158
+ finishedAt: integer('finished_at', { mode: 'timestamp' }),
1159
+ // LEGACY: written only by the REMOVED 'comments' seed (the picked-comments basket). Kept so old
1160
+ // rows keep their data; nothing reads or writes them now.
1161
+ commentTargets: text('comment_targets'),
1162
+ commentVerdicts: text('comment_verdicts'),
1163
+ // 0078. Who started the run ('manual' | 'auto'); NULL on older rows reads as 'manual'.
1164
+ trigger: text('trigger'),
1165
+ // 0078. seed === 'review': JSON AiFixReviewItem[] — the items the run was given, refs and all,
1166
+ // written at insert (`included: false` = left out for the prompt budget). NULL otherwise.
1167
+ reviewItems: text('review_items'),
1168
+ // 0078. JSON AiFixChangeReport — the agent's per-change report, validated (refs checked
1169
+ // against the shown set, text clipped). Written on success; NULL before and on older rows.
1170
+ changeReport: text('change_report'),
1171
+ }, (t) => ({
1172
+ prCreatedIdx: index('af_account_pr_created').on(t.accountId, t.prId, t.createdAt),
1173
+ statusIdx: index('af_account_status').on(t.accountId, t.status),
1174
+ }));
1175
+ // ---- Workspaces (CORE) ----
1176
+ // A named grouping of an account's repos, and the ONE scope this app has. Exactly one row per
1177
+ // account carries is_default: it is auto-created, RENAMEABLE, NOT deletable, and new repos land
1178
+ // in it. Everything the old `teams` table did, minus the many-to-many and minus the four scope
1179
+ // sentinels ('all' / 'none' / 'teams' / a set) that made "which repos am I looking at" a
1180
+ // five-branch question with three independent parsers.
1181
+ export const workspaces = sqliteTable('workspaces', {
1182
+ id: integer('id').primaryKey({ autoIncrement: true }),
1183
+ accountId: integer('account_id')
1184
+ .notNull()
1185
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1186
+ name: text('name').notNull(),
1187
+ // Exactly one true row per account. ensureDefaultWorkspace() is the ONLY writer of `true`;
1188
+ // createWorkspace always writes false and deleteWorkspace refuses a true row. That invariant
1189
+ // is ALSO enforced by a PARTIAL UNIQUE INDEX created in the .sql migrations (not here — see
1190
+ // the note under the table), so two concurrent ensureDefaultWorkspace() calls cannot both
1191
+ // insert.
1192
+ isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false),
1193
+ // ── THE WORKSPACE HALF OF THE PENDING MUTE (migration 0058 / pg 0045) ────────────────────
1194
+ // TRUE ⇒ every repo in this workspace stops claiming the reader's turn: `getMyTurn` forces
1195
+ // `relevance: 'none'` (hence `personal: false`) on every one of its rows, so the Pending card
1196
+ // relabels to the neutral "Review or reply", the browser notification stops firing and every
1197
+ // `myTurnPersonal` figure drops it — while the row itself, and the broad `myTurn` count,
1198
+ // are untouched. Muting routes work; it never deletes it.
1199
+ //
1200
+ // ⚠ IT IS NOT THE `repos.inbox_watch` AXIS THAT MIGRATION 0046 DROPPED. That column decided
1201
+ // whether a repo's work was VISIBLE at all, which made "which of the two is this screen
1202
+ // obeying — membership or watch?" a live question on every surface. This one changes no
1203
+ // screen's population: Feed, Timeline, Activity, Bots and the Pending board all still cover
1204
+ // a muted repo in full, and the ONE thing that moves is the OWNERSHIP CLAIM a card makes and
1205
+ // the counts that claim feeds. There is still exactly one visibility axis, and it is
1206
+ // membership.
1207
+ //
1208
+ // ⚠ A UNION WITH `pending_muted_repos`, NOT A PARENT OF IT. See that table.
1209
+ pendingMuted: integer('pending_muted', { mode: 'boolean' }).notNull().default(false),
1210
+ // ── CHRONOLOGY'S WORKING HOURS AND WAIT BUDGETS (migration 0065 / pg 0052) ──────────────
1211
+ // OVERRIDES ONLY: NULL until someone changes something, then just the fields they changed.
1212
+ // Every reader resolves through `resolveFlowSettings` (packages/shared/src/flow-settings.ts),
1213
+ // so a later change to a product default reaches every workspace that never overrode it —
1214
+ // the `accounts.blast_radius_config` precedent. Per WORKSPACE, because two teams in two
1215
+ // zones in one account work different hours.
1216
+ flowSettings: text('flow_settings', { mode: 'json' }).$type(),
1217
+ // ── AUTO CLAUDE REVIEW (migration 0074 / pg 0061; moved off the plugin's
1218
+ // pro_workspace_settings, whose columns are dormant now) ─────────────────────────────────
1219
+ // NULL/false = off. `autoReviewEnabledAt` is when it was last switched ON — the floor the
1220
+ // sweeper reviews from (only PRs OPENED at or after it), so a first sync, a backfill or a new
1221
+ // repo can never make an old PR look new. Re-stamped on every off -> on; cleared on off. The
1222
+ // ONE writer is `setWorkspaceAutoReview` (review/claude-review/auto-settings.ts).
1223
+ autoReviewEnabled: integer('auto_review_enabled', { mode: 'boolean' }),
1224
+ autoReviewEnabledAt: integer('auto_review_enabled_at', { mode: 'timestamp' }),
1225
+ createdAt: integer('created_at', { mode: 'timestamp' })
1226
+ .notNull()
1227
+ .default(sql `(unixepoch())`),
1228
+ }, (t) => ({
1229
+ accountNameUx: uniqueIndex('workspaces_account_name').on(t.accountId, t.name),
1230
+ accountIdx: index('workspaces_account_idx').on(t.accountId),
1231
+ // NOT a lookup index — `id` is already the PK. It is the PARENT KEY of the composite FKs on
1232
+ // workspace_repos and workspace_reviewers, exactly as `repos_id_account` is for those tables'
1233
+ // repo FKs. Both dialects require a unique index over the parent-key columns before such an
1234
+ // FK is legal (Postgres accepts a plain unique index; it does not need a named constraint).
1235
+ // Drop it and both composite FKs become unexpressible.
1236
+ idAccountUx: uniqueIndex('workspaces_id_account').on(t.id, t.accountId),
1237
+ }));
1238
+ // THE PARTIAL UNIQUE INDEX ON `is_default` LIVES IN THE MIGRATIONS, NOT HERE. Drizzle index
1239
+ // predicates are DDL-only metadata that nothing in this repo consumes (core sqlite migrations are
1240
+ // hand-written and the pg baseline is not regenerated for this change), so declaring it in the
1241
+ // table config above would buy nothing — but the CONSTRAINT ITSELF is required, because
1242
+ // `ensureDefaultWorkspace` runs on effectively every request and two concurrent calls for an
1243
+ // account with no default would otherwise both SELECT nothing and both INSERT. Both dialects
1244
+ // enforce it, one literal apart:
1245
+ // sqlite (0044): CREATE UNIQUE INDEX … `workspaces_one_default` ON `workspaces` (`account_id`)
1246
+ // WHERE `is_default` = 1;
1247
+ // postgres (0031): CREATE UNIQUE INDEX … workspaces_one_default ON workspaces (account_id)
1248
+ // WHERE is_default;
1249
+ // With it in place, `ensureDefaultWorkspace` is `INSERT … ON CONFLICT DO NOTHING` then re-SELECT
1250
+ // — the loser of a race reads the winner's row instead of 500ing.
1251
+ // EXACTLY ONE WORKSPACE PER REPO, AS A DATABASE FACT. The unique on (account_id, repo_id) is what
1252
+ // makes that structural: assigning a repo elsewhere is an UPSERT on that key, i.e. a MOVE, and no
1253
+ // code path can produce a second membership row. `repo_id` alone would do (repos.id is a global
1254
+ // PK) — account_id rides in the key so the isolation predicate and the conflict target are the
1255
+ // same columns, the same discipline the old repo_reviewers used.
1256
+ //
1257
+ // WHY A TABLE AND NOT A `repos.workspace_id` COLUMN. SQLite cannot ADD a CONSTRAINT to an
1258
+ // existing table and cannot cheaply make an existing column NOT NULL; a NOT NULL FK column on
1259
+ // `repos` therefore means rebuilding `repos` (create-copy-drop-rename) under `foreign_keys=ON`,
1260
+ // with every child FK in the schema pointing at it mid-flight. That is a large, irreversible
1261
+ // risk for a column, and it is exactly why 0042 created `repo_reviewers` fresh rather than
1262
+ // altering anything. A join table arrives fully constrained on day one.
1263
+ //
1264
+ // THE RESIDUAL RISK, and it is real: a repo with NO membership row is invisible to every
1265
+ // workspace-scoped read. It is closed on both sides —
1266
+ // • WRITE: sync/upsert.ts `upsertRepo` inserts the membership row in the SAME runTransaction
1267
+ // as the repo row, ON CONFLICT (account_id, repo_id) DO NOTHING so re-adding an existing repo
1268
+ // never moves it out of the workspace a human put it in.
1269
+ // • READ: `ensureRepoMemberships(accountId)` (queries.ts) diffs the account's repo ids against
1270
+ // its membership repo ids IN JS (the portable anti-join `getUnassignedRepoIds` already used)
1271
+ // and inserts the missing ones into Default. It is called from `listWorkspaces` and from
1272
+ // `resolveWorkspaceScope`, i.e. on every page load and every scoped request.
1273
+ // ⚠ IT IS A WRITE ON ESSENTIALLY EVERY GET, so the insert MUST carry
1274
+ // `ON CONFLICT (account_id, repo_id) DO NOTHING` (concurrent requests WILL race the unique).
1275
+ // It writes the membership row and NOTHING else — there is no second visibility column left
1276
+ // to touch (`repos.inbox_watch` was dropped in 0046), which is one of the things that made
1277
+ // collapsing to a single axis worth doing: a repair pass can no longer have a side effect.
1278
+ //
1279
+ // THE WORKSPACE CASCADE IS A SAFETY NET, NOT THE DELETE PATH. `deleteWorkspace` MOVES the
1280
+ // workspace's repos to Default inside its transaction BEFORE deleting the row, so the cascade
1281
+ // finds nothing to do. It exists so account erasure and a hand-run `DELETE FROM workspaces`
1282
+ // cannot leave orphan membership rows.
1283
+ export const workspaceRepos = sqliteTable('workspace_repos', {
1284
+ id: integer('id').primaryKey({ autoIncrement: true }),
1285
+ accountId: integer('account_id')
1286
+ .notNull()
1287
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1288
+ // No single-column FKs: both are the COMPOSITE declarations below.
1289
+ workspaceId: integer('workspace_id').notNull(),
1290
+ repoId: integer('repo_id').notNull(),
1291
+ createdAt: integer('created_at', { mode: 'timestamp' })
1292
+ .notNull()
1293
+ .default(sql `(unixepoch())`),
1294
+ }, (t) => ({
1295
+ // One workspace per repo, structurally. Also the upsert conflict target for
1296
+ // assignReposToWorkspace.
1297
+ accountRepoUx: uniqueIndex('workspace_repos_account_repo').on(t.accountId, t.repoId),
1298
+ accountWorkspaceIdx: index('workspace_repos_account_workspace_idx').on(t.accountId, t.workspaceId),
1299
+ repoIdx: index('workspace_repos_repo_idx').on(t.repoId),
1300
+ // Tenancy as a constraint. `workspaceId` arrives in a REQUEST BODY, so a plain
1301
+ // `REFERENCES workspaces(id)` would accept (account 2, workspace 10) where workspace 10
1302
+ // belongs to account 1 — both halves individually valid. NAMED so Postgres quotes it in the
1303
+ // violation message and a grep for the name finds a live constraint. (SQLite parses and
1304
+ // stores the name but never reports it — its error is the bare "FOREIGN KEY constraint
1305
+ // failed" and `PRAGMA foreign_key_list` has no name column — so on that side it is
1306
+ // documentation that at least matches the stored DDL.)
1307
+ workspaceAccountFk: foreignKey({
1308
+ name: 'workspace_repos_workspace_account_fk',
1309
+ columns: [t.workspaceId, t.accountId],
1310
+ foreignColumns: [workspaces.id, workspaces.accountId],
1311
+ }).onDelete('cascade'),
1312
+ repoAccountFk: foreignKey({
1313
+ name: 'workspace_repos_repo_account_fk',
1314
+ columns: [t.repoId, t.accountId],
1315
+ foreignColumns: [repos.id, repos.accountId],
1316
+ }).onDelete('cascade'),
1317
+ }));
1318
+ // ---- The REPO half of the Pending mute (CORE, free) — migration 0058 / pg 0045 ----
1319
+ //
1320
+ // PRESENCE IS THE FACT: one row per (account, repo) the reader has muted for Pending, no
1321
+ // `muted` boolean and no "considered, not muted" row, so every reader is a single indexed
1322
+ // existence check. The effective answer for a repo is
1323
+ //
1324
+ // muted == its workspace's `workspaces.pending_muted` OR a row here
1325
+ //
1326
+ // ⚠ TWO INDEPENDENT FACTS, OR-ed — NOT AN INHERITANCE CHAIN, and the difference is the whole
1327
+ // design. `null`-means-inherit is a named bug class here (`workspace_reviewers.monthly_cents`,
1328
+ // the Slack target, the sprint cadence): it needs a resolver, and "which grain am I reading?"
1329
+ // becomes a question every call site answers separately. There is no resolver — there is one
1330
+ // union, computed once in db/pending-mute.ts. Un-muting a workspace does not un-mute a repo the
1331
+ // user muted by name, and muting a workspace does not overwrite (or silently adopt) the repo
1332
+ // rows underneath it.
1333
+ //
1334
+ // ⚠ NO `workspace_id` COLUMN, DELIBERATELY. A repo belongs to EXACTLY ONE workspace
1335
+ // (`workspace_repos`, UNIQUE (account_id, repo_id)); copying that fact onto this row would give
1336
+ // the account two answers to "which workspace is this repo in", and A FACT LIVES AT EXACTLY ONE
1337
+ // GRAIN. The consequence is intended: moving a repo to another workspace carries its mute with
1338
+ // it, which is the honest reading of "I muted that repository". The Settings screen scopes the
1339
+ // LIST it shows by membership; the stored fact stays repo-grained.
1340
+ //
1341
+ // ⚠ THIS IS NOT THE DROPPED `repos.inbox_watch` AXIS (migration 0046), and it is not a column on
1342
+ // `repos` for exactly that reason. `inbox_watch` decided whether a repo's work was VISIBLE, i.e.
1343
+ // a SECOND scope competing with membership. Nothing here changes any screen's population: a
1344
+ // muted repo is fully live on Feed, Timeline, Activity, Bots and the Pending board. What changes
1345
+ // is whether its rows may CLAIM THE READER'S TURN and interrupt them.
1346
+ export const pendingMutedRepos = sqliteTable('pending_muted_repos', {
1347
+ id: integer('id').primaryKey({ autoIncrement: true }),
1348
+ accountId: integer('account_id')
1349
+ .notNull()
1350
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1351
+ // No single-column FK: it is the COMPOSITE declaration below.
1352
+ repoId: integer('repo_id').notNull(),
1353
+ createdAt: integer('created_at', { mode: 'timestamp' })
1354
+ .notNull()
1355
+ .default(sql `(unixepoch())`),
1356
+ }, (t) => ({
1357
+ // One row per muted repo — and the upsert conflict target for the settings write.
1358
+ accountRepoUx: uniqueIndex('pending_muted_repos_account_repo').on(t.accountId, t.repoId),
1359
+ // Tenancy as a constraint, not a convention: `repoId` arrives in a REQUEST BODY, so a plain
1360
+ // `REFERENCES repos(id)` would accept (account 2, repo 10) where repo 10 belongs to account
1361
+ // 1 — both halves individually valid. NAMED, and the hand-written migrations spell the same
1362
+ // name; Postgres quotes it in the violation message, SQLite stores it but never reports it.
1363
+ repoAccountFk: foreignKey({
1364
+ name: 'pending_muted_repos_repo_account_fk',
1365
+ columns: [t.repoId, t.accountId],
1366
+ foreignColumns: [repos.id, repos.accountId],
1367
+ }).onDelete('cascade'),
1368
+ }));
1369
+ // ---- My Turn dismissals (CORE, free, both modes) ----
1370
+ // "Take this off my plate for now." One row per dismissed SUBJECT — a pull request, or a repo for a
1371
+ // red default branch — never per card: a PR can hold several My Turn jobs, and dismissing the one
1372
+ // on screen must not surface the next. Read ONLY inside `getMyTurn` (db/my-turn-dismissals.ts).
1373
+ //
1374
+ // ⚠ NOT THE TABLE MIGRATION 0060 DROPPED, though it has the same name. That one backed a "Done"
1375
+ // button that NEVER EXPIRED — a PR whose ball came back stayed hidden behind it for weeks. This one
1376
+ // holds one timestamp and the fold does the rest: an item whose clock is LATER than `dismissed_at`
1377
+ // shows again, and a subject with no My Turn item at all (acted on, closed, request withdrawn)
1378
+ // discharges its row, so the next summons starts fresh. It never outlives the thing it dismissed.
1379
+ //
1380
+ // Exactly one of `pr_id` / `repo_id` is set (the writer's rule; both routes name one). Both are
1381
+ // COMPOSITE FKs against `(id, account_id)`, so a cross-account pair fails in the database.
1382
+ export const myTurnDismissals = sqliteTable('my_turn_dismissals', {
1383
+ id: integer('id').primaryKey({ autoIncrement: true }),
1384
+ accountId: integer('account_id')
1385
+ .notNull()
1386
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1387
+ // No single-column FKs: both are the COMPOSITE declarations below.
1388
+ prId: integer('pr_id'),
1389
+ repoId: integer('repo_id'),
1390
+ dismissedAt: integer('dismissed_at', { mode: 'timestamp' }).notNull(),
1391
+ }, (t) => ({
1392
+ // The upsert targets — one row per subject. NULLs are distinct in both dialects, so a trunk row
1393
+ // (pr_id NULL) never collides with another trunk row on the PR index, and vice versa.
1394
+ accountPrUx: uniqueIndex('my_turn_dismissals_account_pr').on(t.accountId, t.prId),
1395
+ accountRepoUx: uniqueIndex('my_turn_dismissals_account_repo').on(t.accountId, t.repoId),
1396
+ prAccountFk: foreignKey({
1397
+ name: 'my_turn_dismissals_pr_account_fk',
1398
+ columns: [t.prId, t.accountId],
1399
+ foreignColumns: [pullRequests.id, pullRequests.accountId],
1400
+ }).onDelete('cascade'),
1401
+ repoAccountFk: foreignKey({
1402
+ name: 'my_turn_dismissals_repo_account_fk',
1403
+ columns: [t.repoId, t.accountId],
1404
+ foreignColumns: [repos.id, repos.accountId],
1405
+ }).onDelete('cascade'),
1406
+ }));
1407
+ // ---- Bot-Triage Platform (WS1 / WS6) ----
1408
+ // THE BOT OBJECT: one row per (account, WORKSPACE, actor). It replaces BOTH `repo_reviewers`
1409
+ // (the judgement, per repo) and `account_reviewers` (identity + price, per account).
1410
+ //
1411
+ // WHY ONE TABLE NOW. The two-table split existed because judgement and identity lived at
1412
+ // DIFFERENT grains: "not a bot on web" had to not blank CodeRabbit's brand colour on api and
1413
+ // infra. With one workspace as the only scope, both facts are about the same key — so a second
1414
+ // table would key on the identical three columns and be joined at every call site, i.e. this
1415
+ // table with extra steps. "CodeRabbit in 6 repos of a workspace" is ONE row: one judgement, one
1416
+ // price, one brand colour.
1417
+ //
1418
+ // ⚠ WHAT MUST NOT BE LOST WITH THE SPLIT: the two PROVENANCE FLAGS stay separate columns and are
1419
+ // honoured INDEPENDENTLY. `source` owns automated/role/confidence/reasons; `identity_source` owns
1420
+ // kind/label. A classification pass that respects only one of them either reverts a human's vendor
1421
+ // correction or freezes auto-detection. Inside one row this is a narrowed `set:` object, not a
1422
+ // table boundary, so it is code discipline now and it is pinned by tests.
1423
+ //
1424
+ // ⚠ `monthly_cents` IS NEVER IN ANY DERIVED **UPDATE**. It is the one column no classifier can
1425
+ // regenerate, so exactly ONE writer touches it and it names exactly ONE row:
1426
+ // `setReviewerCost` — an UPDATE keyed on (account_id, workspace_id, author_user_id). The price
1427
+ // is per WORKSPACE, like every other attribute on this row: editing it in workspace A leaves
1428
+ // A's siblings alone and they may hold different numbers. There is no fan-out and no INSERT
1429
+ // seed. A newly created row's price is simply NULL until someone sets it, and the column
1430
+ // appears in no other `set:` object anywhere in the codebase.
1431
+ // NULL = no price set, 0 = "recorded as free". Nothing inherits; there is no chain behind a `??`.
1432
+ // Storage is integer CENTS in both dialects (pg `numeric` has no sqlite twin and node-postgres
1433
+ // returns it as a STRING; REAL is a float64 that cannot hold money); the WIRE is DOLLARS,
1434
+ // converted only at the store boundary, clamped to MAX_MONTHLY_CENTS. The ROUNDING RULE is fixed
1435
+ // and identical in both dialects — cents = floor(usd × 100 + 0.5) evaluated in IEEE-754 binary64.
1436
+ //
1437
+ // ⚠ RENDERING RULE, and it is the client's job because no schema can enforce it: within ONE
1438
+ // workspace there is exactly one row per actor, so a total there is a plain sum. ACROSS
1439
+ // workspaces it must never be summed — six workspaces each listing a $120 CodeRabbit is either
1440
+ // six subscriptions or one seen six ways, and the app must not assert which. Compare-workspaces
1441
+ // shows the figures side by side and does not total them.
1442
+ //
1443
+ // WHY `workspace_reviewers` AND NOT `workspace_bots`: a row may legitimately say
1444
+ // `automated: false` — a human someone corrected off the bot list — so "bots" would be a lie for
1445
+ // a real, load-bearing subset of the table. The name states the KEY, exactly as the table names
1446
+ // it replaces did: reviewers, per workspace.
1447
+ //
1448
+ // ⚠ THE WORKSPACE CASCADE IS FAR MORE DANGEROUS HERE than on workspace_repos: it would destroy
1449
+ // every manual judgement, every manual vendor name and every `monthly_cents` in the workspace —
1450
+ // money the user typed — while the repos survive. `deleteWorkspace` therefore ALSO re-homes the
1451
+ // reviewer rows to Default before deleting the workspace row. Under the old model deleting a team
1452
+ // touched no classification at all, so this is a NEW failure mode and it must be closed in code,
1453
+ // not in copy.
1454
+ export const workspaceReviewers = sqliteTable('workspace_reviewers', {
1455
+ id: integer('id').primaryKey({ autoIncrement: true }),
1456
+ accountId: integer('account_id')
1457
+ .notNull()
1458
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1459
+ // NOTE THE MISSING `.references()`: this column's foreign key is the COMPOSITE one declared
1460
+ // in the table config below, `(workspace_id, account_id) → workspaces(id, account_id)`.
1461
+ workspaceId: integer('workspace_id').notNull(),
1462
+ // No cascade: `users` is GLOBAL storage shared by every account and is never deleted.
1463
+ authorUserId: integer('author_user_id')
1464
+ .notNull()
1465
+ .references(() => users.id),
1466
+ // ── JUDGEMENT (owned by `source`) ──
1467
+ automated: integer('automated', { mode: 'boolean' }).notNull(),
1468
+ // ReviewerRole — 'review' | 'quality_check' | 'dependency' | 'code_agent' | 'release' |
1469
+ // 'housekeeping'. Just a FLAG ON THIS OBJECT, orthogonal to `kind` (vendor identity): a login
1470
+ // keeps its brand while being marked a linter. `automated` stays TRUE for every automation
1471
+ // role, so exclusion + the feed are unaffected; only the SCORING sets (behaviour, dedup,
1472
+ // benchmark, ROI) narrow, and they narrow to EXACTLY 'review' — never "not quality_check",
1473
+ // which would re-admit all four newer roles. BOT-ONLY PRs DELIBERATELY DO NOT NARROW: that
1474
+ // list answers "did a human look at this", and a PR reviewed only by SonarQube is exactly
1475
+ // what it exists to surface. NOT NULL DEFAULT 'review' so a row written by an older code path
1476
+ // still means something. Migration 0053 re-derives it for every row whose `source` is not
1477
+ // 'manual' — the stored value beats the login seed on read, so a widened vocabulary in code
1478
+ // does nothing for an actor that has already been classified.
1479
+ role: text('role').notNull().default('review'),
1480
+ confidence: text('confidence').notNull(), // ClassificationConfidence — 'high'|'medium'|'low'
1481
+ source: text('source').notNull(), // ClassificationSource; 'manual' is never re-derived
1482
+ reasonsJson: text('reasons_json', { mode: 'json' }).$type(),
1483
+ // ── IDENTITY (owned by `identity_source`) ──
1484
+ // Vendor identity — drives BOT_VENDOR_META / automatedReviewerMeta() colour + brand name.
1485
+ // NULL when this actor is not automated in this workspace.
1486
+ kind: text('kind'), // AutomatedReviewerKind | null
1487
+ label: text('label'), // human-set display name; null ⇒ brand name ⇒ login
1488
+ // Provenance of `kind` + `label` ONLY — 'auto' (the classifier derived them) | 'manual' (a
1489
+ // human named this thing and the classifier must leave it alone). IT IS NOT THE SAME FIELD AS
1490
+ // `source`, and keeping the two apart is what stops a vendor correction reverting a
1491
+ // judgement (and vice versa) now that they share a row.
1492
+ identitySource: text('identity_source', { enum: ['auto', 'manual'] })
1493
+ .notNull()
1494
+ .default('auto'),
1495
+ // ── PRICE (owned by nothing derived; one writer only) ──
1496
+ monthlyCents: integer('monthly_cents'),
1497
+ // How `monthly_cents` is READ: 'flat' = the whole workspace subscription; 'per_seat' = a
1498
+ // per-seat unit price, multiplied on read by the workspace's derived human seat count.
1499
+ // The product (seats × cents) is NEVER stored — it can exceed int4 and would go stale.
1500
+ // Written ONLY by `setReviewerCost`, exactly like `monthly_cents` (same one-writer rule,
1501
+ // same standalone cost route; never in any derived UPDATE, never in the PATCH body).
1502
+ costModel: text('cost_model', { enum: ['flat', 'per_seat'] })
1503
+ .notNull()
1504
+ .default('flat'),
1505
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
1506
+ .notNull()
1507
+ .default(sql `(unixepoch())`),
1508
+ }, (t) => ({
1509
+ // The upsert conflict target for EVERY writer. A stale target type-checks perfectly and
1510
+ // raises "no unique or exclusion constraint matching the ON CONFLICT specification" at
1511
+ // RUNTIME, in both dialects, only when a row is actually written.
1512
+ accountWorkspaceAuthorUx: uniqueIndex('workspace_reviewers_account_workspace_author').on(t.accountId, t.workspaceId, t.authorUserId),
1513
+ // Listing one workspace's reviewers (the Bots settings list).
1514
+ accountWorkspaceIdx: index('workspace_reviewers_account_workspace_idx').on(t.accountId, t.workspaceId),
1515
+ // Reaching every row of ONE actor across the account's workspaces.
1516
+ accountAuthorIdx: index('workspace_reviewers_account_author_idx').on(t.accountId, t.authorUserId),
1517
+ // ── TENANCY AS A CONSTRAINT, NOT A CONVENTION ────────────────────────────────────────
1518
+ // `workspace_id` arrives in a REQUEST BODY rather than from sync. A plain
1519
+ // `workspace_id → workspaces(id)` FK accepts (account 2, workspace 10) where workspace 10
1520
+ // belongs to account 1 — both halves are individually valid — so the only thing between that
1521
+ // and a row written into another tenant's workspace would be one hand-written predicate in
1522
+ // one handler. The composite FK makes the PAIR itself the thing that must exist, and it needs
1523
+ // the `workspaces_id_account` unique index that exists for exactly this purpose.
1524
+ //
1525
+ // THE `name` IS REAL, NOT DECORATION — the hand-written migrations SPELL IT (`CONSTRAINT
1526
+ // "workspace_reviewers_workspace_account_fk" FOREIGN KEY …`) in both dialects, so the
1527
+ // declaration and the emitted DDL say the same thing. In Postgres the name is what the
1528
+ // violation message quotes; SQLite stores it but never reports it.
1529
+ workspaceAccountFk: foreignKey({
1530
+ name: 'workspace_reviewers_workspace_account_fk',
1531
+ columns: [t.workspaceId, t.accountId],
1532
+ foreignColumns: [workspaces.id, workspaces.accountId],
1533
+ }).onDelete('cascade'),
1534
+ }));
1535
+ // NOTE: `repo_reviewers` (account, repo, author) and `account_reviewers` (account, author) were
1536
+ // the TWO-GRAIN bot store this replaces — the judgement per repo, the identity + price per actor.
1537
+ // Both are DROPPED by migration 0045 / pg 0032, which folds them onto `workspace_reviewers`
1538
+ // above. The split existed only because those facts sat at different grains; with the workspace
1539
+ // as the one scope they are facts about the same key. Leaving either table behind would leave a
1540
+ // second, differently-keyed answer to the same question in every database — the exact failure
1541
+ // `bot_review_classification` was dropped to avoid. Don't reintroduce a schema binding for either.
1542
+ // NOTE: the `bot_mute_rules` table (migration 0029) backed a removed feature — the
1543
+ // Pierre-only "hide" mute + the standing auto-resolve cron. Both were dropped; resolving
1544
+ // likely-addressed bot threads is now strictly user-initiated + confirm-gated (see
1545
+ // bot-triage/resolve.ts). The table is left orphaned in existing DBs (no drop migration);
1546
+ // don't reintroduce a schema binding for it.
1547
+ // NOTE: `teams` + `team_repos` (the many-to-many grouping a repo could sit in 0..N of) are what
1548
+ // `workspaces` + `workspace_repos` above replace. Both are DROPPED by migration 0044 / pg 0031,
1549
+ // which preserves the team IDS as workspace ids (a URL, a bookmark, a persisted filter and a
1550
+ // plugin cache row all carry the number) and gives every repo exactly one membership row. The
1551
+ // four scope sentinels those tables needed ('all' / 'none' / 'teams' / a set) are gone with them:
1552
+ // there is one workspace id and no parsers. Don't reintroduce a schema binding for either.
1553
+ // ── Cross-org benchmark network (CORE, cloud-only, opt-in) ──────────────────────────────
1554
+ // Phase 0 of the neutral review-bot benchmark: de-identified, AGGREGATE-ONLY weekly outcome
1555
+ // stats per known vendor, contributed by opted-in cloud accounts (see accounts.benchmarkOptIn),
1556
+ // so a later paid feature can show "your CodeRabbit is 38% acted-on vs a 45% peer median".
1557
+ // NO raw text / logins / repo names / PR titles ever land here — counts only. `in_house` /
1558
+ // `pierre` are EXCLUDED at collection (not comparable across orgs / identifying). Written ONLY
1559
+ // by the firewalled weekly rollup (sync/benchmark-rollup.ts) from each account's OWN data, so
1560
+ // collection stays accountId-scoped; the CROSS-account read is the future serving job (Phase 1),
1561
+ // which applies k-anonymity (K>=5). Serving (percentiles/cohorts) lives in the Pro plugin later.
1562
+ export const benchmarkContributions = sqliteTable('benchmark_contributions', {
1563
+ id: integer('id').primaryKey({ autoIncrement: true }),
1564
+ accountId: integer('account_id')
1565
+ .notNull()
1566
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1567
+ // A known review-bot vendor kind (shared ReviewBotKind). Never in_house/pierre.
1568
+ vendorKind: text('vendor_kind').notNull(),
1569
+ // ISO-week start (UTC Monday 00:00) the aggregates cover.
1570
+ weekStart: integer('week_start', { mode: 'timestamp' }).notNull(),
1571
+ // Deterministic weekly outcome aggregates — counts only.
1572
+ threads: integer('threads').notNull().default(0),
1573
+ comments: integer('comments').notNull().default(0),
1574
+ // "acted-on" = resolved | likely_addressed | a human replied after the bot (the same
1575
+ // heuristic the ROI panel uses; approximate — the UI says so).
1576
+ actedOn: integer('acted_on').notNull().default(0),
1577
+ untouched: integer('untouched').notNull().default(0),
1578
+ humanFollow: integer('human_follow').notNull().default(0),
1579
+ // Oldest still-untouched thread age (days) in the week; null when none untouched.
1580
+ oldestUntouchedDays: integer('oldest_untouched_days'),
1581
+ // Covariate: the account's active-contributor size bucket at contribution time
1582
+ // ('1' | '2-5' | '6-20' | '21-50' | '51-200' | '200+'), so cohorts can condition on size.
1583
+ orgSizeBucket: text('org_size_bucket').notNull(),
1584
+ // RESERVED for FUTURE ML-derived aggregate distributions (category mix / severity mix /
1585
+ // precision-by-category) — populated by the SAME rollup once the classifier ships, still
1586
+ // aggregate-only JSON. Null in Phase 0. Forward hook so ML enrichment needs no new migration.
1587
+ mlMetrics: text('ml_metrics'),
1588
+ // Contribution-format version so the row shape can evolve unambiguously.
1589
+ schemaVersion: integer('schema_version').notNull().default(1),
1590
+ createdAt: integer('created_at', { mode: 'timestamp' })
1591
+ .notNull()
1592
+ .default(sql `(unixepoch())`),
1593
+ }, (t) => ({
1594
+ // One row per (account, vendor, week) — the idempotent upsert target. accountId-first so
1595
+ // uniqueness is per-tenant (two accounts contribute the same vendor+week independently).
1596
+ uniq: uniqueIndex('bench_contrib_uniq').on(t.accountId, t.vendorKind, t.weekStart),
1597
+ accountIdx: index('bench_contrib_account_idx').on(t.accountId),
1598
+ // Serving-side cohort scans (Phase 1): all accounts' rows for a vendor across weeks.
1599
+ cohortIdx: index('bench_contrib_cohort_idx').on(t.vendorKind, t.weekStart),
1600
+ }));
1601
+ // ── Cross-workspace full-text search index (CORE, no AI) ────────────────────────────────
1602
+ // One row per searchable text unit — a PR (title + description), a review body, a review-comment,
1603
+ // or a PR-comment — so a team lead can "pinpoint where certain text exists" across every repo on
1604
+ // the account. Populated inside persistPr() (delete-by-prId then insert, so removed comments drop out) and
1605
+ // backfilled from already-stored data at startup (sync/search-backfill.ts). Denormalized `accountId`
1606
+ // is the tenant anchor: EVERY search filters on it, so cross-account isolation stays one indexed
1607
+ // predicate (mirroring events/pullRequests). Matching is PORTABLE case-insensitive SUBSTRING
1608
+ // (`lower(body) LIKE …`), so the identical query runs on SQLite and Postgres — substring is the
1609
+ // right semantics for "find this exact text" (a Postgres `pg_trgm` GIN index is a drop-in
1610
+ // accelerator, see the migration note). `threadId` lets a review-comment hit deep-link straight to
1611
+ // its thread. Rebuilt per-PR, so no unique index is needed. FKs cascade so a repo/PR delete cleans up.
1612
+ export const searchIndex = sqliteTable('search_index', {
1613
+ id: integer('id').primaryKey({ autoIncrement: true }),
1614
+ accountId: integer('account_id')
1615
+ .notNull()
1616
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1617
+ repoId: integer('repo_id')
1618
+ .notNull()
1619
+ .references(() => repos.id, { onDelete: 'cascade' }),
1620
+ prId: integer('pr_id')
1621
+ .notNull()
1622
+ .references(() => pullRequests.id, { onDelete: 'cascade' }),
1623
+ kind: text('kind', {
1624
+ enum: ['pr', 'review', 'review_comment', 'pr_comment'],
1625
+ }).notNull(),
1626
+ // The entity id within its kind (prId / reviewId / reviewCommentId / prCommentId) — builds the
1627
+ // in-app anchor for the hit.
1628
+ refId: integer('ref_id').notNull(),
1629
+ // For review_comment rows: the owning reviewThreads.id, so a hit deep-links to the thread.
1630
+ // Null for the other kinds.
1631
+ threadId: integer('thread_id'),
1632
+ // Who wrote this unit (users.id) — powers "search by person" (matched via the users join) and
1633
+ // the author chip on a result. Nullable (ghost / deleted authors).
1634
+ authorId: integer('author_id').references(() => users.id),
1635
+ // The searchable text (PR rows carry title + description; the others carry the body).
1636
+ body: text('body').notNull(),
1637
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
1638
+ }, (t) => ({
1639
+ accountRepoIdx: index('search_account_repo_idx').on(t.accountId, t.repoId),
1640
+ prIdx: index('search_pr_idx').on(t.prId),
1641
+ }));
1642
+ // ── ML severity/category labels for BOT-authored text (CORE, free tier, no LLM) ──────────
1643
+ // One row per classified target. Written ONLY by the background enrichment worker
1644
+ // (sync/ml-enrichment.ts), which POSTs batches to the `severity-api` microservice from the
1645
+ // sibling `pierre-ml` repo. Read by the per-PR badge index and the Bots severity rollup.
1646
+ // Full contract: docs/ML-SEVERITY.md.
1647
+ //
1648
+ // WHY THE ROW DENORMALISES accountId / repoId / prId / authorUserId, when `targetId` already
1649
+ // names a row that could be joined for all four: `target_kind` is polymorphic across THREE
1650
+ // tables, so every scoped read would otherwise be a three-way UNION of joins — including the
1651
+ // Bots rollup, which groups by author across a whole workspace. These are SNAPSHOT facts about
1652
+ // an immutable parent (a comment never changes PR or author), not a second writable copy of a
1653
+ // live fact, so the "one fact, one grain" rule is not in play. accountId is additionally the
1654
+ // tenant anchor every read filters on, per the project-wide rule.
1655
+ //
1656
+ // TARGET IDS ARE NOT FOREIGN KEYS. `target_id` lives in three different id spaces
1657
+ // (reviewComments.id / prComments.id / reviews.id) so no single FK can express it — the same
1658
+ // shape the plugin's `pr_comment_annotations` uses. Cleanup rides the CASCADING pr_id FK
1659
+ // instead: deleting a PR (deleteRepo, the retention sweep) takes its labels with it, which is
1660
+ // why this table is deliberately absent from both hand-written delete paths (the `search_index`
1661
+ // precedent). Its own comments can only be deleted WITH their PR, so nothing dangles.
1662
+ export const mlCommentLabels = sqliteTable('ml_comment_labels', {
1663
+ id: integer('id').primaryKey({ autoIncrement: true }),
1664
+ accountId: integer('account_id')
1665
+ .notNull()
1666
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1667
+ repoId: integer('repo_id')
1668
+ .notNull()
1669
+ .references(() => repos.id, { onDelete: 'cascade' }),
1670
+ prId: integer('pr_id')
1671
+ .notNull()
1672
+ .references(() => pullRequests.id, { onDelete: 'cascade' }),
1673
+ // MlLabelTargetKind — 'review_comment' | 'pr_comment' | 'review'. NOT the plugin's
1674
+ // AnnotationTargetKind (that one has 'thread' and no 'review').
1675
+ targetKind: text('target_kind', {
1676
+ enum: ['review_comment', 'pr_comment', 'review'],
1677
+ }).notNull(),
1678
+ // The target's own primary key WITHIN its kind. Not globally unique — which is exactly why
1679
+ // the unique index below carries target_kind.
1680
+ targetId: integer('target_id').notNull(),
1681
+ // The bot that wrote the labelled text (users.id). Denormalised so the Bots rollup is one
1682
+ // GROUP BY rather than a three-way union back to the source tables.
1683
+ authorUserId: integer('author_user_id')
1684
+ .notNull()
1685
+ .references(() => users.id),
1686
+ // MlSeverity, lowercased at the wire boundary ('nit'|'minor'|'major'|'critical').
1687
+ severity: text('severity', {
1688
+ enum: ['nit', 'minor', 'major', 'critical'],
1689
+ }).notNull(),
1690
+ // 0..3, nit → critical. The service's own ordinal, stored so ordering/thresholding is an
1691
+ // indexed integer comparison instead of a CASE over the enum.
1692
+ severityOrd: integer('severity_ord').notNull(),
1693
+ severityProb: real('severity_prob').notNull(),
1694
+ // ── THE VENDOR'S OWN CLAIM, kept BESIDE ours — never folded into it. ──────────────────
1695
+ // The severity the review bot declared for itself in its own markup (CodeRabbit's
1696
+ // "Major" badge and friends), parsed by the service's deterministic marker reader.
1697
+ // NULLABLE because most comments carry no vendor badge at all, and because an older
1698
+ // severity-api build omits the field entirely (the client reads it defensively → null).
1699
+ //
1700
+ // It is stored to be SHOWN NEXT TO ours ("CodeRabbit: Major · Pierre: Minor"), and for no
1701
+ // other purpose. On the 300-comment `gold_v2_sample` adjudication our model scores 0.700
1702
+ // exact / 0.303 ordinal MAE against the vendor badge's 0.474 / 0.697 — so this column is
1703
+ // materially LESS accurate than the one next to it. Nothing may derive, correct or
1704
+ // fall back to it: the disagreement is the product.
1705
+ vendorSeverity: text('vendor_severity', {
1706
+ enum: ['nit', 'minor', 'major', 'critical'],
1707
+ }),
1708
+ // How sure the marker reader is that it read a real vendor badge rather than inferred one
1709
+ // from prose. Advisory metadata ABOUT the column above, not about our own severity.
1710
+ vendorSeverityConfidence: text('vendor_severity_confidence', {
1711
+ enum: ['high', 'medium', 'low'],
1712
+ }),
1713
+ // MlCategory[] — multi-label, never empty.
1714
+ categories: text('categories', { mode: 'json' }).$type().notNull(),
1715
+ // The service returns a probability for ALL eight categories; kept whole so a later
1716
+ // threshold change is a re-read rather than a re-score of the entire corpus.
1717
+ categoryProbs: text('category_probs', { mode: 'json' })
1718
+ .$type()
1719
+ .notNull(),
1720
+ isSummary: integer('is_summary', { mode: 'boolean' }).notNull(),
1721
+ // Which backends served it, verbatim. Lacking 'modernbert-onnx' means the marker fallback
1722
+ // answered — a degraded deployment that must be VISIBLE, not silently lower quality.
1723
+ backend: text('backend').notNull(),
1724
+ modelVersion: text('model_version').notNull(),
1725
+ // sha256 of the exact text sent to the service. Comment bodies are MUTABLE (every sync
1726
+ // re-upserts them), so a boolean "enriched" flag or the row id would both go stale
1727
+ // invisibly; the hash is what lets a re-score be decided without a second model call.
1728
+ bodyHash: text('body_hash').notNull(),
1729
+ // The SOURCE comment's createdAt, copied so the Bots rollup can window without a
1730
+ // three-way union back to the polymorphic parents (and so the worker can order a
1731
+ // re-score newest-first). Immutable on the parent, so it never diverges.
1732
+ targetCreatedAt: integer('target_created_at', { mode: 'timestamp' }).notNull(),
1733
+ createdAt: integer('created_at', { mode: 'timestamp' })
1734
+ .notNull()
1735
+ .default(sql `(unixepoch())`),
1736
+ updatedAt: integer('updated_at', { mode: 'timestamp' })
1737
+ .notNull()
1738
+ .default(sql `(unixepoch())`),
1739
+ }, (t) => ({
1740
+ // THE conflict target for every writer. accountId first so uniqueness is per tenant, and
1741
+ // target_kind is load-bearing: the three id spaces overlap freely.
1742
+ accountTargetUx: uniqueIndex('mcl_account_target').on(t.accountId, t.targetKind, t.targetId),
1743
+ // The per-PR badge index (one query serves every card on a PR).
1744
+ accountPrIdx: index('mcl_account_pr_idx').on(t.accountId, t.prId),
1745
+ // The Bots rollup: one workspace's repos, grouped by bot.
1746
+ accountRepoAuthorIdx: index('mcl_account_repo_author_idx').on(t.accountId, t.repoId, t.authorUserId),
1747
+ }));
1748
+ // ── "@you" on a PR — the source of My Turn's @mention cards (CORE, no AI) ─────────────────────
1749
+ // PRESENCE IS THE FACT: a row means "this account's viewer login is @mentioned somewhere on this
1750
+ // PR" (a PR comment, a review body, or an inline review comment). There is no `mentioned` boolean
1751
+ // and no row for "scanned, found nothing" — absence is the answer. The row also carries WHEN the
1752
+ // newest such mention was made and by whom (`mentioned_at`), because the card clears when the
1753
+ // viewer acts after that moment.
1754
+ //
1755
+ // WHY A TABLE AND NOT A COLUMN ON pull_requests. The fact is DERIVED and re-derivable, it is
1756
+ // about a (tenant, PR) pair rather than about the PR, and it is sparse — 12 rows out of 8.5k PRs
1757
+ // on this repo's own dev account. Widening the hottest table in the schema by a column that is
1758
+ // false 99.9% of the time, and that every sync upsert would then have to carry, buys nothing.
1759
+ //
1760
+ // WRITTEN ONLY BY sync/mention-scan.ts, which re-derives the FULL set per account per tick and
1761
+ // diffs it against what is stored — so an edited-away mention, a deleted comment, a renamed
1762
+ // account and a 90-day backfill all converge within one tick with no write path needing a hook
1763
+ // (the sync/ml-enrichment.ts precedent). `login` is the provenance of the derivation, not a
1764
+ // second copy of `accounts.github_login`: it records which login this row was matched FOR, so a
1765
+ // reader can refuse rows derived under a login the account no longer has instead of trusting a
1766
+ // row the scan has not caught up with yet.
1767
+ export const prMentions = sqliteTable('pr_mentions', {
1768
+ id: integer('id').primaryKey({ autoIncrement: true }),
1769
+ accountId: integer('account_id')
1770
+ .notNull()
1771
+ .references(() => accounts.id, { onDelete: 'cascade' }),
1772
+ // Denormalised from the PR — a snapshot of an immutable parent fact (a PR never changes
1773
+ // repo), and what makes `deleteRepo` one indexed predicate rather than a join.
1774
+ repoId: integer('repo_id')
1775
+ .notNull()
1776
+ .references(() => repos.id, { onDelete: 'cascade' }),
1777
+ prId: integer('pr_id')
1778
+ .notNull()
1779
+ .references(() => pullRequests.id, { onDelete: 'cascade' }),
1780
+ // The viewer login this row was derived from, stored lowercased (matching is
1781
+ // case-insensitive, so the stored form has to be canonical or a reader's equality test
1782
+ // would depend on how GitHub happened to spell the login that day).
1783
+ login: text('login').notNull(),
1784
+ // The NEWEST mention of `login` on this PR by a person who is not the viewer and not
1785
+ // automation (the global automation set). NULL = the scanner has not stamped this row yet —
1786
+ // the reader shows NO card for it (under-notifying is the safe direction). Rewritten by every
1787
+ // scan tick. No FK on the author: a display hint the scanner rewrites, over a global table.
1788
+ mentionedAt: integer('mentioned_at', { mode: 'timestamp' }),
1789
+ mentionedByUserId: integer('mentioned_by_user_id'),
1790
+ createdAt: integer('created_at', { mode: 'timestamp' })
1791
+ .notNull()
1792
+ .default(sql `(unixepoch())`),
1793
+ }, (t) => ({
1794
+ // THE conflict target — one row per (tenant, PR), which is exactly the presence semantics.
1795
+ accountPrUx: uniqueIndex('prm_account_pr').on(t.accountId, t.prId),
1796
+ // deleteRepo deletes by repo; the retention sweep and deletePrSubtree delete by PR id, which
1797
+ // is NOT a prefix of the unique above.
1798
+ accountRepoIdx: index('prm_account_repo_idx').on(t.accountId, t.repoId),
1799
+ prIdx: index('prm_pr_idx').on(t.prId),
1800
+ }));
1801
+ //# sourceMappingURL=schema.sqlite.js.map