@wascartech/devbot 0.0.0-stage → 2.1.1

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 (376) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +638 -2
  3. package/dist/access-grant-store.js +99 -0
  4. package/dist/access-grant-store.js.map +1 -0
  5. package/dist/access-resolve.js +11 -0
  6. package/dist/access-resolve.js.map +1 -0
  7. package/dist/access-rule-store.js +99 -0
  8. package/dist/access-rule-store.js.map +1 -0
  9. package/dist/admin/access-routes.js +73 -0
  10. package/dist/admin/access-routes.js.map +1 -0
  11. package/dist/admin/access-rule-routes.js +107 -0
  12. package/dist/admin/access-rule-routes.js.map +1 -0
  13. package/dist/admin/custom-repos-routes.js +93 -0
  14. package/dist/admin/custom-repos-routes.js.map +1 -0
  15. package/dist/admin/env-routes.js +74 -0
  16. package/dist/admin/env-routes.js.map +1 -0
  17. package/dist/admin/global-settings-routes.js +254 -0
  18. package/dist/admin/global-settings-routes.js.map +1 -0
  19. package/dist/admin/mcp-cleanup.js +12 -0
  20. package/dist/admin/mcp-cleanup.js.map +1 -0
  21. package/dist/admin/mcp-routes.js +169 -0
  22. package/dist/admin/mcp-routes.js.map +1 -0
  23. package/dist/admin/query-params.js +80 -0
  24. package/dist/admin/query-params.js.map +1 -0
  25. package/dist/admin/routes.js +150 -0
  26. package/dist/admin/routes.js.map +1 -0
  27. package/dist/admin/run-history-routes.js +44 -0
  28. package/dist/admin/run-history-routes.js.map +1 -0
  29. package/dist/admin/run-routes.js +99 -0
  30. package/dist/admin/run-routes.js.map +1 -0
  31. package/dist/admin/settings-routes.js +119 -0
  32. package/dist/admin/settings-routes.js.map +1 -0
  33. package/dist/admin/stats-routes.js +51 -0
  34. package/dist/admin/stats-routes.js.map +1 -0
  35. package/dist/admin/task-routes.js +213 -0
  36. package/dist/admin/task-routes.js.map +1 -0
  37. package/dist/admin/test-routes.js +38 -0
  38. package/dist/admin/test-routes.js.map +1 -0
  39. package/dist/admin/volume-routes.js +82 -0
  40. package/dist/admin/volume-routes.js.map +1 -0
  41. package/dist/admin/webhook-routes.js +174 -0
  42. package/dist/admin/webhook-routes.js.map +1 -0
  43. package/dist/agents.js +48 -0
  44. package/dist/agents.js.map +1 -0
  45. package/dist/assignee-value.js +6 -0
  46. package/dist/assignee-value.js.map +1 -0
  47. package/dist/attachments.js +76 -0
  48. package/dist/attachments.js.map +1 -0
  49. package/dist/auto-commands.js +88 -0
  50. package/dist/auto-commands.js.map +1 -0
  51. package/dist/auto-features.js +32 -0
  52. package/dist/auto-features.js.map +1 -0
  53. package/dist/base-branch-store.js +40 -0
  54. package/dist/base-branch-store.js.map +1 -0
  55. package/dist/base-branches.js +9 -0
  56. package/dist/base-branches.js.map +1 -0
  57. package/dist/build-workflow.js +116 -0
  58. package/dist/build-workflow.js.map +1 -0
  59. package/dist/change-request-lifecycle.js +16 -0
  60. package/dist/change-request-lifecycle.js.map +1 -0
  61. package/dist/change-request-store.js +72 -0
  62. package/dist/change-request-store.js.map +1 -0
  63. package/dist/ci-failure.js +80 -0
  64. package/dist/ci-failure.js.map +1 -0
  65. package/dist/cli.js +3 -0
  66. package/dist/cli.js.map +1 -0
  67. package/dist/config.js +17 -0
  68. package/dist/config.js.map +1 -0
  69. package/dist/conflict-fix.js +178 -0
  70. package/dist/conflict-fix.js.map +1 -0
  71. package/dist/cookie.js +70 -0
  72. package/dist/cookie.js.map +1 -0
  73. package/dist/crypto-util.js +40 -0
  74. package/dist/crypto-util.js.map +1 -0
  75. package/dist/custom-repos-store.js +90 -0
  76. package/dist/custom-repos-store.js.map +1 -0
  77. package/dist/custom-repos-sync.js +118 -0
  78. package/dist/custom-repos-sync.js.map +1 -0
  79. package/dist/db.js +12 -0
  80. package/dist/db.js.map +1 -0
  81. package/dist/devbot-scripts.js +18 -0
  82. package/dist/devbot-scripts.js.map +1 -0
  83. package/dist/devcontainer.js +150 -0
  84. package/dist/devcontainer.js.map +1 -0
  85. package/dist/env-config-store.js +132 -0
  86. package/dist/env-config-store.js.map +1 -0
  87. package/dist/error-log-store.js +193 -0
  88. package/dist/error-log-store.js.map +1 -0
  89. package/dist/git.js +605 -0
  90. package/dist/git.js.map +1 -0
  91. package/dist/global-queue.js +127 -0
  92. package/dist/global-queue.js.map +1 -0
  93. package/dist/global-settings-store.js +307 -0
  94. package/dist/global-settings-store.js.map +1 -0
  95. package/dist/help.js +41 -0
  96. package/dist/help.js.map +1 -0
  97. package/dist/http-csp.js +15 -0
  98. package/dist/http-csp.js.map +1 -0
  99. package/dist/http-util.js +40 -0
  100. package/dist/http-util.js.map +1 -0
  101. package/dist/idempotency.js +50 -0
  102. package/dist/idempotency.js.map +1 -0
  103. package/dist/lifecycle.js +234 -0
  104. package/dist/lifecycle.js.map +1 -0
  105. package/dist/logger.js +33 -0
  106. package/dist/logger.js.map +1 -0
  107. package/dist/lsp-servers.js +81 -0
  108. package/dist/lsp-servers.js.map +1 -0
  109. package/dist/mcp/auth.js +62 -0
  110. package/dist/mcp/auth.js.map +1 -0
  111. package/dist/mcp/effective-mcp.js +42 -0
  112. package/dist/mcp/effective-mcp.js.map +1 -0
  113. package/dist/mcp/oauth-refresh.js +138 -0
  114. package/dist/mcp/oauth-refresh.js.map +1 -0
  115. package/dist/mcp/peer-sync.js +109 -0
  116. package/dist/mcp/peer-sync.js.map +1 -0
  117. package/dist/mcp/registry.js +54 -0
  118. package/dist/mcp/registry.js.map +1 -0
  119. package/dist/mcp/runner-bridge.js +93 -0
  120. package/dist/mcp/runner-bridge.js.map +1 -0
  121. package/dist/mcp/server.js +173 -0
  122. package/dist/mcp/server.js.map +1 -0
  123. package/dist/mcp/tools/_helpers.js +88 -0
  124. package/dist/mcp/tools/_helpers.js.map +1 -0
  125. package/dist/mcp/tools/attachments.js +117 -0
  126. package/dist/mcp/tools/attachments.js.map +1 -0
  127. package/dist/mcp/tools/cicd.js +105 -0
  128. package/dist/mcp/tools/cicd.js.map +1 -0
  129. package/dist/mcp/tools/discovery.js +34 -0
  130. package/dist/mcp/tools/discovery.js.map +1 -0
  131. package/dist/mcp/tools/issues.js +56 -0
  132. package/dist/mcp/tools/issues.js.map +1 -0
  133. package/dist/mcp/tools/merge_requests.js +67 -0
  134. package/dist/mcp/tools/merge_requests.js.map +1 -0
  135. package/dist/mcp/tools/metadata.js +127 -0
  136. package/dist/mcp/tools/metadata.js.map +1 -0
  137. package/dist/mcp/tools/peer_sync.js +43 -0
  138. package/dist/mcp/tools/peer_sync.js.map +1 -0
  139. package/dist/mcp/tools/repository.js +178 -0
  140. package/dist/mcp/tools/repository.js.map +1 -0
  141. package/dist/mcp/tools/work_tracking.js +125 -0
  142. package/dist/mcp/tools/work_tracking.js.map +1 -0
  143. package/dist/mcp/tools/workitems.js +30 -0
  144. package/dist/mcp/tools/workitems.js.map +1 -0
  145. package/dist/mcp/volume-files.js +187 -0
  146. package/dist/mcp/volume-files.js.map +1 -0
  147. package/dist/mcp-auth-store.js +134 -0
  148. package/dist/mcp-auth-store.js.map +1 -0
  149. package/dist/mcp-config-store.js +83 -0
  150. package/dist/mcp-config-store.js.map +1 -0
  151. package/dist/mention.js +512 -0
  152. package/dist/mention.js.map +1 -0
  153. package/dist/migrate.js +438 -0
  154. package/dist/migrate.js.map +1 -0
  155. package/dist/mr-assignee.js +71 -0
  156. package/dist/mr-assignee.js.map +1 -0
  157. package/dist/mr-content.js +90 -0
  158. package/dist/mr-content.js.map +1 -0
  159. package/dist/oauth-routes.js +54 -0
  160. package/dist/oauth-routes.js.map +1 -0
  161. package/dist/onboarding-allowlist.js +32 -0
  162. package/dist/onboarding-allowlist.js.map +1 -0
  163. package/dist/opencode.js +291 -0
  164. package/dist/opencode.js.map +1 -0
  165. package/dist/outbound-webhook-store.js +229 -0
  166. package/dist/outbound-webhook-store.js.map +1 -0
  167. package/dist/outbound-webhook.js +314 -0
  168. package/dist/outbound-webhook.js.map +1 -0
  169. package/dist/path-sandbox.js +200 -0
  170. package/dist/path-sandbox.js.map +1 -0
  171. package/dist/platform/git-auth.js +34 -0
  172. package/dist/platform/git-auth.js.map +1 -0
  173. package/dist/platform/gitealike/attachments.js +62 -0
  174. package/dist/platform/gitealike/attachments.js.map +1 -0
  175. package/dist/platform/gitealike/base.js +935 -0
  176. package/dist/platform/gitealike/base.js.map +1 -0
  177. package/dist/platform/gitealike/forgejo.js +16 -0
  178. package/dist/platform/gitealike/forgejo.js.map +1 -0
  179. package/dist/platform/gitealike/gitea.js +16 -0
  180. package/dist/platform/gitealike/gitea.js.map +1 -0
  181. package/dist/platform/gitealike/http.js +152 -0
  182. package/dist/platform/gitealike/http.js.map +1 -0
  183. package/dist/platform/gitealike/schemas.js +284 -0
  184. package/dist/platform/gitealike/schemas.js.map +1 -0
  185. package/dist/platform/github/attachments.js +52 -0
  186. package/dist/platform/github/attachments.js.map +1 -0
  187. package/dist/platform/github/http.js +153 -0
  188. package/dist/platform/github/http.js.map +1 -0
  189. package/dist/platform/github/index.js +936 -0
  190. package/dist/platform/github/index.js.map +1 -0
  191. package/dist/platform/github/schemas.js +308 -0
  192. package/dist/platform/github/schemas.js.map +1 -0
  193. package/dist/platform/github/zip.js +85 -0
  194. package/dist/platform/github/zip.js.map +1 -0
  195. package/dist/platform/gitlab/admin.js +105 -0
  196. package/dist/platform/gitlab/admin.js.map +1 -0
  197. package/dist/platform/gitlab/api.js +229 -0
  198. package/dist/platform/gitlab/api.js.map +1 -0
  199. package/dist/platform/gitlab/attachments.js +79 -0
  200. package/dist/platform/gitlab/attachments.js.map +1 -0
  201. package/dist/platform/gitlab/http.js +165 -0
  202. package/dist/platform/gitlab/http.js.map +1 -0
  203. package/dist/platform/gitlab/index.js +630 -0
  204. package/dist/platform/gitlab/index.js.map +1 -0
  205. package/dist/platform/gitlab/oauth.js +58 -0
  206. package/dist/platform/gitlab/oauth.js.map +1 -0
  207. package/dist/platform/gitlab/read.js +182 -0
  208. package/dist/platform/gitlab/read.js.map +1 -0
  209. package/dist/platform/gitlab/schemas-read.js +274 -0
  210. package/dist/platform/gitlab/schemas-read.js.map +1 -0
  211. package/dist/platform/gitlab/schemas.js +251 -0
  212. package/dist/platform/gitlab/schemas.js.map +1 -0
  213. package/dist/platform/gitlab/signature.js +52 -0
  214. package/dist/platform/gitlab/signature.js.map +1 -0
  215. package/dist/platform/index.js +38 -0
  216. package/dist/platform/index.js.map +1 -0
  217. package/dist/platform/port.js +26 -0
  218. package/dist/platform/port.js.map +1 -0
  219. package/dist/platform/types.js +80 -0
  220. package/dist/platform/types.js.map +1 -0
  221. package/dist/podman.js +119 -0
  222. package/dist/podman.js.map +1 -0
  223. package/dist/project-sync.js +69 -0
  224. package/dist/project-sync.js.map +1 -0
  225. package/dist/prompt.js +47 -0
  226. package/dist/prompt.js.map +1 -0
  227. package/dist/queue-store.js +236 -0
  228. package/dist/queue-store.js.map +1 -0
  229. package/dist/queue.js +129 -0
  230. package/dist/queue.js.map +1 -0
  231. package/dist/redact.js +23 -0
  232. package/dist/redact.js.map +1 -0
  233. package/dist/run-files.js +297 -0
  234. package/dist/run-files.js.map +1 -0
  235. package/dist/run-filters.js +54 -0
  236. package/dist/run-filters.js.map +1 -0
  237. package/dist/run-history-store.js +229 -0
  238. package/dist/run-history-store.js.map +1 -0
  239. package/dist/run-log-sink.js +270 -0
  240. package/dist/run-log-sink.js.map +1 -0
  241. package/dist/run-log.js +97 -0
  242. package/dist/run-log.js.map +1 -0
  243. package/dist/run-usage.js +29 -0
  244. package/dist/run-usage.js.map +1 -0
  245. package/dist/run-view.js +112 -0
  246. package/dist/run-view.js.map +1 -0
  247. package/dist/runner.js +1094 -0
  248. package/dist/runner.js.map +1 -0
  249. package/dist/runtime-apply.js +26 -0
  250. package/dist/runtime-apply.js.map +1 -0
  251. package/dist/runtime-settings.js +71 -0
  252. package/dist/runtime-settings.js.map +1 -0
  253. package/dist/runtime-state.js +19 -0
  254. package/dist/runtime-state.js.map +1 -0
  255. package/dist/server.js +119 -0
  256. package/dist/server.js.map +1 -0
  257. package/dist/settings-store.js +320 -0
  258. package/dist/settings-store.js.map +1 -0
  259. package/dist/status.js +121 -0
  260. package/dist/status.js.map +1 -0
  261. package/dist/stop.js +56 -0
  262. package/dist/stop.js.map +1 -0
  263. package/dist/sweeps.js +87 -0
  264. package/dist/sweeps.js.map +1 -0
  265. package/dist/task-log.js +279 -0
  266. package/dist/task-log.js.map +1 -0
  267. package/dist/task-view.js +62 -0
  268. package/dist/task-view.js.map +1 -0
  269. package/dist/token-store.js +68 -0
  270. package/dist/token-store.js.map +1 -0
  271. package/dist/types/access.js +74 -0
  272. package/dist/types/access.js.map +1 -0
  273. package/dist/types/custom-repos.js +28 -0
  274. package/dist/types/custom-repos.js.map +1 -0
  275. package/dist/types/env-config.js +25 -0
  276. package/dist/types/env-config.js.map +1 -0
  277. package/dist/types/env.js +190 -0
  278. package/dist/types/env.js.map +1 -0
  279. package/dist/types/global-settings.js +178 -0
  280. package/dist/types/global-settings.js.map +1 -0
  281. package/dist/types/lsp-servers.js +145 -0
  282. package/dist/types/lsp-servers.js.map +1 -0
  283. package/dist/types/mcp-config.js +77 -0
  284. package/dist/types/mcp-config.js.map +1 -0
  285. package/dist/types/opencode.js +59 -0
  286. package/dist/types/opencode.js.map +1 -0
  287. package/dist/types/outbound-webhook.js +56 -0
  288. package/dist/types/outbound-webhook.js.map +1 -0
  289. package/dist/types/project-settings.js +114 -0
  290. package/dist/types/project-settings.js.map +1 -0
  291. package/dist/types/provider-registry.js +37 -0
  292. package/dist/types/provider-registry.js.map +1 -0
  293. package/dist/types/runtime-settings.js +78 -0
  294. package/dist/types/runtime-settings.js.map +1 -0
  295. package/dist/usage-stats.js +159 -0
  296. package/dist/usage-stats.js.map +1 -0
  297. package/dist/version.js +19 -0
  298. package/dist/version.js.map +1 -0
  299. package/dist/volume-gc.js +193 -0
  300. package/dist/volume-gc.js.map +1 -0
  301. package/dist/volume-manage.js +191 -0
  302. package/dist/volume-manage.js.map +1 -0
  303. package/dist/volume-store.js +122 -0
  304. package/dist/volume-store.js.map +1 -0
  305. package/dist/webhook.js +1106 -0
  306. package/dist/webhook.js.map +1 -0
  307. package/dist/work-item-auto-store.js +75 -0
  308. package/dist/work-item-auto-store.js.map +1 -0
  309. package/dist/work-item-branch-store.js +37 -0
  310. package/dist/work-item-branch-store.js.map +1 -0
  311. package/migrations/0001_initial.sql +59 -0
  312. package/migrations/0002_queue_status.sql +15 -0
  313. package/migrations/0003_work_item_base_branch.sql +14 -0
  314. package/migrations/0004_project_settings.sql +15 -0
  315. package/migrations/0005_global_settings.sql +34 -0
  316. package/migrations/0006_model_variants.sql +8 -0
  317. package/migrations/0007_container_error_logs.sql +21 -0
  318. package/migrations/0008_project_access_grants.sql +16 -0
  319. package/migrations/0009_drop_imported_legacy.sql +4 -0
  320. package/migrations/0010_container_error_log_model.sql +6 -0
  321. package/migrations/0011_container_error_log_title.sql +5 -0
  322. package/migrations/0012_pending_task_cancel.sql +6 -0
  323. package/migrations/0013_builder_image.sql +6 -0
  324. package/migrations/0014_build_workflow.sql +7 -0
  325. package/migrations/0015_access_rules.sql +24 -0
  326. package/migrations/0016_trigger_policy.sql +17 -0
  327. package/migrations/0017_attachment_upload.sql +8 -0
  328. package/migrations/0018_work_item_branch.sql +11 -0
  329. package/migrations/0019_auto_push_mr.sql +18 -0
  330. package/migrations/0020_outbound_webhooks.sql +47 -0
  331. package/migrations/0021_runtime_settings.sql +12 -0
  332. package/migrations/0022_auto_resolve_conflicts.sql +17 -0
  333. package/migrations/0023_ci_fail_auto_report.sql +27 -0
  334. package/migrations/0024_work_item_auto_override.sql +22 -0
  335. package/migrations/0025_run_history.sql +37 -0
  336. package/migrations/0026_run_history_task_seq.sql +6 -0
  337. package/migrations/0027_custom_repos.sql +11 -0
  338. package/migrations/0028_run_usage.sql +14 -0
  339. package/migrations/0029_drop_ocr_langs.sql +6 -0
  340. package/migrations/0030_mr_assignee.sql +9 -0
  341. package/migrations/0031_lsp_servers.sql +14 -0
  342. package/opencode-config/managed/devbot-agent-policy.md +61 -0
  343. package/opencode-config/managed/opencode.json +77 -0
  344. package/opencode-config/opencode.json +211 -0
  345. package/opencode-config/plugins/devbot-path-guard.js +224 -0
  346. package/package.json +158 -4
  347. package/public/assets/DashboardSidebarCollapse-rIuzCmVm.js +1 -0
  348. package/public/assets/DashboardToolbar-DFCUqZ4Z.js +1 -0
  349. package/public/assets/ErrorLogModal.vue_vue_type_script_setup_true_lang-BCyNIf1E.js +1 -0
  350. package/public/assets/ErrorLogsPage-DyYK2Rop.js +1 -0
  351. package/public/assets/LogSlideover.vue_vue_type_script_setup_true_lang-33xie7TE.js +1 -0
  352. package/public/assets/OverviewPage-CFfa0uwg.js +1 -0
  353. package/public/assets/PageGrid-CBROIU87.js +1 -0
  354. package/public/assets/PeriodRangePicker.vue_vue_type_script_setup_true_lang-DiFUNem7.js +1 -0
  355. package/public/assets/ProjectDetailPage-CreyvUit.js +1 -0
  356. package/public/assets/ProjectsPage-CFx8-elJ.js +1 -0
  357. package/public/assets/RunDetailSlideover.vue_vue_type_script_setup_true_lang-DopYYmCT.js +1 -0
  358. package/public/assets/RunsPage-CRnnXU4P.js +1 -0
  359. package/public/assets/Select-Bvx32Diz.js +1 -0
  360. package/public/assets/SelectMenu-BjMxc4VB.js +1 -0
  361. package/public/assets/SettingsPage-Bb6LM3wO.js +60 -0
  362. package/public/assets/TimeCell.vue_vue_type_script_setup_true_lang-DW_UB0Fu.js +1 -0
  363. package/public/assets/UsagePage-BLMDEsv-.js +332 -0
  364. package/public/assets/UsagePage-DodEeAjB.css +1 -0
  365. package/public/assets/VolumesPage-CxcLc3L2.js +1 -0
  366. package/public/assets/WebhookConfigPanel.vue_vue_type_script_setup_true_lang-B2V2GF6b.js +1 -0
  367. package/public/assets/WorkItemCell.vue_vue_type_script_setup_true_lang-BQn6vTjd.js +1 -0
  368. package/public/assets/index-BMbMibyb.js +4 -0
  369. package/public/assets/index-C5RBBrPj.js +30 -0
  370. package/public/assets/index-CgX29UgC.css +1 -0
  371. package/public/assets/usage-format-jsb31_O8.js +1 -0
  372. package/public/assets/useManagedProjects-8rtjRqVJ.js +1 -0
  373. package/public/assets/useRouteTab-DfwrcMic.js +1 -0
  374. package/public/favicon.ico +0 -0
  375. package/public/favicon.png +0 -0
  376. package/public/index.html +15 -0
