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,1289 @@
1
+ import { getBotFlaggingComments, getBotVendorComments } from '../../db/ml-labels.js';
2
+ import { getBotOverlapClusters } from '../../db/bot-overlap.js';
3
+ import { getBotVolume, getBotVolumeScatter, getPrBotVolume } from '../../db/bot-volume.js';
4
+ import { getBotAnalytics, getBotOnlyPrs, getBotVendorPrs, getBotDedupClusters, getResolvableBotThreadPrs, getResolvableBotThreadsForScope, listDetectedReviewers, resetWorkspaceReviewerIdentity, resetWorkspaceReviewerJudgement, resolveWorkspaceScope, setReviewerCost, setWorkspaceReviewer, SCOPE_RESOLVE_THREAD_CAP, } from '../../db/queries.js';
5
+ import { resolveThreadsOnGitHub } from '../../bot-triage/resolve.js';
6
+ import { getAutomationOutput } from '../../db/automation-output.js';
7
+ import { accountIdOf } from '../plugins/auth.js';
8
+ import { entitledProCapabilities } from '../../pro/contract.js';
9
+ // Parse a comma-separated id list into a positive-int array, or null when empty/absent (so
10
+ // `resolveWorkspaceScope` treats it as "no narrowing — every repo in the workspace"). Mirrors the
11
+ // parser in activity.ts.
12
+ //
13
+ // ⚠ ITS RESULT IS NEVER A SCOPE. It is the `narrow` argument to `resolveWorkspaceScope`, which
14
+ // INTERSECTS it with the workspace's membership. A handler that passed this list straight to a
15
+ // getter would let `?workspace=5&repoIds=<a repo of workspace 9>` measure one workspace's data
16
+ // through another's verdicts, and make the listing's lazy classifier write workspace-5 rows for
17
+ // actors with zero footprint there.
18
+ function parseIntList(raw) {
19
+ if (raw == null || raw.trim() === '')
20
+ return null;
21
+ const ids = raw
22
+ .split(',')
23
+ .map((s) => Number.parseInt(s.trim(), 10))
24
+ .filter((n) => Number.isInteger(n) && n > 0);
25
+ return ids.length > 0 ? ids : null;
26
+ }
27
+ /* ─────────────────────────────────────────────────────────────────────────────────────────────
28
+ ENTITLEMENT — WHO MAY READ THE ROI POPULATION
29
+ ─────────────────────────────────────────────────────────────────────────────────────────────
30
+
31
+ The Bot-ROI panel went PAID (`botDepth`) — the whole panel, not just its cost columns: the
32
+ per-bot signal-to-noise table, the verdicts, the ML flagging strip, the volume column and every
33
+ drill-down hanging off them. Which means the analytics ROUTES that feed it need the gate too; a
34
+ client-side `if (!botDepth) return <lock/>` is a rendering decision, not a monetisation gate, and
35
+ leaves the whole table one `curl` away (which is exactly the state /volume/scatter was in — its
36
+ only consumer was capability-gated, its route was not).
37
+
38
+ TWO PREDICATES, because two different questions get asked of this file:
39
+
40
+ • `botDepthEntitled` — "may this account see the ROI panel's own data?" Used by the routes
41
+ whose ENTIRE population is that panel: the volume family, the flagging drill-down, the
42
+ per-vendor PR list, and the seat PRICES on the reviewer listing.
43
+
44
+ • `botAnalyticsEntitled` — the UNION `botDepth || periodReports`, for the THREE routes the ROI
45
+ panel SHARES with the People report's bot sections (`GET /api/bot-analytics`,
46
+ `…/vendor/:key/comments` and `GET /api/bot-authoring`, all three read by PeopleReportDetail).
47
+ Gating those on `botDepth` alone would dark a surface someone had already bought under a
48
+ different capability. They are genuinely two products over one fold, so the predicate says
49
+ so rather than pretending one flag covers both.
50
+
51
+ ⚠ ALL THREE UNION ROUTES MUST USE **THE SAME PREDICATE**, not "the union where it was convenient".
52
+ `/api/bot-authoring` shipped ungated in the first cut of this change precisely because it does
53
+ not live under `/api/pro/` and reads as a plain core route — and it is the People report's bot
54
+ AUTHORING vector plus its evidence cards, i.e. exactly the paid population the locked pane
55
+ promises. A free cloud account could collect bot user ids from the (deliberately free) reviewer
56
+ listing and read the whole vector by URL. If a fourth surface ever shares this fold, add it here
57
+ rather than inventing a third predicate.
58
+
59
+ ⚠ WHAT IS **NOT** GATED, and must not be swept in by a later "finish the job" pass: the reviewer
60
+ LISTING itself (`GET /api/bot-reviewers` is the app-wide bot identity/colour backbone and the
61
+ free classification screen — its price COLUMNS are stripped, the rows are not withheld),
62
+ `…/bot-only-prs` (the list behind the FREE governance caution — its caption and its list must
63
+ agree, and the caption is outside the panel), the resolvable-thread read/resolve pair, and
64
+ per-PR dedup. Those are `botTriage`-free surfaces.
65
+
66
+ ⚠ `!req.account` READS AS UNENTITLED, matching the cost route's long-standing shape. In the real
67
+ app the account is always populated (local synthesizes one; cloud 401s first), so this only ever
68
+ fires in a bare test harness — where "no account" genuinely is "no entitlement".
69
+
70
+ ⚠ A TEST HARNESS NEEDS **TWO** THINGS TO REACH A GATED ROUTE, and registering the auth plugin is
71
+ only the first. `entitledProCapabilities` intersects the account with the LOADED PLUGIN's live
72
+ capability singleton, which is `EMPTY_CAPABILITIES` in any process that never bound a plugin — so
73
+ even a synthesized `isLocal` account is legitimately unentitled. A test that wants a 200 must
74
+ also `setProCapabilities({...})` (pro/contract.ts) in its `beforeAll`. */
75
+ function botDepthEntitled(req) {
76
+ return Boolean(req.account && entitledProCapabilities(req.account).botDepth);
77
+ }
78
+ function botAnalyticsEntitled(req) {
79
+ if (!req.account)
80
+ return false;
81
+ const caps = entitledProCapabilities(req.account);
82
+ return caps.botDepth || caps.periodReports;
83
+ }
84
+ // The 402 body every gate here answers with — the same shape the `/api/pro/*` blanket gate sends
85
+ // (api/plugins/auth.ts), so a client cannot tell a core paid route from a plugin one.
86
+ const PRO_REQUIRED = { error: 'pro required' };
87
+ /* THE PRICE STRIP — the ONE spelling, applied by EVERY handler that returns a reviewer row.
88
+ *
89
+ * `PUT …/cost` is the only route that WRITES a price, and it is 402'd without `botDepth`. But four
90
+ * routes READ one back: the listing, the PATCH, and both resets. Stripping only the listing left
91
+ * three doors open — a downgraded account could still read every seat price it had entered by
92
+ * calling `DELETE /api/bot-reviewers/:id/identity` (a free route, no body, echoes the whole row).
93
+ *
94
+ * ⚠ THE STRIPPED SHAPE IS EXACTLY A NEVER-PRICED ROW, `costModel: 'flat'` included
95
+ * (`WorkspaceReviewer` documents "a row with no price is always 'flat'"), so nothing downstream has
96
+ * to learn a fourth cost state. The consequence — a `null` price now means EITHER "no price set"
97
+ * OR "you may not see it" — is documented on the field in packages/shared: decide which from
98
+ * `/api/me`'s capability, never from the value.
99
+ *
100
+ * ⚠ THE GDPR EXPORT IS A DELIBERATE EXEMPTION, not a hole this missed. `GET /api/me/export`
101
+ * (db/export-account.ts) still carries `workspace_reviewers.monthly_cents`: it is data the subject
102
+ * typed in, and Art. 15/20 access/portability are about the subject's own data, not about a
103
+ * feature tier. That is a considered split — see the note in export-account.ts's column list. */
104
+ function stripCost(r) {
105
+ return { ...r, costMonthlyUsd: null, costModel: 'flat', effectiveMonthlyUsd: null };
106
+ }
107
+ // The explicit-bounds span cap — mirrors the synthesis person grain's PERSON_WINDOW_MAX_MS
108
+ // (packages/pro/src/synthesis/scope.ts; a plugin constant core cannot import): far past any real
109
+ // reporting period, tight enough that one request can never name a decade of rows.
110
+ const WINDOW_BOUNDS_MAX_SPAN_MS = 200 * 86_400_000;
111
+ // Resolve the optional `?fromMs=&toMs=` pair into `getBotAnalytics`' widened window form
112
+ // (`{kind, fromMs, toMs}` — apiVersion 18; the enum alone when no bounds were sent). The pair
113
+ // names a POPULATION, so garbage 400s rather than degrading (`{error}` here → 400 in the
114
+ // handler): both-or-neither, `fromMs < toMs`, span ≤ the 200-day cap. The digits-only shape is
115
+ // already ajv-enforced by the route schemas; bounds only narrow WHAT IS MEASURED inside the
116
+ // account's own resolved scope — they carry no authority, exactly like the enum they refine.
117
+ function parseWindowBounds(window, fromMs, toMs) {
118
+ if (fromMs == null && toMs == null)
119
+ return { window };
120
+ if (fromMs == null || toMs == null) {
121
+ return { error: '`fromMs` and `toMs` are only valid together' };
122
+ }
123
+ const from = Number(fromMs);
124
+ const to = Number(toMs);
125
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) {
126
+ return { error: '`fromMs`/`toMs` must be epoch-ms integers' };
127
+ }
128
+ if (from >= to)
129
+ return { error: '`fromMs` must be earlier than `toMs`' };
130
+ if (to - from > WINDOW_BOUNDS_MAX_SPAN_MS) {
131
+ return { error: '`fromMs`/`toMs` span too large (max 200 days)' };
132
+ }
133
+ return { window: { kind: window, fromMs: from, toMs: to } };
134
+ }
135
+ // ── THE SCOPE PARAMETER ─────────────────────────────────────────────────────────────────────
136
+ // Every read route here takes `?workspace=<integer>` (absent / unknown / unparseable / another
137
+ // tenant's id ⇒ the account's Default workspace — NOT a 404: every id yields the same response
138
+ // shape, so it is not an existence oracle, the resolved id is always one the caller owns, and a
139
+ // stale bookmark degrades to something renderable instead of a blank screen). `?repoIds=<csv>`
140
+ // survives alongside it and still narrows the DATA — it no longer competes with the scope for the
141
+ // VERDICT, because the workspace owns that outright.
142
+ //
143
+ // The wire type is `string`, not `integer`, on purpose: an ajv `integer` would 400 on a garbage
144
+ // `?workspace=`, and the contract above is that garbage degrades to Default rather than erroring.
145
+ //
146
+ // ── THE WRITE SURFACE: TWO ROUTES, SPLIT BY MUTABILITY (NOT BY GRAIN) ───────────────────────
147
+ // There is ONE grain now — a `workspace_reviewers` row, keyed (account, workspace, actor) — so the
148
+ // grain mismatch the old three-route split defended against cannot occur. What survives is a
149
+ // MUTABILITY difference:
150
+ //
151
+ // PATCH /api/bot-reviewers/:userId WorkspaceReviewerPatchBody automated/role/kind/label
152
+ // PUT /api/bot-reviewers/:userId/cost ReviewerCostBody monthly_cents
153
+ //
154
+ // `automated`, `role`, `kind` and `label` are all RE-DERIVABLE: a wrong write is repaired by the
155
+ // next classification pass or by a reset, so they belong in one body keyed by two INDEPENDENT
156
+ // provenance flags. `monthly_cents` is derivable by nothing and is money. Keeping it on its own
157
+ // PUT means NO COMBINED BODY CAN ADDRESS THE COLUMN AT ALL, and the PATCH handler's `set:` object
158
+ // contains no cost key. That is the same structural guarantee the old two-table split provided,
159
+ // preserved with one fewer table — and `additionalProperties:false` is NOT what provides it
160
+ // (Fastify's ajv runs `removeAdditional:true`, so unknown keys are STRIPPED, not rejected). What
161
+ // provides it is that each handler calls exactly ONE query-layer function with a narrow `set:`.
162
+ //
163
+ // …plus TWO RESETS, one per PROVENANCE FLAG:
164
+ //
165
+ // DELETE /api/bot-reviewers/:userId/judgement?workspaceId= automated/role back to auto
166
+ // DELETE /api/bot-reviewers/:userId/identity?workspaceId= kind/label back to auto, PRICE KEPT
167
+ //
168
+ // ⚠ THE RESETS ARE NOT OPTIONAL POLISH. A manual write pins its half against re-derivation, and
169
+ // flipping the value back by hand leaves it just as pinned — so without a way back to auto, every
170
+ // edit here is permanent. That is also what makes the role-only patch's `source:'manual'` stamp
171
+ // (which pins `automated` for that workspace as a side effect) the right trade rather than a trap:
172
+ // the pin is visible AND undoable in the same place. `WorkspaceReviewer.isManualOverride` and
173
+ // `.identitySource` exist to drive exactly these two affordances.
174
+ //
175
+ // ⚠ BOTH RESETS ARE AN UPDATE + IMMEDIATE RE-DERIVE, NOT A ROW DELETE, and both answer 200 with
176
+ // the re-derived row. The old per-repo judgement reset DELETED its row (the row held nothing else,
177
+ // and the listing re-derived a missing one), so there was nothing to echo and it answered 204.
178
+ // This row also carries the identity AND the price, so a delete is lossy.
179
+ //
180
+ // ⚠ EVERY WRITE ON THIS SURFACE IS WORKSPACE-WIDE. The old per-repo PATCH could honestly promise
181
+ // "this leaves your other repos alone"; nothing here can. A control rendered in a repo-shaped
182
+ // context must say so in its copy.
183
+ //
184
+ // ⚠ READS DEGRADE, WRITES 404. A write names the row it edits, so an unowned or unknown
185
+ // `workspaceId` must not silently land in Default — the query layer's ownership gate returns null
186
+ // and the handler 404s. Only the read routes fall back, and only because their answer is shaped
187
+ // identically whatever the id was.
188
+ // PATCH /api/bot-reviewers/:userId — the four re-derivable fields of ONE workspace_reviewers row.
189
+ //
190
+ // `workspaceId` is REQUIRED and rides in the BODY: the row IS the object, and the workspace is
191
+ // part of its key, not a filter over it. The query layer verifies the workspace belongs to the
192
+ // caller and 404s otherwise (the composite FK `(workspace_id, account_id) → workspaces(id,
193
+ // account_id)` would also reject a cross-tenant write, but a constraint violation is a 500 and
194
+ // this must be a 404).
195
+ //
196
+ // `role` IS a closed 2-value set, so it is enumerated (an unknown role would land in a NOT NULL
197
+ // column the metric split branches on). `kind` is left an open nullable string: the
198
+ // `AutomatedReviewerKind` union lives in the types-only shared package the backend cannot import
199
+ // at runtime, and the query layer coerces an unknown kind, so pinning the vendor list here would
200
+ // only add drift. All four value fields are OPTIONAL — absent means "leave the stored value
201
+ // alone", so an old client that only knows `automated` cannot silently un-mark a quality check —
202
+ // but the handler rejects a body carrying NONE of them.
203
+ //
204
+ // ⚠ THERE IS NO `monthlyUsd` HERE, and that is the structural half of the cost guarantee: the
205
+ // handler calls `setWorkspaceReviewer`, whose `set:` object has no `monthlyCents` key.
206
+ const patchSchema = {
207
+ params: {
208
+ type: 'object',
209
+ required: ['userId'],
210
+ properties: { userId: { type: 'integer' } },
211
+ },
212
+ body: {
213
+ type: 'object',
214
+ additionalProperties: false,
215
+ required: ['workspaceId'],
216
+ properties: {
217
+ workspaceId: { type: 'integer', minimum: 1 },
218
+ automated: { type: 'boolean' },
219
+ // Must stay in lockstep with `ReviewerRole` in shared — the schema is the only validation
220
+ // this field gets, and an unlisted role is rejected with a 400 rather than stored.
221
+ role: {
222
+ type: 'string',
223
+ enum: ['review', 'quality_check', 'dependency', 'code_agent', 'release', 'housekeeping'],
224
+ },
225
+ kind: { type: ['string', 'null'] },
226
+ label: { type: ['string', 'null'] },
227
+ },
228
+ },
229
+ };
230
+ // DELETE /api/bot-reviewers/:userId/judgement?workspaceId= — hand automated/role/confidence/
231
+ // reasons back to detection for ONE workspace, and re-derive in the same request.
232
+ //
233
+ // `workspaceId` rides in the QUERY STRING rather than a body: a DELETE body is stripped by enough
234
+ // intermediaries (and by some fetch implementations) that it is not a place to put a required
235
+ // field. It is required for the same reason it is required on the PATCH — the row is the object.
236
+ const judgementResetSchema = {
237
+ params: {
238
+ type: 'object',
239
+ required: ['userId'],
240
+ properties: { userId: { type: 'integer' } },
241
+ },
242
+ querystring: {
243
+ type: 'object',
244
+ additionalProperties: false,
245
+ required: ['workspaceId'],
246
+ properties: { workspaceId: { type: 'integer', minimum: 1 } },
247
+ },
248
+ };
249
+ // DELETE /api/bot-reviewers/:userId/identity?workspaceId= — clear kind/label, `identitySource`
250
+ // back to 'auto', re-derive immediately. Same shape and same reasoning as the judgement reset
251
+ // (spelled out rather than aliased so the two can diverge without a shared-object surprise).
252
+ const identityResetSchema = {
253
+ params: {
254
+ type: 'object',
255
+ required: ['userId'],
256
+ properties: { userId: { type: 'integer' } },
257
+ },
258
+ querystring: {
259
+ type: 'object',
260
+ additionalProperties: false,
261
+ required: ['workspaceId'],
262
+ properties: { workspaceId: { type: 'integer', minimum: 1 } },
263
+ },
264
+ };
265
+ // PUT /api/bot-reviewers/:userId/cost — what this bot costs IN THIS WORKSPACE.
266
+ //
267
+ // `monthlyUsd` is REQUIRED and NULLABLE precisely so `undefined` is not a third meaning: a number
268
+ // sets the price (0 is real — "we pay nothing"), null clears it. Clearing is a COLUMN write, never
269
+ // a row delete: the row also carries the judgement and the identity.
270
+ //
271
+ // ⚠ THE BOUNDS ARE NOT COSMETIC. Storage is int4 CENTS in both dialects, and 21474836.47 is where
272
+ // the dialects STOP AGREEING: Postgres RAISES `integer out of range` (a 500) above it while
273
+ // SQLite's 64-bit integers accept the value happily — so an unbounded field means the same
274
+ // request succeeds locally and 500s in cloud, leaving a number cloud can never represent.
275
+ // (Measured: monthlyUsd 99999999999 stored 2147483647 on pg and 9999999999900 on sqlite.) ajv
276
+ // 400s out-of-range here; the query layer ALSO clamps, as the backstop for every other caller.
277
+ //
278
+ // `multipleOf: 0.01` rejects a fractional-cent price like $1.005, which nothing downstream can
279
+ // print and which the two rounding paths were measured disagreeing about. (Non-finite values
280
+ // cannot survive JSON.parse, and `type: number` rejects them anyway.)
281
+ //
282
+ // `costModel` is OPTIONAL (omitted ⇒ 'flat') and rides THIS body because it changes what the
283
+ // number MEANS: 'per_seat' makes `monthlyUsd` a per-seat unit price, multiplied on read by the
284
+ // workspace's derived seat count. It is money the same way the number is, so it must never appear
285
+ // in the PATCH body — the no-combined-body guarantee covers both columns or neither. A CLEAR
286
+ // (`monthlyUsd: null`) resets the stored model to 'flat' server-side regardless of what rides
287
+ // along.
288
+ const costSchema = {
289
+ params: {
290
+ type: 'object',
291
+ required: ['userId'],
292
+ properties: { userId: { type: 'integer' } },
293
+ },
294
+ body: {
295
+ type: 'object',
296
+ additionalProperties: false,
297
+ required: ['workspaceId', 'monthlyUsd'],
298
+ properties: {
299
+ workspaceId: { type: 'integer', minimum: 1 },
300
+ monthlyUsd: {
301
+ type: ['number', 'null'],
302
+ minimum: 0,
303
+ maximum: 21474836.47,
304
+ multipleOf: 0.01,
305
+ },
306
+ costModel: { type: 'string', enum: ['flat', 'per_seat'] },
307
+ },
308
+ },
309
+ };
310
+ // GET /api/bot-reviewers?workspace=&repoIds= — the bot listing for ONE workspace. `workspace`
311
+ // decides the VERDICT; `repoIds` narrows which repos' footprints are displayed. They can no longer
312
+ // disagree, because they answer different questions — and the narrowing is bounded by the
313
+ // workspace's membership inside `resolveWorkspaceScope`, so it can never reach outside the scope.
314
+ const listReviewersSchema = {
315
+ querystring: {
316
+ type: 'object',
317
+ additionalProperties: false,
318
+ properties: {
319
+ workspace: { type: 'string' },
320
+ repoIds: { type: 'string' },
321
+ },
322
+ },
323
+ };
324
+ // GET /api/bot-analytics?window=&workspace=&repoIds=[&fromMs=&toMs=] — the window is a closed
325
+ // 4-value set, safe to enum + default; the optional bounds pair refines it to a REAL period (the
326
+ // People report's bot sections) and is validated by `parseWindowBounds` (only-together, ordered,
327
+ // span-capped ⇒ 400 — a bad bound is a client bug, not a stale bookmark).
328
+ // GET /api/bot-authoring — the AUTHORING half of "what did this automation do" (plan follow-up).
329
+ // Bounds are REQUIRED here, unlike the two routes above: this endpoint exists to serve the People
330
+ // report's bot sections, which always know their period, and a vector whose window silently
331
+ // defaulted to a rolling enum would be captioned with the report's dates while measuring
332
+ // something else. Same digits-only ajv shape and the same `parseWindowBounds` rules otherwise.
333
+ const authoringSchema = {
334
+ querystring: {
335
+ type: 'object',
336
+ additionalProperties: false,
337
+ required: ['userId', 'fromMs', 'toMs'],
338
+ properties: {
339
+ userId: { type: 'string', pattern: '^[0-9]+$' },
340
+ workspace: { type: 'string' },
341
+ repoIds: { type: 'string' },
342
+ fromMs: { type: 'string', pattern: '^[0-9]+$' },
343
+ toMs: { type: 'string', pattern: '^[0-9]+$' },
344
+ // `1` adds the receipt rows under the vector, mirroring the person route's `?evidence=1`.
345
+ evidence: { type: 'string' },
346
+ },
347
+ },
348
+ };
349
+ const analyticsSchema = {
350
+ querystring: {
351
+ type: 'object',
352
+ additionalProperties: false,
353
+ properties: {
354
+ window: {
355
+ type: 'string',
356
+ enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
357
+ default: 'rolling_14',
358
+ },
359
+ // The workspace whose verdicts these metrics are computed under. Absent/unknown ⇒ Default.
360
+ workspace: { type: 'string' },
361
+ // Optional DATA narrowing (comma-separated repo ids) — the per-repo Bots tab. It is
362
+ // intersected with the workspace's membership; it does NOT change who counts as a bot.
363
+ repoIds: { type: 'string' },
364
+ // Optional explicit bounds (epoch-ms integer strings, half-open [fromMs, toMs)) — they
365
+ // only narrow WHAT IS MEASURED inside the resolved scope, never who counts as a bot.
366
+ fromMs: { type: 'string', pattern: '^[0-9]+$' },
367
+ toMs: { type: 'string', pattern: '^[0-9]+$' },
368
+ },
369
+ },
370
+ };
371
+ // GET /api/bot-analytics/vendor/:key/prs?window= — per-REVIEWER PR drill-down. `key` is the
372
+ // analytics-row identity: `u<userId>` (a single reviewer) or the 'pierre' sentinel; the handler
373
+ // parses it (anything else → 400). The window/workspace/repoIds resolution is identical to
374
+ // /api/bot-analytics, because this list must reproduce ONE of that panel's rows.
375
+ const vendorPrsSchema = {
376
+ params: {
377
+ type: 'object',
378
+ required: ['key'],
379
+ properties: { key: { type: 'string' } },
380
+ },
381
+ querystring: {
382
+ type: 'object',
383
+ additionalProperties: false,
384
+ properties: {
385
+ window: {
386
+ type: 'string',
387
+ enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
388
+ default: 'rolling_14',
389
+ },
390
+ workspace: { type: 'string' },
391
+ repoIds: { type: 'string' },
392
+ },
393
+ },
394
+ };
395
+ // GET /api/bot-analytics/vendor/:key/comments?window=[&fromMs=&toMs=] — the per-REVIEWER
396
+ // COMMENTS drill-down. Same shape as vendorPrsSchema, spelled out rather than aliased so the two
397
+ // can diverge without a shared-object surprise (the reset-schema precedent above) — and it HAS
398
+ // diverged: this one also takes the optional explicit-bounds pair (the People report's per-bot
399
+ // evidence cards cover the real period), validated exactly like /api/bot-analytics's.
400
+ const vendorCommentsSchema = {
401
+ params: {
402
+ type: 'object',
403
+ required: ['key'],
404
+ properties: { key: { type: 'string' } },
405
+ },
406
+ querystring: {
407
+ type: 'object',
408
+ additionalProperties: false,
409
+ properties: {
410
+ window: {
411
+ type: 'string',
412
+ enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
413
+ default: 'rolling_14',
414
+ },
415
+ workspace: { type: 'string' },
416
+ repoIds: { type: 'string' },
417
+ fromMs: { type: 'string', pattern: '^[0-9]+$' },
418
+ toMs: { type: 'string', pattern: '^[0-9]+$' },
419
+ },
420
+ },
421
+ };
422
+ // The two ML enums, spelled out LOCALLY rather than imported from the shared package.
423
+ //
424
+ // ⚠ `@pierre-review/shared` is TYPES-ONLY and is not a published runtime dependency — the
425
+ // release build greps `release/dist` and FAILS on a surviving value import — so this file cannot
426
+ // write `enum: [...ML_CATEGORIES]`. `db/ml-labels.ts` carries exactly the same accommodation (its
427
+ // own private `ML_CATEGORY_VALUES` / `SEVERITY_KEYS`). These lists must stay in step with the
428
+ // `MlCategory` / `MlSeverity` unions; drift shows up as a 400 on a class the model really emits,
429
+ // which is why the `MlCategory[]` / `MlSeverity[]` annotations are load-bearing — a value the
430
+ // union dropped stops compiling here.
431
+ const FLAGGING_CATEGORY_VALUES = [
432
+ 'correctness_bug',
433
+ 'security',
434
+ 'performance',
435
+ 'style_readability',
436
+ 'maintainability_refactor',
437
+ 'testing',
438
+ 'documentation',
439
+ 'nitpick',
440
+ 'praise',
441
+ ];
442
+ const FLAGGING_SEVERITY_VALUES = ['nit', 'minor', 'major', 'critical'];
443
+ // GET /api/bot-analytics/flagging?select=&…&window=&workspace=&repoIds=&limit=&cursor= — the
444
+ // drill-down behind EVERY tile and chip on the Bots rail's ML strip. One route, one discriminated
445
+ // `select`, because every arm names a population of the SAME windowed label scan the strip is
446
+ // folded from; six routes could not have guaranteed that.
447
+ //
448
+ // ⚠ `workspace` and `repoIds` are STRING, never integer — the degrade-to-Default contract at the
449
+ // top of this file: an ajv `integer` would 400 on a garbage `?workspace=`, and garbage must
450
+ // render the Default workspace rather than error. `limit` IS an integer, and that asymmetry is
451
+ // the point: a bad page size is a client bug, not a stale bookmark.
452
+ //
453
+ // ⚠ `additionalProperties:false` here STRIPS unknown keys rather than rejecting them (Fastify's
454
+ // ajv runs `removeAdditional:true`), so it is tidiness, not a guarantee. What bounds this route is
455
+ // that the handler builds its selector from the enumerated keys and hands the scope to exactly one
456
+ // getter.
457
+ const flaggingSchema = {
458
+ querystring: {
459
+ type: 'object',
460
+ additionalProperties: false,
461
+ required: ['select'],
462
+ properties: {
463
+ select: {
464
+ type: 'string',
465
+ enum: ['findings', 'summaries', 'severity', 'category', 'overlap'],
466
+ },
467
+ // CSV for `select=severity` (the High-severity tile sends `major,critical`). ajv cannot
468
+ // validate a CSV against an enum, so the handler parses it and 400s on anything unknown.
469
+ severities: { type: 'string' },
470
+ category: { type: 'string', enum: [...FLAGGING_CATEGORY_VALUES] },
471
+ // The confusion-matrix cell filter — a PAIR. 'none' is the vendor axis for "the bot
472
+ // declared no severity at all", which is a real column, not a missing value.
473
+ cellVendor: { type: 'string', enum: ['nit', 'minor', 'major', 'critical', 'none'] },
474
+ cellOurs: { type: 'string', enum: ['nit', 'minor', 'major', 'critical'] },
475
+ disagree: { type: 'string', enum: ['any', 'over', 'under'] },
476
+ // The per-bot narrowing, opened by the Behaviour tab's inflation index: a CSV of `users.id`s
477
+ // (the numbers behind `u<userId>` keys), NOT vendor key strings.
478
+ //
479
+ // ⚠ A LIST, because the card-level "View all N →" sums over the PANEL's bots (role
480
+ // `'review'`) while this route resolves role `'all'` — both deliberate — so only the exact
481
+ // id set can make the button's number and the list it opens agree by construction.
482
+ //
483
+ // ⚠ STRING + `parseIntList`, the `repoIds` precedent, and NOT an ajv array: a CSV is what
484
+ // every other id list on this surface is spelled as, and ajv cannot validate its members
485
+ // anyway. Bounds and validation therefore live in `parseIntList` (positive integers only,
486
+ // anything else dropped) exactly as they do for `repoIds`.
487
+ //
488
+ // ⚠ And it MUST be declared here at all: `additionalProperties: false` under Fastify's
489
+ // `removeAdditional: true` STRIPS an undeclared key and answers 200, so a client sending
490
+ // `authorUserIds` against a schema that never heard of it would list the WHOLE workspace
491
+ // under a caption promising a subset.
492
+ authorUserIds: { type: 'string' },
493
+ window: {
494
+ type: 'string',
495
+ enum: ['rolling_7', 'rolling_14', 'rolling_30', 'sprint'],
496
+ default: 'rolling_14',
497
+ },
498
+ workspace: { type: 'string' },
499
+ repoIds: { type: 'string' },
500
+ limit: { type: 'integer', default: 20, minimum: 1, maximum: 50 },
501
+ // OPAQUE server-minted state. Bounded so a hostile client cannot make the handler parse an
502
+ // arbitrarily long string; a malformed one degrades to the first page rather than 400ing.
503
+ cursor: { type: 'string', minLength: 1, maxLength: 64 },
504
+ },
505
+ },
506
+ };
507
+ // ── Bot comment VOLUME (PAID `botDepth`, deterministic — db/bot-volume.ts) ──────────────────
508
+ // Three routes over ONE base scan: the per-bot column, its PR drill-down, and the LOC chart. The
509
+ // FOLD stays core and capability-blind; the three handlers below carry the gate.
510
+ //
511
+ // ⚠ `workspace`/`repoIds` are STRING here for the same reason they are everywhere else on this
512
+ // surface — an ajv `integer` would 400 on a stale bookmark, and a stale bookmark must degrade to
513
+ // the Default workspace. `limit` IS an integer (a bad page size is a client bug, not a bookmark).
514
+ //
515
+ // ⚠ EVERY PARAMETER THESE HANDLERS READ MUST BE DECLARED HERE. Fastify runs ajv with
516
+ // `removeAdditional: true`, so an undeclared key is STRIPPED and the request answers 200 with the
517
+ // filter silently not applied — a `sort=ratio` the schema never heard of would return the
518
+ // comments-sorted list under a caption promising the other one.
519
+ const volumeScopeSchema = {
520
+ querystring: {
521
+ type: 'object',
522
+ additionalProperties: false,
523
+ properties: {
524
+ window: {
525
+ type: 'string',
526
+ enum: ['rolling_7', 'rolling_14', 'rolling_30', 'rolling_90', 'sprint'],
527
+ default: 'rolling_30',
528
+ },
529
+ workspace: { type: 'string' },
530
+ repoIds: { type: 'string' },
531
+ },
532
+ },
533
+ };
534
+ const volumePrsSchema = {
535
+ querystring: {
536
+ type: 'object',
537
+ additionalProperties: false,
538
+ properties: {
539
+ window: {
540
+ type: 'string',
541
+ enum: ['rolling_7', 'rolling_14', 'rolling_30', 'rolling_90', 'sprint'],
542
+ default: 'rolling_30',
543
+ },
544
+ workspace: { type: 'string' },
545
+ repoIds: { type: 'string' },
546
+ // The per-bot narrowing. A CSV of `users.id`s, the `BotFlaggingRefine.authorUserIds`
547
+ // spelling verbatim — ONE convention on this surface, not two. Present-but-empty means NO
548
+ // bots (see the handler); only absence widens.
549
+ authorUserIds: { type: 'string' },
550
+ // ⚠ AN ENUM, so an unknown sort 400s rather than silently falling back to the default and
551
+ // presenting a comments-ranked list as a ratio-ranked one. The default is `'comments'` by
552
+ // product decision even though raw count mostly ranks by SIZE — see BotVolumePrSort.
553
+ sort: { type: 'string', enum: ['comments', 'ratio'], default: 'comments' },
554
+ limit: { type: 'integer', default: 20, minimum: 1, maximum: 50 },
555
+ // OPAQUE server-minted state, bounded so a hostile client cannot make the handler parse an
556
+ // arbitrarily long string. A malformed cursor degrades to the first page rather than 400ing.
557
+ cursor: { type: 'string', minLength: 1, maxLength: 64 },
558
+ },
559
+ },
560
+ };
561
+ const prIdParamSchema = {
562
+ params: {
563
+ type: 'object',
564
+ required: ['id'],
565
+ properties: { id: { type: 'integer' } },
566
+ },
567
+ };
568
+ // GET /api/bot-threads/resolvable?workspace=&repoIds= — the workspace-wide review list. Same
569
+ // scope resolution as the analytics routes.
570
+ const resolvableSchema = {
571
+ querystring: {
572
+ type: 'object',
573
+ additionalProperties: false,
574
+ properties: {
575
+ workspace: { type: 'string' },
576
+ repoIds: { type: 'string' },
577
+ },
578
+ },
579
+ };
580
+ // POST /api/bot-threads/resolve — the confirm-gated workspace-wide resolve. `threadIds` is the
581
+ // explicit reviewed list (required; [] is a legal no-op), capped at SCOPE_RESOLVE_THREAD_CAP per
582
+ // request (the client chunks a larger selection); `workspaceId` is the scope the server re-derives
583
+ // eligibility against.
584
+ //
585
+ // ⚠ `workspaceId` IS REQUIRED, and it is what makes "the listing and the resolve agree" structural
586
+ // rather than a convention. Its predecessor carried an optional `repoIds` while the listing was
587
+ // resolved from a TEAM scope, so a reviewer marked automated only under a per-team override had
588
+ // its threads offered by the listing and then found ineligible here — the route resolved 0 with no
589
+ // error anywhere. One workspace id on both sides cannot disagree with itself.
590
+ const scopeResolveSchema = {
591
+ body: {
592
+ type: 'object',
593
+ required: ['threadIds', 'workspaceId'],
594
+ additionalProperties: false,
595
+ properties: {
596
+ threadIds: {
597
+ type: 'array',
598
+ items: { type: 'integer' },
599
+ maxItems: SCOPE_RESOLVE_THREAD_CAP,
600
+ },
601
+ workspaceId: { type: 'integer', minimum: 1 },
602
+ },
603
+ },
604
+ };
605
+ // Bot-triage platform routes (CORE, always registered — never "core" in the TIER sense). Two tiers
606
+ // live in this one file and the split is deliberate:
607
+ //
608
+ // FREE (`botTriage`) detection/override + the reviewer listing, the bot-only governance list,
609
+ // per-PR cross-bot dedup, and the confirm-gated workspace-wide thread resolve
610
+ // — everything an `npx` install needs to classify and triage its bots.
611
+ // PAID (`botDepth`) the ROI ANALYTICS: the per-bot table, the ML flagging strip, the volume
612
+ // family, every drill-down off them, and the seat PRICES. See the two
613
+ // entitlement predicates at the top of this file for the exact split and for
614
+ // the two routes the People report shares.
615
+ //
616
+ // Every handler is account-scoped via accountIdOf(req); id-addressed reads/writes verify ownership
617
+ // → 404. No AI anywhere in here — paid does not mean model-backed, it means the analysis is the
618
+ // product.
619
+ export async function botTriageRoutes(app) {
620
+ // One row per (workspace, actor), each carrying its judgement, identity, price and the evidence
621
+ // behind them — including `repoFootprints[]`, the real blast radius of an edit that is
622
+ // workspace-wide by design. Powers the Bots "Settings" tab.
623
+ //
624
+ // ⚠ THE ROUTE IS FREE AND MUST STAY FREE — but its PRICE COLUMNS ARE NOT. This is the identity
625
+ // and colour backbone for the whole SPA (the feed's vendor tags, ThreadList, BotTriageCard, the
626
+ // People picker, every drill-down header), so a 402 here would black out bot identity app-wide,
627
+ // and it also serves the free classification screen an OSS user needs. What it may NOT do is
628
+ // ship seat pricing to an account that cannot see pricing: today the client merely HIDES those
629
+ // cells, which is a rendering decision, not a gate. So the rows come back with the money
630
+ // stripped — exactly the shape a never-priced row already has, `costModel: 'flat'` included
631
+ // (`WorkspaceReviewer` documents "a row with no price is always 'flat'"), so nothing downstream
632
+ // has to learn a fourth cost state.
633
+ //
634
+ // `workspaceSeatCount` SURVIVES the strip: it is a derived HEADCOUNT (distinct human PR authors),
635
+ // not a price, and it is already visible in other free surfaces.
636
+ app.get('/api/bot-reviewers', { schema: listReviewersSchema }, async (req) => {
637
+ const { workspace, repoIds } = req.query;
638
+ const accountId = accountIdOf(req);
639
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
640
+ const listing = await listDetectedReviewers(accountId, scope);
641
+ if (botDepthEntitled(req))
642
+ return listing;
643
+ const resp = {
644
+ ...listing,
645
+ reviewers: listing.reviewers.map(stripCost),
646
+ };
647
+ return resp;
648
+ });
649
+ // ── THE ONE WRITE ─────────────────────────────────────────────────────────────────────────
650
+ // Force a reviewer automated (or back to human), set its role, name its vendor, relabel it —
651
+ // for ONE workspace. The two halves stamp their provenance flags INDEPENDENTLY: automated/role
652
+ // stamp `source:'manual'`; kind/label stamp `identitySource:'manual'`.
653
+ //
654
+ // ⚠ THAT INDEPENDENCE IS THE ONLY THING LEFT DOING THE JOB THE TWO-TABLE SPLIT USED TO DO. The
655
+ // predecessor wrote identity per repo and read it per actor, so "Not a bot" on ONE repo nulled
656
+ // the kind and CodeRabbit lost its brand colour and vendor name on every repo the user never
657
+ // touched. With both facts on one row there is no table boundary left to catch a handler that
658
+ // stamps one flag while the user edited the other — only these two flags.
659
+ //
660
+ // ⚠ IT CANNOT REACH THE PRICE. No `monthlyUsd` in the body, and `setWorkspaceReviewer`'s `set:`
661
+ // object carries no cost key.
662
+ //
663
+ // 404 covers an unknown user id, an unknown/foreign workspace id, AND an actor with no stored
664
+ // row and no footprint in that workspace — deliberately the same status for all three:
665
+ // distinguishing them would turn this route into an existence oracle over another tenant's
666
+ // workspace ids and over the GLOBAL users table.
667
+ app.patch('/api/bot-reviewers/:userId', { schema: patchSchema }, async (req, reply) => {
668
+ const { userId } = req.params;
669
+ const body = req.body;
670
+ // An opinion-free patch is a client bug, not a no-op worth a 200: with none of the four fields
671
+ // there is nothing to write, and stamping a provenance flag anyway would freeze detection for
672
+ // that workspace on the strength of an empty request.
673
+ if (body.automated === undefined &&
674
+ body.role === undefined &&
675
+ body.kind === undefined &&
676
+ body.label === undefined) {
677
+ reply.status(400);
678
+ return {
679
+ error: 'BadRequest',
680
+ message: 'Patch must carry at least one of `automated`, `role`, `kind`, `label`',
681
+ };
682
+ }
683
+ const reviewer = await setWorkspaceReviewer(accountIdOf(req), userId, body);
684
+ if (!reviewer) {
685
+ reply.status(404);
686
+ return {
687
+ error: 'NotFound',
688
+ message: `Reviewer ${userId} not found in workspace ${body.workspaceId}`,
689
+ };
690
+ }
691
+ // The classification WRITE is free; the price it echoes back is not — see `stripCost`.
692
+ const resp = botDepthEntitled(req) ? reviewer : stripCost(reviewer);
693
+ return resp;
694
+ });
695
+ // ── JUDGEMENT RESET ───────────────────────────────────────────────────────────────────────
696
+ // Hand `automated`/`role`/`confidence`/`reasons` back to detection for one workspace and
697
+ // re-derive in the same request, so the auto verdict lands in this response.
698
+ //
699
+ // ⚠ IT IS AN UPDATE, NOT A ROW DELETE, which is why it answers 200 and not the old 204. The row
700
+ // also holds the vendor identity and the price; deleting it would take both with it. A
701
+ // clear-WITHOUT-derive would be worse still: the human's values would sit under an auto label
702
+ // until something else overwrote them — a stale opinion wearing the wrong provenance.
703
+ //
704
+ // ⚠ IT TOUCHES NEITHER THE IDENTITY NOR THE PRICE. Different provenance flag, different route.
705
+ //
706
+ // ⚠ BLAST RADIUS IS THE WHOLE WORKSPACE. The UI offers it only where `isManualOverride` is true
707
+ // — a control that resets an already-auto row would appear to do nothing.
708
+ //
709
+ // Same 404 rule as the PATCH, and the same reason: one status for unknown user / unknown or
710
+ // foreign workspace / no stored row, so it is never an existence oracle.
711
+ app.delete('/api/bot-reviewers/:userId/judgement', { schema: judgementResetSchema }, async (req, reply) => {
712
+ const { userId } = req.params;
713
+ const { workspaceId } = req.query;
714
+ const reviewer = await resetWorkspaceReviewerJudgement(accountIdOf(req), userId, workspaceId);
715
+ if (!reviewer) {
716
+ reply.status(404);
717
+ return {
718
+ error: 'NotFound',
719
+ message: `No stored judgement for reviewer ${userId} in workspace ${workspaceId}`,
720
+ };
721
+ }
722
+ // Echoed, unlike its 204-answering predecessor: the row still exists (it still holds the
723
+ // identity and the price), and this is the re-derived verdict the caller asked to fall back
724
+ // to — which saves the client a refetch to learn what auto detection actually came back as.
725
+ // The price it carries is stripped for an unentitled reader, exactly as on the listing.
726
+ const resp = botDepthEntitled(req) ? reviewer : stripCost(reviewer);
727
+ return resp;
728
+ });
729
+ // ── IDENTITY RESET ────────────────────────────────────────────────────────────────────────
730
+ // Hand `kind` + `label` back to detection: clear them, set `identitySource:'auto'`, and
731
+ // re-derive immediately so the auto answer lands in this response.
732
+ //
733
+ // ⚠ IT KEEPS THE PRICE. The row also carries `monthly_cents`, and a price is not a
734
+ // classification opinion — clearing it because someone un-named a vendor is exactly the coupling
735
+ // this contract keeps separated. The UI says so in words, because "reset" reads as "delete
736
+ // everything" otherwise.
737
+ //
738
+ // ⚠ IT TOUCHES NO JUDGEMENT FIELD. Naming a vendor is not a statement about how it behaves, and
739
+ // stamping `source` from here would freeze auto-classification for the whole workspace.
740
+ //
741
+ // Same 404 rule as its judgement twin.
742
+ app.delete('/api/bot-reviewers/:userId/identity', { schema: identityResetSchema }, async (req, reply) => {
743
+ const { userId } = req.params;
744
+ const { workspaceId } = req.query;
745
+ const reviewer = await resetWorkspaceReviewerIdentity(accountIdOf(req), userId, workspaceId);
746
+ if (!reviewer) {
747
+ reply.status(404);
748
+ return {
749
+ error: 'NotFound',
750
+ message: `Reviewer ${userId} not found in workspace ${workspaceId}`,
751
+ };
752
+ }
753
+ // ⚠ IT KEEPS THE PRICE (above) but does not necessarily SHOW it: same strip as the listing,
754
+ // or this free, body-less DELETE becomes the read path for every price on the workspace.
755
+ const resp = botDepthEntitled(req) ? reviewer : stripCost(reviewer);
756
+ return resp;
757
+ });
758
+ // ── COST ──────────────────────────────────────────────────────────────────────────────────
759
+ // What this bot costs per month IN THIS WORKSPACE. A number sets it (0 is real: "we pay
760
+ // nothing"); null CLEARS it — a column write, never a row delete, because the row also carries
761
+ // the judgement and the identity.
762
+ //
763
+ // ⚠ IT IS A STANDALONE ROUTE ON PURPOSE, and that is the whole reason it did not fold into the
764
+ // PATCH above when the two grains merged. Cost is derivable by nothing and is money: giving it
765
+ // its own body means no combined body can address `monthly_cents` at all. That structural
766
+ // guarantee survived the two-TABLE split; it must survive the two-GRAIN merge.
767
+ //
768
+ // ⚠ THE PRICE IS PER WORKSPACE, like every other attribute on the row. The UPDATE predicate is
769
+ // (account_id, workspace_id, author_user_id) — exactly one row. The same actor's rows in other
770
+ // workspaces are untouched and may legitimately hold different numbers; nothing reconciles them,
771
+ // there is no fan-out writer and no INSERT seed. Within one workspace there is one row per
772
+ // actor, so a total there is a plain sum; across workspaces it is not a sum at all, and no
773
+ // surface may add them up.
774
+ //
775
+ // Same 404 rule as the resets: no row for that (workspace, actor) ⇒ no price to attach.
776
+ app.put('/api/bot-reviewers/:userId/cost', { schema: costSchema }, async (req, reply) => {
777
+ // ── PAID (`botDepth`, plan P0.3) ── The cost overlay re-gated from "plugin loaded"
778
+ // (`botTriage`) to the paid depth tier. Same entitlement view /api/me serves and the
779
+ // /api/pro/* 402 gate mirrors: local accounts are fully entitled whenever the plugin is
780
+ // bound (so a flags-on local run keeps working), a free cloud account gets the same 402
781
+ // shape as the plugin routes. Everything else that WRITES on this resource (classification,
782
+ // identity, role) stays free — this is still the only bot-reviewers WRITE behind the check.
783
+ //
784
+ // ⚠ THE READ SIDE IS FOUR ROUTES, NOT ONE. The listing, the PATCH and both resets all echo a
785
+ // `WorkspaceReviewer`, so all four run `stripCost` (see its note) — otherwise the free,
786
+ // body-less `DELETE …/identity` is a perfectly good read path for every price this route
787
+ // refuses to write. The ONE deliberate exemption is the GDPR export, which is the subject's
788
+ // own data rather than a product surface.
789
+ if (!botDepthEntitled(req)) {
790
+ reply.status(402);
791
+ return PRO_REQUIRED;
792
+ }
793
+ const { userId } = req.params;
794
+ const { workspaceId, monthlyUsd, costModel } = req.body;
795
+ const reviewer = await setReviewerCost(accountIdOf(req), userId, workspaceId, monthlyUsd, costModel);
796
+ if (!reviewer) {
797
+ reply.status(404);
798
+ return {
799
+ error: 'NotFound',
800
+ message: `Reviewer ${userId} not found in workspace ${workspaceId}`,
801
+ };
802
+ }
803
+ const resp = reviewer;
804
+ return resp;
805
+ });
806
+ // Bot ROI / utilisation over a window (default rolling_14): per-vendor threads/acted-on/
807
+ // untouched + verdict + trend + deterministic tuning suggestions. Cost is SERVER-resolved from
808
+ // the workspace row — a null here is FINAL.
809
+ //
810
+ // ⚠ Never sum the price across workspaces. Within this one workspace's rows it is a plain sum.
811
+ // The authoring-automation vector: deterministic, no AI. `output: null` is the ONE degrade shape
812
+ // (unknown/foreign id, a HUMAN, or an automation that has never authored a PR in this workspace)
813
+ // so the route is not an existence oracle — the person route's posture.
814
+ //
815
+ // PAID ON THE UNION (`botDepth || periodReports`), like its two siblings. Its ONE consumer is the
816
+ // People report's bot sections (PeopleReportDetail's `useReportBotAuthoring` — the only call site
817
+ // in the SPA), which is now a locked pane without `periodReports`; and `?evidence=1` adds the
818
+ // receipt PR cards on top of the vector. `botDepth` alone would blank the bot half of a report a
819
+ // `periodReports` customer already paid for.
820
+ //
821
+ // The gate goes FIRST, ahead of `parseWindowBounds`, so the 400/402 split cannot be read as an
822
+ // oracle over which bounds the fold would have accepted.
823
+ app.get('/api/bot-authoring', { schema: authoringSchema }, async (req, reply) => {
824
+ if (!botAnalyticsEntitled(req)) {
825
+ reply.status(402);
826
+ return PRO_REQUIRED;
827
+ }
828
+ const { userId, workspace, repoIds, fromMs, toMs, evidence } = req.query;
829
+ // Reuse the shared validator so the bounds rules (ordered, span-capped) have ONE spelling
830
+ // across all three routes. The enum it refines is irrelevant here — the fold takes the pair.
831
+ const bounds = parseWindowBounds('rolling_14', fromMs, toMs);
832
+ if ('error' in bounds) {
833
+ reply.status(400);
834
+ return { error: 'BadRequest', message: bounds.error };
835
+ }
836
+ const accountId = accountIdOf(req);
837
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
838
+ const output = await getAutomationOutput(accountId, scope, Number(userId), { fromMs: Number(fromMs), toMs: Number(toMs) }, { evidence: evidence === '1' });
839
+ return {
840
+ workspaceId: scope.workspaceId,
841
+ window: { fromMs: Number(fromMs), toMs: Number(toMs) },
842
+ output,
843
+ };
844
+ });
845
+ app.get('/api/bot-analytics', { schema: analyticsSchema }, async (req, reply) => {
846
+ const { window, workspace, repoIds, fromMs, toMs } = req.query;
847
+ // Optional REAL bounds (the People report) refine the enum via the fold's widened
848
+ // apiVersion-18 window form; a malformed pair 400s (it names a population, never degrades).
849
+ const bounds = parseWindowBounds(window, fromMs, toMs);
850
+ if ('error' in bounds) {
851
+ reply.status(400);
852
+ return { error: 'BadRequest', message: bounds.error };
853
+ }
854
+ const accountId = accountIdOf(req);
855
+ // ONE object drives both halves: `workspaceId` decides who counts as a bot, `repoIds` narrows
856
+ // which data is measured — and the narrowing is already bounded by the workspace's membership,
857
+ // so the two cannot describe different sets of repos.
858
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
859
+ const entitled = botAnalyticsEntitled(req);
860
+ // The Inflation column's weekly history — folded only when the account can see the column it
861
+ // draws in, which since the whole table went paid means "whenever the rest of this response is
862
+ // populated". Kept as its own flag because it is an extra SCAN WIDTH inside the getter (the
863
+ // trend span), not just a field to drop.
864
+ const analytics = await getBotAnalytics(accountId, bounds.window, scope, {
865
+ inflationHistory: entitled,
866
+ });
867
+ if (entitled)
868
+ return analytics;
869
+ // ── THE NARROWED (unentitled) SHAPE ───────────────────────────────────────────────────────
870
+ // ⚠ THIS ROUTE IS NOT 402-ABLE, and that is the single most important thing to know before
871
+ // editing it. One response feeds FOUR consumers on two tiers: the paid ROI panel and its
872
+ // drill-downs, the paid People report's bot sections — and the FREE amber "Only a bot reviewed
873
+ // N open PRs" governance caution, which lives in BotsView OUTSIDE the panel and reads nothing
874
+ // but `totals.botOnlyPrs`. A blanket 402 would make that caution silently vanish (the client
875
+ // reads `?? 0`, so there is no error anywhere) and would re-fire every five minutes on the
876
+ // hook's sync-cadence poll. So the paid POPULATION is withheld and the free verdicts stay.
877
+ //
878
+ // The fold above still RUNS for an unentitled account — it is where `botOnlyPrs` and
879
+ // `suggestions` come from, so there is nothing cheaper to call and this costs exactly what the
880
+ // route cost before the gate. Only `inflationHistory` (an extra scan WIDTH, not a field) is
881
+ // actually skipped.
882
+ //
883
+ // WHAT SURVIVES, and why each one:
884
+ // • `totals.botOnlyPrs` + `overdueGraceMs` — the free governance caution and its wording.
885
+ // • `suggestions` — the free amber tuning-suggestions box, which is hoisted OUT of the panel
886
+ // into BotsView's free area precisely so this narrowing can keep feeding it.
887
+ // WHAT IS WITHHELD: `vendors` (the ROI table: verdicts, noise ratios, PRICES), `qualityChecks`,
888
+ // the whole `ml` block (the flagging strip), and the ROI half of `totals`.
889
+ //
890
+ // ⚠ THE ZEROS IN `totals` ARE A WIRE ARTEFACT, NOT DATA. `vendors`, `suggestions` and `totals`
891
+ // are REQUIRED on `BotAnalyticsResponse` (`ml`/`qualityChecks` are optional, which is why those
892
+ // two are simply absent — the honest spelling). Nothing free renders these numbers, and the SPA
893
+ // decides what to draw from its own `/api/me` capability rather than by sniffing the payload,
894
+ // so no screen can read a zero as a measurement. `actedOnPct` is already nullable, so it takes
895
+ // the honest `null` rather than a 0%.
896
+ //
897
+ // ⚠ MAKING THE THREE OPTIONAL IN `packages/shared` WAS CONSIDERED AND DEFERRED, deliberately.
898
+ // It would let "not entitled" and "nothing happened" differ on the wire, which is the tidier
899
+ // contract — but `BotAnalyticsResponse.totals`/`.vendors`/`.suggestions` are read by the PRIVATE
900
+ // plugin submodule too (`packages/pro/src/insights/chat.ts` grounds on all three), so widening
901
+ // them is a two-repository, gitlink-coordinated edit for a distinction NOTHING currently reads:
902
+ // every consumer is contractually forbidden from sniffing this payload for entitlement. Revisit
903
+ // it with the next apiVersion bump, when the plugin half is already being touched. The ambiguity
904
+ // is pinned in bot-triage-entitlement.test.ts so it stays a known shape rather than a surprise.
905
+ const resp = {
906
+ enabled: analytics.enabled,
907
+ generatedAt: analytics.generatedAt,
908
+ window: analytics.window,
909
+ vendors: [],
910
+ totals: {
911
+ threads: 0,
912
+ comments: 0,
913
+ actedOn: 0,
914
+ actedOnPct: null,
915
+ untouched: 0,
916
+ botOnlyPrs: analytics.totals.botOnlyPrs,
917
+ overdueGraceMs: analytics.totals.overdueGraceMs,
918
+ overlapClusters: 0,
919
+ },
920
+ suggestions: analytics.suggestions,
921
+ };
922
+ return resp;
923
+ });
924
+ // The bot BEHAVIOUR analytics route MOVED to the plugin (`GET /api/pro/bot-behaviour`,
925
+ // packages/pro/src/bots/behaviour-routes.ts) behind the `botDepth` entitlement — the
926
+ // periodReports precedent: the compute stays CORE (db/queries.ts getBotBehaviourAnalytics,
927
+ // reached via the ProHostQueries.getBotBehaviour seam), but there is no free surface for it.
928
+ // The exact PR list behind the analytics totals.botOnlyPrs count — "only a bot reviewed these".
929
+ // Same window/scope resolution as /api/bot-analytics so the amber caption's number and this
930
+ // expandable list are computed identically and can't drift. Unbounded but small (real bot-only
931
+ // PRs); no pagination.
932
+ app.get('/api/bot-analytics/bot-only-prs', { schema: analyticsSchema }, async (req) => {
933
+ const { window, workspace, repoIds } = req.query;
934
+ const accountId = accountIdOf(req);
935
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
936
+ const { window: win, prs } = await getBotOnlyPrs(accountId, window, scope);
937
+ const resp = { window: win, prs, generatedAt: new Date().toISOString() };
938
+ return resp;
939
+ });
940
+ // The per-REVIEWER PR drill-down behind one Bot-ROI row: the PRs that one automated reviewer
941
+ // touched in the window (threads/comments/acted-on/untouched/bot-only), newest-activity first.
942
+ // `key` is the analytics row identity — `u<userId>` (a single reviewer) or the 'pierre' sentinel;
943
+ // anything else is a client bug → 400.
944
+ //
945
+ // PAID (`botDepth`). It reproduces ONE ROW of the paid table and has no other opener, so unlike
946
+ // /api/bot-analytics there is no free half to preserve: a 402 is the honest answer rather than an
947
+ // empty list, which would read as "this bot touched nothing".
948
+ app.get('/api/bot-analytics/vendor/:key/prs', { schema: vendorPrsSchema }, async (req, reply) => {
949
+ if (!botDepthEntitled(req)) {
950
+ reply.status(402);
951
+ return PRO_REQUIRED;
952
+ }
953
+ const { key } = req.params;
954
+ const { window, workspace, repoIds } = req.query;
955
+ const m = /^u(\d+)$/.exec(key);
956
+ const target = m
957
+ ? { userId: Number(m[1]) }
958
+ : key === 'pierre'
959
+ ? { kind: 'pierre' }
960
+ : null;
961
+ if (!target) {
962
+ reply.status(400);
963
+ return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
964
+ }
965
+ const accountId = accountIdOf(req);
966
+ // The SAME scope the ROI row was computed at — this list reproduces one of that panel's rows,
967
+ // so the header label and the per-PR `botOnly` badge must take the identical workspace and
968
+ // repo set. One screen cannot show two contradictory bot-only answers.
969
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
970
+ const resp = await getBotVendorPrs(accountId, target, window, scope);
971
+ return resp;
972
+ });
973
+ // The per-REVIEWER COMMENTS drill-down behind the same Bot-ROI row: everything the reviewer
974
+ // said in the window (inline review comments, PR comments, review bodies), each row with its
975
+ // stored ML label INLINE — one response, never the per-PR label index per row. Same key parse
976
+ // and the same scope resolution as the /prs sibling, so the list reproduces the same ROI row;
977
+ // the 'pierre' sentinel answers empty (verbatim reviews are human-posted — no per-comment
978
+ // rows to attribute). ⚠ Rate tier `search`, pinned in rate-limit.test.ts — this ships up to
979
+ // 3000 comment BODIES per source plus a three-way label join per request.
980
+ //
981
+ // PAID ON THE UNION (`botDepth || periodReports`) — one of the two routes with two paid owners:
982
+ // the ROI table's comments drill-down AND every bot section of the People report's per-bot
983
+ // evidence cards. Gating it on `botDepth` alone would blank evidence a `periodReports` customer
984
+ // had already bought. 402 rather than an empty list, for the /prs sibling's reason: `comments:
985
+ // []` is indistinguishable from "this bot said nothing in the window", which is a claim.
986
+ app.get('/api/bot-analytics/vendor/:key/comments', { schema: vendorCommentsSchema }, async (req, reply) => {
987
+ if (!botAnalyticsEntitled(req)) {
988
+ reply.status(402);
989
+ return PRO_REQUIRED;
990
+ }
991
+ const { key } = req.params;
992
+ const { window, workspace, repoIds, fromMs, toMs } = req.query;
993
+ const m = /^u(\d+)$/.exec(key);
994
+ const target = m
995
+ ? { userId: Number(m[1]) }
996
+ : key === 'pierre'
997
+ ? { kind: 'pierre' }
998
+ : null;
999
+ if (!target) {
1000
+ reply.status(400);
1001
+ return { error: 'BadRequest', message: `Invalid vendor key: ${key}` };
1002
+ }
1003
+ // Same optional real-bounds refinement as /api/bot-analytics (the People report's per-bot
1004
+ // evidence cards) — a malformed pair 400s.
1005
+ const bounds = parseWindowBounds(window, fromMs, toMs);
1006
+ if ('error' in bounds) {
1007
+ reply.status(400);
1008
+ return { error: 'BadRequest', message: bounds.error };
1009
+ }
1010
+ const accountId = accountIdOf(req);
1011
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
1012
+ const resp = await getBotVendorComments(accountId, target, bounds.window, scope);
1013
+ return resp;
1014
+ });
1015
+ // ── "WHAT THE BOTS ARE FLAGGING" ──────────────────────────────────────────────────────────
1016
+ // The drill-down behind every tile and chip on the Bots rail's ML strip. `select` picks the
1017
+ // population; the response is discriminated on `kind` — 'comments' for the four label
1018
+ // selectors, 'clusters' for same-line overlap (deterministic line clusters, not ML rows).
1019
+ //
1020
+ // ⚠ `total` IS THE TILE'S NUMBER, BY CONSTRUCTION — not an independently-derived count that
1021
+ // happens to agree. The strip's buckets are a JS fold (`foldMlLabelRow`) over a JSON
1022
+ // `categories` column that no portable SQL predicate can express, so a `COUNT(*) WHERE
1023
+ // severity IN (…)` could never match it: it would count summaries and praise, could not
1024
+ // express praise at all, and would ignore both `coerceSeverity` failures and the scan cap's
1025
+ // newest-first truncation. The getters therefore re-run the strip's identical scan and
1026
+ // identical fold and then slice. Nothing on either side of this route re-derives a count —
1027
+ // note in particular that the client's `pillOf` tests praise BEFORE isSummary, the opposite
1028
+ // of the backend, so a client-side recount would disagree with the tile it drilled into.
1029
+ //
1030
+ // ⚠ Rate tier `search`, pinned in rate-limit.test.ts, and that predicate is anchored on this
1031
+ // EXACT path — spelling this route with a sub-path or a path parameter would silently drop it
1032
+ // back onto the 600/min blanket bucket with no error anywhere. Per request it re-runs the
1033
+ // strip's whole 50k-row label scan (the page offset is a JS slice over the fold) or the
1034
+ // window's whole thread scan plus ±3-line clustering, then hydrates a page of comment BODIES —
1035
+ // and an IntersectionObserver fires it again on every scroll.
1036
+ //
1037
+ // PAID (`botDepth`). Its only openers are the ROI panel's ML strip tiles and the Inflation
1038
+ // cells, both inside the locked panel — and the gate goes FIRST, above the selector parsing, so
1039
+ // an unentitled caller cannot use the 400/200 split as an oracle over which selectors exist.
1040
+ app.get('/api/bot-analytics/flagging', { schema: flaggingSchema }, async (req, reply) => {
1041
+ if (!botDepthEntitled(req)) {
1042
+ reply.status(402);
1043
+ return PRO_REQUIRED;
1044
+ }
1045
+ const q = req.query;
1046
+ // The selector and the refinement are assembled BEFORE any DB work: every failure below is a
1047
+ // client bug, and there is nothing to resolve a scope for in a request that cannot name a
1048
+ // population.
1049
+ let selector;
1050
+ if (q.select === 'severity') {
1051
+ // ⚠ AN UNKNOWN OR EMPTY SEVERITY LIST 400s, unlike `?workspace=`. The difference is who
1052
+ // minted the value: a workspace id can be a stale bookmark and must degrade to something
1053
+ // renderable, whereas this list is written by the tile that opened the tab. Silently
1054
+ // dropping an unrecognised class would name a DIFFERENT population than the tile did and
1055
+ // the list would quietly disagree with the number the user clicked.
1056
+ const raw = (q.severities ?? '')
1057
+ .split(',')
1058
+ .map((s) => s.trim())
1059
+ .filter((s) => s !== '');
1060
+ const severities = raw.filter((s) => FLAGGING_SEVERITY_VALUES.includes(s));
1061
+ if (severities.length === 0 || severities.length !== raw.length) {
1062
+ reply.status(400);
1063
+ return {
1064
+ error: 'BadRequest',
1065
+ message: `\`severities\` must be a comma-separated subset of ${FLAGGING_SEVERITY_VALUES.join(', ')}`,
1066
+ };
1067
+ }
1068
+ selector = { kind: 'severity', severities };
1069
+ }
1070
+ else if (q.select === 'category') {
1071
+ // ajv has already rejected an unknown category VALUE, so only absence can reach here.
1072
+ if (!q.category) {
1073
+ reply.status(400);
1074
+ return { error: 'BadRequest', message: '`select=category` requires `category`' };
1075
+ }
1076
+ selector = { kind: 'category', category: q.category };
1077
+ }
1078
+ else if (q.select === 'summaries') {
1079
+ selector = { kind: 'summaries' };
1080
+ }
1081
+ else if (q.select === 'overlap') {
1082
+ selector = { kind: 'overlap' };
1083
+ }
1084
+ else {
1085
+ selector = { kind: 'findings' };
1086
+ }
1087
+ // A confusion-matrix cell is a PAIR; half of one names no cell. Rejected rather than
1088
+ // silently ignored, because a filter that appears to be applied and is not is the worse
1089
+ // failure mode — the user reads an unfiltered list as the filtered one.
1090
+ let cell = null;
1091
+ if (q.cellVendor && q.cellOurs) {
1092
+ cell = { vendor: q.cellVendor, ours: q.cellOurs };
1093
+ }
1094
+ else if (q.cellVendor || q.cellOurs) {
1095
+ reply.status(400);
1096
+ return {
1097
+ error: 'BadRequest',
1098
+ message: '`cellVendor` and `cellOurs` must be sent together or not at all',
1099
+ };
1100
+ }
1101
+ // ⚠ NO OWNERSHIP CHECK ON `authorUserIds`, and that is not an oversight: `users` is a GLOBAL
1102
+ // table, so "does this account own that user row" is not a question it can answer. The
1103
+ // narrowing is applied as a predicate over an already accountId- and scope-filtered label
1104
+ // scan, so a foreign or nonexistent id matches nothing — an empty list, identical in both
1105
+ // cases, which is what stops it being an existence oracle.
1106
+ //
1107
+ // ⚠ PRESENT-BUT-EMPTY IS "NO BOTS", NEVER "EVERY BOT" — the `repoIds` rule (`if (ids)`, never
1108
+ // `ids.length > 0`). `parseIntList` collapses absence AND an unusable list to null, which is
1109
+ // right for a repo NARROW (null = don't narrow) and wrong here: a caller that computed an
1110
+ // empty bot set and sent it would get the whole workspace back under a caption promising a
1111
+ // subset — the very failure this parameter was widened to prevent. So the KEY's presence
1112
+ // decides whether there is a narrowing at all, and `parseIntList` only parses its members.
1113
+ const authorUserIds = q.authorUserIds == null ? null : (parseIntList(q.authorUserIds) ?? []);
1114
+ const refine = {
1115
+ cell,
1116
+ disagree: q.disagree ?? null,
1117
+ authorUserIds,
1118
+ };
1119
+ // ⚠ THE CURSOR DEGRADES, IT NEVER 400s — the opposite rule to `severities` above, and for the
1120
+ // opposite reason: it is server-minted state a client only ever echoes back, and it is OPAQUE
1121
+ // on the wire precisely so its encoding can change (a later keyset switch) without breaking
1122
+ // in-flight clients. Today it carries an offset into the folded population. Anything that is
1123
+ // not exactly that — a stale shape, a value past the end, an unsafe integer — starts again at
1124
+ // the first page, which is always a renderable answer.
1125
+ const m = /^o:(\d+)$/.exec(q.cursor ?? '');
1126
+ const parsedOffset = m ? Number(m[1]) : 0;
1127
+ const offset = Number.isSafeInteger(parsedOffset) ? parsedOffset : 0;
1128
+ const accountId = accountIdOf(req);
1129
+ // The SAME scope the strip was measured at — this list reproduces one of that strip's tiles,
1130
+ // so it must take the identical workspace (who counts as a bot) and repo narrowing (which
1131
+ // data is measured), bounded by the workspace's membership inside the resolver. A `BotScope`
1132
+ // is only ever built here; `parseIntList`'s result is the `narrow` argument, never a scope.
1133
+ const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
1134
+ const page = { offset, limit: q.limit };
1135
+ const resp = selector.kind === 'overlap'
1136
+ ? await getBotOverlapClusters(accountId, refine, q.window, scope, page)
1137
+ : await getBotFlaggingComments(accountId, selector, refine, q.window, scope, page);
1138
+ return resp;
1139
+ });
1140
+ // ── Bot comment VOLUME — the three routes over db/bot-volume.ts's one base scan ───────────
1141
+ //
1142
+ // GET /api/bot-analytics/volume — the ROI tab's "avg bot comments per PR" column + workspace
1143
+ // totals. The two averages differ ONLY by denominator (`avgCommentsPerCommentedPr` divides by
1144
+ // the PRs that bot touched, `avgCommentsPerScopePr` by every merged PR in scope) and on a quiet
1145
+ // repo they differ by ~6× — the field names carry that, and so must the column header.
1146
+ //
1147
+ // ⚠ Rate tier `search`, pinned in rate-limit.test.ts. Per request it walks every merged PR in
1148
+ // the window (up to 5000) plus three grouped comment counts over them, then folds in JS. No
1149
+ // GitHub, no model — this process's event loop, the flagging-drill-down shape of cost — so it
1150
+ // borrows the same 60/min bucket rather than the 600/min blanket one.
1151
+ //
1152
+ // ── ALL THREE VOLUME ROUTES ARE PAID (`botDepth`) ─────────────────────────────────────────
1153
+ // Their only consumers are the ROI table's "Comments/PR" column, that column's drill-down tab,
1154
+ // and the `botDepth`-gated WorkspaceBotCharts scatter — no free surface reads any of them, so
1155
+ // there is nothing here to narrow and a 402 is the whole gate. The scatter in particular was the
1156
+ // file's one CLIENT-ONLY gate (component gated, route open); it is closed in the same pass,
1157
+ // because "the component that draws it is hidden" has never been a gate.
1158
+ app.get('/api/bot-analytics/volume', { schema: volumeScopeSchema }, async (req, reply) => {
1159
+ if (!botDepthEntitled(req)) {
1160
+ reply.status(402);
1161
+ return PRO_REQUIRED;
1162
+ }
1163
+ const q = req.query;
1164
+ const accountId = accountIdOf(req);
1165
+ const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
1166
+ const resp = await getBotVolume(accountId, q.window, scope);
1167
+ return resp;
1168
+ });
1169
+ // GET /api/bot-analytics/volume/prs — the paginated drill-down behind that column. Default sort
1170
+ // is raw `botComments` DESC; `sort=ratio` ranks by the bucket-relative expectation, which is
1171
+ // the only ordering that surfaces a small PR a bot tore apart (raw count mostly ranks by size).
1172
+ app.get('/api/bot-analytics/volume/prs', { schema: volumePrsSchema }, async (req, reply) => {
1173
+ if (!botDepthEntitled(req)) {
1174
+ reply.status(402);
1175
+ return PRO_REQUIRED;
1176
+ }
1177
+ const q = req.query;
1178
+ const accountId = accountIdOf(req);
1179
+ // ⚠ PRESENT-BUT-EMPTY IS "NO BOTS", NEVER "EVERY BOT" — the `repoIds` rule (`if (ids)`, never
1180
+ // `ids.length > 0`), identical to the flagging route above. `parseIntList` collapses absence
1181
+ // AND an unusable list to null, which is right for a repo NARROW and wrong here: a caller
1182
+ // that computed an empty bot set and sent it would get the whole workspace back under a
1183
+ // caption promising a subset. So the KEY's presence decides whether there is a narrowing at
1184
+ // all, and `parseIntList` only parses its members.
1185
+ //
1186
+ // ⚠ NO OWNERSHIP CHECK, and that is not an oversight: `users` is a GLOBAL table, so "does this
1187
+ // account own that user row" is not a question it can answer. The narrowing is applied over an
1188
+ // already accountId- and scope-filtered fold, so a foreign or nonexistent id matches nothing —
1189
+ // an empty list, identical in both cases, which is what stops it being an existence oracle.
1190
+ const authorUserIds = q.authorUserIds == null ? null : (parseIntList(q.authorUserIds) ?? []);
1191
+ const refine = { authorUserIds };
1192
+ // ⚠ THE CURSOR DEGRADES, IT NEVER 400s — server-minted state a client only ever echoes back,
1193
+ // opaque precisely so its encoding can change without breaking in-flight clients. Anything
1194
+ // that is not exactly `o:<n>` starts again at the first page, which is always renderable.
1195
+ const m = /^o:(\d+)$/.exec(q.cursor ?? '');
1196
+ const parsedOffset = m ? Number(m[1]) : 0;
1197
+ const offset = Number.isSafeInteger(parsedOffset) ? parsedOffset : 0;
1198
+ const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
1199
+ const resp = await getPrBotVolume(accountId, q.window, scope, refine, {
1200
+ offset,
1201
+ limit: q.limit,
1202
+ sort: q.sort,
1203
+ });
1204
+ return resp;
1205
+ });
1206
+ // GET /api/bot-analytics/volume/scatter — the Behaviour tab's LOC-vs-volume chart: one point per
1207
+ // SIZED merged PR (capped at 2000, newest first) plus the five bucket means over the WHOLE
1208
+ // scope. The buckets are what make the chart readable — size is sublinear in comments, so the
1209
+ // expectation curve bends and the naive "comments per 100 LOC" reading is the one to avoid.
1210
+ app.get('/api/bot-analytics/volume/scatter', { schema: volumeScopeSchema }, async (req, reply) => {
1211
+ if (!botDepthEntitled(req)) {
1212
+ reply.status(402);
1213
+ return PRO_REQUIRED;
1214
+ }
1215
+ const q = req.query;
1216
+ const accountId = accountIdOf(req);
1217
+ const scope = await resolveWorkspaceScope(accountId, q.workspace, parseIntList(q.repoIds));
1218
+ const resp = await getBotVolumeScatter(accountId, q.window, scope);
1219
+ return resp;
1220
+ });
1221
+ // Cross-bot dedup clusters for one PR (≥2 automated reviewers on the same path/line window).
1222
+ // Ownership-scoped → 404 for a PR this account doesn't own. No `?workspace=`: the getter derives
1223
+ // the judgement scope from the PR's own repo, which is the only workspace that can be right for
1224
+ // it — a caller-supplied one could measure this PR's threads under another workspace's verdicts.
1225
+ app.get('/api/prs/:id/bot-dedup', { schema: prIdParamSchema }, async (req, reply) => {
1226
+ const { id } = req.params;
1227
+ const resp = await getBotDedupClusters(id, accountIdOf(req));
1228
+ if (!resp) {
1229
+ reply.status(404);
1230
+ return { error: 'NotFound', message: `PR ${id} not found` };
1231
+ }
1232
+ return resp;
1233
+ });
1234
+ // The workspace-wide review list: every PR with ≥1 `likely_addressed` automated-reviewer thread
1235
+ // in the scope, UNCAPPED, newest-thread-first, each row carrying all its resolvable thread ids +
1236
+ // a bot thread-state mix + `totalThreads` (the whole backlog). The client sorts / paginates /
1237
+ // "Select all"s across pages and chunks the resolve. Read-only.
1238
+ app.get('/api/bot-threads/resolvable', { schema: resolvableSchema }, async (req) => {
1239
+ const { workspace, repoIds } = req.query;
1240
+ const accountId = accountIdOf(req);
1241
+ const scope = await resolveWorkspaceScope(accountId, workspace, parseIntList(repoIds));
1242
+ const { prs, totalThreads } = await getResolvableBotThreadPrs(accountId, scope);
1243
+ const resp = {
1244
+ prs,
1245
+ totalThreads,
1246
+ generatedAt: new Date().toISOString(),
1247
+ };
1248
+ return resp;
1249
+ });
1250
+ // The confirm-gated workspace-wide resolve. NEVER blind: the server RE-DERIVES the eligible set
1251
+ // (owned + automated-reviewer-originated + `likely_addressed` + unresolved) ∩ the client's
1252
+ // explicit reviewed ids, under the workspace named in the body, then resolves each via the SAME
1253
+ // shared helper the per-PR route uses. An empty list is a no-op (not an error); per-thread
1254
+ // failures are reported, not fatal. The re-derive path passes `threadIds` so the page cap is
1255
+ // bypassed — no requested-and-eligible id is silently dropped.
1256
+ app.post('/api/bot-threads/resolve', { schema: scopeResolveSchema }, async (req) => {
1257
+ const { threadIds, workspaceId } = req.body;
1258
+ const accountId = accountIdOf(req);
1259
+ if (threadIds.length === 0) {
1260
+ const noop = { resolved: 0, failed: 0, results: [] };
1261
+ return noop;
1262
+ }
1263
+ // The listing and the resolve now derive the judgement from THE SAME workspace id, so they
1264
+ // cannot evaluate different rules. Under the team grain the listing was team-resolved while
1265
+ // this re-derive ran at the account default, and a reviewer marked automated only under a
1266
+ // per-team override had its threads offered and then found ineligible — the route resolved 0.
1267
+ // No `repoIds` narrowing here on purpose: the resolve acts on ids the user explicitly ticked,
1268
+ // and narrowing them a second time could only silently drop some of them.
1269
+ //
1270
+ // `workspaceId` goes through the SCOPE resolver, not an ownership 404, because here it names a
1271
+ // scope rather than a row to write: an unknown or foreign id degrades to the caller's Default
1272
+ // like every other read scope, and the worst it can do is re-derive fewer eligible threads.
1273
+ // The resolver only ever returns a workspace the caller owns, so it can never reach another
1274
+ // tenant.
1275
+ const scope = await resolveWorkspaceScope(accountId, workspaceId);
1276
+ const { threads: eligible } = await getResolvableBotThreadsForScope(accountId, scope, threadIds);
1277
+ const result = await resolveThreadsOnGitHub(accountId, eligible.map((t) => ({ id: t.threadId, threadNodeId: t.threadNodeId, prId: t.prId })));
1278
+ req.log.info({
1279
+ accountId,
1280
+ workspaceId: scope.workspaceId,
1281
+ requested: threadIds.length,
1282
+ eligible: eligible.length,
1283
+ resolved: result.resolved,
1284
+ failed: result.failed,
1285
+ }, 'workspace-wide bot-thread resolve');
1286
+ return result;
1287
+ });
1288
+ }
1289
+ //# sourceMappingURL=bot-triage.js.map