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,1190 @@
1
+ import { and, desc, eq, sql } from 'drizzle-orm';
2
+ import { db, schema, runTransaction } from '../db/client.js';
3
+ import { searchText } from '../db/search.js';
4
+ // The single owner of "which workspace is this account's Default" — concurrent-safe (INSERT …
5
+ // ON CONFLICT DO NOTHING then re-SELECT) and carrying the name-collision ladder. Duplicating a
6
+ // bare `SELECT … WHERE is_default` here would be a second answer to the same question.
7
+ import { ensureDefaultWorkspace } from '../db/queries.js';
8
+ import { config } from '../config.js';
9
+ import { isLikelyBot } from './bot-detection.js';
10
+ import { notePrChanged } from './pr-change-signal.js';
11
+ import { classifyPrSecurity, } from './security-detect.js';
12
+ import { deriveThreadState, } from './derive-thread-state.js';
13
+ const { repos, users, pullRequests, reviews, reviewThreads, reviewComments, prComments, commits, events, reviewRequests, reviewRequestEvents, ciStatusEvents, searchIndex, workspaceRepos, } = schema;
14
+ function toDate(iso) {
15
+ return iso ? new Date(iso) : null;
16
+ }
17
+ function maxDate(dates) {
18
+ let max = null;
19
+ for (const d of dates) {
20
+ if (d && (!max || d > max))
21
+ max = d;
22
+ }
23
+ return max;
24
+ }
25
+ function minDate(dates) {
26
+ let min = null;
27
+ for (const d of dates) {
28
+ if (d && (!min || d < min))
29
+ min = d;
30
+ }
31
+ return min;
32
+ }
33
+ // Short single-line preview of a comment body. Stored even in lean mode (where the
34
+ // full body is dropped) so the triage path (getMyTurn/getThreadsAwaiting) and
35
+ // graceful UI degradation work without hydrating from GitHub.
36
+ function excerptOf(body) {
37
+ if (!body)
38
+ return null;
39
+ const oneLine = body.replace(/\s+/g, ' ').trim();
40
+ if (!oneLine)
41
+ return null;
42
+ return oneLine.length > 160 ? `${oneLine.slice(0, 159)}…` : oneLine;
43
+ }
44
+ /** Resolves GraphQL actors to local user ids, caching by login within a run. */
45
+ export function createUserResolver() {
46
+ const cache = new Map();
47
+ return {
48
+ async resolve(exec, actor) {
49
+ const login = actor?.login;
50
+ if (!login)
51
+ return null;
52
+ const cached = cache.get(login);
53
+ if (cached !== undefined)
54
+ return cached;
55
+ const nodeId = actor?.id ?? null;
56
+ // The GitHub node id is the STABLE identity; the login is NOT. The same
57
+ // account can surface under two different logins — even inside one sync
58
+ // payload: a bot's Bot-typed author field reads `dependabot[bot]` while its
59
+ // commit-author field reads the bare `dependabot`, both carrying the same
60
+ // node id; and humans get renamed. Because BOTH `github_login` AND
61
+ // `github_node_id` are UNIQUE, the login-keyed upsert below is unsafe when a
62
+ // DIFFERENT row already owns this node id: matching the login (or inserting a
63
+ // new one) while `coalesce`-stamping the node raises "UNIQUE constraint
64
+ // failed: users.github_node_id". So when we have a node id, resolve on it
65
+ // FIRST and reuse the row that owns it — the colliding insert/update is never
66
+ // issued. Only a brand-new (or absent) node id falls through to the
67
+ // login-keyed upsert, where stamping the node can't collide.
68
+ if (nodeId) {
69
+ const owner = (await exec
70
+ .select({
71
+ id: users.id,
72
+ isBot: users.isBot,
73
+ isBotOverridden: users.isBotOverridden,
74
+ })
75
+ .from(users)
76
+ .where(eq(users.githubNodeId, nodeId))
77
+ .limit(1)
78
+ .execute())[0];
79
+ if (owner) {
80
+ // Refresh volatile metadata but NEVER rewrite the login: the row already
81
+ // holds a canonical login for this node id, and rewriting it would risk the
82
+ // github_login UNIQUE (when the incoming login belongs to another live row)
83
+ // and thrash a bot that alternates login forms across a single sync. Only
84
+ // set fields the actor actually carries (a commit-author resolve has no
85
+ // name/avatar/typename) so we never clobber a known value with null — the
86
+ // same "coalesce" intent as the upsert below, via plain portable value
87
+ // binds (a raw `sql` boolean can't bind on sqlite; a bare null param can
88
+ // confuse pg type inference). isBot honours a manual override.
89
+ const set = { isBot: owner.isBotOverridden ? owner.isBot : isLikelyBot(login) };
90
+ if (actor?.name != null)
91
+ set.displayName = actor.name;
92
+ if (actor?.avatarUrl != null)
93
+ set.avatarUrl = actor.avatarUrl;
94
+ if (actor?.__typename != null)
95
+ set.githubType = actor.__typename;
96
+ await exec.update(users).set(set).where(eq(users.id, owner.id)).execute();
97
+ cache.set(login, owner.id);
98
+ return owner.id;
99
+ }
100
+ }
101
+ const row = (await exec
102
+ .insert(users)
103
+ .values({
104
+ githubLogin: login,
105
+ githubNodeId: nodeId,
106
+ displayName: actor?.name ?? null,
107
+ avatarUrl: actor?.avatarUrl ?? null,
108
+ // GraphQL __typename ('User'|'Bot'|…) when the actor carried it (the fat
109
+ // activity query selects it on every author); null for locally-synthesized
110
+ // actors (commit authors / review requests). Feeds the bot-triage classifier.
111
+ githubType: actor?.__typename ?? null,
112
+ isBot: isLikelyBot(login),
113
+ })
114
+ .onConflictDoUpdate({
115
+ target: users.githubLogin,
116
+ set: {
117
+ githubNodeId: sql `coalesce(excluded.github_node_id, ${users.githubNodeId})`,
118
+ displayName: sql `coalesce(excluded.display_name, ${users.displayName})`,
119
+ avatarUrl: sql `coalesce(excluded.avatar_url, ${users.avatarUrl})`,
120
+ // Never overwrite a known github_type with null (a later {login,id}-only
121
+ // resolve of the same actor must not wipe the type captured from GraphQL).
122
+ githubType: sql `coalesce(excluded.github_type, ${users.githubType})`,
123
+ // Never clobber a manual is_bot override.
124
+ isBot: sql `case when ${users.isBotOverridden} = true then ${users.isBot} else excluded.is_bot end`,
125
+ },
126
+ })
127
+ .returning({ id: users.id })
128
+ .execute())[0];
129
+ cache.set(login, row.id);
130
+ return row.id;
131
+ },
132
+ };
133
+ }
134
+ /**
135
+ * Upsert a repo by its GitHub node id; returns the local repo id.
136
+ *
137
+ * ── IT ALSO WRITES THE WORKSPACE MEMBERSHIP ROW, IN THE SAME TRANSACTION ─────────────────────
138
+ * A repo with NO `workspace_repos` row is INVISIBLE to every workspace-scoped read — no PRs, no
139
+ * feed rows, no bots, and silently. This is the WRITE side of closing that gap (the READ side is
140
+ * `ensureRepoMemberships`, which repairs stragglers on essentially every GET). Doing it here, in
141
+ * the same `runTransaction` as the repo row, means a repo is never committed without a home.
142
+ *
143
+ * ⚠ `ON CONFLICT (account_id, repo_id) DO NOTHING` IS LOAD-BEARING. That unique is "one workspace
144
+ * per repo", so an upsert on it would be a MOVE: a re-sync of an existing repo would silently drag
145
+ * it back out of the workspace a human deliberately put it in, on every sync tick.
146
+ *
147
+ * The membership row IS the whole of a repo's visibility now — there is no second "watched" axis
148
+ * to also set (`repos.inbox_watch` and its start stamp are dropped). A repo in a workspace is
149
+ * fully live: Feed, Activity, My Turn and Bots all cover it.
150
+ *
151
+ * The Default workspace is resolved BEFORE the transaction opens, deliberately:
152
+ * `ensureDefaultWorkspace` runs on `db`, and on Postgres `db` is a Pool — i.e. a DIFFERENT
153
+ * connection from the transaction's executor — so calling it inside would run outside the
154
+ * transaction on one dialect and inside it on the other.
155
+ *
156
+ * Safe against `runTransaction`'s bare `BEGIN`: `upsertRepo` has no caller already inside a
157
+ * transaction (`api/routes/repos.ts`, `sync/sync-repo.ts`).
158
+ */
159
+ export async function upsertRepo(owner, name, githubNodeId, defaultBranch, accountId, viewerPermission, description) {
160
+ // default_branch: only overwrite when known (a branch is never revoked, and the
161
+ // lightweight add-repo path omits it) so we never null a synced value.
162
+ // viewer_permission: the activity sync ALWAYS fetches it (passing null when
163
+ // GitHub reports no permission, i.e. access was revoked), so `undefined` means
164
+ // "not fetched" (add path → preserve) while `null`/string is authoritative and
165
+ // overwrites — otherwise a revoked-to-null permission would stay stale-elevated.
166
+ // description: the three-state partial-response policy (sync/branch-status.ts is the
167
+ // reference) — `undefined` ⇒ the selection wasn't received (add path, a tolerant
168
+ // partial) ⇒ OMIT so the stored value survives; `null` ⇒ GitHub positively said the
169
+ // repo has no description ⇒ clear; a string overwrites.
170
+ const set = { owner, name };
171
+ if (defaultBranch != null)
172
+ set.defaultBranch = defaultBranch;
173
+ if (viewerPermission !== undefined)
174
+ set.viewerPermission = viewerPermission;
175
+ if (description !== undefined)
176
+ set.description = description;
177
+ const workspaceId = await ensureDefaultWorkspace(accountId);
178
+ return runTransaction(async (tx) => {
179
+ const row = (await tx
180
+ .insert(repos)
181
+ .values({
182
+ accountId,
183
+ owner,
184
+ name,
185
+ githubNodeId,
186
+ defaultBranch: defaultBranch ?? null,
187
+ viewerPermission: viewerPermission ?? null,
188
+ description: description ?? null,
189
+ })
190
+ .onConflictDoUpdate({
191
+ target: [repos.accountId, repos.githubNodeId],
192
+ set,
193
+ })
194
+ .returning({ id: repos.id })
195
+ .execute())[0];
196
+ await tx
197
+ .insert(workspaceRepos)
198
+ .values({ accountId, workspaceId, repoId: row.id })
199
+ .onConflictDoNothing({ target: [workspaceRepos.accountId, workspaceRepos.repoId] })
200
+ .execute();
201
+ return row.id;
202
+ });
203
+ }
204
+ function prState(s) {
205
+ if (s === 'MERGED')
206
+ return 'merged';
207
+ if (s === 'CLOSED')
208
+ return 'closed';
209
+ return 'open';
210
+ }
211
+ // Exported because the default-branch snapshot (sync/branch-status.ts) maps the SAME GitHub
212
+ // `StatusState` enum off a Commit's rollup. Two copies would drift silently.
213
+ export function ciStatusFrom(state) {
214
+ switch ((state ?? '').toUpperCase()) {
215
+ case 'SUCCESS':
216
+ return 'success';
217
+ case 'FAILURE':
218
+ return 'failure';
219
+ case 'PENDING':
220
+ return 'pending';
221
+ case 'ERROR':
222
+ return 'error';
223
+ case 'EXPECTED':
224
+ return 'expected';
225
+ default:
226
+ return 'unknown';
227
+ }
228
+ }
229
+ function mergeableFrom(state) {
230
+ switch ((state ?? '').toUpperCase()) {
231
+ case 'MERGEABLE':
232
+ return 'mergeable';
233
+ case 'CONFLICTING':
234
+ return 'conflicting';
235
+ default:
236
+ return 'unknown';
237
+ }
238
+ }
239
+ const MERGE_STATE_STATUSES = new Set([
240
+ 'clean',
241
+ 'dirty',
242
+ 'unstable',
243
+ 'blocked',
244
+ 'behind',
245
+ 'has_hooks',
246
+ 'unknown',
247
+ ]);
248
+ // GitHub's MergeStateStatus enum also has DRAFT — "the merge is blocked due to the pull
249
+ // request being a draft". We deliberately do NOT model it: `isDraft` is already stored as
250
+ // its own boolean, and every merge surface resolves draft-ness from that (lib/ui.ts
251
+ // `mergeVerdict` checks `isDraft` before it looks at the status). Folding DRAFT into the
252
+ // enum would mean a draft PR permanently reports mergeStateStatus='draft' and the moment it
253
+ // is marked ready we'd have no idea whether it is clean or blocked until the next sync.
254
+ // It lands in 'unknown' below, which is exactly what it is: not yet computed FOR A MERGE.
255
+ const DRAFT_MERGE_STATE = 'draft';
256
+ // GraphQL PullRequestReviewDecision → the stored lowercase enum. Null (the repo requires no
257
+ // review at all) is preserved as null — distinct from 'approved'.
258
+ /**
259
+ * GitHub's PullRequestReviewDecision → the stored enum. Exported because
260
+ * GET /api/prs/:id/merge-options surfaces the LIVE decision from its merge-queue probe and must
261
+ * map it exactly as the sync walk does — two spellings of one enum is how the merge control and
262
+ * the PR row start disagreeing about the same PR.
263
+ *
264
+ * ⚠ It cannot distinguish "GitHub said null" from "we were handed nothing": both return null.
265
+ * The caller owns that distinction (omit the key vs. write null) — see the partial-response rule
266
+ * in CLAUDE.md and sync/branch-status.ts.
267
+ */
268
+ export function reviewDecisionFrom(decision) {
269
+ switch ((decision ?? '').toUpperCase()) {
270
+ case 'APPROVED':
271
+ return 'approved';
272
+ case 'CHANGES_REQUESTED':
273
+ return 'changes_requested';
274
+ case 'REVIEW_REQUIRED':
275
+ return 'review_required';
276
+ default:
277
+ return null;
278
+ }
279
+ }
280
+ function mergeStateStatusFrom(state) {
281
+ const lower = (state ?? '').toLowerCase();
282
+ // DRAFT is a known value we intentionally map to 'unknown' (see DRAFT_MERGE_STATE above);
283
+ // naming it here keeps that a decision rather than an accident of the fallback.
284
+ if (lower === DRAFT_MERGE_STATE)
285
+ return 'unknown';
286
+ return (MERGE_STATE_STATUSES.has(lower) ? lower : 'unknown');
287
+ }
288
+ // GitHub's MergeQueueEntryState → the stored lowercase enum. Exported for the same reason
289
+ // `reviewDecisionFrom` is: the two merge-queue WRITE routes in api/routes/prs.ts stamp this
290
+ // column from a mutation response, and a second spelling of one enum is how the merge control
291
+ // and the PR row start disagreeing about the same PR.
292
+ //
293
+ // ⚠ Returns null for anything unrecognised, INCLUDING a state GitHub adds later. That is the
294
+ // safe direction here — an unknown string stored raw would leak straight onto the wire as a
295
+ // display value nobody wrote copy for — but it does mean "no entry" and "an entry in a state we
296
+ // do not model" are both null. The caller must therefore never infer membership from this
297
+ // column; `inMergeQueue` is the membership fact.
298
+ export function mergeQueueEntryStateFrom(state) {
299
+ switch ((state ?? '').toUpperCase()) {
300
+ case 'AWAITING_CHECKS':
301
+ return 'awaiting_checks';
302
+ case 'LOCKED':
303
+ return 'locked';
304
+ case 'MERGEABLE':
305
+ return 'mergeable';
306
+ case 'QUEUED':
307
+ return 'queued';
308
+ case 'UNMERGEABLE':
309
+ return 'unmergeable';
310
+ default:
311
+ return null;
312
+ }
313
+ }
314
+ // Exported because sync/branch-status.ts maps the SAME contexts union for default-branch
315
+ // commits. The CheckRun status/conclusion → CheckRunState table must not exist twice: if trunk
316
+ // and PR surfaces disagreed about what "failing" means, the same check would render differently
317
+ // depending on where you looked at it.
318
+ export function checkContextState(c) {
319
+ if (c.__typename === 'StatusContext') {
320
+ switch ((c.state ?? '').toUpperCase()) {
321
+ case 'SUCCESS':
322
+ return 'success';
323
+ case 'FAILURE':
324
+ return 'failure';
325
+ case 'ERROR':
326
+ return 'error';
327
+ case 'PENDING':
328
+ case 'EXPECTED':
329
+ return 'pending';
330
+ default:
331
+ return 'unknown';
332
+ }
333
+ }
334
+ // CheckRun: outcome is meaningful only once completed.
335
+ if ((c.status ?? '').toUpperCase() !== 'COMPLETED')
336
+ return 'pending';
337
+ switch ((c.conclusion ?? '').toUpperCase()) {
338
+ case 'SUCCESS':
339
+ return 'success';
340
+ case 'FAILURE':
341
+ case 'TIMED_OUT':
342
+ case 'STARTUP_FAILURE':
343
+ case 'ACTION_REQUIRED':
344
+ return 'failure';
345
+ case 'CANCELLED':
346
+ case 'STALE':
347
+ case 'NEUTRAL':
348
+ return 'neutral';
349
+ case 'SKIPPED':
350
+ return 'skipped';
351
+ default:
352
+ return 'unknown';
353
+ }
354
+ }
355
+ // A GitHub Actions check's detailsUrl is .../actions/runs/<runId>/job/<jobId> — parse
356
+ // the two ids so the frontend can fetch that job's logs on demand. Third-party CI
357
+ // (StatusContext / external CheckRuns) has a detailsUrl pointing elsewhere; no match →
358
+ // null, and the UI keeps it as a plain external link (logs aren't retrievable).
359
+ const ACTIONS_JOB_RE = /\/actions\/runs\/(\d+)\/job\/(\d+)/;
360
+ // Exported for the default-branch commit checks, which parse the same detailsUrl shape.
361
+ export function parseActionsIds(url) {
362
+ const m = url ? ACTIONS_JOB_RE.exec(url) : null;
363
+ if (!m)
364
+ return { runId: null, jobId: null };
365
+ return { runId: Number(m[1]), jobId: Number(m[2]) };
366
+ }
367
+ export function checkRunsFrom(head) {
368
+ const nodes = head?.statusCheckRollup?.contexts?.nodes ?? [];
369
+ const mapped = nodes.map((c) => {
370
+ const url = c.__typename === 'CheckRun' ? c.detailsUrl : c.targetUrl;
371
+ const { runId, jobId } = c.__typename === 'CheckRun' ? parseActionsIds(url) : { runId: null, jobId: null };
372
+ return {
373
+ name: c.__typename === 'CheckRun' ? c.name : c.context,
374
+ state: checkContextState(c),
375
+ url,
376
+ runId,
377
+ jobId,
378
+ };
379
+ });
380
+ // GitHub's `statusCheckRollup.contexts` returns EVERY check attached to the head
381
+ // commit's check suites — it does NOT collapse to "latest per check name" the way the
382
+ // PR UI does. When a head commit is covered by more than one workflow RUN (a re-run,
383
+ // or a workflow fired by both the `push` and `pull_request` events — classic for
384
+ // Dependabot), each run contributes its own same-named CheckRun, so the same check
385
+ // appears two+ times. Dedupe by display name, keeping the NEWEST run (Actions run ids
386
+ // increase monotonically, so the highest runId is the most recent — matching the PR
387
+ // UI); a real CheckRun (has a runId) beats a same-named StatusContext (runId null).
388
+ // Map preserves first-insertion order, so distinct checks keep their order.
389
+ const byName = new Map();
390
+ for (const c of mapped) {
391
+ const prev = byName.get(c.name);
392
+ if (!prev || (c.runId ?? -1) > (prev.runId ?? -1))
393
+ byName.set(c.name, c);
394
+ }
395
+ return [...byName.values()];
396
+ }
397
+ const REVIEW_STATES = new Set([
398
+ 'approved',
399
+ 'changes_requested',
400
+ 'commented',
401
+ 'dismissed',
402
+ 'pending',
403
+ ]);
404
+ function reviewState(s) {
405
+ const lower = s.toLowerCase();
406
+ return (REVIEW_STATES.has(lower) ? lower : 'commented');
407
+ }
408
+ // A review warrants its own timeline marker only when it's substantive: a
409
+ // decision (approved/changes_requested/dismissed) or one carrying a summary
410
+ // body. An empty "commented" review is just GitHub's wrapper around inline
411
+ // comments — those already show as review_comment markers, so the wrapper would
412
+ // duplicate them. See db/cleanup.ts for backfilling existing rows.
413
+ export function isSubstantiveReview(state, body) {
414
+ return state !== 'commented' || !!body?.trim();
415
+ }
416
+ // Lifecycle transitions GitHub exposes no discrete event for, detected by comparing the
417
+ // PR's prior persisted row (`prev`) against the incoming sync (`next`). Returns the
418
+ // event types to emit: draft→ready (`pr_ready_for_review`) and closed→open
419
+ // (`pr_reopened`). `prev` is null on a PR's first sight, so nothing is emitted then —
420
+ // we never observed it as draft/closed, so there's no transition to report.
421
+ export function lifecycleTransitions(prev, next) {
422
+ const out = [];
423
+ if (prev?.isDraft === true && next.isDraft === false)
424
+ out.push('pr_ready_for_review');
425
+ if (prev?.state === 'closed' && next.state === 'open')
426
+ out.push('pr_reopened');
427
+ return out;
428
+ }
429
+ async function upsertEvent(exec, row) {
430
+ await exec
431
+ .insert(events)
432
+ .values(row)
433
+ .onConflictDoUpdate({
434
+ target: [events.accountId, events.dedupeKey],
435
+ set: {
436
+ actorId: row.actorId,
437
+ occurredAt: row.occurredAt,
438
+ refTable: row.refTable,
439
+ refId: row.refId,
440
+ },
441
+ })
442
+ .execute();
443
+ }
444
+ /**
445
+ * Persist a single PR and all its nested entities idempotently, derive thread
446
+ * states, and emit timeline events. `commitFilesBySha` must already be
447
+ * populated for any commits relevant to unresolved-thread derivation.
448
+ */
449
+ // Decide a review thread's resolvedAt on this sync. We can't read GitHub's true resolution time,
450
+ // so we approximate it by the sync that FIRST observes the thread flip unresolved→resolved:
451
+ // • a prior stamp is preserved (idempotent — never moves once set),
452
+ // • a witnessed unresolved→resolved transition stamps `observedAt`,
453
+ // • a thread already resolved the first time we see it (no prior row, or prior already resolved
454
+ // with a null stamp) stays null — its true resolution time is unknowable, so it's excluded
455
+ // from the resolution-latency metric rather than counted with a bogus (deploy-time) latency.
456
+ export function nextResolvedAt(prior, isResolved, observedAt) {
457
+ if (prior?.resolvedAt != null)
458
+ return prior.resolvedAt;
459
+ if (isResolved && prior != null && !prior.isResolved)
460
+ return observedAt;
461
+ return null;
462
+ }
463
+ /**
464
+ * Persist a PR's review-request HISTORY (`review_request_events`) and stamp that we received it.
465
+ * Shared by persistPr and the one-time backfill so the two write one shape.
466
+ *
467
+ * ⚠ `nodes === undefined | null` IS "NOT RECEIVED" AND WRITES NOTHING — not the events, not the
468
+ * stamp. A fixture without the selection and a token GitHub nulled it for both land here, and
469
+ * stamping them would tell Chronology "never requested" about PRs we simply could not see. An
470
+ * EMPTY array is a positive statement (no request ever made) and IS stamped.
471
+ *
472
+ * Events are immutable on GitHub, so each is inserted DO NOTHING on (pr_id, node id).
473
+ */
474
+ export async function persistReviewRequestHistory(exec, prId, nodes, resolver) {
475
+ if (nodes == null)
476
+ return;
477
+ for (const n of nodes) {
478
+ if (n == null || !n.id || !n.createdAt)
479
+ continue;
480
+ const kind = n.__typename === 'ReviewRequestedEvent'
481
+ ? 'requested'
482
+ : n.__typename === 'ReviewRequestRemovedEvent'
483
+ ? 'removed'
484
+ : null;
485
+ if (kind == null)
486
+ continue;
487
+ const r = n.requestedReviewer;
488
+ let reviewerKind = 'unknown';
489
+ let reviewerUserId = null;
490
+ let teamSlug = null;
491
+ if (r && (r.__typename === 'User' || r.__typename === 'Bot') && r.login) {
492
+ reviewerKind = 'user';
493
+ reviewerUserId = await resolver.resolve(exec, {
494
+ login: r.login,
495
+ id: r.id,
496
+ __typename: r.__typename,
497
+ });
498
+ }
499
+ else if (r && r.__typename === 'Team') {
500
+ reviewerKind = 'team';
501
+ teamSlug = r.slug ?? null;
502
+ }
503
+ await exec
504
+ .insert(reviewRequestEvents)
505
+ .values({
506
+ prId,
507
+ githubNodeId: n.id,
508
+ kind,
509
+ occurredAt: new Date(n.createdAt),
510
+ reviewerKind,
511
+ reviewerUserId,
512
+ teamSlug,
513
+ })
514
+ .onConflictDoNothing({ target: [reviewRequestEvents.prId, reviewRequestEvents.githubNodeId] })
515
+ .execute();
516
+ }
517
+ await exec
518
+ .update(pullRequests)
519
+ .set({ reviewRequestsSyncedAt: new Date() })
520
+ .where(eq(pullRequests.id, prId))
521
+ .execute();
522
+ }
523
+ /** One classification → the four columns, for BOTH writers (`persistPr` and the one-shot backfill
524
+ * in sync/backfill-pr-security.ts), so a walk and a backfill can never store the same PR two ways.
525
+ * An empty id list is stored as NULL. The caller owns the "only on a received `bodyText`" gate. */
526
+ export function securityColumnsFor(input) {
527
+ const s = classifyPrSecurity(input);
528
+ return {
529
+ dependencyVendor: s.dependencyVendor,
530
+ securityFix: s.securityFix,
531
+ advisoryIds: s.advisoryIds.length > 0 ? s.advisoryIds : null,
532
+ securityCheckedAt: new Date(),
533
+ };
534
+ }
535
+ export async function persistPr(pr, repoId, resolver, commitFilesBySha, accountId) {
536
+ // DEPENDENCY + SECURITY SIGNALS — the three-state write (the `queueObserved` pattern below).
537
+ // GitHub types `bodyText` String!, so a null can only be a partial response and `undefined` a
538
+ // response that never carried the selection: both LEARN NOTHING and write nothing. A string —
539
+ // even '' — is a positive statement, and the classification is written whole, NULLs included.
540
+ // ⚠ The FULL bodyText, never `searchRows`/`search_index`: that copy is capped at 4,000
541
+ // characters and Dependabot's security footer is the last line of a body that runs to 65 KB.
542
+ // ⚠ Classified BEFORE the transaction: it is pure CPU over text the PR's author wrote, and no
543
+ // regex over that text should run while the write lock is held.
544
+ const securityObserved = typeof pr.bodyText === 'string'
545
+ ? securityColumnsFor({
546
+ title: pr.title,
547
+ headRefName: pr.headRefName ?? null,
548
+ labels: (pr.labels?.nodes ?? []).map((l) => l.name),
549
+ bodyText: pr.bodyText,
550
+ })
551
+ : {};
552
+ // Set inside the transaction from the `prev` read; the SPA change signal is raised only AFTER
553
+ // the commit, so it can never announce a row a reader cannot see yet.
554
+ let boardMoved = false;
555
+ await runTransaction(async (tx) => {
556
+ const authorId = await resolver.resolve(tx, pr.author);
557
+ // The actual merger (null for non-merged PRs / when GitHub omits the actor).
558
+ const mergedById = await resolver.resolve(tx, pr.mergedBy);
559
+ const openedAt = new Date(pr.createdAt);
560
+ const mergedAt = toDate(pr.mergedAt);
561
+ const closedAt = toDate(pr.closedAt);
562
+ const commitDates = pr.commits.nodes.map((c) => new Date(c.commit.committedDate));
563
+ const lastCommitAt = maxDate(commitDates);
564
+ const firstReviewAt = minDate(pr.reviews.nodes.map((r) => toDate(r.submittedAt)));
565
+ // Earliest review request (timeline first:1 is chronological; min guards any ordering surprise).
566
+ const firstReviewRequestedAt = minDate((pr.firstReviewRequest?.nodes ?? []).map((n) => toDate(n.createdAt ?? null)));
567
+ const head = pr.headCommit?.nodes[0]?.commit;
568
+ const headSha = head?.oid ?? null;
569
+ const ciStatus = ciStatusFrom(head?.statusCheckRollup?.state);
570
+ const mergeable = mergeableFrom(pr.mergeable);
571
+ const mergeStateStatus = mergeStateStatusFrom(pr.mergeStateStatus);
572
+ const reviewDecision = reviewDecisionFrom(pr.reviewDecision);
573
+ // ---- merge queue: the THREE-STATE partial-response write --------------------------------
574
+ //
575
+ // `graphqlTolerant` hands back GitHub's partial data with a forbidden field NULLED — the key
576
+ // is PRESENT, not absent — so "GitHub says this PR is not queued" and "we never received the
577
+ // selection" look identical at the JSON level, and they demand opposite writes. Writing
578
+ // unconditionally would clear a real queue entry on every tick for a partially-forbidden
579
+ // token; writing only on non-null would leave a dequeued PR reading "queued" forever.
580
+ //
581
+ // The discriminator is free here and needs no `pagePartial` flag threaded down from
582
+ // sync-repo.ts (which is how `repos.description` resolves the same problem): GitHub types
583
+ // `isInMergeQueue` as `Boolean!`, so a real answer is only ever true or false. A null can
584
+ // ONLY be a nulled-by-partial-error field, and `undefined` can only be a response (or a
585
+ // hand-built fixture) that never carried the selection. Both mean "learn nothing".
586
+ //
587
+ // `false` IS a positive statement and MUST clear both columns — a repo with the merge queue
588
+ // switched off answers false with a null entry every walk, and so does a PR that just landed
589
+ // or was ejected. That is the case this whole column exists to make visible.
590
+ const queueObserved = pr.isInMergeQueue == null
591
+ ? {}
592
+ : {
593
+ inMergeQueue: pr.isInMergeQueue,
594
+ // Not queued ⇒ no entry, whatever the (absent) object says. Queued ⇒ the entry's
595
+ // state, or null when GitHub gave us the membership but not a state we model — see
596
+ // mergeQueueEntryStateFrom on why an unmodelled state is not stored raw.
597
+ mergeQueueEntryState: pr.isInMergeQueue
598
+ ? mergeQueueEntryStateFrom(pr.mergeQueueEntry?.state)
599
+ : null,
600
+ };
601
+ const labels = (pr.labels?.nodes ?? []).map((l) => ({
602
+ name: l.name,
603
+ color: l.color,
604
+ }));
605
+ const checkRuns = checkRunsFrom(head);
606
+ // Diff size — always stored (small metadata, not bulky text), so the PR-detail
607
+ // LOC label + "Changes" tab work in every mode straight from the DB. The files
608
+ // list is capped at 100 by the query; changedFiles keeps the true total.
609
+ const additions = pr.additions ?? 0;
610
+ const deletions = pr.deletions ?? 0;
611
+ const changedFiles = pr.changedFiles ?? 0;
612
+ const files = (pr.files?.nodes ?? []).map((f) => ({
613
+ path: f.path,
614
+ additions: f.additions ?? 0,
615
+ deletions: f.deletions ?? 0,
616
+ }));
617
+ // The PR's prior draft/state, read BEFORE the upsert below, so we can emit the
618
+ // lifecycle transitions GitHub doesn't expose as discrete events (draft → ready,
619
+ // reopened). null on first sight of a PR → no transition event.
620
+ //
621
+ // The board-visible columns ride the same read so the SPA's change signal
622
+ // (sync/pr-change-signal.ts) fires only when one of them actually MOVED — a walk that
623
+ // restates what is stored must not cascade a refetch over every screen.
624
+ const prev = (await tx
625
+ .select({
626
+ isDraft: pullRequests.isDraft,
627
+ state: pullRequests.state,
628
+ headSha: pullRequests.headSha,
629
+ ciStatus: pullRequests.ciStatus,
630
+ mergeable: pullRequests.mergeable,
631
+ mergeStateStatus: pullRequests.mergeStateStatus,
632
+ reviewDecision: pullRequests.reviewDecision,
633
+ inMergeQueue: pullRequests.inMergeQueue,
634
+ mergeQueueEntryState: pullRequests.mergeQueueEntryState,
635
+ updatedAt: pullRequests.updatedAt,
636
+ })
637
+ .from(pullRequests)
638
+ .where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.githubNodeId, pr.id)))
639
+ .execute())[0] ?? null;
640
+ boardMoved = boardVisibleMoved(prev, {
641
+ state: prState(pr.state),
642
+ isDraft: pr.isDraft,
643
+ headSha,
644
+ ciStatus,
645
+ mergeable,
646
+ mergeStateStatus,
647
+ reviewDecision,
648
+ updatedAt: new Date(pr.updatedAt),
649
+ ...queueObserved,
650
+ });
651
+ const prRow = (await tx
652
+ .insert(pullRequests)
653
+ .values({
654
+ accountId,
655
+ githubNodeId: pr.id,
656
+ repoId,
657
+ number: pr.number,
658
+ title: pr.title,
659
+ // Lean mode (cloud): the PR description and the per-job checkRuns JSON are
660
+ // not stored — hydrated on demand. ciStatus (the summary enum) is kept.
661
+ body: config.persistBodies ? (pr.body ?? null) : null,
662
+ authorId,
663
+ mergedById,
664
+ baseRefName: pr.baseRefName ?? null,
665
+ headRefName: pr.headRefName ?? null,
666
+ state: prState(pr.state),
667
+ isDraft: pr.isDraft,
668
+ openedAt,
669
+ firstReviewAt,
670
+ firstReviewRequestedAt,
671
+ lastCommitAt,
672
+ mergedAt,
673
+ closedAt,
674
+ updatedAt: new Date(pr.updatedAt),
675
+ headSha,
676
+ ciStatus,
677
+ mergeable,
678
+ mergeStateStatus,
679
+ reviewDecision,
680
+ // Spread, never assigned: an omitted key leaves the column at its NULL default on
681
+ // INSERT and untouched on UPDATE, which is exactly what "we did not receive that
682
+ // selection" means. Same shape as branch-status.ts's `observed`.
683
+ ...queueObserved,
684
+ labels,
685
+ ...securityObserved,
686
+ checkRuns: config.persistBodies ? checkRuns : null,
687
+ additions,
688
+ deletions,
689
+ changedFiles,
690
+ files,
691
+ })
692
+ .onConflictDoUpdate({
693
+ target: [pullRequests.accountId, pullRequests.githubNodeId],
694
+ set: {
695
+ title: pr.title,
696
+ body: config.persistBodies ? (pr.body ?? null) : null,
697
+ authorId,
698
+ mergedById,
699
+ baseRefName: pr.baseRefName ?? null,
700
+ headRefName: pr.headRefName ?? null,
701
+ state: prState(pr.state),
702
+ isDraft: pr.isDraft,
703
+ firstReviewAt,
704
+ firstReviewRequestedAt,
705
+ lastCommitAt,
706
+ mergedAt,
707
+ closedAt,
708
+ updatedAt: new Date(pr.updatedAt),
709
+ headSha,
710
+ ciStatus,
711
+ mergeable,
712
+ mergeStateStatus,
713
+ reviewDecision,
714
+ ...queueObserved,
715
+ labels,
716
+ ...securityObserved,
717
+ checkRuns: config.persistBodies ? checkRuns : null,
718
+ additions,
719
+ deletions,
720
+ changedFiles,
721
+ files,
722
+ },
723
+ })
724
+ .returning({ id: pullRequests.id })
725
+ .execute())[0];
726
+ const prId = prRow.id;
727
+ // ---- cross-team search index rows (accumulated, written once at the end) ----
728
+ // `bodyText` (plain-text description) is fetched unconditionally, so the PR row indexes the
729
+ // description even under lean storage where pullRequests.body is null. Review / comment rows are
730
+ // pushed at their insert sites below. The whole set is rebuilt per-PR (delete + insert) so an
731
+ // edited/removed comment never leaves a stale hit.
732
+ const searchRows = [];
733
+ searchRows.push({
734
+ kind: 'pr',
735
+ refId: prId,
736
+ threadId: null,
737
+ authorId,
738
+ body: searchText(pr.title, pr.bodyText),
739
+ createdAt: openedAt,
740
+ });
741
+ // ---- CI status transition log (DORA-ish CI metrics) ----
742
+ // Record a row whenever this PR head's CI rollup / failing-check set / head SHA
743
+ // differs from the last snapshot — an append-only transition log (not a per-tick
744
+ // dump). Uses the fresh GraphQL checkRuns (populated even in lean mode, unlike the
745
+ // stored column), so the failing check NAMES are the stage-level failure reasons.
746
+ if (headSha != null && ciStatus !== 'unknown') {
747
+ const failing = [
748
+ ...new Set(checkRuns
749
+ .filter((c) => c.state === 'failure' || c.state === 'error')
750
+ .map((c) => c.name)),
751
+ ].sort();
752
+ const lastCi = (await tx
753
+ .select({
754
+ status: ciStatusEvents.status,
755
+ headSha: ciStatusEvents.headSha,
756
+ failingChecks: ciStatusEvents.failingChecks,
757
+ })
758
+ .from(ciStatusEvents)
759
+ .where(and(eq(ciStatusEvents.accountId, accountId), eq(ciStatusEvents.prId, prId)))
760
+ .orderBy(desc(ciStatusEvents.observedAt))
761
+ .limit(1)
762
+ .execute())[0];
763
+ const changed = lastCi == null ||
764
+ lastCi.status !== ciStatus ||
765
+ lastCi.headSha !== headSha ||
766
+ JSON.stringify([...(lastCi.failingChecks ?? [])].sort()) !== JSON.stringify(failing);
767
+ if (changed) {
768
+ await tx
769
+ .insert(ciStatusEvents)
770
+ .values({
771
+ accountId,
772
+ repoId,
773
+ prId,
774
+ headSha,
775
+ status: ciStatus,
776
+ failingChecks: failing,
777
+ observedAt: new Date(),
778
+ })
779
+ .execute();
780
+ }
781
+ }
782
+ // ---- review requests (outstanding) — reconcile by delete + reinsert ----
783
+ await tx.delete(reviewRequests).where(eq(reviewRequests.prId, prId)).execute();
784
+ for (const rr of pr.reviewRequests?.nodes ?? []) {
785
+ const reviewer = rr.requestedReviewer;
786
+ if (!reviewer)
787
+ continue;
788
+ if (reviewer.__typename === 'User') {
789
+ const userId = await resolver.resolve(tx, {
790
+ login: reviewer.login,
791
+ id: reviewer.id,
792
+ });
793
+ await tx.insert(reviewRequests).values({ prId, userId, teamName: null }).execute();
794
+ }
795
+ else if (reviewer.__typename === 'Team') {
796
+ await tx
797
+ .insert(reviewRequests)
798
+ .values({ prId, userId: null, teamName: reviewer.name })
799
+ .execute();
800
+ }
801
+ }
802
+ // ---- review-request HISTORY (Chronology's "asked to first look") ----
803
+ await persistReviewRequestHistory(tx, prId, pr.reviewRequestHistory?.nodes, resolver);
804
+ // ---- lifecycle events ----
805
+ await upsertEvent(tx, {
806
+ accountId,
807
+ repoId,
808
+ actorId: authorId,
809
+ prId,
810
+ type: 'pr_opened',
811
+ occurredAt: openedAt,
812
+ refTable: 'pull_requests',
813
+ refId: prId,
814
+ dedupeKey: `pr_opened:${pr.id}`,
815
+ });
816
+ if (pr.state === 'MERGED' && mergedAt) {
817
+ await upsertEvent(tx, {
818
+ accountId,
819
+ repoId,
820
+ actorId: authorId,
821
+ prId,
822
+ type: 'pr_merged',
823
+ occurredAt: mergedAt,
824
+ refTable: 'pull_requests',
825
+ refId: prId,
826
+ dedupeKey: `pr_merged:${pr.id}`,
827
+ });
828
+ }
829
+ else if (pr.state === 'CLOSED' && closedAt) {
830
+ await upsertEvent(tx, {
831
+ accountId,
832
+ repoId,
833
+ actorId: authorId,
834
+ prId,
835
+ type: 'pr_closed',
836
+ occurredAt: closedAt,
837
+ refTable: 'pull_requests',
838
+ refId: prId,
839
+ dedupeKey: `pr_closed:${pr.id}`,
840
+ });
841
+ }
842
+ // Transitions GitHub gives no discrete event for (draft→ready, reopened): emitted
843
+ // only when THIS sync observed the flip (`prev` is the pre-upsert row), so a PR
844
+ // first seen already ready/open emits nothing. dedupeKey is keyed on the PR (one
845
+ // ready / one reopened per PR) — re-toggling re-emits at most once. occurredAt =
846
+ // updatedAt is the closest available signal (GitHub gives no ready-at/reopened-at).
847
+ for (const type of lifecycleTransitions(prev, {
848
+ isDraft: pr.isDraft,
849
+ state: prState(pr.state),
850
+ })) {
851
+ await upsertEvent(tx, {
852
+ accountId,
853
+ repoId,
854
+ actorId: authorId,
855
+ prId,
856
+ type,
857
+ occurredAt: new Date(pr.updatedAt),
858
+ refTable: 'pull_requests',
859
+ refId: prId,
860
+ dedupeKey: `${type}:${pr.id}`,
861
+ });
862
+ }
863
+ // ---- reviews ----
864
+ for (const r of pr.reviews.nodes) {
865
+ const reviewerId = await resolver.resolve(tx, r.author);
866
+ const submittedAt = toDate(r.submittedAt);
867
+ if (!submittedAt)
868
+ continue; // pending reviews have no timestamp
869
+ const reviewRow = (await tx
870
+ .insert(reviews)
871
+ .values({
872
+ githubNodeId: r.id,
873
+ prId,
874
+ authorId: reviewerId,
875
+ state: reviewState(r.state),
876
+ // Review bodies are always persisted (Feed renders them; also drives
877
+ // substantive-review detection) regardless of lean storage.
878
+ body: r.body ?? null,
879
+ databaseId: r.fullDatabaseId ?? null,
880
+ submittedAt,
881
+ })
882
+ .onConflictDoUpdate({
883
+ target: [reviews.prId, reviews.githubNodeId],
884
+ set: {
885
+ state: reviewState(r.state),
886
+ body: r.body ?? null,
887
+ databaseId: r.fullDatabaseId ?? null,
888
+ submittedAt,
889
+ },
890
+ })
891
+ .returning({ id: reviews.id })
892
+ .execute())[0];
893
+ const reviewBody = searchText(r.body);
894
+ if (reviewBody)
895
+ searchRows.push({
896
+ kind: 'review',
897
+ refId: reviewRow.id,
898
+ threadId: null,
899
+ authorId: reviewerId,
900
+ body: reviewBody,
901
+ createdAt: submittedAt,
902
+ });
903
+ if (isSubstantiveReview(reviewState(r.state), r.body)) {
904
+ await upsertEvent(tx, {
905
+ accountId,
906
+ repoId,
907
+ actorId: reviewerId,
908
+ prId,
909
+ type: 'review_submitted',
910
+ occurredAt: submittedAt,
911
+ refTable: 'reviews',
912
+ refId: reviewRow.id,
913
+ dedupeKey: `review_submitted:${r.id}`,
914
+ });
915
+ }
916
+ }
917
+ // ---- review threads + comments ----
918
+ const commitInputs = pr.commits.nodes.map((c) => ({
919
+ oid: c.commit.oid,
920
+ committedDate: c.commit.committedDate,
921
+ }));
922
+ // Prior resolved-state per thread (this PR) so we can stamp resolvedAt on a WITNESSED
923
+ // unresolved→resolved flip only. A thread already resolved the first time we see it keeps a
924
+ // null resolvedAt (its true resolution time is unknown). Read once, before the upserts.
925
+ const priorThreads = new Map();
926
+ for (const row of await tx
927
+ .select({
928
+ nodeId: reviewThreads.githubNodeId,
929
+ isResolved: reviewThreads.isResolved,
930
+ resolvedAt: reviewThreads.resolvedAt,
931
+ })
932
+ .from(reviewThreads)
933
+ .where(eq(reviewThreads.prId, prId))
934
+ .execute())
935
+ priorThreads.set(row.nodeId, { isResolved: row.isResolved, resolvedAt: row.resolvedAt });
936
+ const observedAt = new Date();
937
+ for (const t of pr.reviewThreads.nodes) {
938
+ const commentNodes = t.comments.nodes;
939
+ const originalCommenterId = await resolver.resolve(tx, commentNodes[0]?.author);
940
+ const resolvedByLogin = t.resolvedBy?.login ?? null;
941
+ // Preserve a prior stamp; set it fresh only when we observe unresolved→resolved this sync.
942
+ const resolvedAt = nextResolvedAt(priorThreads.get(t.id), t.isResolved, observedAt);
943
+ const { state: derivedState, addressedConfidence, addressedReason } = deriveThreadState({
944
+ isResolved: t.isResolved,
945
+ path: t.path,
946
+ isOutdated: t.isOutdated,
947
+ resolvedByLogin,
948
+ comments: commentNodes.map((c) => ({
949
+ author: c.author ? { login: c.author.login } : null,
950
+ createdAt: c.createdAt,
951
+ body: c.body ?? null,
952
+ })),
953
+ }, commitInputs, commitFilesBySha);
954
+ const threadRow = (await tx
955
+ .insert(reviewThreads)
956
+ .values({
957
+ githubNodeId: t.id,
958
+ prId,
959
+ path: t.path,
960
+ line: t.line,
961
+ isResolved: t.isResolved,
962
+ isOutdated: t.isOutdated,
963
+ derivedState,
964
+ addressedConfidence,
965
+ addressedReason,
966
+ resolvedByLogin,
967
+ resolvedAt,
968
+ originalCommenterId,
969
+ createdAt: commentNodes[0]
970
+ ? new Date(commentNodes[0].createdAt)
971
+ : new Date(pr.createdAt),
972
+ })
973
+ .onConflictDoUpdate({
974
+ target: [reviewThreads.prId, reviewThreads.githubNodeId],
975
+ set: {
976
+ isResolved: t.isResolved,
977
+ isOutdated: t.isOutdated,
978
+ derivedState,
979
+ addressedConfidence,
980
+ addressedReason,
981
+ resolvedByLogin,
982
+ resolvedAt,
983
+ line: t.line,
984
+ },
985
+ })
986
+ .returning({ id: reviewThreads.id })
987
+ .execute())[0];
988
+ for (const c of commentNodes) {
989
+ const commenterId = await resolver.resolve(tx, c.author);
990
+ const createdAt = new Date(c.createdAt);
991
+ const rcRow = (await tx
992
+ .insert(reviewComments)
993
+ .values({
994
+ githubNodeId: c.id,
995
+ threadId: threadRow.id,
996
+ prId,
997
+ authorId: commenterId,
998
+ // Review-comment bodies are always persisted (Feed renders them); only
999
+ // the large diff hunk stays lean-gated. Excerpt kept for triage.
1000
+ body: c.body,
1001
+ excerpt: excerptOf(c.body),
1002
+ diffHunk: config.persistBodies ? (c.diffHunk ?? null) : null,
1003
+ databaseId: c.fullDatabaseId ?? null,
1004
+ createdAt,
1005
+ })
1006
+ .onConflictDoUpdate({
1007
+ target: [reviewComments.prId, reviewComments.githubNodeId],
1008
+ set: {
1009
+ body: c.body,
1010
+ excerpt: excerptOf(c.body),
1011
+ diffHunk: config.persistBodies ? (c.diffHunk ?? null) : null,
1012
+ databaseId: c.fullDatabaseId ?? null,
1013
+ },
1014
+ })
1015
+ .returning({ id: reviewComments.id })
1016
+ .execute())[0];
1017
+ const rcBody = searchText(c.body);
1018
+ if (rcBody)
1019
+ searchRows.push({
1020
+ kind: 'review_comment',
1021
+ refId: rcRow.id,
1022
+ // Deep-link target: a review-comment hit opens the PR's Threads tab on this thread.
1023
+ threadId: threadRow.id,
1024
+ authorId: commenterId,
1025
+ body: rcBody,
1026
+ createdAt,
1027
+ });
1028
+ await upsertEvent(tx, {
1029
+ accountId,
1030
+ repoId,
1031
+ actorId: commenterId,
1032
+ prId,
1033
+ type: 'review_comment',
1034
+ occurredAt: createdAt,
1035
+ // Point at the thread for in-app navigation.
1036
+ refTable: 'review_threads',
1037
+ refId: threadRow.id,
1038
+ dedupeKey: `review_comment:${c.id}`,
1039
+ });
1040
+ }
1041
+ }
1042
+ // ---- general PR comments ----
1043
+ for (const c of pr.comments.nodes) {
1044
+ const commenterId = await resolver.resolve(tx, c.author);
1045
+ const createdAt = new Date(c.createdAt);
1046
+ const commentRow = (await tx
1047
+ .insert(prComments)
1048
+ .values({
1049
+ githubNodeId: c.id,
1050
+ prId,
1051
+ authorId: commenterId,
1052
+ // PR-comment bodies are always persisted (Feed renders them).
1053
+ body: c.body ?? null,
1054
+ databaseId: c.fullDatabaseId ?? null,
1055
+ createdAt,
1056
+ })
1057
+ .onConflictDoUpdate({
1058
+ target: [prComments.prId, prComments.githubNodeId],
1059
+ set: {
1060
+ body: c.body ?? null,
1061
+ databaseId: c.fullDatabaseId ?? null,
1062
+ },
1063
+ })
1064
+ .returning({ id: prComments.id })
1065
+ .execute())[0];
1066
+ const pcBody = searchText(c.body);
1067
+ if (pcBody)
1068
+ searchRows.push({
1069
+ kind: 'pr_comment',
1070
+ refId: commentRow.id,
1071
+ threadId: null,
1072
+ authorId: commenterId,
1073
+ body: pcBody,
1074
+ createdAt,
1075
+ });
1076
+ await upsertEvent(tx, {
1077
+ accountId,
1078
+ repoId,
1079
+ actorId: commenterId,
1080
+ prId,
1081
+ type: 'pr_comment',
1082
+ occurredAt: createdAt,
1083
+ refTable: 'pr_comments',
1084
+ refId: commentRow.id,
1085
+ dedupeKey: `pr_comment:${c.id}`,
1086
+ });
1087
+ }
1088
+ // ---- commits ----
1089
+ for (const node of pr.commits.nodes) {
1090
+ const c = node.commit;
1091
+ const commitAuthorId = await resolver.resolve(tx, c.author?.user
1092
+ ? { login: c.author.user.login, id: c.author.user.id }
1093
+ : null);
1094
+ const committerId = await resolver.resolve(tx, c.committer?.user
1095
+ ? { login: c.committer.user.login, id: c.committer.user.id }
1096
+ : null);
1097
+ const committedAt = new Date(c.committedDate);
1098
+ // The subject line, written ONLY when GitHub sent the key (a tolerant partial or an old
1099
+ // fixture omits it) — spreading `{}` leaves a stored headline alone instead of clearing it.
1100
+ const headline = c.messageHeadline === undefined
1101
+ ? {}
1102
+ : { messageHeadline: c.messageHeadline == null ? null : c.messageHeadline.slice(0, 200) };
1103
+ // Upsert (not DoNothing) so we always get the row id back to point the
1104
+ // timeline event at — the marker modal resolves the commit via ref_id.
1105
+ const commitRow = (await tx
1106
+ .insert(commits)
1107
+ .values({
1108
+ sha: c.oid,
1109
+ prId,
1110
+ authorId: commitAuthorId,
1111
+ committerId,
1112
+ message: config.persistBodies ? c.message : null,
1113
+ ...headline,
1114
+ committedAt,
1115
+ })
1116
+ .onConflictDoUpdate({
1117
+ target: [commits.sha, commits.prId],
1118
+ set: { message: config.persistBodies ? c.message : null, ...headline, committedAt },
1119
+ })
1120
+ .returning({ id: commits.id })
1121
+ .execute())[0];
1122
+ await upsertEvent(tx, {
1123
+ accountId,
1124
+ repoId,
1125
+ actorId: commitAuthorId ?? committerId,
1126
+ prId,
1127
+ type: 'commit_pushed',
1128
+ occurredAt: committedAt,
1129
+ refTable: 'commits',
1130
+ refId: commitRow.id,
1131
+ dedupeKey: `commit_pushed:${pr.id}:${c.oid}`,
1132
+ });
1133
+ }
1134
+ // ---- rebuild this PR's search-index rows ----
1135
+ // Delete-then-insert (inside the same tx) so an edited/removed comment leaves no stale hit and
1136
+ // re-sync is idempotent. Chunked so a comment-heavy PR stays under SQLite's bound variable limit
1137
+ // (999): ~7 columns × 100 rows = 700 < 999.
1138
+ await tx.delete(searchIndex).where(eq(searchIndex.prId, prId)).execute();
1139
+ for (let i = 0; i < searchRows.length; i += 100) {
1140
+ await tx
1141
+ .insert(searchIndex)
1142
+ .values(searchRows.slice(i, i + 100).map((r) => ({ accountId, repoId, prId, ...r })))
1143
+ .execute();
1144
+ }
1145
+ });
1146
+ if (boardMoved)
1147
+ notePrChanged(accountId, repoId);
1148
+ }
1149
+ /**
1150
+ * Did this write move anything a reader can see?
1151
+ *
1152
+ * • A first sighting (`prev === null`) counts ONLY for an OPEN PR — a new card. A merged or
1153
+ * closed PR first seen by the 90-day deep backfill (hundreds of them, minutes apart) changes no
1154
+ * board, and counting it cascaded a whole-app refetch on every SPA poll of a backfill; the
1155
+ * walk's own completion stamp covers the history it brings in.
1156
+ * • `updatedAt` MOVING counts. GitHub bumps it for every new review, comment, reply and commit —
1157
+ * the rows the My turn / Unanswered threads / Waiting on review tabs fold — and a webhook or
1158
+ * settle sync that stored them otherwise raised nothing. It stays DIFF-GATED: a walk restating
1159
+ * an unchanged PR (the overlap window) carries the same `updatedAt`. What it cannot see is an
1160
+ * updatedAt-SILENT change (a thread resolve, CI finishing, mergeability computed) — the explicit
1161
+ * columns cover the last two.
1162
+ * • The merge-queue pair is compared ONLY when this response observed it (the three-state
1163
+ * `queueObserved` rule: an absent key wrote nothing, so it cannot have moved).
1164
+ * Exported for its unit test.
1165
+ */
1166
+ export function boardVisibleMoved(prev, next) {
1167
+ if (prev == null)
1168
+ return next.state === 'open';
1169
+ if (next.updatedAt !== undefined &&
1170
+ (prev.updatedAt == null || prev.updatedAt.getTime() !== next.updatedAt.getTime())) {
1171
+ return true;
1172
+ }
1173
+ if (prev.state !== next.state ||
1174
+ prev.isDraft !== next.isDraft ||
1175
+ prev.headSha !== next.headSha ||
1176
+ prev.ciStatus !== next.ciStatus ||
1177
+ prev.mergeable !== next.mergeable ||
1178
+ prev.mergeStateStatus !== next.mergeStateStatus ||
1179
+ prev.reviewDecision !== next.reviewDecision) {
1180
+ return true;
1181
+ }
1182
+ if (next.inMergeQueue !== undefined) {
1183
+ if (prev.inMergeQueue !== next.inMergeQueue)
1184
+ return true;
1185
+ if (prev.mergeQueueEntryState !== (next.mergeQueueEntryState ?? null))
1186
+ return true;
1187
+ }
1188
+ return false;
1189
+ }
1190
+ //# sourceMappingURL=upsert.js.map