@@ -0,0 +1,1106 @@
1
+ import { config } from "./config.js";
2
+ import { log } from "./logger.js";
3
+ import { enqueue, isFinalRunOutcome } from "./queue.js";
4
+ import { acquireGlobalSlot, releaseGlobalSlot, globalQueueDepth, globalQueueMax, globalQueuePosition } from "./global-queue.js";
5
+ import { run, runForcePushOnly, runPushOnly, runMrOnly, runFixConflictsOnly, ERROR_REPLY, destroyWorkItemVolume } from "./runner.js";
6
+ import { getPlatform } from "./platform/index.js";
7
+ import { redact } from "./redact.js";
8
+ import { recordErrorLog } from "./error-log-store.js";
9
+ import { recordRunHistory } from "./run-history-store.js";
10
+ import { beginRunLog, finishRunLogFor } from "./run-log.js";
11
+ import { drainUsage } from "./run-usage.js";
12
+ import { zeroUsage } from "./opencode.js";
13
+ import { getToken } from "./token-store.js";
14
+ import { isDuplicateWebhook } from "./idempotency.js";
15
+ import { parseMention, resolveSubagent } from "./mention.js";
16
+ import { handleResetNote } from "./agents.js";
17
+ import { handleStopNote } from "./stop.js";
18
+ import { handleHelpNote } from "./help.js";
19
+ import { handleAutoCommands } from "./auto-commands.js";
20
+ import { hasUsableConfig } from "./global-settings-store.js";
21
+ import { resolveMinRole, resolveRequireAuthorOrAssignee, resolveModelForMode, resolveVariantForMode } from "./settings-store.js";
22
+ import { autoFeatureEnabled } from "./work-item-auto-store.js";
23
+ import { AUTO_FEATURES } from "./auto-features.js";
24
+ import { buildStatusBody, buildStartNote, taskLabel } from "./status.js";
25
+ import { resolveWorkBranch } from "./git.js";
26
+ import { resolveAccessRule } from "./access-rule-store.js";
27
+ import { permissionAtLeast, roleLabel } from "./types/access.js";
28
+ import { appendPending, countPendingForWorkItem, deletePending, countQueuedGlobal, getTask, listInFlight } from "./queue-store.js";
29
+ import { emitRunOutcome, emitRunStarted, shouldEmitRunStarted } from "./outbound-webhook.js";
30
+ import { deleteVolumeRow } from "./volume-store.js";
31
+ import { deleteBaseBranch } from "./base-branch-store.js";
32
+ import { getWorkBranch } from "./work-item-branch-store.js";
33
+ import { getChangeRequestBySourceBranch, markCiReported } from "./change-request-store.js";
34
+ import { runtime } from "./runtime-state.js";
35
+ // The baseline trigger policy has two configurable gates (global default +
36
+ // per-project override, see settings-store.ts):
37
+ // 1. requireAuthorOrAssignee (default true) — the actor must be the work
38
+ // item's author or assignee.
39
+ // 2. minRole (default "write") — the actor's normalized project permission
40
+ // must be at least this role. Each driver normalizes its role to
41
+ // write/admin/owner (GitLab access_level >= 30, Gitea/ForgeJo collaborator
42
+ // role, GitHub push/maintain/admin).
43
+ // The denial note is built from the resolved policy so it always matches.
44
+ const UNCONFIGURED_MSG = "⚠️ Bot not configured — an admin must set up an AI provider and build/plan models in the WebUI.";
45
+ function toAccessRole(p) {
46
+ return p === "read" || p === "write" || p === "admin" || p === "owner" ? p : "none";
47
+ }
48
+ // Build the denial note from the resolved policy so it names the actual rule.
49
+ function buildDenyMsg(requireAuthorOrAssignee, minRole) {
50
+ const rolePart = minRole === "none" ? "" : `a project ${roleLabel(minRole)} (or higher)`;
51
+ if (requireAuthorOrAssignee && rolePart) {
52
+ return `🚫 Only the work item's author/assignee who is ${rolePart} can trigger this bot.`;
53
+ }
54
+ if (requireAuthorOrAssignee) {
55
+ return "🚫 Only the work item's author/assignee can trigger this bot.";
56
+ }
57
+ if (rolePart) {
58
+ return `🚫 Only ${rolePart} can trigger this bot.`;
59
+ }
60
+ // Both gates disabled: nobody is denied by the baseline; keep a generic note
61
+ // in case this is ever reached via a different path.
62
+ return "🚫 You are not authorized to trigger this bot in this project.";
63
+ }
64
+ // Trigger-authorization gate. Baseline, in order:
65
+ // 1. getMemberPermission — normalized project role (throw → deny)
66
+ // 2. author/assignee gate (when enabled) — getWorkItem (throw → deny)
67
+ // Then a per-project access rule (matching the actor's numeric id, then their
68
+ // normalized role) may replace the role gate for that subject with
69
+ // full/plan/denied. A rule cannot bypass the author/assignee gate.
70
+ // Denial posts an error comment (in-thread when a triggering comment exists,
71
+ // top-level otherwise — e.g. work-item-creation mentions). "ignore" posts
72
+ // nothing. Policy and rules are read live, so changes take effect immediately.
73
+ async function ensureCanTrigger(repoId, workItemNumber, actor, triggeringCommentId, actorPermissionHint) {
74
+ const p = getPlatform();
75
+ let permission;
76
+ if (actorPermissionHint) {
77
+ // The forge already computed the actor's permission for the signed payload
78
+ // (Gitea/ForgeJo). Those forges restrict the per-user permission endpoint to
79
+ // site admins, repo admins, and the user themselves, so a bot PAT would get
80
+ // 403 for anyone else; trusting the payload hint avoids a false deny.
81
+ permission = actorPermissionHint;
82
+ }
83
+ else {
84
+ try {
85
+ permission = await p.workItems.getMemberPermission(repoId, actor);
86
+ }
87
+ catch (e) {
88
+ log.warn("trigger-auth member lookup failed", { repoId, userId: actor.id, err: e });
89
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, buildDenyMsg(true, "write"));
90
+ return "deny";
91
+ }
92
+ }
93
+ const requireAuthorOrAssignee = resolveRequireAuthorOrAssignee(repoId);
94
+ const minRole = resolveMinRole(repoId);
95
+ const denyMsg = buildDenyMsg(requireAuthorOrAssignee, minRole);
96
+ // Gate 1: author/assignee (skipped when disabled).
97
+ if (requireAuthorOrAssignee) {
98
+ try {
99
+ const issue = await p.workItems.getWorkItem(repoId, workItemNumber);
100
+ const isAuthorOrAssignee = actor.id === issue.author?.id || (issue.assignees ?? []).some((a) => a.id === actor.id);
101
+ if (!isAuthorOrAssignee) {
102
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, denyMsg);
103
+ return "deny";
104
+ }
105
+ }
106
+ catch (e) {
107
+ log.warn("trigger-auth issue lookup failed", { repoId, workItemNumber, err: e });
108
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, denyMsg);
109
+ return "deny";
110
+ }
111
+ }
112
+ // A matching per-project rule replaces the role gate for that subject. An
113
+ // exact user rule beats a role rule.
114
+ const rule = resolveAccessRule(repoId, { userId: actor.id, permission: toAccessRole(permission) });
115
+ if (rule) {
116
+ if (rule.level === "denied") {
117
+ log.info("trigger ignored: denied by access rule", {
118
+ repoId, userId: actor.id, subjectType: rule.subjectType, subjectId: rule.subjectId,
119
+ });
120
+ return "ignore";
121
+ }
122
+ log.debug("trigger allowed by access rule", {
123
+ repoId, userId: actor.id, subjectType: rule.subjectType, subjectId: rule.subjectId, level: rule.level,
124
+ });
125
+ return rule.level;
126
+ }
127
+ // Gate 2: minimum role.
128
+ if (!permissionAtLeast(toAccessRole(permission), minRole)) {
129
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, denyMsg);
130
+ return "deny";
131
+ }
132
+ return "full";
133
+ }
134
+ // True when a mention asks for a build-level action (build mode or any
135
+ // force-push / push / mr / fix-conflicts). A plan-only subject is dropped
136
+ // silently for these.
137
+ function wantsBuild(parsed) {
138
+ return parsed.mode === "build" || parsed.forcePush || parsed.push || parsed.mr || parsed.fixConflicts;
139
+ }
140
+ // True when the mention requests any run at all (even a bare /base or /branch
141
+ // mention dispatches a plan run today). A mention carrying only auto-* commands
142
+ // is handled and dispatches nothing.
143
+ function hasRunIntent(parsed) {
144
+ return (parsed.mode === "build" ||
145
+ parsed.prompt.length > 0 ||
146
+ parsed.agents.length > 0 ||
147
+ parsed.forcePush ||
148
+ parsed.push ||
149
+ parsed.mr ||
150
+ parsed.fixConflicts ||
151
+ parsed.baseBranch !== undefined ||
152
+ parsed.workBranch !== undefined);
153
+ }
154
+ // Drain accumulated token usage only when this attempt makes the run final; a
155
+ // retry keeps the accumulator intact so all attempts sum onto the one history
156
+ // row. Reading zero (no usage recorded) is harmless.
157
+ function drainFinalUsage(workItemId, outcome, seq) {
158
+ const attempts = getTask(seq)?.attempts ?? 0;
159
+ if (!isFinalRunOutcome(outcome.kind, attempts, runtime().maxRetries))
160
+ return zeroUsage();
161
+ return drainUsage(workItemId);
162
+ }
163
+ // Persist a settled run to the run-history store (work item #61), but only when
164
+ // it is final (terminal/ok/cancelled, or a transient with retries exhausted or
165
+ // disabled) — retries collapse to one row. Returns the new row id (for the
166
+ // error-log cross-reference), or undefined when nothing was recorded. Never
167
+ // throws — a history write failure must not affect the run outcome.
168
+ function persistRunHistory(input, seq, outcome, runLog, usage) {
169
+ const attempts = getTask(seq)?.attempts ?? 0;
170
+ if (!isFinalRunOutcome(outcome.kind, attempts, runtime().maxRetries))
171
+ return undefined;
172
+ const status = outcome.kind === "ok" ? "ok" : outcome.kind === "cancelled" ? "cancelled" : "failed";
173
+ const detail = outcome.kind === "transient" || outcome.kind === "terminal" ? outcome.failure : undefined;
174
+ const fallbackMessage = outcome.kind === "ok" || outcome.kind === "cancelled" ? "" : outcome.reason;
175
+ const task = getTask(seq);
176
+ try {
177
+ return recordRunHistory({
178
+ taskSeq: seq,
179
+ projectId: input.projectId,
180
+ workItemId: input.workItemId,
181
+ workItemNumber: input.workItemNumber,
182
+ workItemTitle: input.workItemTitle,
183
+ mode: input.mode,
184
+ model: detail?.model || resolveModelForMode(input.projectId, input.mode),
185
+ variant: detail?.variant || resolveVariantForMode(input.projectId, input.mode),
186
+ status,
187
+ phase: detail?.phase ?? "",
188
+ kind: detail?.kind ?? outcome.kind,
189
+ exitCode: detail?.exitCode ?? null,
190
+ message: detail?.message ?? fallbackMessage,
191
+ attempts,
192
+ enqueuedAt: task?.enqueued_at ?? 0,
193
+ startedAt: task?.claimed_at ?? 0,
194
+ logFile: runLog.logFile,
195
+ logTruncated: runLog.truncated,
196
+ inputTokens: usage.input,
197
+ outputTokens: usage.output,
198
+ reasoningTokens: usage.reasoning,
199
+ cacheReadTokens: usage.cacheRead,
200
+ cacheWriteTokens: usage.cacheWrite,
201
+ costUsd: usage.costUsd,
202
+ });
203
+ }
204
+ catch (e) {
205
+ log.warn("run history persist failed", { workItemId: input.workItemId, seq, err: e });
206
+ return undefined;
207
+ }
208
+ }
209
+ // Persist a failed run to the error-log store, but only when the failure is
210
+ // final: a terminal outcome, or a transient one with retries exhausted (or
211
+ // retries disabled). Retries that will run again are not recorded. Never
212
+ // throws — an error-log write failure must not affect the run outcome.
213
+ // `runLog`/`historyId` connect the row to the shared per-run log file (work item
214
+ // #61): when a file was captured the row carries only the reference, and the
215
+ // detail view reads the full multi-phase run from it.
216
+ function persistFinalFailure(input, seq, outcome, fallbackMessage, runLog, historyId) {
217
+ if (outcome.kind === "ok" || outcome.kind === "cancelled")
218
+ return;
219
+ if (outcome.kind === "transient") {
220
+ const attempts = getTask(seq)?.attempts ?? 0;
221
+ if (!isFinalRunOutcome("transient", attempts, runtime().maxRetries))
222
+ return;
223
+ }
224
+ const detail = outcome.failure ?? {
225
+ phase: "run",
226
+ kind: outcome.kind,
227
+ exitCode: null,
228
+ message: fallbackMessage ?? outcome.reason,
229
+ logs: "",
230
+ model: "",
231
+ variant: "",
232
+ };
233
+ const logFile = runLog?.logFile ?? "";
234
+ try {
235
+ recordErrorLog({
236
+ taskSeq: seq,
237
+ workItemId: input.workItemId,
238
+ // The error-log store (and its `work_item_iid` column) predates the
239
+ // platform abstraction's `iid`→`number` rename; the value is the same
240
+ // project-internal work item number.
241
+ workItemNumber: input.workItemNumber,
242
+ projectId: input.projectId,
243
+ mode: input.mode,
244
+ phase: detail.phase,
245
+ kind: detail.kind,
246
+ exitCode: detail.exitCode,
247
+ message: detail.message,
248
+ // When a shared run-log file exists it carries the full run, so the row
249
+ // stores no inline body (the detail view prefers the file). A run with no
250
+ // captured file keeps the redacted detail body as a fallback.
251
+ logs: logFile ? "" : detail.logs,
252
+ truncated: false,
253
+ model: detail.model,
254
+ variant: detail.variant,
255
+ workItemTitle: input.workItemTitle,
256
+ logFile,
257
+ ...(historyId !== undefined ? { runHistoryId: historyId } : {}),
258
+ });
259
+ }
260
+ catch (e) {
261
+ log.warn("error log persist failed", { workItemId: input.workItemId, err: e });
262
+ }
263
+ }
264
+ // The `EmitRunInput` shared by the started and terminal events. The exact
265
+ // model/variant resolved for the run's mode is captured here so both events
266
+ // report the same values (a mid-run settings edit can't make them disagree).
267
+ function emitInputFor(input) {
268
+ const mode = input.mode;
269
+ return {
270
+ projectId: input.projectId,
271
+ workItemId: input.workItemId,
272
+ workItemNumber: input.workItemNumber,
273
+ mode,
274
+ model: resolveModelForMode(input.projectId, mode),
275
+ variant: resolveVariantForMode(input.projectId, mode),
276
+ };
277
+ }
278
+ // Emit an outbound webhook event for a settled run, but only once it is final
279
+ // (terminal/ok/cancelled, or a transient with retries exhausted). Mirrors the
280
+ // error-log finality rule so retries never produce duplicate notifications.
281
+ // Never throws — a webhook problem must not affect the run outcome.
282
+ function emitFinalOutcome(input, seq, outcome) {
283
+ const attempts = getTask(seq)?.attempts ?? 0;
284
+ if (!isFinalRunOutcome(outcome.kind, attempts, runtime().maxRetries))
285
+ return;
286
+ const detail = outcome.kind === "ok" || outcome.kind === "cancelled" ? undefined : outcome.failure;
287
+ const based = emitInputFor(input);
288
+ emitRunOutcome({
289
+ ...based,
290
+ model: detail?.model || based.model,
291
+ variant: detail?.variant || based.variant,
292
+ }, {
293
+ kind: outcome.kind,
294
+ ...(detail
295
+ ? { failure: { phase: detail.phase, kind: detail.kind, message: detail.message } }
296
+ : {}),
297
+ });
298
+ }
299
+ // Emit `run.started` once per task lifecycle, immediately before the task
300
+ // executes (after global-gate admission). A transient retry bumps the row's
301
+ // attempts and is skipped here; the terminal event already covers the retry
302
+ // outcome. Never throws.
303
+ function emitStartedIfFirstAttempt(input, seq) {
304
+ if (!shouldEmitRunStarted(getTask(seq)?.attempts ?? 0))
305
+ return;
306
+ emitRunStarted(emitInputFor(input));
307
+ }
308
+ function comment(repoId, number, body) {
309
+ getPlatform()
310
+ .workItems.postComment(repoId, number, body)
311
+ .catch((e) => log.warn("note failed", { repoId, number, err: e }));
312
+ }
313
+ // Project a durable queue row into the pure status renderer's shape. The
314
+ // payload is parsed defensively: a corrupt row still renders (plan / empty
315
+ // prompt) instead of failing the whole status note.
316
+ function toStatusTask(row) {
317
+ let input = {};
318
+ try {
319
+ const parsed = JSON.parse(row.payload);
320
+ if (parsed !== null && typeof parsed === "object") {
321
+ input = parsed;
322
+ }
323
+ }
324
+ catch {
325
+ /* corrupt payload — keep the defaults */
326
+ }
327
+ return {
328
+ seq: row.seq,
329
+ label: taskLabel({
330
+ mode: input.mode === "build" ? "build" : "plan",
331
+ pushOnly: input.pushOnly === true,
332
+ mrOnly: input.mrOnly === true,
333
+ forcePushOnly: input.forcePushOnly === true,
334
+ fixConflictsOnly: input.fixConflictsOnly === true,
335
+ }),
336
+ prompt: typeof input.prompt === "string" ? input.prompt : "",
337
+ attempts: row.attempts,
338
+ enqueuedAt: row.enqueued_at,
339
+ claimedAt: row.claimed_at,
340
+ globalPosition: row.position,
341
+ };
342
+ }
343
+ // /status (work item #103): post one local snapshot and dispatch nothing. The
344
+ // collector reads only SQLite stores and in-memory gate state; no forge data
345
+ // calls are made beyond the trigger gate and the reply itself.
346
+ async function handleStatusNote(projectId, workItemNumber, workItemId, triggeringNoteId) {
347
+ try {
348
+ const rows = listInFlight().filter((r) => r.work_item_id === workItemId);
349
+ const runningRow = rows.find((r) => r.status === "running") ?? null;
350
+ const branch = resolveWorkBranch(getWorkBranch(workItemId), workItemNumber);
351
+ const changeRequest = getChangeRequestBySourceBranch(projectId, branch);
352
+ const depth = globalQueueDepth();
353
+ const autoFeatures = Object.fromEntries(AUTO_FEATURES.map((f) => [f, autoFeatureEnabled(projectId, workItemId, f)]));
354
+ const body = buildStatusBody({
355
+ workItemNumber,
356
+ branch,
357
+ changeRequestNumber: changeRequest?.cr_number ?? null,
358
+ running: runningRow ? toStatusTask(runningRow) : null,
359
+ runningGatePosition: runningRow ? globalQueuePosition(runningRow.seq) : null,
360
+ queued: rows.filter((r) => r.status === "queued").map(toStatusTask),
361
+ gate: { active: depth.active, waiting: depth.waiting, max: globalQueueMax() },
362
+ maxQueuePerWorkItem: runtime().maxQueuePerWorkItem,
363
+ maxRetries: runtime().maxRetries,
364
+ autoFeatures,
365
+ });
366
+ await getPlatform().workItems.postErrorReply(projectId, workItemNumber, triggeringNoteId, body);
367
+ }
368
+ catch (e) {
369
+ log.warn("status reply failed", { projectId, workItemNumber, err: e });
370
+ }
371
+ }
372
+ export function dispatch(d) {
373
+ const workItemId = d.workItemId;
374
+ const queuedBehind = countPendingForWorkItem(workItemId) > 0;
375
+ const seq = appendPending(workItemId, JSON.stringify({ ...d, queuedBehind }));
376
+ // Global queue overflow cap (MAX_GLOBAL_QUEUE). 0 = unbounded (legacy).
377
+ if (runtime().maxGlobalQueue > 0) {
378
+ const queued = countQueuedGlobal();
379
+ if (queued > runtime().maxGlobalQueue) {
380
+ deletePending(seq);
381
+ log.warn("global queue overflow", { workItemId, queued, cap: runtime().maxGlobalQueue });
382
+ if (runtime().queueFeedback)
383
+ comment(d.projectId, d.workItemNumber, "⚠️ Global queue full, retry later.");
384
+ return;
385
+ }
386
+ }
387
+ if (runtime().queueFeedback) {
388
+ if (d.source === "ci-failure" && d.ciFailure) {
389
+ const mr = d.ciFailure.changeRequestNumber;
390
+ comment(d.projectId, d.workItemNumber, `⚙️ CI failed${mr ? ` on MR !${mr}` : ""} — analyzing the logs.`);
391
+ }
392
+ else {
393
+ comment(d.projectId, d.workItemNumber, `📨 Got it — ${taskLabel(d)} task queued.`);
394
+ }
395
+ }
396
+ // The handler is a single function per work item, reused for all rows of
397
+ // that key. It decodes the payload to get the DispatchInput, acquires the
398
+ // global gate, runs, and returns a RunOutcome.
399
+ const handler = (claimedSeq, payload) => {
400
+ let input;
401
+ try {
402
+ input = JSON.parse(payload);
403
+ }
404
+ catch {
405
+ log.error("dispatch handler: payload parse failed", { seq: claimedSeq });
406
+ return Promise.resolve({ kind: "terminal", reason: "payload parse failed" });
407
+ }
408
+ // Run-log sink result for this attempt. Declared outside the .then so the
409
+ // .catch (unexpected runner error) can still record the captured file: the
410
+ // inner finally closes the sink, and a second finish is a no-op.
411
+ let runLog = { logFile: "", truncated: false };
412
+ // Guard so a throw after the terminal persist (e.g. in emitFinalOutcome)
413
+ // cannot make the catch record a second history/error row for the same seq.
414
+ let finalRecorded = false;
415
+ // "Waited" is measured from the row's enqueue time, not from gate claim.
416
+ const row = getTask(claimedSeq);
417
+ const enqueuedAt = row?.enqueued_at ?? Date.now();
418
+ const attempts = row?.attempts ?? 0;
419
+ const slot = acquireGlobalSlot({
420
+ seq: claimedSeq,
421
+ enqueuedAt,
422
+ onAdvance: (_pos, isNext) => {
423
+ if (!isNext || !runtime().queueFeedback)
424
+ return;
425
+ comment(input.projectId, input.workItemNumber, "⏳ You're next.");
426
+ },
427
+ });
428
+ if (slot.queued && runtime().queueFeedback) {
429
+ comment(input.projectId, input.workItemNumber, `⏳ Queued globally (position ${slot.position}).`);
430
+ }
431
+ return slot.ready
432
+ .then(async () => {
433
+ // Reset per attempt: the handler closure is reused across rows of the
434
+ // same work item, so a stale reference must not leak into this attempt.
435
+ runLog = { logFile: "", truncated: false };
436
+ finalRecorded = false;
437
+ if (runtime().queueFeedback) {
438
+ const remaining = listInFlight()
439
+ .filter((r) => r.work_item_id === input.workItemId && r.status === "queued")
440
+ .map(toStatusTask);
441
+ const note = buildStartNote({
442
+ label: taskLabel(input),
443
+ prompt: input.prompt,
444
+ enqueuedAt,
445
+ startedAt: Date.now(),
446
+ queuedBehind: input.queuedBehind === true,
447
+ gateQueued: slot.queued,
448
+ attempts,
449
+ remaining,
450
+ });
451
+ if (note)
452
+ comment(input.projectId, input.workItemNumber, note);
453
+ }
454
+ // Outbound webhook: one `run.started` per task lifecycle, emitted just
455
+ // before the task executes (first attempt only — retries are covered by
456
+ // the terminal event).
457
+ emitStartedIfFirstAttempt(input, claimedSeq);
458
+ // Open this attempt's run-log file (a retry appends an attempt header to
459
+ // the same per-seq file). The sink captures every phase container's
460
+ // output until finishRunLogFor closes it below.
461
+ beginRunLog(input.workItemId, claimedSeq);
462
+ let outcome;
463
+ try {
464
+ outcome = input.mrOnly
465
+ ? await runMrOnly(input)
466
+ : input.fixConflictsOnly
467
+ ? await runFixConflictsOnly(input)
468
+ : input.pushOnly
469
+ ? await runPushOnly(input)
470
+ : input.forcePushOnly
471
+ ? await runForcePushOnly(input)
472
+ : await run(input);
473
+ }
474
+ finally {
475
+ // Close the sink on every exit path (including a thrown runner error)
476
+ // so the file reference is available to the catch below.
477
+ runLog = finishRunLogFor(input.workItemId);
478
+ }
479
+ // Post a retry note if the outcome was transient and retries remain.
480
+ if (outcome.kind === "transient" && runtime().maxRetries > 0) {
481
+ const attempts = getTask(claimedSeq)?.attempts ?? 0;
482
+ if (attempts < runtime().maxRetries) {
483
+ comment(input.projectId, input.workItemNumber, `🔁 Retrying (attempt ${attempts + 1}/${runtime().maxRetries}, transient: ${outcome.reason}).`);
484
+ }
485
+ else {
486
+ comment(input.projectId, input.workItemNumber, `⚠️ Gave up after ${runtime().maxRetries} attempts: ${outcome.reason}.`);
487
+ }
488
+ }
489
+ // Persist run history + the error (when final) with the shared log-file
490
+ // reference, so retries don't create duplicate rows. Token usage is
491
+ // drained only when this attempt settles the run for good: a retry keeps
492
+ // accumulating in the per-work-item accumulator.
493
+ const usage = drainFinalUsage(input.workItemId, outcome, claimedSeq);
494
+ const historyId = persistRunHistory(input, claimedSeq, outcome, runLog, usage);
495
+ persistFinalFailure(input, claimedSeq, outcome, undefined, runLog, historyId);
496
+ finalRecorded = true;
497
+ // Outbound webhook: same finality rule, one notification per run.
498
+ emitFinalOutcome(input, claimedSeq, outcome);
499
+ return outcome;
500
+ })
501
+ .catch((e) => {
502
+ log.error("runner error", e);
503
+ comment(input.projectId, input.workItemNumber, ERROR_REPLY);
504
+ const outcome = { kind: "terminal", reason: "unexpected runner error" };
505
+ // The inner finally may already have closed the sink; a second call is a
506
+ // no-op. Keep whichever result exists.
507
+ if (!runLog.logFile)
508
+ runLog = finishRunLogFor(input.workItemId);
509
+ // If the .then already recorded this seq as final, do not record again —
510
+ // a throw in a best-effort step after the persist must not duplicate the
511
+ // history/error rows.
512
+ if (!finalRecorded) {
513
+ const historyId = persistRunHistory(input, claimedSeq, outcome, runLog, drainFinalUsage(input.workItemId, outcome, claimedSeq));
514
+ persistFinalFailure(input, claimedSeq, outcome, e instanceof Error ? e.message : String(e), runLog, historyId);
515
+ emitFinalOutcome(input, claimedSeq, outcome);
516
+ }
517
+ return outcome;
518
+ })
519
+ .finally(() => {
520
+ releaseGlobalSlot();
521
+ });
522
+ };
523
+ const result = enqueue(workItemId, handler, { maxPending: runtime().maxQueuePerWorkItem });
524
+ if (!result.accepted) {
525
+ deletePending(seq);
526
+ log.warn("queue overflow", { workItemId });
527
+ if (runtime().queueFeedback)
528
+ comment(d.projectId, d.workItemNumber, "⚠️ Queue full, retry later.");
529
+ return;
530
+ }
531
+ if (result.position > 0 && runtime().queueFeedback) {
532
+ comment(d.projectId, d.workItemNumber, `⏳ Queued (position ${result.position}).`);
533
+ }
534
+ }
535
+ function mentionRegex() {
536
+ return new RegExp(`@${config.BOT_USERNAME}\\b`);
537
+ }
538
+ function resolveSubagentFor(parsed) {
539
+ const r = resolveSubagent(parsed);
540
+ return { ...r, agentNames: parsed.agents.map((a) => a.name) };
541
+ }
542
+ // /base Step-0 gate: validate the parsed /base <branch> before dispatch.
543
+ async function checkBaseBranch(repoId, workItemNumber, triggeringCommentId, parsed) {
544
+ const p = getPlatform();
545
+ if (parsed.baseError) {
546
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, parsed.baseError).catch((e) => log.warn("base-error reply failed", e));
547
+ return false;
548
+ }
549
+ if (parsed.baseBranch) {
550
+ try {
551
+ const exists = await p.repository.branchExists(repoId, parsed.baseBranch);
552
+ if (!exists) {
553
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, `⚠️ Branch \`${parsed.baseBranch}\` not found in this project.`).catch((e) => log.warn("base-missing reply failed", e));
554
+ return false;
555
+ }
556
+ }
557
+ catch (e) {
558
+ log.warn("base branch existence check failed", { repoId, branch: parsed.baseBranch, err: e });
559
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, `⚠️ Could not verify branch \`${parsed.baseBranch}\`; please retry.`).catch((e) => log.warn("base-verify reply failed", e));
560
+ return false;
561
+ }
562
+ }
563
+ return true;
564
+ }
565
+ // /branch Step-0 gate: validate and collision-check the parsed /branch <name>
566
+ // before dispatch. Order is load-bearing:
567
+ // 1. charset error (parseMention set branchError) → reject in-thread.
568
+ // 2. no /branch → allow.
569
+ // 3. a name is already stored for this work item → allow. The store is
570
+ // authoritative (first-write-wins) and the runner posts the ignored-note;
571
+ // checking the remote here would reject against the branch we pushed.
572
+ // 4. first naming → reject if the branch already exists remotely (fail
573
+ // closed on a forge error) so we never clobber an unknown branch.
574
+ async function checkWorkBranch(repoId, workItemNumber, workItemId, triggeringCommentId, parsed) {
575
+ const p = getPlatform();
576
+ if (parsed.branchError) {
577
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, parsed.branchError).catch((e) => log.warn("branch-error reply failed", e));
578
+ return false;
579
+ }
580
+ if (!parsed.workBranch)
581
+ return true;
582
+ if (getWorkBranch(workItemId))
583
+ return true;
584
+ try {
585
+ const exists = await p.repository.branchExists(repoId, parsed.workBranch);
586
+ if (exists) {
587
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, `⚠️ /branch '${parsed.workBranch}' — this branch already exists. Pick another name, or omit /branch to use the default wi-${workItemNumber}.`).catch((e) => log.warn("branch-collision reply failed", e));
588
+ return false;
589
+ }
590
+ }
591
+ catch (e) {
592
+ log.warn("work branch existence check failed", { repoId, branch: parsed.workBranch, err: e });
593
+ await p.workItems.postErrorReply(repoId, workItemNumber, triggeringCommentId, `⚠️ Could not verify branch \`${parsed.workBranch}\`; please retry.`).catch((e) => log.warn("branch-verify reply failed", e));
594
+ return false;
595
+ }
596
+ return true;
597
+ }
598
+ function repoCloneUrl(ev) {
599
+ return ev.repo.cloneUrl;
600
+ }
601
+ async function handleCommentEvent(ev) {
602
+ if (ev.kind !== "comment" || !ev.workItem || !ev.actor || ev.body === undefined)
603
+ return false;
604
+ // Self-loop guard: ignore comments authored by the bot.
605
+ if (ev.actor.id === config.BOT_USER_ID)
606
+ return false;
607
+ if (!mentionRegex().test(ev.body))
608
+ return false;
609
+ const commentId = ev.commentId;
610
+ const repoId = ev.repo.id;
611
+ const number = ev.workItem.number;
612
+ // Trigger-authorization gate runs before /reset, /agent, and dispatch —
613
+ // "general rule for all mentions". Fail-closed; denial posted in-thread.
614
+ // An "ignore" (rule: denied) returns handled=false → 204, no note.
615
+ const auth = await ensureCanTrigger(repoId, number, ev.actor, commentId, ev.actorPermission);
616
+ if (auth === "deny")
617
+ return true;
618
+ if (auth === "ignore")
619
+ return false;
620
+ const parsed = parseMention(ev.body);
621
+ // A plan-only subject may not run build-level tasks. The mention is dropped
622
+ // silently (like denied) — the gate already matched them deliberately.
623
+ if (auth === "plan" && wantsBuild(parsed)) {
624
+ log.info("trigger ignored: plan-only subject requested build", { repoId, userId: ev.actor.id, workItemNumber: number });
625
+ return false;
626
+ }
627
+ if (parsed.reset) {
628
+ handleResetNote(repoId, number, ev.workItem.id, commentId);
629
+ return true;
630
+ }
631
+ // /stop short-circuits after /reset (which parseMention already prioritizes):
632
+ // cancel this work item's in-flight/queued runs. Authorization is already
633
+ // enforced by ensureCanTrigger; no provider/config gate — you can always stop.
634
+ if (parsed.stop) {
635
+ handleStopNote(repoId, number, ev.workItem.id, commentId);
636
+ return true;
637
+ }
638
+ // /help (work item #95) short-circuits after /stop: reply with the command
639
+ // list and dispatch no run. Authorization is already enforced by
640
+ // ensureCanTrigger; no provider/config gate — the list is static text.
641
+ if (parsed.help) {
642
+ await handleHelpNote(repoId, number, commentId);
643
+ return true;
644
+ }
645
+ // /status (work item #103) short-circuits after /help: post the local status
646
+ // snapshot and dispatch no run. Authorization is already enforced by
647
+ // ensureCanTrigger; no provider/config gate — every field is local.
648
+ if (parsed.status) {
649
+ await handleStatusNote(repoId, number, ev.workItem.id, commentId);
650
+ return true;
651
+ }
652
+ // Contextual auto-* commands (#58) are applied and acknowledged before any
653
+ // run in this mention is dispatched, so /build /auto-push off builds with
654
+ // auto-push off. A mention with only auto commands returns here and
655
+ // dispatches nothing. Admin enable (project-off) is checked in the handler.
656
+ if (parsed.autoCommands.length > 0) {
657
+ await handleAutoCommands({
658
+ projectId: repoId,
659
+ workItemNumber: number,
660
+ workItemId: ev.workItem.id,
661
+ ...(commentId !== undefined ? { triggeringNoteId: commentId } : {}),
662
+ commands: parsed.autoCommands,
663
+ ...(ev.actor.username ? { actorUsername: ev.actor.username } : {}),
664
+ });
665
+ if (!hasRunIntent(parsed))
666
+ return true;
667
+ }
668
+ // /fix-conflicts standalone (no /build): re-run the conflict resolver on the
669
+ // existing work branch against its MR's current target, then re-push the
670
+ // resolved branch — no publish-first and no MR create/update. Checked before
671
+ // /mr so an explicit fix wins a combined mention. Needs no base branch,
672
+ // agent, or provider/model config at dispatch; the runner mints MCP context
673
+ // and requires a build model only when conflicts are actually found.
674
+ if (parsed.fixConflicts && parsed.mode !== "build") {
675
+ dispatch({
676
+ projectId: repoId,
677
+ workItemNumber: number,
678
+ workItemId: ev.workItem.id,
679
+ repoHttpUrl: repoCloneUrl(ev),
680
+ mode: "build",
681
+ prompt: "",
682
+ workItemTitle: ev.workItem.title,
683
+ workItemDescription: ev.workItem.description,
684
+ ...(commentId !== undefined ? { triggeringNoteId: commentId } : {}),
685
+ fixConflictsOnly: true,
686
+ });
687
+ return true;
688
+ }
689
+ // /mr standalone (no /build): publish-and-open task. Push the work item's
690
+ // existing branch (if ahead) then create/update the merge request. Takes
691
+ // precedence over a bare /push (an MR needs the branch) and carries
692
+ // /force-push through when combined (/mr /force-push). Needs no base branch,
693
+ // agent, or provider/model config.
694
+ if (parsed.mr && parsed.mode !== "build") {
695
+ dispatch({
696
+ projectId: repoId,
697
+ workItemNumber: number,
698
+ workItemId: ev.workItem.id,
699
+ repoHttpUrl: repoCloneUrl(ev),
700
+ mode: "build",
701
+ prompt: "",
702
+ workItemTitle: ev.workItem.title,
703
+ workItemDescription: ev.workItem.description,
704
+ ...(commentId !== undefined ? { triggeringNoteId: commentId } : {}),
705
+ mrOnly: true,
706
+ ...(parsed.forcePush ? { forcePush: true } : {}),
707
+ });
708
+ return true;
709
+ }
710
+ // /force-push standalone (no /build): a push-only task. It needs no base
711
+ // branch, subagents, or provider/model config — dispatch and return before
712
+ // those gates (a force-push must not be blocked by an unconfigured model).
713
+ if (parsed.forcePush && parsed.mode !== "build") {
714
+ dispatch({
715
+ projectId: repoId,
716
+ workItemNumber: number,
717
+ workItemId: ev.workItem.id,
718
+ repoHttpUrl: repoCloneUrl(ev),
719
+ mode: "build",
720
+ prompt: "",
721
+ workItemTitle: ev.workItem.title,
722
+ workItemDescription: ev.workItem.description,
723
+ ...(commentId !== undefined ? { triggeringNoteId: commentId } : {}),
724
+ forcePushOnly: true,
725
+ });
726
+ return true;
727
+ }
728
+ // /push standalone (no /build): a push-only task (no force). Same rationale as
729
+ // /force-push — publish the existing local branch and return before the
730
+ // /base + provider/model gates.
731
+ if (parsed.push && parsed.mode !== "build") {
732
+ dispatch({
733
+ projectId: repoId,
734
+ workItemNumber: number,
735
+ workItemId: ev.workItem.id,
736
+ repoHttpUrl: repoCloneUrl(ev),
737
+ mode: "build",
738
+ prompt: "",
739
+ workItemTitle: ev.workItem.title,
740
+ workItemDescription: ev.workItem.description,
741
+ ...(commentId !== undefined ? { triggeringNoteId: commentId } : {}),
742
+ pushOnly: true,
743
+ });
744
+ return true;
745
+ }
746
+ // /base Step-0 gate: charset already validated in parseMention; verify the
747
+ // branch exists on the remote before dispatch. Rejects the whole mention
748
+ // (in-thread) on invalid/missing/unverifiable — no run is dispatched.
749
+ if (!(await checkBaseBranch(repoId, number, commentId, parsed)))
750
+ return true;
751
+ // /branch Step-0 gate: charset + remote collision check before dispatch.
752
+ if (!(await checkWorkBranch(repoId, number, ev.workItem.id, commentId, parsed)))
753
+ return true;
754
+ const { prompt, error, agentNames } = resolveSubagentFor(parsed);
755
+ if (error) {
756
+ getPlatform().workItems.postErrorReply(repoId, number, commentId, error).catch((e) => log.warn("unknown-agent reply failed", e));
757
+ return true;
758
+ }
759
+ if (!hasUsableConfig()) {
760
+ getPlatform().workItems.postErrorReply(repoId, number, commentId, UNCONFIGURED_MSG).catch((e) => log.warn("unconfigured reply failed", e));
761
+ return true;
762
+ }
763
+ dispatch({
764
+ projectId: repoId,
765
+ workItemNumber: number,
766
+ workItemId: ev.workItem.id,
767
+ repoHttpUrl: repoCloneUrl(ev),
768
+ mode: parsed.mode,
769
+ prompt,
770
+ workItemTitle: ev.workItem.title,
771
+ workItemDescription: ev.workItem.description,
772
+ ...(agentNames.length > 0 ? { agentNames } : {}),
773
+ ...(commentId !== undefined ? { triggeringNoteId: commentId } : {}),
774
+ ...(parsed.baseBranch ? { baseBranch: parsed.baseBranch } : {}),
775
+ ...(parsed.workBranch ? { workBranch: parsed.workBranch } : {}),
776
+ ...(parsed.forcePush ? { forcePush: true } : {}),
777
+ ...(parsed.push ? { pushRequested: true } : {}),
778
+ ...(parsed.mr ? { mrRequested: true } : {}),
779
+ ...(parsed.fixConflicts ? { fixConflictsRequested: true } : {}),
780
+ });
781
+ return true;
782
+ }
783
+ async function handleWorkItemOpenEvent(ev) {
784
+ if (ev.kind !== "workitem-open" || !ev.workItem || !ev.actor)
785
+ return false;
786
+ const desc = ev.workItem.description ?? "";
787
+ if (!mentionRegex().test(desc))
788
+ return false;
789
+ const repoId = ev.repo.id;
790
+ const number = ev.workItem.number;
791
+ // Trigger-authorization gate. No triggering note on the open path (the
792
+ // mention is the work item description itself), so triggeringCommentId is
793
+ // undefined → denial posts top-level (there's no thread to reply to).
794
+ // A denied rule drops the mention silently (handled=false → 204).
795
+ const auth = await ensureCanTrigger(repoId, number, ev.actor, undefined, ev.actorPermission);
796
+ if (auth === "deny")
797
+ return true;
798
+ if (auth === "ignore")
799
+ return false;
800
+ const parsed = parseMention(desc);
801
+ // A plan-only subject may not run build-level tasks (silent drop).
802
+ if (auth === "plan" && wantsBuild(parsed)) {
803
+ log.info("trigger ignored: plan-only subject requested build", { repoId, userId: ev.actor.id, workItemNumber: number });
804
+ return false;
805
+ }
806
+ if (parsed.reset) {
807
+ handleResetNote(repoId, number, ev.workItem.id, undefined);
808
+ return true;
809
+ }
810
+ // /stop short-circuits after /reset: cancel this work item's in-flight/queued
811
+ // runs. No triggering note on the open path → handleStopNote posts top-level.
812
+ if (parsed.stop) {
813
+ handleStopNote(repoId, number, ev.workItem.id, undefined);
814
+ return true;
815
+ }
816
+ // /help short-circuits after /stop on the open path too: reply with the
817
+ // command list and dispatch no run. No triggering note → reply top-level.
818
+ if (parsed.help) {
819
+ await handleHelpNote(repoId, number, undefined);
820
+ return true;
821
+ }
822
+ // /status short-circuits after /help on the open path too: local snapshot,
823
+ // no run. No triggering note → reply top-level.
824
+ if (parsed.status) {
825
+ await handleStatusNote(repoId, number, ev.workItem.id, undefined);
826
+ return true;
827
+ }
828
+ // Contextual auto-* commands (#58): applied and acknowledged before any run in
829
+ // the description is dispatched; a description with only auto commands
830
+ // dispatches nothing. No triggering note on the open path.
831
+ if (parsed.autoCommands.length > 0) {
832
+ await handleAutoCommands({
833
+ projectId: repoId,
834
+ workItemNumber: number,
835
+ workItemId: ev.workItem.id,
836
+ commands: parsed.autoCommands,
837
+ ...(ev.actor.username ? { actorUsername: ev.actor.username } : {}),
838
+ });
839
+ if (!hasRunIntent(parsed))
840
+ return true;
841
+ }
842
+ // /fix-conflicts standalone (no /build): fix-only task. Same rationale as the
843
+ // note path — dispatch before the /base + provider/model gates. Checked
844
+ // before /mr so an explicit fix wins a combined mention.
845
+ if (parsed.fixConflicts && parsed.mode !== "build") {
846
+ dispatch({
847
+ projectId: repoId,
848
+ workItemNumber: number,
849
+ workItemId: ev.workItem.id,
850
+ repoHttpUrl: repoCloneUrl(ev),
851
+ mode: "build",
852
+ prompt: "",
853
+ workItemTitle: ev.workItem.title,
854
+ workItemDescription: "",
855
+ fixConflictsOnly: true,
856
+ });
857
+ return true;
858
+ }
859
+ // /mr standalone (no /build): publish-and-open task. Same rationale as the
860
+ // note path — dispatch before the /base + provider/model gates. Precedence:
861
+ // /mr wins over /push; /force-push combined is carried through.
862
+ if (parsed.mr && parsed.mode !== "build") {
863
+ dispatch({
864
+ projectId: repoId,
865
+ workItemNumber: number,
866
+ workItemId: ev.workItem.id,
867
+ repoHttpUrl: repoCloneUrl(ev),
868
+ mode: "build",
869
+ prompt: "",
870
+ workItemTitle: ev.workItem.title,
871
+ workItemDescription: "",
872
+ mrOnly: true,
873
+ ...(parsed.forcePush ? { forcePush: true } : {}),
874
+ });
875
+ return true;
876
+ }
877
+ // /force-push standalone (no /build): push-only task. Same rationale as the
878
+ // note path — dispatch before the /base + provider/model gates.
879
+ if (parsed.forcePush && parsed.mode !== "build") {
880
+ dispatch({
881
+ projectId: repoId,
882
+ workItemNumber: number,
883
+ workItemId: ev.workItem.id,
884
+ repoHttpUrl: repoCloneUrl(ev),
885
+ mode: "build",
886
+ prompt: "",
887
+ workItemTitle: ev.workItem.title,
888
+ workItemDescription: "",
889
+ forcePushOnly: true,
890
+ });
891
+ return true;
892
+ }
893
+ // /push standalone (no /build): push-only task (no force). Same rationale as
894
+ // /force-push — dispatch before the /base + provider/model gates.
895
+ if (parsed.push && parsed.mode !== "build") {
896
+ dispatch({
897
+ projectId: repoId,
898
+ workItemNumber: number,
899
+ workItemId: ev.workItem.id,
900
+ repoHttpUrl: repoCloneUrl(ev),
901
+ mode: "build",
902
+ prompt: "",
903
+ workItemTitle: ev.workItem.title,
904
+ workItemDescription: "",
905
+ pushOnly: true,
906
+ });
907
+ return true;
908
+ }
909
+ // /base Step-0 gate (no triggering note on the open path → denial posts
910
+ // top-level). Validates charset + remote existence before dispatch.
911
+ if (!(await checkBaseBranch(repoId, number, undefined, parsed)))
912
+ return true;
913
+ // /branch Step-0 gate (no triggering note on the open path → denial posts
914
+ // top-level). Charset + remote collision check before dispatch.
915
+ if (!(await checkWorkBranch(repoId, number, ev.workItem.id, undefined, parsed)))
916
+ return true;
917
+ const { prompt, error, agentNames } = resolveSubagentFor(parsed);
918
+ if (error) {
919
+ getPlatform().workItems.postErrorReply(repoId, number, undefined, error).catch((e) => log.warn("work-item-hook unknown-agent reply failed", e));
920
+ return true;
921
+ }
922
+ if (!hasUsableConfig()) {
923
+ getPlatform().workItems.postErrorReply(repoId, number, undefined, UNCONFIGURED_MSG).catch((e) => log.warn("unconfigured reply failed", e));
924
+ return true;
925
+ }
926
+ dispatch({
927
+ projectId: repoId,
928
+ workItemNumber: number,
929
+ workItemId: ev.workItem.id,
930
+ repoHttpUrl: repoCloneUrl(ev),
931
+ mode: parsed.mode,
932
+ prompt,
933
+ workItemTitle: ev.workItem.title,
934
+ workItemDescription: "",
935
+ ...(agentNames.length > 0 ? { agentNames } : {}),
936
+ ...(parsed.baseBranch ? { baseBranch: parsed.baseBranch } : {}),
937
+ ...(parsed.workBranch ? { workBranch: parsed.workBranch } : {}),
938
+ ...(parsed.forcePush ? { forcePush: true } : {}),
939
+ ...(parsed.push ? { pushRequested: true } : {}),
940
+ ...(parsed.mr ? { mrRequested: true } : {}),
941
+ ...(parsed.fixConflicts ? { fixConflictsRequested: true } : {}),
942
+ });
943
+ return true;
944
+ }
945
+ async function handleWorkItemCloseEvent(ev) {
946
+ if (ev.kind !== "workitem-close" || !ev.workItem)
947
+ return false;
948
+ const workItemId = ev.workItem.id;
949
+ destroyWorkItemVolume(workItemId)
950
+ .then((r) => {
951
+ log.debug("work-item-close volume destroy", { workItemId, ok: r.ok });
952
+ deleteVolumeRow(workItemId);
953
+ })
954
+ .catch((e) => log.warn("work-item-close volume destroy failed", { workItemId, err: e }));
955
+ try {
956
+ deleteBaseBranch(workItemId);
957
+ }
958
+ catch (e) {
959
+ log.warn("work-item-close base-branch delete failed", { workItemId, err: e });
960
+ }
961
+ return true;
962
+ }
963
+ // CI-failure auto report (work item #57). A failed pipeline webhook on a
964
+ // merge/pull request DevBot created starts a plan run seeded with the failing
965
+ // job logs. This handler is deliberately quiet on every no-op path: an unknown
966
+ // branch, a disabled toggle, or an unresolvable work item must not spam the
967
+ // thread. When it does dispatch, it marks the pipeline reported first so a
968
+ // redelivery cannot start a second run.
969
+ export async function handlePipelineFailureEvent(ev) {
970
+ const projectId = ev.repo.id;
971
+ if (!ev.sourceBranch)
972
+ return false;
973
+ const registered = getChangeRequestBySourceBranch(projectId, ev.sourceBranch);
974
+ if (!registered)
975
+ return false;
976
+ // Effective gate: project/global resolution with the work-item override on
977
+ // top (#58). An admin may have enabled it for this one work item.
978
+ if (!autoFeatureEnabled(projectId, registered.work_item_id, "ciFailAutoReport"))
979
+ return false;
980
+ if (ev.pipelineId !== undefined && registered.last_pipeline_id === ev.pipelineId)
981
+ return false;
982
+ if (ev.commitSha && registered.last_pipeline_sha === ev.commitSha)
983
+ return false;
984
+ const p = getPlatform();
985
+ if (!hasUsableConfig()) {
986
+ log.info("ci-failure event ignored: bot not configured", { projectId });
987
+ return false;
988
+ }
989
+ let workItem;
990
+ try {
991
+ workItem = await p.workItems.getWorkItem(projectId, registered.work_item_number);
992
+ }
993
+ catch (e) {
994
+ log.warn("ci-failure work item lookup failed", { projectId, workItemNumber: registered.work_item_number, err: e });
995
+ return false;
996
+ }
997
+ if (workItem.state !== "open") {
998
+ log.info("ci-failure event ignored: work item not open", { projectId, workItemNumber: registered.work_item_number });
999
+ return false;
1000
+ }
1001
+ // Claim the pipeline before dispatching. If markCiReported reports no row the
1002
+ // registry vanished mid-flight; skip rather than risk a duplicate.
1003
+ if (ev.pipelineId !== undefined) {
1004
+ const claimed = markCiReported({
1005
+ projectId,
1006
+ sourceBranch: ev.sourceBranch,
1007
+ pipelineId: ev.pipelineId,
1008
+ sha: ev.commitSha ?? null,
1009
+ });
1010
+ if (!claimed)
1011
+ return false;
1012
+ }
1013
+ const repoPath = registered.work_item_number;
1014
+ const pipelineRef = ev.sourceBranch;
1015
+ dispatch({
1016
+ projectId,
1017
+ workItemNumber: repoPath,
1018
+ workItemId: registered.work_item_id,
1019
+ repoHttpUrl: ev.repo.cloneUrl,
1020
+ mode: "plan",
1021
+ prompt: "CI failed on the merge/pull request. Analyze the failing job logs and propose a fix.",
1022
+ workItemTitle: workItem.title,
1023
+ workItemDescription: workItem.description,
1024
+ ...(ev.pipelineId !== undefined
1025
+ ? {
1026
+ ciFailure: {
1027
+ pipelineId: ev.pipelineId,
1028
+ ref: pipelineRef,
1029
+ ...(ev.commitSha ? { sha: ev.commitSha } : {}),
1030
+ ...(ev.changeRequestNumber !== undefined ? { changeRequestNumber: ev.changeRequestNumber } : {}),
1031
+ },
1032
+ }
1033
+ : {}),
1034
+ source: "ci-failure",
1035
+ });
1036
+ return true;
1037
+ }
1038
+ function logWebhook(event, status, ctx = {}) {
1039
+ const level = status >= 400 ? "warn" : "info";
1040
+ log[level]("webhook", { event, status, ...ctx });
1041
+ }
1042
+ export function mountWebhook(app) {
1043
+ app.post("/webhook", async (req, res) => {
1044
+ const p = getPlatform();
1045
+ const event = p.ingress.eventName(req.headers);
1046
+ const projectId = p.ingress.projectIdFromBody(req.body);
1047
+ if (projectId === undefined) {
1048
+ logWebhook(event, 401, { reason: "missing project id" });
1049
+ return res.status(401).end();
1050
+ }
1051
+ const stored = getToken(projectId);
1052
+ if (!stored) {
1053
+ logWebhook(event, 401, { reason: "no stored token", projectId });
1054
+ return res.status(401).end();
1055
+ }
1056
+ if (!p.ingress.verifySignature(stored.signingToken, req.headers, req.rawBody, Date.now())) {
1057
+ logWebhook(event, 401, { reason: "signature mismatch", projectId });
1058
+ return res.status(401).end();
1059
+ }
1060
+ // Idempotency: dedupe delivery id within the TTL window so a forge retry
1061
+ // (after a restart mid-run) doesn't double-dispatch the same work.
1062
+ const webhookId = p.ingress.deliveryId(req.headers);
1063
+ if (webhookId && isDuplicateWebhook(webhookId)) {
1064
+ logWebhook(event, 204, { reason: "duplicate delivery" });
1065
+ return res.status(204).end();
1066
+ }
1067
+ if (!event) {
1068
+ logWebhook(undefined, 400);
1069
+ return res.status(400).end();
1070
+ }
1071
+ log.debug("webhook received", {
1072
+ event,
1073
+ tokenReceived: true,
1074
+ body: redact(JSON.stringify(req.body)),
1075
+ });
1076
+ const normalized = p.ingress.parseWebhook(req.headers, req.body);
1077
+ if (!normalized) {
1078
+ logWebhook(event, 204, { reason: "unhandled event" });
1079
+ return res.status(204).end();
1080
+ }
1081
+ let handled = false;
1082
+ switch (normalized.kind) {
1083
+ case "comment":
1084
+ handled = await handleCommentEvent(normalized);
1085
+ break;
1086
+ case "workitem-open":
1087
+ handled = await handleWorkItemOpenEvent(normalized);
1088
+ break;
1089
+ case "workitem-close":
1090
+ handled = await handleWorkItemCloseEvent(normalized);
1091
+ break;
1092
+ case "pipeline-failed":
1093
+ handled = await handlePipelineFailureEvent(normalized);
1094
+ break;
1095
+ default:
1096
+ handled = false;
1097
+ }
1098
+ const status = handled ? 202 : 204;
1099
+ logWebhook(event, status, {
1100
+ workItemNumber: normalized.workItem?.number,
1101
+ ...(!handled && { reason: "filtered" }),
1102
+ });
1103
+ return res.status(status).end();
1104
+ });
1105
+ }
1106
+ //# sourceMappingURL=webhook.js.map