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,930 @@
1
+ // ── WHERE IT'S STUCK: the court ledger (CORE, deterministic, NO MODEL) ───────────────────────
2
+ //
3
+ // Every hour a pull request is open, somebody is holding the ball. Charge each interval to its
4
+ // holder and the hours account for themselves — no stage boundaries to argue about, and no
5
+ // assumption that a pull request moves through a pipeline exactly once (real ones oscillate
6
+ // author -> reviewer -> author).
7
+ //
8
+ // ⚠ WHAT THIS REPLACED, AND WHY. `db/flow-findings.ts` emitted findings at a PATH-BUCKET grain and
9
+ // produced rows like "src/** is a bottleneck". A directory is four proxies from anything an EM can
10
+ // change, and on a conventional single-package repo `src/**` IS the repository — so the row stated
11
+ // a fact about the repo with a directory's authority. Two of its four kinds are deleted outright
12
+ // (`single_reviewer_path`, `round_trips`); `size_latency` is deleted as a finding because 845,316
13
+ // pull requests say size does not predict time-to-merge (r_s = 0.26, the field's strongest
14
+ // negative result); `approval_parked` survives, absorbed here as the LANDING court, where it
15
+ // finally has a denominator.
16
+ //
17
+ // ⚠ A BOT ACTION NEVER MOVES THE BALL. One predicate, and it is the entire moat: a tool keying on
18
+ // `user.type === 'Bot'` cannot separate "this pull request was reviewed" from "a person looked at
19
+ // this". Human-ness comes from `resolveActorLanes`' UNION, never `users.isBot` alone — the second
20
+ // row of a duplicated identity (`dependabot` vs `dependabot[bot]`) carries `is_bot = 0` and would
21
+ // otherwise be counted as a human reviewer.
22
+ //
23
+ // ⚠ EVERY SENTENCE IN THIS FILE IS TEMPLATED. No model, no plugin, no AI import. An EM makes
24
+ // staffing decisions off this screen, so a generated sentence would launder an unverified figure
25
+ // into it. Same discipline `packages/pro/test/llm-isolation.test.ts` pins for the bot advisor.
26
+ //
27
+ // ── THE STATE MACHINE, and the three decisions inside it ─────────────────────────────────────
28
+ //
29
+ // Start at REVIEWER from `openedAt` (a fresh pull request is waiting to be looked at). Then per
30
+ // human action, charge the elapsed interval to the current court and move the ball:
31
+ //
32
+ // reviewer acts (review, or a comment by anyone who is not the author) -> AUTHOR
33
+ // author acts (a commit, or a comment by the author) -> LANDING if already
34
+ // approved, else REVIEWER
35
+ // an APPROVING review -> LANDING
36
+ //
37
+ // 1. A reviewer comment AFTER approval moves the ball back to the AUTHOR, not to the reviewer.
38
+ // Somebody said something and the author owes a reply; that is the author's court by the
39
+ // definition above, and it stays true after an approval.
40
+ // 2. An author push AFTER approval stays in LANDING. Whether it invalidates the approval depends
41
+ // on a branch-protection setting we do not sync, so the conservative reading is "approved,
42
+ // with new code, waiting to land" — and it does not silently inflate the author court.
43
+ // 3. ⚠ A pull request no human ever acted on is EXCLUDED, not scored. Its ledger would be 100%
44
+ // REVIEWER by construction and would swamp every share on the screen. On real data that is
45
+ // 46% of merges, so this is not an edge case — they are reported separately, as governance.
46
+ //
47
+ // ── WINDOW PURITY ────────────────────────────────────────────────────────────────────────────
48
+ // The window is on `mergedAt`, TWO-SIDED and half-open `[from, to)`, matching db/period-metrics.ts:
49
+ // a cycle-time figure belongs to the period the work COMPLETED in, or a long-running pull request
50
+ // would move between windows as it aged.
51
+ import { and, eq, gte, inArray, lt } from 'drizzle-orm';
52
+ import { FLOW_RULES, } from '../shared/index.js';
53
+ import { db, schema } from './client.js';
54
+ import { resolveActorLanes } from './actor-lanes.js';
55
+ import { surfacesForPath } from './blast-radius.js';
56
+ import { getResolvedFlowSettings } from './flow-settings.js';
57
+ import { buildWorkingCalendar } from './working-hours.js';
58
+ import { buildFlowDetail, ticketKeyOf, } from './flow-detail.js';
59
+ const { ciStatusEvents, commits, prComments, pullRequests, repos, reviewComments, reviewRequestEvents, reviews, users, } = schema;
60
+ // ── Window ───────────────────────────────────────────────────────────────────────────────────
61
+ export const FLOW_DEFAULT_WINDOW_DAYS = 30;
62
+ // Clamped, not merely defaulted: below a week the medians rest on a handful of pull requests, and
63
+ // above 90 days the RETROACTIVE COVERAGE BIAS rule bites — a workspace that onboarded repos across
64
+ // the span shows a "trend" that is entirely onboarding.
65
+ export const FLOW_MIN_WINDOW_DAYS = 7;
66
+ export const FLOW_MAX_WINDOW_DAYS = 90;
67
+ // ── Scan caps ────────────────────────────────────────────────────────────────────────────────
68
+ // Every scan is bounded and hitting any of them sets `coverage.truncated`. A silent truncation
69
+ // reads as "we covered everything", which is a stronger claim than any figure here can support.
70
+ const FLOW_PR_CAP = 5_000;
71
+ const FLOW_ACTION_CAP = 200_000;
72
+ /** `inArray` bind parameters per statement — far inside SQLite's 32,766 and Postgres' 65,535. */
73
+ const ID_CHUNK = 900;
74
+ // ── Floors and thresholds ────────────────────────────────────────────────────────────────────
75
+ // ⚠ Assigned from `FLOW_RULES` (packages/shared/src/flow-settings.ts), the ONE spelling the page's
76
+ // explanations quote. The NAMES stay: `pr-intervals.test.ts` reads the dominant-court expression
77
+ // by them, and pins the three calibrated values exactly.
78
+ /** Merged, human-touched pull requests a repo needs before its profile is a number rather than an
79
+ * anecdote. */
80
+ const FLOW_MIN_REPO_PRS = FLOW_RULES.minRepoPrs;
81
+ /** A court must hold at least this share before it is worth naming as the thing to fix. */
82
+ const FLOW_DOMINANT_SHARE = FLOW_RULES.dominantShare;
83
+ /**
84
+ * ⚠ AND THE REPO MUST ACTUALLY BE SLOW. This is the single most important constant in the file.
85
+ * A real repository in the development corpus is 73% author-court with a p75 lead time of
86
+ * EIGHTEEN MINUTES; reporting its share alone would invent a crisis in a healthy repo — the exact
87
+ * failure that made the path-bucket findings worthless. Lopsided AND slow, or say nothing.
88
+ */
89
+ const FLOW_SLOW_P75_HOURS = FLOW_RULES.slowP75ClockHours;
90
+ /** Openable pull requests offered per claim. */
91
+ const FLOW_EVIDENCE_CAP = 5;
92
+ /** Repos listed. A board nobody can read is not a worklist. */
93
+ const FLOW_REPO_CAP = 12;
94
+ /** Below this share of a repo's merges, an unreviewed-merge rate is not worth a line. */
95
+ const FLOW_UNREVIEWED_MIN_SHARE = FLOW_RULES.unreviewedMinShare;
96
+ const FLOW_UNREVIEWED_MIN_COUNT = FLOW_RULES.unreviewedMinCount;
97
+ const HOUR_MS = 3_600_000;
98
+ // ── Small pure helpers ───────────────────────────────────────────────────────────────────────
99
+ function chunk(xs, n) {
100
+ const out = [];
101
+ for (let i = 0; i < xs.length; i += n)
102
+ out.push(xs.slice(i, i + n));
103
+ return out;
104
+ }
105
+ /** Nearest-rank percentile over an unsorted array. Returns 0 for an empty one — every caller
106
+ * guards on the sample floor before it reads the result. */
107
+ export function percentile(xs, p) {
108
+ if (xs.length === 0)
109
+ return 0;
110
+ const s = [...xs].sort((a, b) => a - b);
111
+ const i = Math.min(s.length - 1, Math.max(0, Math.ceil(p * s.length) - 1));
112
+ return s[i] ?? 0;
113
+ }
114
+ export function median(xs) {
115
+ return percentile(xs, 0.5);
116
+ }
117
+ /** One decimal at most, never a bare `.0` — "4.0h" implies a precision this file cannot support. */
118
+ function oneDp(n) {
119
+ return n.toFixed(1).replace(/\.0$/, '');
120
+ }
121
+ /**
122
+ * A duration in prose. ⚠ Used ONLY for the templated sentences; the wire carries raw hours and the
123
+ * SPA formats them, so a figure never appears on screen in two spellings.
124
+ */
125
+ export function fmtHours(h) {
126
+ if (h < 1)
127
+ return `${Math.max(1, Math.round(h * 60))} minutes`;
128
+ if (h < 10)
129
+ return `${oneDp(h)} hours`;
130
+ if (h < 48)
131
+ return `${Math.round(h)} hours`;
132
+ return `${oneDp(h / 24)} days`;
133
+ }
134
+ function pct(share) {
135
+ return `${Math.round(share * 100)}%`;
136
+ }
137
+ function plural(n, one, many) {
138
+ return n === 1 ? one : many;
139
+ }
140
+ /**
141
+ * THE LEDGER, as a pure function so it can be tested without a database. ⚠ THE ONLY STATE
142
+ * MACHINE: `walkCourts` below is a sum over these spells, so the clock-hour split and the
143
+ * working-hour split cannot disagree about who held the ball.
144
+ *
145
+ * `actions` need not be sorted; anything outside `[openedMs, mergedMs]` is ignored rather than
146
+ * clamped, because an action stamped outside the pull request's own life is a data error and
147
+ * silently stretching an interval to cover it would put invented hours on the screen.
148
+ */
149
+ export function walkCourtIntervals(openedMs, mergedMs, actions) {
150
+ const walk = {
151
+ spells: [],
152
+ rounds: 0,
153
+ firstLookMs: null,
154
+ firstReviewerId: null,
155
+ approvedAtMs: null,
156
+ };
157
+ if (!(mergedMs > openedMs))
158
+ return walk;
159
+ const inLife = actions
160
+ .filter((a) => a.atMs >= openedMs && a.atMs <= mergedMs)
161
+ .sort((a, b) => a.atMs - b.atMs);
162
+ let court = 'reviewer';
163
+ let approved = false;
164
+ let last = openedMs;
165
+ const charge = (untilMs) => {
166
+ if (untilMs > last) {
167
+ const prev = walk.spells[walk.spells.length - 1];
168
+ if (prev && prev.court === court && prev.toMs === last)
169
+ prev.toMs = untilMs;
170
+ else
171
+ walk.spells.push({ court, fromMs: last, toMs: untilMs });
172
+ }
173
+ last = untilMs;
174
+ };
175
+ for (const a of inLife) {
176
+ charge(a.atMs);
177
+ const before = court;
178
+ if (a.by === 'reviewer' && walk.firstLookMs == null) {
179
+ walk.firstLookMs = a.atMs;
180
+ walk.firstReviewerId = a.userId ?? null;
181
+ }
182
+ if (a.approves) {
183
+ approved = true;
184
+ if (walk.approvedAtMs == null)
185
+ walk.approvedAtMs = a.atMs;
186
+ court = 'landing';
187
+ }
188
+ else if (a.by === 'reviewer') {
189
+ // Decision 1: somebody said something, so the author owes a reply — even after an approval.
190
+ court = 'author';
191
+ }
192
+ else {
193
+ // Decision 2: an author push after approval stays in LANDING. Whether it invalidates the
194
+ // approval is a branch-protection setting we do not sync.
195
+ court = approved ? 'landing' : 'reviewer';
196
+ }
197
+ if (court === 'author' && before !== 'author')
198
+ walk.rounds += 1;
199
+ }
200
+ charge(mergedMs);
201
+ return walk;
202
+ }
203
+ /** Clock hours per court — the sum of `walkCourtIntervals`' spells. */
204
+ export function walkCourts(openedMs, mergedMs, actions) {
205
+ const hours = { reviewer: 0, author: 0, landing: 0 };
206
+ for (const s of walkCourtIntervals(openedMs, mergedMs, actions).spells) {
207
+ hours[s.court] += (s.toMs - s.fromMs) / HOUR_MS;
208
+ }
209
+ return hours;
210
+ }
211
+ // ── The templated narrative ──────────────────────────────────────────────────────────────────
212
+ //
213
+ // ⚠ ONE SENTENCE PER COURT, AND EACH NAMES A DIFFERENT ACTION. That is the whole point of the
214
+ // split: "your PRs are slow" is not a finding, "nothing is blocked on the author or on CI, so this
215
+ // is a routing problem" is. Every figure interpolated below is code-derived.
216
+ //
217
+ // The actions are the ones with published effect sizes behind them, which is why they are worth
218
+ // hard-coding rather than leaving to the reader:
219
+ // reviewer -> a reminder on an overdue pull request, and naming an individual rather than a team
220
+ // author -> nothing an EM can push on directly; the honest advice is about round-trip cost
221
+ // landing -> automatic merge, which this product already ships
222
+ const COURT_WAIT = {
223
+ reviewer: 'waiting for somebody to look at it',
224
+ author: 'waiting for its author to answer review',
225
+ landing: 'approved and waiting to merge',
226
+ };
227
+ /**
228
+ * The REPO row's own sentence: figures specific to this repository and NOTHING ELSE.
229
+ *
230
+ * ⚠ No advice here — see `RepoCourtProfile.narrative`. Six repos in one court produced six
231
+ * identical recommendations on the first cut, which is exactly the restatement problem that made
232
+ * the path-bucket findings worthless.
233
+ */
234
+ function narrate(court, share, prs, p75LeadHours, medianLeadHours) {
235
+ return (`${pct(share)} of open time is spent ${COURT_WAIT[court]}. ` +
236
+ `Across ${prs} merged pull ${plural(prs, 'request', 'requests')}, half cleared in ` +
237
+ `${fmtHours(medianLeadHours)} and the slowest quarter took ${fmtHours(p75LeadHours)} or more.`);
238
+ }
239
+ /**
240
+ * The advice, once per court. See `CourtDirective` in shared for why it lives at this grain.
241
+ *
242
+ * ⚠ `namedBeatsTeam` IS THIS WORKSPACE'S OWN MEASUREMENT, and it can overrule the default advice.
243
+ * "Request a named reviewer instead of a team" is the published intervention, but on a real
244
+ * workspace a team request was answered FASTER (1.8 working hours to 3.1). Advice the reader's
245
+ * own history contradicts is worse than none, so when the stored request history shows a team
246
+ * answered at least as quickly, the reviewer paragraph drops that clause. `null` (no history, or
247
+ * too few of either) keeps the default.
248
+ */
249
+ function directiveFor(court, repos, namedBeatsTeam = null) {
250
+ const where = `${repos} ${plural(repos, 'repository', 'repositories')} here ${plural(repos, 'is', 'are')}`;
251
+ switch (court) {
252
+ case 'reviewer':
253
+ if (namedBeatsTeam === false) {
254
+ return (`${where} spending most of a pull request's life waiting for a person to look — not on ` +
255
+ `the author, and not on checks. That makes it a routing problem rather than a capacity ` +
256
+ `one: chase the reviews that are already overdue. Asking a team has been ` +
257
+ `answered as quickly as asking a named person here, so what matters is that somebody ` +
258
+ `follows up, not who was asked.`);
259
+ }
260
+ return (`${where} spending most of a pull request's life waiting for a person to look — not on ` +
261
+ `the author, and not on checks. That makes it a routing problem rather than a capacity ` +
262
+ `one: request a named reviewer instead of a team, and chase the reviews that are already ` +
263
+ `overdue. Both are cheap, and both are the interventions with the largest ` +
264
+ `measured effect on how long a pull request stays open.`);
265
+ case 'author':
266
+ return (`${where} spending most of a pull request's life waiting for its author to answer ` +
267
+ `review. Reviewers are keeping up — the cost is in the round trip. Fewer, clearer review ` +
268
+ `passes will do more here than asking anyone to go faster, and nothing on this screen is ` +
269
+ `an argument about how quickly an individual replies.`);
270
+ case 'landing':
271
+ return (`${where} spending most of a pull request's life approved and waiting to merge. The ` +
272
+ `review is already done, so this is the merge step and not a people problem. Arming ` +
273
+ `"merge when ready" lands these without anyone watching them.`);
274
+ }
275
+ }
276
+ /**
277
+ * The same advice in ONE line, for the page; `directiveFor`'s paragraph sits behind the "i".
278
+ *
279
+ * ⚠ It states the rule that put the repositories here (the dominant share, read from the constant
280
+ * so it cannot drift from the fold) and the action, and nothing else — the per-repository figures
281
+ * are printed on the rows beneath it. `namedBeatsTeam` has the same meaning as in `directiveFor`:
282
+ * only a measured `false` drops the named-reviewer clause.
283
+ */
284
+ function directiveSummaryFor(court, namedBeatsTeam = null) {
285
+ const lead = `At least ${pct(FLOW_DOMINANT_SHARE)} of the time here is spent `;
286
+ switch (court) {
287
+ case 'reviewer':
288
+ return namedBeatsTeam === false
289
+ ? `${lead}waiting for a reviewer. Chase overdue reviews.`
290
+ : `${lead}waiting for a reviewer. Ask a named person rather than a team, and chase overdue reviews.`;
291
+ case 'author':
292
+ return `${lead}waiting for the author. Fewer, clearer review rounds will help more than speed.`;
293
+ case 'landing':
294
+ return `${lead}approved and waiting to merge. Arm “merge when ready” to land these.`;
295
+ }
296
+ }
297
+ // ── The refusals ─────────────────────────────────────────────────────────────────────────────
298
+ //
299
+ // ⚠ ONE PLAIN FACT EACH. The rule behind a refusal (the lopsided-and-slow call-out, the
300
+ // unreviewed-merge floor) is explained behind that section's "i" on the page, quoting FLOW_RULES;
301
+ // repeating it inside the refusal put the same explanation on screen twice.
302
+ function noHumanReviewReason(windowDays) {
303
+ return `No pull request merged in the last ${windowDays} days had a human review or comment on it.`;
304
+ }
305
+ function noneStandsOutReason(measured, windowDays) {
306
+ return `Measured ${measured} ${plural(measured, 'repository', 'repositories')} in the last ${windowDays} days. None stands out.`;
307
+ }
308
+ function unreviewedUnderFloorReason() {
309
+ return 'No repository merged enough pull requests without a human review to name.';
310
+ }
311
+ function headlineFor(courts, medianLead, p75Lead, prs) {
312
+ const by = new Map(courts.map((c) => [c.court, c.share]));
313
+ const top = [...courts].sort((a, b) => b.share - a.share)[0];
314
+ const lead = `Half of pull requests cleared in ${fmtHours(medianLead)}; the slowest quarter took ` +
315
+ `${fmtHours(p75Lead)} or more.`;
316
+ return (`Across ${prs} merged pull requests a person actually worked on, time split ` +
317
+ `${pct(by.get('reviewer') ?? 0)} waiting for a reviewer, ` +
318
+ `${pct(by.get('author') ?? 0)} waiting for the author, and ` +
319
+ `${pct(by.get('landing') ?? 0)} approved and waiting to land. ` +
320
+ `${lead}${top && top.share >= FLOW_DOMINANT_SHARE ? '' : ' No single court dominates here.'}`);
321
+ }
322
+ function sharesOf(h) {
323
+ const total = h.reviewer + h.author + h.landing;
324
+ const s = (v) => (total > 0 ? v / total : 0);
325
+ return [
326
+ { court: 'reviewer', hours: h.reviewer, share: s(h.reviewer) },
327
+ { court: 'author', hours: h.author, share: s(h.author) },
328
+ { court: 'landing', hours: h.landing, share: s(h.landing) },
329
+ ];
330
+ }
331
+ /**
332
+ * Lines added plus removed, or null when the size was never observed. The same ambiguity
333
+ * `codeLocFor` documents: the columns are NOT NULL with a 0 default, so all-zero columns with a
334
+ * file list that sums to zero is "we never saw its size", not "it changed nothing".
335
+ */
336
+ function linesOf(pr) {
337
+ const cols = pr.additions + pr.deletions;
338
+ if (cols > 0)
339
+ return cols;
340
+ const fromFiles = (pr.files ?? []).reduce((n, f) => n + ((f?.additions ?? 0) + (f?.deletions ?? 0)), 0);
341
+ return fromFiles > 0 ? fromFiles : null;
342
+ }
343
+ export async function getFlowCourts(accountId, scope, windowDaysRaw) {
344
+ const windowDays = Math.min(FLOW_MAX_WINDOW_DAYS, Math.max(FLOW_MIN_WINDOW_DAYS, Math.round(windowDaysRaw)));
345
+ const toMs = Date.now();
346
+ const to = new Date(toMs);
347
+ const from = new Date(toMs - windowDays * 24 * HOUR_MS);
348
+ const refusals = [];
349
+ const refuse = (kind, reason, basis = 'insufficient_data') => {
350
+ refusals.push({ kind, reason, basis });
351
+ };
352
+ const settings = await getResolvedFlowSettings(accountId, scope.workspaceId);
353
+ const empty = (coverage) => ({
354
+ workspaceId: scope.workspaceId,
355
+ windowDays,
356
+ measuredPrs: 0,
357
+ courts: sharesOf({ reviewer: 0, author: 0, landing: 0 }),
358
+ medianLeadHours: 0,
359
+ p75LeadHours: 0,
360
+ headline: null,
361
+ repos: [],
362
+ directives: [],
363
+ unreviewed: [],
364
+ refusals,
365
+ coverage,
366
+ settings,
367
+ courtsWork: sharesOf({ reviewer: 0, author: 0, landing: 0 }),
368
+ medianLeadWorkHours: 0,
369
+ p75LeadWorkHours: 0,
370
+ workHeadline: null,
371
+ budgets: [],
372
+ prs: [],
373
+ prsCapped: false,
374
+ contrast: null,
375
+ sizeBands: [],
376
+ weekdays: [],
377
+ landingTail: null,
378
+ concentration: [],
379
+ prFigures: { overWorkingDay: 0, slowestTenthCount: 0, slowestTenthShare: 0, neverWentBack: 0 },
380
+ });
381
+ // `[]` is a real answer ("this workspace is empty"), never a widening to the whole account.
382
+ if (scope.repoIds.length === 0) {
383
+ for (const k of ['courts', 'unreviewed']) {
384
+ refuse(k, 'This workspace has no repositories yet.');
385
+ }
386
+ return empty({
387
+ reposInWorkspace: 0,
388
+ reposWithData: 0,
389
+ prsScanned: 0,
390
+ truncated: false,
391
+ excludedNoHumanTouch: 0,
392
+ excludedBotAuthored: 0,
393
+ });
394
+ }
395
+ const caps = { truncated: false };
396
+ const noteCap = (rows, cap) => {
397
+ if (rows >= cap)
398
+ caps.truncated = true;
399
+ };
400
+ const repoRows = await db
401
+ .select({ id: repos.id, owner: repos.owner, name: repos.name })
402
+ .from(repos)
403
+ .where(and(eq(repos.accountId, accountId), inArray(repos.id, scope.repoIds)))
404
+ .execute();
405
+ const repoName = new Map(repoRows.map((r) => [r.id, `${r.owner}/${r.name}`]));
406
+ // Merged in-window. TWO-SIDED, half-open — the window-purity rule.
407
+ const prRows = await db
408
+ .select({
409
+ id: pullRequests.id,
410
+ repoId: pullRequests.repoId,
411
+ number: pullRequests.number,
412
+ title: pullRequests.title,
413
+ authorId: pullRequests.authorId,
414
+ mergedById: pullRequests.mergedById,
415
+ headRefName: pullRequests.headRefName,
416
+ additions: pullRequests.additions,
417
+ deletions: pullRequests.deletions,
418
+ changedFiles: pullRequests.changedFiles,
419
+ files: pullRequests.files,
420
+ requestsSyncedAt: pullRequests.reviewRequestsSyncedAt,
421
+ openedAt: pullRequests.openedAt,
422
+ mergedAt: pullRequests.mergedAt,
423
+ })
424
+ .from(pullRequests)
425
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(pullRequests.state, 'merged'), gte(pullRequests.mergedAt, from), lt(pullRequests.mergedAt, to)))
426
+ .limit(FLOW_PR_CAP)
427
+ .execute();
428
+ noteCap(prRows.length, FLOW_PR_CAP);
429
+ const prs = [];
430
+ for (const r of prRows) {
431
+ if (r.openedAt == null || r.mergedAt == null)
432
+ continue;
433
+ const openedMs = r.openedAt.getTime();
434
+ const mergedMs = r.mergedAt.getTime();
435
+ if (!(mergedMs > openedMs))
436
+ continue;
437
+ prs.push({
438
+ id: r.id,
439
+ repoId: r.repoId,
440
+ number: r.number,
441
+ title: r.title,
442
+ authorId: r.authorId,
443
+ mergedById: r.mergedById,
444
+ headRefName: r.headRefName,
445
+ additions: r.additions,
446
+ deletions: r.deletions,
447
+ changedFiles: r.changedFiles,
448
+ files: r.files ?? null,
449
+ openedMs,
450
+ mergedMs,
451
+ requestsKnown: r.requestsSyncedAt != null,
452
+ });
453
+ }
454
+ if (prs.length === 0) {
455
+ for (const k of ['courts', 'unreviewed']) {
456
+ refuse(k, `No pull request merged in the last ${windowDays} days.`);
457
+ }
458
+ return empty({
459
+ reposInWorkspace: scope.repoIds.length,
460
+ reposWithData: 0,
461
+ prsScanned: 0,
462
+ truncated: caps.truncated,
463
+ excludedNoHumanTouch: 0,
464
+ excludedBotAuthored: 0,
465
+ });
466
+ }
467
+ const byId = new Map(prs.map((p) => [p.id, p]));
468
+ const prIds = prs.map((p) => p.id);
469
+ const lanes = await resolveActorLanes(accountId, scope);
470
+ // ⚠ The lane resolver's UNION, never `users.isBot` alone — see the header.
471
+ const isHuman = (userId) => userId != null && lanes.laneOf(userId) === 'human';
472
+ const actions = new Map();
473
+ const requestEvents = new Map();
474
+ const ciObs = new Map();
475
+ const push = (prId, a) => {
476
+ const list = actions.get(prId);
477
+ if (list)
478
+ list.push(a);
479
+ else
480
+ actions.set(prId, [a]);
481
+ };
482
+ for (const ids of chunk(prIds, ID_CHUNK)) {
483
+ // Reviews. `state` carries the approval latch.
484
+ const rvRows = await db
485
+ .select({ prId: reviews.prId, authorId: reviews.authorId, at: reviews.submittedAt, state: reviews.state })
486
+ .from(reviews)
487
+ .where(inArray(reviews.prId, ids))
488
+ .limit(FLOW_ACTION_CAP)
489
+ .execute();
490
+ noteCap(rvRows.length, FLOW_ACTION_CAP);
491
+ for (const r of rvRows) {
492
+ if (r.at == null || !isHuman(r.authorId))
493
+ continue;
494
+ if (r.state === 'pending')
495
+ continue;
496
+ const pr = byId.get(r.prId);
497
+ if (pr == null)
498
+ continue;
499
+ // A review by the pull request's OWN author is a self-comment, not somebody reviewing it.
500
+ const self = r.authorId != null && r.authorId === pr.authorId;
501
+ push(r.prId, {
502
+ atMs: r.at.getTime(),
503
+ by: self ? 'author' : 'reviewer',
504
+ approves: !self && r.state === 'approved',
505
+ userId: r.authorId,
506
+ });
507
+ }
508
+ // Inline review comments.
509
+ const rcRows = await db
510
+ .select({ prId: reviewComments.prId, authorId: reviewComments.authorId, at: reviewComments.createdAt })
511
+ .from(reviewComments)
512
+ .where(inArray(reviewComments.prId, ids))
513
+ .limit(FLOW_ACTION_CAP)
514
+ .execute();
515
+ noteCap(rcRows.length, FLOW_ACTION_CAP);
516
+ for (const r of rcRows) {
517
+ if (r.at == null || !isHuman(r.authorId))
518
+ continue;
519
+ const pr = byId.get(r.prId);
520
+ if (pr == null)
521
+ continue;
522
+ push(r.prId, {
523
+ atMs: r.at.getTime(),
524
+ by: r.authorId === pr.authorId ? 'author' : 'reviewer',
525
+ approves: false,
526
+ userId: r.authorId,
527
+ });
528
+ }
529
+ // Conversation comments.
530
+ const pcRows = await db
531
+ .select({ prId: prComments.prId, authorId: prComments.authorId, at: prComments.createdAt })
532
+ .from(prComments)
533
+ .where(inArray(prComments.prId, ids))
534
+ .limit(FLOW_ACTION_CAP)
535
+ .execute();
536
+ noteCap(pcRows.length, FLOW_ACTION_CAP);
537
+ for (const r of pcRows) {
538
+ if (r.at == null || !isHuman(r.authorId))
539
+ continue;
540
+ const pr = byId.get(r.prId);
541
+ if (pr == null)
542
+ continue;
543
+ push(r.prId, {
544
+ atMs: r.at.getTime(),
545
+ by: r.authorId === pr.authorId ? 'author' : 'reviewer',
546
+ approves: false,
547
+ userId: r.authorId,
548
+ });
549
+ }
550
+ // Pushes. A commit is ALWAYS an author action whoever authored it — the ball moves because new
551
+ // code arrived on the branch, not because of whose name is on it.
552
+ const cmRows = await db
553
+ .select({ prId: commits.prId, at: commits.committedAt })
554
+ .from(commits)
555
+ .where(inArray(commits.prId, ids))
556
+ .limit(FLOW_ACTION_CAP)
557
+ .execute();
558
+ noteCap(cmRows.length, FLOW_ACTION_CAP);
559
+ for (const r of cmRows) {
560
+ if (r.at == null)
561
+ continue;
562
+ push(r.prId, { atMs: r.at.getTime(), by: 'author', approves: false });
563
+ }
564
+ // Review-request HISTORY — who was asked first, and when. Never moves the ball: a request is
565
+ // not a review. Read by PR id only (a PR child with no account_id; the ids are this account's).
566
+ const rrRows = await db
567
+ .select({
568
+ prId: reviewRequestEvents.prId,
569
+ at: reviewRequestEvents.occurredAt,
570
+ kind: reviewRequestEvents.kind,
571
+ reviewerKind: reviewRequestEvents.reviewerKind,
572
+ userId: reviewRequestEvents.reviewerUserId,
573
+ })
574
+ .from(reviewRequestEvents)
575
+ .where(inArray(reviewRequestEvents.prId, ids))
576
+ .limit(FLOW_ACTION_CAP)
577
+ .execute();
578
+ noteCap(rrRows.length, FLOW_ACTION_CAP);
579
+ for (const r of rrRows) {
580
+ if (r.at == null)
581
+ continue;
582
+ const list = requestEvents.get(r.prId);
583
+ const e = { atMs: r.at.getTime(), kind: r.kind, reviewerKind: r.reviewerKind, userId: r.userId };
584
+ if (list)
585
+ list.push(e);
586
+ else
587
+ requestEvents.set(r.prId, [e]);
588
+ }
589
+ // CI observations — for "checks went red", never for the ball. Red time is approximate: a
590
+ // failure counts until the next observation, and observations are only as dense as syncs.
591
+ const ciRows = await db
592
+ .select({ prId: ciStatusEvents.prId, at: ciStatusEvents.observedAt, status: ciStatusEvents.status })
593
+ .from(ciStatusEvents)
594
+ .where(and(eq(ciStatusEvents.accountId, accountId), inArray(ciStatusEvents.prId, ids)))
595
+ .limit(FLOW_ACTION_CAP)
596
+ .execute();
597
+ noteCap(ciRows.length, FLOW_ACTION_CAP);
598
+ for (const r of ciRows) {
599
+ if (r.at == null)
600
+ continue;
601
+ const list = ciObs.get(r.prId);
602
+ const o = { atMs: r.at.getTime(), red: r.status === 'failure' || r.status === 'error' };
603
+ if (list)
604
+ list.push(o);
605
+ else
606
+ ciObs.set(r.prId, [o]);
607
+ }
608
+ }
609
+ // Author bot-ness. Read BY ID — `users` is one of the two GLOBAL tables and is never handed to a
610
+ // tenant as a listing. ⚠ This is not decoration on the evidence rows: it GATES the population.
611
+ const authorIds = [...new Set(prs.map((p) => p.authorId).filter((id) => id != null))];
612
+ const botAuthors = new Set();
613
+ for (const ids of chunk(authorIds, ID_CHUNK)) {
614
+ const uRows = await db
615
+ .select({ id: users.id, isBot: users.isBot })
616
+ .from(users)
617
+ .where(inArray(users.id, ids))
618
+ .execute();
619
+ for (const u of uRows)
620
+ if (u.isBot || !isHuman(u.id))
621
+ botAuthors.add(u.id);
622
+ }
623
+ const measured = [];
624
+ const unreviewedByRepo = new Map();
625
+ let excludedNoHumanTouch = 0;
626
+ let excludedBotAuthored = 0;
627
+ for (const pr of prs) {
628
+ // ⚠ AUTOMATION'S OWN PULL REQUESTS ARE NOT THIS SCREEN'S SUBJECT. See
629
+ // `FlowCoverage.excludedBotAuthored` — blending them moved every share and produced evidence
630
+ // rows citing dependency bumps as things people were waiting on.
631
+ if (pr.authorId != null && botAuthors.has(pr.authorId)) {
632
+ excludedBotAuthored += 1;
633
+ continue;
634
+ }
635
+ const acts = actions.get(pr.id) ?? [];
636
+ const humanReviewed = acts.some((a) => a.by === 'reviewer');
637
+ if (!humanReviewed) {
638
+ // Decision 3: excluded from the split, reported as governance.
639
+ excludedNoHumanTouch += 1;
640
+ const list = unreviewedByRepo.get(pr.repoId);
641
+ if (list)
642
+ list.push(pr);
643
+ else
644
+ unreviewedByRepo.set(pr.repoId, [pr]);
645
+ continue;
646
+ }
647
+ const walk = walkCourtIntervals(pr.openedMs, pr.mergedMs, acts);
648
+ const hours = { reviewer: 0, author: 0, landing: 0 };
649
+ for (const sp of walk.spells)
650
+ hours[sp.court] += (sp.toMs - sp.fromMs) / HOUR_MS;
651
+ measured.push({
652
+ pr,
653
+ hours,
654
+ leadHours: (pr.mergedMs - pr.openedMs) / HOUR_MS,
655
+ walk,
656
+ });
657
+ }
658
+ // ⚠ The unreviewed-merge DENOMINATOR is the same human-authored population the shares are over.
659
+ // Counting bot merges into it would report "30% of merges went in unreviewed" against a total
660
+ // that includes work no human was ever meant to review.
661
+ const mergedByRepo = new Map();
662
+ for (const pr of prs) {
663
+ if (pr.authorId != null && botAuthors.has(pr.authorId))
664
+ continue;
665
+ mergedByRepo.set(pr.repoId, (mergedByRepo.get(pr.repoId) ?? 0) + 1);
666
+ }
667
+ const coverage = {
668
+ reposInWorkspace: scope.repoIds.length,
669
+ reposWithData: new Set(prs.map((p) => p.repoId)).size,
670
+ prsScanned: prs.length,
671
+ truncated: caps.truncated,
672
+ excludedNoHumanTouch,
673
+ excludedBotAuthored,
674
+ };
675
+ if (measured.length === 0) {
676
+ refuse('courts', noHumanReviewReason(windowDays));
677
+ }
678
+ // ── Workspace-wide ─────────────────────────────────────────────────────────────────────────
679
+ const wsHours = { reviewer: 0, author: 0, landing: 0 };
680
+ for (const m of measured) {
681
+ wsHours.reviewer += m.hours.reviewer;
682
+ wsHours.author += m.hours.author;
683
+ wsHours.landing += m.hours.landing;
684
+ }
685
+ const wsCourts = sharesOf(wsHours);
686
+ const wsLeads = measured.map((m) => m.leadHours);
687
+ const wsMedian = median(wsLeads);
688
+ const wsP75 = percentile(wsLeads, 0.75);
689
+ // ── Per repo ───────────────────────────────────────────────────────────────────────────────
690
+ const byRepo = new Map();
691
+ for (const m of measured) {
692
+ const list = byRepo.get(m.pr.repoId);
693
+ if (list)
694
+ list.push(m);
695
+ else
696
+ byRepo.set(m.pr.repoId, [m]);
697
+ }
698
+ const ghUrl = (repoId, number) => `https://github.com/${repoName.get(repoId) ?? ''}/pull/${number}`;
699
+ const evidenceOf = (rows, court) => [...rows]
700
+ .sort((a, b) => b.hours[court] - a.hours[court] || a.pr.id - b.pr.id)
701
+ .slice(0, FLOW_EVIDENCE_CAP)
702
+ .map((m) => ({
703
+ prId: m.pr.id,
704
+ repoFullName: repoName.get(m.pr.repoId) ?? '',
705
+ prNumber: m.pr.number,
706
+ prTitle: m.pr.title,
707
+ githubUrl: ghUrl(m.pr.repoId, m.pr.number),
708
+ hoursInCourt: m.hours[court],
709
+ leadHours: m.leadHours,
710
+ authorIsBot: m.pr.authorId != null && botAuthors.has(m.pr.authorId),
711
+ }));
712
+ let clearedFloor = 0;
713
+ const profiles = [];
714
+ for (const [repoId, rows] of byRepo) {
715
+ if (rows.length < FLOW_MIN_REPO_PRS)
716
+ continue;
717
+ clearedFloor += 1;
718
+ const h = { reviewer: 0, author: 0, landing: 0 };
719
+ for (const m of rows) {
720
+ h.reviewer += m.hours.reviewer;
721
+ h.author += m.hours.author;
722
+ h.landing += m.hours.landing;
723
+ }
724
+ const courts = sharesOf(h);
725
+ const leads = rows.map((m) => m.leadHours);
726
+ const p75 = percentile(leads, 0.75);
727
+ const top = [...courts].sort((a, b) => b.share - a.share)[0];
728
+ // ⚠ LOPSIDED **AND** SLOW. See FLOW_SLOW_P75_HOURS — a share alone invents a crisis in a repo
729
+ // whose pull requests clear in eighteen minutes.
730
+ const dominant = top != null && top.share >= FLOW_DOMINANT_SHARE && p75 >= FLOW_SLOW_P75_HOURS
731
+ ? top.court
732
+ : null;
733
+ profiles.push({
734
+ repoId,
735
+ repoFullName: repoName.get(repoId) ?? '',
736
+ prs: rows.length,
737
+ courts,
738
+ medianLeadHours: median(leads),
739
+ p75LeadHours: p75,
740
+ dominant,
741
+ narrative: dominant == null ? null : narrate(dominant, top?.share ?? 0, rows.length, p75, median(leads)),
742
+ evidence: dominant == null ? [] : evidenceOf(rows, dominant),
743
+ });
744
+ }
745
+ // Worst first: a named court outranks a quiet repo, then by how slow the tail is.
746
+ profiles.sort((a, b) => Number(b.dominant != null) - Number(a.dominant != null) ||
747
+ b.p75LeadHours - a.p75LeadHours ||
748
+ a.repoFullName.localeCompare(b.repoFullName));
749
+ const shownRepos = profiles.slice(0, FLOW_REPO_CAP);
750
+ // One directive per court that actually has a repository under it, in a fixed order so the
751
+ // screen does not reshuffle between polls.
752
+ const COURT_ORDER = ['reviewer', 'author', 'landing'];
753
+ const directives = COURT_ORDER.map((court) => ({
754
+ court,
755
+ repos: shownRepos.filter((r) => r.dominant === court).length,
756
+ }))
757
+ .filter((d) => d.repos > 0)
758
+ .map((d) => ({
759
+ ...d,
760
+ directive: directiveFor(d.court, d.repos),
761
+ summary: directiveSummaryFor(d.court),
762
+ }));
763
+ if (measured.length > 0 && clearedFloor === 0) {
764
+ refuse('courts', `No repository reached ${FLOW_MIN_REPO_PRS} merged pull requests with a human review in the last ${windowDays} days.`);
765
+ }
766
+ else if (shownRepos.length > 0 && shownRepos.every((p) => p.dominant == null)) {
767
+ refuse('courts', noneStandsOutReason(clearedFloor, windowDays), 'measured_clean');
768
+ }
769
+ // ── Merged without a human review ──────────────────────────────────────────────────────────
770
+ const unreviewed = [];
771
+ for (const [repoId, rows] of unreviewedByRepo) {
772
+ const merged = mergedByRepo.get(repoId) ?? 0;
773
+ if (merged === 0)
774
+ continue;
775
+ const share = rows.length / merged;
776
+ if (rows.length < FLOW_UNREVIEWED_MIN_COUNT || share < FLOW_UNREVIEWED_MIN_SHARE)
777
+ continue;
778
+ unreviewed.push({
779
+ repoId,
780
+ repoFullName: repoName.get(repoId) ?? '',
781
+ merged,
782
+ withoutHumanReview: rows.length,
783
+ share,
784
+ evidence: [...rows]
785
+ .sort((a, b) => b.mergedMs - a.mergedMs || a.id - b.id)
786
+ .slice(0, FLOW_EVIDENCE_CAP)
787
+ .map((pr) => ({
788
+ prId: pr.id,
789
+ repoFullName: repoName.get(pr.repoId) ?? '',
790
+ prNumber: pr.number,
791
+ prTitle: pr.title,
792
+ githubUrl: ghUrl(pr.repoId, pr.number),
793
+ hoursInCourt: 0,
794
+ leadHours: (pr.mergedMs - pr.openedMs) / HOUR_MS,
795
+ authorIsBot: pr.authorId != null && botAuthors.has(pr.authorId),
796
+ })),
797
+ });
798
+ }
799
+ unreviewed.sort((a, b) => b.share - a.share || a.repoFullName.localeCompare(b.repoFullName));
800
+ if (unreviewed.length === 0) {
801
+ refuse('unreviewed', excludedNoHumanTouch === 0
802
+ ? `Every pull request merged in the last ${windowDays} days had a human review or comment on it.`
803
+ : unreviewedUnderFloorReason(), 'measured_clean');
804
+ }
805
+ // ── Working hours, budgets and the per-PR view (db/flow-detail.ts) ──────────────────────────
806
+ const facts = measured.map((m) => {
807
+ const reach = new Set();
808
+ for (const f of m.pr.files ?? []) {
809
+ if (typeof f?.path === 'string')
810
+ for (const s of surfacesForPath(f.path))
811
+ reach.add(s);
812
+ }
813
+ return {
814
+ prId: m.pr.id,
815
+ repoId: m.pr.repoId,
816
+ repoFullName: repoName.get(m.pr.repoId) ?? '',
817
+ number: m.pr.number,
818
+ title: m.pr.title,
819
+ githubUrl: ghUrl(m.pr.repoId, m.pr.number),
820
+ openedMs: m.pr.openedMs,
821
+ mergedMs: m.pr.mergedMs,
822
+ spells: m.walk.spells,
823
+ rounds: m.walk.rounds,
824
+ firstLookMs: m.walk.firstLookMs,
825
+ approvedAtMs: m.walk.approvedAtMs,
826
+ firstReviewerId: m.walk.firstReviewerId,
827
+ lines: linesOf(m.pr),
828
+ files: linesOf(m.pr) == null ? null : m.pr.changedFiles || (m.pr.files?.length ?? null),
829
+ reachAreas: [...reach].sort(),
830
+ ciRedHours: ciRedHoursOf(ciObs.get(m.pr.id) ?? [], m.pr.openedMs, m.pr.mergedMs),
831
+ ticketKey: ticketKeyOf(m.pr.title, m.pr.headRefName),
832
+ selfMerged: m.pr.mergedById != null && m.pr.mergedById === m.pr.authorId,
833
+ ...firstRequestOf(m.pr, requestEvents.get(m.pr.id) ?? [], isHuman),
834
+ };
835
+ });
836
+ const calFrom = facts.reduce((lo, f) => Math.min(lo, f.openedMs), toMs);
837
+ const calendar = buildWorkingCalendar(settings, calFrom, toMs);
838
+ const detail = buildFlowDetail(facts, calendar, settings, FLOW_MIN_REPO_PRS);
839
+ // The workspace's own answer to "named person or team?", when it has one — see directiveFor.
840
+ const person = detail.requests?.rows.find((r) => r.kind === 'person')?.medianRequestToLookWorkHours;
841
+ const team = detail.requests?.rows.find((r) => r.kind === 'team')?.medianRequestToLookWorkHours;
842
+ const namedBeatsTeam = person == null || team == null ? null : person < team;
843
+ if (namedBeatsTeam === false) {
844
+ for (const d of directives) {
845
+ if (d.court !== 'reviewer')
846
+ continue;
847
+ d.directive = directiveFor('reviewer', d.repos, false);
848
+ d.summary = directiveSummaryFor('reviewer', false);
849
+ }
850
+ }
851
+ return {
852
+ workspaceId: scope.workspaceId,
853
+ windowDays,
854
+ measuredPrs: measured.length,
855
+ courts: wsCourts,
856
+ medianLeadHours: wsMedian,
857
+ p75LeadHours: wsP75,
858
+ headline: measured.length === 0 ? null : headlineFor(wsCourts, wsMedian, wsP75, measured.length),
859
+ repos: shownRepos,
860
+ directives,
861
+ unreviewed,
862
+ refusals,
863
+ coverage,
864
+ settings,
865
+ ...detail,
866
+ };
867
+ }
868
+ /**
869
+ * Who was asked FIRST, and when — from the stored request history.
870
+ *
871
+ * ⚠ NULL KIND WHEN THE HISTORY WAS NEVER RECEIVED. A PR with no rows reads "nobody was asked" only
872
+ * when we hold a positive statement of that (`requestsKnown`); otherwise it is "not known", and
873
+ * Chronology counts it apart rather than in the "nobody" row.
874
+ *
875
+ * The first MOMENT decides: requests made in the same instant (a person and a team together) count
876
+ * as a named person, because a person was named. Requests to automation (a Copilot review, a bot
877
+ * account) are skipped — asking a bot is not asking someone to look — so a PR whose only requests
878
+ * went to bots reads "nobody". A request dated before the PR opened is clamped to the opening.
879
+ */
880
+ function firstRequestOf(pr, events, isHuman) {
881
+ if (!pr.requestsKnown)
882
+ return { requestKind: null, firstRequestMs: null };
883
+ const asks = events
884
+ .filter((e) => e.kind === 'requested' && e.atMs <= pr.mergedMs)
885
+ .filter((e) => e.reviewerKind === 'team' ||
886
+ (e.reviewerKind === 'user' && isHuman(e.userId) && e.userId !== pr.authorId))
887
+ .sort((a, b) => a.atMs - b.atMs);
888
+ const first = asks[0];
889
+ if (first == null)
890
+ return { requestKind: 'none', firstRequestMs: null };
891
+ const sameMoment = asks.filter((e) => e.atMs === first.atMs);
892
+ const kind = sameMoment.some((e) => e.reviewerKind === 'user') ? 'person' : 'team';
893
+ return { requestKind: kind, firstRequestMs: Math.max(first.atMs, pr.openedMs) };
894
+ }
895
+ /** Clock hours a PR's checks were observed red while it was open. */
896
+ function ciRedHoursOf(obs, openedMs, mergedMs) {
897
+ const sorted = [...obs].sort((a, b) => a.atMs - b.atMs);
898
+ let red = 0;
899
+ for (let i = 0; i < sorted.length; i += 1) {
900
+ const o = sorted[i];
901
+ if (!o.red)
902
+ continue;
903
+ const end = i + 1 < sorted.length ? sorted[i + 1].atMs : mergedMs;
904
+ red += Math.max(0, Math.min(end, mergedMs) - Math.max(o.atMs, openedMs));
905
+ }
906
+ return red / HOUR_MS;
907
+ }
908
+ /** Exposed for the unit test — the floors a fixture has to clear or miss. */
909
+ export const __flowTesting = {
910
+ FLOW_MIN_REPO_PRS,
911
+ FLOW_DOMINANT_SHARE,
912
+ FLOW_SLOW_P75_HOURS,
913
+ FLOW_EVIDENCE_CAP,
914
+ FLOW_UNREVIEWED_MIN_COUNT,
915
+ FLOW_UNREVIEWED_MIN_SHARE,
916
+ walkCourts,
917
+ walkCourtIntervals,
918
+ directiveFor,
919
+ directiveSummaryFor,
920
+ noHumanReviewReason,
921
+ noneStandsOutReason,
922
+ unreviewedUnderFloorReason,
923
+ ciRedHoursOf,
924
+ firstRequestOf,
925
+ percentile,
926
+ median,
927
+ fmtHours,
928
+ narrate,
929
+ };
930
+ //# sourceMappingURL=pr-intervals.js.map