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,1081 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { and, eq } from 'drizzle-orm';
5
+ import { config } from '../config.js';
6
+ import { getAccessToken } from '../auth/account.js';
7
+ import { db, schema } from '../db/client.js';
8
+ import { hasConflictMarkers } from '../coding/merge.js';
9
+ import { fetchPrHeadInfo } from '../github/mutations.js';
10
+ import { cleanupCloneCache, ensureClone, fetchRefIntoClone, withRepoLock, } from '../review/clone-manager.js';
11
+ import { gitSupportsMergeTree, gitTry } from './git.js';
12
+ import { diffTokens, threeWayChunks } from './diff.js';
13
+ import { parseMergeTree, stripMangledSuffix } from './parse.js';
14
+ import { regionFingerprint } from './hash.js';
15
+ import { TokenInterner, internAll, isWhitespaceToken, splitLines, tokenizeWords, } from './tokens.js';
16
+ const { pullRequests, repos } = schema;
17
+ /**
18
+ * THE CONFLICT MODEL BUILDER.
19
+ *
20
+ * ⚠ NO WORKTREE. NONE, ANYWHERE, AT ANY PHASE. `merge-tree --write-tree` performs a full
21
+ * three-way merge with no index and no working tree, every other command here is
22
+ * worktree-free, and the oracle runs `merge-file` in `os.tmpdir()` over three loose files.
23
+ * That makes the resolver immune to the worktree defect classes rather than dependent on
24
+ * their fix, and it is why a session costs nothing to hold open.
25
+ *
26
+ * ⚠ NO GIT 2.40+ FLAGS. No `merge-tree --merge-base=`, no `-X ours|theirs`, no
27
+ * `merge-file --object-id`. v1 is local-only and the dev machine is on 2.54, but the cloud
28
+ * image is 2.39.5 where all three exit 129 — a later port must not be a rewrite. The one
29
+ * version floor we do have is 2.38 for `--write-tree`, probed once and refused as
30
+ * `git_too_old` at the open route.
31
+ *
32
+ * The lock is held for the GIT phase only (seconds; merge-tree itself is 17ms). The oracle
33
+ * and the region folding hold nothing.
34
+ */
35
+ /** Above this, listing the files is not help — it is a wall of text. Distinct from
36
+ * `conflictMaxFiles`, which degrades the extras to `budget_exhausted` and still lists them. */
37
+ const HARD_CONFLICTED_PATH_CEILING = 1000;
38
+ /** `git fetch` takes the oids as argv, so they go in batches rather than one 2000-arg line. */
39
+ const PREFETCH_BATCH = 200;
40
+ /* ═════════════════════════════ the UTF-8 gate ═════════════════════════════ */
41
+ // `ignoreBOM: true` KEEPS a leading U+FEFF as a character instead of swallowing it, so the
42
+ // re-encode at land time writes the same bytes back. Dropping it would silently strip the BOM
43
+ // from every file that has one.
44
+ const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
45
+ /**
46
+ * Strict decode, or nothing.
47
+ *
48
+ * ⚠ `Buffer.includes(0)` IS NOT SUFFICIENT to call something text. `[63 61 66 e9 0a]` — "caf"
49
+ * plus a Latin-1 é — has no NUL, so a naive check calls it text; `.toString('utf8')` yields
50
+ * `caf�` and re-encoding writes `EF BF BD`. That corrupts the WHOLE file, not just the
51
+ * region the user touched, because the land path replaces the entire path with the fold
52
+ * output. We refuse rather than guess an encoding.
53
+ */
54
+ export function decodeStrict(buf) {
55
+ try {
56
+ return STRICT_UTF8.decode(buf);
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /* ═════════════════════════════ classification ═════════════════════════════ */
63
+ /** The label under an unresolvable file. A NOUN PHRASE — no instruction, no full stop. The
64
+ * panel states "N files need resolving on GitHub." once, above the list. */
65
+ export const UNSUPPORTED_LABELS = {
66
+ binary: 'Binary file',
67
+ submodule: 'Submodule',
68
+ symlink: 'Symlink',
69
+ file_directory: 'A directory of the same name',
70
+ rename_rename: 'Renamed differently on each side',
71
+ rename_delete: 'Renamed on one side, deleted on the other',
72
+ modify_delete: 'Changed on one side, deleted on the other',
73
+ mode_change: 'File mode changed',
74
+ not_text: 'Not UTF-8 text',
75
+ too_large: 'Too large to resolve here',
76
+ too_many_conflicts: 'Too many conflicts to resolve here',
77
+ no_common_ancestor: 'No common ancestor',
78
+ engine_disagreement: 'Git splits this file differently',
79
+ budget_exhausted: 'Past this session’s limit',
80
+ };
81
+ const REGULAR_MODES = new Set(['100644', '100755']);
82
+ /**
83
+ * Classify a path from merge-tree's records.
84
+ *
85
+ * ⚠ PRECEDENCE-ORDERED OVER ALL RECORDS FOR THE PATH, NEVER A FIRST MATCH. A binary conflict
86
+ * emits BOTH `CONFLICT (binary)` and `CONFLICT (contents)`; a gitlink emits both
87
+ * `CONFLICT (submodule not initialized)` and `CONFLICT (contents)`. Taking the first record
88
+ * wins the race for whichever git happened to print first, and a binary file classified as
89
+ * text is a file we would offer to rewrite as UTF-8.
90
+ *
91
+ * Returns `null` for "text, resolvable here" — the blob-level NUL and UTF-8 checks happen
92
+ * afterwards, once the bytes are in hand.
93
+ */
94
+ export function classifyPath(rec) {
95
+ const modes = [...rec.stages.values()].map((s) => s.mode);
96
+ if (modes.includes('160000'))
97
+ return 'submodule';
98
+ if (rec.types.has('CONFLICT (binary)'))
99
+ return 'binary';
100
+ if (rec.types.has('CONFLICT (distinct modes)') || modes.includes('120000'))
101
+ return 'symlink';
102
+ if (rec.types.has('CONFLICT (file/directory)'))
103
+ return 'file_directory';
104
+ if (rec.types.has('CONFLICT (rename/rename)'))
105
+ return 'rename_rename';
106
+ if (rec.types.has('CONFLICT (rename/delete)'))
107
+ return 'rename_delete';
108
+ if (rec.types.has('CONFLICT (modify/delete)'))
109
+ return 'modify_delete';
110
+ const s2 = rec.stages.get(2);
111
+ const s3 = rec.stages.get(3);
112
+ if (s2 && s3 && s2.mode !== s3.mode) {
113
+ return REGULAR_MODES.has(s2.mode) && REGULAR_MODES.has(s3.mode) ? 'mode_change' : 'symlink';
114
+ }
115
+ if (!s2 || !s3)
116
+ return 'modify_delete';
117
+ if (!rec.types.has('CONFLICT (contents)'))
118
+ return 'engine_disagreement';
119
+ if (!REGULAR_MODES.has(s2.mode))
120
+ return 'symlink';
121
+ // An ADD/ADD carries stages 2 and 3 with NO stage 1 (git types it `CONFLICT (contents)`
122
+ // and words the message `CONFLICT (add/add)`). We could model it with an empty base, but
123
+ // then "keep the ancestor" means DELETE THE FILE, offered as a plain third button beside
124
+ // the two versions. It is refused by name instead.
125
+ const s1 = rec.stages.get(1);
126
+ if (!s1)
127
+ return 'no_common_ancestor';
128
+ return null;
129
+ }
130
+ /**
131
+ * The wand's word-level merge for ONE contested region: the same three-way chunker, run over
132
+ * word tokens instead of lines.
133
+ *
134
+ * The disjointness bar, and every clause of it earns its place:
135
+ * • no `conflict` chunk at word grain — the two edits must not touch the same words;
136
+ * • between two consecutive changed chunks of OPPOSITE sides, the intervening stable run
137
+ * must hold at least one NON-WHITESPACE token. `foo(a, b)` with one side on `a` and one
138
+ * on `b` is disjoint; `foo bar` is not — a single space is not evidence two authors were
139
+ * working on different things;
140
+ * • neither word diff exceeded its budget, and the token count is under the cap.
141
+ *
142
+ * Then three mechanical checks before we hand back a merge: no conflict marker survived,
143
+ * every changed run is present in order, every stable run is unchanged. Returns null on any
144
+ * failure — the region simply gets no wand, which is the safe outcome.
145
+ */
146
+ export function disjointWordMerge(baseText, oursText, theirsText) {
147
+ const baseTok = tokenizeWords(baseText);
148
+ const oursTok = tokenizeWords(oursText);
149
+ const theirsTok = tokenizeWords(theirsText);
150
+ const total = baseTok.length + oursTok.length + theirsTok.length;
151
+ if (total > config.conflictWordTokenCap)
152
+ return null;
153
+ const interner = new TokenInterner();
154
+ const b = internAll(baseTok, interner);
155
+ const o = internAll(oursTok, interner);
156
+ const t = internAll(theirsTok, interner);
157
+ const oursDiff = diffTokens(b, o, config.conflictWordDiffMaxD);
158
+ const theirsDiff = diffTokens(b, t, config.conflictWordDiffMaxD);
159
+ if (!oursDiff || !theirsDiff)
160
+ return null;
161
+ const chunks = threeWayChunks(b, o, t, oursDiff, theirsDiff);
162
+ if (chunks.some((c) => c.kind === 'conflict'))
163
+ return null;
164
+ // The "opposite sides need real evidence between them" rule.
165
+ let lastChangedSide = null;
166
+ let stableSinceLastChange = [];
167
+ for (const c of chunks) {
168
+ if (c.kind === 'unchanged') {
169
+ stableSinceLastChange.push(...baseTok.slice(c.baseStart, c.baseEnd));
170
+ continue;
171
+ }
172
+ const side = c.kind === 'ours_only' ? 'ours' : c.kind === 'theirs_only' ? 'theirs' : 'both';
173
+ if (side === 'both') {
174
+ // `both_same` needs no separation argument: neither author is being overruled.
175
+ lastChangedSide = null;
176
+ stableSinceLastChange = [];
177
+ continue;
178
+ }
179
+ if (lastChangedSide && lastChangedSide !== side) {
180
+ if (!stableSinceLastChange.some((tok) => !isWhitespaceToken(tok)))
181
+ return null;
182
+ }
183
+ lastChangedSide = side;
184
+ stableSinceLastChange = [];
185
+ }
186
+ // Assemble, then verify the assembly independently of the loop that produced it.
187
+ const merged = [];
188
+ const expected = [];
189
+ for (const c of chunks) {
190
+ const run = c.kind === 'theirs_only'
191
+ ? theirsTok.slice(c.theirsStart, c.theirsEnd)
192
+ : c.kind === 'unchanged'
193
+ ? baseTok.slice(c.baseStart, c.baseEnd)
194
+ : oursTok.slice(c.oursStart, c.oursEnd);
195
+ expected.push(run);
196
+ merged.push(...run);
197
+ }
198
+ let pos = 0;
199
+ for (const run of expected) {
200
+ for (const tok of run) {
201
+ if (merged[pos] !== tok)
202
+ return null;
203
+ pos++;
204
+ }
205
+ }
206
+ if (pos !== merged.length)
207
+ return null;
208
+ const mergedText = merged.join('');
209
+ // `MARKER_RE` deliberately ignores a bare `=======`, which real markdown contains.
210
+ if (hasConflictMarkers(mergedText))
211
+ return null;
212
+ return { lines: splitLines(mergedText).lines };
213
+ }
214
+ /**
215
+ * Build the region list for ONE text file from its three decoded sides.
216
+ *
217
+ * A diff that runs out of budget degrades this file to ONE conflict spanning everything —
218
+ * honest and still resolvable, rather than a wrong split.
219
+ */
220
+ export function buildFileRegions(baseLines, oursLines, theirsLines) {
221
+ const interner = new TokenInterner();
222
+ const b = internAll(baseLines, interner);
223
+ const o = internAll(oursLines, interner);
224
+ const t = internAll(theirsLines, interner);
225
+ const oursDiff = diffTokens(b, o, config.conflictDiffMaxD);
226
+ const theirsDiff = diffTokens(b, t, config.conflictDiffMaxD);
227
+ let chunks;
228
+ if (!oursDiff || !theirsDiff) {
229
+ chunks = [
230
+ {
231
+ kind: 'conflict',
232
+ baseStart: 0,
233
+ baseEnd: baseLines.length,
234
+ oursStart: 0,
235
+ oursEnd: oursLines.length,
236
+ theirsStart: 0,
237
+ theirsEnd: theirsLines.length,
238
+ },
239
+ ];
240
+ }
241
+ else {
242
+ chunks = threeWayChunks(b, o, t, oursDiff, theirsDiff);
243
+ }
244
+ const conflictCount = chunks.filter((c) => c.kind === 'conflict').length;
245
+ if (conflictCount > config.conflictMaxRegions) {
246
+ return { regions: [], unsupported: 'too_many_conflicts' };
247
+ }
248
+ const regions = chunks.map((c, i) => {
249
+ const base = baseLines.slice(c.baseStart, c.baseEnd);
250
+ // ⚠ On `unchanged`, ours and theirs are EMPTY — not copies of base. Three copies of every
251
+ // untouched line is what turns a 30-file conflict into a multi-megabyte model, and the
252
+ // fold reads `base` there anyway. Their LENGTHS are still recoverable from base, which is
253
+ // what the per-side line numbering and the oracle walk rely on.
254
+ const ours = c.kind === 'unchanged' ? [] : oursLines.slice(c.oursStart, c.oursEnd);
255
+ const theirs = c.kind === 'unchanged' ? [] : theirsLines.slice(c.theirsStart, c.theirsEnd);
256
+ const wandAndMerge = wandFor(c.kind, base, ours, theirs);
257
+ return {
258
+ id: i + 1,
259
+ kind: c.kind,
260
+ base,
261
+ ours,
262
+ theirs,
263
+ fingerprint: regionFingerprint(base, ours, theirs),
264
+ wand: wandAndMerge.wand,
265
+ mergedLines: wandAndMerge.mergedLines,
266
+ };
267
+ });
268
+ return { regions, unsupported: null };
269
+ }
270
+ function wandFor(kind, base, ours, theirs) {
271
+ switch (kind) {
272
+ case 'unchanged':
273
+ return { wand: null, mergedLines: null };
274
+ case 'ours_only':
275
+ return { wand: { decision: 'ours', reason: 'only_ours' }, mergedLines: null };
276
+ case 'theirs_only':
277
+ return { wand: { decision: 'theirs', reason: 'only_theirs' }, mergedLines: null };
278
+ case 'both_same':
279
+ return { wand: { decision: 'ours', reason: 'both_same' }, mergedLines: null };
280
+ case 'conflict': {
281
+ // Computed EAGERLY for every contested region: it is pure CPU over strings already in
282
+ // memory, it is what the wand button keys on, and a lazy second path could disagree
283
+ // with the first.
284
+ const merged = disjointWordMerge(joinRegion(base), joinRegion(ours), joinRegion(theirs));
285
+ if (!merged)
286
+ return { wand: null, mergedLines: null };
287
+ return {
288
+ wand: { decision: 'disjoint_merge', reason: 'disjoint_words' },
289
+ mergedLines: merged.lines,
290
+ };
291
+ }
292
+ }
293
+ }
294
+ /** A region's lines as one string for the word pass. The trailing newline is deliberate: it
295
+ * keeps line boundaries as real tokens, so a word-level merge cannot silently join two
296
+ * lines that were separate on both sides. */
297
+ function joinRegion(lines) {
298
+ return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
299
+ }
300
+ /**
301
+ * Parse `git merge-file -p --diff3` output into the OURS-side ranges git contested.
302
+ *
303
+ * ⚠ POSITIONS, NOT COUNTS. `merge-file`'s exit status is the conflict count CAPPED AT 127, so
304
+ * a file with 200 contested regions reports 127 and a count comparison silently passes.
305
+ *
306
+ * The ours section of each block is verbatim ours text, so each block is located by searching
307
+ * ours FORWARD from the previous block's end — monotonic, and a block we cannot place is a
308
+ * refusal rather than a guess. An empty ours section (both sides added at the same point) is
309
+ * an insertion point, recorded as a zero-length range at the cursor.
310
+ */
311
+ export function oracleConflictRanges(mergedText, oursLines, oursFinalNewline = true) {
312
+ const out = [];
313
+ const lines = splitLines(mergedText).lines;
314
+ let cursor = 0;
315
+ let i = 0;
316
+ while (i < lines.length) {
317
+ const line = lines[i];
318
+ if (!line.startsWith('<<<<<<<')) {
319
+ i++;
320
+ continue;
321
+ }
322
+ i++;
323
+ const oursSection = [];
324
+ while (i < lines.length && !lines[i].startsWith('|||||||')) {
325
+ oursSection.push(lines[i]);
326
+ i++;
327
+ }
328
+ // Skip the base and theirs sections; only the ours side is used for placement.
329
+ while (i < lines.length && !lines[i].startsWith('>>>>>>>'))
330
+ i++;
331
+ if (i >= lines.length)
332
+ return null; // unterminated block — refuse rather than guess
333
+ i++;
334
+ if (oursSection.length === 0) {
335
+ out.push({ start: cursor, end: cursor });
336
+ continue;
337
+ }
338
+ const at = findSequence(oursLines, oursSection, cursor, oursFinalNewline);
339
+ if (at < 0)
340
+ return null;
341
+ out.push({ start: at, end: at + oursSection.length });
342
+ cursor = at + oursSection.length;
343
+ }
344
+ return out;
345
+ }
346
+ function findSequence(haystack, needle, from, haystackFinalNewline) {
347
+ outer: for (let s = from; s + needle.length <= haystack.length; s++) {
348
+ for (let k = 0; k < needle.length; k++) {
349
+ if (!lineMatches(haystack, s + k, needle[k], haystackFinalNewline)) {
350
+ continue outer;
351
+ }
352
+ }
353
+ return s;
354
+ }
355
+ return -1;
356
+ }
357
+ /**
358
+ * ⚠ `merge-file` RE-TERMINATES a file that ended without a newline, and on a CRLF file the
359
+ * terminator it adds is `\r\n` — so ours' final line comes back one `\r` longer than it
360
+ * actually is. That is an artefact of the oracle's own output, not a disagreement about where
361
+ * the conflict is, and treating it as one made every CRLF file with no trailing newline
362
+ * `engine_disagreement`. The relaxation is deliberately confined to that exact case: the LAST
363
+ * line of ours, and only when ours has no terminator.
364
+ */
365
+ function lineMatches(haystack, at, needle, haystackFinalNewline) {
366
+ const hay = haystack[at];
367
+ if (hay === undefined)
368
+ return false;
369
+ if (hay === needle)
370
+ return true;
371
+ return at === haystack.length - 1 && !haystackFinalNewline && `${hay}\r` === needle;
372
+ }
373
+ /**
374
+ * Every region git contested must overlap one of ours. OUR extra regions are fine — being
375
+ * more conservative than git is allowed; contesting LESS than git is not, because that is a
376
+ * region we would merge silently and git would not.
377
+ */
378
+ export function oracleAgrees(ourConflictRanges, gitRanges) {
379
+ for (const g of gitRanges) {
380
+ const hit = ourConflictRanges.some((r) => g.start <= r.end && g.end >= r.start);
381
+ if (!hit)
382
+ return false;
383
+ }
384
+ return true;
385
+ }
386
+ /** Run the oracle over one file's three sides. Returns true when git agrees, false when it
387
+ * contests something we merged silently — which makes the file `engine_disagreement`. */
388
+ async function runOracle(baseText, oursText, theirsText, oursLines, oursFinalNewline, ourConflictRanges) {
389
+ const dir = mkdtempSync(join(tmpdir(), 'pierre-oracle-'));
390
+ try {
391
+ const o = join(dir, 'ours');
392
+ const b = join(dir, 'base');
393
+ const t = join(dir, 'theirs');
394
+ writeFileSync(o, oursText, 'utf8');
395
+ writeFileSync(b, baseText, 'utf8');
396
+ writeFileSync(t, theirsText, 'utf8');
397
+ const res = await gitTry(['merge-file', '-p', '--diff3', '-L', 'ours', '-L', 'base', '-L', 'theirs', o, b, t], dir);
398
+ // ⚠ merge-file's exit status is the CONFLICT COUNT, capped at 127 — which is why we do
399
+ // not read it. Above 127 it is a real failure (unreadable input), and a cross-check that
400
+ // cannot run must not turn every file into a refusal.
401
+ if (res.code > 127)
402
+ return true;
403
+ const gitRanges = oracleConflictRanges(res.stdout.toString('utf8'), oursLines, oursFinalNewline);
404
+ if (gitRanges === null)
405
+ return false;
406
+ return oracleAgrees(ourConflictRanges, gitRanges);
407
+ }
408
+ catch {
409
+ // The oracle is a cross-check, not a dependency: if it cannot run we do not turn every
410
+ // file into a refusal.
411
+ return true;
412
+ }
413
+ finally {
414
+ try {
415
+ rmSync(dir, { recursive: true, force: true });
416
+ }
417
+ catch {
418
+ /* a temp dir we could not remove is not worth failing a merge over */
419
+ }
420
+ }
421
+ }
422
+ /**
423
+ * Build the model inside an EXISTING clone that already has both commits.
424
+ *
425
+ * Exported separately from `buildConflictModel` so the tests can drive it against real temp
426
+ * repositories with no GitHub and no database — the git behaviour this whole feature rests on
427
+ * is the half worth testing against real git.
428
+ */
429
+ export async function buildConflictModelInClone(ctx, opts = {}) {
430
+ const deadline = opts.deadlineMs ?? Date.now() + config.conflictModelTimeoutMs;
431
+ const expired = () => Date.now() > deadline;
432
+ if (!(await gitSupportsMergeTree(ctx.cloneDir))) {
433
+ return {
434
+ status: 'failed',
435
+ code: 'git_too_old',
436
+ message: 'This machine’s git is older than 2.38. Update git to resolve conflicts here.',
437
+ };
438
+ }
439
+ opts.onPhase?.('merging');
440
+ // ---- the merge base ----
441
+ // ⚠ Used for the REBASE COMMIT COUNT and the blob prefetch, never for the `base` TEXT of a
442
+ // region — that always comes from merge-tree's stage 1. MEASURED on a real criss-cross:
443
+ // `merge-base --all` returned two commits whose file read `x/MID/yA` and `xB/MID/y` while
444
+ // stage 1 was `xB/MID/yA`, different from BOTH. A second base resolution is how "keep the
445
+ // ancestor" comes to commit bytes the pane never showed.
446
+ const mb = await gitTry(['merge-base', '--all', ctx.headSha, ctx.baseSha], ctx.cloneDir);
447
+ const mbCandidates = mb.stdout
448
+ .toString('utf8')
449
+ .split('\n')
450
+ .map((s) => s.trim())
451
+ .filter((s) => s.length > 0);
452
+ if (mbCandidates.length === 0) {
453
+ return {
454
+ status: 'failed',
455
+ code: 'unrelated_histories',
456
+ message: 'This branch and its base share no history.',
457
+ };
458
+ }
459
+ const mergeBaseIsVirtual = mbCandidates.length > 1;
460
+ const mergeBaseSha = mergeBaseIsVirtual ? null : mbCandidates[0];
461
+ const rebaseBase = mbCandidates[0];
462
+ // ---- prefetch the blobs merge-tree will need ----
463
+ opts.onPhase?.('fetching');
464
+ const prefetch = await prefetchBlobs(ctx, mbCandidates);
465
+ if (prefetch === 'unavailable') {
466
+ return {
467
+ status: 'failed',
468
+ code: 'objects_unavailable',
469
+ message: 'Some of this merge’s file contents could not be fetched from GitHub.',
470
+ };
471
+ }
472
+ if (expired())
473
+ return timedOut();
474
+ // ---- the merge ----
475
+ opts.onPhase?.('merging');
476
+ const mtArgs = ['merge-tree', '--write-tree', '-z'];
477
+ if (prefetch === 'narrowed') {
478
+ // A CONFIG KEY, not a 2.40 flag: `-c merge.renames=false` works everywhere `merge-tree`
479
+ // does, and rename detection is what makes the blob budget explode.
480
+ mtArgs.unshift('-c', 'merge.renames=false');
481
+ }
482
+ const mt = await gitTry([...mtArgs, ctx.headSha, ctx.baseSha], ctx.cloneDir);
483
+ if (mt.stdout.length === 0) {
484
+ // ⚠ A non-zero exit with EMPTY stdout is an error; a non-zero exit with a leading tree
485
+ // oid is conflicts. Branching on the code alone turns "not something we can merge" into
486
+ // a model with no files in it.
487
+ const stderr = mt.stderr.trim();
488
+ if (/unrelated histories/i.test(stderr)) {
489
+ return {
490
+ status: 'failed',
491
+ code: 'unrelated_histories',
492
+ message: 'This branch and its base share no history.',
493
+ };
494
+ }
495
+ return {
496
+ status: 'failed',
497
+ code: 'objects_unavailable',
498
+ message: stderr.slice(0, 300) || 'git could not merge these two commits.',
499
+ };
500
+ }
501
+ const parsed = parseMergeTree(mt.stdout);
502
+ const commitsAboveBase = await countCommits(ctx.cloneDir, rebaseBase, ctx.headSha);
503
+ const skeleton = {
504
+ accountId: ctx.accountId,
505
+ prId: ctx.prId,
506
+ owner: ctx.owner,
507
+ name: ctx.name,
508
+ number: ctx.number,
509
+ headSha: ctx.headSha,
510
+ baseSha: ctx.baseSha,
511
+ headRef: ctx.headRef,
512
+ baseRef: ctx.baseRef,
513
+ mergeBaseSha,
514
+ mergeBaseIsVirtual,
515
+ mergedTreeSha: parsed.treeOid,
516
+ commitsAboveBase,
517
+ reservedBranchNames: ctx.reservedBranchNames,
518
+ prBranchPushable: ctx.prBranchPushable ?? true,
519
+ prBranchUnavailableReason: ctx.prBranchUnavailableReason ?? null,
520
+ };
521
+ if (parsed.stages.length === 0) {
522
+ const model = {
523
+ ...skeleton,
524
+ files: [],
525
+ totalConflictedPaths: 0,
526
+ truncated: false,
527
+ renameDetection: prefetch === 'narrowed' ? 'off' : 'on',
528
+ strategies: ['merge'],
529
+ rebaseUnavailableReason: null,
530
+ };
531
+ return { status: 'clean', model };
532
+ }
533
+ // ---- group every record by path ----
534
+ const byPath = groupRecords(parsed.stages, parsed.messages);
535
+ if (byPath.size > HARD_CONFLICTED_PATH_CEILING) {
536
+ return {
537
+ status: 'failed',
538
+ code: 'too_many_files',
539
+ message: `This merge conflicts in ${byPath.size} files. Resolve it on GitHub.`,
540
+ };
541
+ }
542
+ opts.onPhase?.('reading');
543
+ const files = await buildFiles(ctx, [...byPath.values()], deadline);
544
+ const resolvable = files.filter((f) => f.unsupported === null);
545
+ const strategies = ['merge'];
546
+ let rebaseUnavailableReason = null;
547
+ if (commitsAboveBase !== 1) {
548
+ rebaseUnavailableReason = `This branch has ${commitsAboveBase} commits. Rebasing can conflict once per commit — merge instead.`;
549
+ }
550
+ else if (resolvable.length !== files.length) {
551
+ const stuck = files.length - resolvable.length;
552
+ rebaseUnavailableReason = `Rebase needs every conflicted file resolved here. ${stuck} of ${files.length} must be resolved on GitHub.`;
553
+ }
554
+ else {
555
+ strategies.push('rebase');
556
+ }
557
+ const model = {
558
+ ...skeleton,
559
+ files,
560
+ totalConflictedPaths: byPath.size,
561
+ truncated: files.some((f) => f.unsupported === 'budget_exhausted'),
562
+ renameDetection: prefetch === 'narrowed' ? 'off' : 'on',
563
+ strategies,
564
+ rebaseUnavailableReason,
565
+ };
566
+ return { status: 'ready', model };
567
+ }
568
+ function timedOut() {
569
+ return {
570
+ status: 'failed',
571
+ code: 'timeout',
572
+ message: 'Working out this merge took too long.',
573
+ };
574
+ }
575
+ function groupRecords(stages, messages) {
576
+ const byPath = new Map();
577
+ const get = (rawPath) => {
578
+ let rec = byPath.get(rawPath);
579
+ if (!rec) {
580
+ rec = { rawPath, stages: new Map(), types: new Set(), relatedRaw: new Set() };
581
+ byPath.set(rawPath, rec);
582
+ }
583
+ return rec;
584
+ };
585
+ // Only STAGE entries mint a file. A path that appears solely in a message — the directory
586
+ // half of a file/directory conflict, say — is carried as a related path, because there is
587
+ // no blob triple to resolve there.
588
+ for (const s of stages)
589
+ get(s.path).stages.set(s.stage, s);
590
+ for (const m of messages) {
591
+ for (const p of m.paths) {
592
+ const rec = byPath.get(p);
593
+ if (!rec)
594
+ continue;
595
+ rec.types.add(m.type);
596
+ for (const other of m.paths)
597
+ if (other !== p)
598
+ rec.relatedRaw.add(other);
599
+ }
600
+ }
601
+ return byPath;
602
+ }
603
+ async function buildFiles(ctx, records, deadline) {
604
+ const committishes = [ctx.headSha, ctx.baseSha];
605
+ const sorted = [...records].sort((a, b) => (a.rawPath < b.rawPath ? -1 : 1));
606
+ const files = [];
607
+ let bytesAttached = 0;
608
+ for (let index = 0; index < sorted.length; index++) {
609
+ const rec = sorted[index];
610
+ const path = stripMangledSuffix(rec.rawPath, committishes);
611
+ const relatedPaths = [...rec.relatedRaw]
612
+ .map((p) => stripMangledSuffix(p, committishes))
613
+ .filter((p) => p !== path);
614
+ const s2 = rec.stages.get(2);
615
+ const base = (reason) => ({
616
+ index,
617
+ path,
618
+ relatedPaths,
619
+ unsupported: reason,
620
+ unsupportedLabel: reason === null ? null : UNSUPPORTED_LABELS[reason],
621
+ regions: [],
622
+ terminators: { base: false, ours: false, theirs: false },
623
+ maxSideBytes: 0,
624
+ stage2Mode: s2?.mode ?? null,
625
+ });
626
+ const structural = classifyPath(rec);
627
+ if (structural !== null) {
628
+ files.push(base(structural));
629
+ continue;
630
+ }
631
+ if (index >= config.conflictMaxFiles || Date.now() > deadline) {
632
+ // Still listed and still classified — an unlisted file is why the PR stays conflicted
633
+ // after a commit, with nothing on screen to explain it.
634
+ files.push(base('budget_exhausted'));
635
+ continue;
636
+ }
637
+ const s1 = rec.stages.get(1);
638
+ const s3 = rec.stages.get(3);
639
+ if (!s1 || !s2 || !s3) {
640
+ files.push(base('no_common_ancestor'));
641
+ continue;
642
+ }
643
+ const blobs = await readBlobs(ctx.cloneDir, [s1.oid, s2.oid, s3.oid]);
644
+ if (!blobs) {
645
+ files.push(base('budget_exhausted'));
646
+ continue;
647
+ }
648
+ const [baseBuf, oursBuf, theirsBuf] = blobs;
649
+ const maxSideBytes = Math.max(baseBuf.length, oursBuf.length, theirsBuf.length);
650
+ if (maxSideBytes > config.conflictMaxFileBytes) {
651
+ files.push({ ...base('too_large'), maxSideBytes });
652
+ continue;
653
+ }
654
+ if (bytesAttached + baseBuf.length + oursBuf.length + theirsBuf.length >
655
+ config.conflictMaxTotalBytes) {
656
+ files.push({ ...base('budget_exhausted'), maxSideBytes });
657
+ continue;
658
+ }
659
+ // The NUL check is the cheap half of the binary test and it comes first; the strict
660
+ // decode is the half that catches Latin-1, which has no NUL at all.
661
+ if (baseBuf.includes(0) || oursBuf.includes(0) || theirsBuf.includes(0)) {
662
+ files.push({ ...base('binary'), maxSideBytes });
663
+ continue;
664
+ }
665
+ const baseText = decodeStrict(baseBuf);
666
+ const oursText = decodeStrict(oursBuf);
667
+ const theirsText = decodeStrict(theirsBuf);
668
+ if (baseText === null || oursText === null || theirsText === null) {
669
+ files.push({ ...base('not_text'), maxSideBytes });
670
+ continue;
671
+ }
672
+ const baseSplit = splitLines(baseText);
673
+ const oursSplit = splitLines(oursText);
674
+ const theirsSplit = splitLines(theirsText);
675
+ const built = buildFileRegions(baseSplit.lines, oursSplit.lines, theirsSplit.lines);
676
+ const terminators = {
677
+ base: baseSplit.finalNewline,
678
+ ours: oursSplit.finalNewline,
679
+ theirs: theirsSplit.finalNewline,
680
+ };
681
+ if (built.unsupported !== null) {
682
+ files.push({ ...base(built.unsupported), maxSideBytes, terminators });
683
+ continue;
684
+ }
685
+ const ourRanges = ourConflictRangesOf(built.regions, oursSplit.lines);
686
+ const agrees = await runOracle(baseText, oursText, theirsText, oursSplit.lines, oursSplit.finalNewline, ourRanges);
687
+ if (!agrees) {
688
+ files.push({ ...base('engine_disagreement'), maxSideBytes, terminators });
689
+ continue;
690
+ }
691
+ bytesAttached += baseBuf.length + oursBuf.length + theirsBuf.length;
692
+ files.push({
693
+ index,
694
+ path,
695
+ relatedPaths,
696
+ unsupported: null,
697
+ unsupportedLabel: null,
698
+ regions: built.regions,
699
+ terminators,
700
+ maxSideBytes,
701
+ stage2Mode: s2.mode,
702
+ });
703
+ }
704
+ return files;
705
+ }
706
+ /** Our contested regions expressed as OURS-side line ranges, for the oracle comparison. */
707
+ function ourConflictRangesOf(regions, oursLines) {
708
+ const out = [];
709
+ let cursor = 0;
710
+ for (const r of regions) {
711
+ const len = r.kind === 'unchanged' ? r.base.length : r.ours.length;
712
+ if (r.kind === 'conflict')
713
+ out.push({ start: cursor, end: cursor + len });
714
+ cursor += len;
715
+ }
716
+ // A defensive clamp: the walk above must land exactly on the ours side's length.
717
+ if (cursor !== oursLines.length)
718
+ return out;
719
+ return out;
720
+ }
721
+ /* ═════════════════════════════ git plumbing ═════════════════════════════ */
722
+ async function countCommits(cloneDir, from, to) {
723
+ const res = await gitTry(['rev-list', '--count', `${from}..${to}`], cloneDir);
724
+ if (res.code !== 0)
725
+ return -1;
726
+ const n = Number(res.stdout.toString('utf8').trim());
727
+ return Number.isInteger(n) ? n : -1;
728
+ }
729
+ /** Read the merge base / ours / theirs blobs. `null` when any of them is missing, which the
730
+ * caller treats as a budget problem for that ONE file rather than a failure of the model. */
731
+ async function readBlobs(cloneDir, oids) {
732
+ const out = [];
733
+ for (const oid of oids) {
734
+ const res = await gitTry(['cat-file', 'blob', oid], cloneDir);
735
+ if (res.code !== 0)
736
+ return null;
737
+ out.push(res.stdout);
738
+ }
739
+ const [a, b, c] = out;
740
+ if (!a || !b || !c)
741
+ return null;
742
+ return [a, b, c];
743
+ }
744
+ /**
745
+ * Make sure every blob merge-tree will read is in the local store.
746
+ *
747
+ * The clone is BLOBLESS and its `origin` carries no credential, so git cannot lazily fetch
748
+ * on its own. We name the oids explicitly.
749
+ *
750
+ * ⚠ THE BY-OID FETCH EXITS NON-ZERO ON COMPLETE SUCCESS — `fatal: bad revision '<oid>'`,
751
+ * `error: … did not send all necessary objects` — because git cannot make a ref out of a
752
+ * blob oid. SUCCESS IS PROVEN ONLY BY THE FOLLOW-UP `cat-file --batch-check`. Any
753
+ * implementation that checks the exit code refuses every PR.
754
+ *
755
+ * ⚠ `--filter=blob:none` on that fetch is load-bearing: without it the blob does not land.
756
+ */
757
+ async function prefetchBlobs(ctx, mergeBaseCandidates) {
758
+ // ⚠ EVERY candidate, not just the first. On a criss-cross history merge-tree builds a
759
+ // VIRTUAL base by merging the candidates, so it reads content from all of them; prefetching
760
+ // against one leaves the others' blobs missing in a blobless clone, and the merge then
761
+ // fails for a reason that looks nothing like its cause.
762
+ const ours = new Map();
763
+ const theirs = new Map();
764
+ // Stage-1 material: the base side's own blobs, which the two maps above (keyed on the
765
+ // DESTINATION oid) do not carry.
766
+ const ancestors = new Set();
767
+ for (const mergeBase of mergeBaseCandidates) {
768
+ for (const [path, oid] of await diffTreeOids(ctx.cloneDir, mergeBase, ctx.headSha)) {
769
+ ours.set(path, oid);
770
+ }
771
+ for (const [path, oid] of await diffTreeOids(ctx.cloneDir, mergeBase, ctx.baseSha)) {
772
+ theirs.set(path, oid);
773
+ }
774
+ for (const oid of (await diffTreeOids(ctx.cloneDir, ctx.headSha, mergeBase)).values()) {
775
+ ancestors.add(oid);
776
+ }
777
+ }
778
+ let narrowed = false;
779
+ let wanted = new Set([...ours.values(), ...theirs.values(), ...ancestors]);
780
+ if (wanted.size > config.conflictPrefetchMaxBlobs) {
781
+ // Narrow to the paths BOTH sides touched — with rename detection off those are the only
782
+ // ones that can conflict, so they are the only ones whose content merge-tree must read.
783
+ narrowed = true;
784
+ wanted = new Set(ancestors);
785
+ for (const [path, oid] of ours) {
786
+ if (!theirs.has(path))
787
+ continue;
788
+ wanted.add(oid);
789
+ const t = theirs.get(path);
790
+ if (t)
791
+ wanted.add(t);
792
+ }
793
+ }
794
+ const missing = await missingOids(ctx.cloneDir, [...wanted]);
795
+ if (missing.length === 0)
796
+ return narrowed ? 'narrowed' : 'ok';
797
+ if (!ctx.token)
798
+ return 'unavailable';
799
+ const url = `https://x-access-token:${ctx.token}@github.com/${ctx.owner}/${ctx.name}.git`;
800
+ for (let i = 0; i < missing.length; i += PREFETCH_BATCH) {
801
+ const batch = missing.slice(i, i + PREFETCH_BATCH);
802
+ await gitTry([
803
+ 'fetch',
804
+ '--no-tags',
805
+ '--no-write-fetch-head',
806
+ '--recurse-submodules=no',
807
+ '--filter=blob:none',
808
+ url,
809
+ ...batch,
810
+ ], ctx.cloneDir);
811
+ }
812
+ const stillMissing = await missingOids(ctx.cloneDir, missing);
813
+ if (stillMissing.length > 0)
814
+ return 'unavailable';
815
+ return narrowed ? 'narrowed' : 'ok';
816
+ }
817
+ /** path → blob oid on the `to` side, for every path that differs. `--no-renames` keeps this
818
+ * a plain per-path list; rename detection here would cost blob content we have not fetched. */
819
+ async function diffTreeOids(cloneDir, from, to) {
820
+ const out = new Map();
821
+ const res = await gitTry(['diff-tree', '-r', '-z', '--no-renames', from, to], cloneDir);
822
+ if (res.code !== 0)
823
+ return out;
824
+ // `:<srcmode> <dstmode> <srcoid> <dstoid> <status>\0<path>\0`
825
+ const fields = res.stdout.toString('utf8').split('\0');
826
+ for (let i = 0; i < fields.length; i++) {
827
+ const f = fields[i];
828
+ if (f === undefined || !f.startsWith(':'))
829
+ continue;
830
+ const bits = f.slice(1).split(' ');
831
+ const dstOid = bits[3];
832
+ const path = fields[i + 1];
833
+ if (dstOid === undefined || path === undefined)
834
+ continue;
835
+ if (/^0+$/.test(dstOid))
836
+ continue; // deleted on this side — no blob to fetch
837
+ out.set(path, dstOid);
838
+ i++;
839
+ }
840
+ return out;
841
+ }
842
+ async function missingOids(cloneDir, oids) {
843
+ if (oids.length === 0)
844
+ return [];
845
+ const stdin = Buffer.from(`${oids.join('\n')}\n`, 'utf8');
846
+ const res = await gitTry(['cat-file', '--batch-check', '--buffer'], cloneDir, stdin);
847
+ const lines = res.stdout.toString('utf8').split('\n');
848
+ const missing = [];
849
+ for (const line of lines) {
850
+ if (!line)
851
+ continue;
852
+ if (line.endsWith(' missing')) {
853
+ const oid = line.slice(0, line.length - ' missing'.length);
854
+ if (oid)
855
+ missing.push(oid);
856
+ }
857
+ }
858
+ return missing;
859
+ }
860
+ /* ═════════════════════════════ the public entry point ═════════════════════════════ */
861
+ /**
862
+ * Resolve the PR, make sure the clone has both commits, and build the model.
863
+ *
864
+ * Returns `null` when `prId` is not this account's PR — the caller 404s, so the route never
865
+ * becomes an existence oracle for another tenant's ids.
866
+ */
867
+ export async function buildConflictModel(args) {
868
+ const rows = await db
869
+ .select({
870
+ prId: pullRequests.id,
871
+ number: pullRequests.number,
872
+ state: pullRequests.state,
873
+ headRefName: pullRequests.headRefName,
874
+ baseRefName: pullRequests.baseRefName,
875
+ owner: repos.owner,
876
+ name: repos.name,
877
+ defaultBranch: repos.defaultBranch,
878
+ defaultBranchName: repos.defaultBranchName,
879
+ })
880
+ .from(pullRequests)
881
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
882
+ .where(and(eq(pullRequests.id, args.prId), eq(repos.accountId, args.accountId)))
883
+ .limit(1)
884
+ .execute();
885
+ const pr = rows[0];
886
+ if (!pr)
887
+ return null;
888
+ if (pr.state !== 'open') {
889
+ return {
890
+ status: 'failed',
891
+ code: 'not_open',
892
+ message: 'This pull request is closed.',
893
+ };
894
+ }
895
+ const baseRef = pr.baseRefName;
896
+ if (!baseRef) {
897
+ return {
898
+ status: 'failed',
899
+ code: 'objects_unavailable',
900
+ message: 'This pull request’s base branch hasn’t been synced yet.',
901
+ };
902
+ }
903
+ const token = await getAccessToken(args.accountId);
904
+ const reserved = [...new Set([pr.defaultBranchName, pr.defaultBranch, baseRef].filter((b) => typeof b === 'string' && b.length > 0))];
905
+ // ── CAN WE PUSH TO THE PR'S OWN BRANCH? ──────────────────────────────────────────────────
906
+ // `isFork` / `maintainerCanModify` are NOT synced columns, so the only way to know is to ask,
907
+ // and the answer has to be known BEFORE the reader fills in the landing form — otherwise the
908
+ // screen offers "Push to <headRef>" for a head we cannot write to and the refusal arrives
909
+ // after the button. One REST call, on a path that is about to clone and fetch two branches
910
+ // and is click-gated besides.
911
+ //
912
+ // ⚠ NON-FATAL, AND TRUE IS THE FALLBACK. A head-info failure must not cost the whole session:
913
+ // we say "pushable", the landing step offers the option, and `landConflictResolution` —
914
+ // which re-reads exactly these two fields immediately before the push — refuses with
915
+ // `PushDenied`. That is the behaviour that shipped before this field existed.
916
+ let prBranchPushable = true;
917
+ let prBranchUnavailableReason = null;
918
+ try {
919
+ const head = await fetchPrHeadInfo(token, pr.owner, pr.name, pr.number);
920
+ if (head.isFork && !head.maintainerCanModify) {
921
+ prBranchPushable = false;
922
+ prBranchUnavailableReason =
923
+ 'This pull request comes from a fork that does not allow maintainer edits, so its branch can’t be pushed to.';
924
+ }
925
+ }
926
+ catch {
927
+ /* see above: the land route is the authorisation, not this read */
928
+ }
929
+ args.onPhase?.('cloning');
930
+ try {
931
+ // ⚠ ONE lock across the whole git phase — clone, fetch, merge-tree, blob reads. Taking it
932
+ // twice leaves a window in which another job's cleanup can reach the refs we just
933
+ // fetched, and the shas we pinned would then resolve to nothing.
934
+ return await withRepoLock(`${pr.owner}/${pr.name}`, async () => {
935
+ const cloneDir = await ensureClone(pr.owner, pr.name, token);
936
+ args.onPhase?.('fetching');
937
+ // ⚠ SESSION-NAMESPACED, NEVER FETCH_HEAD. The clone cache is keyed `owner__name` and
938
+ // shared across accounts and jobs; FETCH_HEAD is ONE FILE per repository, so a
939
+ // concurrent job's fetch would hand us its commit.
940
+ const ns = `refs/pierre/conflict/${args.sessionId}`;
941
+ const headSha = await fetchRefIntoClone({
942
+ cloneDir,
943
+ owner: pr.owner,
944
+ name: pr.name,
945
+ token,
946
+ remoteRef: `refs/pull/${pr.number}/head`,
947
+ destRef: `${ns}/head`,
948
+ });
949
+ const baseSha = await fetchRefIntoClone({
950
+ cloneDir,
951
+ owner: pr.owner,
952
+ name: pr.name,
953
+ token,
954
+ remoteRef: `refs/heads/${baseRef}`,
955
+ destRef: `${ns}/base`,
956
+ });
957
+ if ((args.expectHeadSha && args.expectHeadSha !== headSha) ||
958
+ (args.expectBaseSha && args.expectBaseSha !== baseSha)) {
959
+ return { status: 'moved', headSha, baseSha };
960
+ }
961
+ return buildConflictModelInClone({
962
+ accountId: args.accountId,
963
+ prId: args.prId,
964
+ cloneDir,
965
+ owner: pr.owner,
966
+ name: pr.name,
967
+ number: pr.number,
968
+ headSha,
969
+ baseSha,
970
+ headRef: pr.headRefName ?? '',
971
+ baseRef,
972
+ reservedBranchNames: reserved,
973
+ prBranchPushable,
974
+ prBranchUnavailableReason,
975
+ token,
976
+ }, { onPhase: args.onPhase, deadlineMs: args.deadlineMs });
977
+ });
978
+ }
979
+ finally {
980
+ // The clone cache is a cache: sweep it off the hot path, never inside the lock.
981
+ setImmediate(() => {
982
+ void cleanupCloneCache();
983
+ });
984
+ }
985
+ }
986
+ /* ═════════════════════════════ the wire projection ═════════════════════════════ */
987
+ /**
988
+ * The manifest rows. `regionCount` counts EVERY region; `conflictCount` counts the contested
989
+ * ones; `decidableCount` counts every region that takes a decision — the population the commit
990
+ * gate and the header both work in — and `wandResolvableCount` is the subset of the contested
991
+ * ones the wand can settle without picking a side.
992
+ */
993
+ export function conflictFileEntries(model) {
994
+ return model.files.map((f) => ({
995
+ index: f.index,
996
+ path: f.path,
997
+ relatedPaths: f.relatedPaths,
998
+ unsupported: f.unsupported,
999
+ unsupportedLabel: f.unsupportedLabel,
1000
+ regionCount: f.regions.length,
1001
+ conflictCount: f.regions.filter((r) => r.kind === 'conflict').length,
1002
+ decidableCount: f.regions.filter((r) => r.kind !== 'unchanged').length,
1003
+ wandResolvableCount: f.regions.filter((r) => r.kind === 'conflict' && r.wand !== null).length,
1004
+ maxSideBytes: f.maxSideBytes,
1005
+ }));
1006
+ }
1007
+ /** Which buttons a region offers. ⚠ `'suggestion'` never appears — it is a session fact, not
1008
+ * a model fact, and a model that advertised it would be advertising a Pro capability the
1009
+ * account may not have. */
1010
+ export function allowedDecisions(region) {
1011
+ switch (region.kind) {
1012
+ case 'unchanged':
1013
+ return ['base'];
1014
+ case 'ours_only':
1015
+ return ['ours', 'base'];
1016
+ case 'theirs_only':
1017
+ return ['theirs', 'base'];
1018
+ case 'both_same':
1019
+ return ['ours', 'base'];
1020
+ case 'conflict': {
1021
+ const out = [
1022
+ 'ours',
1023
+ 'theirs',
1024
+ 'both_ours_first',
1025
+ 'both_theirs_first',
1026
+ 'base',
1027
+ ];
1028
+ if (region.wand?.reason === 'disjoint_words')
1029
+ out.push('disjoint_merge');
1030
+ return out;
1031
+ }
1032
+ }
1033
+ }
1034
+ /** What the centre pane starts on. With auto-apply (the default) a one-sided region defaults
1035
+ * to its own side and `both_same` to `ours`; a genuine conflict always starts at `base`,
1036
+ * undecided, because there is nothing to apply. */
1037
+ export function defaultDecisionFor(region, autoApply) {
1038
+ if (!autoApply || region.kind === 'conflict' || region.kind === 'unchanged')
1039
+ return 'base';
1040
+ return region.wand?.decision ?? 'base';
1041
+ }
1042
+ export function conflictFileContent(model, index, autoApply) {
1043
+ const file = model.files.find((f) => f.index === index);
1044
+ if (!file)
1045
+ return null;
1046
+ // `n` is that SIDE's own 1-based file line number, so the three panes can be read against
1047
+ // the real file. Walked once, in order — every side is covered end to end.
1048
+ let baseLine = 1;
1049
+ let oursLine = 1;
1050
+ let theirsLine = 1;
1051
+ const regions = file.regions.map((r) => {
1052
+ const region = {
1053
+ id: r.id,
1054
+ kind: r.kind,
1055
+ fingerprint: r.fingerprint,
1056
+ base: numbered(r.base, baseLine),
1057
+ ours: numbered(r.ours, oursLine),
1058
+ theirs: numbered(r.theirs, theirsLine),
1059
+ defaultDecision: defaultDecisionFor(r, autoApply),
1060
+ allowed: allowedDecisions(r),
1061
+ wand: r.wand,
1062
+ // ⚠ THE BYTES, NOT A PROMISE THE CLIENT CAN RECOMPUTE. `land.ts` splices exactly this
1063
+ // array for a `'disjoint_merge'` decision, so the centre pane must render exactly this
1064
+ // array — see the ⚠ on `ConflictRegion.mergedLines` for the divergence that made the
1065
+ // SPA's own word merge print the ancestor for a region the commit landed merged.
1066
+ mergedLines: r.mergedLines,
1067
+ };
1068
+ // An `unchanged` region carries empty `ours`/`theirs` but still occupies those lines,
1069
+ // and its length there is the base's by construction.
1070
+ const sideLen = r.kind === 'unchanged' ? r.base.length : 0;
1071
+ baseLine += r.base.length;
1072
+ oursLine += r.ours.length + sideLen;
1073
+ theirsLine += r.theirs.length + sideLen;
1074
+ return region;
1075
+ });
1076
+ return { index: file.index, path: file.path, terminators: file.terminators, regions };
1077
+ }
1078
+ function numbered(lines, from) {
1079
+ return lines.map((text, i) => ({ n: from + i, text }));
1080
+ }
1081
+ //# sourceMappingURL=model.js.map