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.
- package/AGENT-GUIDE.md +468 -0
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +126 -2
- package/dist/server/anchors.js +207 -0
- package/dist/server/assets.js +147 -0
- package/dist/server/backups.js +212 -0
- package/dist/server/branches.js +113 -0
- package/dist/server/cli.js +255 -0
- package/dist/server/commits.js +393 -0
- package/dist/server/config.js +59 -0
- package/dist/server/context.js +68 -0
- package/dist/server/events.js +87 -0
- package/dist/server/git.js +103 -0
- package/dist/server/index.js +566 -0
- package/dist/server/landed.js +71 -0
- package/dist/server/plan.js +260 -0
- package/dist/server/port.js +3 -0
- package/dist/server/projectEdit.js +117 -0
- package/dist/server/projects.js +319 -0
- package/dist/server/prs.js +314 -0
- package/dist/server/reviewer.js +33 -0
- package/dist/server/reviews.js +586 -0
- package/dist/server/safefs.js +92 -0
- package/dist/server/security.js +38 -0
- package/dist/server/stackviz.js +446 -0
- package/dist/shared/assets.js +54 -0
- package/dist/shared/paths.js +13 -0
- package/dist/shared/prText.js +22 -0
- package/dist/shared/readiness.js +37 -0
- package/dist/shared/repoLabel.js +13 -0
- package/dist/shared/totals.js +75 -0
- package/dist/web/assets/abap-CLvhMVsD.js +1 -0
- package/dist/web/assets/actionscript-3-Cs6JJXT5.js +1 -0
- package/dist/web/assets/ada-X3Y2mlq2.js +1 -0
- package/dist/web/assets/ahk-DwADkxyd.js +1 -0
- package/dist/web/assets/ahk2-_6rzW9BS.js +1 -0
- package/dist/web/assets/andromeeda-vGVdxbeo.js +1 -0
- package/dist/web/assets/angular-html-DJ4eInGE.js +1 -0
- package/dist/web/assets/angular-ts-DBhgg2a7.js +1 -0
- package/dist/web/assets/apache-U0d_L8uA.js +1 -0
- package/dist/web/assets/apex-KFGFyKwG.js +1 -0
- package/dist/web/assets/apl-TCQAu201.js +1 -0
- package/dist/web/assets/apple-touch-icon-BNrSfFwW.png +0 -0
- package/dist/web/assets/applescript-CCn79oCD.js +1 -0
- package/dist/web/assets/ara-4CJ0cIlV.js +1 -0
- package/dist/web/assets/asciidoc-SCjQUq34.js +1 -0
- package/dist/web/assets/asm-Cmm7eHzH.js +1 -0
- package/dist/web/assets/astro-DVtFuwC2.js +1 -0
- package/dist/web/assets/aurora-x-CDeNXAV0.js +1 -0
- package/dist/web/assets/awk-BWXHIvNe.js +1 -0
- package/dist/web/assets/ayu-dark-DluEY0Gj.js +1 -0
- package/dist/web/assets/ayu-light-C3h-C4tm.js +1 -0
- package/dist/web/assets/ayu-mirage-Bqwy1Gya.js +1 -0
- package/dist/web/assets/ballerina-B7ZEbQpA.js +1 -0
- package/dist/web/assets/bat-qdxAdBWQ.js +1 -0
- package/dist/web/assets/beancount-C2IN4Vk8.js +1 -0
- package/dist/web/assets/berry-DKpUyyne.js +1 -0
- package/dist/web/assets/bibtex-Ci_nEsc7.js +1 -0
- package/dist/web/assets/bicep-CUHmPFLl.js +1 -0
- package/dist/web/assets/bird2-CYIUgYqB.js +1 -0
- package/dist/web/assets/blade-CMp-sZYg.js +1 -0
- package/dist/web/assets/bsl-_VjlB9mv.js +1 -0
- package/dist/web/assets/c-DgqspIM2.js +1 -0
- package/dist/web/assets/c3-D0apG41V.js +1 -0
- package/dist/web/assets/cadence-CQ2zXKGN.js +1 -0
- package/dist/web/assets/cairo-DLTphjLi.js +1 -0
- package/dist/web/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
- package/dist/web/assets/catppuccin-latte-CtsyrtPq.js +1 -0
- package/dist/web/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
- package/dist/web/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
- package/dist/web/assets/chapel-NTLt7i8E.js +1 -0
- package/dist/web/assets/clarity-CtfBBspg.js +1 -0
- package/dist/web/assets/clojure-DqKBuwfJ.js +1 -0
- package/dist/web/assets/cmake-Bj61d0ZC.js +1 -0
- package/dist/web/assets/cobol-CJiWQrT4.js +1 -0
- package/dist/web/assets/codeowners-C8r90Shi.js +1 -0
- package/dist/web/assets/codeql-oeQT6MSM.js +1 -0
- package/dist/web/assets/coffee-Bv_hE6wf.js +1 -0
- package/dist/web/assets/common-lisp-Cv5bFMCO.js +1 -0
- package/dist/web/assets/coq-CGaSfhwR.js +1 -0
- package/dist/web/assets/cpp-CuUsyAvc.js +1 -0
- package/dist/web/assets/crystal-BeLopiD_.js +1 -0
- package/dist/web/assets/csharp-dmMxLVLW.js +1 -0
- package/dist/web/assets/css-OngwBV3K.js +1 -0
- package/dist/web/assets/csv-Dx-8-gkx.js +1 -0
- package/dist/web/assets/cue-CE9AQfxI.js +1 -0
- package/dist/web/assets/cypher-ClKdZ_lG.js +1 -0
- package/dist/web/assets/d-qD-0Kul2.js +1 -0
- package/dist/web/assets/dark-plus-Cs2F2srj.js +1 -0
- package/dist/web/assets/dart-CHZY9Xhz.js +1 -0
- package/dist/web/assets/dax-BkyTk9wS.js +1 -0
- package/dist/web/assets/desktop-Dlh5hvp9.js +1 -0
- package/dist/web/assets/diff-woXpYk--.js +1 -0
- package/dist/web/assets/docker-IyjqRm3v.js +1 -0
- package/dist/web/assets/dotenv-CuH5my32.js +1 -0
- package/dist/web/assets/dracula-BHWKrbxM.js +1 -0
- package/dist/web/assets/dracula-soft-5eyTD99u.js +1 -0
- package/dist/web/assets/dream-maker-DW3nJb8Q.js +1 -0
- package/dist/web/assets/edge-BFLildoE.js +1 -0
- package/dist/web/assets/elixir-1Fs6FjVs.js +1 -0
- package/dist/web/assets/elm-piMM67vF.js +1 -0
- package/dist/web/assets/emacs-lisp-DR2vtWaj.js +1 -0
- package/dist/web/assets/erb-JJ0sF8GL.js +1 -0
- package/dist/web/assets/erlang-Cphh6RMH.js +1 -0
- package/dist/web/assets/everforest-dark-sB-x3p7T.js +1 -0
- package/dist/web/assets/everforest-light-Df2xbC6M.js +1 -0
- package/dist/web/assets/favicon-Bld6z4jR.svg +13 -0
- package/dist/web/assets/favicon-CDTyigy2.ico +0 -0
- package/dist/web/assets/fennel-DQxkIbk2.js +1 -0
- package/dist/web/assets/fish-BJitypiv.js +1 -0
- package/dist/web/assets/fluent-C03EYrpw.js +1 -0
- package/dist/web/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
- package/dist/web/assets/fortran-free-form-CYNrtFtB.js +1 -0
- package/dist/web/assets/fsharp-D13ZGOAj.js +1 -0
- package/dist/web/assets/gdresource-xMC7jr7x.js +1 -0
- package/dist/web/assets/gdscript-DYvRD3hg.js +1 -0
- package/dist/web/assets/gdshader-CBce3t8t.js +1 -0
- package/dist/web/assets/genie-CV2tkWYe.js +1 -0
- package/dist/web/assets/gherkin-DExj1W_8.js +1 -0
- package/dist/web/assets/git-commit-BSykSTBG.js +1 -0
- package/dist/web/assets/git-rebase-CtqXywWe.js +1 -0
- package/dist/web/assets/github-dark-C-LZuMrd.js +1 -0
- package/dist/web/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/dist/web/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
- package/dist/web/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
- package/dist/web/assets/github-light-EUqPIrTm.js +1 -0
- package/dist/web/assets/github-light-default-BXViO-2h.js +1 -0
- package/dist/web/assets/github-light-high-contrast-B68TUdTA.js +1 -0
- package/dist/web/assets/gleam-CSRkHgEL.js +1 -0
- package/dist/web/assets/glimmer-js-BPewWxGC.js +1 -0
- package/dist/web/assets/glimmer-ts-DyUQWB47.js +1 -0
- package/dist/web/assets/glsl-D6tJoMaT.js +1 -0
- package/dist/web/assets/gn-CAXYAgV-.js +1 -0
- package/dist/web/assets/gnuplot-7GGW24-e.js +1 -0
- package/dist/web/assets/go-rLFTqkRN.js +1 -0
- package/dist/web/assets/graphql-E6u2hkWE.js +1 -0
- package/dist/web/assets/groovy-CacY0gHj.js +1 -0
- package/dist/web/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
- package/dist/web/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
- package/dist/web/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
- package/dist/web/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
- package/dist/web/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
- package/dist/web/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
- package/dist/web/assets/hack-Djq191-P.js +1 -0
- package/dist/web/assets/haml-C0LqmZG3.js +1 -0
- package/dist/web/assets/handlebars-DwdxF6uN.js +1 -0
- package/dist/web/assets/haskell-D8IpX4py.js +1 -0
- package/dist/web/assets/haxe-C8oCnwJJ.js +1 -0
- package/dist/web/assets/hcl-Dh228itO.js +1 -0
- package/dist/web/assets/hjson-CxZEssPk.js +1 -0
- package/dist/web/assets/hlsl-Cvrh5tZx.js +1 -0
- package/dist/web/assets/horizon-CE9ld1lL.js +1 -0
- package/dist/web/assets/horizon-bright-DSNQnXHK.js +1 -0
- package/dist/web/assets/houston-CsvMBhTu.js +1 -0
- package/dist/web/assets/html-B5Myt0fy.js +1 -0
- package/dist/web/assets/html-derivative-DTVrLAw3.js +1 -0
- package/dist/web/assets/http-CFoh3cQf.js +1 -0
- package/dist/web/assets/hurl-B_38naAo.js +1 -0
- package/dist/web/assets/hxml-Dcm88srW.js +1 -0
- package/dist/web/assets/hy-CZbG8q4J.js +1 -0
- package/dist/web/assets/imba-DsUTQ-LC.js +1 -0
- package/dist/web/assets/index-CfxIojO0.js +451 -0
- package/dist/web/assets/index-QywYYqTw.css +1 -0
- package/dist/web/assets/ini-B5eOa1yu.js +1 -0
- package/dist/web/assets/java-odnexk6r.js +1 -0
- package/dist/web/assets/javascript-DvFCJKws.js +1 -0
- package/dist/web/assets/jinja-UeLt2DYu.js +1 -0
- package/dist/web/assets/jison-HNaDYeqE.js +1 -0
- package/dist/web/assets/json-D57CnrjR.js +1 -0
- package/dist/web/assets/json5-BR5RXkoi.js +1 -0
- package/dist/web/assets/jsonc-CYpm1nAK.js +1 -0
- package/dist/web/assets/jsonl-CmCQp5Yx.js +1 -0
- package/dist/web/assets/jsonnet-CJTPZ8u_.js +1 -0
- package/dist/web/assets/jssm-DXw9l8Rf.js +1 -0
- package/dist/web/assets/jsx-o_5ZAydi.js +1 -0
- package/dist/web/assets/julia-20UebbrD.js +1 -0
- package/dist/web/assets/just-DqnPCg-Q.js +1 -0
- package/dist/web/assets/kanagawa-dragon-Bozd7klA.js +1 -0
- package/dist/web/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
- package/dist/web/assets/kanagawa-wave-BML5mF2J.js +1 -0
- package/dist/web/assets/kdl-CsD5j6eV.js +1 -0
- package/dist/web/assets/kotlin-DhhofPvG.js +1 -0
- package/dist/web/assets/kusto-C7mF5XQf.js +1 -0
- package/dist/web/assets/laserwave-C_8bwKvT.js +1 -0
- package/dist/web/assets/latex-CZhIB-LV.js +1 -0
- package/dist/web/assets/lean-CewbzKMR.js +1 -0
- package/dist/web/assets/less-DVTAwKKz.js +1 -0
- package/dist/web/assets/light-plus-DVQuIRkW.js +1 -0
- package/dist/web/assets/liquid-BzouPsN0.js +1 -0
- package/dist/web/assets/llvm-DqHOMS-s.js +1 -0
- package/dist/web/assets/log-BNLmms1o.js +1 -0
- package/dist/web/assets/logo-Cluzi2Zq.js +1 -0
- package/dist/web/assets/lua-BXclv8Fm.js +1 -0
- package/dist/web/assets/luau-VGWVLoqX.js +1 -0
- package/dist/web/assets/make-Dixweg8N.js +1 -0
- package/dist/web/assets/markdown-BYOwaDjH.js +1 -0
- package/dist/web/assets/marko-M78_INlG.js +1 -0
- package/dist/web/assets/material-theme-Bm3Qr25_.js +1 -0
- package/dist/web/assets/material-theme-darker-2IIEA8gg.js +1 -0
- package/dist/web/assets/material-theme-lighter-uhdI0v04.js +1 -0
- package/dist/web/assets/material-theme-ocean-CHQ94UKr.js +1 -0
- package/dist/web/assets/material-theme-palenight-B5W6OYN7.js +1 -0
- package/dist/web/assets/matlab-D7qyCx1q.js +1 -0
- package/dist/web/assets/mdc-CBluOiY7.js +1 -0
- package/dist/web/assets/mdx-DQZ5AkYe.js +1 -0
- package/dist/web/assets/mermaid-BxpfTIzt.js +1 -0
- package/dist/web/assets/min-dark-BSWPekZh.js +1 -0
- package/dist/web/assets/min-light-DDpmG2fV.js +1 -0
- package/dist/web/assets/mipsasm-BMqwQI7S.js +1 -0
- package/dist/web/assets/mojo-fg40_ntQ.js +1 -0
- package/dist/web/assets/monokai-BscAjiQZ.js +1 -0
- package/dist/web/assets/moonbit-D9W784E_.js +1 -0
- package/dist/web/assets/move-BYnGhZoM.js +1 -0
- package/dist/web/assets/narrat-_X_XdTYD.js +1 -0
- package/dist/web/assets/nextflow-CSgcYZcx.js +1 -0
- package/dist/web/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
- package/dist/web/assets/nginx-CZFFQFbT.js +1 -0
- package/dist/web/assets/night-owl-DhmEMT88.js +1 -0
- package/dist/web/assets/night-owl-light-eJ-hLW7d.js +1 -0
- package/dist/web/assets/nim-17kKsGqk.js +1 -0
- package/dist/web/assets/nix-IvuFDN5E.js +1 -0
- package/dist/web/assets/nord-Cb4Vim4T.js +1 -0
- package/dist/web/assets/nsis-CjNLYCLg.js +1 -0
- package/dist/web/assets/nushell--0fT3PI1.js +1 -0
- package/dist/web/assets/objective-c-D1A_Heim.js +1 -0
- package/dist/web/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/dist/web/assets/ocaml-O90oeIOV.js +1 -0
- package/dist/web/assets/odin-B1RWQWA5.js +1 -0
- package/dist/web/assets/one-dark-pro-C6YyR8OD.js +1 -0
- package/dist/web/assets/one-light-D7Lr4KcI.js +1 -0
- package/dist/web/assets/openscad-BUDT5pXO.js +1 -0
- package/dist/web/assets/org-BZABHRQH.js +1 -0
- package/dist/web/assets/pascal-4ZHwLPI5.js +1 -0
- package/dist/web/assets/perl-CMrAQ9Je.js +1 -0
- package/dist/web/assets/php-DwA3kETk.js +1 -0
- package/dist/web/assets/pierre-dark-CpLgRqie.js +1 -0
- package/dist/web/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
- package/dist/web/assets/pierre-dark-soft-kZQmAZld.js +1 -0
- package/dist/web/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
- package/dist/web/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
- package/dist/web/assets/pierre-light-CoaEpmwp.js +1 -0
- package/dist/web/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
- package/dist/web/assets/pierre-light-soft-lWLdNTOI.js +1 -0
- package/dist/web/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
- package/dist/web/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
- package/dist/web/assets/pkl-ot-7Btpt.js +1 -0
- package/dist/web/assets/plastic-DQwYfKfQ.js +1 -0
- package/dist/web/assets/plsql-DGHpHOYJ.js +1 -0
- package/dist/web/assets/po-BiJDBrnU.js +1 -0
- package/dist/web/assets/poimandres-DRFjx7u4.js +1 -0
- package/dist/web/assets/polar-C7UOKdEL.js +1 -0
- package/dist/web/assets/postcss-BXeXVLqQ.js +1 -0
- package/dist/web/assets/powerquery-DNMTfnFr.js +1 -0
- package/dist/web/assets/powershell-QtSnmXcu.js +1 -0
- package/dist/web/assets/prisma-DMgNhnYS.js +1 -0
- package/dist/web/assets/prolog-iXnhIJG7.js +1 -0
- package/dist/web/assets/proto-DB4EqR-F.js +1 -0
- package/dist/web/assets/pug-D4fM85kA.js +1 -0
- package/dist/web/assets/puppet-CDv2pdJW.js +1 -0
- package/dist/web/assets/purescript-9MfHhQsQ.js +1 -0
- package/dist/web/assets/python-gzcpVVnB.js +1 -0
- package/dist/web/assets/qml-C3893RHi.js +1 -0
- package/dist/web/assets/qmldir-DCQb3MpD.js +1 -0
- package/dist/web/assets/qss-Fe1Jh2GI.js +1 -0
- package/dist/web/assets/r-DwJhLW77.js +1 -0
- package/dist/web/assets/racket-DcIDlBhZ.js +1 -0
- package/dist/web/assets/raku-B3gFvitq.js +1 -0
- package/dist/web/assets/razor-_KkCNZ8V.js +1 -0
- package/dist/web/assets/rbs-C0MpOrj4.js +1 -0
- package/dist/web/assets/red-CYlAq2b0.js +1 -0
- package/dist/web/assets/reg-CRGYupPL.js +1 -0
- package/dist/web/assets/regexp-BZ9NHg3b.js +1 -0
- package/dist/web/assets/rel-BtDbiS_P.js +1 -0
- package/dist/web/assets/riscv-Ckw8ddFX.js +1 -0
- package/dist/web/assets/ron-VUp2lXgN.js +1 -0
- package/dist/web/assets/rose-pine-aMsaMD1D.js +1 -0
- package/dist/web/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
- package/dist/web/assets/rose-pine-moon-DO-qXg-I.js +1 -0
- package/dist/web/assets/rosmsg-CAekHB0j.js +1 -0
- package/dist/web/assets/rst-gdK-1c4t.js +1 -0
- package/dist/web/assets/ruby-DsmqYtlH.js +1 -0
- package/dist/web/assets/rust-DpiIf5JO.js +1 -0
- package/dist/web/assets/sas-BNGv1ScF.js +1 -0
- package/dist/web/assets/sass-DXrisJhu.js +1 -0
- package/dist/web/assets/scala-BiFt_7mu.js +1 -0
- package/dist/web/assets/scheme-DQCgrYNe.js +1 -0
- package/dist/web/assets/scss-DjYUXT73.js +1 -0
- package/dist/web/assets/sdbl-bTVj8UrX.js +1 -0
- package/dist/web/assets/shaderlab-TOUzSsQk.js +1 -0
- package/dist/web/assets/shellscript-DBtV-eli.js +1 -0
- package/dist/web/assets/shellsession-BKEV8Fnx.js +1 -0
- package/dist/web/assets/slack-dark-DnToyrRv.js +1 -0
- package/dist/web/assets/slack-ochin-B2OO5cIa.js +1 -0
- package/dist/web/assets/smalltalk-BHpQb5nM.js +1 -0
- package/dist/web/assets/smithy-Dgaz7aur.js +1 -0
- package/dist/web/assets/snazzy-light-4G7pJPwS.js +1 -0
- package/dist/web/assets/solarized-dark-b97czJOk.js +1 -0
- package/dist/web/assets/solarized-light-DFPxpIYQ.js +1 -0
- package/dist/web/assets/solidity-Bj7E1mRn.js +1 -0
- package/dist/web/assets/soy-DUapMxos.js +1 -0
- package/dist/web/assets/sparql-D_iOobhT.js +1 -0
- package/dist/web/assets/splunk-BC2Px7Mm.js +1 -0
- package/dist/web/assets/sql-B-UyqKkd.js +1 -0
- package/dist/web/assets/ssh-config-BgfXC-Er.js +1 -0
- package/dist/web/assets/stata-Xq3iZiMK.js +1 -0
- package/dist/web/assets/stylus-B6D30XZt.js +1 -0
- package/dist/web/assets/surrealql-CzXacK8i.js +1 -0
- package/dist/web/assets/svelte-CZaBEmPP.js +1 -0
- package/dist/web/assets/swift-CyEgAFGc.js +1 -0
- package/dist/web/assets/synthwave-84-nFMaYfgc.js +1 -0
- package/dist/web/assets/system-verilog-Dh5ihWbf.js +1 -0
- package/dist/web/assets/systemd-BxMlprV5.js +1 -0
- package/dist/web/assets/talonscript-CohzipZa.js +1 -0
- package/dist/web/assets/tasl-DMoTqEGO.js +1 -0
- package/dist/web/assets/tcl-CZd0xW_V.js +1 -0
- package/dist/web/assets/templ-CxkGuUpr.js +1 -0
- package/dist/web/assets/terraform-DswuEJGm.js +1 -0
- package/dist/web/assets/tex-DP74JH6g.js +1 -0
- package/dist/web/assets/tokyo-night-oM2G3aXe.js +1 -0
- package/dist/web/assets/toml-CcmNWLt0.js +1 -0
- package/dist/web/assets/ts-tags-BAcrCE2Y.js +1 -0
- package/dist/web/assets/tsv-sltzmVWM.js +1 -0
- package/dist/web/assets/tsx-Dw11YP6z.js +1 -0
- package/dist/web/assets/turtle-ByJddavk.js +1 -0
- package/dist/web/assets/twig-BMVjBxD6.js +1 -0
- package/dist/web/assets/typescript-DoBLlVdC.js +1 -0
- package/dist/web/assets/typespec-BGhR2SqG.js +1 -0
- package/dist/web/assets/typst-w2u_vl4_.js +1 -0
- package/dist/web/assets/v-DBj4K9d_.js +1 -0
- package/dist/web/assets/vala-zf12oZj6.js +1 -0
- package/dist/web/assets/vb-DOwzaHZ0.js +1 -0
- package/dist/web/assets/verilog-B2gwz-sJ.js +1 -0
- package/dist/web/assets/vesper-DdrHHSXu.js +1 -0
- package/dist/web/assets/vhdl-BroJfC0k.js +1 -0
- package/dist/web/assets/viml-DvXPmvsu.js +1 -0
- package/dist/web/assets/vitesse-black-fwtXNY1n.js +1 -0
- package/dist/web/assets/vitesse-dark-BZCL-v6S.js +1 -0
- package/dist/web/assets/vitesse-light-VbXTXTou.js +1 -0
- package/dist/web/assets/vue-BmuHTv5p.js +1 -0
- package/dist/web/assets/vue-html-Dt0zSF4I.js +1 -0
- package/dist/web/assets/vue-vine-wvJ1BTex.js +1 -0
- package/dist/web/assets/vyper-CgoNMtux.js +1 -0
- package/dist/web/assets/wasm-BnjxR4X6.js +1 -0
- package/dist/web/assets/wasm-ByWQv1Qj.js +1 -0
- package/dist/web/assets/wenyan-C8pVoKbM.js +1 -0
- package/dist/web/assets/wgsl-BsKzXJz4.js +1 -0
- package/dist/web/assets/wikitext-ClFFjSW2.js +1 -0
- package/dist/web/assets/wit-DdvCle-K.js +1 -0
- package/dist/web/assets/wolfram-DLL8P-h_.js +1 -0
- package/dist/web/assets/xml-CXftIBCQ.js +1 -0
- package/dist/web/assets/xsl-BoHSOMz1.js +1 -0
- package/dist/web/assets/yaml-dGreldtD.js +1 -0
- package/dist/web/assets/zenscript-BnlCZFoB.js +1 -0
- package/dist/web/assets/zig-CMLA9XwU.js +1 -0
- package/dist/web/index.html +16 -0
- package/examples/project.example.yaml +40 -0
- package/package.json +89 -3
- package/server/fonts/Inter-OFL.txt +93 -0
- package/server/fonts/Inter_400Regular.ttf +0 -0
- package/server/fonts/Inter_600SemiBold.ttf +0 -0
- package/server/fonts/Inter_700Bold.ttf +0 -0
- package/server/fonts/JetBrainsMono-OFL.txt +93 -0
- package/server/fonts/JetBrainsMono_400Regular.ttf +0 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
// Review comment files: projects/<project>/reviews/<repo>/<full-sha>.yaml
|
|
2
|
+
// Reads are tolerant of hand edits (by the reviewer or by an agent); writes are atomic, re-read the file first and
|
|
3
|
+
// apply the change as an operation to what's on disk, retrying if the file changed underneath us.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import YAML from 'yaml';
|
|
8
|
+
import { atomicWrite, renameToBak } from './safefs.js';
|
|
9
|
+
import { isMixed, makeAnchor, placeThread } from './anchors.js';
|
|
10
|
+
// objects whose ids were invented on read (no id in the file); replaced with real ids on write
|
|
11
|
+
const autoIds = new WeakSet();
|
|
12
|
+
export const randomId = (prefix) => `${prefix}-${crypto.randomBytes(4).toString('hex')}`;
|
|
13
|
+
const SHA_FILE = /^([0-9a-f]{40})\.yaml$/;
|
|
14
|
+
/** `<dir>/<40-hex>.yaml`; `dir` is always built by the server from validated names, never taken from a URL. */
|
|
15
|
+
export function reviewPath(dir, sha) {
|
|
16
|
+
if (!/^[0-9a-f]{40}$/.test(sha))
|
|
17
|
+
throw new Error('bad sha');
|
|
18
|
+
if (!path.isAbsolute(dir))
|
|
19
|
+
throw new Error('review dir must be absolute');
|
|
20
|
+
return path.join(dir, `${sha}.yaml`);
|
|
21
|
+
}
|
|
22
|
+
export function str(v) {
|
|
23
|
+
if (v === null || v === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (typeof v === 'string')
|
|
26
|
+
return v;
|
|
27
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
28
|
+
return String(v);
|
|
29
|
+
if (v instanceof Date)
|
|
30
|
+
return v.toISOString();
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function num(v) {
|
|
34
|
+
const n = typeof v === 'string' ? Number(v) : v;
|
|
35
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
36
|
+
}
|
|
37
|
+
const normSide = (s) => {
|
|
38
|
+
const v = str(s)?.toLowerCase();
|
|
39
|
+
return v === 'left' || v === 'old' || v === 'deletions' ? 'left' : 'right';
|
|
40
|
+
};
|
|
41
|
+
/** Commit subject as used for re-matching: no fixup!/squash!/amend! prefixes, whitespace collapsed. */
|
|
42
|
+
export function normalizeSubject(s) {
|
|
43
|
+
let v = (s ?? '').replace(/\s+/g, ' ').trim();
|
|
44
|
+
for (;;) {
|
|
45
|
+
const next = v.replace(/^(fixup|squash|amend)!\s*/i, '');
|
|
46
|
+
if (next === v)
|
|
47
|
+
return v;
|
|
48
|
+
v = next;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Normalise one comment; deterministic fallback ids so the UI can address them before a write. */
|
|
52
|
+
function normComment(c, threadId, i) {
|
|
53
|
+
if (typeof c === 'string')
|
|
54
|
+
c = { body: c };
|
|
55
|
+
if (!c || typeof c !== 'object')
|
|
56
|
+
c = { body: String(c ?? '') };
|
|
57
|
+
const author = (str(c.author) ?? 'unknown').trim().toLowerCase() || 'unknown';
|
|
58
|
+
const out = {
|
|
59
|
+
...c,
|
|
60
|
+
id: str(c.id) ?? `${threadId}-c${i}`,
|
|
61
|
+
author,
|
|
62
|
+
created: str(c.created) ?? null,
|
|
63
|
+
body: str(c.body) ?? str(c.text) ?? str(c.comment) ?? '',
|
|
64
|
+
};
|
|
65
|
+
if (!str(c.id))
|
|
66
|
+
autoIds.add(out);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function normAnchor(a) {
|
|
70
|
+
if (!a || typeof a !== 'object')
|
|
71
|
+
return undefined;
|
|
72
|
+
const commit = str(a.commit);
|
|
73
|
+
const p = str(a.path);
|
|
74
|
+
const line = num(a.line);
|
|
75
|
+
const text = str(a.text);
|
|
76
|
+
if (!commit || !/^[0-9a-f]{40}$/.test(commit) || !p || !line || text === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
const out = { ...a, commit, path: p, side: normSide(a.side), line, text };
|
|
79
|
+
const sl = num(a.start_line);
|
|
80
|
+
const ss = a.start_side == null ? undefined : normSide(a.start_side);
|
|
81
|
+
delete out.start_side;
|
|
82
|
+
if (sl && ss && ss !== out.side) {
|
|
83
|
+
out.start_line = sl;
|
|
84
|
+
out.start_side = ss;
|
|
85
|
+
}
|
|
86
|
+
else if (sl && sl < line)
|
|
87
|
+
out.start_line = sl;
|
|
88
|
+
else
|
|
89
|
+
delete out.start_line;
|
|
90
|
+
for (const k of ['before', 'after', 'old_path', 'start_text']) {
|
|
91
|
+
const v = str(a[k]);
|
|
92
|
+
if (v === undefined)
|
|
93
|
+
delete out[k];
|
|
94
|
+
else
|
|
95
|
+
out[k] = v;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function normThread(t, i) {
|
|
100
|
+
// a bare string (or number) in `threads` is a general comment; never drop it
|
|
101
|
+
if (t !== null && t !== undefined && typeof t !== 'object')
|
|
102
|
+
t = { comments: [{ body: String(t) }] };
|
|
103
|
+
if (!t || typeof t !== 'object')
|
|
104
|
+
t = {};
|
|
105
|
+
const id = str(t.id) ?? `t-auto${i}`;
|
|
106
|
+
// `comments:` written as a single mapping or a single string instead of a list
|
|
107
|
+
let comments = Array.isArray(t.comments) ? t.comments : t.comments && typeof t.comments === 'object' ? [t.comments] : typeof t.comments === 'string' ? [{ body: t.comments }] : [];
|
|
108
|
+
// tolerate a thread written with a single top-level body
|
|
109
|
+
if (comments.length === 0 && (t.body || t.comment))
|
|
110
|
+
comments = [{ author: t.author, created: t.created, body: t.body ?? t.comment }];
|
|
111
|
+
const out = {
|
|
112
|
+
...t,
|
|
113
|
+
id,
|
|
114
|
+
resolved: t.resolved === true || t.resolved === 'true',
|
|
115
|
+
comments: comments.map((c, j) => normComment(c, id, j)),
|
|
116
|
+
};
|
|
117
|
+
if (out.path !== undefined)
|
|
118
|
+
out.path = str(t.path);
|
|
119
|
+
if (!out.path)
|
|
120
|
+
delete out.path;
|
|
121
|
+
const line = num(t.line);
|
|
122
|
+
if (out.path && line) {
|
|
123
|
+
out.line = line;
|
|
124
|
+
out.side = normSide(t.side);
|
|
125
|
+
const sl = num(t.start_line);
|
|
126
|
+
const ss = t.start_side == null ? undefined : normSide(t.start_side);
|
|
127
|
+
delete out.start_side;
|
|
128
|
+
if (sl && ss && ss !== out.side) {
|
|
129
|
+
// a range from an old-file line to a new-file line (or the reverse): no ordering between the numbers
|
|
130
|
+
out.start_line = sl;
|
|
131
|
+
out.start_side = ss;
|
|
132
|
+
}
|
|
133
|
+
else if (sl && sl < line)
|
|
134
|
+
out.start_line = sl;
|
|
135
|
+
else
|
|
136
|
+
delete out.start_line;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
delete out.line;
|
|
140
|
+
delete out.start_line;
|
|
141
|
+
delete out.start_side;
|
|
142
|
+
if (!out.path)
|
|
143
|
+
delete out.side;
|
|
144
|
+
else if (t.side)
|
|
145
|
+
out.side = normSide(t.side);
|
|
146
|
+
}
|
|
147
|
+
const anchor = out.path && out.line ? normAnchor(t.anchor) : undefined;
|
|
148
|
+
if (anchor)
|
|
149
|
+
out.anchor = anchor;
|
|
150
|
+
else
|
|
151
|
+
delete out.anchor;
|
|
152
|
+
if (t.outdated === true || t.outdated === 'true')
|
|
153
|
+
out.outdated = true;
|
|
154
|
+
else
|
|
155
|
+
delete out.outdated;
|
|
156
|
+
delete out.body;
|
|
157
|
+
if (!str(t.id))
|
|
158
|
+
autoIds.add(out);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
export function normalize(doc, ctx) {
|
|
162
|
+
if (!doc || typeof doc !== 'object')
|
|
163
|
+
doc = {};
|
|
164
|
+
const threads = Array.isArray(doc.threads) ? doc.threads : [];
|
|
165
|
+
const viewed = Array.isArray(doc.viewed) ? doc.viewed.map(str).filter(Boolean) : [];
|
|
166
|
+
const r = {
|
|
167
|
+
...doc,
|
|
168
|
+
repo: str(doc.repo) ?? ctx.repo,
|
|
169
|
+
repo_path: str(doc.repo_path) ?? ctx.repoPath,
|
|
170
|
+
branch: str(doc.branch) ?? ctx.branch,
|
|
171
|
+
commit: str(doc.commit) ?? ctx.sha,
|
|
172
|
+
patch_id: str(doc.patch_id) ?? null,
|
|
173
|
+
subject: str(doc.subject) ?? ctx.subject,
|
|
174
|
+
viewed: [...new Set(viewed)],
|
|
175
|
+
threads: threads.map(normThread),
|
|
176
|
+
};
|
|
177
|
+
if (Array.isArray(doc.previous_commits))
|
|
178
|
+
r.previous_commits = doc.previous_commits.map(str).filter(Boolean);
|
|
179
|
+
else
|
|
180
|
+
delete r.previous_commits;
|
|
181
|
+
return r;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Shapes we can't normalise without dropping content (so a later write would lose it). These are treated
|
|
185
|
+
* like invalid YAML: shown as an error, and the file is never overwritten until it's fixed.
|
|
186
|
+
*/
|
|
187
|
+
function structureError(doc) {
|
|
188
|
+
if (doc === null || doc === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
if (typeof doc !== 'object' || Array.isArray(doc))
|
|
191
|
+
return 'the top level must be a mapping (repo:, commit:, threads: …)';
|
|
192
|
+
if (doc.threads != null && !Array.isArray(doc.threads))
|
|
193
|
+
return '`threads` must be a list';
|
|
194
|
+
if (doc.viewed != null && !Array.isArray(doc.viewed))
|
|
195
|
+
return '`viewed` must be a list';
|
|
196
|
+
for (const [i, t] of (doc.threads ?? []).entries()) {
|
|
197
|
+
if (t && typeof t === 'object' && !Array.isArray(t) && t.comments != null && !Array.isArray(t.comments) && typeof t.comments !== 'object' && typeof t.comments !== 'string')
|
|
198
|
+
return `threads[${i}].comments must be a list`;
|
|
199
|
+
if (Array.isArray(t))
|
|
200
|
+
return `threads[${i}] must be a mapping`;
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
export function readYaml(file) {
|
|
205
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
206
|
+
let doc;
|
|
207
|
+
try {
|
|
208
|
+
doc = YAML.parse(raw);
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
return { doc: null, raw, error: e.message };
|
|
212
|
+
}
|
|
213
|
+
const bad = structureError(doc);
|
|
214
|
+
if (bad)
|
|
215
|
+
return { doc: null, raw, error: bad };
|
|
216
|
+
return { doc: doc ?? {}, raw };
|
|
217
|
+
}
|
|
218
|
+
export function readRaw(file) {
|
|
219
|
+
if (!file)
|
|
220
|
+
return null;
|
|
221
|
+
try {
|
|
222
|
+
return fs.readFileSync(file, 'utf8');
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function emptyReview(ctx) {
|
|
229
|
+
return { repo: ctx.repo, repo_path: ctx.repoPath, branch: ctx.branch, commit: ctx.sha, patch_id: null, subject: ctx.subject, viewed: [], threads: [] };
|
|
230
|
+
}
|
|
231
|
+
const orphanCache = new Map();
|
|
232
|
+
function readOrphan(file, sha) {
|
|
233
|
+
let st;
|
|
234
|
+
try {
|
|
235
|
+
st = fs.statSync(file);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const key = `${st.mtimeMs}:${st.size}`;
|
|
241
|
+
const hit = orphanCache.get(file);
|
|
242
|
+
if (hit && hit.key === key)
|
|
243
|
+
return hit.o;
|
|
244
|
+
let o = null;
|
|
245
|
+
try {
|
|
246
|
+
const { doc } = readYaml(file);
|
|
247
|
+
if (doc && typeof doc === 'object')
|
|
248
|
+
o = { sha, file, mtime: st.mtimeMs, patchId: str(doc.patch_id), changeId: str(doc.change_id), subject: normalizeSubject(str(doc.subject)), repoPath: str(doc.repo_path), ...(doc.landed != null ? { landed: true } : {}) };
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
o = null;
|
|
252
|
+
}
|
|
253
|
+
orphanCache.set(file, { key, o });
|
|
254
|
+
return o;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Deterministic range-wide assignment of orphaned files (review or PR-override files whose sha is no longer in the range)
|
|
258
|
+
* to live commits that have no file of their own. Levels are tried in order, each over the whole range
|
|
259
|
+
* oldest-first before the next: patch-id, Change-Id trailer, normalised subject (only for subjects that are
|
|
260
|
+
* unique in the range). Each orphan goes to at most one commit; several candidates => newest file wins
|
|
261
|
+
* (mtime), then lowest sha.
|
|
262
|
+
*
|
|
263
|
+
* With `inspect` (git, on the reviewed repo): a file whose commit is still an ancestor of the range end only
|
|
264
|
+
* left the range (base or `from` moved, e.g. it landed upstream); it was not rewritten, so it is never
|
|
265
|
+
* carried to another commit. A file without `patch_id` / `subject` (hand-written) gets them from git, so it
|
|
266
|
+
* still follows a rebase. A review file whose `repo_path` is another repo and whose commit this repo doesn't
|
|
267
|
+
* have (two repos that shared a name) is skipped.
|
|
268
|
+
*/
|
|
269
|
+
export async function matchOrphans(dir, live, opts = {}) {
|
|
270
|
+
const out = new Map();
|
|
271
|
+
if (!fs.existsSync(dir))
|
|
272
|
+
return out;
|
|
273
|
+
const liveSet = new Set(live.map((c) => c.sha));
|
|
274
|
+
const found = [];
|
|
275
|
+
for (const f of fs.readdirSync(dir).sort()) {
|
|
276
|
+
const m = SHA_FILE.exec(f);
|
|
277
|
+
if (!m || liveSet.has(m[1]))
|
|
278
|
+
continue;
|
|
279
|
+
const o = readOrphan(path.join(dir, f), m[1]);
|
|
280
|
+
if (o && !o.landed)
|
|
281
|
+
found.push(o);
|
|
282
|
+
}
|
|
283
|
+
if (!found.length)
|
|
284
|
+
return out;
|
|
285
|
+
const fileless = live.filter((c) => !c.frozen && !fs.existsSync(path.join(dir, `${c.sha}.yaml`)));
|
|
286
|
+
if (!fileless.length)
|
|
287
|
+
return out;
|
|
288
|
+
const orphans = [];
|
|
289
|
+
for (const o of found) {
|
|
290
|
+
const facts = opts.inspect ? await opts.inspect(o.sha).catch(() => null) : null;
|
|
291
|
+
if (!facts) {
|
|
292
|
+
orphans.push(o);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (facts.reachable)
|
|
296
|
+
continue;
|
|
297
|
+
if (!facts.exists && o.repoPath && opts.repoPath && path.resolve(o.repoPath) !== path.resolve(opts.repoPath))
|
|
298
|
+
continue;
|
|
299
|
+
orphans.push({
|
|
300
|
+
...o, // never mutate the cached orphan
|
|
301
|
+
patchId: o.patchId ?? (facts.getPatchId ? (await facts.getPatchId().catch(() => null)) ?? undefined : undefined),
|
|
302
|
+
changeId: o.changeId ?? facts.changeId ?? undefined,
|
|
303
|
+
subject: o.subject || normalizeSubject(facts.subject),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (!orphans.length)
|
|
307
|
+
return out;
|
|
308
|
+
const subjectCount = new Map();
|
|
309
|
+
for (const c of live.filter((x) => !x.frozen)) {
|
|
310
|
+
const s = normalizeSubject(c.subject);
|
|
311
|
+
subjectCount.set(s, (subjectCount.get(s) ?? 0) + 1);
|
|
312
|
+
}
|
|
313
|
+
const taken = new Set();
|
|
314
|
+
const levels = [
|
|
315
|
+
{ by: 'patch-id', live: (c) => (orphans.some((o) => o.patchId) ? c.getPatchId() : Promise.resolve(null)), orphan: (o) => o.patchId },
|
|
316
|
+
{ by: 'change-id', live: async (c) => c.changeId, orphan: (o) => o.changeId },
|
|
317
|
+
{
|
|
318
|
+
by: 'subject',
|
|
319
|
+
live: async (c) => {
|
|
320
|
+
const s = normalizeSubject(c.subject);
|
|
321
|
+
return s && subjectCount.get(s) === 1 ? s : null;
|
|
322
|
+
},
|
|
323
|
+
orphan: (o) => o.subject || undefined,
|
|
324
|
+
},
|
|
325
|
+
];
|
|
326
|
+
for (const level of levels) {
|
|
327
|
+
for (const c of fileless) {
|
|
328
|
+
if (out.has(c.sha))
|
|
329
|
+
continue;
|
|
330
|
+
const k = await level.live(c);
|
|
331
|
+
if (!k)
|
|
332
|
+
continue;
|
|
333
|
+
const cands = orphans.filter((o) => !taken.has(o.sha) && level.orphan(o) === k);
|
|
334
|
+
if (!cands.length)
|
|
335
|
+
continue;
|
|
336
|
+
cands.sort((a, b) => b.mtime - a.mtime || (a.sha < b.sha ? -1 : 1));
|
|
337
|
+
taken.add(cands[0].sha);
|
|
338
|
+
out.set(c.sha, { orphan: cands[0], by: level.by });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
export async function loadReview(ctx) {
|
|
344
|
+
const file = reviewPath(ctx.dir, ctx.sha);
|
|
345
|
+
if (fs.existsSync(file)) {
|
|
346
|
+
const { doc, raw, error } = readYaml(file);
|
|
347
|
+
if (error)
|
|
348
|
+
return { review: emptyReview(ctx), file, raw, parseError: error };
|
|
349
|
+
return { review: normalize(doc, ctx), file, raw };
|
|
350
|
+
}
|
|
351
|
+
// No file under this sha: try to re-match a file from before a rebase.
|
|
352
|
+
const live = ctx.liveCommits ?? [{ sha: ctx.sha, subject: ctx.subject, changeId: ctx.changeId, getPatchId: ctx.getPatchId }];
|
|
353
|
+
const m = (await matchOrphans(ctx.dir, live, { inspect: ctx.inspectOrphan, repoPath: ctx.repoPath })).get(ctx.sha);
|
|
354
|
+
if (m) {
|
|
355
|
+
const { doc, raw, error } = readYaml(m.orphan.file);
|
|
356
|
+
if (!error)
|
|
357
|
+
return { review: normalize(doc, ctx), file: m.orphan.file, raw, matchedFrom: m.orphan.sha, matchedBy: m.by };
|
|
358
|
+
}
|
|
359
|
+
return { review: emptyReview(ctx), file: null, raw: null };
|
|
360
|
+
}
|
|
361
|
+
// ---------------- placing threads on the current commit ----------------
|
|
362
|
+
/**
|
|
363
|
+
* Give every inline thread an anchor (if it has none, or if it is anchored on this commit but its
|
|
364
|
+
* location was edited by hand), then place it on ctx.sha: update path/side/line/start_line to where its
|
|
365
|
+
* anchor text is now, or set `outdated: true` (keeping the last known location) if it can't be found.
|
|
366
|
+
* `fileSha` is the commit the file's locations refer to (the old sha for a carried-over file);
|
|
367
|
+
* `fresh` are threads created by this operation (so their locations refer to ctx.sha).
|
|
368
|
+
*/
|
|
369
|
+
export async function placeThreads(r, ctx, fileSha, fresh = new Set()) {
|
|
370
|
+
const inline = r.threads.filter((t) => t.path && t.line);
|
|
371
|
+
if (!inline.length || !ctx.getFiles)
|
|
372
|
+
return;
|
|
373
|
+
const filesCache = new Map();
|
|
374
|
+
const files = (sha) => {
|
|
375
|
+
if (!filesCache.has(sha))
|
|
376
|
+
filesCache.set(sha, ctx.getFiles(sha).catch(() => null));
|
|
377
|
+
return filesCache.get(sha);
|
|
378
|
+
};
|
|
379
|
+
const current = await files(ctx.sha);
|
|
380
|
+
if (!current)
|
|
381
|
+
return;
|
|
382
|
+
for (const t of inline) {
|
|
383
|
+
const a = t.anchor;
|
|
384
|
+
const moved = a && a.commit === ctx.sha &&
|
|
385
|
+
(a.path !== t.path || a.side !== t.side || a.line !== t.line || a.start_line !== t.start_line || (a.start_side ?? a.side) !== (t.start_side ?? t.side));
|
|
386
|
+
if (!a || moved) {
|
|
387
|
+
let src = fresh.has(t) || moved ? ctx.sha : fileSha;
|
|
388
|
+
let srcFiles = src === ctx.sha ? current : await files(src);
|
|
389
|
+
if (!srcFiles) {
|
|
390
|
+
src = ctx.sha; // the old commit is gone from the repo: best effort, anchor where it is now
|
|
391
|
+
srcFiles = current;
|
|
392
|
+
}
|
|
393
|
+
const made = makeAnchor(srcFiles, src, { path: t.path, side: t.side ?? 'right', line: t.line, start_line: t.start_line, start_side: t.start_side });
|
|
394
|
+
if (made)
|
|
395
|
+
t.anchor = made;
|
|
396
|
+
}
|
|
397
|
+
const p = placeThread(t, current, ctx.sha);
|
|
398
|
+
if (p.outdated) {
|
|
399
|
+
t.outdated = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
delete t.outdated;
|
|
403
|
+
t.path = p.path;
|
|
404
|
+
t.side = p.side;
|
|
405
|
+
t.line = p.line;
|
|
406
|
+
if (p.start_line)
|
|
407
|
+
t.start_line = p.start_line;
|
|
408
|
+
else
|
|
409
|
+
delete t.start_line;
|
|
410
|
+
if (p.start_line && p.start_side && isMixed(p))
|
|
411
|
+
t.start_side = p.start_side;
|
|
412
|
+
else
|
|
413
|
+
delete t.start_side;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** The review as the UI should show it on ctx.sha: loaded, carried over if needed, threads placed. */
|
|
417
|
+
export async function viewReview(ctx) {
|
|
418
|
+
const l = await loadReview(ctx);
|
|
419
|
+
if (!l.parseError)
|
|
420
|
+
await placeThreads(l.review, ctx, l.matchedFrom ?? ctx.sha);
|
|
421
|
+
return l;
|
|
422
|
+
}
|
|
423
|
+
// ---------------- writing ----------------
|
|
424
|
+
/** The review file can't be written right now (invalid YAML on disk, or it keeps changing). HTTP 409. */
|
|
425
|
+
export class ReviewConflictError extends Error {
|
|
426
|
+
}
|
|
427
|
+
// serialise writes per file
|
|
428
|
+
const locks = new Map();
|
|
429
|
+
export function withLock(key, fn) {
|
|
430
|
+
const prev = locks.get(key) ?? Promise.resolve();
|
|
431
|
+
const next = prev.then(fn, fn);
|
|
432
|
+
locks.set(key, next.catch(() => { }));
|
|
433
|
+
return next;
|
|
434
|
+
}
|
|
435
|
+
export { atomicWrite };
|
|
436
|
+
const BLOCK_KEYS = new Set(['body', 'text', 'before', 'after']);
|
|
437
|
+
export function toYaml(r) {
|
|
438
|
+
// Fixed key order for readability; unknown keys are kept after the known ones.
|
|
439
|
+
const { repo, repo_path, branch, commit, patch_id, change_id, subject, previous_commits, matched_by, viewed, threads, ...rest } = r;
|
|
440
|
+
const ordered = { repo, repo_path, branch, commit, patch_id };
|
|
441
|
+
if (change_id)
|
|
442
|
+
ordered.change_id = change_id;
|
|
443
|
+
ordered.subject = subject;
|
|
444
|
+
if (previous_commits?.length)
|
|
445
|
+
ordered.previous_commits = previous_commits;
|
|
446
|
+
if (matched_by)
|
|
447
|
+
ordered.matched_by = matched_by;
|
|
448
|
+
Object.assign(ordered, rest);
|
|
449
|
+
ordered.viewed = viewed;
|
|
450
|
+
ordered.threads = threads.map((t) => {
|
|
451
|
+
const { id, path: p, side, line, start_line, start_side, resolved, outdated, anchor, comments, ...trest } = t;
|
|
452
|
+
const o = { id };
|
|
453
|
+
if (p)
|
|
454
|
+
o.path = p;
|
|
455
|
+
if (p && side)
|
|
456
|
+
o.side = side;
|
|
457
|
+
if (p && line)
|
|
458
|
+
o.line = line;
|
|
459
|
+
if (p && start_line)
|
|
460
|
+
o.start_line = start_line;
|
|
461
|
+
if (p && start_line && start_side && start_side !== (side ?? 'right'))
|
|
462
|
+
o.start_side = start_side;
|
|
463
|
+
o.resolved = resolved;
|
|
464
|
+
if (outdated)
|
|
465
|
+
o.outdated = true;
|
|
466
|
+
Object.assign(o, trest);
|
|
467
|
+
if (anchor) {
|
|
468
|
+
const { commit: ac, path: ap, old_path, side: as, line: al, start_line: asl, start_side: ass, start_text, text, before, after, ...arest } = anchor;
|
|
469
|
+
const ao = { commit: ac, path: ap };
|
|
470
|
+
if (old_path)
|
|
471
|
+
ao.old_path = old_path;
|
|
472
|
+
Object.assign(ao, { side: as, line: al });
|
|
473
|
+
if (asl)
|
|
474
|
+
ao.start_line = asl;
|
|
475
|
+
if (asl && ass && ass !== as) {
|
|
476
|
+
ao.start_side = ass;
|
|
477
|
+
if (start_text !== undefined)
|
|
478
|
+
ao.start_text = start_text;
|
|
479
|
+
}
|
|
480
|
+
if (before !== undefined)
|
|
481
|
+
ao.before = before;
|
|
482
|
+
ao.text = text;
|
|
483
|
+
if (after !== undefined)
|
|
484
|
+
ao.after = after;
|
|
485
|
+
Object.assign(ao, arest);
|
|
486
|
+
o.anchor = ao;
|
|
487
|
+
}
|
|
488
|
+
o.comments = comments.map((c) => {
|
|
489
|
+
const { id: cid, author, created, body, ...crest } = c;
|
|
490
|
+
const co = { id: cid, author };
|
|
491
|
+
if (created)
|
|
492
|
+
co.created = created;
|
|
493
|
+
Object.assign(co, crest);
|
|
494
|
+
co.body = body;
|
|
495
|
+
return co;
|
|
496
|
+
});
|
|
497
|
+
return o;
|
|
498
|
+
});
|
|
499
|
+
// comment bodies (and multi-line anchor text) are written as block literals, easy to read and append to
|
|
500
|
+
const doc = new YAML.Document(ordered);
|
|
501
|
+
YAML.visit(doc, {
|
|
502
|
+
Pair(_, pair) {
|
|
503
|
+
if (!YAML.isScalar(pair.key) || !YAML.isScalar(pair.value) || typeof pair.value.value !== 'string')
|
|
504
|
+
return;
|
|
505
|
+
const k = pair.key.value;
|
|
506
|
+
if (k === 'body' || (BLOCK_KEYS.has(k) && pair.value.value.includes('\n')))
|
|
507
|
+
pair.value.type = 'BLOCK_LITERAL';
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
return doc.toString({ lineWidth: 0, blockQuote: 'literal' });
|
|
511
|
+
}
|
|
512
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
513
|
+
const MAX_ATTEMPTS = 6;
|
|
514
|
+
/**
|
|
515
|
+
* Read-modify-write a review. `fn` is an operation applied to a fresh read of the file from disk (never to
|
|
516
|
+
* a cached copy). If the file changes on disk while we work (an agent saved it), the whole operation is
|
|
517
|
+
* re-applied to the new contents, so concurrent edits are merged rather than overwritten.
|
|
518
|
+
*/
|
|
519
|
+
export function mutateReview(ctx, fn) {
|
|
520
|
+
const target = reviewPath(ctx.dir, ctx.sha);
|
|
521
|
+
return withLock(target, async () => {
|
|
522
|
+
for (let attempt = 1;; attempt++) {
|
|
523
|
+
const loaded = await loadReview(ctx);
|
|
524
|
+
if (loaded.parseError) {
|
|
525
|
+
// maybe caught a non-atomic save half-way; give it a moment
|
|
526
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
527
|
+
await sleep(40 * attempt);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
throw new ReviewConflictError(`refusing to overwrite ${loaded.file}: it is not valid YAML (${loaded.parseError}). Fix the file by hand.`);
|
|
531
|
+
}
|
|
532
|
+
const r = loaded.review;
|
|
533
|
+
const before = new Set(r.threads);
|
|
534
|
+
fn(r);
|
|
535
|
+
const fresh = new Set(r.threads.filter((t) => !before.has(t)));
|
|
536
|
+
// give real ids to anything that came without one (or with a duplicate)
|
|
537
|
+
const seen = new Set();
|
|
538
|
+
for (const t of r.threads) {
|
|
539
|
+
if (autoIds.has(t) || seen.has(t.id))
|
|
540
|
+
t.id = randomId('t');
|
|
541
|
+
seen.add(t.id);
|
|
542
|
+
for (const c of t.comments) {
|
|
543
|
+
if (autoIds.has(c) || seen.has(c.id))
|
|
544
|
+
c.id = randomId('c');
|
|
545
|
+
seen.add(c.id);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (!r.patch_id || loaded.matchedFrom)
|
|
549
|
+
r.patch_id = await ctx.getPatchId();
|
|
550
|
+
if (ctx.changeId)
|
|
551
|
+
r.change_id = ctx.changeId;
|
|
552
|
+
await placeThreads(r, ctx, loaded.matchedFrom ?? ctx.sha, fresh);
|
|
553
|
+
r.repo = ctx.repo;
|
|
554
|
+
r.repo_path = ctx.repoPath;
|
|
555
|
+
r.branch = ctx.branch;
|
|
556
|
+
r.subject = ctx.subject;
|
|
557
|
+
if (loaded.matchedFrom && loaded.matchedFrom !== ctx.sha) {
|
|
558
|
+
r.previous_commits = [...new Set([...(r.previous_commits ?? []), loaded.matchedFrom])];
|
|
559
|
+
r.matched_by = loaded.matchedBy;
|
|
560
|
+
}
|
|
561
|
+
r.commit = ctx.sha;
|
|
562
|
+
const text = toYaml(r);
|
|
563
|
+
// optimistic concurrency: did anyone touch the source file (or create the target) since we read it?
|
|
564
|
+
const changed = readRaw(loaded.file) !== loaded.raw || (loaded.file !== target && fs.existsSync(target));
|
|
565
|
+
if (changed) {
|
|
566
|
+
if (attempt < MAX_ATTEMPTS)
|
|
567
|
+
continue;
|
|
568
|
+
throw new ReviewConflictError(`${loaded.file ?? target} keeps changing on disk; not saving over it. Try again.`);
|
|
569
|
+
}
|
|
570
|
+
atomicWrite(target, text);
|
|
571
|
+
if (loaded.matchedFrom && loaded.file && loaded.file !== target) {
|
|
572
|
+
// keep the pre-rebase file, but out of the way of future matching
|
|
573
|
+
try {
|
|
574
|
+
renameToBak(loaded.file, `superseded-by-${ctx.sha.slice(0, 8)}`); // never over an existing file
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
/* ignore */
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return r;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
export function unresolvedCount(r) {
|
|
585
|
+
return r.threads.filter((t) => !t.resolved && t.comments.length > 0).length;
|
|
586
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The only places the server writes, renames or removes files under the data home. Rules (test/fsguard.test.ts
|
|
2
|
+
// enforces that no other server file calls a deleting or overwriting fs function):
|
|
3
|
+
// - every write is atomic: a fresh temp file in the same directory, renamed over the target;
|
|
4
|
+
// - a rename never clobbers an existing file (renameNoClobber), except atomicWrite replacing the file it updates;
|
|
5
|
+
// - nothing is unlinked except our own temp file after a failed write, and the old name of a file that has
|
|
6
|
+
// just been hard-linked to its new name (so the data is still there, under the new name).
|
|
7
|
+
// Superseded files are renamed to `.bak`, never deleted. Automatic snapshots (backups.ts) cover the rest.
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
const TEMP_NAME = /^\..+\.\d+\.\d+\.[0-9a-f]{6}\.tmp$/;
|
|
12
|
+
/**
|
|
13
|
+
* Write `text` (or bytes) to `file` atomically: temp file in the same directory, then rename. With `createOnly`,
|
|
14
|
+
* never replace an existing `file` (fails with ExistsError instead), even one that appears while writing.
|
|
15
|
+
*/
|
|
16
|
+
export function atomicWrite(file, text, { createOnly = false } = {}) {
|
|
17
|
+
const dir = path.dirname(file);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.${crypto.randomBytes(3).toString('hex')}.tmp`);
|
|
20
|
+
try {
|
|
21
|
+
fs.writeFileSync(tmp, text, { encoding: 'utf8', flag: 'wx' }); // 'wx': never over an existing file (bytes ignore the encoding)
|
|
22
|
+
if (!createOnly && fs.existsSync(file))
|
|
23
|
+
fs.renameSync(tmp, file); // the update itself: replaces the previous version of `file`
|
|
24
|
+
else
|
|
25
|
+
renameNoClobber(tmp, file); // creating: fails rather than replace a file that appeared meanwhile
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
removeOwnTemp(tmp);
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Clean up a temp file written by atomicWrite whose rename failed. Refuses anything that isn't one. */
|
|
33
|
+
function removeOwnTemp(tmp) {
|
|
34
|
+
if (!TEMP_NAME.test(path.basename(tmp)))
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
fs.unlinkSync(tmp); // fs-guard: temp-file cleanup (only our own .tmp, never renamed into place)
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* already gone */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class ExistsError extends Error {
|
|
44
|
+
code = 'EEXIST';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rename `from` to `to`, failing with EEXIST if `to` exists (fs.rename would silently replace it). Done as a hard
|
|
48
|
+
* link (atomic, fails if `to` exists) and then dropping the old name, only once both names are the same file.
|
|
49
|
+
*/
|
|
50
|
+
export function renameNoClobber(from, to) {
|
|
51
|
+
try {
|
|
52
|
+
fs.linkSync(from, to);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
const code = e.code;
|
|
56
|
+
if (code === 'EEXIST')
|
|
57
|
+
throw new ExistsError(`${to} already exists; not replacing it`);
|
|
58
|
+
if (code !== 'EPERM' && code !== 'ENOTSUP' && code !== 'EOPNOTSUPP' && code !== 'ENOSYS')
|
|
59
|
+
throw e;
|
|
60
|
+
// a file system without hard links: check, then rename (we hold the per-file lock, so this is only racy
|
|
61
|
+
// against a hand edit creating exactly this name in the same instant)
|
|
62
|
+
if (fs.existsSync(to))
|
|
63
|
+
throw new ExistsError(`${to} already exists; not replacing it`);
|
|
64
|
+
fs.renameSync(from, to);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const a = fs.statSync(from);
|
|
68
|
+
const b = fs.statSync(to);
|
|
69
|
+
if (a.ino !== b.ino || a.dev !== b.dev)
|
|
70
|
+
return; // should not happen; keep both names rather than risk anything
|
|
71
|
+
fs.unlinkSync(from); // fs-guard: drop the old name of a file that is now also linked at `to`
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Move a superseded file out of the way of matching: `<name>.yaml` -> `<name>.<tag>.yaml.bak`, or
|
|
75
|
+
* `<name>.<tag>.2.yaml.bak`, … if that exists. Never replaces an existing file. Returns the new path.
|
|
76
|
+
*/
|
|
77
|
+
export function renameToBak(file, tag) {
|
|
78
|
+
for (let n = 1; n < 1000; n++) {
|
|
79
|
+
const bak = file.replace(/\.yaml$/, n === 1 ? `.${tag}.yaml.bak` : `.${tag}.${n}.yaml.bak`);
|
|
80
|
+
if (fs.existsSync(bak))
|
|
81
|
+
continue;
|
|
82
|
+
try {
|
|
83
|
+
renameNoClobber(file, bak);
|
|
84
|
+
return bak;
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (!(e instanceof ExistsError))
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`no free .bak name for ${file}`);
|
|
92
|
+
}
|