@humanbased/crosscheck 0.14.0

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 (381) hide show
  1. package/AGENT.md +207 -0
  2. package/ISSUE.md +234 -0
  3. package/LICENSE +21 -0
  4. package/README.md +234 -0
  5. package/README.zh.md +169 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/screenshot-watch-timing.png +0 -0
  8. package/assets/screenshot-watch-timing.svg +1 -0
  9. package/assets/screenshot-watch.png +0 -0
  10. package/crosscheck.config.example.yml +214 -0
  11. package/dist/__tests__/annotation.test.d.ts +2 -0
  12. package/dist/__tests__/annotation.test.d.ts.map +1 -0
  13. package/dist/__tests__/annotation.test.js +134 -0
  14. package/dist/__tests__/annotation.test.js.map +1 -0
  15. package/dist/__tests__/backtrace.test.d.ts +2 -0
  16. package/dist/__tests__/backtrace.test.d.ts.map +1 -0
  17. package/dist/__tests__/backtrace.test.js +280 -0
  18. package/dist/__tests__/backtrace.test.js.map +1 -0
  19. package/dist/__tests__/board.test.d.ts +2 -0
  20. package/dist/__tests__/board.test.d.ts.map +1 -0
  21. package/dist/__tests__/board.test.js +149 -0
  22. package/dist/__tests__/board.test.js.map +1 -0
  23. package/dist/__tests__/codex.test.d.ts +2 -0
  24. package/dist/__tests__/codex.test.d.ts.map +1 -0
  25. package/dist/__tests__/codex.test.js +92 -0
  26. package/dist/__tests__/codex.test.js.map +1 -0
  27. package/dist/__tests__/comment-bodies.test.d.ts +2 -0
  28. package/dist/__tests__/comment-bodies.test.d.ts.map +1 -0
  29. package/dist/__tests__/comment-bodies.test.js +75 -0
  30. package/dist/__tests__/comment-bodies.test.js.map +1 -0
  31. package/dist/__tests__/conflict-resolve.test.d.ts +2 -0
  32. package/dist/__tests__/conflict-resolve.test.d.ts.map +1 -0
  33. package/dist/__tests__/conflict-resolve.test.js +123 -0
  34. package/dist/__tests__/conflict-resolve.test.js.map +1 -0
  35. package/dist/__tests__/crosscheck-commit.test.d.ts +2 -0
  36. package/dist/__tests__/crosscheck-commit.test.d.ts.map +1 -0
  37. package/dist/__tests__/crosscheck-commit.test.js +13 -0
  38. package/dist/__tests__/crosscheck-commit.test.js.map +1 -0
  39. package/dist/__tests__/detector.test.d.ts +2 -0
  40. package/dist/__tests__/detector.test.d.ts.map +1 -0
  41. package/dist/__tests__/detector.test.js +112 -0
  42. package/dist/__tests__/detector.test.js.map +1 -0
  43. package/dist/__tests__/diagnose.test.d.ts +2 -0
  44. package/dist/__tests__/diagnose.test.d.ts.map +1 -0
  45. package/dist/__tests__/diagnose.test.js +164 -0
  46. package/dist/__tests__/diagnose.test.js.map +1 -0
  47. package/dist/__tests__/diff-hash.test.d.ts +2 -0
  48. package/dist/__tests__/diff-hash.test.d.ts.map +1 -0
  49. package/dist/__tests__/diff-hash.test.js +126 -0
  50. package/dist/__tests__/diff-hash.test.js.map +1 -0
  51. package/dist/__tests__/durations.test.d.ts +2 -0
  52. package/dist/__tests__/durations.test.d.ts.map +1 -0
  53. package/dist/__tests__/durations.test.js +26 -0
  54. package/dist/__tests__/durations.test.js.map +1 -0
  55. package/dist/__tests__/event-fields.test.d.ts +2 -0
  56. package/dist/__tests__/event-fields.test.d.ts.map +1 -0
  57. package/dist/__tests__/event-fields.test.js +50 -0
  58. package/dist/__tests__/event-fields.test.js.map +1 -0
  59. package/dist/__tests__/filter.test.d.ts +2 -0
  60. package/dist/__tests__/filter.test.d.ts.map +1 -0
  61. package/dist/__tests__/filter.test.js +21 -0
  62. package/dist/__tests__/filter.test.js.map +1 -0
  63. package/dist/__tests__/fix.test.d.ts +2 -0
  64. package/dist/__tests__/fix.test.d.ts.map +1 -0
  65. package/dist/__tests__/fix.test.js +124 -0
  66. package/dist/__tests__/fix.test.js.map +1 -0
  67. package/dist/__tests__/github-client.test.d.ts +2 -0
  68. package/dist/__tests__/github-client.test.d.ts.map +1 -0
  69. package/dist/__tests__/github-client.test.js +22 -0
  70. package/dist/__tests__/github-client.test.js.map +1 -0
  71. package/dist/__tests__/github-scan-client.test.d.ts +2 -0
  72. package/dist/__tests__/github-scan-client.test.d.ts.map +1 -0
  73. package/dist/__tests__/github-scan-client.test.js +100 -0
  74. package/dist/__tests__/github-scan-client.test.js.map +1 -0
  75. package/dist/__tests__/is-fresh-review-comment.test.d.ts +2 -0
  76. package/dist/__tests__/is-fresh-review-comment.test.d.ts.map +1 -0
  77. package/dist/__tests__/is-fresh-review-comment.test.js +86 -0
  78. package/dist/__tests__/is-fresh-review-comment.test.js.map +1 -0
  79. package/dist/__tests__/issue.test.d.ts +2 -0
  80. package/dist/__tests__/issue.test.d.ts.map +1 -0
  81. package/dist/__tests__/issue.test.js +259 -0
  82. package/dist/__tests__/issue.test.js.map +1 -0
  83. package/dist/__tests__/kickass.test.d.ts +2 -0
  84. package/dist/__tests__/kickass.test.d.ts.map +1 -0
  85. package/dist/__tests__/kickass.test.js +268 -0
  86. package/dist/__tests__/kickass.test.js.map +1 -0
  87. package/dist/__tests__/loader.test.d.ts +2 -0
  88. package/dist/__tests__/loader.test.d.ts.map +1 -0
  89. package/dist/__tests__/loader.test.js +180 -0
  90. package/dist/__tests__/loader.test.js.map +1 -0
  91. package/dist/__tests__/onboard-preservation.test.d.ts +2 -0
  92. package/dist/__tests__/onboard-preservation.test.d.ts.map +1 -0
  93. package/dist/__tests__/onboard-preservation.test.js +506 -0
  94. package/dist/__tests__/onboard-preservation.test.js.map +1 -0
  95. package/dist/__tests__/optimize.test.d.ts +2 -0
  96. package/dist/__tests__/optimize.test.d.ts.map +1 -0
  97. package/dist/__tests__/optimize.test.js +101 -0
  98. package/dist/__tests__/optimize.test.js.map +1 -0
  99. package/dist/__tests__/post-review-comment.test.d.ts +2 -0
  100. package/dist/__tests__/post-review-comment.test.d.ts.map +1 -0
  101. package/dist/__tests__/post-review-comment.test.js +44 -0
  102. package/dist/__tests__/post-review-comment.test.js.map +1 -0
  103. package/dist/__tests__/pr-lock.test.d.ts +2 -0
  104. package/dist/__tests__/pr-lock.test.d.ts.map +1 -0
  105. package/dist/__tests__/pr-lock.test.js +115 -0
  106. package/dist/__tests__/pr-lock.test.js.map +1 -0
  107. package/dist/__tests__/pr-picker.test.d.ts +2 -0
  108. package/dist/__tests__/pr-picker.test.d.ts.map +1 -0
  109. package/dist/__tests__/pr-picker.test.js +57 -0
  110. package/dist/__tests__/pr-picker.test.js.map +1 -0
  111. package/dist/__tests__/pr-status-scan.test.d.ts +2 -0
  112. package/dist/__tests__/pr-status-scan.test.d.ts.map +1 -0
  113. package/dist/__tests__/pr-status-scan.test.js +92 -0
  114. package/dist/__tests__/pr-status-scan.test.js.map +1 -0
  115. package/dist/__tests__/pr-status.test.d.ts +2 -0
  116. package/dist/__tests__/pr-status.test.d.ts.map +1 -0
  117. package/dist/__tests__/pr-status.test.js +346 -0
  118. package/dist/__tests__/pr-status.test.js.map +1 -0
  119. package/dist/__tests__/repo-picker.test.d.ts +2 -0
  120. package/dist/__tests__/repo-picker.test.d.ts.map +1 -0
  121. package/dist/__tests__/repo-picker.test.js +115 -0
  122. package/dist/__tests__/repo-picker.test.js.map +1 -0
  123. package/dist/__tests__/review-comment-body.test.d.ts +2 -0
  124. package/dist/__tests__/review-comment-body.test.d.ts.map +1 -0
  125. package/dist/__tests__/review-comment-body.test.js +54 -0
  126. package/dist/__tests__/review-comment-body.test.js.map +1 -0
  127. package/dist/__tests__/review-models.test.d.ts +2 -0
  128. package/dist/__tests__/review-models.test.d.ts.map +1 -0
  129. package/dist/__tests__/review-models.test.js +39 -0
  130. package/dist/__tests__/review-models.test.js.map +1 -0
  131. package/dist/__tests__/review-status.test.d.ts +2 -0
  132. package/dist/__tests__/review-status.test.d.ts.map +1 -0
  133. package/dist/__tests__/review-status.test.js +95 -0
  134. package/dist/__tests__/review-status.test.js.map +1 -0
  135. package/dist/__tests__/runner.test.d.ts +2 -0
  136. package/dist/__tests__/runner.test.d.ts.map +1 -0
  137. package/dist/__tests__/runner.test.js +204 -0
  138. package/dist/__tests__/runner.test.js.map +1 -0
  139. package/dist/__tests__/scan-cache.test.d.ts +2 -0
  140. package/dist/__tests__/scan-cache.test.d.ts.map +1 -0
  141. package/dist/__tests__/scan-cache.test.js +59 -0
  142. package/dist/__tests__/scan-cache.test.js.map +1 -0
  143. package/dist/__tests__/scan-client.test.d.ts +2 -0
  144. package/dist/__tests__/scan-client.test.d.ts.map +1 -0
  145. package/dist/__tests__/scan-client.test.js +30 -0
  146. package/dist/__tests__/scan-client.test.js.map +1 -0
  147. package/dist/__tests__/scan.test.d.ts +2 -0
  148. package/dist/__tests__/scan.test.d.ts.map +1 -0
  149. package/dist/__tests__/scan.test.js +115 -0
  150. package/dist/__tests__/scan.test.js.map +1 -0
  151. package/dist/__tests__/scopes.test.d.ts +2 -0
  152. package/dist/__tests__/scopes.test.d.ts.map +1 -0
  153. package/dist/__tests__/scopes.test.js +101 -0
  154. package/dist/__tests__/scopes.test.js.map +1 -0
  155. package/dist/__tests__/sha-cache.test.d.ts +2 -0
  156. package/dist/__tests__/sha-cache.test.d.ts.map +1 -0
  157. package/dist/__tests__/sha-cache.test.js +40 -0
  158. package/dist/__tests__/sha-cache.test.js.map +1 -0
  159. package/dist/__tests__/smart-switch.test.d.ts +2 -0
  160. package/dist/__tests__/smart-switch.test.d.ts.map +1 -0
  161. package/dist/__tests__/smart-switch.test.js +145 -0
  162. package/dist/__tests__/smart-switch.test.js.map +1 -0
  163. package/dist/ck.d.ts +3 -0
  164. package/dist/ck.d.ts.map +1 -0
  165. package/dist/ck.js +8 -0
  166. package/dist/ck.js.map +1 -0
  167. package/dist/cli.d.ts +3 -0
  168. package/dist/cli.d.ts.map +1 -0
  169. package/dist/cli.js +132 -0
  170. package/dist/cli.js.map +1 -0
  171. package/dist/commands/diagnose.d.ts +54 -0
  172. package/dist/commands/diagnose.d.ts.map +1 -0
  173. package/dist/commands/diagnose.js +294 -0
  174. package/dist/commands/diagnose.js.map +1 -0
  175. package/dist/commands/impact.d.ts +38 -0
  176. package/dist/commands/impact.d.ts.map +1 -0
  177. package/dist/commands/impact.js +210 -0
  178. package/dist/commands/impact.js.map +1 -0
  179. package/dist/commands/init.d.ts +12 -0
  180. package/dist/commands/init.d.ts.map +1 -0
  181. package/dist/commands/init.js +183 -0
  182. package/dist/commands/init.js.map +1 -0
  183. package/dist/commands/issue.d.ts +25 -0
  184. package/dist/commands/issue.d.ts.map +1 -0
  185. package/dist/commands/issue.js +445 -0
  186. package/dist/commands/issue.js.map +1 -0
  187. package/dist/commands/kickass.d.ts +59 -0
  188. package/dist/commands/kickass.d.ts.map +1 -0
  189. package/dist/commands/kickass.js +288 -0
  190. package/dist/commands/kickass.js.map +1 -0
  191. package/dist/commands/onboard.d.ts +70 -0
  192. package/dist/commands/onboard.d.ts.map +1 -0
  193. package/dist/commands/onboard.js +883 -0
  194. package/dist/commands/onboard.js.map +1 -0
  195. package/dist/commands/optimize.d.ts +16 -0
  196. package/dist/commands/optimize.d.ts.map +1 -0
  197. package/dist/commands/optimize.js +244 -0
  198. package/dist/commands/optimize.js.map +1 -0
  199. package/dist/commands/review.d.ts +2 -0
  200. package/dist/commands/review.d.ts.map +1 -0
  201. package/dist/commands/review.js +118 -0
  202. package/dist/commands/review.js.map +1 -0
  203. package/dist/commands/run.d.ts +13 -0
  204. package/dist/commands/run.d.ts.map +1 -0
  205. package/dist/commands/run.js +243 -0
  206. package/dist/commands/run.js.map +1 -0
  207. package/dist/commands/scan.d.ts +94 -0
  208. package/dist/commands/scan.d.ts.map +1 -0
  209. package/dist/commands/scan.js +276 -0
  210. package/dist/commands/scan.js.map +1 -0
  211. package/dist/commands/serve.d.ts +9 -0
  212. package/dist/commands/serve.d.ts.map +1 -0
  213. package/dist/commands/serve.js +402 -0
  214. package/dist/commands/serve.js.map +1 -0
  215. package/dist/commands/status.d.ts +2 -0
  216. package/dist/commands/status.d.ts.map +1 -0
  217. package/dist/commands/status.js +89 -0
  218. package/dist/commands/status.js.map +1 -0
  219. package/dist/commands/watch.d.ts +9 -0
  220. package/dist/commands/watch.d.ts.map +1 -0
  221. package/dist/commands/watch.js +902 -0
  222. package/dist/commands/watch.js.map +1 -0
  223. package/dist/config/loader.d.ts +47 -0
  224. package/dist/config/loader.d.ts.map +1 -0
  225. package/dist/config/loader.js +334 -0
  226. package/dist/config/loader.js.map +1 -0
  227. package/dist/config/schema.d.ts +814 -0
  228. package/dist/config/schema.d.ts.map +1 -0
  229. package/dist/config/schema.js +152 -0
  230. package/dist/config/schema.js.map +1 -0
  231. package/dist/github/client.d.ts +139 -0
  232. package/dist/github/client.d.ts.map +1 -0
  233. package/dist/github/client.js +711 -0
  234. package/dist/github/client.js.map +1 -0
  235. package/dist/github/detector.d.ts +12 -0
  236. package/dist/github/detector.d.ts.map +1 -0
  237. package/dist/github/detector.js +120 -0
  238. package/dist/github/detector.js.map +1 -0
  239. package/dist/github/merge.d.ts +9 -0
  240. package/dist/github/merge.d.ts.map +1 -0
  241. package/dist/github/merge.js +33 -0
  242. package/dist/github/merge.js.map +1 -0
  243. package/dist/github/review-status.d.ts +6 -0
  244. package/dist/github/review-status.d.ts.map +1 -0
  245. package/dist/github/review-status.js +51 -0
  246. package/dist/github/review-status.js.map +1 -0
  247. package/dist/github/webhook.d.ts +41 -0
  248. package/dist/github/webhook.d.ts.map +1 -0
  249. package/dist/github/webhook.js +50 -0
  250. package/dist/github/webhook.js.map +1 -0
  251. package/dist/lib/annotation.d.ts +23 -0
  252. package/dist/lib/annotation.d.ts.map +1 -0
  253. package/dist/lib/annotation.js +103 -0
  254. package/dist/lib/annotation.js.map +1 -0
  255. package/dist/lib/backtrace.d.ts +40 -0
  256. package/dist/lib/backtrace.d.ts.map +1 -0
  257. package/dist/lib/backtrace.js +169 -0
  258. package/dist/lib/backtrace.js.map +1 -0
  259. package/dist/lib/board.d.ts +74 -0
  260. package/dist/lib/board.d.ts.map +1 -0
  261. package/dist/lib/board.js +640 -0
  262. package/dist/lib/board.js.map +1 -0
  263. package/dist/lib/clone.d.ts +12 -0
  264. package/dist/lib/clone.d.ts.map +1 -0
  265. package/dist/lib/clone.js +30 -0
  266. package/dist/lib/clone.js.map +1 -0
  267. package/dist/lib/comment-bodies.d.ts +17 -0
  268. package/dist/lib/comment-bodies.d.ts.map +1 -0
  269. package/dist/lib/comment-bodies.js +51 -0
  270. package/dist/lib/comment-bodies.js.map +1 -0
  271. package/dist/lib/crosscheck-commit.d.ts +2 -0
  272. package/dist/lib/crosscheck-commit.d.ts.map +1 -0
  273. package/dist/lib/crosscheck-commit.js +4 -0
  274. package/dist/lib/crosscheck-commit.js.map +1 -0
  275. package/dist/lib/diff-hash.d.ts +16 -0
  276. package/dist/lib/diff-hash.d.ts.map +1 -0
  277. package/dist/lib/diff-hash.js +71 -0
  278. package/dist/lib/diff-hash.js.map +1 -0
  279. package/dist/lib/durations.d.ts +5 -0
  280. package/dist/lib/durations.d.ts.map +1 -0
  281. package/dist/lib/durations.js +39 -0
  282. package/dist/lib/durations.js.map +1 -0
  283. package/dist/lib/event-fields.d.ts +6 -0
  284. package/dist/lib/event-fields.d.ts.map +1 -0
  285. package/dist/lib/event-fields.js +20 -0
  286. package/dist/lib/event-fields.js.map +1 -0
  287. package/dist/lib/filter.d.ts +2 -0
  288. package/dist/lib/filter.d.ts.map +1 -0
  289. package/dist/lib/filter.js +4 -0
  290. package/dist/lib/filter.js.map +1 -0
  291. package/dist/lib/fortune.d.ts +2 -0
  292. package/dist/lib/fortune.d.ts.map +1 -0
  293. package/dist/lib/fortune.js +26 -0
  294. package/dist/lib/fortune.js.map +1 -0
  295. package/dist/lib/languages.d.ts +3 -0
  296. package/dist/lib/languages.d.ts.map +1 -0
  297. package/dist/lib/languages.js +26 -0
  298. package/dist/lib/languages.js.map +1 -0
  299. package/dist/lib/log-analysis.d.ts +17 -0
  300. package/dist/lib/log-analysis.d.ts.map +1 -0
  301. package/dist/lib/log-analysis.js +72 -0
  302. package/dist/lib/log-analysis.js.map +1 -0
  303. package/dist/lib/logger.d.ts +14 -0
  304. package/dist/lib/logger.d.ts.map +1 -0
  305. package/dist/lib/logger.js +84 -0
  306. package/dist/lib/logger.js.map +1 -0
  307. package/dist/lib/port.d.ts +2 -0
  308. package/dist/lib/port.d.ts.map +1 -0
  309. package/dist/lib/port.js +21 -0
  310. package/dist/lib/port.js.map +1 -0
  311. package/dist/lib/pr-lock.d.ts +4 -0
  312. package/dist/lib/pr-lock.d.ts.map +1 -0
  313. package/dist/lib/pr-lock.js +91 -0
  314. package/dist/lib/pr-lock.js.map +1 -0
  315. package/dist/lib/pr-picker.d.ts +10 -0
  316. package/dist/lib/pr-picker.d.ts.map +1 -0
  317. package/dist/lib/pr-picker.js +80 -0
  318. package/dist/lib/pr-picker.js.map +1 -0
  319. package/dist/lib/pr-status.d.ts +206 -0
  320. package/dist/lib/pr-status.d.ts.map +1 -0
  321. package/dist/lib/pr-status.js +613 -0
  322. package/dist/lib/pr-status.js.map +1 -0
  323. package/dist/lib/repo-picker.d.ts +23 -0
  324. package/dist/lib/repo-picker.d.ts.map +1 -0
  325. package/dist/lib/repo-picker.js +411 -0
  326. package/dist/lib/repo-picker.js.map +1 -0
  327. package/dist/lib/review-models.d.ts +7 -0
  328. package/dist/lib/review-models.d.ts.map +1 -0
  329. package/dist/lib/review-models.js +32 -0
  330. package/dist/lib/review-models.js.map +1 -0
  331. package/dist/lib/runner.d.ts +65 -0
  332. package/dist/lib/runner.d.ts.map +1 -0
  333. package/dist/lib/runner.js +710 -0
  334. package/dist/lib/runner.js.map +1 -0
  335. package/dist/lib/scan-cache.d.ts +31 -0
  336. package/dist/lib/scan-cache.d.ts.map +1 -0
  337. package/dist/lib/scan-cache.js +112 -0
  338. package/dist/lib/scan-cache.js.map +1 -0
  339. package/dist/lib/scopes.d.ts +16 -0
  340. package/dist/lib/scopes.d.ts.map +1 -0
  341. package/dist/lib/scopes.js +37 -0
  342. package/dist/lib/scopes.js.map +1 -0
  343. package/dist/lib/sha-cache.d.ts +7 -0
  344. package/dist/lib/sha-cache.d.ts.map +1 -0
  345. package/dist/lib/sha-cache.js +44 -0
  346. package/dist/lib/sha-cache.js.map +1 -0
  347. package/dist/lib/smart-switch.d.ts +44 -0
  348. package/dist/lib/smart-switch.d.ts.map +1 -0
  349. package/dist/lib/smart-switch.js +145 -0
  350. package/dist/lib/smart-switch.js.map +1 -0
  351. package/dist/lib/verdict.d.ts +9 -0
  352. package/dist/lib/verdict.d.ts.map +1 -0
  353. package/dist/lib/verdict.js +52 -0
  354. package/dist/lib/verdict.js.map +1 -0
  355. package/dist/lib/workflow.d.ts +85 -0
  356. package/dist/lib/workflow.d.ts.map +1 -0
  357. package/dist/lib/workflow.js +116 -0
  358. package/dist/lib/workflow.js.map +1 -0
  359. package/dist/reviewers/address.d.ts +5 -0
  360. package/dist/reviewers/address.d.ts.map +1 -0
  361. package/dist/reviewers/address.js +87 -0
  362. package/dist/reviewers/address.js.map +1 -0
  363. package/dist/reviewers/claude.d.ts +12 -0
  364. package/dist/reviewers/claude.d.ts.map +1 -0
  365. package/dist/reviewers/claude.js +78 -0
  366. package/dist/reviewers/claude.js.map +1 -0
  367. package/dist/reviewers/codex.d.ts +9 -0
  368. package/dist/reviewers/codex.d.ts.map +1 -0
  369. package/dist/reviewers/codex.js +121 -0
  370. package/dist/reviewers/codex.js.map +1 -0
  371. package/dist/reviewers/conflict-resolve.d.ts +15 -0
  372. package/dist/reviewers/conflict-resolve.d.ts.map +1 -0
  373. package/dist/reviewers/conflict-resolve.js +219 -0
  374. package/dist/reviewers/conflict-resolve.js.map +1 -0
  375. package/dist/reviewers/fix.d.ts +7 -0
  376. package/dist/reviewers/fix.d.ts.map +1 -0
  377. package/dist/reviewers/fix.js +197 -0
  378. package/dist/reviewers/fix.js.map +1 -0
  379. package/get-started.md +1271 -0
  380. package/get-started.zh.md +1208 -0
  381. package/package.json +75 -0
