local-review 0.0.1 → 0.2.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 (364) hide show
  1. package/AGENT-GUIDE.md +468 -0
  2. package/CHANGELOG.md +53 -0
  3. package/LICENSE +21 -0
  4. package/README.md +126 -2
  5. package/dist/server/anchors.js +207 -0
  6. package/dist/server/assets.js +147 -0
  7. package/dist/server/backups.js +212 -0
  8. package/dist/server/branches.js +113 -0
  9. package/dist/server/cli.js +255 -0
  10. package/dist/server/commits.js +393 -0
  11. package/dist/server/config.js +59 -0
  12. package/dist/server/context.js +68 -0
  13. package/dist/server/events.js +87 -0
  14. package/dist/server/git.js +103 -0
  15. package/dist/server/index.js +566 -0
  16. package/dist/server/landed.js +71 -0
  17. package/dist/server/plan.js +260 -0
  18. package/dist/server/port.js +3 -0
  19. package/dist/server/projectEdit.js +117 -0
  20. package/dist/server/projects.js +319 -0
  21. package/dist/server/prs.js +314 -0
  22. package/dist/server/reviewer.js +33 -0
  23. package/dist/server/reviews.js +586 -0
  24. package/dist/server/safefs.js +92 -0
  25. package/dist/server/security.js +38 -0
  26. package/dist/server/stackviz.js +446 -0
  27. package/dist/shared/assets.js +54 -0
  28. package/dist/shared/paths.js +13 -0
  29. package/dist/shared/prText.js +22 -0
  30. package/dist/shared/readiness.js +37 -0
  31. package/dist/shared/repoLabel.js +13 -0
  32. package/dist/shared/totals.js +75 -0
  33. package/dist/web/assets/abap-CLvhMVsD.js +1 -0
  34. package/dist/web/assets/actionscript-3-Cs6JJXT5.js +1 -0
  35. package/dist/web/assets/ada-X3Y2mlq2.js +1 -0
  36. package/dist/web/assets/ahk-DwADkxyd.js +1 -0
  37. package/dist/web/assets/ahk2-_6rzW9BS.js +1 -0
  38. package/dist/web/assets/andromeeda-vGVdxbeo.js +1 -0
  39. package/dist/web/assets/angular-html-DJ4eInGE.js +1 -0
  40. package/dist/web/assets/angular-ts-DBhgg2a7.js +1 -0
  41. package/dist/web/assets/apache-U0d_L8uA.js +1 -0
  42. package/dist/web/assets/apex-KFGFyKwG.js +1 -0
  43. package/dist/web/assets/apl-TCQAu201.js +1 -0
  44. package/dist/web/assets/apple-touch-icon-BNrSfFwW.png +0 -0
  45. package/dist/web/assets/applescript-CCn79oCD.js +1 -0
  46. package/dist/web/assets/ara-4CJ0cIlV.js +1 -0
  47. package/dist/web/assets/asciidoc-SCjQUq34.js +1 -0
  48. package/dist/web/assets/asm-Cmm7eHzH.js +1 -0
  49. package/dist/web/assets/astro-DVtFuwC2.js +1 -0
  50. package/dist/web/assets/aurora-x-CDeNXAV0.js +1 -0
  51. package/dist/web/assets/awk-BWXHIvNe.js +1 -0
  52. package/dist/web/assets/ayu-dark-DluEY0Gj.js +1 -0
  53. package/dist/web/assets/ayu-light-C3h-C4tm.js +1 -0
  54. package/dist/web/assets/ayu-mirage-Bqwy1Gya.js +1 -0
  55. package/dist/web/assets/ballerina-B7ZEbQpA.js +1 -0
  56. package/dist/web/assets/bat-qdxAdBWQ.js +1 -0
  57. package/dist/web/assets/beancount-C2IN4Vk8.js +1 -0
  58. package/dist/web/assets/berry-DKpUyyne.js +1 -0
  59. package/dist/web/assets/bibtex-Ci_nEsc7.js +1 -0
  60. package/dist/web/assets/bicep-CUHmPFLl.js +1 -0
  61. package/dist/web/assets/bird2-CYIUgYqB.js +1 -0
  62. package/dist/web/assets/blade-CMp-sZYg.js +1 -0
  63. package/dist/web/assets/bsl-_VjlB9mv.js +1 -0
  64. package/dist/web/assets/c-DgqspIM2.js +1 -0
  65. package/dist/web/assets/c3-D0apG41V.js +1 -0
  66. package/dist/web/assets/cadence-CQ2zXKGN.js +1 -0
  67. package/dist/web/assets/cairo-DLTphjLi.js +1 -0
  68. package/dist/web/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
  69. package/dist/web/assets/catppuccin-latte-CtsyrtPq.js +1 -0
  70. package/dist/web/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
  71. package/dist/web/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
  72. package/dist/web/assets/chapel-NTLt7i8E.js +1 -0
  73. package/dist/web/assets/clarity-CtfBBspg.js +1 -0
  74. package/dist/web/assets/clojure-DqKBuwfJ.js +1 -0
  75. package/dist/web/assets/cmake-Bj61d0ZC.js +1 -0
  76. package/dist/web/assets/cobol-CJiWQrT4.js +1 -0
  77. package/dist/web/assets/codeowners-C8r90Shi.js +1 -0
  78. package/dist/web/assets/codeql-oeQT6MSM.js +1 -0
  79. package/dist/web/assets/coffee-Bv_hE6wf.js +1 -0
  80. package/dist/web/assets/common-lisp-Cv5bFMCO.js +1 -0
  81. package/dist/web/assets/coq-CGaSfhwR.js +1 -0
  82. package/dist/web/assets/cpp-CuUsyAvc.js +1 -0
  83. package/dist/web/assets/crystal-BeLopiD_.js +1 -0
  84. package/dist/web/assets/csharp-dmMxLVLW.js +1 -0
  85. package/dist/web/assets/css-OngwBV3K.js +1 -0
  86. package/dist/web/assets/csv-Dx-8-gkx.js +1 -0
  87. package/dist/web/assets/cue-CE9AQfxI.js +1 -0
  88. package/dist/web/assets/cypher-ClKdZ_lG.js +1 -0
  89. package/dist/web/assets/d-qD-0Kul2.js +1 -0
  90. package/dist/web/assets/dark-plus-Cs2F2srj.js +1 -0
  91. package/dist/web/assets/dart-CHZY9Xhz.js +1 -0
  92. package/dist/web/assets/dax-BkyTk9wS.js +1 -0
  93. package/dist/web/assets/desktop-Dlh5hvp9.js +1 -0
  94. package/dist/web/assets/diff-woXpYk--.js +1 -0
  95. package/dist/web/assets/docker-IyjqRm3v.js +1 -0
  96. package/dist/web/assets/dotenv-CuH5my32.js +1 -0
  97. package/dist/web/assets/dracula-BHWKrbxM.js +1 -0
  98. package/dist/web/assets/dracula-soft-5eyTD99u.js +1 -0
  99. package/dist/web/assets/dream-maker-DW3nJb8Q.js +1 -0
  100. package/dist/web/assets/edge-BFLildoE.js +1 -0
  101. package/dist/web/assets/elixir-1Fs6FjVs.js +1 -0
  102. package/dist/web/assets/elm-piMM67vF.js +1 -0
  103. package/dist/web/assets/emacs-lisp-DR2vtWaj.js +1 -0
  104. package/dist/web/assets/erb-JJ0sF8GL.js +1 -0
  105. package/dist/web/assets/erlang-Cphh6RMH.js +1 -0
  106. package/dist/web/assets/everforest-dark-sB-x3p7T.js +1 -0
  107. package/dist/web/assets/everforest-light-Df2xbC6M.js +1 -0
  108. package/dist/web/assets/favicon-Bld6z4jR.svg +13 -0
  109. package/dist/web/assets/favicon-CDTyigy2.ico +0 -0
  110. package/dist/web/assets/fennel-DQxkIbk2.js +1 -0
  111. package/dist/web/assets/fish-BJitypiv.js +1 -0
  112. package/dist/web/assets/fluent-C03EYrpw.js +1 -0
  113. package/dist/web/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
  114. package/dist/web/assets/fortran-free-form-CYNrtFtB.js +1 -0
  115. package/dist/web/assets/fsharp-D13ZGOAj.js +1 -0
  116. package/dist/web/assets/gdresource-xMC7jr7x.js +1 -0
  117. package/dist/web/assets/gdscript-DYvRD3hg.js +1 -0
  118. package/dist/web/assets/gdshader-CBce3t8t.js +1 -0
  119. package/dist/web/assets/genie-CV2tkWYe.js +1 -0
  120. package/dist/web/assets/gherkin-DExj1W_8.js +1 -0
  121. package/dist/web/assets/git-commit-BSykSTBG.js +1 -0
  122. package/dist/web/assets/git-rebase-CtqXywWe.js +1 -0
  123. package/dist/web/assets/github-dark-C-LZuMrd.js +1 -0
  124. package/dist/web/assets/github-dark-default-DXG-b-1a.js +1 -0
  125. package/dist/web/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
  126. package/dist/web/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
  127. package/dist/web/assets/github-light-EUqPIrTm.js +1 -0
  128. package/dist/web/assets/github-light-default-BXViO-2h.js +1 -0
  129. package/dist/web/assets/github-light-high-contrast-B68TUdTA.js +1 -0
  130. package/dist/web/assets/gleam-CSRkHgEL.js +1 -0
  131. package/dist/web/assets/glimmer-js-BPewWxGC.js +1 -0
  132. package/dist/web/assets/glimmer-ts-DyUQWB47.js +1 -0
  133. package/dist/web/assets/glsl-D6tJoMaT.js +1 -0
  134. package/dist/web/assets/gn-CAXYAgV-.js +1 -0
  135. package/dist/web/assets/gnuplot-7GGW24-e.js +1 -0
  136. package/dist/web/assets/go-rLFTqkRN.js +1 -0
  137. package/dist/web/assets/graphql-E6u2hkWE.js +1 -0
  138. package/dist/web/assets/groovy-CacY0gHj.js +1 -0
  139. package/dist/web/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
  140. package/dist/web/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
  141. package/dist/web/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
  142. package/dist/web/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
  143. package/dist/web/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
  144. package/dist/web/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
  145. package/dist/web/assets/hack-Djq191-P.js +1 -0
  146. package/dist/web/assets/haml-C0LqmZG3.js +1 -0
  147. package/dist/web/assets/handlebars-DwdxF6uN.js +1 -0
  148. package/dist/web/assets/haskell-D8IpX4py.js +1 -0
  149. package/dist/web/assets/haxe-C8oCnwJJ.js +1 -0
  150. package/dist/web/assets/hcl-Dh228itO.js +1 -0
  151. package/dist/web/assets/hjson-CxZEssPk.js +1 -0
  152. package/dist/web/assets/hlsl-Cvrh5tZx.js +1 -0
  153. package/dist/web/assets/horizon-CE9ld1lL.js +1 -0
  154. package/dist/web/assets/horizon-bright-DSNQnXHK.js +1 -0
  155. package/dist/web/assets/houston-CsvMBhTu.js +1 -0
  156. package/dist/web/assets/html-B5Myt0fy.js +1 -0
  157. package/dist/web/assets/html-derivative-DTVrLAw3.js +1 -0
  158. package/dist/web/assets/http-CFoh3cQf.js +1 -0
  159. package/dist/web/assets/hurl-B_38naAo.js +1 -0
  160. package/dist/web/assets/hxml-Dcm88srW.js +1 -0
  161. package/dist/web/assets/hy-CZbG8q4J.js +1 -0
  162. package/dist/web/assets/imba-DsUTQ-LC.js +1 -0
  163. package/dist/web/assets/index-CfxIojO0.js +451 -0
  164. package/dist/web/assets/index-QywYYqTw.css +1 -0
  165. package/dist/web/assets/ini-B5eOa1yu.js +1 -0
  166. package/dist/web/assets/java-odnexk6r.js +1 -0
  167. package/dist/web/assets/javascript-DvFCJKws.js +1 -0
  168. package/dist/web/assets/jinja-UeLt2DYu.js +1 -0
  169. package/dist/web/assets/jison-HNaDYeqE.js +1 -0
  170. package/dist/web/assets/json-D57CnrjR.js +1 -0
  171. package/dist/web/assets/json5-BR5RXkoi.js +1 -0
  172. package/dist/web/assets/jsonc-CYpm1nAK.js +1 -0
  173. package/dist/web/assets/jsonl-CmCQp5Yx.js +1 -0
  174. package/dist/web/assets/jsonnet-CJTPZ8u_.js +1 -0
  175. package/dist/web/assets/jssm-DXw9l8Rf.js +1 -0
  176. package/dist/web/assets/jsx-o_5ZAydi.js +1 -0
  177. package/dist/web/assets/julia-20UebbrD.js +1 -0
  178. package/dist/web/assets/just-DqnPCg-Q.js +1 -0
  179. package/dist/web/assets/kanagawa-dragon-Bozd7klA.js +1 -0
  180. package/dist/web/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
  181. package/dist/web/assets/kanagawa-wave-BML5mF2J.js +1 -0
  182. package/dist/web/assets/kdl-CsD5j6eV.js +1 -0
  183. package/dist/web/assets/kotlin-DhhofPvG.js +1 -0
  184. package/dist/web/assets/kusto-C7mF5XQf.js +1 -0
  185. package/dist/web/assets/laserwave-C_8bwKvT.js +1 -0
  186. package/dist/web/assets/latex-CZhIB-LV.js +1 -0
  187. package/dist/web/assets/lean-CewbzKMR.js +1 -0
  188. package/dist/web/assets/less-DVTAwKKz.js +1 -0
  189. package/dist/web/assets/light-plus-DVQuIRkW.js +1 -0
  190. package/dist/web/assets/liquid-BzouPsN0.js +1 -0
  191. package/dist/web/assets/llvm-DqHOMS-s.js +1 -0
  192. package/dist/web/assets/log-BNLmms1o.js +1 -0
  193. package/dist/web/assets/logo-Cluzi2Zq.js +1 -0
  194. package/dist/web/assets/lua-BXclv8Fm.js +1 -0
  195. package/dist/web/assets/luau-VGWVLoqX.js +1 -0
  196. package/dist/web/assets/make-Dixweg8N.js +1 -0
  197. package/dist/web/assets/markdown-BYOwaDjH.js +1 -0
  198. package/dist/web/assets/marko-M78_INlG.js +1 -0
  199. package/dist/web/assets/material-theme-Bm3Qr25_.js +1 -0
  200. package/dist/web/assets/material-theme-darker-2IIEA8gg.js +1 -0
  201. package/dist/web/assets/material-theme-lighter-uhdI0v04.js +1 -0
  202. package/dist/web/assets/material-theme-ocean-CHQ94UKr.js +1 -0
  203. package/dist/web/assets/material-theme-palenight-B5W6OYN7.js +1 -0
  204. package/dist/web/assets/matlab-D7qyCx1q.js +1 -0
  205. package/dist/web/assets/mdc-CBluOiY7.js +1 -0
  206. package/dist/web/assets/mdx-DQZ5AkYe.js +1 -0
  207. package/dist/web/assets/mermaid-BxpfTIzt.js +1 -0
  208. package/dist/web/assets/min-dark-BSWPekZh.js +1 -0
  209. package/dist/web/assets/min-light-DDpmG2fV.js +1 -0
  210. package/dist/web/assets/mipsasm-BMqwQI7S.js +1 -0
  211. package/dist/web/assets/mojo-fg40_ntQ.js +1 -0
  212. package/dist/web/assets/monokai-BscAjiQZ.js +1 -0
  213. package/dist/web/assets/moonbit-D9W784E_.js +1 -0
  214. package/dist/web/assets/move-BYnGhZoM.js +1 -0
  215. package/dist/web/assets/narrat-_X_XdTYD.js +1 -0
  216. package/dist/web/assets/nextflow-CSgcYZcx.js +1 -0
  217. package/dist/web/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
  218. package/dist/web/assets/nginx-CZFFQFbT.js +1 -0
  219. package/dist/web/assets/night-owl-DhmEMT88.js +1 -0
  220. package/dist/web/assets/night-owl-light-eJ-hLW7d.js +1 -0
  221. package/dist/web/assets/nim-17kKsGqk.js +1 -0
  222. package/dist/web/assets/nix-IvuFDN5E.js +1 -0
  223. package/dist/web/assets/nord-Cb4Vim4T.js +1 -0
  224. package/dist/web/assets/nsis-CjNLYCLg.js +1 -0
  225. package/dist/web/assets/nushell--0fT3PI1.js +1 -0
  226. package/dist/web/assets/objective-c-D1A_Heim.js +1 -0
  227. package/dist/web/assets/objective-cpp-BsSzOQcm.js +1 -0
  228. package/dist/web/assets/ocaml-O90oeIOV.js +1 -0
  229. package/dist/web/assets/odin-B1RWQWA5.js +1 -0
  230. package/dist/web/assets/one-dark-pro-C6YyR8OD.js +1 -0
  231. package/dist/web/assets/one-light-D7Lr4KcI.js +1 -0
  232. package/dist/web/assets/openscad-BUDT5pXO.js +1 -0
  233. package/dist/web/assets/org-BZABHRQH.js +1 -0
  234. package/dist/web/assets/pascal-4ZHwLPI5.js +1 -0
  235. package/dist/web/assets/perl-CMrAQ9Je.js +1 -0
  236. package/dist/web/assets/php-DwA3kETk.js +1 -0
  237. package/dist/web/assets/pierre-dark-CpLgRqie.js +1 -0
  238. package/dist/web/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
  239. package/dist/web/assets/pierre-dark-soft-kZQmAZld.js +1 -0
  240. package/dist/web/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
  241. package/dist/web/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
  242. package/dist/web/assets/pierre-light-CoaEpmwp.js +1 -0
  243. package/dist/web/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
  244. package/dist/web/assets/pierre-light-soft-lWLdNTOI.js +1 -0
  245. package/dist/web/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
  246. package/dist/web/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
  247. package/dist/web/assets/pkl-ot-7Btpt.js +1 -0
  248. package/dist/web/assets/plastic-DQwYfKfQ.js +1 -0
  249. package/dist/web/assets/plsql-DGHpHOYJ.js +1 -0
  250. package/dist/web/assets/po-BiJDBrnU.js +1 -0
  251. package/dist/web/assets/poimandres-DRFjx7u4.js +1 -0
  252. package/dist/web/assets/polar-C7UOKdEL.js +1 -0
  253. package/dist/web/assets/postcss-BXeXVLqQ.js +1 -0
  254. package/dist/web/assets/powerquery-DNMTfnFr.js +1 -0
  255. package/dist/web/assets/powershell-QtSnmXcu.js +1 -0
  256. package/dist/web/assets/prisma-DMgNhnYS.js +1 -0
  257. package/dist/web/assets/prolog-iXnhIJG7.js +1 -0
  258. package/dist/web/assets/proto-DB4EqR-F.js +1 -0
  259. package/dist/web/assets/pug-D4fM85kA.js +1 -0
  260. package/dist/web/assets/puppet-CDv2pdJW.js +1 -0
  261. package/dist/web/assets/purescript-9MfHhQsQ.js +1 -0
  262. package/dist/web/assets/python-gzcpVVnB.js +1 -0
  263. package/dist/web/assets/qml-C3893RHi.js +1 -0
  264. package/dist/web/assets/qmldir-DCQb3MpD.js +1 -0
  265. package/dist/web/assets/qss-Fe1Jh2GI.js +1 -0
  266. package/dist/web/assets/r-DwJhLW77.js +1 -0
  267. package/dist/web/assets/racket-DcIDlBhZ.js +1 -0
  268. package/dist/web/assets/raku-B3gFvitq.js +1 -0
  269. package/dist/web/assets/razor-_KkCNZ8V.js +1 -0
  270. package/dist/web/assets/rbs-C0MpOrj4.js +1 -0
  271. package/dist/web/assets/red-CYlAq2b0.js +1 -0
  272. package/dist/web/assets/reg-CRGYupPL.js +1 -0
  273. package/dist/web/assets/regexp-BZ9NHg3b.js +1 -0
  274. package/dist/web/assets/rel-BtDbiS_P.js +1 -0
  275. package/dist/web/assets/riscv-Ckw8ddFX.js +1 -0
  276. package/dist/web/assets/ron-VUp2lXgN.js +1 -0
  277. package/dist/web/assets/rose-pine-aMsaMD1D.js +1 -0
  278. package/dist/web/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
  279. package/dist/web/assets/rose-pine-moon-DO-qXg-I.js +1 -0
  280. package/dist/web/assets/rosmsg-CAekHB0j.js +1 -0
  281. package/dist/web/assets/rst-gdK-1c4t.js +1 -0
  282. package/dist/web/assets/ruby-DsmqYtlH.js +1 -0
  283. package/dist/web/assets/rust-DpiIf5JO.js +1 -0
  284. package/dist/web/assets/sas-BNGv1ScF.js +1 -0
  285. package/dist/web/assets/sass-DXrisJhu.js +1 -0
  286. package/dist/web/assets/scala-BiFt_7mu.js +1 -0
  287. package/dist/web/assets/scheme-DQCgrYNe.js +1 -0
  288. package/dist/web/assets/scss-DjYUXT73.js +1 -0
  289. package/dist/web/assets/sdbl-bTVj8UrX.js +1 -0
  290. package/dist/web/assets/shaderlab-TOUzSsQk.js +1 -0
  291. package/dist/web/assets/shellscript-DBtV-eli.js +1 -0
  292. package/dist/web/assets/shellsession-BKEV8Fnx.js +1 -0
  293. package/dist/web/assets/slack-dark-DnToyrRv.js +1 -0
  294. package/dist/web/assets/slack-ochin-B2OO5cIa.js +1 -0
  295. package/dist/web/assets/smalltalk-BHpQb5nM.js +1 -0
  296. package/dist/web/assets/smithy-Dgaz7aur.js +1 -0
  297. package/dist/web/assets/snazzy-light-4G7pJPwS.js +1 -0
  298. package/dist/web/assets/solarized-dark-b97czJOk.js +1 -0
  299. package/dist/web/assets/solarized-light-DFPxpIYQ.js +1 -0
  300. package/dist/web/assets/solidity-Bj7E1mRn.js +1 -0
  301. package/dist/web/assets/soy-DUapMxos.js +1 -0
  302. package/dist/web/assets/sparql-D_iOobhT.js +1 -0
  303. package/dist/web/assets/splunk-BC2Px7Mm.js +1 -0
  304. package/dist/web/assets/sql-B-UyqKkd.js +1 -0
  305. package/dist/web/assets/ssh-config-BgfXC-Er.js +1 -0
  306. package/dist/web/assets/stata-Xq3iZiMK.js +1 -0
  307. package/dist/web/assets/stylus-B6D30XZt.js +1 -0
  308. package/dist/web/assets/surrealql-CzXacK8i.js +1 -0
  309. package/dist/web/assets/svelte-CZaBEmPP.js +1 -0
  310. package/dist/web/assets/swift-CyEgAFGc.js +1 -0
  311. package/dist/web/assets/synthwave-84-nFMaYfgc.js +1 -0
  312. package/dist/web/assets/system-verilog-Dh5ihWbf.js +1 -0
  313. package/dist/web/assets/systemd-BxMlprV5.js +1 -0
  314. package/dist/web/assets/talonscript-CohzipZa.js +1 -0
  315. package/dist/web/assets/tasl-DMoTqEGO.js +1 -0
  316. package/dist/web/assets/tcl-CZd0xW_V.js +1 -0
  317. package/dist/web/assets/templ-CxkGuUpr.js +1 -0
  318. package/dist/web/assets/terraform-DswuEJGm.js +1 -0
  319. package/dist/web/assets/tex-DP74JH6g.js +1 -0
  320. package/dist/web/assets/tokyo-night-oM2G3aXe.js +1 -0
  321. package/dist/web/assets/toml-CcmNWLt0.js +1 -0
  322. package/dist/web/assets/ts-tags-BAcrCE2Y.js +1 -0
  323. package/dist/web/assets/tsv-sltzmVWM.js +1 -0
  324. package/dist/web/assets/tsx-Dw11YP6z.js +1 -0
  325. package/dist/web/assets/turtle-ByJddavk.js +1 -0
  326. package/dist/web/assets/twig-BMVjBxD6.js +1 -0
  327. package/dist/web/assets/typescript-DoBLlVdC.js +1 -0
  328. package/dist/web/assets/typespec-BGhR2SqG.js +1 -0
  329. package/dist/web/assets/typst-w2u_vl4_.js +1 -0
  330. package/dist/web/assets/v-DBj4K9d_.js +1 -0
  331. package/dist/web/assets/vala-zf12oZj6.js +1 -0
  332. package/dist/web/assets/vb-DOwzaHZ0.js +1 -0
  333. package/dist/web/assets/verilog-B2gwz-sJ.js +1 -0
  334. package/dist/web/assets/vesper-DdrHHSXu.js +1 -0
  335. package/dist/web/assets/vhdl-BroJfC0k.js +1 -0
  336. package/dist/web/assets/viml-DvXPmvsu.js +1 -0
  337. package/dist/web/assets/vitesse-black-fwtXNY1n.js +1 -0
  338. package/dist/web/assets/vitesse-dark-BZCL-v6S.js +1 -0
  339. package/dist/web/assets/vitesse-light-VbXTXTou.js +1 -0
  340. package/dist/web/assets/vue-BmuHTv5p.js +1 -0
  341. package/dist/web/assets/vue-html-Dt0zSF4I.js +1 -0
  342. package/dist/web/assets/vue-vine-wvJ1BTex.js +1 -0
  343. package/dist/web/assets/vyper-CgoNMtux.js +1 -0
  344. package/dist/web/assets/wasm-BnjxR4X6.js +1 -0
  345. package/dist/web/assets/wasm-ByWQv1Qj.js +1 -0
  346. package/dist/web/assets/wenyan-C8pVoKbM.js +1 -0
  347. package/dist/web/assets/wgsl-BsKzXJz4.js +1 -0
  348. package/dist/web/assets/wikitext-ClFFjSW2.js +1 -0
  349. package/dist/web/assets/wit-DdvCle-K.js +1 -0
  350. package/dist/web/assets/wolfram-DLL8P-h_.js +1 -0
  351. package/dist/web/assets/xml-CXftIBCQ.js +1 -0
  352. package/dist/web/assets/xsl-BoHSOMz1.js +1 -0
  353. package/dist/web/assets/yaml-dGreldtD.js +1 -0
  354. package/dist/web/assets/zenscript-BnlCZFoB.js +1 -0
  355. package/dist/web/assets/zig-CMLA9XwU.js +1 -0
  356. package/dist/web/index.html +16 -0
  357. package/examples/project.example.yaml +40 -0
  358. package/package.json +89 -3
  359. package/server/fonts/Inter-OFL.txt +93 -0
  360. package/server/fonts/Inter_400Regular.ttf +0 -0
  361. package/server/fonts/Inter_600SemiBold.ttf +0 -0
  362. package/server/fonts/Inter_700Bold.ttf +0 -0
  363. package/server/fonts/JetBrainsMono-OFL.txt +93 -0
  364. package/server/fonts/JetBrainsMono_400Regular.ttf +0 -0
