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,1666 @@
1
+ // Query layer for ML severity/category labels on BOT-authored text (CORE, free tier).
2
+ //
3
+ // Three jobs, and nothing else:
4
+ // 1. CANDIDATES — which bot-authored targets in a workspace have no label yet
5
+ // (the background worker's worklist; see sync/ml-enrichment.ts)
6
+ // 2. WRITE — upsert a batch of labels
7
+ // 3. READS — the per-PR badge index, and the Bots severity rollup
8
+ //
9
+ // WHO COUNTS AS A BOT is not decided here: it is `automatedReviewerUserIds(accountId,
10
+ // workspaceId, 'all')` from queries.ts, the one workspace-grain answer. `'all'` rather than
11
+ // `'review'` on purpose — a quality check (SonarQube, Codecov, Hound) posts exactly the kind
12
+ // of finding a severity label is FOR, and the role split exists to stop a linter's volume
13
+ // distorting a REVIEWER's ROI numbers, which is a different question from "how bad is this
14
+ // comment". The role is still reported per row, so a consumer can narrow.
15
+ //
16
+ // TIMING: `workspace_reviewers` rows are written LAZILY (listDetectedReviewers, on a read of
17
+ // the Bots tab), never by sync. Known vendor logins are automated with zero stored rows, so
18
+ // CodeRabbit et al. are candidates from the first sync; a purely in-house bot becomes a
19
+ // candidate only once it has been classified. That is fine BECAUSE this is a pull-based
20
+ // worker: it re-derives the bot set every tick, so a newly-classified or newly-marked bot's
21
+ // whole backlog is picked up on the next pass with no backfill trigger of its own.
22
+ import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, sql } from 'drizzle-orm';
23
+ import { db, schema } from './client.js';
24
+ import { botWindowMs } from './bot-window.js';
25
+ import { labelFor as labelForKind } from '../sync/reviewer-classify.js';
26
+ import { automatedReviewerUserIds, classificationKindForUser, classificationLabelMap, listWorkspaces, } from './queries.js';
27
+ const { mlCommentLabels, pullRequests, repos, reviewComments, reviewThreads, prComments, reviews, users, } = schema;
28
+ const ML_CATEGORY_VALUES = new Set([
29
+ 'correctness_bug',
30
+ 'security',
31
+ 'performance',
32
+ 'style_readability',
33
+ 'maintainability_refactor',
34
+ 'testing',
35
+ 'documentation',
36
+ 'nitpick',
37
+ 'praise', // v2 non-finding class — excluded from severity-weighted rollups below
38
+ ]);
39
+ const SEVERITY_KEYS = ['nit', 'minor', 'major', 'critical'];
40
+ /**
41
+ * "This row has text worth classifying."
42
+ *
43
+ * ⚠ `IS NOT NULL` IS NOT ENOUGH, and getting this wrong is silent. An approval with no comment
44
+ * is a `reviews` row with an EMPTY-STRING body — there are 5.4k of them in this repo's own dev
45
+ * database. They are not candidates (there is nothing to classify), but a NOT NULL test counts
46
+ * them, so the candidate query and the pending COUNT would disagree forever: the worker would
47
+ * skip them every tick while the Bots panel reported them as "still being processed" and
48
+ * coverage never reached 100%.
49
+ *
50
+ * `trim(x) <> ''` is portable — both SQLite and Postgres have `trim` — so the two callers below
51
+ * share ONE predicate rather than two that could drift.
52
+ */
53
+ const hasText = (col) => sql `trim(${col}) <> ''`;
54
+ function emptySeverityCounts() {
55
+ return { nit: 0, minor: 0, major: 0, critical: 0 };
56
+ }
57
+ /** Anything the service invents beyond its nine documented values is dropped, not stored. */
58
+ function coerceCategories(raw) {
59
+ if (!Array.isArray(raw))
60
+ return [];
61
+ return raw.filter((c) => typeof c === 'string' && ML_CATEGORY_VALUES.has(c));
62
+ }
63
+ function coerceSeverity(raw) {
64
+ return typeof raw === 'string' && SEVERITY_KEYS.includes(raw)
65
+ ? raw
66
+ : null;
67
+ }
68
+ const VENDOR_CONFIDENCE_KEYS = ['high', 'medium', 'low'];
69
+ function coerceVendorConfidence(raw) {
70
+ return typeof raw === 'string' && VENDOR_CONFIDENCE_KEYS.includes(raw)
71
+ ? raw
72
+ : null;
73
+ }
74
+ /**
75
+ * Deep re-sync support: drop every ML label in one repo so the enrichment worker re-scores
76
+ * it against the CURRENTLY served model. Labels carry the model_version that produced them;
77
+ * a deep sync is the user's explicit "re-fetch and re-derive everything" gesture, so stale
78
+ * versions are purged rather than left to mix with fresh ones in the same charts.
79
+ */
80
+ export async function deleteMlLabelsForRepo(accountId, repoId) {
81
+ await db
82
+ .delete(mlCommentLabels)
83
+ .where(and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.repoId, repoId)))
84
+ .execute();
85
+ }
86
+ /**
87
+ * Bot-authored targets in this workspace that carry no label yet, NEWEST FIRST.
88
+ *
89
+ * Newest-first is a product decision, not an implementation detail: the enrichment is a
90
+ * background sweep that can take an hour over a large history, and the labels a user is about
91
+ * to look at are the ones on today's PRs. History fills in behind them.
92
+ *
93
+ * `limit` is a POOL size, not a batch size — the caller re-sorts the pool by body length before
94
+ * batching, because the model pads a batch to its longest member (see config.mlBatchMaxChars).
95
+ */
96
+ export async function listMlCandidates(accountId, scope, limit) {
97
+ if (scope.repoIds.length === 0)
98
+ return [];
99
+ const automatedIds = await automatedReviewerUserIds(accountId, scope.workspaceId, 'all');
100
+ if (automatedIds.length === 0)
101
+ return [];
102
+ // The join predicate is identical for all three kinds bar the discriminator and which id
103
+ // space `target_id` is compared against — hence the AnyColumn parameter rather than three
104
+ // copies that could drift.
105
+ const unlabelled = (kind, targetId) => and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, kind), eq(mlCommentLabels.targetId, targetId));
106
+ const [rc, pc, rv] = await Promise.all([
107
+ db
108
+ .select({
109
+ targetId: reviewComments.id,
110
+ prId: reviewComments.prId,
111
+ repoId: pullRequests.repoId,
112
+ authorUserId: reviewComments.authorId,
113
+ body: reviewComments.body,
114
+ targetCreatedAt: reviewComments.createdAt,
115
+ diffHunk: reviewComments.diffHunk,
116
+ path: reviewThreads.path,
117
+ })
118
+ .from(reviewComments)
119
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
120
+ // LEFT, not inner: `path` is only a HINT to the model, so a review comment whose thread
121
+ // row is missing (an orphan cannot arise under this schema's FKs, but nothing here should
122
+ // depend on that) must still be selectable — `countUnlabelledBotText` does not join
123
+ // threads at all, and an inner join here would count such a row as pending forever while
124
+ // never offering it to the worker (the exact phantom-pending drift `hasText` exists to
125
+ // prevent on the body predicate).
126
+ .leftJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
127
+ .leftJoin(mlCommentLabels, unlabelled('review_comment', reviewComments.id))
128
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviewComments.authorId), inArray(reviewComments.authorId, automatedIds), isNotNull(reviewComments.body), hasText(reviewComments.body), isNull(mlCommentLabels.id)))
129
+ .orderBy(desc(reviewComments.createdAt))
130
+ .limit(limit)
131
+ .execute(),
132
+ db
133
+ .select({
134
+ targetId: prComments.id,
135
+ prId: prComments.prId,
136
+ repoId: pullRequests.repoId,
137
+ authorUserId: prComments.authorId,
138
+ body: prComments.body,
139
+ targetCreatedAt: prComments.createdAt,
140
+ })
141
+ .from(prComments)
142
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
143
+ .leftJoin(mlCommentLabels, unlabelled('pr_comment', prComments.id))
144
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(prComments.authorId), inArray(prComments.authorId, automatedIds), isNotNull(prComments.body), hasText(prComments.body), isNull(mlCommentLabels.id)))
145
+ .orderBy(desc(prComments.createdAt))
146
+ .limit(limit)
147
+ .execute(),
148
+ db
149
+ .select({
150
+ targetId: reviews.id,
151
+ prId: reviews.prId,
152
+ repoId: pullRequests.repoId,
153
+ authorUserId: reviews.authorId,
154
+ body: reviews.body,
155
+ targetCreatedAt: reviews.submittedAt,
156
+ })
157
+ .from(reviews)
158
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
159
+ .leftJoin(mlCommentLabels, unlabelled('review', reviews.id))
160
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviews.authorId), inArray(reviews.authorId, automatedIds), isNotNull(reviews.body), hasText(reviews.body), isNull(mlCommentLabels.id)))
161
+ .orderBy(desc(reviews.submittedAt))
162
+ .limit(limit)
163
+ .execute(),
164
+ ]);
165
+ const out = [];
166
+ const push = (kind, rows) => {
167
+ for (const r of rows) {
168
+ const raw = r.body ?? '';
169
+ // ⚠ THE SQL PREDICATES ARE THE SOLE AUTHORITY ON CANDIDACY — nothing is dropped here.
170
+ // This loop used to skip `raw.trim() === ''`, which sounds harmless and is not: SQL's
171
+ // `trim()` strips SPACES ONLY in both dialects, JavaScript's strips all whitespace, so a
172
+ // body of " \n " passed the SQL filter and was then discarded in JS. The row is then
173
+ // pending forever — re-selected on every tick, never labelled, and counted by
174
+ // `countUnlabelledBotText` as work outstanding. Whatever SQL offers, we classify; the
175
+ // trim below only normalises the text we SEND (falling back to the raw string, so the
176
+ // service is never handed an empty body).
177
+ if (r.authorUserId == null)
178
+ continue; // unreachable — `isNotNull(authorId)` above; narrows the type
179
+ out.push({
180
+ targetKind: kind,
181
+ targetId: r.targetId,
182
+ prId: r.prId,
183
+ repoId: r.repoId,
184
+ authorUserId: r.authorUserId,
185
+ body: raw.trim() || raw,
186
+ targetCreatedAt: r.targetCreatedAt,
187
+ diffHunk: r.diffHunk ?? null,
188
+ path: r.path ?? null,
189
+ });
190
+ }
191
+ };
192
+ push('review_comment', rc);
193
+ push('pr_comment', pc);
194
+ push('review', rv);
195
+ out.sort((a, b) => b.targetCreatedAt.getTime() - a.targetCreatedAt.getTime());
196
+ return out.slice(0, limit);
197
+ }
198
+ /**
199
+ * Upsert labels.
200
+ *
201
+ * ⚠ THE CONFLICT TARGET IS THE TABLE'S DECLARED UNIQUE — `(account_id, target_kind,
202
+ * target_id)`, index `mcl_account_target`. A stale target type-checks perfectly and only fails
203
+ * at RUNTIME, in both dialects, when a row is actually written.
204
+ *
205
+ * Rows are written one statement per row rather than one multi-VALUES insert: a batch mixes
206
+ * three target kinds and drizzle's `onConflictDoUpdate` set-clause would have to reference
207
+ * `excluded.*` uniformly, which is the kind of thing that reads fine and silently writes the
208
+ * wrong row when the shapes diverge. Batches are ≤128 and this is a background worker.
209
+ */
210
+ export async function upsertMlLabels(rows) {
211
+ let written = 0;
212
+ for (const r of rows) {
213
+ const values = {
214
+ accountId: r.accountId,
215
+ repoId: r.repoId,
216
+ prId: r.prId,
217
+ targetKind: r.targetKind,
218
+ targetId: r.targetId,
219
+ authorUserId: r.authorUserId,
220
+ severity: r.severity,
221
+ severityOrd: r.severityOrd,
222
+ severityProb: r.severityProb,
223
+ // `?? null` rather than a straight read: drizzle DROPS an `undefined` key from both the
224
+ // INSERT column list and the SET list, so an accidental undefined would insert fine and
225
+ // then silently refuse to clear a stale vendor claim on the update path. An explicit null
226
+ // means the same thing on both.
227
+ vendorSeverity: r.vendorSeverity ?? null,
228
+ vendorSeverityConfidence: r.vendorSeverityConfidence ?? null,
229
+ categories: r.categories,
230
+ categoryProbs: r.categoryProbs,
231
+ isSummary: r.isSummary,
232
+ backend: r.backend,
233
+ modelVersion: r.modelVersion,
234
+ bodyHash: r.bodyHash,
235
+ targetCreatedAt: r.targetCreatedAt,
236
+ updatedAt: new Date(),
237
+ };
238
+ const res = await db
239
+ .insert(mlCommentLabels)
240
+ .values(values)
241
+ .onConflictDoUpdate({
242
+ target: [
243
+ mlCommentLabels.accountId,
244
+ mlCommentLabels.targetKind,
245
+ mlCommentLabels.targetId,
246
+ ],
247
+ set: {
248
+ severity: values.severity,
249
+ severityOrd: values.severityOrd,
250
+ severityProb: values.severityProb,
251
+ // ⚠ IN BOTH HALVES. A re-score must be able to CLEAR a vendor claim as well as set
252
+ // one (a vendor drops its badge, or the service that read it is rolled back), so
253
+ // these are written on the update path too — an omitted key here would freeze the
254
+ // first value ever stored while the insert path looked perfectly correct.
255
+ vendorSeverity: values.vendorSeverity,
256
+ vendorSeverityConfidence: values.vendorSeverityConfidence,
257
+ categories: values.categories,
258
+ categoryProbs: values.categoryProbs,
259
+ isSummary: values.isSummary,
260
+ backend: values.backend,
261
+ modelVersion: values.modelVersion,
262
+ bodyHash: values.bodyHash,
263
+ targetCreatedAt: values.targetCreatedAt,
264
+ updatedAt: values.updatedAt,
265
+ },
266
+ })
267
+ .returning({ id: mlCommentLabels.id })
268
+ .execute();
269
+ written += res.length;
270
+ }
271
+ return written;
272
+ }
273
+ // ── 3. Reads ─────────────────────────────────────────────────────────────────────────────
274
+ // EXPORTED for the person-period evidence fold (db/person-period.ts), which attaches stored
275
+ // labels to a handful of capped comment rows — same coercions, one spelling.
276
+ export function toWireLabel(row) {
277
+ const severity = coerceSeverity(row.severity);
278
+ if (!severity)
279
+ return null;
280
+ return {
281
+ targetKind: row.targetKind,
282
+ targetId: row.targetId,
283
+ severity,
284
+ severityOrd: row.severityOrd,
285
+ severityProb: row.severityProb,
286
+ // Coerced on the way OUT as well as in. The column is plain text in both dialects (the
287
+ // drizzle `enum` is a compile-time nicety, not a CHECK constraint), and unlike `severity`
288
+ // above a bad value here must NOT drop the whole label — the row's real severity is the
289
+ // useful one, so an unreadable vendor claim degrades to "no vendor claim".
290
+ vendorSeverity: coerceSeverity(row.vendorSeverity),
291
+ vendorSeverityConfidence: coerceVendorConfidence(row.vendorSeverityConfidence),
292
+ categories: coerceCategories(row.categories),
293
+ isSummary: row.isSummary,
294
+ backend: row.backend,
295
+ modelVersion: row.modelVersion,
296
+ createdAt: row.createdAt.toISOString(),
297
+ };
298
+ }
299
+ /**
300
+ * Every label on one PR — the ONE query each badge reads, so N cards on a PR cost one request.
301
+ *
302
+ * Returns null for a PR this account does not own (the handler 404s on that), which is the
303
+ * standard id-addressed ownership rule: ownership is checked HERE, not in the handler.
304
+ */
305
+ export async function getPrMlLabels(prId, accountId) {
306
+ const owned = await db
307
+ .select({ id: pullRequests.id })
308
+ .from(pullRequests)
309
+ .where(and(eq(pullRequests.id, prId), eq(pullRequests.accountId, accountId)))
310
+ .limit(1)
311
+ .execute();
312
+ if (owned.length === 0)
313
+ return null;
314
+ const rows = await db
315
+ .select()
316
+ .from(mlCommentLabels)
317
+ .where(and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.prId, prId)))
318
+ .execute();
319
+ return rows.map(toWireLabel).filter((l) => l !== null);
320
+ }
321
+ // A workspace's whole labelled corpus is read into memory for the rollup. It is bounded by
322
+ // retention (180 days by default) and each row is a handful of small columns, but a cap keeps a
323
+ // pathological account from turning one request into a huge allocation.
324
+ //
325
+ // TWO THINGS THE CAP NEEDS TO BE HONEST. It must have an ORDER BY — without one the rows a
326
+ // truncated scan returns are whatever the storage engine hands back first, which differs between
327
+ // SQLite and Postgres and CHANGES on Postgres after an UPDATE, so the same workspace would
328
+ // report different totals run to run. And the response must SAY it truncated (`truncated`),
329
+ // because a capped scan silently presented as a total is exactly the failure `pending` exists
330
+ // to avoid at the other end.
331
+ const ROLLUP_SCAN_CAP = 50_000;
332
+ /**
333
+ * The Bots-interface severity rollup: how the bots in this workspace are distributed across
334
+ * severities and categories, plus how much of their corpus has been labelled so far.
335
+ *
336
+ * Deliberately UNWINDOWED in this first cut — it covers every label in scope. The Bots panels
337
+ * around it are windowed, so the block states its own coverage rather than borrowing theirs.
338
+ */
339
+ export async function getBotSeverityRollup(accountId, scope, enabled) {
340
+ const generatedAt = new Date().toISOString();
341
+ const empty = {
342
+ workspaceId: scope.workspaceId,
343
+ repoIds: scope.repoIds,
344
+ enabled,
345
+ labelled: 0,
346
+ pending: 0,
347
+ unscorable: 0,
348
+ totals: {
349
+ bySeverity: emptySeverityCounts(),
350
+ byCategory: [],
351
+ summaries: 0,
352
+ praise: 0,
353
+ findings: 0,
354
+ },
355
+ rows: [],
356
+ backends: [],
357
+ truncated: false,
358
+ generatedAt,
359
+ };
360
+ if (scope.repoIds.length === 0)
361
+ return empty;
362
+ const automatedIds = await automatedReviewerUserIds(accountId, scope.workspaceId, 'all');
363
+ if (automatedIds.length === 0)
364
+ return empty;
365
+ const [labelRows, kindMap, labelMap, userRows, pending, unscorable] = await Promise.all([
366
+ db
367
+ .select({
368
+ authorUserId: mlCommentLabels.authorUserId,
369
+ severity: mlCommentLabels.severity,
370
+ isSummary: mlCommentLabels.isSummary,
371
+ categories: mlCommentLabels.categories,
372
+ backend: mlCommentLabels.backend,
373
+ })
374
+ .from(mlCommentLabels)
375
+ .where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds)))
376
+ .orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
377
+ .limit(ROLLUP_SCAN_CAP)
378
+ .execute(),
379
+ classificationKindForUser(accountId, scope.workspaceId),
380
+ classificationLabelMap(accountId, scope.workspaceId),
381
+ db
382
+ .select({ id: users.id, login: users.githubLogin, name: users.displayName })
383
+ .from(users)
384
+ .where(inArray(users.id, automatedIds))
385
+ .execute(),
386
+ countUnlabelledBotText(accountId, scope, automatedIds),
387
+ countUnscorableBotText(accountId, scope, automatedIds),
388
+ ]);
389
+ const loginById = new Map();
390
+ for (const u of userRows)
391
+ loginById.set(u.id, u.login || u.name?.trim() || `#${u.id}`);
392
+ const totalsBySeverity = emptySeverityCounts();
393
+ const totalsByCategory = new Map();
394
+ const backends = new Set();
395
+ let summaries = 0;
396
+ let praise = 0;
397
+ let findings = 0;
398
+ const byBot = new Map();
399
+ for (const row of labelRows) {
400
+ const severity = coerceSeverity(row.severity);
401
+ if (!severity)
402
+ continue;
403
+ if (row.backend)
404
+ backends.add(row.backend);
405
+ const acc = byBot.get(row.authorUserId) ?? {
406
+ labelled: 0,
407
+ bySeverity: emptySeverityCounts(),
408
+ summaries: 0,
409
+ praise: 0,
410
+ findings: 0,
411
+ high: 0,
412
+ categories: new Map(),
413
+ };
414
+ acc.labelled += 1;
415
+ const categories = coerceCategories(row.categories);
416
+ const isPraise = categories.includes('praise');
417
+ if (row.isSummary) {
418
+ acc.summaries += 1;
419
+ summaries += 1;
420
+ }
421
+ else if (isPraise) {
422
+ // v2 non-finding class: the bot acknowledging a fix / withdrawing a concern. Counted
423
+ // like summaries — visible as labelled work, excluded from every severity-weighted
424
+ // number for the same reason walkthroughs are (it is not a finding).
425
+ acc.praise += 1;
426
+ praise += 1;
427
+ }
428
+ else {
429
+ acc.findings += 1;
430
+ findings += 1;
431
+ // ⚠ SEVERITY COUNTS ARE FINDINGS-ONLY, and it has to be inside this branch. Counting a
432
+ // walkthrough's severity here while every RATE below divides by `findings` would let
433
+ // "nits as a share of findings" exceed 100% — a vendor that posts one summary per PR would
434
+ // reliably push it over. Summaries have their own counter; nothing divides by `labelled`.
435
+ acc.bySeverity[severity] += 1;
436
+ totalsBySeverity[severity] += 1;
437
+ if (severity === 'major' || severity === 'critical')
438
+ acc.high += 1;
439
+ // Categories describe a FINDING. A walkthrough comment's category is an artefact of the
440
+ // marker parser reading a summary table, so counting it would make "what do the bots
441
+ // talk about" a chart of each vendor's summary template.
442
+ for (const c of categories) {
443
+ acc.categories.set(c, (acc.categories.get(c) ?? 0) + 1);
444
+ totalsByCategory.set(c, (totalsByCategory.get(c) ?? 0) + 1);
445
+ }
446
+ }
447
+ byBot.set(row.authorUserId, acc);
448
+ }
449
+ const rows = [...byBot.entries()]
450
+ .map(([userId, acc]) => {
451
+ const login = loginById.get(userId) ?? `#${userId}`;
452
+ const kind = (kindMap.get(userId) ?? null);
453
+ return {
454
+ reviewerKey: `u${userId}`,
455
+ userId,
456
+ login,
457
+ // Custom label → vendor brand → login: the same precedence the ROI panel uses, so one
458
+ // bot reads with one name across the whole Bots interface.
459
+ label: labelMap.get(userId) ?? (kind ? vendorBrand(kind, login) : login),
460
+ kind,
461
+ labelled: acc.labelled,
462
+ bySeverity: acc.bySeverity,
463
+ highShare: acc.findings > 0 ? acc.high / acc.findings : 0,
464
+ summaries: acc.summaries,
465
+ praise: acc.praise,
466
+ topCategories: [...acc.categories.entries()]
467
+ .map(([category, count]) => ({ category, count }))
468
+ .sort((a, b) => b.count - a.count || a.category.localeCompare(b.category))
469
+ .slice(0, 5),
470
+ };
471
+ })
472
+ .sort((a, b) => b.labelled - a.labelled || a.label.localeCompare(b.label));
473
+ return {
474
+ workspaceId: scope.workspaceId,
475
+ repoIds: scope.repoIds,
476
+ enabled,
477
+ labelled: labelRows.length,
478
+ pending,
479
+ unscorable,
480
+ totals: {
481
+ bySeverity: totalsBySeverity,
482
+ byCategory: [...totalsByCategory.entries()]
483
+ .map(([category, count]) => ({ category, count }))
484
+ .sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)),
485
+ summaries,
486
+ praise,
487
+ findings,
488
+ },
489
+ rows,
490
+ backends: [...backends].sort(),
491
+ truncated: labelRows.length >= ROLLUP_SCAN_CAP,
492
+ generatedAt,
493
+ };
494
+ }
495
+ // Vendor brand names for the row label. Kept local and minimal on purpose: the frontend owns
496
+ // BOT_VENDOR_META (colours + display names), and duplicating that whole map server-side would
497
+ // give two answers to "what is this bot called".
498
+ function vendorBrand(kind, login) {
499
+ if (kind === 'in_house' || kind === 'vendor' || kind === 'pierre')
500
+ return login;
501
+ return kind
502
+ .split('_')
503
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
504
+ .join(' ');
505
+ }
506
+ /** null = this row's severity could not be coerced, so it belongs to none of the three buckets. */
507
+ export function foldMlLabelRow(row) {
508
+ const severity = coerceSeverity(row.severity);
509
+ if (!severity)
510
+ return null;
511
+ const categories = coerceCategories(row.categories);
512
+ if (row.isSummary)
513
+ return { bucket: 'summary', severity, categories };
514
+ // v2 non-finding class: the bot acknowledging a fix / withdrawing a concern. Bucketed like
515
+ // summaries — visible as labelled work, excluded from every severity-weighted number, because
516
+ // it is not a finding.
517
+ if (categories.includes('praise'))
518
+ return { bucket: 'praise', severity, categories };
519
+ return { bucket: 'finding', severity, categories };
520
+ }
521
+ export async function getMlWindowAggregates(accountId, scope, automatedIds, from,
522
+ // The window's upper bound — a Date ONLY when the caller holds EXPLICIT bounds (the People
523
+ // report's completed period), applied half-open (`lt`, the route's stated [fromMs, toMs)
524
+ // contract). `null` = the enum form, whose window ends at now: the scan then carries NO upper
525
+ // predicate, which keeps its WHERE byte-identical to `foldBotFlaggingPopulation`'s — the
526
+ // identity behind "an Inflation count equals the drill-down's filteredTotal" (the drill-down
527
+ // only ever serves enum windows). A bot still labelling after a completed period must not leak
528
+ // those rows into the period's counts, weekly buckets, or cap budget.
529
+ to,
530
+ // Pro (`botDepth`) only: widen the scan back to `trendFrom` and fold the per-bot WEEKLY
531
+ // inflation series over it (`inflationWeekly`, 12 buckets of 7 days from `trendFrom` — the
532
+ // same anchoring as getBotAnalytics' own trend buckets, so the sparkline and the ROI trend
533
+ // share an x-axis). Omitted ⇒ the scan stays window-only and `inflationWeekly` is null.
534
+ //
535
+ // ⚠ WINDOW COUNTS ARE UNCHANGED BY THE WIDENING. The scan orders newest-first with the same
536
+ // cap, so the in-window rows are a PREFIX of the widened scan — identical rows in identical
537
+ // order to the window-only scan (and to the flagging drill-down's) until the cap bites INSIDE
538
+ // the window, which truncated both scans identically before. Every non-weekly aggregate below
539
+ // gates on `inWindow`.
540
+ history) {
541
+ const emptyTotals = {
542
+ labelled: 0,
543
+ findings: 0,
544
+ summaries: 0,
545
+ praise: 0,
546
+ pending: 0,
547
+ unscorable: 0,
548
+ bySeverity: emptySeverityCounts(),
549
+ byCategory: [],
550
+ backends: [],
551
+ truncated: false,
552
+ };
553
+ if (scope.repoIds.length === 0 || automatedIds.length === 0) {
554
+ return { byBot: new Map(), totals: emptyTotals };
555
+ }
556
+ // The widened lower bound covers the window by construction in the caller (trendFrom =
557
+ // min(from, now − 84d)); min() here is a backstop so a mis-anchored history can never NARROW
558
+ // the window scan.
559
+ const scanFrom = history != null && history.trendFrom.getTime() < from.getTime() ? history.trendFrom : from;
560
+ const fromMs = from.getTime();
561
+ const [labelRows, pending, unscorable] = await Promise.all([
562
+ db
563
+ .select({
564
+ authorUserId: mlCommentLabels.authorUserId,
565
+ severity: mlCommentLabels.severity,
566
+ isSummary: mlCommentLabels.isSummary,
567
+ categories: mlCommentLabels.categories,
568
+ backend: mlCommentLabels.backend,
569
+ // The two extra columns the Inflation fold needs. `vendorSeverity` is compared, never
570
+ // believed (MlLabel.vendorSeverity); `targetCreatedAt` decides `inWindow` and the weekly
571
+ // bucket.
572
+ vendorSeverity: mlCommentLabels.vendorSeverity,
573
+ targetCreatedAt: mlCommentLabels.targetCreatedAt,
574
+ })
575
+ .from(mlCommentLabels)
576
+ .where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds), gte(mlCommentLabels.targetCreatedAt, scanFrom),
577
+ // Explicit bounds only (see the `to` parameter note) — newest-first still holds, so
578
+ // the in-window rows remain a prefix of any widened scan.
579
+ ...(to != null ? [lt(mlCommentLabels.targetCreatedAt, to)] : [])))
580
+ .orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
581
+ .limit(ROLLUP_SCAN_CAP)
582
+ .execute(),
583
+ countUnlabelledBotText(accountId, scope, automatedIds, from, to),
584
+ // NOT window-bounded on purpose: the NULL-body population is legacy by nature (the
585
+ // 2026-06 lean-storage window) and mostly sits OUTSIDE every rolling window — a windowed
586
+ // count would read 0 while badges are visibly missing from the very lists this strip
587
+ // sits above. This is the honesty channel getBotSeverityRollup carries; when the merged
588
+ // table retired that response's panel, the count has to live HERE or the Bots screen
589
+ // claims "N of N scored" over comments it can never score.
590
+ countUnscorableBotText(accountId, scope, automatedIds),
591
+ ]);
592
+ // The weekly buckets for the Pro sparkline: 12 × 7 days anchored on `trendFrom`, index clamped
593
+ // — byte-for-byte the bucketing getBotAnalytics uses for its own trend arrays, so the two
594
+ // series share an x-axis.
595
+ const WEEK_MS = 7 * 86_400_000;
596
+ const SPAN_WEEKS = 12;
597
+ const trendFromMs = history?.trendFrom.getTime() ?? 0;
598
+ const weekIndexOf = (ms) => Math.min(SPAN_WEEKS - 1, Math.max(0, Math.floor((ms - trendFromMs) / WEEK_MS)));
599
+ const emptyWeekly = () => Array.from({ length: SPAN_WEEKS }, (_, i) => ({
600
+ weekStartMs: trendFromMs + i * WEEK_MS,
601
+ overCall: 0,
602
+ underCall: 0,
603
+ }));
604
+ const byBot = new Map();
605
+ const totalsBySeverity = emptySeverityCounts();
606
+ const totalsByCategory = new Map();
607
+ const backends = new Set();
608
+ let findings = 0;
609
+ let summaries = 0;
610
+ let praise = 0;
611
+ // The totals' `labelled` twin under a widened scan: rows whose targetCreatedAt is in-window,
612
+ // fold success or not — exactly what `labelRows.length` counted when the scan was window-only.
613
+ let windowLabelled = 0;
614
+ // Whether the cap bit INSIDE the window. Rows arrive newest-first, so if the scan hit the cap
615
+ // but its oldest row already predates `from`, every in-window row was captured and the WINDOW
616
+ // totals are complete — only the (Pro, advisory) weekly history is a newest-first sample.
617
+ const capHit = labelRows.length >= ROLLUP_SCAN_CAP;
618
+ const oldestScannedMs = capHit
619
+ ? (labelRows[labelRows.length - 1]?.targetCreatedAt.getTime() ?? fromMs)
620
+ : null;
621
+ const windowTruncated = capHit && oldestScannedMs != null && oldestScannedMs >= fromMs;
622
+ for (const row of labelRows) {
623
+ const rowMs = row.targetCreatedAt.getTime();
624
+ const inWindow = rowMs >= fromMs;
625
+ if (inWindow)
626
+ windowLabelled += 1;
627
+ // ⚠ Through `foldMlLabelRow`, NEVER a local re-spelling of the three branches: the flagging
628
+ // drill-down slices this same scan with this same fold, and a second copy of the order here
629
+ // is exactly how the tile and the list it opens would drift apart.
630
+ const folded = foldMlLabelRow(row);
631
+ if (!folded)
632
+ continue;
633
+ if (inWindow && row.backend)
634
+ backends.add(row.backend);
635
+ let acc = byBot.get(row.authorUserId);
636
+ if (!acc) {
637
+ acc = {
638
+ labelled: 0,
639
+ findings: 0,
640
+ summaries: 0,
641
+ praise: 0,
642
+ high: 0,
643
+ bySeverity: emptySeverityCounts(),
644
+ vendorBadged: 0,
645
+ vendorOverCall: 0,
646
+ vendorUnderCall: 0,
647
+ inflationWeekly: history != null ? emptyWeekly() : null,
648
+ };
649
+ byBot.set(row.authorUserId, acc);
650
+ }
651
+ if (inWindow)
652
+ acc.labelled += 1;
653
+ if (folded.bucket === 'summary') {
654
+ if (inWindow) {
655
+ acc.summaries += 1;
656
+ summaries += 1;
657
+ }
658
+ }
659
+ else if (folded.bucket === 'praise') {
660
+ if (inWindow) {
661
+ acc.praise += 1;
662
+ praise += 1;
663
+ }
664
+ }
665
+ else {
666
+ // ⚠ FINDINGS-ONLY, inside this branch — the same phantom-gap rule as the rollup: counting
667
+ // a walkthrough's severity while every rate divides by `findings` lets a share top 100%.
668
+ if (inWindow) {
669
+ acc.findings += 1;
670
+ findings += 1;
671
+ acc.bySeverity[folded.severity] += 1;
672
+ totalsBySeverity[folded.severity] += 1;
673
+ if (folded.severity === 'major' || folded.severity === 'critical')
674
+ acc.high += 1;
675
+ for (const c of folded.categories) {
676
+ totalsByCategory.set(c, (totalsByCategory.get(c) ?? 0) + 1);
677
+ }
678
+ }
679
+ // ── Inflation — findings-only, badged-only, via the ONE direction rule ───────────────
680
+ // `vendorAgreementOf` is the same classifier the confusion matrix and the flagging
681
+ // drill-down's `disagree` refinement apply, over the same fold of the same scan — which is
682
+ // exactly why the ROI column's count equals that drill-down's `filteredTotal` for the same
683
+ // bot + direction. A null vendor claim is SILENCE, in neither counter.
684
+ const dir = vendorAgreementOf(coerceSeverity(row.vendorSeverity), folded.severity);
685
+ if (dir != null) {
686
+ if (inWindow) {
687
+ acc.vendorBadged += 1;
688
+ if (dir === 'over')
689
+ acc.vendorOverCall += 1;
690
+ else if (dir === 'under')
691
+ acc.vendorUnderCall += 1;
692
+ }
693
+ if (acc.inflationWeekly != null && dir !== 'agree') {
694
+ const wk = acc.inflationWeekly[weekIndexOf(rowMs)];
695
+ if (dir === 'over')
696
+ wk.overCall += 1;
697
+ else
698
+ wk.underCall += 1;
699
+ }
700
+ }
701
+ }
702
+ }
703
+ return {
704
+ byBot,
705
+ totals: {
706
+ labelled: windowLabelled,
707
+ findings,
708
+ summaries,
709
+ praise,
710
+ pending,
711
+ unscorable,
712
+ bySeverity: totalsBySeverity,
713
+ byCategory: [...totalsByCategory.entries()]
714
+ .map(([category, count]) => ({ category, count }))
715
+ .sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)),
716
+ backends: [...backends].sort(),
717
+ // Says the WINDOW sample is capped — under a widened (Pro-history) scan a cap that bit
718
+ // only in the pre-window tail leaves every window total complete, and claiming otherwise
719
+ // would be the same dishonesty the flag exists to prevent, inverted.
720
+ truncated: windowTruncated,
721
+ },
722
+ };
723
+ }
724
+ export async function listMlLabelsForBehaviour(accountId, scope, automatedIds, from, to) {
725
+ if (scope.repoIds.length === 0 || automatedIds.length === 0) {
726
+ return { rows: [], truncated: false };
727
+ }
728
+ // Newest-first + a cap, then `truncated` — the ROLLUP_SCAN_CAP contract: a capped scan needs an
729
+ // ORDER BY or the sample differs run to run (heap order flips on Postgres after an UPDATE), and
730
+ // it has to SAY it was capped rather than present a sample as a total.
731
+ const raw = await db
732
+ .select({
733
+ authorUserId: mlCommentLabels.authorUserId,
734
+ severity: mlCommentLabels.severity,
735
+ vendorSeverity: mlCommentLabels.vendorSeverity,
736
+ categories: mlCommentLabels.categories,
737
+ isSummary: mlCommentLabels.isSummary,
738
+ targetCreatedAt: mlCommentLabels.targetCreatedAt,
739
+ })
740
+ .from(mlCommentLabels)
741
+ .where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds), gte(mlCommentLabels.targetCreatedAt, from), lte(mlCommentLabels.targetCreatedAt, to)))
742
+ .orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
743
+ .limit(ROLLUP_SCAN_CAP)
744
+ .execute();
745
+ const rows = [];
746
+ for (const r of raw) {
747
+ const severity = coerceSeverity(r.severity);
748
+ if (!severity)
749
+ continue;
750
+ rows.push({
751
+ authorUserId: r.authorUserId,
752
+ severity,
753
+ vendorSeverity: coerceSeverity(r.vendorSeverity),
754
+ categories: coerceCategories(r.categories),
755
+ isSummary: r.isSummary,
756
+ targetCreatedAtMs: r.targetCreatedAt.getTime(),
757
+ });
758
+ }
759
+ return { rows, truncated: raw.length >= ROLLUP_SCAN_CAP };
760
+ }
761
+ // ── The per-REVIEWER comments drill-down (GET /api/bot-analytics/vendor/:key/comments) ──────
762
+ // Everything one automated reviewer SAID in the window — inline review comments (path + thread
763
+ // state), issue-level PR comments, and non-empty review bodies — each row carrying its stored
764
+ // ML label INLINE via a LEFT JOIN on (account_id, target_kind, target_id). One response serves
765
+ // the whole list: a cross-PR list must never mount the per-PR label index per row.
766
+ //
767
+ // Deliberately a NEW query rather than a re-export of getBotReviewComments: that query is
768
+ // role:'review' by design (it feeds the synthesis seam's 'workspace-bots' fold), while this
769
+ // drill-down must also serve quality_check-roled rows —
770
+ // mirroring getBotVendorPrs, whose quality-check section offers the same drill-down.
771
+ //
772
+ // The 'pierre' sentinel answers EMPTY: its verbatim reviews are posted with the human's token
773
+ // (per-review provenance), so there are no attributable per-comment rows to list — the same
774
+ // reasoning as its getBotVendorPrs special case, minus the review-join it uses for PR rows.
775
+ const BOT_VENDOR_COMMENT_CAP = 3000; // most-recent rows per source (the themes-funnel precedent)
776
+ // The left-joined label columns → a wire MlLabel, or null when the target has no label row.
777
+ // Coercions mirror toWireLabel: an unreadable severity drops the LABEL (not the comment row),
778
+ // an unreadable vendor claim degrades to "no vendor claim".
779
+ function inlineLabel(targetKind, targetId, r) {
780
+ const severity = coerceSeverity(r.mlSeverity);
781
+ if (!severity || r.mlSeverityOrd == null || r.mlSeverityProb == null)
782
+ return null;
783
+ return {
784
+ targetKind,
785
+ targetId,
786
+ severity,
787
+ severityOrd: r.mlSeverityOrd,
788
+ severityProb: r.mlSeverityProb,
789
+ vendorSeverity: coerceSeverity(r.mlVendorSeverity),
790
+ vendorSeverityConfidence: coerceVendorConfidence(r.mlVendorSeverityConfidence),
791
+ categories: coerceCategories(r.mlCategories),
792
+ isSummary: r.mlIsSummary ?? false,
793
+ backend: r.mlBackend ?? '',
794
+ modelVersion: r.mlModelVersion ?? '',
795
+ createdAt: (r.mlCreatedAt ?? new Date(0)).toISOString(),
796
+ };
797
+ }
798
+ export async function getBotVendorComments(accountId, target,
799
+ // Either a KIND (resolved here against the one shared duration mapping) or a kind carried
800
+ // ALONGSIDE explicit bounds — the getBotAnalytics window form, widened here for the same
801
+ // reason: the People report's bot sections must list a completed period's comments, and an
802
+ // arbitrary-bounds period has no enum spelling. Bounds only narrow WHAT IS MEASURED inside
803
+ // the caller's resolved scope (route-validated where they arrive over the wire).
804
+ window,
805
+ // The SAME BotScope the ROI row was computed at — this list reproduces one of that panel's
806
+ // rows, so it takes the identical workspace + repo narrowing.
807
+ scope) {
808
+ const nowMs = Date.now();
809
+ const windowKind = typeof window === 'string' ? window : window.kind;
810
+ const to = new Date(typeof window === 'string' ? nowMs : window.toMs);
811
+ // The one shared window→duration mapping (db/bot-window.ts) — same window as the ROI row —
812
+ // unless the caller supplied real bounds.
813
+ const from = new Date(typeof window === 'string' ? nowMs - botWindowMs(window) : window.fromMs);
814
+ const win = { kind: windowKind, from: from.toISOString(), to: to.toISOString() };
815
+ // The upper predicate, applied ONLY under EXPLICIT bounds — `getMlWindowAggregates`' `to`
816
+ // rule, and the ROI row this list reproduces uses the same `toBound`. Under the enum form the
817
+ // window ends at NOW, where an upper bound can only exclude rows written in the CURRENT SECOND
818
+ // (second-granular timestamps on sqlite) and would break byte-identity with the other
819
+ // enum-form scans over the same rows.
820
+ const toBound = typeof window === 'string' ? null : to;
821
+ const generatedAt = new Date(nowMs).toISOString();
822
+ if ('kind' in target) {
823
+ return {
824
+ enabled: true,
825
+ key: 'pierre',
826
+ kind: 'pierre',
827
+ label: labelForKind('pierre'),
828
+ login: null,
829
+ window: win,
830
+ comments: [],
831
+ truncated: false,
832
+ generatedAt,
833
+ };
834
+ }
835
+ const userId = target.userId;
836
+ const key = `u${userId}`;
837
+ // NOT role-filtered — mirrors getBotVendorPrs: the quality-check section of the ROI panel
838
+ // offers the same drill-down.
839
+ const kindMap = await classificationKindForUser(accountId, scope.workspaceId);
840
+ if (!kindMap.has(userId)) {
841
+ // An id this workspace has NOT classified as automated identifies NOTHING. The users
842
+ // table is GLOBAL — resolving login/displayName for an arbitrary numeric id here
843
+ // would hand any tenant a cross-account login-enumeration oracle, exactly what the
844
+ // /api/users/:id/stats precedent exists to prevent (counts only, no profile fields).
845
+ // The key is the caller's own input; the label degrades to it; login stays null.
846
+ return {
847
+ enabled: true, key, kind: 'in_house', label: key, login: null, window: win,
848
+ comments: [], truncated: false, generatedAt,
849
+ };
850
+ }
851
+ const kindTyped = kindMap.get(userId) ?? 'in_house';
852
+ // Identity mirrors getBotVendorPrs / getBotAnalytics.reviewerLabel exactly: the workspace's
853
+ // custom label → the vendor's pretty name (known vendors) → login/display name. Safe to
854
+ // read the global row HERE: the classification row above proves the account association.
855
+ const classLabel = await classificationLabelMap(accountId, scope.workspaceId);
856
+ const [userRow] = await db
857
+ .select({ login: users.githubLogin, name: users.displayName })
858
+ .from(users)
859
+ .where(eq(users.id, userId))
860
+ .limit(1)
861
+ .execute();
862
+ const login = userRow?.login ?? null;
863
+ const custom = classLabel.get(userId);
864
+ let label;
865
+ if (custom)
866
+ label = custom;
867
+ else if (kindTyped !== 'in_house' && kindTyped !== 'pierre' && kindTyped !== 'vendor')
868
+ label = labelForKind(kindTyped);
869
+ else
870
+ label = userRow?.name?.trim() || login || key;
871
+ const empty = {
872
+ enabled: true, key, kind: kindTyped, label, login, window: win,
873
+ comments: [], truncated: false, generatedAt,
874
+ };
875
+ if (scope.repoIds.length === 0)
876
+ return empty;
877
+ const mlCols = {
878
+ mlSeverity: mlCommentLabels.severity,
879
+ mlSeverityOrd: mlCommentLabels.severityOrd,
880
+ mlSeverityProb: mlCommentLabels.severityProb,
881
+ mlVendorSeverity: mlCommentLabels.vendorSeverity,
882
+ mlVendorSeverityConfidence: mlCommentLabels.vendorSeverityConfidence,
883
+ mlCategories: mlCommentLabels.categories,
884
+ mlIsSummary: mlCommentLabels.isSummary,
885
+ mlBackend: mlCommentLabels.backend,
886
+ mlModelVersion: mlCommentLabels.modelVersion,
887
+ mlCreatedAt: mlCommentLabels.createdAt,
888
+ };
889
+ // Inline review-thread comments (carry the thread's path + derivedState).
890
+ const rcRows = await db
891
+ .select({
892
+ targetId: reviewComments.id,
893
+ prId: reviewComments.prId,
894
+ prNumber: pullRequests.number,
895
+ prTitle: pullRequests.title,
896
+ prAuthorId: pullRequests.authorId,
897
+ repoId: pullRequests.repoId,
898
+ owner: repos.owner,
899
+ name: repos.name,
900
+ body: reviewComments.body,
901
+ createdAt: reviewComments.createdAt,
902
+ path: reviewThreads.path,
903
+ derivedState: reviewThreads.derivedState,
904
+ threadId: reviewComments.threadId,
905
+ ...mlCols,
906
+ })
907
+ .from(reviewComments)
908
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
909
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
910
+ .leftJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
911
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review_comment'), eq(mlCommentLabels.targetId, reviewComments.id)))
912
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(reviewComments.authorId, userId), gte(reviewComments.createdAt, from),
913
+ // Half-open [from, to) — the route's stated bounds contract, and the person-period
914
+ // evidence fold's spelling, so a boundary-ms comment lands in exactly one period.
915
+ ...(toBound != null ? [lt(reviewComments.createdAt, toBound)] : [])))
916
+ .orderBy(desc(reviewComments.createdAt))
917
+ .limit(BOT_VENDOR_COMMENT_CAP)
918
+ .execute();
919
+ // Issue-level PR comments (no path / thread state).
920
+ const pcRows = await db
921
+ .select({
922
+ targetId: prComments.id,
923
+ prId: prComments.prId,
924
+ prNumber: pullRequests.number,
925
+ prTitle: pullRequests.title,
926
+ prAuthorId: pullRequests.authorId,
927
+ repoId: pullRequests.repoId,
928
+ owner: repos.owner,
929
+ name: repos.name,
930
+ body: prComments.body,
931
+ createdAt: prComments.createdAt,
932
+ ...mlCols,
933
+ })
934
+ .from(prComments)
935
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
936
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
937
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'pr_comment'), eq(mlCommentLabels.targetId, prComments.id)))
938
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(prComments.authorId, userId), gte(prComments.createdAt, from), ...(toBound != null ? [lt(prComments.createdAt, toBound)] : [])))
939
+ .orderBy(desc(prComments.createdAt))
940
+ .limit(BOT_VENDOR_COMMENT_CAP)
941
+ .execute();
942
+ // Review BODIES with real text only (`hasText` — an approval with no comment is an
943
+ // empty-string body, and 5.4k empty rows are not things the bot "said"). Matches the label
944
+ // corpus: the candidate query applies the same predicate, so every row here is labellable.
945
+ const rvRows = await db
946
+ .select({
947
+ targetId: reviews.id,
948
+ prId: reviews.prId,
949
+ prNumber: pullRequests.number,
950
+ prTitle: pullRequests.title,
951
+ prAuthorId: pullRequests.authorId,
952
+ repoId: pullRequests.repoId,
953
+ owner: repos.owner,
954
+ name: repos.name,
955
+ body: reviews.body,
956
+ createdAt: reviews.submittedAt,
957
+ ...mlCols,
958
+ })
959
+ .from(reviews)
960
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
961
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
962
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review'), eq(mlCommentLabels.targetId, reviews.id)))
963
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), eq(reviews.authorId, userId), isNotNull(reviews.body), hasText(reviews.body), gte(reviews.submittedAt, from), ...(toBound != null ? [lt(reviews.submittedAt, toBound)] : [])))
964
+ .orderBy(desc(reviews.submittedAt))
965
+ .limit(BOT_VENDOR_COMMENT_CAP)
966
+ .execute();
967
+ const toIso = (d) => {
968
+ if (d == null)
969
+ return new Date(0).toISOString();
970
+ if (d instanceof Date)
971
+ return d.toISOString();
972
+ const ms = Number(d) > 1e12 ? Number(d) : Number(d) * 1000;
973
+ return new Date(ms).toISOString();
974
+ };
975
+ const out = [];
976
+ for (const r of rcRows) {
977
+ out.push({
978
+ targetKind: 'review_comment',
979
+ targetId: r.targetId,
980
+ prId: r.prId,
981
+ prNumber: r.prNumber,
982
+ prTitle: r.prTitle,
983
+ prAuthorId: r.prAuthorId ?? null,
984
+ repoId: r.repoId,
985
+ repoFullName: `${r.owner}/${r.name}`,
986
+ path: r.path ?? null,
987
+ threadId: r.threadId ?? null,
988
+ derivedState: r.derivedState ?? null,
989
+ body: r.body,
990
+ createdAt: toIso(r.createdAt),
991
+ mlLabel: inlineLabel('review_comment', r.targetId, r),
992
+ });
993
+ }
994
+ for (const r of pcRows) {
995
+ out.push({
996
+ targetKind: 'pr_comment',
997
+ targetId: r.targetId,
998
+ prId: r.prId,
999
+ prNumber: r.prNumber,
1000
+ prTitle: r.prTitle,
1001
+ prAuthorId: r.prAuthorId ?? null,
1002
+ repoId: r.repoId,
1003
+ repoFullName: `${r.owner}/${r.name}`,
1004
+ path: null,
1005
+ threadId: null,
1006
+ derivedState: null,
1007
+ body: r.body,
1008
+ createdAt: toIso(r.createdAt),
1009
+ mlLabel: inlineLabel('pr_comment', r.targetId, r),
1010
+ });
1011
+ }
1012
+ for (const r of rvRows) {
1013
+ out.push({
1014
+ targetKind: 'review',
1015
+ targetId: r.targetId,
1016
+ prId: r.prId,
1017
+ prNumber: r.prNumber,
1018
+ prTitle: r.prTitle,
1019
+ prAuthorId: r.prAuthorId ?? null,
1020
+ repoId: r.repoId,
1021
+ repoFullName: `${r.owner}/${r.name}`,
1022
+ path: null,
1023
+ threadId: null,
1024
+ derivedState: null,
1025
+ body: r.body,
1026
+ createdAt: toIso(r.createdAt),
1027
+ mlLabel: inlineLabel('review', r.targetId, r),
1028
+ });
1029
+ }
1030
+ // Newest-first, capped combined — `truncated` when either a source hit its own cap or the
1031
+ // combined stream overflowed (the getBotReviewComments rule).
1032
+ out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
1033
+ const truncated = rcRows.length >= BOT_VENDOR_COMMENT_CAP ||
1034
+ pcRows.length >= BOT_VENDOR_COMMENT_CAP ||
1035
+ rvRows.length >= BOT_VENDOR_COMMENT_CAP ||
1036
+ out.length > BOT_VENDOR_COMMENT_CAP;
1037
+ return {
1038
+ enabled: true, key, kind: kindTyped, label, login, window: win,
1039
+ comments: out.slice(0, BOT_VENDOR_COMMENT_CAP), truncated, generatedAt,
1040
+ };
1041
+ }
1042
+ async function countBotText(accountId, scope, automatedIds, mode,
1043
+ // Optional window on the SOURCE row's own timestamp (createdAt / submittedAt) — the windowed
1044
+ // ROI fold's coverage statement must be about the window it sits over. Absent `from` = the
1045
+ // whole corpus, exactly as before; `to` follows getMlWindowAggregates' contract (a Date only
1046
+ // under explicit bounds, applied half-open; null = the now-ending enum window, no upper
1047
+ // predicate).
1048
+ from, to) {
1049
+ if (scope.repoIds.length === 0 || automatedIds.length === 0)
1050
+ return 0;
1051
+ const n = sql `count(*)`;
1052
+ const bodyPredicate = (col) => mode === 'unlabelled' ? and(isNotNull(col), hasText(col)) : isNull(col);
1053
+ const windowed = (col) => [
1054
+ ...(from ? [gte(col, from)] : []),
1055
+ ...(to != null ? [lt(col, to)] : []),
1056
+ ];
1057
+ const [rc, pc, rv] = await Promise.all([
1058
+ db
1059
+ .select({ n })
1060
+ .from(reviewComments)
1061
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
1062
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review_comment'), eq(mlCommentLabels.targetId, reviewComments.id)))
1063
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviewComments.authorId), inArray(reviewComments.authorId, automatedIds), bodyPredicate(reviewComments.body), isNull(mlCommentLabels.id), ...windowed(reviewComments.createdAt)))
1064
+ .execute(),
1065
+ db
1066
+ .select({ n })
1067
+ .from(prComments)
1068
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
1069
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'pr_comment'), eq(mlCommentLabels.targetId, prComments.id)))
1070
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(prComments.authorId), inArray(prComments.authorId, automatedIds), bodyPredicate(prComments.body), isNull(mlCommentLabels.id), ...windowed(prComments.createdAt)))
1071
+ .execute(),
1072
+ db
1073
+ .select({ n })
1074
+ .from(reviews)
1075
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
1076
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, 'review'), eq(mlCommentLabels.targetId, reviews.id)))
1077
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(reviews.authorId), inArray(reviews.authorId, automatedIds), bodyPredicate(reviews.body), isNull(mlCommentLabels.id), ...windowed(reviews.submittedAt)))
1078
+ .execute(),
1079
+ ]);
1080
+ return Number(rc[0]?.n ?? 0) + Number(pc[0]?.n ?? 0) + Number(rv[0]?.n ?? 0);
1081
+ }
1082
+ /**
1083
+ * How much bot text in scope is still waiting for a label. Three counts with the same
1084
+ * left-join-is-null predicate the candidate query uses, so "pending" and "what the worker will
1085
+ * pick up next" cannot drift apart. Optional `from` bounds the count to a window (the ROI fold).
1086
+ */
1087
+ async function countUnlabelledBotText(accountId, scope, automatedIds, from, to) {
1088
+ return countBotText(accountId, scope, automatedIds, 'unlabelled', from, to);
1089
+ }
1090
+ /** How much bot text in scope can NEVER be labelled as stored — see `UnlabelledBodyMode`. */
1091
+ async function countUnscorableBotText(accountId, scope, automatedIds) {
1092
+ return countBotText(accountId, scope, automatedIds, 'unscorable');
1093
+ }
1094
+ /**
1095
+ * The PRs in scope still carrying bot-authored NULL-body targets — the worklist for
1096
+ * `pnpm ml:backfill-bodies` (scripts/backfill-null-bodies.ts). Same bot set and the same
1097
+ * NULL-body predicate as `countUnscorableBotText`, so the script repairs exactly the
1098
+ * population that count reports.
1099
+ */
1100
+ export async function listNullBodyBotPrIds(accountId, scope, automatedIds) {
1101
+ if (scope.repoIds.length === 0 || automatedIds.length === 0)
1102
+ return [];
1103
+ const prIdsFor = (kind, table) => db
1104
+ .selectDistinct({ prId: table.prId })
1105
+ .from(table)
1106
+ .innerJoin(pullRequests, eq(pullRequests.id, table.prId))
1107
+ .leftJoin(mlCommentLabels, and(eq(mlCommentLabels.accountId, accountId), eq(mlCommentLabels.targetKind, kind), eq(mlCommentLabels.targetId, table.id)))
1108
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), isNotNull(table.authorId), inArray(table.authorId, automatedIds), isNull(table.body), isNull(mlCommentLabels.id)))
1109
+ .execute();
1110
+ const [rc, pc, rv] = await Promise.all([
1111
+ prIdsFor('review_comment', reviewComments),
1112
+ prIdsFor('pr_comment', prComments),
1113
+ prIdsFor('review', reviews),
1114
+ ]);
1115
+ return [...new Set([...rc, ...pc, ...rv].map((r) => r.prId))];
1116
+ }
1117
+ /**
1118
+ * The account's whole enrichment backlog — what `GET /api/ml-status` reports so a sync surface
1119
+ * can keep showing activity until the labels exist, instead of announcing "complete" the moment
1120
+ * the GitHub walk ends and leaving the model calls unrepresented.
1121
+ *
1122
+ * ACCOUNT-WIDE, NOT WORKSPACE-SCOPED, because that is the worker's own grain: a tick walks every
1123
+ * workspace of every active account. Scoping this to the selected workspace would under-report
1124
+ * exactly the work that is running and let the indicator go quiet while the CPU is busy.
1125
+ *
1126
+ * Cost: one bot-set resolve plus three indexed counts PER WORKSPACE, and they share the same
1127
+ * left-join-is-null predicate as `listMlCandidates`, so "pending" and "what the worker will pick
1128
+ * up next" cannot drift. That is not free, which is why the route in front of it caches — see
1129
+ * the TTL note there. Workspaces are walked serially rather than with `Promise.all` so a
1130
+ * many-workspace account cannot open 4×N connections at once on a poll.
1131
+ */
1132
+ export async function getMlBacklogForAccount(accountId) {
1133
+ const workspaces = await listWorkspaces(accountId);
1134
+ let pending = 0;
1135
+ let unscorable = 0;
1136
+ for (const ws of workspaces) {
1137
+ if (ws.repoIds.length === 0)
1138
+ continue;
1139
+ const scope = { workspaceId: ws.id, repoIds: ws.repoIds };
1140
+ const automatedIds = await automatedReviewerUserIds(accountId, ws.id, 'all');
1141
+ if (automatedIds.length === 0)
1142
+ continue;
1143
+ pending += await countUnlabelledBotText(accountId, scope, automatedIds);
1144
+ unscorable += await countUnscorableBotText(accountId, scope, automatedIds);
1145
+ }
1146
+ const labelledRows = await db
1147
+ .select({ n: sql `count(*)` })
1148
+ .from(mlCommentLabels)
1149
+ .where(eq(mlCommentLabels.accountId, accountId))
1150
+ .execute();
1151
+ return { pending, unscorable, labelled: Number(labelledRows[0]?.n ?? 0) };
1152
+ }
1153
+ // ── "What the bots are flagging" — the drill-down behind the ML totals strip ─────────────────
1154
+ //
1155
+ // Every tile and chip on the Bots rail's ML strip opens this ONE getter with a different
1156
+ // selector, and the contract is that the list's `total` IS the tile's number — not a second,
1157
+ // independently-derived count that happens to agree today.
1158
+ //
1159
+ // That rules out the obvious implementation. A `count(*) … WHERE severity = 'nit'` can never
1160
+ // equal the "Nits" tile: it counts summaries and praise, it cannot express praise at all (a JSON
1161
+ // column in both dialects), it ignores `coerceSeverity` failures, and it ignores ROLLUP_SCAN_CAP's
1162
+ // newest-first truncation. So this getter re-runs the STRIP'S OWN scan — byte-identical WHERE /
1163
+ // ORDER BY / LIMIT, only the select list widens — re-folds it through the shared
1164
+ // `foldMlLabelRow`, and slices the result. Pagination is therefore an OFFSET INTO THE FOLDED
1165
+ // IN-MEMORY POPULATION, behind an opaque `o:<n>` cursor so a later keyset switch is not a wire
1166
+ // break; keyset pagination is the wrong tool here because it cannot reproduce a count only JS
1167
+ // can compute.
1168
+ //
1169
+ // Cost is bounded the same way the strip's is: one capped label scan, then hydration of the
1170
+ // PAGE ONLY (≤50 rows) — never of the population.
1171
+ const FLAGGING_PAGE_MAX = 50;
1172
+ // The matrix axes, worst-first, mirroring the shared `ML_SEVERITIES` order with `'none'` (the bot
1173
+ // declared nothing) appended to the vendor axis. Local copies because `@pierre-review/shared` is
1174
+ // a TYPES-ONLY dependency of the backend — the release build greps `release/dist` and fails on a
1175
+ // real import — the same local-copy rule `REASON_PRIORITY` follows in queries.ts.
1176
+ // ⚠ Two severity orders live in this codebase (`ML_SEVERITIES` worst-first, the Bots panel's
1177
+ // `SEVERITY_COLUMNS` ascending). Flipping one axis silently transposes every cell, so the order
1178
+ // is pinned here and the client looks cells up by (vendor, ours) rather than by position.
1179
+ const MATRIX_VENDOR_AXIS = ['critical', 'major', 'minor', 'nit', 'none'];
1180
+ const MATRIX_OURS_AXIS = ['critical', 'major', 'minor', 'nit'];
1181
+ /**
1182
+ * OUR severity as a 0..3 ordinal — `SEVERITY_KEYS` is stored ascending, so its INDEX is the
1183
+ * ordinal, the same 0..3 the shared `ML_SEVERITY_ORD` map spells out for the frontend. Re-derived
1184
+ * here rather than imported for the types-only reason above.
1185
+ *
1186
+ * ⚠ Used ONLY to say which way a vendor's claim differs from ours. It never orders, seeds,
1187
+ * corrects or falls back OUR severity — see MlLabel.vendorSeverity.
1188
+ */
1189
+ function severityOrdinal(s) {
1190
+ return SEVERITY_KEYS.indexOf(s);
1191
+ }
1192
+ /**
1193
+ * THE ours-vs-vendor direction rule, in ONE place.
1194
+ *
1195
+ * `null` = the bot declared nothing, which is SILENCE, not conflict — it lands in the matrix's
1196
+ * 'none' column and in neither `agree` nor either disagreement counter. That is what keeps
1197
+ * `agree + overCall + underCall === declared` true, and it is the property every caption on
1198
+ * these screens leans on.
1199
+ *
1200
+ * Exported because three surfaces now ask the same question — the confusion matrix, the
1201
+ * flagging drill-down's `disagree` refinement, and the Behaviour tab's per-bot inflation index
1202
+ * (`getBotBehaviourAnalytics`) — and a fourth hand-spelled `>` is exactly how two numbers that
1203
+ * must agree come to differ by one row with nothing failing.
1204
+ *
1205
+ * ⚠ Used ONLY to say which way the two differ. The vendor's badge never orders, seeds, corrects
1206
+ * or falls back OUR severity — see MlLabel.vendorSeverity (0.474 vs 0.700 exact).
1207
+ */
1208
+ export function vendorAgreementOf(vendor, ours) {
1209
+ if (vendor == null)
1210
+ return null;
1211
+ if (vendor === ours)
1212
+ return 'agree';
1213
+ return severityOrdinal(vendor) > severityOrdinal(ours) ? 'over' : 'under';
1214
+ }
1215
+ function emptyAgreementMatrix() {
1216
+ return {
1217
+ // Dense 5×4: a zero cell is PRESENT, not omitted — the grid renders every combination, and
1218
+ // an absent cell would read as "no data here" rather than "they never disagreed this way".
1219
+ cells: MATRIX_VENDOR_AXIS.flatMap((vendor) => MATRIX_OURS_AXIS.map((ours) => ({ vendor, ours, count: 0 }))),
1220
+ declared: 0,
1221
+ undeclared: 0,
1222
+ agree: 0,
1223
+ overCall: 0,
1224
+ underCall: 0,
1225
+ total: 0,
1226
+ };
1227
+ }
1228
+ /**
1229
+ * The ours-vs-vendor confusion matrix over a population.
1230
+ *
1231
+ * ⚠ A DISPLAY OF TWO CLAIMS, NEVER A RECONCILIATION. The vendor badge scores 0.474 exact against
1232
+ * our 0.700 on the adjudicated gold-300, so it is here to show WHERE the two differ and nothing
1233
+ * more: it does not enter a selector predicate, `total`, or the fold.
1234
+ */
1235
+ function buildAgreementMatrix(rows) {
1236
+ const counts = new Map();
1237
+ let declared = 0;
1238
+ let agree = 0;
1239
+ let overCall = 0;
1240
+ let underCall = 0;
1241
+ for (const r of rows) {
1242
+ const axis = r.vendor ?? 'none';
1243
+ const key = `${axis}|${r.ours}`;
1244
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1245
+ // A null vendor claim is UNDECLARED — it lands in the 'none' column and in NEITHER agreement
1246
+ // nor disagreement. Silence is not a conflict, and `agree + over + under === declared` is the
1247
+ // property that keeps the caption honest. (`vendorAgreementOf` is the one direction rule.)
1248
+ const dir = vendorAgreementOf(r.vendor, r.ours);
1249
+ if (dir == null)
1250
+ continue;
1251
+ declared += 1;
1252
+ if (dir === 'agree')
1253
+ agree += 1;
1254
+ else if (dir === 'over')
1255
+ overCall += 1;
1256
+ else
1257
+ underCall += 1;
1258
+ }
1259
+ return {
1260
+ cells: MATRIX_VENDOR_AXIS.flatMap((vendor) => MATRIX_OURS_AXIS.map((ours) => ({
1261
+ vendor,
1262
+ ours,
1263
+ count: counts.get(`${vendor}|${ours}`) ?? 0,
1264
+ }))),
1265
+ declared,
1266
+ undeclared: rows.length - declared,
1267
+ agree,
1268
+ overCall,
1269
+ underCall,
1270
+ total: rows.length,
1271
+ };
1272
+ }
1273
+ /**
1274
+ * Does this folded row belong to the tile that was clicked?
1275
+ *
1276
+ * ⚠ `severity` and `category` gate on `bucket === 'finding'` because the strip increments
1277
+ * `bySeverity`/`byCategory` ONLY inside its finding branch (a walkthrough's severity is never
1278
+ * counted, and a walkthrough's categories are an artefact of the marker parser reading a summary
1279
+ * table). Dropping either gate makes the list overshoot the tile it opened from.
1280
+ *
1281
+ * ⚠ NOTHING HERE READS `vendorSeverity`. The bot's own badge is display-only; a selector that
1282
+ * consulted it would let the less accurate of the two labels decide which rows are "high
1283
+ * severity", which is precisely what MlLabel.vendorSeverity forbids.
1284
+ */
1285
+ function matchesFlaggingSelector(selector, fold) {
1286
+ switch (selector.kind) {
1287
+ case 'findings':
1288
+ return fold.bucket === 'finding';
1289
+ case 'summaries':
1290
+ return fold.bucket === 'summary';
1291
+ case 'severity':
1292
+ return fold.bucket === 'finding' && selector.severities.includes(fold.severity);
1293
+ case 'category':
1294
+ // ⚠ PRAISE IS ITS OWN BUCKET, NOT A FINDING CATEGORY — the one arm where the obvious
1295
+ // spelling is unsatisfiable. `foldMlLabelRow` tests `isSummary` then praise then finding,
1296
+ // so a praise row NEVER carries `bucket === 'finding'`; asking for `category:'praise'`
1297
+ // under the finding gate matches nothing, for every input, forever.
1298
+ //
1299
+ // It stayed invisible while the only source of category selectors was the strip's chips,
1300
+ // which come from `byCategory` — incremented solely inside the FINDING branch, so praise
1301
+ // can never appear there. The drill-down's severity picker offers Praise directly (it sits
1302
+ // beside the four severities, the `SEVERITY_PILLS` precedent), which made the dead arm
1303
+ // reachable: the option reads "Praise · 55" from the analytics fold while the list it opens
1304
+ // is empty — a control that looks broken, which is the exact failure this codebase has
1305
+ // already paid for once with the CI-failure lens.
1306
+ return selector.category === 'praise'
1307
+ ? fold.bucket === 'praise'
1308
+ : fold.bucket === 'finding' && fold.categories.includes(selector.category);
1309
+ }
1310
+ }
1311
+ /**
1312
+ * The refinement the matrix / direction toggle / per-bot bar applies AFTER the selector has
1313
+ * fixed `total`.
1314
+ *
1315
+ * ⚠ `authorUserIds` narrows the LIST, never the matrix — the matrix is built over the selector
1316
+ * population pre-refine (phase 3 below) so a cell cannot zero out the cell that was clicked, and
1317
+ * a bot narrowing is no different: drilling into one bot must not silently redraw the grid the
1318
+ * click came from. `filteredTotal` is what describes the narrowed list.
1319
+ *
1320
+ * ⚠ `[]` MEANS "NO BOTS", NOT "EVERY BOT" — the `repoIds` rule. The gate is on the list being
1321
+ * PRESENT (`!= null`), never on its length, so a caller that computed an empty bot set gets an
1322
+ * empty page rather than the whole workspace under a caption promising a subset.
1323
+ */
1324
+ function matchesFlaggingRefine(refine, vendor, ours, authorUserId) {
1325
+ // `includes` over an array of at most one entry per automated reviewer in the workspace — a
1326
+ // per-call Set would allocate once per scanned row for a membership test of ~10 numbers.
1327
+ if (refine.authorUserIds != null && !refine.authorUserIds.includes(authorUserId))
1328
+ return false;
1329
+ if (refine.cell) {
1330
+ if ((vendor ?? 'none') !== refine.cell.vendor)
1331
+ return false;
1332
+ if (ours !== refine.cell.ours)
1333
+ return false;
1334
+ }
1335
+ if (refine.disagree) {
1336
+ // Undeclared is not a disagreement (see buildAgreementMatrix); `'any'` takes either
1337
+ // direction. One shared classifier, so this predicate and the matrix cannot drift.
1338
+ const dir = vendorAgreementOf(vendor, ours);
1339
+ if (dir == null || dir === 'agree')
1340
+ return false;
1341
+ if (refine.disagree !== 'any' && dir !== refine.disagree)
1342
+ return false;
1343
+ }
1344
+ return true;
1345
+ }
1346
+ /**
1347
+ * Hydrate ONE PAGE (≤50 rows) into wire cards: four small parent selects, one `users` select and
1348
+ * the two identity maps. Never called with the population.
1349
+ *
1350
+ * ⚠ Every parent WHERE carries `eq(pullRequests.accountId, accountId)` and
1351
+ * `inArray(pullRequests.repoId, scope.repoIds)` even though these ids came from an
1352
+ * already-scoped scan. The tenancy predicate is not optional because the input was trusted —
1353
+ * that is how an id list stops being an existence oracle in every code path, not just the ones
1354
+ * whose caller remembered.
1355
+ */
1356
+ export async function hydrateFlaggingPage(accountId, scope, pageRows) {
1357
+ if (pageRows.length === 0)
1358
+ return [];
1359
+ const idsFor = (kind) => pageRows.filter((p) => p.row.targetKind === kind).map((p) => p.row.targetId);
1360
+ const rcIds = idsFor('review_comment');
1361
+ const pcIds = idsFor('pr_comment');
1362
+ const rvIds = idsFor('review');
1363
+ const authorIds = [...new Set(pageRows.map((p) => p.row.authorUserId))];
1364
+ const [rcRows, pcRows, rvRows, userRows, kindMap, classLabel] = await Promise.all([
1365
+ rcIds.length > 0
1366
+ ? db
1367
+ .select({
1368
+ targetId: reviewComments.id,
1369
+ prId: reviewComments.prId,
1370
+ prNumber: pullRequests.number,
1371
+ prTitle: pullRequests.title,
1372
+ prAuthorId: pullRequests.authorId,
1373
+ repoId: pullRequests.repoId,
1374
+ owner: repos.owner,
1375
+ name: repos.name,
1376
+ body: reviewComments.body,
1377
+ createdAt: reviewComments.createdAt,
1378
+ threadId: reviewComments.threadId,
1379
+ path: reviewThreads.path,
1380
+ line: reviewThreads.line,
1381
+ derivedState: reviewThreads.derivedState,
1382
+ })
1383
+ .from(reviewComments)
1384
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewComments.prId))
1385
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
1386
+ // LEFT, as everywhere else: a comment whose thread row is missing still renders, just
1387
+ // without a path/line/state.
1388
+ .leftJoin(reviewThreads, eq(reviewThreads.id, reviewComments.threadId))
1389
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), inArray(reviewComments.id, rcIds)))
1390
+ .execute()
1391
+ : [],
1392
+ pcIds.length > 0
1393
+ ? db
1394
+ .select({
1395
+ targetId: prComments.id,
1396
+ prId: prComments.prId,
1397
+ prNumber: pullRequests.number,
1398
+ prTitle: pullRequests.title,
1399
+ prAuthorId: pullRequests.authorId,
1400
+ repoId: pullRequests.repoId,
1401
+ owner: repos.owner,
1402
+ name: repos.name,
1403
+ body: prComments.body,
1404
+ createdAt: prComments.createdAt,
1405
+ })
1406
+ .from(prComments)
1407
+ .innerJoin(pullRequests, eq(pullRequests.id, prComments.prId))
1408
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
1409
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), inArray(prComments.id, pcIds)))
1410
+ .execute()
1411
+ : [],
1412
+ rvIds.length > 0
1413
+ ? db
1414
+ .select({
1415
+ targetId: reviews.id,
1416
+ prId: reviews.prId,
1417
+ prNumber: pullRequests.number,
1418
+ prTitle: pullRequests.title,
1419
+ prAuthorId: pullRequests.authorId,
1420
+ repoId: pullRequests.repoId,
1421
+ owner: repos.owner,
1422
+ name: repos.name,
1423
+ body: reviews.body,
1424
+ createdAt: reviews.submittedAt,
1425
+ })
1426
+ .from(reviews)
1427
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
1428
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
1429
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, scope.repoIds), inArray(reviews.id, rvIds)))
1430
+ .execute()
1431
+ : [],
1432
+ authorIds.length > 0
1433
+ ? db
1434
+ .select({ id: users.id, login: users.githubLogin, name: users.displayName })
1435
+ .from(users)
1436
+ .where(inArray(users.id, authorIds))
1437
+ .execute()
1438
+ : [],
1439
+ classificationKindForUser(accountId, scope.workspaceId),
1440
+ classificationLabelMap(accountId, scope.workspaceId),
1441
+ ]);
1442
+ const parents = new Map();
1443
+ for (const r of rcRows) {
1444
+ parents.set(`review_comment:${r.targetId}`, {
1445
+ prId: r.prId,
1446
+ prNumber: r.prNumber,
1447
+ prTitle: r.prTitle,
1448
+ prAuthorId: r.prAuthorId ?? null,
1449
+ repoId: r.repoId,
1450
+ repoFullName: `${r.owner}/${r.name}`,
1451
+ path: r.path ?? null,
1452
+ line: r.line ?? null,
1453
+ threadId: r.threadId ?? null,
1454
+ derivedState: r.derivedState ?? null,
1455
+ body: r.body,
1456
+ createdAt: r.createdAt,
1457
+ });
1458
+ }
1459
+ const pushFlat = (kind, rows) => {
1460
+ for (const r of rows) {
1461
+ parents.set(`${kind}:${r.targetId}`, {
1462
+ prId: r.prId,
1463
+ prNumber: r.prNumber,
1464
+ prTitle: r.prTitle,
1465
+ prAuthorId: r.prAuthorId ?? null,
1466
+ repoId: r.repoId,
1467
+ repoFullName: `${r.owner}/${r.name}`,
1468
+ path: null,
1469
+ line: null,
1470
+ threadId: null,
1471
+ derivedState: null,
1472
+ body: r.body,
1473
+ createdAt: r.createdAt,
1474
+ });
1475
+ }
1476
+ };
1477
+ pushFlat('pr_comment', pcRows);
1478
+ pushFlat('review', rvRows);
1479
+ // Identity: the workspace's custom label → the vendor's pretty name → display name/login —
1480
+ // getBotAnalytics' `reviewerLabel` precedence verbatim, so one bot reads with one name across
1481
+ // the whole Bots interface.
1482
+ const displayById = new Map();
1483
+ const loginById = new Map();
1484
+ for (const u of userRows) {
1485
+ displayById.set(u.id, u.name?.trim() || u.login || `#${u.id}`);
1486
+ loginById.set(u.id, u.login || null);
1487
+ }
1488
+ const reviewerLabel = (userId, kind) => {
1489
+ const custom = classLabel.get(userId);
1490
+ if (custom)
1491
+ return custom;
1492
+ if (kind !== 'in_house' && kind !== 'pierre' && kind !== 'vendor')
1493
+ return labelForKind(kind);
1494
+ return displayById.get(userId) ?? labelForKind(kind);
1495
+ };
1496
+ const items = [];
1497
+ for (const { row, fold } of pageRows) {
1498
+ const parent = parents.get(`${row.targetKind}:${row.targetId}`);
1499
+ // A label whose parent row is gone is DROPPED from the page but stays counted in
1500
+ // `total`/`filteredTotal` — the same tolerance getBotVendorComments has, and the honest one:
1501
+ // re-deriving the count from what hydrated would put the list back out of step with the tile
1502
+ // it was opened from, which is the whole thing this getter exists to prevent.
1503
+ if (!parent)
1504
+ continue;
1505
+ const kind = kindMap.get(row.authorUserId) ?? 'in_house';
1506
+ items.push({
1507
+ targetKind: row.targetKind,
1508
+ targetId: row.targetId,
1509
+ prId: parent.prId,
1510
+ prNumber: parent.prNumber,
1511
+ prTitle: parent.prTitle,
1512
+ prAuthorId: parent.prAuthorId,
1513
+ repoId: parent.repoId,
1514
+ repoFullName: parent.repoFullName,
1515
+ path: parent.path,
1516
+ threadId: parent.threadId,
1517
+ derivedState: parent.derivedState,
1518
+ // Whitespace-only collapses to null so the card can tell "GitHub no longer returns this
1519
+ // text" apart from a body it simply hasn't been handed. Reachable because bodies are
1520
+ // re-upserted every walk while labels are never re-scored — see the hydration note above.
1521
+ body: parent.body && parent.body.trim() !== '' ? parent.body : null,
1522
+ createdAt: parent.createdAt.toISOString(),
1523
+ // Inline, never a per-card fetch: this list spans many PRs, so the per-PR
1524
+ // ['ml-labels', prId] index could not serve it (the ThreadAssessment
1525
+ // 60-requests-for-60-empty-boxes rule). Severity and categories come from the FOLD, so the
1526
+ // badge and the bucket that admitted the row can never disagree.
1527
+ mlLabel: {
1528
+ targetKind: row.targetKind,
1529
+ targetId: row.targetId,
1530
+ severity: fold.severity,
1531
+ severityOrd: row.severityOrd,
1532
+ severityProb: row.severityProb,
1533
+ vendorSeverity: coerceSeverity(row.vendorSeverity),
1534
+ vendorSeverityConfidence: coerceVendorConfidence(row.vendorSeverityConfidence),
1535
+ categories: fold.categories,
1536
+ isSummary: row.isSummary,
1537
+ backend: row.backend,
1538
+ modelVersion: row.modelVersion,
1539
+ createdAt: row.createdAt.toISOString(),
1540
+ },
1541
+ authorUserId: row.authorUserId,
1542
+ authorLogin: loginById.get(row.authorUserId) ?? null,
1543
+ authorLabel: reviewerLabel(row.authorUserId, kind),
1544
+ authorKind: kind,
1545
+ line: parent.line,
1546
+ // The PR, deliberately not a per-comment permalink: `review_threads` has no `url` column
1547
+ // and the numeric REST comment id is not stored, so a fabricated anchor would 404.
1548
+ prUrl: `https://github.com/${parent.repoFullName}/pull/${parent.prNumber}`,
1549
+ });
1550
+ }
1551
+ return items;
1552
+ }
1553
+ export async function foldBotFlaggingPopulation(accountId, selector, refine, window,
1554
+ // ⚠ The SAME BotScope the strip was computed at — this population reproduces one of that
1555
+ // strip's tiles, so it takes the identical workspace + repo narrowing. `repoIds: []` is a real
1556
+ // empty workspace, never "widen to the account".
1557
+ scope) {
1558
+ const nowMs = Date.now();
1559
+ const to = new Date(nowMs);
1560
+ // The one shared window→duration mapping (db/bot-window.ts) — the same `from` getBotAnalytics
1561
+ // hands getMlWindowAggregates, which is what makes the two scans the same scan.
1562
+ const from = new Date(nowMs - botWindowMs(window));
1563
+ const win = { kind: window, from: from.toISOString(), to: to.toISOString() };
1564
+ const generatedAt = to.toISOString();
1565
+ const empty = {
1566
+ win,
1567
+ generatedAt,
1568
+ selected: [],
1569
+ narrowed: [],
1570
+ truncated: false,
1571
+ };
1572
+ if (scope.repoIds.length === 0)
1573
+ return empty;
1574
+ // `'all'`, not `'review'` — the strip counts quality checks too, and a drill-down that narrowed
1575
+ // the bot set would list fewer rows than the tile it opened from.
1576
+ const automatedIds = await automatedReviewerUserIds(accountId, scope.workspaceId, 'all');
1577
+ if (automatedIds.length === 0)
1578
+ return empty;
1579
+ // ── Phase 1: the population scan ─────────────────────────────────────────────────────────
1580
+ // ⚠ WHERE / ORDER BY / LIMIT are byte-identical to getMlWindowAggregates' scan; ONLY the select
1581
+ // list widens. That identity — same rows, same order, same cap — plus the shared fold below is
1582
+ // the entire reason `total` equals the tile rather than approximating it.
1583
+ const labelRows = await db
1584
+ .select({
1585
+ // What the fold consumes …
1586
+ authorUserId: mlCommentLabels.authorUserId,
1587
+ severity: mlCommentLabels.severity,
1588
+ isSummary: mlCommentLabels.isSummary,
1589
+ categories: mlCommentLabels.categories,
1590
+ backend: mlCommentLabels.backend,
1591
+ // … and what a CARD needs on top of it.
1592
+ id: mlCommentLabels.id,
1593
+ targetKind: mlCommentLabels.targetKind,
1594
+ targetId: mlCommentLabels.targetId,
1595
+ severityOrd: mlCommentLabels.severityOrd,
1596
+ severityProb: mlCommentLabels.severityProb,
1597
+ vendorSeverity: mlCommentLabels.vendorSeverity,
1598
+ vendorSeverityConfidence: mlCommentLabels.vendorSeverityConfidence,
1599
+ modelVersion: mlCommentLabels.modelVersion,
1600
+ createdAt: mlCommentLabels.createdAt,
1601
+ })
1602
+ .from(mlCommentLabels)
1603
+ .where(and(eq(mlCommentLabels.accountId, accountId), inArray(mlCommentLabels.repoId, scope.repoIds), inArray(mlCommentLabels.authorUserId, automatedIds), gte(mlCommentLabels.targetCreatedAt, from)))
1604
+ .orderBy(desc(mlCommentLabels.targetCreatedAt), desc(mlCommentLabels.id))
1605
+ .limit(ROLLUP_SCAN_CAP)
1606
+ .execute();
1607
+ // ── Phase 2: the shared fold, then the selector predicate ────────────────────────────────
1608
+ const selected = [];
1609
+ for (const row of labelRows) {
1610
+ const fold = foldMlLabelRow(row);
1611
+ if (!fold)
1612
+ continue;
1613
+ if (!matchesFlaggingSelector(selector, fold))
1614
+ continue;
1615
+ selected.push({ row, fold });
1616
+ }
1617
+ // ── Phase 4: refine (phase 3, the matrix, is response-only — the caller builds it) ───────
1618
+ const narrowed = selected.filter(({ row, fold }) => matchesFlaggingRefine(refine, coerceSeverity(row.vendorSeverity), fold.severity, row.authorUserId));
1619
+ return {
1620
+ win,
1621
+ generatedAt,
1622
+ selected,
1623
+ narrowed,
1624
+ // The same honesty rule as ROLLUP_SCAN_CAP everywhere else: a capped scan says so rather than
1625
+ // presenting a most-recent sample as a total.
1626
+ truncated: labelRows.length >= ROLLUP_SCAN_CAP,
1627
+ };
1628
+ }
1629
+ export async function getBotFlaggingComments(accountId, selector, refine, window,
1630
+ // ⚠ The SAME BotScope the strip was computed at — see foldBotFlaggingPopulation.
1631
+ scope, page) {
1632
+ const offset = Math.max(0, Math.trunc(page.offset));
1633
+ const limit = Math.max(1, Math.min(Math.trunc(page.limit), FLAGGING_PAGE_MAX));
1634
+ // ── Phases 1–2 + 4: the ONE population fold (shared with the synthesis input) ────────────
1635
+ const pop = await foldBotFlaggingPopulation(accountId, selector, refine, window, scope);
1636
+ const { selected, narrowed } = pop;
1637
+ // ── Phase 3: the matrix, over the SELECTOR population and PRE-refine ─────────────────────
1638
+ // Computed before the refinement so clicking a cell cannot zero out the cell that was clicked
1639
+ // — the commentFacetCounts / ConsolidatedFeedResponse.counts rule.
1640
+ const matrix = selected.length === 0
1641
+ ? emptyAgreementMatrix()
1642
+ : buildAgreementMatrix(selected.map(({ row, fold }) => ({
1643
+ vendor: coerceSeverity(row.vendorSeverity),
1644
+ ours: fold.severity,
1645
+ })));
1646
+ const pageRows = narrowed.slice(offset, offset + limit);
1647
+ const consumed = offset + pageRows.length;
1648
+ // ── Phase 5: hydrate the page only ───────────────────────────────────────────────────────
1649
+ const items = await hydrateFlaggingPage(accountId, scope, pageRows);
1650
+ return {
1651
+ kind: 'comments',
1652
+ workspaceId: scope.workspaceId,
1653
+ window: pop.win,
1654
+ selector,
1655
+ refine,
1656
+ total: selected.length,
1657
+ filteredTotal: narrowed.length,
1658
+ matrix,
1659
+ items,
1660
+ // Opaque on the wire; `o:<n>` is today's encoding of "offset into the folded population".
1661
+ nextCursor: consumed < narrowed.length ? `o:${consumed}` : null,
1662
+ truncated: pop.truncated,
1663
+ generatedAt: pop.generatedAt,
1664
+ };
1665
+ }
1666
+ //# sourceMappingURL=ml-labels.js.map