@@ -0,0 +1,902 @@
1
+ import { execSync, spawn } from 'child_process';
2
+ import chalk from 'chalk';
3
+ import { createWebhookServer } from '../github/webhook.js';
4
+ import { createGithubClient, getCommitMessage, registerOrgWebhook, deleteOrgWebhook, registerRepoWebhook, deleteRepoWebhook, findOrgWebhook, findRepoWebhook, listUserRepos, checkRepoAccessible, } from '../github/client.js';
5
+ import { detectOriginFull, assignReviewer } from '../github/detector.js';
6
+ import { loadConfig, getGithubToken, getWebhookSecret, resolveConfigPath, promptDeploymentMode, detectScopesForDeployment, patchDeploymentConfig, detectGitHubLogin, } from '../config/loader.js';
7
+ import { randomFortune } from '../lib/fortune.js';
8
+ import { scanUnreviewedPRs } from '../lib/backtrace.js';
9
+ import { initLogger, log as fileLog, logError, logUncaught } from '../lib/logger.js';
10
+ import { isAuthorAllowed } from '../lib/filter.js';
11
+ import { runWorkflow } from '../lib/runner.js';
12
+ import { loadWorkflow } from '../lib/workflow.js';
13
+ import { PRBoard, fmtTime, FMT_TIME_WIDTH } from '../lib/board.js';
14
+ import { clonePRForReview } from '../lib/clone.js';
15
+ import { getSmartSwitch, isSubscriptionLimitError, detectFailedVendor, triggerSwitch, notifyReviewSuccess, stopSmartSwitch, } from '../lib/smart-switch.js';
16
+ import { mkdtempSync, rmSync } from 'fs';
17
+ import { tmpdir } from 'os';
18
+ import { join } from 'path';
19
+ import { PersistentShaSet } from '../lib/sha-cache.js';
20
+ import { PersistentDiffHashMap, computeDiffHash } from '../lib/diff-hash.js';
21
+ import { dedupScopes } from '../lib/scopes.js';
22
+ import { acquirePRLock, releasePRLock } from '../lib/pr-lock.js';
23
+ import { checkRemoteLock, acquireRemoteLock, releaseRemoteLock, startRemoteLockHeartbeat } from '../github/review-status.js';
24
+ import { isCrosscheckCommitMessage } from '../lib/crosscheck-commit.js';
25
+ function buildFallbackConfig(config, fallbackVendor) {
26
+ return {
27
+ ...config,
28
+ mode: 'single-vendor',
29
+ vendors: {
30
+ codex: { ...config.vendors.codex, enabled: fallbackVendor === 'codex' },
31
+ claude: { ...config.vendors.claude, enabled: fallbackVendor === 'claude' },
32
+ },
33
+ };
34
+ }
35
+ // Compute PR diff size in lines, excluding noise (lockfiles, binaries, data files)
36
+ const NOISE_EXT = /\.(lock|snap|min\.js|min\.css|csv|json|png|jpg|jpeg|gif|svg|mp4|woff2?|ttf|eot|ico|pdf)$/i;
37
+ function computePRLoc(tmpDir, baseBranch) {
38
+ try {
39
+ const stat = execSync(`git diff --stat origin/${baseBranch}...HEAD`, { cwd: tmpDir, encoding: 'utf8' });
40
+ let total = 0;
41
+ for (const line of stat.split('\n')) {
42
+ const m = line.match(/^\s+(.+?)\s+\|\s+(\d+)/);
43
+ if (!m)
44
+ continue;
45
+ const file = m[1].trim().replace(/\{.*?=> /, '').replace('}', ''); // handle rename notation
46
+ if (!NOISE_EXT.test(file))
47
+ total += parseInt(m[2], 10);
48
+ }
49
+ return total;
50
+ }
51
+ catch {
52
+ return 0;
53
+ }
54
+ }
55
+ function detectCurrentRepo() {
56
+ try {
57
+ const remote = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim();
58
+ const m = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
59
+ if (m)
60
+ return { owner: m[1], repo: m[2] };
61
+ }
62
+ catch { /* ignore */ }
63
+ return null;
64
+ }
65
+ // lhr.life tunnels can go dead (503) without the SSH process exiting.
66
+ // Polls every 60s and kills the proc after 2 consecutive failures (~2 min detection).
67
+ function waitForTunnelEnd(tunnelProc, tunnelUrl) {
68
+ return new Promise(resolve => {
69
+ let failCount = 0;
70
+ const check = setInterval(async () => {
71
+ let alive = false;
72
+ try {
73
+ const res = await fetch(tunnelUrl, { signal: AbortSignal.timeout(8000) });
74
+ alive = res.status !== 503;
75
+ }
76
+ catch { /* network error = dead */ }
77
+ if (!alive) {
78
+ if (++failCount >= 2) {
79
+ clearInterval(check);
80
+ tunnelProc.kill();
81
+ }
82
+ }
83
+ else {
84
+ failCount = 0;
85
+ }
86
+ }, 60_000);
87
+ tunnelProc.on('exit', () => { clearInterval(check); resolve(); });
88
+ tunnelProc.on('error', () => { clearInterval(check); resolve(); });
89
+ });
90
+ }
91
+ // Opens a localhost.run SSH tunnel. Resolves with the public base URL once
92
+ // the tunnel is ready. Rejects after 20s if no URL appears in the output.
93
+ function openTunnel(localPort) {
94
+ return new Promise((resolve, reject) => {
95
+ const proc = spawn('ssh', [
96
+ '-R', `80:localhost:${localPort}`,
97
+ '-o', 'StrictHostKeyChecking=no',
98
+ '-o', 'ServerAliveInterval=30',
99
+ '-o', 'LogLevel=ERROR',
100
+ 'nokey@localhost.run',
101
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
102
+ const timer = setTimeout(() => {
103
+ proc.kill();
104
+ reject(new Error('Tunnel did not start within 20s — check your internet connection'));
105
+ }, 20000);
106
+ const onData = (data) => {
107
+ const text = data.toString();
108
+ const match = text.match(/https:\/\/[a-zA-Z0-9.-]+\.(?:localhost\.run|lhr\.life)[^\s]*/i);
109
+ if (match) {
110
+ clearTimeout(timer);
111
+ resolve({ url: match[0].replace(/\/$/, ''), proc });
112
+ }
113
+ };
114
+ proc.stdout?.on('data', onData);
115
+ proc.stderr?.on('data', onData);
116
+ proc.on('exit', (code) => {
117
+ clearTimeout(timer);
118
+ if (code !== 0 && code !== null) {
119
+ reject(new Error(`SSH tunnel exited (code ${code})`));
120
+ }
121
+ });
122
+ });
123
+ }
124
+ export async function runWatch(opts = {}) {
125
+ const configPath = opts.config;
126
+ let config = loadConfig(configPath);
127
+ initLogger(config.logs);
128
+ process.on('uncaughtException', (err) => {
129
+ logUncaught('uncaughtException', err);
130
+ console.error(chalk.red(`\n✗ Uncaught exception: ${err.message}`));
131
+ process.exit(2);
132
+ });
133
+ process.on('unhandledRejection', (reason) => {
134
+ logUncaught('unhandledRejection', reason);
135
+ console.error(chalk.red(`\n✗ Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`));
136
+ process.exit(2);
137
+ });
138
+ let token;
139
+ try {
140
+ token = getGithubToken();
141
+ }
142
+ catch (err) {
143
+ logError({ command: 'watch', phase: 'auth' }, err);
144
+ console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
145
+ process.exit(1);
146
+ }
147
+ fileLog({ level: 'info', event: 'session_start', command: 'watch' });
148
+ const webhookSecret = getWebhookSecret();
149
+ const webhookPath = config.server.webhook_path;
150
+ // Board manages all terminal output after startup
151
+ const board = new PRBoard();
152
+ const workflow = loadWorkflow(process.cwd());
153
+ board.setConfig(config, workflow);
154
+ // Thin wrapper: routes important messages to both terminal and file log
155
+ const bLog = (line1, line2) => {
156
+ board.log(line1, line2);
157
+ fileLog({ level: 'info', event: 'message', message: line2 ? `${line1} ${line2}` : line1 });
158
+ };
159
+ // Connectivity events (tunnel/webhook) go into the live connectivity section
160
+ const cLog = (line) => {
161
+ board.logConnectivity(line);
162
+ fileLog({ level: 'info', event: 'message', message: line });
163
+ };
164
+ // PR deduplication — skip if already reviewing this PR+SHA
165
+ const inFlight = new Set();
166
+ // SHAs pushed by the fix step — persisted to disk so restarts don't re-review our own commits
167
+ const crosscheckShas = new PersistentShaSet();
168
+ // Last-reviewed diff hash per PR — skip reviews when a new SHA has identical diff vs base
169
+ // (force-push, amend, no-op rebase). Persisted so restarts don't re-review unchanged content.
170
+ const diffHashes = new PersistentDiffHashMap();
171
+ // PRs reviewed at least once this session — synchronize events on these run as recheck rounds
172
+ const reviewedPRKeys = new Set();
173
+ const prRoundCounts = new Map();
174
+ async function reviewPR(params) {
175
+ const { owner, repoName, prNumber } = params;
176
+ const key = `${owner}/${repoName}#${prNumber}@${params.headSha}`;
177
+ if (inFlight.has(key))
178
+ return;
179
+ inFlight.add(key);
180
+ // Outer try/finally ensures the inFlight key is always released, even if
181
+ // detectOriginFull / assignReviewer throw before the inner try block starts.
182
+ try {
183
+ if (!isAuthorAllowed(config.routing.allowed_authors, params.author)) {
184
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'author_not_allowed', author: params.author });
185
+ return;
186
+ }
187
+ const { origin, method: originMethod } = await detectOriginFull(params.body ?? '', params.headRef, owner, repoName, prNumber, config, token, params.author);
188
+ // Smart-switch: when cross-vendor is degraded, override to single-vendor with the healthy vendor
189
+ const ss = getSmartSwitch();
190
+ const effectiveConfig = (config.mode === 'cross-vendor' && ss.active && ss.fallbackVendor)
191
+ ? buildFallbackConfig(config, ss.fallbackVendor)
192
+ : config;
193
+ const reviewer = await assignReviewer(origin, effectiveConfig);
194
+ fileLog({ level: 'info', event: 'pr_received', repo: `${owner}/${repoName}`, pr: prNumber, sha: params.headSha, action: params.action, origin, origin_method: originMethod, author: params.author, smart_switch_active: ss.active });
195
+ if (!reviewer) {
196
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'no_reviewer', origin });
197
+ return;
198
+ }
199
+ const ts = chalk.dim(fmtTime());
200
+ const tsIndent = ' '.repeat(FMT_TIME_WIDTH + 2);
201
+ const modeNote = ss.active ? chalk.yellow(' [smart-switch]') : '';
202
+ bLog(`${ts} PR #${prNumber} ${params.action} ${chalk.dim(params.title)}`, `${tsIndent}origin=${chalk.yellow(origin)} via=${chalk.dim(originMethod)} reviewer=${chalk.cyan(reviewer)}${modeNote}`);
203
+ const pr = {
204
+ title: params.title,
205
+ body: params.body ?? '',
206
+ head: { ref: params.headRef, sha: params.headSha, repo: params.headRepo ? { full_name: params.headRepo } : null },
207
+ base: { ref: params.baseRef, repo: { full_name: `${owner}/${repoName}` } },
208
+ html_url: `https://github.com/${owner}/${repoName}/pull/${prNumber}`,
209
+ user: { login: params.author },
210
+ };
211
+ if (!acquirePRLock(owner, repoName, prNumber, params.headSha)) {
212
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'in_progress_local' });
213
+ return;
214
+ }
215
+ const lockOctokit = createGithubClient(token);
216
+ if (await checkRemoteLock(lockOctokit, owner, repoName, params.headSha)) {
217
+ releasePRLock(owner, repoName, prNumber, params.headSha);
218
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'in_progress_remote' });
219
+ return;
220
+ }
221
+ try {
222
+ await acquireRemoteLock(lockOctokit, owner, repoName, params.headSha);
223
+ }
224
+ catch (err) {
225
+ releasePRLock(owner, repoName, prNumber, params.headSha);
226
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'lock' }, err);
227
+ return;
228
+ }
229
+ const prKey = `${owner}/${repoName}#${prNumber}`;
230
+ const isRecheckRun = reviewedPRKeys.has(prKey);
231
+ const round = isRecheckRun ? (prRoundCounts.get(prKey) ?? 1) + 1 : 1;
232
+ const reviewStart = Date.now();
233
+ const tmpDir = mkdtempSync(join(tmpdir(), 'crosscheck-repo-'));
234
+ let stopHeartbeat = () => { };
235
+ let boardAdded = false;
236
+ try {
237
+ clonePRForReview({
238
+ owner, repo: repoName, prNumber, baseRef: params.baseRef,
239
+ tmpDir, token, protocol: config.clone_protocol,
240
+ onBaseFetchFailed: () => fileLog({ level: 'warn', event: 'base_branch_fetch_skipped', repo: `${owner}/${repoName}`, pr: prNumber, base: params.baseRef }),
241
+ });
242
+ // Diff-aware skip: a new HEAD SHA with the same patch vs base as the last
243
+ // successfully-reviewed SHA (force-push, amend, no-op rebase) doesn't need
244
+ // a fresh review. Post a one-line acknowledgement so the PR author sees we noticed.
245
+ // When the base ref fetch failed earlier, the diff vs base is not measurable;
246
+ // skip the dedup check entirely and don't update the cache after this review.
247
+ let newDiffHash = null;
248
+ try {
249
+ newDiffHash = computeDiffHash(tmpDir, params.baseRef);
250
+ }
251
+ catch { /* base unavailable — proceed with full review, skip cache update */ }
252
+ const prev = newDiffHash ? diffHashes.get(prKey) : undefined;
253
+ if (newDiffHash && prev && prev.hash === newDiffHash && prev.sha !== params.headSha) {
254
+ const prevShort = prev.sha.slice(0, 7);
255
+ const nowShort = params.headSha.slice(0, 7);
256
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'no_diff_change', sha: params.headSha, prev_sha: prev.sha });
257
+ bLog(`${chalk.dim(fmtTime())} PR #${prNumber} ${params.action} ${chalk.dim('no diff change since last review')}`, `${' '.repeat(FMT_TIME_WIDTH + 2)}prev=${chalk.dim(prevShort)} → ${chalk.dim(nowShort)} ${chalk.dim('(skipped)')}`);
258
+ try {
259
+ await lockOctokit.rest.issues.createComment({
260
+ owner, repo: repoName, issue_number: prNumber,
261
+ body: `✓ No diff change since the last review (was \`${prevShort}\`, now \`${nowShort}\`). Skipping re-review.\n\n<!-- crosscheck: no_diff_change prev_sha=${prev.sha} sha=${params.headSha} -->`,
262
+ });
263
+ fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, kind: 'no_diff_change' });
264
+ }
265
+ catch (err) {
266
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'no_diff_comment' }, err);
267
+ }
268
+ await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'success');
269
+ return;
270
+ }
271
+ board.addPR(key, prNumber, `${owner}/${repoName}`, params.headRef, round);
272
+ boardAdded = true;
273
+ const prLoc = computePRLoc(tmpDir, params.baseRef);
274
+ board.updatePR(key, { prLoc });
275
+ stopHeartbeat = startRemoteLockHeartbeat(lockOctokit, owner, repoName, params.headSha);
276
+ const { verdict } = await runWorkflow({
277
+ owner, repoName, prNumber, pr,
278
+ tmpDir, token, config: effectiveConfig, origin,
279
+ reviewStart,
280
+ log: (msg) => bLog(`${chalk.dim(fmtTime())} ${msg}`),
281
+ onPhaseChange: (label, data) => board.updatePR(key, { label, ...data }),
282
+ crosscheckShas,
283
+ smartSwitchFallback: (ss.active && ss.fallbackVendor) ? ss.fallbackVendor : undefined,
284
+ isRecheckRun,
285
+ round,
286
+ });
287
+ void verdict;
288
+ reviewedPRKeys.add(prKey);
289
+ prRoundCounts.set(prKey, round);
290
+ // Recompute the diff hash AFTER runWorkflow — workflow steps such as
291
+ // `conflict-resolve` or `fix` followed by `recheck` can mutate the checkout,
292
+ // so the pre-workflow hash may not represent the content that was actually
293
+ // reviewed. Caching the stale hash would cause a later force-push back to
294
+ // the pre-mutation diff to be skipped incorrectly as `no_diff_change`.
295
+ if (newDiffHash) {
296
+ let reviewedHash = null;
297
+ try {
298
+ reviewedHash = computeDiffHash(tmpDir, params.baseRef);
299
+ }
300
+ catch { /* base unavailable post-workflow — skip cache update */ }
301
+ if (reviewedHash)
302
+ diffHashes.upsert(prKey, { sha: params.headSha, hash: reviewedHash });
303
+ }
304
+ board.completePR(key, {
305
+ elapsedMs: Date.now() - reviewStart,
306
+ url: `github.com/${owner}/${repoName}/pull/${prNumber}`,
307
+ });
308
+ // Smart-switch recovery confirmation: if a restore attempt is pending and
309
+ // this reviewer matches the previously-degraded vendor, announce full restoration.
310
+ notifyReviewSuccess(reviewer, bLog);
311
+ stopHeartbeat();
312
+ await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'success');
313
+ }
314
+ catch (err) {
315
+ stopHeartbeat();
316
+ const message = err instanceof Error ? err.message : String(err);
317
+ if (boardAdded)
318
+ board.failPR(key, message);
319
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'review' }, err);
320
+ await releaseRemoteLock(lockOctokit, owner, repoName, params.headSha, 'failure');
321
+ // Smart-switch: when a reviewer hits a subscription limit in cross-vendor mode,
322
+ // degrade to single-vendor with the healthy vendor for the next 30 minutes.
323
+ if (config.mode === 'cross-vendor' && !getSmartSwitch().active && isSubscriptionLimitError(err)) {
324
+ const failedVendor = detectFailedVendor(err);
325
+ if (failedVendor)
326
+ triggerSwitch(failedVendor, message, bLog);
327
+ }
328
+ }
329
+ finally {
330
+ releasePRLock(owner, repoName, prNumber, params.headSha);
331
+ rmSync(tmpDir, { force: true, recursive: true });
332
+ }
333
+ }
334
+ catch (err) {
335
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'setup' }, err);
336
+ }
337
+ finally {
338
+ inFlight.delete(key);
339
+ }
340
+ }
341
+ // Start local webhook server
342
+ const server = createWebhookServer(config, webhookSecret, async (event) => {
343
+ const { pull_request: pr, repository: repo } = event;
344
+ const owner = repo.owner.login;
345
+ const repoName = repo.name;
346
+ const prNumber = event.number;
347
+ const key = `${owner}/${repoName}#${prNumber}@${pr.head.sha}`;
348
+ if (inFlight.has(key)) {
349
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'duplicate' });
350
+ return;
351
+ }
352
+ if (event.action === 'synchronize') {
353
+ const message = await getCommitMessage(owner, repoName, pr.head.sha, token).catch(() => null);
354
+ if (message !== null && isCrosscheckCommitMessage(message)) {
355
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'crosscheck_commit', sha: pr.head.sha });
356
+ return;
357
+ }
358
+ }
359
+ // Skip synchronize events triggered by our own address commits.
360
+ // crosscheckShas is backed by disk so this also covers SHAs from prior sessions.
361
+ if (crosscheckShas.has(pr.head.sha)) {
362
+ fileLog({ level: 'info', event: 'pr_skipped', repo: `${owner}/${repoName}`, pr: prNumber, reason: 'crosscheck_sha', sha: pr.head.sha });
363
+ return;
364
+ }
365
+ await reviewPR({
366
+ owner, repoName, prNumber,
367
+ title: pr.title, body: pr.body, author: pr.user.login,
368
+ headSha: pr.head.sha, headRef: pr.head.ref, headRepo: pr.head.repo?.full_name ?? null,
369
+ baseRef: pr.base.ref, action: event.action,
370
+ });
371
+ }, (msg) => bLog(chalk.dim(fmtTime()) + ' ' + msg), fileLog);
372
+ await new Promise((resolve, reject) => {
373
+ server.on('error', (err) => {
374
+ if (err.code === 'EADDRINUSE') {
375
+ reject(new Error(`Port ${config.server.port} is already in use.\n` +
376
+ ` Another crosscheck watch instance is likely running on this port.\n` +
377
+ ` Stop it first — running two instances against the same scopes will\n` +
378
+ ` register duplicate webhooks and post duplicate reviews.\n` +
379
+ ` To run intentionally on a different port, change it in config:\n` +
380
+ ` server:\n port: ${config.server.port + 1}`));
381
+ }
382
+ else {
383
+ reject(err);
384
+ }
385
+ });
386
+ server.listen(config.server.port, resolve);
387
+ }).catch((err) => {
388
+ console.error(chalk.red(`\n✗ ${err.message}`));
389
+ process.exit(1);
390
+ });
391
+ // ── Deployment setup ─────────────────────────────────────────────────────
392
+ // Runs before scope building so detected users/orgs feed into webhook registration.
393
+ let effectiveDeployment = config.deployment;
394
+ let sessionOnly = false;
395
+ let selfLogin = null;
396
+ if (opts.personal || opts.team) {
397
+ // One-time flag: auto-detect scopes for this session, no config write.
398
+ effectiveDeployment = opts.personal ? 'personal' : 'team';
399
+ sessionOnly = true;
400
+ const detected = await detectScopesForDeployment(effectiveDeployment, token);
401
+ selfLogin = detected.login;
402
+ config = { ...config, users: detected.users, orgs: detected.orgs, repos: [] };
403
+ }
404
+ else if (opts.reconfigure || !config.deployment) {
405
+ // First run (no deployment in config) or explicit --reconfigure.
406
+ effectiveDeployment = await promptDeploymentMode(opts.reconfigure ? config.deployment : undefined);
407
+ const cfgPath = resolveConfigPath(configPath) ?? join(process.cwd(), 'crosscheck.config.yml');
408
+ const detected = await detectScopesForDeployment(effectiveDeployment, token);
409
+ selfLogin = detected.login;
410
+ // force=true only for --reconfigure; first-run preserves any manually-configured orgs/authors
411
+ patchDeploymentConfig(cfgPath, effectiveDeployment, detected.login, detected.orgs, !!opts.reconfigure);
412
+ config = loadConfig(configPath);
413
+ console.log(`\n ${chalk.green('✓')} deployment set to ${chalk.cyan(effectiveDeployment)} ${chalk.dim(`(saved to ${cfgPath})`)}`);
414
+ }
415
+ // ── Scope building ────────────────────────────────────────────────────────
416
+ // Determine scopes once — these don't change between tunnel reconnects.
417
+ // orgs, users, and repos are additive: all configured sources contribute scopes.
418
+ const rawScopes = [];
419
+ for (const org of config.orgs)
420
+ rawScopes.push({ org });
421
+ const userRepoResults = [];
422
+ if (config.users.length > 0) {
423
+ // selfLogin is known when we just ran detection; fall back to detectGitHubLogin() for
424
+ // existing configs so personal-mode users still get private repos enumerated.
425
+ if (!selfLogin)
426
+ selfLogin = detectGitHubLogin();
427
+ for (const user of config.users) {
428
+ try {
429
+ const repos = await listUserRepos(user, token, user === selfLogin);
430
+ for (const { owner, name } of repos)
431
+ rawScopes.push({ owner, repo: name });
432
+ userRepoResults.push({ user, count: repos.length });
433
+ }
434
+ catch (err) {
435
+ const msg = err instanceof Error ? err.message : String(err);
436
+ userRepoResults.push({ user, error: msg });
437
+ }
438
+ }
439
+ }
440
+ // Validate explicitly-configured repos and skip any that are inaccessible.
441
+ const repoChecks = await Promise.all(config.repos.map(async ({ owner, name }) => ({
442
+ owner, name,
443
+ ok: await checkRepoAccessible(owner, name, token).catch(() => false),
444
+ })));
445
+ for (const { owner, name, ok } of repoChecks) {
446
+ if (ok) {
447
+ rawScopes.push({ owner, repo: name });
448
+ }
449
+ else {
450
+ console.log(chalk.yellow(` ✗ repo not accessible: ${owner}/${name} — skipped`));
451
+ fileLog({ level: 'warn', event: 'repo_inaccessible', repo: `${owner}/${name}` });
452
+ }
453
+ }
454
+ // Collapse repo scopes already covered by an org scope. Registering both produces
455
+ // duplicate webhook deliveries from GitHub (one per registered hook), which our
456
+ // in-flight dedup absorbs but still clutters logs and burns signature-verification cycles.
457
+ const { scopes, dropped: droppedRepos, fallbackRepos } = dedupScopes(rawScopes);
458
+ for (const [org, repos] of droppedRepos) {
459
+ for (const repo of repos) {
460
+ const fallback = fallbackRepos.get(org)?.find(s => s.repo === repo);
461
+ fileLog({ level: 'info', event: 'scope_deduped', org, owner: fallback?.owner ?? org, repo, reason: 'covered_by_org_scope' });
462
+ }
463
+ }
464
+ if (scopes.length === 0 && config.tunnel.backend !== 'smee') {
465
+ // localhost.run needs a target repo to auto-register webhooks.
466
+ // smee users register the webhook manually — no target required here.
467
+ const detected = detectCurrentRepo();
468
+ if (!detected) {
469
+ console.error(chalk.red('No repos, users, or orgs configured. Run inside a git repo or set repos/users/orgs in config.'));
470
+ server.close(() => process.exit(1));
471
+ return;
472
+ }
473
+ scopes.push({ owner: detected.owner, repo: detected.repo });
474
+ }
475
+ function webhookFailureReason(msg, isOrg) {
476
+ const isCreds = /bad credentials|\[401\]/i.test(msg);
477
+ const isScope = /admin:org|write:org|forbidden|\[403\]|must have admin|resource not accessible/i.test(msg)
478
+ || (isOrg && /\[404\]/i.test(msg));
479
+ return isCreds ? 'creds' : isScope ? 'scope' : `other:${msg}`;
480
+ }
481
+ function addWebhookFailure(failures, reason, label, msg) {
482
+ const bucket = failures.get(reason);
483
+ if (bucket) {
484
+ bucket.labels.push(label);
485
+ }
486
+ else {
487
+ failures.set(reason, { labels: [label], msg });
488
+ }
489
+ }
490
+ // Mutable tunnel session state — replaced on each reconnect
491
+ let currentTunnelProc = null;
492
+ let currentRegistered = [];
493
+ let running = true;
494
+ async function deleteCurrentWebhooks() {
495
+ for (const hook of currentRegistered) {
496
+ try {
497
+ if (hook.type === 'org') {
498
+ await deleteOrgWebhook(hook.org, hook.hookId, token);
499
+ }
500
+ else {
501
+ await deleteRepoWebhook(hook.owner, hook.repo, hook.hookId, token);
502
+ }
503
+ }
504
+ catch { /* best-effort */ }
505
+ }
506
+ currentRegistered = [];
507
+ }
508
+ const cleanup = async () => {
509
+ running = false;
510
+ board.stop();
511
+ stopSmartSwitch();
512
+ console.log('\nCleaning up...');
513
+ currentTunnelProc?.kill();
514
+ await deleteCurrentWebhooks();
515
+ fileLog({ level: 'info', event: 'session_end', command: 'watch' });
516
+ server.close(() => process.exit(0));
517
+ };
518
+ process.on('SIGINT', () => { void cleanup(); });
519
+ process.on('SIGTERM', () => { void cleanup(); });
520
+ // ── Static startup banner ─────────────────────────────────────────────────
521
+ console.log(chalk.dim(`\n "${randomFortune()}"\n`));
522
+ console.log(chalk.bold('crosscheck watch\n'));
523
+ if (effectiveDeployment) {
524
+ const deployLabel = sessionOnly
525
+ ? chalk.dim(`${effectiveDeployment} (session only — not saved)`)
526
+ : chalk.cyan(effectiveDeployment);
527
+ console.log(` profile ${deployLabel} · ${chalk.cyan(config.mode)} · ${chalk.cyan(config.quality.tier)}`);
528
+ }
529
+ else {
530
+ console.log(` profile ${chalk.cyan(config.mode)} · ${chalk.cyan(config.quality.tier)}`);
531
+ }
532
+ if (config.orgs.length > 0) {
533
+ console.log(` orgs ${chalk.cyan(config.orgs.join(', '))}`);
534
+ }
535
+ if (config.users.length > 0) {
536
+ const userParts = userRepoResults.map(r => {
537
+ if ('error' in r)
538
+ return chalk.yellow(`${r.user} (⚠ list failed)`);
539
+ return `${chalk.cyan(r.user)} ${chalk.dim(`(${r.count} repos)`)}`;
540
+ });
541
+ console.log(` users ${userParts.join(', ')}`);
542
+ }
543
+ if (config.orgs.length === 0 && config.users.length === 0) {
544
+ const labels = scopes.map(s => 'org' in s ? s.org : `${s.owner}/${s.repo}`);
545
+ console.log(` repos ${chalk.cyan(labels.join(', '))}`);
546
+ }
547
+ const cfgPath = resolveConfigPath(configPath);
548
+ console.log(` config ${chalk.dim(cfgPath ?? 'none (using defaults)')} ${chalk.dim('← edit to change above')}`);
549
+ if (effectiveDeployment === 'team' && config.routing.allowed_authors.length === 0) {
550
+ console.log(` authors ${chalk.dim('all PRs (team mode)')}`);
551
+ }
552
+ else if (config.routing.allowed_authors.length > 0) {
553
+ console.log(` authors ${chalk.cyan(config.routing.allowed_authors.join(', '))}`);
554
+ }
555
+ else {
556
+ console.log();
557
+ console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('No author filter set — all PRs in monitored orgs/repos will be reviewed.')}`);
558
+ console.log(` ${chalk.dim('Run')} ${chalk.cyan('crosscheck watch --reconfigure')} ${chalk.dim('to set up a deployment mode.')}`);
559
+ }
560
+ // Warn when author_routes will be silently bypassed (cross-vendor + both vendors enabled)
561
+ // so users understand why their configured mapping isn't applying.
562
+ const bothVendorsEnabled = config.mode === 'cross-vendor'
563
+ && config.vendors.claude.enabled
564
+ && config.vendors.codex.enabled;
565
+ const routedAllowedAuthors = bothVendorsEnabled
566
+ ? Object.entries(config.routing.author_routes).filter(([login]) => config.routing.allowed_authors.length === 0 || config.routing.allowed_authors.includes(login))
567
+ : [];
568
+ if (routedAllowedAuthors.length > 0) {
569
+ console.log();
570
+ console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('author_routes bypassed in cross-vendor mode (both vendors enabled).')}`);
571
+ for (const [login, vendor] of routedAllowedAuthors) {
572
+ console.log(` ${chalk.dim(`${login} → ${vendor}`)}`);
573
+ }
574
+ console.log(` ${chalk.dim('PRs without attribution markers (body / Co-Authored-By / branch prefix)')}`);
575
+ console.log(` ${chalk.dim('fall through to')} ${chalk.cyan(`fallback_reviewer: ${config.routing.fallback_reviewer ?? 'skip'}`)} ${chalk.dim('instead.')}`);
576
+ }
577
+ // Warn when repo scopes were dropped because their owner is also an org scope —
578
+ // both being registered causes duplicate webhook deliveries from GitHub.
579
+ if (droppedRepos.size > 0) {
580
+ console.log();
581
+ console.log(` ${chalk.yellow('⚠')} ${chalk.yellow('redundant repo scopes — org webhook already covers these:')}`);
582
+ for (const [org, repos] of droppedRepos) {
583
+ console.log(` ${chalk.dim(`${org}/{${repos.join(', ')}}`)}`);
584
+ }
585
+ console.log(` ${chalk.dim('Remove these entries from')} ${chalk.cyan('config.repos')} ${chalk.dim('to silence this warning.')}`);
586
+ }
587
+ console.log();
588
+ // Board starts after the banner — all output below is live-updated
589
+ board.start();
590
+ // ── Backtrace scan ────────────────────────────────────────────────────────
591
+ if (opts.backtrace === true || (opts.backtrace !== false && config.backtrace.enabled)) {
592
+ void (async () => {
593
+ try {
594
+ cLog(`${chalk.dim('✦')} backtrace: scanning open PRs in monitored scope...`);
595
+ const { queued, alreadyReviewed, skippedAuthor } = await scanUnreviewedPRs(scopes, config, token);
596
+ cLog(`${chalk.dim('✦')} backtrace: ${queued.length} unreviewed, ${alreadyReviewed} already reviewed, ${skippedAuthor} skipped (author filter)`);
597
+ void Promise.all(queued.map(pr => reviewPR({
598
+ owner: pr.owner, repoName: pr.repo, prNumber: pr.number,
599
+ title: pr.title, body: pr.body, author: pr.author,
600
+ headSha: pr.headSha, headRef: pr.headRef, headRepo: pr.headRepo,
601
+ baseRef: pr.baseRef, action: 'backtrace',
602
+ })));
603
+ }
604
+ catch (err) {
605
+ const msg = err instanceof Error ? err.message : String(err);
606
+ cLog(`${chalk.yellow('⚠')} backtrace: scan failed — ${msg}`);
607
+ }
608
+ })();
609
+ }
610
+ // ── Smee mode ─────────────────────────────────────────────────────────────
611
+ // Smee channel URL is stable — webhooks are registered once and survive restarts.
612
+ if (config.tunnel.backend === 'smee') {
613
+ const channelUrl = config.tunnel.smee_channel;
614
+ if (!channelUrl) {
615
+ board.stop();
616
+ console.error(chalk.red('✗ tunnel.smee_channel is required when tunnel.backend: smee'));
617
+ console.error(chalk.dim(' Visit https://smee.io/new to get a free channel URL.'));
618
+ server.close(() => process.exit(1));
619
+ return;
620
+ }
621
+ board.setTunnel('smee', channelUrl, true);
622
+ fileLog({ level: 'info', event: 'tunnel_opened', url: channelUrl, backend: 'smee' });
623
+ // Register webhooks pointing at the smee channel URL (idempotent — skip if already set).
624
+ // The smee channel URL never changes, so this survives restarts without creating duplicates.
625
+ let smeeOk = 0, smeeFail = 0;
626
+ let smeeTotal = scopes.length;
627
+ const smeeFailuresByReason = new Map();
628
+ const succeededOrgs = new Set();
629
+ for (const scope of scopes) {
630
+ const label = 'org' in scope ? scope.org : `${scope.owner}/${scope.repo}`;
631
+ try {
632
+ let existing;
633
+ if ('org' in scope) {
634
+ existing = await findOrgWebhook(scope.org, channelUrl, token);
635
+ if (!existing)
636
+ await registerOrgWebhook(scope.org, channelUrl, webhookSecret, token);
637
+ succeededOrgs.add(scope.org);
638
+ }
639
+ else {
640
+ existing = await findRepoWebhook(scope.owner, scope.repo, channelUrl, token);
641
+ if (!existing)
642
+ await registerRepoWebhook(scope.owner, scope.repo, channelUrl, webhookSecret, token);
643
+ }
644
+ smeeOk++;
645
+ fileLog({ level: 'info', event: existing ? 'webhook_active' : 'webhook_registered', scope: label, url: channelUrl });
646
+ }
647
+ catch (err) {
648
+ const msg = err instanceof Error ? err.message : String(err);
649
+ const fallbackOrg = 'org' in scope ? scope.org : null;
650
+ smeeFail++;
651
+ addWebhookFailure(smeeFailuresByReason, webhookFailureReason(msg, fallbackOrg !== null), label, msg);
652
+ fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: msg });
653
+ const fallback = fallbackOrg ? fallbackRepos.get(fallbackOrg) ?? [] : [];
654
+ smeeTotal += fallback.length;
655
+ await Promise.all(fallback.map(async ({ owner, repo }) => {
656
+ const repoLabel = `${owner}/${repo}`;
657
+ try {
658
+ const existing = await findRepoWebhook(owner, repo, channelUrl, token);
659
+ if (!existing)
660
+ await registerRepoWebhook(owner, repo, channelUrl, webhookSecret, token);
661
+ smeeOk++;
662
+ fileLog({ level: 'info', event: existing ? 'webhook_active' : 'webhook_registered', scope: repoLabel, url: channelUrl, fallback_for_org: fallbackOrg });
663
+ }
664
+ catch (fallbackErr) {
665
+ const fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
666
+ smeeFail++;
667
+ addWebhookFailure(smeeFailuresByReason, webhookFailureReason(fallbackMsg, false), repoLabel, fallbackMsg);
668
+ fileLog({ level: 'warn', event: 'webhook_error', scope: repoLabel, message: fallbackMsg, fallback_for_org: fallbackOrg });
669
+ }
670
+ }));
671
+ }
672
+ }
673
+ // Cleanup: org hook succeeded → delete any stale repo-level hooks for repos now covered by the org hook.
674
+ // Without this, a repo hook registered before the org scope was added would keep firing,
675
+ // re-introducing the duplicate-delivery problem the scope dedup is meant to fix.
676
+ for (const [org, repos] of droppedRepos) {
677
+ if (!succeededOrgs.has(org))
678
+ continue;
679
+ for (const repo of repos) {
680
+ try {
681
+ const staleId = await findRepoWebhook(org, repo, channelUrl, token);
682
+ if (staleId) {
683
+ await deleteRepoWebhook(org, repo, staleId, token);
684
+ fileLog({ level: 'info', event: 'webhook_deleted', scope: `${org}/${repo}`, reason: 'covered_by_org_hook' });
685
+ }
686
+ }
687
+ catch { /* best-effort */ }
688
+ }
689
+ }
690
+ // Grouped failure summary — one block per error type
691
+ for (const [reason, { labels, msg }] of smeeFailuresByReason) {
692
+ const count = labels.length;
693
+ const shown = labels.slice(0, 5);
694
+ const overflow = count - shown.length;
695
+ const sample = shown.join(', ') + (overflow > 0 ? ` +${overflow} more` : '');
696
+ const noun = count === 1 ? 'webhook' : 'webhooks';
697
+ if (reason === 'creds') {
698
+ cLog(`${chalk.yellow('⚠')} ${count} ${noun} failed: token invalid — run: ${chalk.cyan('gh auth refresh')}`);
699
+ }
700
+ else if (reason === 'scope') {
701
+ cLog(`${chalk.yellow('⚠')} ${count} ${noun} failed: missing scope — run: ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
702
+ }
703
+ else {
704
+ cLog(`${chalk.yellow('⚠')} ${count} ${noun} failed: ${msg}`);
705
+ }
706
+ cLog(` ${chalk.dim(sample)}`);
707
+ }
708
+ cLog(`${smeeFail === 0 ? chalk.green('✓') : chalk.yellow('⚠')} webhooks registered: ${smeeOk}/${smeeTotal}${smeeFail > 0 ? ` (${smeeFail} failed)` : ''}`);
709
+ let smeeRetryDelay = 5_000;
710
+ while (running) {
711
+ const smeeProc = spawn('smee', [
712
+ '--url', channelUrl,
713
+ '--path', config.server.webhook_path,
714
+ '--port', String(config.server.port),
715
+ ], { stdio: 'pipe' });
716
+ currentTunnelProc = smeeProc;
717
+ try {
718
+ await new Promise((resolve, reject) => {
719
+ smeeProc.on('error', (err) => {
720
+ if (err.code === 'ENOENT') {
721
+ reject(new Error('smee-client not installed — run: npm install -g smee-client'));
722
+ }
723
+ else {
724
+ reject(err);
725
+ }
726
+ });
727
+ smeeProc.on('exit', () => resolve());
728
+ });
729
+ }
730
+ catch (err) {
731
+ board.stop();
732
+ console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`));
733
+ server.close(() => process.exit(1));
734
+ return;
735
+ }
736
+ if (!running)
737
+ break;
738
+ currentTunnelProc = null;
739
+ board.setTunnel('smee', channelUrl, false);
740
+ cLog(chalk.yellow(`smee relay exited — reconnecting in ${smeeRetryDelay / 1000}s`));
741
+ fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true, backend: 'smee' });
742
+ await new Promise(r => setTimeout(r, smeeRetryDelay));
743
+ smeeRetryDelay = Math.min(smeeRetryDelay * 2, 60_000);
744
+ board.setTunnel('smee', channelUrl, true);
745
+ }
746
+ return;
747
+ }
748
+ // ── localhost.run mode ────────────────────────────────────────────────────
749
+ let reconnectDelay = 5_000;
750
+ while (running) {
751
+ board.setTunnel('localhost.run', null, false);
752
+ let tunnelUrl;
753
+ let tunnelProc;
754
+ try {
755
+ ;
756
+ ({ url: tunnelUrl, proc: tunnelProc } = await openTunnel(config.server.port));
757
+ }
758
+ catch (err) {
759
+ if (!running)
760
+ break;
761
+ const msg = err instanceof Error ? err.message : String(err);
762
+ cLog(chalk.yellow(`tunnel failed: ${msg} — retrying in ${reconnectDelay / 1000}s`));
763
+ fileLog({ level: 'warn', event: 'tunnel_error', message: msg });
764
+ await new Promise(r => setTimeout(r, reconnectDelay));
765
+ reconnectDelay = Math.min(reconnectDelay * 2, 60_000);
766
+ continue;
767
+ }
768
+ reconnectDelay = 5_000; // reset backoff on success
769
+ currentTunnelProc = tunnelProc;
770
+ board.setTunnel('localhost.run', tunnelUrl, true);
771
+ cLog(`${chalk.green('✓')} tunnel ready: ${chalk.cyan(tunnelUrl)}`);
772
+ fileLog({ level: 'info', event: 'tunnel_opened', url: tunnelUrl });
773
+ // Register webhooks in parallel: dedup check → register with backoff → aggregate summary
774
+ const webhookUrl = `${tunnelUrl}${webhookPath}`;
775
+ currentRegistered = [];
776
+ let hookOk = 0, hookFail = 0;
777
+ let hookTotal = scopes.length;
778
+ const failuresByReason = new Map();
779
+ await Promise.all(scopes.map(async (scope) => {
780
+ const label = 'org' in scope ? scope.org : `${scope.owner}/${scope.repo}`;
781
+ // Dedup: skip if a hook for this exact URL already exists (e.g. previous session not cleaned up)
782
+ let existingId = null;
783
+ try {
784
+ existingId = 'org' in scope
785
+ ? await findOrgWebhook(scope.org, webhookUrl, token)
786
+ : await findRepoWebhook(scope.owner, scope.repo, webhookUrl, token);
787
+ }
788
+ catch { /* ignore — proceed to register */ }
789
+ if (existingId !== null) {
790
+ currentRegistered.push('org' in scope
791
+ ? { type: 'org', org: scope.org, hookId: existingId }
792
+ : { type: 'repo', owner: scope.owner, repo: scope.repo, hookId: existingId });
793
+ hookOk++;
794
+ fileLog({ level: 'info', event: 'webhook_active', scope: label, url: webhookUrl });
795
+ return;
796
+ }
797
+ // Register with exponential back-off: delay 2s then 4s before giving up
798
+ let hookId = null;
799
+ let lastErr = '';
800
+ for (let attempt = 0; attempt < 3; attempt++) {
801
+ if (attempt > 0) {
802
+ const delay = 2 ** attempt * 1000;
803
+ fileLog({ level: 'warn', event: 'webhook_register_retry', scope: label, attempt, message: lastErr });
804
+ await new Promise(r => setTimeout(r, delay));
805
+ }
806
+ try {
807
+ hookId = 'org' in scope
808
+ ? await registerOrgWebhook(scope.org, webhookUrl, webhookSecret, token)
809
+ : await registerRepoWebhook(scope.owner, scope.repo, webhookUrl, webhookSecret, token);
810
+ break;
811
+ }
812
+ catch (err) {
813
+ lastErr = err instanceof Error ? err.message : String(err);
814
+ }
815
+ }
816
+ if (hookId !== null) {
817
+ currentRegistered.push('org' in scope
818
+ ? { type: 'org', org: scope.org, hookId }
819
+ : { type: 'repo', owner: scope.owner, repo: scope.repo, hookId });
820
+ hookOk++;
821
+ fileLog({ level: 'info', event: 'webhook_registered', scope: label, url: webhookUrl });
822
+ }
823
+ else {
824
+ const fallbackOrg = 'org' in scope ? scope.org : null;
825
+ hookFail++;
826
+ addWebhookFailure(failuresByReason, webhookFailureReason(lastErr, fallbackOrg !== null), label, lastErr);
827
+ fileLog({ level: 'warn', event: 'webhook_error', scope: label, message: lastErr });
828
+ const fallback = fallbackOrg ? fallbackRepos.get(fallbackOrg) ?? [] : [];
829
+ hookTotal += fallback.length;
830
+ await Promise.all(fallback.map(async ({ owner, repo }) => {
831
+ const repoLabel = `${owner}/${repo}`;
832
+ let fallbackHookId = null;
833
+ let fallbackLastErr = '';
834
+ try {
835
+ fallbackHookId = await findRepoWebhook(owner, repo, webhookUrl, token);
836
+ }
837
+ catch { /* ignore — proceed to register */ }
838
+ if (fallbackHookId === null) {
839
+ for (let attempt = 0; attempt < 3; attempt++) {
840
+ if (attempt > 0) {
841
+ const delay = 2 ** attempt * 1000;
842
+ fileLog({ level: 'warn', event: 'webhook_register_retry', scope: repoLabel, attempt, message: fallbackLastErr, fallback_for_org: fallbackOrg });
843
+ await new Promise(r => setTimeout(r, delay));
844
+ }
845
+ try {
846
+ fallbackHookId = await registerRepoWebhook(owner, repo, webhookUrl, webhookSecret, token);
847
+ break;
848
+ }
849
+ catch (fallbackErr) {
850
+ fallbackLastErr = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
851
+ }
852
+ }
853
+ }
854
+ if (fallbackHookId !== null) {
855
+ currentRegistered.push({ type: 'repo', owner, repo, hookId: fallbackHookId });
856
+ hookOk++;
857
+ fileLog({ level: 'info', event: 'webhook_registered', scope: repoLabel, url: webhookUrl, fallback_for_org: fallbackOrg });
858
+ }
859
+ else {
860
+ hookFail++;
861
+ addWebhookFailure(failuresByReason, webhookFailureReason(fallbackLastErr, false), repoLabel, fallbackLastErr);
862
+ fileLog({ level: 'warn', event: 'webhook_error', scope: repoLabel, message: fallbackLastErr, fallback_for_org: fallbackOrg });
863
+ }
864
+ }));
865
+ }
866
+ }));
867
+ // Print grouped failure summary — one block per error type, not one line per repo
868
+ for (const [reason, { labels, msg }] of failuresByReason) {
869
+ const count = labels.length;
870
+ const shown = labels.slice(0, 5);
871
+ const overflow = count - shown.length;
872
+ const sample = shown.join(', ') + (overflow > 0 ? ` +${overflow} more` : '');
873
+ const noun = count === 1 ? 'webhook' : 'webhooks';
874
+ if (reason === 'creds') {
875
+ bLog(` ${chalk.yellow('⚠')} ${count} ${noun} failed: token invalid — run: ${chalk.cyan('gh auth refresh')}`);
876
+ }
877
+ else if (reason === 'scope') {
878
+ bLog(` ${chalk.yellow('⚠')} ${count} ${noun} failed: missing scope — run: ${chalk.cyan('gh auth refresh -s admin:org_hook')}`);
879
+ }
880
+ else {
881
+ bLog(` ${chalk.yellow('⚠')} ${count} ${noun} failed: ${msg}`);
882
+ }
883
+ bLog(` ${chalk.dim(sample)}`);
884
+ bLog(` manual Payload URL: ${chalk.cyan(webhookUrl)}`);
885
+ }
886
+ // Single aggregated connectivity line instead of one per repo
887
+ cLog(`${hookFail === 0 ? chalk.green('✓') : chalk.yellow('⚠')} webhooks registered: ${hookOk}/${hookTotal}${hookFail > 0 ? ` (${hookFail} failed)` : ''}`);
888
+ fileLog({ level: 'info', event: 'webhooks_registered', count: hookOk, total: hookTotal, failed: hookFail, url: webhookUrl });
889
+ // Wait for this tunnel session to end.
890
+ // Health check kills the SSH proc if lhr.life goes dead without exiting.
891
+ await waitForTunnelEnd(tunnelProc, tunnelUrl);
892
+ if (!running)
893
+ break;
894
+ // Clean up webhooks tied to the old URL before reconnecting
895
+ await deleteCurrentWebhooks();
896
+ board.setTunnel('localhost.run', tunnelUrl, false);
897
+ cLog(chalk.yellow('tunnel disconnected — reconnecting in 5s...'));
898
+ fileLog({ level: 'warn', event: 'tunnel_closed', reconnecting: true });
899
+ await new Promise(r => setTimeout(r, reconnectDelay));
900
+ }
901
+ }
902
+ //# sourceMappingURL=watch.js.map