@@ -0,0 +1,103 @@
1
+ // Read-only git access. Every call goes through execFile (no shell) with `git -C <repo>`,
2
+ // and only a fixed allow-list of read-only subcommands may be run.
3
+ import { execFile, spawn } from 'node:child_process';
4
+ const READ_ONLY = new Set([
5
+ 'log', 'show', 'diff', 'rev-list', 'rev-parse', 'merge-base', 'patch-id',
6
+ 'cat-file', 'ls-tree', 'for-each-ref', 'diff-tree',
7
+ ]);
8
+ export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
9
+ // Inherited GIT_* variables could point git somewhere else (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, e.g. when
10
+ // started from inside a git hook) or change its behaviour (GIT_EXTERNAL_DIFF, GIT_CONFIG_PARAMETERS), so drop
11
+ // them all and set only our own.
12
+ const ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')));
13
+ Object.assign(ENV, {
14
+ GIT_OPTIONAL_LOCKS: '0', // never take index locks or refresh the index
15
+ GIT_TERMINAL_PROMPT: '0',
16
+ GIT_PAGER: 'cat',
17
+ LC_ALL: 'C',
18
+ });
19
+ // Prepended to every call. --no-optional-locks is the flag form of GIT_OPTIONAL_LOCKS=0 (belt and braces);
20
+ // the -c settings neutralise repo config that would change our parsed output or run programs:
21
+ // signatures in `show`, colour, quoted paths, textconv filters and external diff drivers.
22
+ const BASE_ARGS = [
23
+ '--no-optional-locks',
24
+ '-c', 'core.quotepath=off',
25
+ '-c', 'color.ui=never',
26
+ '-c', 'log.showSignature=false',
27
+ '-c', 'diff.noprefix=false',
28
+ '-c', 'diff.relative=false',
29
+ ];
30
+ // Options that make otherwise read-only commands write files or run external programs.
31
+ const FORBIDDEN_ARG = /^--(output|output-directory|no-index|ext-diff|textconv|exec|upload-pack|receive-pack)(=|$)/;
32
+ export class GitError extends Error {
33
+ stderr;
34
+ constructor(message, stderr = '') {
35
+ super(message);
36
+ this.stderr = stderr;
37
+ }
38
+ }
39
+ function check(repo, args) {
40
+ const sub = args[0];
41
+ if (!READ_ONLY.has(sub))
42
+ throw new GitError(`refusing to run non-read-only git command: ${sub}`);
43
+ const bad = args.find((a) => FORBIDDEN_ARG.test(a));
44
+ if (bad)
45
+ throw new GitError(`refusing git option ${bad}`);
46
+ if (typeof repo !== 'string' || !repo || repo.startsWith('-'))
47
+ throw new GitError(`bad repo path: ${repo}`);
48
+ return ['-C', repo, ...BASE_ARGS, ...args];
49
+ }
50
+ export function git(repo, args, opts = {}) {
51
+ return new Promise((resolve, reject) => {
52
+ const argv = check(repo, args); // a throw here rejects the promise
53
+ execFile('git', argv, { env: ENV, maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024, encoding: 'utf8' }, (err, stdout, stderr) => {
54
+ if (err)
55
+ reject(new GitError(`git ${args.join(' ')} failed: ${String(stderr || err.message).trim()}`, String(stderr)));
56
+ else
57
+ resolve(stdout);
58
+ });
59
+ });
60
+ }
61
+ /** Like git() but returns a Buffer (for blobs that may be binary). */
62
+ export function gitBuffer(repo, args) {
63
+ return new Promise((resolve, reject) => {
64
+ const argv = check(repo, args); // a throw here rejects the promise
65
+ execFile('git', argv, { env: ENV, maxBuffer: 64 * 1024 * 1024, encoding: 'buffer' }, (err, stdout, stderr) => {
66
+ if (err)
67
+ reject(new GitError(`git ${args.join(' ')} failed: ${String(stderr).trim()}`));
68
+ else
69
+ resolve(stdout);
70
+ });
71
+ });
72
+ }
73
+ /** Pipe `input` to a read-only git command's stdin (used for `git patch-id`). */
74
+ export function gitWithInput(repo, args, input) {
75
+ return new Promise((resolve, reject) => {
76
+ const argv = check(repo, args); // a throw here rejects the promise
77
+ const child = spawn('git', argv, { env: ENV, stdio: ['pipe', 'pipe', 'pipe'] });
78
+ let out = '';
79
+ let err = '';
80
+ child.stdout.setEncoding('utf8').on('data', (d) => (out += d));
81
+ child.stderr.setEncoding('utf8').on('data', (d) => (err += d));
82
+ child.on('error', reject);
83
+ child.on('close', (code) => (code === 0 ? resolve(out) : reject(new GitError(`git ${args[0]} failed: ${err.trim()}`))));
84
+ child.stdin.on('error', () => { });
85
+ child.stdin.end(input);
86
+ });
87
+ }
88
+ /** A ref name we are willing to pass to git: no leading dash, no odd characters. */
89
+ export function safeRef(ref) {
90
+ if (!/^[A-Za-z0-9._\/@{}^~+-]+$/.test(ref) || ref.startsWith('-') || ref.includes('..')) {
91
+ throw new GitError(`unsafe ref: ${ref}`);
92
+ }
93
+ return ref;
94
+ }
95
+ export function isSha(s) {
96
+ return /^[0-9a-f]{40}$/.test(s);
97
+ }
98
+ /** Assert a full 40-hex sha (what we pass to git as a revision when it came from git itself). */
99
+ export function sha40(s) {
100
+ if (typeof s !== 'string' || !isSha(s))
101
+ throw new GitError(`not a full sha: ${String(s)}`);
102
+ return s;
103
+ }
@@ -0,0 +1,566 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { Hono } from 'hono';
5
+ import { serve } from '@hono/node-server';
6
+ import { serveStatic } from '@hono/node-server/serve-static';
7
+ import { streamSSE } from 'hono/streaming';
8
+ import { bodyLimit } from 'hono/body-limit';
9
+ import { PROJECTS_DIR, SAFE_NAME, WEB_DIST } from './config.js';
10
+ import { detailOf, filesOf } from './commits.js';
11
+ import { prStatus, progressOf, storedStatus } from '../shared/readiness.js';
12
+ import { ReviewConflictError, loadReview, mutateReview, randomId, reviewPath, unresolvedCount, viewReview } from './reviews.js';
13
+ import { branchName, githubOf, loadPr, loadPrs, prPath, prView, titleEdited, writePr } from './prs.js';
14
+ import { loadRefs, reconcileBranch } from './branches.js';
15
+ import { contextFor, inspectOrphan, liveCommits, prContextFor } from './context.js';
16
+ import { emit, startWatchers, subscribe } from './events.js';
17
+ import { isArchived, listProjectSlugs, loadProject, projectModified } from './projects.js';
18
+ import { localOnly } from './security.js';
19
+ import { EditNotFoundError, editProjectYaml, setProjectDescription, setStackDescription } from './projectEdit.js';
20
+ import { aggregate, mergeAggregates } from '../shared/totals.js';
21
+ import { REVIEWER } from './reviewer.js';
22
+ import { AssetError, addAsset, assetFile } from './assets.js';
23
+ import { ASSET_MIME, MAX_ASSET_BYTES, typeOfMime, typeOfName } from '../shared/assets.js';
24
+ import { renderStackSvg, scopeModel, stackWeights, svgToPng, vizModel } from './stackviz.js';
25
+ const app = new Hono();
26
+ class HttpError extends Error {
27
+ status;
28
+ constructor(status, message) {
29
+ super(message);
30
+ this.status = status;
31
+ }
32
+ }
33
+ app.use('*', localOnly);
34
+ app.onError((err, c) => {
35
+ const status = err instanceof HttpError || err instanceof AssetError ? err.status : err instanceof ReviewConflictError ? 409 : err instanceof EditNotFoundError ? 404 : 500;
36
+ if (status === 500)
37
+ console.error(err);
38
+ return c.json({ error: err.message }, status);
39
+ });
40
+ async function getProject(slug) {
41
+ // only names that are plain directory names under projects/ (no traversal), and that exist
42
+ const p = SAFE_NAME.test(slug) ? await loadProject(slug) : null;
43
+ if (!p)
44
+ throw new HttpError(404, `no project '${slug}' in ${PROJECTS_DIR}`);
45
+ return p;
46
+ }
47
+ function findRepo(p, name) {
48
+ const r = p.repos.find((x) => x.cfg.name === name);
49
+ if (!r)
50
+ throw new HttpError(404, `no repo '${name}' in project '${p.cfg.slug}'`);
51
+ return r;
52
+ }
53
+ async function resolveCommit(c) {
54
+ const project = await getProject(c.req.param('project'));
55
+ const repo = findRepo(project, c.req.param('repo'));
56
+ if (repo.info.error)
57
+ throw new HttpError(404, repo.info.error);
58
+ const shaParam = c.req.param('sha');
59
+ const p = shaParam.toLowerCase();
60
+ if (!/^[0-9a-f]{4,40}$/.test(p))
61
+ throw new HttpError(400, 'bad sha');
62
+ const matches = repo.info.commits.filter((x) => x.sha.startsWith(p));
63
+ if (matches.length === 0)
64
+ throw new HttpError(404, `commit ${shaParam} is not in ${repo.info.end} (it may have been rebased away)`);
65
+ if (matches.length > 1)
66
+ throw new HttpError(400, `ambiguous sha ${shaParam}`);
67
+ const commit = matches[0];
68
+ return { project, repo, commit, index: repo.info.commits.indexOf(commit) };
69
+ }
70
+ const reviewCtx = ({ repo, commit }) => contextFor(repo.reviewsDir, repo.cfg, repo.info, commit);
71
+ const prCtx = ({ repo, commit }) => prContextFor(repo.prsDir, repo.cfg, repo.info, commit);
72
+ async function reviewPayload(ctx) {
73
+ const l = await viewReview(ctx);
74
+ const target = reviewPath(ctx.dir, ctx.sha);
75
+ const prev = l.review.previous_commits;
76
+ return {
77
+ ...l.review,
78
+ file: l.file ?? target, // the file the comments are in right now
79
+ target_file: target, // where they'll be saved (differs only while carried over)
80
+ matched_from: l.matchedFrom ?? null,
81
+ matched_by: l.matchedBy ?? null,
82
+ // set when this review came from an earlier commit (pending or already saved under this sha)
83
+ carried_over: l.matchedFrom
84
+ ? { from: l.matchedFrom, by: l.matchedBy ?? null, saved: false }
85
+ : prev?.length
86
+ ? { from: prev[prev.length - 1], by: l.review.matched_by ?? null, saved: true }
87
+ : null,
88
+ parse_error: l.parseError ?? null,
89
+ };
90
+ }
91
+ /** The range's own tip branch name (e.g. me/my-feature): discovered on the top commit, but ranked last. */
92
+ const tipOf = (r) => r.cfg.branch ?? r.info.end ?? null;
93
+ /**
94
+ * `branch` (reconciled with git), `github` (as recorded) and `status` (draft / ready, or published once `github`
95
+ * exists) for one commit: the API contract for all three.
96
+ */
97
+ function branchFields(r, refs, sha, l) {
98
+ return { branch: reconcileBranch(refs, sha, l?.override, tipOf(r)), github: l?.override.github ?? null, status: prStatus(l?.override) };
99
+ }
100
+ async function prPayload(r, body) {
101
+ const ctx = prCtx(r);
102
+ const l = await loadPr(ctx);
103
+ return {
104
+ ...prView(l, { subject: r.commit.subject, body }),
105
+ ...branchFields(r.repo, await loadRefs(r.repo.cfg.path), r.commit.sha, l),
106
+ file: l.file ?? prPath(ctx.dir, ctx.sha),
107
+ target_file: prPath(ctx.dir, ctx.sha),
108
+ matched_from: l.matchedFrom ?? null,
109
+ matched_by: l.matchedBy ?? null,
110
+ parse_error: l.parseError ?? null,
111
+ };
112
+ }
113
+ // ---------------- projects ----------------
114
+ async function repoPayload(r, withWeights = false) {
115
+ const [prs, refs] = await Promise.all([
116
+ loadPrs(r.prsDir, liveCommits(r.cfg, r.info), inspectOrphan(r.cfg, r.info)).catch(() => new Map()),
117
+ loadRefs(r.info.path), // once per range, not per commit
118
+ ]);
119
+ const commits = await Promise.all(r.info.commits.map(async (cm, i) => {
120
+ const [l, files] = await Promise.all([
121
+ loadReview(contextFor(r.reviewsDir, r.cfg, r.info, cm)).catch(() => null),
122
+ filesOf(r.info.path, cm).catch(() => []), // cached per sha (a landed PR: its own diff)
123
+ ]);
124
+ const pr = prs.get(cm.sha);
125
+ const edited = titleEdited(pr?.override, cm.subject);
126
+ return {
127
+ ...cm,
128
+ position: i + 1,
129
+ stack: r.stackOf.get(cm.sha) ?? 0,
130
+ title: edited ? pr.override.title : cm.subject,
131
+ title_source: edited ? 'override' : 'commit',
132
+ ...branchFields(r, refs, cm.sha, pr),
133
+ unresolved: l ? unresolvedCount(l.review) : 0,
134
+ threads: l ? l.review.threads.length : 0,
135
+ viewedCount: l ? l.review.viewed.length : 0,
136
+ progress: progressOf(l?.review.threads ?? [], files.map((f) => f.path), l?.review.viewed ?? []),
137
+ };
138
+ }));
139
+ const { info } = r;
140
+ const weights = withWeights ? await stackWeights(r).catch(() => null) : null;
141
+ return {
142
+ name: r.cfg.name,
143
+ path: info.path,
144
+ branch: info.branch,
145
+ end: info.end,
146
+ base: info.base,
147
+ from: info.from,
148
+ head: info.head,
149
+ mergeBase: info.mergeBase,
150
+ error: info.error ?? null,
151
+ warnings: r.warnings,
152
+ stacks: weights ? r.stacks.map((g, i) => ({ ...g, weight: weights[i] })) : r.stacks,
153
+ reviewsDir: r.reviewsDir,
154
+ prsDir: r.prsDir,
155
+ commits,
156
+ };
157
+ }
158
+ app.get('/api/projects', async (c) => {
159
+ const projects = await Promise.all(listProjectSlugs().map(async (slug) => {
160
+ const p = await loadProject(slug);
161
+ if (!p)
162
+ return null;
163
+ const repos = await Promise.all(p.repos.map((r) => repoPayload(r)));
164
+ // follow-on stacks (parked work) and merged PRs are left out of the commit count and given beside it, as in every aggregate
165
+ const t = mergeAggregates(repos.map((r) => {
166
+ const merged = new Set(r.commits.filter((cm) => cm.landed).map((cm) => cm.sha));
167
+ return aggregate(r.stacks.map((g) => ({ follow_on: g.follow_on, prs: g.shas.length, additions: 0, deletions: 0, merged: { prs: g.shas.filter((s) => merged.has(s)).length, additions: 0, deletions: 0 } })));
168
+ }));
169
+ const archived = isArchived(p); // everything has landed
170
+ return {
171
+ slug,
172
+ title: p.cfg.title,
173
+ description: p.cfg.description,
174
+ error: p.cfg.error ?? null,
175
+ repoCount: repos.length,
176
+ repos: repos.map((r) => r.name),
177
+ commitCount: t.prs,
178
+ followOnCount: t.followOn.prs,
179
+ mergedCount: t.merged.prs,
180
+ archived,
181
+ unresolved: repos.reduce((a, r) => a + r.commits.reduce((b, cm) => b + cm.unresolved, 0), 0),
182
+ modified: new Date(projectModified(p.cfg.dir) || 0).toISOString(),
183
+ };
184
+ }));
185
+ return c.json({ projectsDir: PROJECTS_DIR, home: os.homedir(), projects: projects.filter(Boolean) });
186
+ });
187
+ app.get('/api/projects/:project', async (c) => {
188
+ const p = await getProject(c.req.param('project'));
189
+ return c.json({
190
+ slug: p.cfg.slug,
191
+ title: p.cfg.title,
192
+ description: p.cfg.description,
193
+ file: p.cfg.file,
194
+ error: p.cfg.error ?? null,
195
+ repos: await Promise.all(p.repos.map((r) => repoPayload(r, true))),
196
+ });
197
+ });
198
+ // ---------------- project intro / stack notes (written into project.yaml, keeping the rest of it) ----------------
199
+ function descriptionField(b) {
200
+ if (!b || typeof b !== 'object' || Array.isArray(b) || !('description' in b))
201
+ throw new HttpError(400, 'expected {description: string | null}');
202
+ const v = b.description;
203
+ if (v !== null && typeof v !== 'string')
204
+ throw new HttpError(400, 'description must be a string or null');
205
+ if (v && v.length > 256 * 1024)
206
+ throw new HttpError(400, 'description is too long');
207
+ return v;
208
+ }
209
+ app.put('/api/projects/:project/description', async (c) => {
210
+ const description = descriptionField(await body(c));
211
+ const p = await getProject(c.req.param('project'));
212
+ const cfg = await editProjectYaml(p.cfg.slug, (doc) => setProjectDescription(doc, description));
213
+ emit({ type: 'project', project: p.cfg.slug, reason: 'config' });
214
+ return c.json({ description: cfg.description, file: cfg.file });
215
+ });
216
+ app.put('/api/projects/:project/repos/:repo/stacks/:number/description', async (c) => {
217
+ const b = await body(c);
218
+ const description = descriptionField(b);
219
+ if (b.title !== undefined && typeof b.title !== 'string')
220
+ throw new HttpError(400, 'title must be a string');
221
+ const n = c.req.param('number');
222
+ if (!/^[1-9][0-9]{0,3}$/.test(n))
223
+ throw new HttpError(400, 'bad stack number');
224
+ const p = await getProject(c.req.param('project'));
225
+ const repo = findRepo(p, c.req.param('repo')).cfg.name;
226
+ const cfg = await editProjectYaml(p.cfg.slug, (doc, cur) => setStackDescription(doc, cur, repo, Number(n), description, b.title));
227
+ emit({ type: 'project', project: p.cfg.slug, reason: 'config' });
228
+ const spec = cfg.repos.find((r) => r.name === repo)?.stacks[Number(n) - 1];
229
+ return c.json({ description: spec?.description ?? null, title: spec?.title ?? null, file: cfg.file });
230
+ });
231
+ const COMMIT = '/api/projects/:project/repos/:repo/commits/:sha';
232
+ app.get(COMMIT, async (c) => {
233
+ const r = await resolveCommit(c);
234
+ const { project, repo, commit, index } = r;
235
+ const detail = await detailOf(repo.cfg.path, commit);
236
+ // prev / next walk the whole project in sidebar order: repo by repo, commits oldest first
237
+ const flat = project.repos.flatMap((x) => x.info.commits.map((cm) => ({ repo: x.cfg.name, sha: cm.sha })));
238
+ const at = flat.findIndex((x) => x.repo === repo.cfg.name && x.sha === commit.sha);
239
+ const group = repo.stacks[repo.stackOf.get(commit.sha) ?? 0];
240
+ const pr = await prPayload(r, detail.body);
241
+ return c.json({
242
+ project: project.cfg.slug,
243
+ projectTitle: project.cfg.title,
244
+ repo: repo.cfg.name,
245
+ repoPath: repo.cfg.path,
246
+ branch: repo.info.end,
247
+ index,
248
+ total: repo.info.commits.length,
249
+ stack: group ? { ...group, position: group.shas.indexOf(commit.sha) + 1, count: group.shas.length } : null,
250
+ prev: flat[at - 1] ?? null,
251
+ next: flat[at + 1] ?? null,
252
+ commit: { ...detail, branch: pr.branch, github: pr.github, status: pr.status, landed: commit.landed ?? null },
253
+ pr,
254
+ review: await reviewPayload(reviewCtx(r)),
255
+ });
256
+ });
257
+ app.get(`${COMMIT}/review`, async (c) => c.json(await reviewPayload(reviewCtx(await resolveCommit(c)))));
258
+ app.get(`${COMMIT}/pr`, async (c) => {
259
+ const r = await resolveCommit(c);
260
+ return c.json(await prPayload(r, (await detailOf(r.repo.cfg.path, r.commit)).body));
261
+ });
262
+ // ---------------- stack visualization (SVG / PNG) ----------------
263
+ /** For an SVG opened directly (a stack picture, an image asset): inline styles only, no script, nothing loaded. */
264
+ const VIZ_CSP = "default-src 'none'; style-src 'unsafe-inline'";
265
+ function vizOpts(c) {
266
+ const s = c.req.query('scope');
267
+ const scope = s === 'repo' || s === 'stack' ? s : 'project';
268
+ const scale = c.req.query('scale') === 'linear' ? 'linear' : 'sqrt';
269
+ const t = c.req.query('title');
270
+ const title = !(t === '0' || t === 'false' || t === 'no');
271
+ return { scope, scale, title };
272
+ }
273
+ // ---------------- images (assets/) ----------------
274
+ /** The project directory for a slug that names an existing project, without loading its repos (no git). */
275
+ function projectDirOf(slug) {
276
+ const dir = path.join(PROJECTS_DIR, slug);
277
+ if (!SAFE_NAME.test(slug) || !fs.existsSync(path.join(dir, 'project.yaml')))
278
+ throw new HttpError(404, `no project '${slug}' in ${PROJECTS_DIR}`);
279
+ return dir;
280
+ }
281
+ /** What every image response gets, SVG or not: never sniffed, never runs anything, not embeddable cross-site. */
282
+ const ASSET_HEADERS = {
283
+ 'X-Content-Type-Options': 'nosniff',
284
+ 'Content-Security-Policy': VIZ_CSP,
285
+ 'Cross-Origin-Resource-Policy': 'same-origin',
286
+ // content-addressed: a name always means the same bytes
287
+ 'Cache-Control': 'private, max-age=31536000, immutable',
288
+ };
289
+ app.get('/api/projects/:project/assets/:name', (c) => {
290
+ const dir = projectDirOf(c.req.param('project'));
291
+ const name = c.req.param('name');
292
+ const type = typeOfName(name);
293
+ const file = assetFile(dir, name);
294
+ if (!file || !type)
295
+ throw new HttpError(404, `no asset '${name}'`);
296
+ return c.body(new Uint8Array(fs.readFileSync(file)), 200, { ...ASSET_HEADERS, 'Content-Type': ASSET_MIME[type] });
297
+ });
298
+ // the body is the image itself (security.ts only lets image Content-Types through here); bodyLimit refuses an
299
+ // over-size upload from its Content-Length, or as soon as a streamed body passes the limit, before buffering it
300
+ app.post('/api/projects/:project/assets', bodyLimit({ maxSize: MAX_ASSET_BYTES, onError: (c) => c.json({ error: `the image is too large; the limit is ${MAX_ASSET_BYTES / 1024 / 1024} MB` }, 413) }), async (c) => {
301
+ const dir = projectDirOf(c.req.param('project'));
302
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
303
+ const alt = c.req.query('alt');
304
+ const a = addAsset(dir, bytes, typeOfMime(c.req.header('content-type') ?? ''), alt);
305
+ return c.json({ name: a.name, markdown: a.markdown, created: a.created }, a.created ? 201 : 200);
306
+ });
307
+ function sendViz(c, svg, kind, name) {
308
+ const headers = { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
309
+ if (c.req.query('download') !== undefined)
310
+ headers['Content-Disposition'] = `attachment; filename="${name.replace(/[^A-Za-z0-9._-]/g, '-')}.${kind}"`;
311
+ if (kind === 'svg') {
312
+ // the SVG is all escaped text and shapes; still, never let it run anything if opened directly
313
+ headers['Content-Security-Policy'] = VIZ_CSP;
314
+ return c.body(svg, 200, { ...headers, 'Content-Type': 'image/svg+xml; charset=utf-8' });
315
+ }
316
+ return c.body(new Uint8Array(svgToPng(svg)), 200, { ...headers, 'Content-Type': 'image/png' });
317
+ }
318
+ for (const kind of ['svg', 'png']) {
319
+ app.get(`${COMMIT}/stack.${kind}`, async (c) => {
320
+ const { project, repo, commit, index } = await resolveCommit(c);
321
+ const { scope, scale, title } = vizOpts(c);
322
+ const model = scopeModel(await vizModel(project, { repo: repo.cfg.name, sha: commit.sha }), scope);
323
+ return sendViz(c, renderStackSvg(model, { scale, title }), kind, `${project.cfg.slug}-${repo.cfg.name}-pr${index + 1}-stack`);
324
+ });
325
+ // the whole project, nothing highlighted (the project homepage)
326
+ app.get(`/api/projects/:project/stack.${kind}`, async (c) => {
327
+ const project = await getProject(c.req.param('project'));
328
+ return sendViz(c, renderStackSvg(await vizModel(project, null), vizOpts(c)), kind, `${project.cfg.slug}-stacks`);
329
+ });
330
+ }
331
+ // ---------------- comments ----------------
332
+ async function body(c) {
333
+ try {
334
+ return await c.req.json();
335
+ }
336
+ catch {
337
+ throw new HttpError(400, 'expected a JSON body');
338
+ }
339
+ }
340
+ function text(v, field) {
341
+ if (typeof v !== 'string' || !v.trim())
342
+ throw new HttpError(400, `${field} is required`);
343
+ return v.replace(/\r\n/g, '\n').trimEnd();
344
+ }
345
+ function author(v) {
346
+ if (v === undefined)
347
+ return REVIEWER.id;
348
+ if (typeof v !== 'string' || !/^[a-z0-9_-]{1,32}$/i.test(v))
349
+ throw new HttpError(400, 'bad author');
350
+ return v.toLowerCase();
351
+ }
352
+ const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
353
+ async function mutate(c, fn) {
354
+ const r = await resolveCommit(c);
355
+ const ctx = reviewCtx(r);
356
+ await mutateReview(ctx, fn);
357
+ emit({ type: 'review', project: r.project.cfg.slug, repo: r.repo.cfg.name, sha: r.commit.sha });
358
+ return c.json(await reviewPayload(ctx));
359
+ }
360
+ function findThread(r, id) {
361
+ const t = r.threads.find((x) => x.id === id);
362
+ if (!t)
363
+ throw new HttpError(404, `no thread ${id}`);
364
+ return t;
365
+ }
366
+ app.post(`${COMMIT}/threads`, async (c) => {
367
+ const b = await body(c);
368
+ const commentBody = text(b.body, 'body');
369
+ const who = author(b.author);
370
+ return mutate(c, (r) => {
371
+ const t = { id: randomId('t'), resolved: false, comments: [] };
372
+ if (b.path !== undefined && b.path !== null) {
373
+ if (typeof b.path !== 'string' || !b.path)
374
+ throw new HttpError(400, 'bad path');
375
+ t.path = b.path;
376
+ if (b.line !== undefined && b.line !== null) {
377
+ const line = Number(b.line);
378
+ if (!Number.isInteger(line) || line < 1)
379
+ throw new HttpError(400, 'bad line');
380
+ t.side = b.side === 'left' ? 'left' : 'right';
381
+ t.line = line;
382
+ const sl = Number(b.start_line);
383
+ if (b.start_side != null && b.start_side !== 'left' && b.start_side !== 'right')
384
+ throw new HttpError(400, 'bad start_side');
385
+ const startSide = b.start_side ?? t.side;
386
+ if (b.start_line != null && Number.isInteger(sl) && sl >= 1) {
387
+ if (startSide !== t.side) {
388
+ t.start_line = sl; // a range from an old-file line to a new-file line (or the reverse)
389
+ t.start_side = startSide;
390
+ }
391
+ else if (sl < line)
392
+ t.start_line = sl;
393
+ }
394
+ }
395
+ }
396
+ t.comments.push({ id: randomId('c'), author: who, created: now(), body: commentBody });
397
+ r.threads.push(t);
398
+ });
399
+ });
400
+ app.post(`${COMMIT}/threads/:tid/comments`, async (c) => {
401
+ const b = await body(c);
402
+ const commentBody = text(b.body, 'body');
403
+ const who = author(b.author);
404
+ return mutate(c, (r) => {
405
+ const t = findThread(r, c.req.param('tid'));
406
+ t.comments.push({ id: randomId('c'), author: who, created: now(), body: commentBody });
407
+ });
408
+ });
409
+ app.patch(`${COMMIT}/threads/:tid`, async (c) => {
410
+ const b = await body(c);
411
+ return mutate(c, (r) => {
412
+ const t = findThread(r, c.req.param('tid'));
413
+ if (typeof b.resolved === 'boolean')
414
+ t.resolved = b.resolved;
415
+ });
416
+ });
417
+ app.patch(`${COMMIT}/threads/:tid/comments/:cid`, async (c) => {
418
+ const b = await body(c);
419
+ const commentBody = text(b.body, 'body');
420
+ return mutate(c, (r) => {
421
+ const t = findThread(r, c.req.param('tid'));
422
+ const cm = t.comments.find((x) => x.id === c.req.param('cid'));
423
+ if (!cm)
424
+ throw new HttpError(404, 'no such comment');
425
+ if (cm.author !== REVIEWER.id)
426
+ throw new HttpError(403, `only ${REVIEWER.id}'s own comments can be edited here (this one is by ${cm.author})`);
427
+ cm.body = commentBody;
428
+ cm.edited = now();
429
+ });
430
+ });
431
+ app.delete(`${COMMIT}/threads/:tid/comments/:cid`, async (c) => mutate(c, (r) => {
432
+ const t = findThread(r, c.req.param('tid'));
433
+ const i = t.comments.findIndex((x) => x.id === c.req.param('cid'));
434
+ if (i < 0)
435
+ throw new HttpError(404, 'no such comment');
436
+ // never delete an agent's (or anyone else's) words from the UI: only the reviewer's own comments
437
+ if (t.comments[i].author !== REVIEWER.id)
438
+ throw new HttpError(403, `only ${REVIEWER.id}'s own comments can be deleted here (this one is by ${t.comments[i].author})`);
439
+ t.comments.splice(i, 1);
440
+ if (t.comments.length === 0)
441
+ r.threads = r.threads.filter((x) => x !== t);
442
+ }));
443
+ app.put(`${COMMIT}/viewed`, async (c) => {
444
+ const b = await body(c);
445
+ if (typeof b.path !== 'string' || !b.path)
446
+ throw new HttpError(400, 'path is required');
447
+ return mutate(c, (r) => {
448
+ const set = new Set(r.viewed);
449
+ if (b.viewed === false)
450
+ set.delete(b.path);
451
+ else
452
+ set.add(b.path);
453
+ r.viewed = [...set];
454
+ });
455
+ });
456
+ // ---------------- PR title / description overrides ----------------
457
+ function prField(v, field, max) {
458
+ if (v === undefined || v === null)
459
+ return v;
460
+ if (typeof v !== 'string')
461
+ throw new HttpError(400, `${field} must be a string or null`);
462
+ if (v.length > max)
463
+ throw new HttpError(400, `${field} is too long`);
464
+ return v;
465
+ }
466
+ // A branch name an agent may record: what `git check-ref-format --branch` would accept, give or take (we never
467
+ // pass it to git; it's only compared with for-each-ref's output).
468
+ function branchField(v, field) {
469
+ if (v === undefined || v === null)
470
+ return v;
471
+ if (typeof v !== 'string')
472
+ throw new HttpError(400, `${field} must be a string or null`);
473
+ const n = branchName(v);
474
+ if (!n)
475
+ return null; // blank clears
476
+ if (n.length > 250 || !/^[A-Za-z0-9._\/+@-]+$/.test(n) || n.startsWith('-') || n.startsWith('/') || n.endsWith('/') || n.endsWith('.') || n.endsWith('.lock') || n.includes('..') || n.includes('//') || n.includes('@{') || n === '@')
477
+ throw new HttpError(400, `${field}: not a valid branch name: ${JSON.stringify(v)}`);
478
+ return n;
479
+ }
480
+ function githubField(v) {
481
+ if (v === undefined || v === null)
482
+ return v;
483
+ const gh = githubOf(v);
484
+ if (!gh)
485
+ throw new HttpError(400, 'github must be null or {number, url, branch?, base?} (number: a positive integer, or derivable from a .../pull/<n> url)');
486
+ if (gh.url && (!/^https?:\/\//.test(gh.url) || gh.url.length > 2000))
487
+ throw new HttpError(400, 'github.url must be an http(s) URL');
488
+ const o = v;
489
+ if (typeof o === 'object') {
490
+ gh.branch = branchField(o.branch, 'github.branch') ?? undefined;
491
+ gh.base = branchField(o.base, 'github.base') ?? undefined;
492
+ gh.stacked_on = branchField(o.stacked_on, 'github.stacked_on') ?? undefined;
493
+ }
494
+ return gh;
495
+ }
496
+ app.put(`${COMMIT}/pr`, async (c) => {
497
+ const b = await body(c);
498
+ if (!b || typeof b !== 'object' || Array.isArray(b))
499
+ throw new HttpError(400, 'expected {title?, body?, branch?, github?, status?}');
500
+ const title = prField(b.title, 'title', 1000);
501
+ const prBody = prField(b.body, 'body', 1024 * 1024);
502
+ const branch = branchField(b.branch, 'branch');
503
+ const github = githubField(b.github);
504
+ const status = b.status === undefined || b.status === null ? b.status : storedStatus(b.status);
505
+ if (status === undefined && b.status !== undefined)
506
+ throw new HttpError(400, "status must be 'draft', 'ready' or null");
507
+ const r = await resolveCommit(c);
508
+ await writePr(prCtx(r), { title, body: prBody, branch, github, status });
509
+ emit({ type: 'pr', project: r.project.cfg.slug, repo: r.repo.cfg.name, sha: r.commit.sha });
510
+ return c.json(await prPayload(r, (await detailOf(r.repo.cfg.path, r.commit)).body));
511
+ });
512
+ // ---------------- events ----------------
513
+ app.get('/api/events', (c) => streamSSE(c, async (stream) => {
514
+ let id = 0;
515
+ const queue = [];
516
+ let wake = null;
517
+ const unsub = subscribe((e) => {
518
+ queue.push(JSON.stringify(e));
519
+ wake?.();
520
+ });
521
+ stream.onAbort(() => {
522
+ unsub();
523
+ wake?.();
524
+ });
525
+ await stream.writeSSE({ event: 'hello', data: JSON.stringify({ t: Date.now() }) });
526
+ while (!stream.aborted) {
527
+ while (queue.length)
528
+ await stream.writeSSE({ event: 'change', data: queue.shift(), id: String(++id) });
529
+ await new Promise((res) => {
530
+ wake = res;
531
+ setTimeout(res, 25000); // heartbeat
532
+ });
533
+ wake = null;
534
+ if (!queue.length && !stream.aborted)
535
+ await stream.write(': ping\n\n');
536
+ }
537
+ unsub();
538
+ }));
539
+ app.get('/api/health', (c) => c.json({ ok: true }));
540
+ app.get('/api/me', (c) => c.json(REVIEWER));
541
+ app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
542
+ // ---------------- frontend ----------------
543
+ if (!process.env.LOCAL_REVIEW_DEV) {
544
+ app.use('/assets/*', serveStatic({ root: path.relative(process.cwd(), WEB_DIST) || '.' }));
545
+ app.get('*', (c) => {
546
+ const index = path.join(WEB_DIST, 'index.html');
547
+ if (!fs.existsSync(index))
548
+ return c.text('Frontend not built. Run `npm run build` (or `npm start`).', 500);
549
+ return c.html(fs.readFileSync(index, 'utf8'));
550
+ });
551
+ }
552
+ /**
553
+ * Serve the UI and API on 127.0.0.1:`port` (only ever loopback: see security.ts) and watch the data for changes.
554
+ * Resolves with the bound port once listening; rejects if it can't bind (e.g. the port is taken).
555
+ */
556
+ export function startServer(port) {
557
+ return new Promise((resolve, reject) => {
558
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, (i) => {
559
+ server.off('error', reject);
560
+ startWatchers();
561
+ console.log(`Local Review on http://localhost:${i.port} (projects: ${PROJECTS_DIR})`);
562
+ resolve(i.port);
563
+ });
564
+ server.once('error', reject);
565
+ });
566
+ }