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,820 @@
1
+ // ── The PERIOD METRIC VECTOR (CORE, deterministic — no model, no new table) ──────────────────
2
+ //
3
+ // The numbers behind the Insights "Reports" sub-tab: twelve figures describing ONE completed
4
+ // period ("18 Aug – 1 Sep"), plus the coverage read that keeps a comparison between two of them
5
+ // honest. The storage, the narration and the routes are plugin-owned; everything here is free,
6
+ // deterministic and reproducible.
7
+ //
8
+ // ⚠ IT WRITES ITS OWN SQL AND REUSES NONE OF THE EXISTING GETTERS. Measured against the live
9
+ // dev DB before this file existed: `getWorkspaceMetrics`' stat tiles honour an arbitrary window
10
+ // but its weekly series are a FIXED 12 weeks ending NOW, `openPrs`/`ciFailingNow` are
11
+ // current-state snapshots and `ciFailureReasons` has no upper bound at all;
12
+ // `getWorkspaceMetricsDetail` ignores `window.toMs` outright. None of them can answer "what did
13
+ // July look like", and bending them into it would move every existing tile. So this file is a
14
+ // second, narrower reader over the same tables — deliberately, and not by oversight.
15
+ //
16
+ // ⚠ EVERY METRIC IS **WINDOW-PURE**: a function of events timestamped in `[fromMs, toMs)` and
17
+ // nothing else. A stored historical period has to stay reproducible, and a snapshot is not — so
18
+ // `openPrs`, `ciFailingNow`, open-PR age and current thread state are all ABSENT from the vector
19
+ // even though the DORA header carries them. (Trunk red share is absent for a different reason:
20
+ // `trunk_ci_status_events` has no backfill — a known gap — so it is not computable for a past
21
+ // period AT ALL, and a metric that silently reads 0% green for every period before the feature
22
+ // shipped is worse than an omitted one.)
23
+ //
24
+ // ⚠ EVERY PREDICATE IS **TWO-SIDED**: `>= from AND < to`. A one-sided `gte` is precisely what
25
+ // makes the three existing getters unusable here, and the half-open upper bound is what stops
26
+ // two adjacent periods double-counting the event that lands on their shared boundary.
27
+ // `period-metrics.test.ts` pins both edges on four different columns.
28
+ //
29
+ // ⚠ RETROACTIVE HISTORY IS BIASED UNLESS COVERAGE IS RESPECTED, and that is what
30
+ // `getPeriodCoverage` is for. Measured: merged-PR counts by 14-day period over the last six
31
+ // months read 570, 572, 557, 494, 491, 537, 464, 292, 354, 232, 230, 177, 39 — which looks like
32
+ // explosive growth. The number of repos CONTRIBUTING to those same periods is 18, 19, 18, 18,
33
+ // 19, 18, 18, 13, 12, 10, 9, 6, 4. The "trend" is repo onboarding, not team output. A forecast
34
+ // fitted on that would predict growth that is purely a data artifact.
35
+ //
36
+ // ⚑ ITS OWN FILE, NOT queries.ts: that file is 13k lines and CONTAINS LITERAL NUL BYTES around
37
+ // offset 132k, so every search tool silently under-reports matches inside it. Nothing imports
38
+ // this file back, so importing queries.ts here creates no cycle (the db/bot-volume.ts and
39
+ // db/bot-overlap.ts precedent).
40
+ import { createHash } from 'node:crypto';
41
+ import { and, count, desc, eq, gte, inArray, lt, lte, ne } from 'drizzle-orm';
42
+ import { db, schema } from './client.js';
43
+ import { resolveActorLanes } from './actor-lanes.js';
44
+ import { listWorkspaces } from './queries.js';
45
+ // MIRRORED from @pierre-review/shared `ACTOR_LANES`, for the same release-guard reason the metric
46
+ // keys are (see below). Render order: people first, then the automation that AUTHORS code (the
47
+ // lanes that distort throughput), then the automation that RESPONDS to it (the lanes that distort
48
+ // review counts).
49
+ const ACTOR_LANE_ORDER = [
50
+ 'human',
51
+ 'code_agent',
52
+ 'dependency',
53
+ 'ai_review',
54
+ 'quality_gate',
55
+ 'release',
56
+ 'housekeeping',
57
+ ];
58
+ const { prComments, pullRequests, repos, reviewComments, reviews, reviewThreads } = schema;
59
+ // MIRRORED from @pierre-review/shared (PERIOD_METRICS_SCHEMA_VERSION / PERIOD_METRIC_KEYS).
60
+ // Inlined rather than imported for the same reason `AI_CREDITS_PER_USD` is inlined in
61
+ // db/credits.ts: the shared package is TYPES ONLY and is not shipped, so `build-release.mjs`
62
+ // greps `release/dist` and FAILS the build on a real runtime import of it. Two spellings, kept
63
+ // in lockstep — and `period-metrics.test.ts` imports the shared originals (a test is not in
64
+ // release/dist) and asserts the two arrays are identical, so the drift is caught in CI rather
65
+ // than by a reader.
66
+ export const PERIOD_METRICS_SCHEMA_VERSION = 2;
67
+ /** CLOSED + ORDERED at schema version 2. This order IS the render order and is part of the
68
+ * contract. Each human-only twin sits immediately after the blended figure it corrects — read
69
+ * adjacently they state the automation gap without narration. */
70
+ export const PERIOD_METRIC_KEYS = [
71
+ 'merged_prs',
72
+ 'human_merged_prs',
73
+ 'opened_prs',
74
+ 'automation_merge_share_pct',
75
+ 'median_lead_time_hours',
76
+ 'median_time_to_first_human_review_hours',
77
+ 'merge_ci_success_pct',
78
+ 'median_pr_size_lines',
79
+ 'median_human_pr_size_lines',
80
+ 'review_threads_opened',
81
+ 'threads_replied_within_36h_pct',
82
+ 'bot_review_comments',
83
+ 'human_review_comments',
84
+ 'bot_comments_per_merged_pr',
85
+ 'reviewer_concentration_pct',
86
+ ];
87
+ export const PERIOD_METRIC_META = {
88
+ merged_prs: { direction: 'up_good', sampleFloor: 5, absoluteFloor: 3 },
89
+ human_merged_prs: { direction: 'up_good', sampleFloor: 5, absoluteFloor: 3 },
90
+ opened_prs: { direction: 'neutral', sampleFloor: 5, absoluteFloor: 3 },
91
+ // NEUTRAL, and that is a product decision rather than a hedge. More automation is not
92
+ // self-evidently good (a team drowning in bumps) or bad (a team shipping with agents) — the
93
+ // lane split below is what makes the number readable, and an arrow claiming a direction would
94
+ // assert a judgement the figure cannot support.
95
+ automation_merge_share_pct: { direction: 'neutral', sampleFloor: 5, absoluteFloor: 5 },
96
+ median_lead_time_hours: { direction: 'down_good', sampleFloor: 5, absoluteFloor: 2 },
97
+ median_time_to_first_human_review_hours: {
98
+ direction: 'down_good',
99
+ sampleFloor: 5,
100
+ absoluteFloor: 1,
101
+ },
102
+ merge_ci_success_pct: { direction: 'up_good', sampleFloor: 5, absoluteFloor: 5 },
103
+ median_pr_size_lines: { direction: 'down_good', sampleFloor: 5, absoluteFloor: 20 },
104
+ median_human_pr_size_lines: { direction: 'down_good', sampleFloor: 5, absoluteFloor: 20 },
105
+ review_threads_opened: { direction: 'neutral', sampleFloor: 10, absoluteFloor: 5 },
106
+ threads_replied_within_36h_pct: { direction: 'up_good', sampleFloor: 10, absoluteFloor: 5 },
107
+ bot_review_comments: { direction: 'neutral', sampleFloor: 10, absoluteFloor: 5 },
108
+ human_review_comments: { direction: 'up_good', sampleFloor: 10, absoluteFloor: 5 },
109
+ bot_comments_per_merged_pr: { direction: 'neutral', sampleFloor: 5, absoluteFloor: 0.5 },
110
+ reviewer_concentration_pct: { direction: 'down_good', sampleFloor: 10, absoluteFloor: 5 },
111
+ };
112
+ // The reply grace for `threads_replied_within_36h_pct`. MIRRORED from `OVERDUE_GRACE_MS` in
113
+ // queries.ts (file-private there, and that file is the NUL-byte one — importing across it for a
114
+ // constant is not worth the coupling). Same 36h the bot verdict already treats as "a working day
115
+ // and a bit", so the two surfaces agree about what "responded promptly" means.
116
+ const REPLY_GRACE_MS = 36 * 60 * 60 * 1000;
117
+ // ── Scan caps (the ROLLUP_SCAN_CAP honesty rule) ─────────────────────────────────────────────
118
+ // A period is a sprint, not a year, so these are far past anything real: the busiest repo in the
119
+ // dev corpus contributes ~1000 merged PRs per 180 days. They exist so a pathological account
120
+ // cannot pull an unbounded result set into memory. Every capped scan is ordered so that what it
121
+ // keeps is the MOST RECENT slice rather than an arbitrary one.
122
+ const PERIOD_PR_SCAN_CAP = 20_000;
123
+ const PERIOD_THREAD_SCAN_CAP = 50_000;
124
+ const PERIOD_COMMENT_SCAN_CAP = 200_000;
125
+ // ⚠ A DIFFERENT KIND OF CAP FROM THE THREE ABOVE, and much smaller for a reason that is not
126
+ // memory. The first-human-review fold has to look up every review on a candidate PR — including
127
+ // ones OUTSIDE the window, since a PR first reviewed by a person in January must not be counted
128
+ // as newly reviewed now — so the candidate ids travel as BIND PARAMETERS in an `IN (…)`. SQLite
129
+ // caps those at 32,766 and Postgres at 65,535, so reusing `PERIOD_PR_SCAN_CAP` (20,000) would sit
130
+ // one busy quarter away from a hard driver error rather than a truncated result. A period is a
131
+ // sprint: 5,000 human-reviewed PRs in one is far beyond anything real.
132
+ const PERIOD_FIRST_REVIEW_PR_CAP = 5_000;
133
+ export function median(xs) {
134
+ if (xs.length === 0)
135
+ return null;
136
+ const s = [...xs].sort((a, b) => a - b);
137
+ const mid = Math.floor(s.length / 2);
138
+ return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
139
+ }
140
+ /** 2dp — display figures. An unrounded 16.888888888888889 in a report cell is worse than
141
+ * useless, and an unrounded float also churns the fingerprint on re-computation.
142
+ * (Both exported for db/person-period.ts — the person vector rounds and medians identically.) */
143
+ export function round2(n) {
144
+ return Math.round(n * 100) / 100;
145
+ }
146
+ /** A count is a real observation: 0 merged PRs in a period is `0`, not "no data". Only an EMPTY
147
+ * SCOPE and a statistic with nothing behind it (a median of nothing, a percentage of nothing)
148
+ * are null. `null` is NOT `0` and must never render as `0`. */
149
+ const countCell = (n) => ({ value: n, sampleSize: n });
150
+ // ── The ONE definition of "time until a person reviewed it" ──────────────────────────────────
151
+ //
152
+ // Extracted because it is read by TWO surfaces — the vector's
153
+ // `median_time_to_first_human_review_hours` and the lane panel's
154
+ // `medianTimeToFirstHumanReviewHours` — that sit one above the other on the same screen, and they
155
+ // disagreed on the live database the first time this shipped: 18.16h in the table against 18.27h
156
+ // in the panel below it.
157
+ //
158
+ // The cause was not rounding. The lane fold read only reviews INSIDE the window and took each
159
+ // PR's earliest of those, so a PR a person had reviewed in a previous period counted as freshly
160
+ // reviewed. The vector fold looked at all of time and required the first human review to fall in
161
+ // the window, which is the correct question. Two folds, one screen, two answers — and the panel's
162
+ // caption asserted they were the same measurement.
163
+ //
164
+ // So there is now one fold. Anything that wants this number calls this.
165
+ //
166
+ // EXPORTED (P4.2): the 1:1 person vector's "median hours THEIR PRs waited for a first human
167
+ // review" is this same measurement narrowed to one author — `authorUserId` narrows the candidate
168
+ // PR population (query 1; query 2 runs over those candidates, so one predicate covers both) and
169
+ // changes nothing else. A second fold of this number is the named shipped bug this comment block
170
+ // opens with; the narrowing parameter exists precisely so no one writes one.
171
+ export async function loadFirstHumanReviewHours(accountId, scope, from, to, lanes, authorUserId,
172
+ // Evidence sink (the People report's person-period evidence): when given, receives
173
+ // {prId, atMs} for EXACTLY the PRs whose hours entered the median — the sample population off
174
+ // this one fold, so no caller ever writes a sibling predicate to name it. Optional + append-
175
+ // only: both period-vector call sites pass nothing and are byte-identical to before.
176
+ samplesOut,
177
+ // Truncation sink (see `ReviewFoldTruncation`): when given, set to true if ANY of this fold's
178
+ // three caps cut the population — the two PERIOD_COMMENT_SCAN_CAP limits below, or the
179
+ // PERIOD_FIRST_REVIEW_PR_CAP candidate break.
180
+ //
181
+ // Optional and APPEND-ONLY for the same reason `samplesOut` is: this is THE ONE FOLD for this
182
+ // number (the shipped 18.16h-vs-18.27h bug this block opens with), so it gets extended, never
183
+ // forked, and the three call sites that pass nothing are byte-identical to before.
184
+ truncatedOut) {
185
+ // A scan that came back exactly at its cap is ASSUMED to have been cut — the `noteCap`
186
+ // convention db/flow-findings.ts uses. A second COUNT(*) is not worth it, and the cost of a
187
+ // false positive is one honest caveat on screen.
188
+ const noteTruncated = () => {
189
+ if (truncatedOut)
190
+ truncatedOut.truncated = true;
191
+ };
192
+ const inScope = and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), ...(authorUserId != null ? [eq(pullRequests.authorId, authorUserId)] : []));
193
+ // (1) CANDIDATES: every PR with a non-pending review submitted in the window. A superset of
194
+ // what we want — a PR whose first HUMAN review is in-window necessarily has a review in-window
195
+ // — narrowed by step (2). Newest-first so the dedupe keeps the most recent on hitting the cap.
196
+ const reviewedPrRows = await db
197
+ .select({ prId: reviews.prId })
198
+ .from(reviews)
199
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
200
+ .where(and(inScope, gte(reviews.submittedAt, from), lt(reviews.submittedAt, to), ne(reviews.state, 'pending')))
201
+ .orderBy(desc(reviews.submittedAt), desc(reviews.id))
202
+ .limit(PERIOD_COMMENT_SCAN_CAP)
203
+ .execute();
204
+ if (reviewedPrRows.length >= PERIOD_COMMENT_SCAN_CAP)
205
+ noteTruncated();
206
+ // Deduped in TS rather than with a `groupBy`, which keeps the query free of an aggregate:
207
+ // `min(submitted_at)` comes back as an epoch integer on SQLite and a Date on Postgres
208
+ // (drizzle's `mode: 'timestamp'` mapping applies to selected COLUMNS, not to `sql` fragments),
209
+ // and that is a dialect divergence this file has no reason to acquire for a trivial fold.
210
+ const candidatePrIds = [];
211
+ const seenPr = new Set();
212
+ for (const r of reviewedPrRows) {
213
+ if (seenPr.has(r.prId))
214
+ continue;
215
+ seenPr.add(r.prId);
216
+ candidatePrIds.push(r.prId);
217
+ if (candidatePrIds.length >= PERIOD_FIRST_REVIEW_PR_CAP) {
218
+ // The one cap a caller can NEVER infer from the return value: the ids dropped here never
219
+ // reach query (2), so the hours array is short by an unknowable amount rather than clipped
220
+ // at a recognisable length.
221
+ noteTruncated();
222
+ break;
223
+ }
224
+ }
225
+ if (candidatePrIds.length === 0)
226
+ return [];
227
+ // (2) EVERY non-pending review on a candidate PR, across ALL TIME — deliberately NOT restricted
228
+ // to the window.
229
+ //
230
+ // ⚠ THE UNBOUNDED TIME RANGE IS THE WHOLE POINT and must not be "optimised" back to
231
+ // `[from, to)`. That optimisation is precisely the bug described above. A PR a person reviewed
232
+ // in January and revisited today would answer "first reviewed today", reporting a months-old
233
+ // review as fresh latency. Seeing the earlier review is the only way to rule it out.
234
+ //
235
+ // ASCENDING so each PR's earliest review survives the cap; a newest-first cap would drop
236
+ // exactly the rows the fold is looking for.
237
+ const rows = await db
238
+ .select({
239
+ prId: reviews.prId,
240
+ authorId: reviews.authorId,
241
+ submittedAt: reviews.submittedAt,
242
+ openedAt: pullRequests.openedAt,
243
+ })
244
+ .from(reviews)
245
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
246
+ .where(and(inArray(reviews.prId, candidatePrIds), ne(reviews.state, 'pending')))
247
+ .orderBy(reviews.submittedAt, reviews.id)
248
+ .limit(PERIOD_COMMENT_SCAN_CAP)
249
+ .execute();
250
+ if (rows.length >= PERIOD_COMMENT_SCAN_CAP)
251
+ noteTruncated();
252
+ const firstHumanByPr = new Map();
253
+ for (const r of rows) {
254
+ if (firstHumanByPr.has(r.prId))
255
+ continue;
256
+ if (lanes.laneOf(r.authorId) !== 'human')
257
+ continue;
258
+ firstHumanByPr.set(r.prId, { at: r.submittedAt.getTime(), openedAt: r.openedAt.getTime() });
259
+ }
260
+ const fromMs = from.getTime();
261
+ const toMs = to.getTime();
262
+ const out = [];
263
+ for (const [prId, { at, openedAt }] of firstHumanByPr) {
264
+ if (at < fromMs || at >= toMs)
265
+ continue; // first human review was in some OTHER period
266
+ const hours = (at - openedAt) / 3_600_000;
267
+ // A review timestamped before its own PR opened is clock skew, not a negative latency.
268
+ // Excluding it is cheap and it is exactly the direction of error this metric exists to fix.
269
+ if (hours >= 0) {
270
+ out.push(hours);
271
+ samplesOut?.push({ prId, atMs: at });
272
+ }
273
+ }
274
+ return out;
275
+ }
276
+ /** A function, not a shared constant — the struct carries a Map, and one mutable module-level
277
+ * instance handed to every empty-scope call is a trap waiting for the first consumer that
278
+ * writes to it (the bot-volume `emptyBase` precedent). */
279
+ function emptyBase() {
280
+ return {
281
+ mergedPrs: [],
282
+ openedPrs: 0,
283
+ humanFirstReviewHours: [],
284
+ threadsOpened: 0,
285
+ threadsRepliedWithin: 0,
286
+ botComments: 0,
287
+ humanComments: 0,
288
+ reviewsByHuman: new Map(),
289
+ };
290
+ }
291
+ async function loadPeriodBase(accountId, scope, from, to) {
292
+ const repoIds = scope.repoIds;
293
+ // ⚠ THE AUTOMATION SET IS THE LANE RESOLVER'S UNION, NOT `automatedReviewerUserIds` ALONE.
294
+ //
295
+ // The workspace verdict still decides who counts as a bot, but on its own it MISSES the second
296
+ // row of a duplicated identity: `dependabot` and `dependabot[bot]` are separate `users` rows on
297
+ // real accounts and one of each pair sat at `automated = 0`, i.e. was counted as a person. That
298
+ // is what put bot text in `human_review_comments` and bot reviewers in the concentration
299
+ // figure. `resolveActorLanes` unions the verdict with `users.isBot` and the login vocabularies
300
+ // and subtracts anyone a human vouched for, which is the same set the lane panel renders — so
301
+ // the metric table and the lane breakdown under it can no longer disagree about who is a person.
302
+ //
303
+ // Role stays `'all'` inside that resolver by the ROI/flagging convention: a quality check posts
304
+ // exactly the kind of text `bot_review_comments` counts, and narrowing to reviewers would make
305
+ // the bot and human halves fail to sum to the total.
306
+ const lanes = await resolveActorLanes(accountId, scope);
307
+ const automatedIds = [...lanes.automatedIds];
308
+ const inScope = and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds));
309
+ const [mergedRows, openedRow, humanFirstReviewHours, threadRows, commentRows, reviewRows, botCommentRow] = await Promise.all([
310
+ // (1) PRs MERGED in the window — the population behind metrics 1, 3, 5, 6 and the
311
+ // denominator of 11. Newest-merged first so the cap keeps a recent sample.
312
+ db
313
+ .select({
314
+ authorId: pullRequests.authorId,
315
+ openedAt: pullRequests.openedAt,
316
+ mergedAt: pullRequests.mergedAt,
317
+ additions: pullRequests.additions,
318
+ deletions: pullRequests.deletions,
319
+ changedFiles: pullRequests.changedFiles,
320
+ ciStatus: pullRequests.ciStatus,
321
+ })
322
+ .from(pullRequests)
323
+ .where(and(inScope, gte(pullRequests.mergedAt, from), lt(pullRequests.mergedAt, to)))
324
+ .orderBy(desc(pullRequests.mergedAt), desc(pullRequests.id))
325
+ .limit(PERIOD_PR_SCAN_CAP)
326
+ .execute(),
327
+ // (2) PRs OPENED in the window. A bare count — no row is needed, and an exact aggregate
328
+ // cannot be truncated. Deliberately NOT restricted to merged PRs: a period's intake is
329
+ // intake whether or not it landed.
330
+ db
331
+ .select({ c: count() })
332
+ .from(pullRequests)
333
+ .where(and(inScope, gte(pullRequests.openedAt, from), lt(pullRequests.openedAt, to)))
334
+ .execute(),
335
+ // (4) Hours from open to the FIRST HUMAN REVIEW, for PRs whose first human review landed
336
+ // in this window. ONE shared fold — see `loadFirstHumanReviewHours` for why it is not
337
+ // inlined here and why it must look outside the window to answer correctly.
338
+ loadFirstHumanReviewHours(accountId, scope, from, to, lanes),
339
+ // (7) Review threads whose ROOT COMMENT lands in the window. `reviewThreads.createdAt` IS
340
+ // the root comment's timestamp (sync writes it from `comments.nodes[0].createdAt`), so no
341
+ // second lookup is needed to bucket a thread.
342
+ db
343
+ .select({ id: reviewThreads.id, createdAt: reviewThreads.createdAt })
344
+ .from(reviewThreads)
345
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
346
+ .where(and(inScope, gte(reviewThreads.createdAt, from), lt(reviewThreads.createdAt, to)))
347
+ .orderBy(desc(reviewThreads.createdAt), desc(reviewThreads.id))
348
+ .limit(PERIOD_THREAD_SCAN_CAP)
349
+ .execute(),
350
+ // (8) The comments on those threads, for the reply test.
351
+ //
352
+ // The comment window is `[from, to + 36h)`: a reply that qualifies must be within the
353
+ // grace of a root that itself started in-window, so nothing outside that span can change
354
+ // the answer — and bounding it keeps the scan proportional to the period rather than to
355
+ // the age of the longest-lived thread.
356
+ //
357
+ // ⚠ ORDERED **ASCENDING**, the opposite of every other capped scan here, and that
358
+ // inversion is load-bearing: this fold needs each thread's EARLIEST two comments. A
359
+ // newest-first cap would drop the ROOTS first and silently turn late replies into roots.
360
+ db
361
+ .select({ threadId: reviewComments.threadId, createdAt: reviewComments.createdAt })
362
+ .from(reviewComments)
363
+ .innerJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
364
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
365
+ .where(and(inScope, gte(reviewThreads.createdAt, from), lt(reviewThreads.createdAt, to), gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, new Date(to.getTime() + REPLY_GRACE_MS))))
366
+ .orderBy(reviewComments.createdAt, reviewComments.id)
367
+ .limit(PERIOD_COMMENT_SCAN_CAP)
368
+ .execute(),
369
+ // (12) Submitted reviews in the window, per author. `pending` reviews are drafts,
370
+ // invisible on GitHub — the same predicate every other review-activity reader uses.
371
+ // Exact aggregate, no cap.
372
+ db
373
+ .select({ authorId: reviews.authorId, c: count() })
374
+ .from(reviews)
375
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
376
+ .where(and(inScope, gte(reviews.submittedAt, from), lt(reviews.submittedAt, to), ne(reviews.state, 'pending')))
377
+ .groupBy(reviews.authorId)
378
+ .execute(),
379
+ // (9) Bot-authored review comments in the window. Counted as its own exact aggregate and
380
+ // subtracted from the total below, rather than as a `notInArray` twin: `notInArray` drops
381
+ // NULL authors in both dialects (SQL three-valued logic), which would silently lose every
382
+ // comment by a since-deleted GitHub account from the human half.
383
+ automatedIds.length === 0
384
+ ? Promise.resolve([{ c: 0 }])
385
+ : db
386
+ .select({ c: count() })
387
+ .from(reviewComments)
388
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
389
+ .where(and(inScope, gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to), inArray(reviewComments.authorId, automatedIds)))
390
+ .execute(),
391
+ ]);
392
+ // (9 + 10) The total, so `human = total − bot` and the two halves always sum. A comment whose
393
+ // author is NULL (deleted account) lands in the HUMAN half — by the definition's own words
394
+ // ("NOT authored by an automated reviewer") and because the alternative is a third bucket
395
+ // nothing renders.
396
+ const [totalCommentRow] = await db
397
+ .select({ c: count() })
398
+ .from(reviewComments)
399
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
400
+ .where(and(inScope, gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to)))
401
+ .execute();
402
+ const mergedPrs = [];
403
+ for (const r of mergedRows) {
404
+ // `mergedAt` cannot be null here (the two-sided predicate excludes nulls in both dialects),
405
+ // but the COLUMN is nullable, so the narrowing is explicit rather than a `!`.
406
+ if (!r.mergedAt)
407
+ continue;
408
+ // ⚠ UNKNOWN SIZE, NOT ZERO SIZE — the bot-volume.ts rule. Under lean storage these three
409
+ // columns default to 0, so a PR whose detail never hydrated looks exactly like an empty one
410
+ // (135 of three.js's 796 merged PRs are in that state in the dev corpus). Feeding a
411
+ // fabricated 0 into a MEDIAN drags it towards zero by however many rows never hydrated,
412
+ // which is the most convincing kind of wrong number.
413
+ const observed = r.changedFiles > 0 || r.additions > 0 || r.deletions > 0;
414
+ // ⚠ `ciStatus` IS A HEAD-COMMIT SNAPSHOT, and it is window-pure ONLY because the population
415
+ // is MERGED PRs: a merged PR's head never moves again, so the value is frozen at merge time
416
+ // and the metric stays reproducible. It would NOT be for open PRs — one more reason the
417
+ // population is what it is.
418
+ //
419
+ // Only the DETERMINATE outcomes are in the denominator. `null` means never observed (no CI
420
+ // configured, or a row that predates the column), and 'pending'/'expected'/'unknown' mean we
421
+ // genuinely do not know — counting any of them as a CI failure would report a repo with no
422
+ // CI at all as 0% green, which is a lie with a number attached.
423
+ const ci = r.ciStatus;
424
+ mergedPrs.push({
425
+ leadMs: r.mergedAt.getTime() - r.openedAt.getTime(),
426
+ loc: observed ? r.additions + r.deletions : null,
427
+ ciDeterminate: ci === 'success' || ci === 'failure' || ci === 'error',
428
+ ciSuccess: ci === 'success',
429
+ lane: lanes.laneOf(r.authorId),
430
+ });
431
+ }
432
+ // The reply fold. Per thread, the two earliest comment timestamps: `t[0]` is the root and
433
+ // `t[1]` is the first reply, so "replied within 36h" is `t[1] - t[0] <= REPLY_GRACE_MS`.
434
+ //
435
+ // Reading the ROOT off the comment set rather than off `reviewThreads.createdAt` is deliberate
436
+ // and costs nothing: it is immune to a same-second sibling (sqlite stores timestamps as epoch
437
+ // SECONDS, so a bot posting twice in one second would defeat a strict `createdAt > root`
438
+ // comparison) and to the degenerate thread whose `createdAt` fell back to the PR's.
439
+ //
440
+ // ⚠ KNOWN SOFTNESS, and it is the definition's ("any reply"), not an oversight: the author of
441
+ // the follow-up is not checked, so a bot that appends to its OWN thread counts as a reply.
442
+ // Narrowing it to "a different author" would be a different metric and would need its own
443
+ // schema version.
444
+ const firstTwo = new Map();
445
+ for (const c of commentRows) {
446
+ const seen = firstTwo.get(c.threadId);
447
+ if (seen == null)
448
+ firstTwo.set(c.threadId, [c.createdAt.getTime()]);
449
+ else if (seen.length < 2)
450
+ seen.push(c.createdAt.getTime());
451
+ }
452
+ let threadsRepliedWithin = 0;
453
+ for (const t of threadRows) {
454
+ const seen = firstTwo.get(t.id);
455
+ // No comment rows at all ⇒ nothing to reply to and nothing that replied. Not an error: a
456
+ // thread outside the (capped) scan lands here too, which is why the cap is set where it is.
457
+ if (!seen || seen.length < 2)
458
+ continue;
459
+ if (seen[1] - seen[0] <= REPLY_GRACE_MS)
460
+ threadsRepliedWithin += 1;
461
+ }
462
+ // ⚠ REVIEWER CONCENTRATION IS OVER **HUMAN** REVIEWERS. Included, a bot that reviews every PR
463
+ // pins the metric near 100% forever and it stops moving — a dead row on the report, in exactly
464
+ // the bot-heavy workspaces this product is for. The question the metric answers ("is review
465
+ // load resting on one person?") is a bus-factor question, and an always-on bot is not a bus
466
+ // factor. Any caption for it must say "among human reviewers".
467
+ const automated = new Set(automatedIds);
468
+ const reviewsByHuman = new Map();
469
+ for (const r of reviewRows) {
470
+ // A null author is a deleted account: unattributable, so it cannot be anyone's share.
471
+ if (r.authorId == null || automated.has(r.authorId))
472
+ continue;
473
+ reviewsByHuman.set(r.authorId, (reviewsByHuman.get(r.authorId) ?? 0) + r.c);
474
+ }
475
+ const botComments = botCommentRow[0]?.c ?? 0;
476
+ const totalComments = totalCommentRow?.c ?? 0;
477
+ return {
478
+ mergedPrs,
479
+ openedPrs: openedRow[0]?.c ?? 0,
480
+ humanFirstReviewHours,
481
+ threadsOpened: threadRows.length,
482
+ threadsRepliedWithin,
483
+ botComments,
484
+ humanComments: Math.max(0, totalComments - botComments),
485
+ reviewsByHuman,
486
+ };
487
+ }
488
+ // ── The fingerprint ──────────────────────────────────────────────────────────────────────────
489
+ /**
490
+ * sha256 over the raw per-metric figures plus the exact scope and window that produced them.
491
+ *
492
+ * ⚠ NOTHING `Date.now()`-DERIVED MAY ENTER IT. This is the payload-hash trap this codebase has
493
+ * hit repeatedly (the digest cost trap, the annotation hunk): a hash that folds in a moving
494
+ * value differs on every recomputation, so a stored report would read `stale` forever and the
495
+ * UI would invite a re-generation that changes nothing and costs money. Everything below is a
496
+ * function of stored rows and the caller's fixed window.
497
+ *
498
+ * The scope and the window are IN the hash on purpose. §6.4's membership drift is a real reason
499
+ * for a stored report to go stale: adding a repo to the workspace changes what "this period"
500
+ * means even when not one existing row moved.
501
+ */
502
+ function fingerprintOf(scope, window, cells) {
503
+ const parts = [
504
+ `v${PERIOD_METRICS_SCHEMA_VERSION}`,
505
+ `ws:${scope.workspaceId}`,
506
+ `repos:${[...scope.repoIds].sort((a, b) => a - b).join(',')}`,
507
+ `win:${window.fromMs}-${window.toMs}`,
508
+ ];
509
+ for (const key of PERIOD_METRIC_KEYS) {
510
+ const cell = cells[key];
511
+ parts.push(`${key}=${cell.value ?? '~'}/${cell.sampleSize}`);
512
+ }
513
+ return createHash('sha256').update(parts.join('|'), 'utf8').digest('hex');
514
+ }
515
+ // ── (1) The vector ───────────────────────────────────────────────────────────────────────────
516
+ export async function getPeriodMetrics(accountId,
517
+ // `workspaceId` decides who counts as a bot; `repoIds` narrows what is measured. A BotScope is
518
+ // only ever built by `resolveWorkspaceScope`, so `repoIds ⊆ the workspace's membership`.
519
+ scope, window) {
520
+ const from = new Date(window.fromMs);
521
+ const to = new Date(window.toMs);
522
+ // `repoIds: []` is a real empty workspace — every metric is `null` with sampleSize 0, and it
523
+ // is NOT an error and NOT a widening to the account. (`from >= to` is degenerate but legal:
524
+ // the two-sided predicates simply match nothing, so it falls out as an all-empty base rather
525
+ // than needing a guard that would have to decide what to throw.)
526
+ const empty = scope.repoIds.length === 0;
527
+ const base = empty ? emptyBase() : await loadPeriodBase(accountId, scope, from, to);
528
+ const merged = base.mergedPrs.length;
529
+ const sized = base.mergedPrs.filter((p) => p.loc != null);
530
+ const ciKnown = base.mergedPrs.filter((p) => p.ciDeterminate);
531
+ const ciGreen = ciKnown.filter((p) => p.ciSuccess).length;
532
+ const humanReviews = [...base.reviewsByHuman.values()];
533
+ const totalHumanReviews = humanReviews.reduce((n, c) => n + c, 0);
534
+ const busiestReviewer = humanReviews.reduce((m, c) => (c > m ? c : m), 0);
535
+ const leadHours = base.mergedPrs.map((p) => p.leadMs / 3_600_000);
536
+ const reviewHours = base.humanFirstReviewHours;
537
+ const sizes = sized.map((p) => p.loc);
538
+ // The human-only twins. `human` here is the AUTHOR's lane, so a PR opened by a person and
539
+ // merged by a merge queue is still human work — "who merged it" is never a proxy for "who did
540
+ // the work", which is exactly why `release` is its own lane rather than folded into throughput.
541
+ const humanMerged = base.mergedPrs.filter((p) => p.lane === 'human');
542
+ const humanSizes = humanMerged.filter((p) => p.loc != null).map((p) => p.loc);
543
+ const automationMerged = merged - humanMerged.length;
544
+ const nullCell = { value: null, sampleSize: 0 };
545
+ const pct = (numerator, denominator) => denominator === 0
546
+ ? nullCell
547
+ : { value: round2((numerator / denominator) * 100), sampleSize: denominator };
548
+ const med = (xs) => {
549
+ const m = median(xs);
550
+ return m == null ? nullCell : { value: round2(m), sampleSize: xs.length };
551
+ };
552
+ // `Record<PeriodMetricKey, …>` — a metric missing from EITHER branch is a COMPILE error, which
553
+ // is the only thing guaranteeing the response always carries all twelve and is why the empty
554
+ // branch is spelled out rather than built by a loop (a loop would need a cast, and the cast is
555
+ // exactly what would let a future 13th metric go missing here in silence).
556
+ //
557
+ // ⚠ THE EMPTY BRANCH NULLS THE **COUNTS** TOO. It is not the same answer as a populated scope
558
+ // that happened to see nothing: "this workspace has no repos" is not "this workspace merged
559
+ // nothing", and a 0 in that slot renders as a real observation.
560
+ const cells = empty
561
+ ? {
562
+ merged_prs: nullCell,
563
+ human_merged_prs: nullCell,
564
+ opened_prs: nullCell,
565
+ automation_merge_share_pct: nullCell,
566
+ median_lead_time_hours: nullCell,
567
+ median_time_to_first_human_review_hours: nullCell,
568
+ merge_ci_success_pct: nullCell,
569
+ median_pr_size_lines: nullCell,
570
+ median_human_pr_size_lines: nullCell,
571
+ review_threads_opened: nullCell,
572
+ threads_replied_within_36h_pct: nullCell,
573
+ bot_review_comments: nullCell,
574
+ human_review_comments: nullCell,
575
+ bot_comments_per_merged_pr: nullCell,
576
+ reviewer_concentration_pct: nullCell,
577
+ }
578
+ : {
579
+ merged_prs: countCell(merged),
580
+ human_merged_prs: countCell(humanMerged.length),
581
+ opened_prs: countCell(base.openedPrs),
582
+ // A SHARE OF NOTHING IS NOT ZERO. With no merges the question has no denominator, and a
583
+ // 0% here would read as "all of it was people" — the opposite failure to the one the
584
+ // metric exists to prevent.
585
+ automation_merge_share_pct: pct(automationMerged, merged),
586
+ median_lead_time_hours: med(leadHours),
587
+ median_time_to_first_human_review_hours: med(reviewHours),
588
+ merge_ci_success_pct: pct(ciGreen, ciKnown.length),
589
+ median_pr_size_lines: med(sizes),
590
+ median_human_pr_size_lines: med(humanSizes),
591
+ review_threads_opened: countCell(base.threadsOpened),
592
+ threads_replied_within_36h_pct: pct(base.threadsRepliedWithin, base.threadsOpened),
593
+ bot_review_comments: countCell(base.botComments),
594
+ human_review_comments: countCell(base.humanComments),
595
+ // A ratio, not a count: no merged PRs means the question has no denominator, so it is
596
+ // null rather than the 0 that "the bots said nothing" would also produce.
597
+ bot_comments_per_merged_pr: merged === 0
598
+ ? nullCell
599
+ : { value: round2(base.botComments / merged), sampleSize: merged },
600
+ reviewer_concentration_pct: pct(busiestReviewer, totalHumanReviews),
601
+ };
602
+ return {
603
+ // `lowSample` is stamped HERE, beside the floors, and travels on the wire. The alternative —
604
+ // shipping `sampleSize` and letting the SPA compare it against its own copy of the floors —
605
+ // puts the same constants in two repos, and the first time one of them is tuned the marker
606
+ // silently disagrees with the significance rule computed from the other.
607
+ //
608
+ // It answers a DIFFERENT question from `PeriodMetricDelta.significant`: this says the FIGURE
609
+ // is thin, that says the CHANGE is not worth asserting. A metric can easily be one and not the
610
+ // other, and "0h from 2 reviews" needs saying even when nothing moved.
611
+ metrics: PERIOD_METRIC_KEYS.map((key) => ({
612
+ key,
613
+ ...cells[key],
614
+ lowSample: cells[key].sampleSize < PERIOD_METRIC_META[key].sampleFloor,
615
+ })),
616
+ fingerprint: fingerprintOf(scope, window, cells),
617
+ };
618
+ }
619
+ // ── (2) Coverage ─────────────────────────────────────────────────────────────────────────────
620
+ /**
621
+ * Which of `repoIds` were already being TRACKED at `atMs` — i.e. `repos.createdAt <= atMs`.
622
+ *
623
+ * ⚠ THIS IS THE RULE THAT KEEPS THE FORECAST HONEST, and it is not optional. See the header:
624
+ * a naive period-over-period sweep over this dataset shows a merged-PR count climbing from 39 to
625
+ * 570 over six months, which is entirely repo onboarding. A comparison is drawn over the
626
+ * INTERSECTION of two periods' tracked sets so it is like-for-like, and a forecast series uses
627
+ * only periods whose coverage is complete for that subset.
628
+ *
629
+ * `repos.createdAt` is when the repo was added to THIS account, not when it was created on
630
+ * GitHub — the same column My Turn's "New PRs" cutoff rides, and it is load-bearing for the same
631
+ * reason: it is the earliest instant we could possibly know anything about the repo.
632
+ *
633
+ * The bound is INCLUSIVE (`<=`), unlike the metric window's half-open upper edge: a repo added
634
+ * at the exact instant a period starts contributed to the whole of it.
635
+ */
636
+ export async function getPeriodCoverage(accountId, repoIds, atMs) {
637
+ if (repoIds.length === 0)
638
+ return { trackedRepoIds: [] };
639
+ const rows = await db
640
+ .select({ id: repos.id })
641
+ .from(repos)
642
+ .where(and(eq(repos.accountId, accountId), inArray(repos.id, repoIds),
643
+ // ⚠ A REAL `lte`, never `lt(…, atMs + 1)`. SQLite stores `mode: 'timestamp'` as epoch
644
+ // SECONDS, so a one-millisecond nudge lands in the SAME stored value and would silently
645
+ // flip the boundary from inclusive to exclusive — in one dialect only.
646
+ lte(repos.createdAt, new Date(atMs))))
647
+ .execute();
648
+ // Ascending so a stored `repo_ids_json` and a recomputed one compare byte-for-byte.
649
+ return { trackedRepoIds: rows.map((r) => r.id).sort((a, b) => a - b) };
650
+ }
651
+ export async function getPeriodLanes(accountId, scope, window) {
652
+ const from = new Date(window.fromMs);
653
+ const to = new Date(window.toMs);
654
+ const empty = () => ({
655
+ lanes: ACTOR_LANE_ORDER.map((lane) => ({
656
+ lane,
657
+ mergedPrs: 0,
658
+ openedPrs: 0,
659
+ medianPrSizeLines: null,
660
+ medianLeadTimeHours: null,
661
+ comments: 0,
662
+ approvals: 0,
663
+ })),
664
+ automationMergeSharePct: null,
665
+ medianTimeToFirstHumanReviewHours: null,
666
+ silentAutomation: [],
667
+ });
668
+ if (scope.repoIds.length === 0)
669
+ return empty();
670
+ const lanes = await resolveActorLanes(accountId, scope);
671
+ const inScope = and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds));
672
+ // ⚠ THE FIRST-HUMAN-REVIEW FIGURE COMES FROM THE SHARED FOLD, not from a per-review query here.
673
+ //
674
+ // It used to have its own, over reviews INSIDE the window only — which meant a PR a person had
675
+ // reviewed in a previous period counted as freshly reviewed. On the live database that reported
676
+ // 18.27h in this panel against the vector's 18.16h, one directly above the other, with a caption
677
+ // claiming they were the same measurement. They are the same measurement now because they are
678
+ // the same code.
679
+ const [merged, opened, inlineRows, issueRows, reviewRows, humanFirstReviewHours] = await Promise.all([
680
+ db
681
+ .select({
682
+ authorId: pullRequests.authorId,
683
+ openedAt: pullRequests.openedAt,
684
+ mergedAt: pullRequests.mergedAt,
685
+ additions: pullRequests.additions,
686
+ deletions: pullRequests.deletions,
687
+ changedFiles: pullRequests.changedFiles,
688
+ })
689
+ .from(pullRequests)
690
+ .where(and(inScope, gte(pullRequests.mergedAt, from), lt(pullRequests.mergedAt, to)))
691
+ .orderBy(desc(pullRequests.mergedAt), desc(pullRequests.id))
692
+ .limit(PERIOD_PR_SCAN_CAP)
693
+ .execute(),
694
+ db
695
+ .select({ authorId: pullRequests.authorId, c: count() })
696
+ .from(pullRequests)
697
+ .where(and(inScope, gte(pullRequests.openedAt, from), lt(pullRequests.openedAt, to)))
698
+ .groupBy(pullRequests.authorId)
699
+ .execute(),
700
+ db
701
+ .select({ authorId: reviewComments.authorId, c: count() })
702
+ .from(reviewComments)
703
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
704
+ .where(and(inScope, gte(reviewComments.createdAt, from), lt(reviewComments.createdAt, to)))
705
+ .groupBy(reviewComments.authorId)
706
+ .execute(),
707
+ db
708
+ .select({ authorId: prComments.authorId, c: count() })
709
+ .from(prComments)
710
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
711
+ .where(and(inScope, gte(prComments.createdAt, from), lt(prComments.createdAt, to)))
712
+ .groupBy(prComments.authorId)
713
+ .execute(),
714
+ // Bodies and approvals in ONE pass, split by state on the fold. `pending` is a draft nobody
715
+ // can see — the same exclusion every other review reader applies.
716
+ db
717
+ .select({ authorId: reviews.authorId, state: reviews.state, body: reviews.body, c: count() })
718
+ .from(reviews)
719
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
720
+ .where(and(inScope, gte(reviews.submittedAt, from), lt(reviews.submittedAt, to), ne(reviews.state, 'pending')))
721
+ .groupBy(reviews.authorId, reviews.state, reviews.body)
722
+ .execute(),
723
+ loadFirstHumanReviewHours(accountId, scope, from, to, lanes),
724
+ ]);
725
+ const blank = () => ({ mergedPrs: 0, openedPrs: 0, sizes: [], leads: [], comments: 0, approvals: 0 });
726
+ const acc = new Map(ACTOR_LANE_ORDER.map((l) => [l, blank()]));
727
+ const bump = (lane) => acc.get(lane) ?? acc.set(lane, blank()).get(lane);
728
+ const active = new Set();
729
+ for (const r of merged) {
730
+ const lane = lanes.laneOf(r.authorId);
731
+ const a = bump(lane);
732
+ a.mergedPrs += 1;
733
+ if (r.authorId != null)
734
+ active.add(r.authorId);
735
+ // The SIZED rule, identical to the vector's: an unhydrated PR is indistinguishable from an
736
+ // empty one, so it contributes a lead time but not a size.
737
+ if ((r.changedFiles ?? 0) > 0 || (r.additions ?? 0) > 0 || (r.deletions ?? 0) > 0) {
738
+ a.sizes.push((r.additions ?? 0) + (r.deletions ?? 0));
739
+ }
740
+ if (r.mergedAt != null && r.openedAt != null) {
741
+ a.leads.push((r.mergedAt.getTime() - r.openedAt.getTime()) / 3_600_000);
742
+ }
743
+ }
744
+ for (const r of opened) {
745
+ bump(lanes.laneOf(r.authorId)).openedPrs += r.c;
746
+ if (r.authorId != null && r.c > 0)
747
+ active.add(r.authorId);
748
+ }
749
+ for (const rows of [inlineRows, issueRows]) {
750
+ for (const r of rows) {
751
+ bump(lanes.laneOf(r.authorId)).comments += r.c;
752
+ if (r.authorId != null && r.c > 0)
753
+ active.add(r.authorId);
754
+ }
755
+ }
756
+ for (const r of reviewRows) {
757
+ const a = bump(lanes.laneOf(r.authorId));
758
+ if (r.state === 'approved')
759
+ a.approvals += r.c;
760
+ // A review BODY is a comment; an empty-bodied approval is not.
761
+ if (r.body != null && r.body.trim().length > 0)
762
+ a.comments += r.c;
763
+ if (r.authorId != null && r.c > 0)
764
+ active.add(r.authorId);
765
+ }
766
+ const out = ACTOR_LANE_ORDER.map((lane) => {
767
+ const a = acc.get(lane) ?? blank();
768
+ return {
769
+ lane,
770
+ mergedPrs: a.mergedPrs,
771
+ openedPrs: a.openedPrs,
772
+ medianPrSizeLines: median(a.sizes),
773
+ medianLeadTimeHours: median(a.leads),
774
+ comments: a.comments,
775
+ approvals: a.approvals,
776
+ };
777
+ });
778
+ const totalMerged = out.reduce((n, l) => n + l.mergedPrs, 0);
779
+ const automatedMerged = out
780
+ .filter((l) => l.lane !== 'human')
781
+ .reduce((n, l) => n + l.mergedPrs, 0);
782
+ return {
783
+ lanes: out,
784
+ automationMergeSharePct: totalMerged === 0 ? null : round2((automatedMerged / totalMerged) * 100),
785
+ medianTimeToFirstHumanReviewHours: median(humanFirstReviewHours),
786
+ silentAutomation: [...lanes.lane.entries()]
787
+ .filter(([userId]) => !active.has(userId))
788
+ .map(([userId, lane]) => ({ userId, lane })),
789
+ };
790
+ }
791
+ /**
792
+ * COST: N × `getPeriodMetrics`, one per workspace — the same multiplication that put the
793
+ * comparison route on the `search` rate tier. The consuming route's tier must account for it.
794
+ * Rows come back in `listWorkspaces` order (Default first, then by name) so the axis columns
795
+ * are stable across reloads.
796
+ */
797
+ export async function getPeriodMetricsForWorkspaces(accountId, window) {
798
+ const all = await listWorkspaces(accountId);
799
+ if (all.length === 0)
800
+ return [];
801
+ return Promise.all(all.map(async (w) => {
802
+ const [{ metrics }, { trackedRepoIds }] = await Promise.all([
803
+ getPeriodMetrics(accountId, { workspaceId: w.id, repoIds: w.repoIds }, window),
804
+ // Coverage at the WINDOW's start — the same inclusive bound the headline coverage uses.
805
+ getPeriodCoverage(accountId, w.repoIds, window.fromMs),
806
+ ]);
807
+ return {
808
+ workspaceId: w.id,
809
+ name: w.name,
810
+ isDefault: w.isDefault,
811
+ coverage: {
812
+ trackedRepos: trackedRepoIds.length,
813
+ totalRepos: w.repoIds.length,
814
+ complete: trackedRepoIds.length === w.repoIds.length,
815
+ },
816
+ metrics,
817
+ };
818
+ }));
819
+ }
820
+ //# sourceMappingURL=period-metrics.js.map