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
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/edspencer/local-review/main/brand/lockup-dark.svg">
|
|
4
|
+
<img alt="Local Review" src="https://raw.githubusercontent.com/edspencer/local-review/main/brand/lockup-light.svg" width="380">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<p align="center"><strong>Review your PR stacks before you create them.</strong></p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://github.com/edspencer/local-review/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/edspencer/local-review/actions/workflows/ci.yml/badge.svg?branch=main"></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/local-review"><img alt="npm" src="https://img.shields.io/npm/v/local-review?color=4a1942"></a>
|
|
13
|
+
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
14
|
+
<a href="https://local-review.edspencer.net"><img alt="Docs" src="https://img.shields.io/badge/docs-local--review.edspencer.net-4a1942"></a>
|
|
15
|
+
<img alt="Node.js 22 or later" src="https://img.shields.io/badge/node-%E2%89%A522-339933">
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<img alt="Local Review showing a PR: the plum "not on GitHub" header, a sidebar of colour-coded PR stacks with readiness badges, and the PR's title, readiness toggle and Conversation tab" src="https://raw.githubusercontent.com/edspencer/local-review/main/docs/media/readme-hero.png" width="800">
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
Local Review is a GitHub-style pull-request UI for the **stack of commits on your machine**, before any of it is
|
|
23
|
+
pushed. Every commit is shown as its own PR, grouped into the stacks you mean to open. You review them like PRs, your
|
|
24
|
+
coding agent reads your comments and replies in the same place, and when a stack is right, the agent publishes it as
|
|
25
|
+
GitHub stacked PRs.
|
|
26
|
+
|
|
27
|
+
- **Every commit is a PR**, with a Conversation tab, a Files changed tab and a readiness badge, grouped into
|
|
28
|
+
colour-coded [PR stacks](https://local-review.edspencer.net/concepts/pr-stacks/), across one repo or several.
|
|
29
|
+
- **Your agent answers in the review.** Comments are plain YAML files that any coding agent can read and reply to; the
|
|
30
|
+
replies show up in your browser live, and threads follow their code through amends and rebases.
|
|
31
|
+
- **Publish when it's ready.** `local-review plan` prints each PR's branch, base, title and body, and your agent opens
|
|
32
|
+
the stack as [GitHub native stacked PRs](https://local-review.edspencer.net/agents/publishing-stacked-prs/). It
|
|
33
|
+
works in front of any [stacking tool](https://local-review.edspencer.net/concepts/stacking-tools/).
|
|
34
|
+
- **Safe to point at your work.** It never modifies your repos, never talks to GitHub, never deletes your review, and
|
|
35
|
+
only listens on localhost.
|
|
36
|
+
|
|
37
|
+
<p align="center">
|
|
38
|
+
<img alt="A review round trip: the reviewer comments on a range of lines, the agent's reply appears in the thread live, the reviewer resolves the thread and marks the PR ready, and the sidebar's stack tally goes from 1/3 to 2/3 ready" src="https://raw.githubusercontent.com/edspencer/local-review/main/docs/media/demo.gif" width="800">
|
|
39
|
+
<br><sub>One review round trip, on the fictional demo project the docs use.</sub>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
Needs Node.js 22 or later and git, on macOS or Linux.
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npx local-review init my-feature # create ~/.local-review/projects/my-feature/project.yaml
|
|
48
|
+
npx local-review # serve the UI on http://localhost:5622
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Edit `project.yaml` to point at your repo, branch and stacks:
|
|
52
|
+
|
|
53
|
+
```yaml
|
|
54
|
+
title: My feature
|
|
55
|
+
repos:
|
|
56
|
+
- path: ~/code/my-service
|
|
57
|
+
branch: me/my-feature # the range ends at this branch's head
|
|
58
|
+
base: origin/main # and starts at its merge-base with this
|
|
59
|
+
stacks:
|
|
60
|
+
- title: Groundwork
|
|
61
|
+
- title: The feature itself
|
|
62
|
+
starts_at: "Add the feature flag" # the subject of the commit the stack starts at
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Then open **http://localhost:5622**. Edits are picked up live. The full walk-through is the
|
|
66
|
+
[Quickstart](https://local-review.edspencer.net/getting-started/quickstart/).
|
|
67
|
+
|
|
68
|
+
> **Upgrading from before 0.2.0?** The default port was 5000; it's now 5622 (macOS uses 5000 for AirPlay). Update
|
|
69
|
+
> bookmarks and agent prompts, or run `npx local-review --port 5000`.
|
|
70
|
+
|
|
71
|
+
## For agents
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npx local-review guide
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
prints the guide for coding agents driving Local Review from the repo they work on: the data model, how to answer
|
|
78
|
+
review threads, fix a commit mid-stack, tag branches, publish stacked PRs and record merges. It's
|
|
79
|
+
[AGENT-GUIDE.md](AGENT-GUIDE.md) in this repo, and [on the docs site](https://local-review.edspencer.net/agents/agent-guide/).
|
|
80
|
+
To hand a reviewed project to your agent, paste the
|
|
81
|
+
[hand-off prompt](https://local-review.edspencer.net/agents/hand-off-prompt/).
|
|
82
|
+
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
**https://local-review.edspencer.net**
|
|
86
|
+
|
|
87
|
+
- **Getting started:** [Quickstart](https://local-review.edspencer.net/getting-started/quickstart/) ·
|
|
88
|
+
[Installation](https://local-review.edspencer.net/getting-started/installation/)
|
|
89
|
+
- **Concepts:** [Projects & repos](https://local-review.edspencer.net/concepts/projects-and-repos/) ·
|
|
90
|
+
[PR stacks](https://local-review.edspencer.net/concepts/pr-stacks/) ·
|
|
91
|
+
[Readiness](https://local-review.edspencer.net/concepts/readiness/) ·
|
|
92
|
+
[Landed PRs](https://local-review.edspencer.net/concepts/landed-prs/) ·
|
|
93
|
+
[Following rebases](https://local-review.edspencer.net/concepts/following-rebases/)
|
|
94
|
+
- **Using it:** [A tour of the UI](https://local-review.edspencer.net/using/ui-tour/) ·
|
|
95
|
+
[Reviewing & comments](https://local-review.edspencer.net/using/reviewing-and-comments/) ·
|
|
96
|
+
[Stack pictures](https://local-review.edspencer.net/using/stack-pictures/)
|
|
97
|
+
- **Reference:** [CLI](https://local-review.edspencer.net/reference/cli/) ·
|
|
98
|
+
[project.yaml](https://local-review.edspencer.net/reference/project-yaml/) ·
|
|
99
|
+
[Comment files](https://local-review.edspencer.net/reference/comment-files/) ·
|
|
100
|
+
[HTTP API](https://local-review.edspencer.net/reference/http-api/) ·
|
|
101
|
+
[Where your data lives](https://local-review.edspencer.net/reference/data-directory/)
|
|
102
|
+
|
|
103
|
+
## Guarantees
|
|
104
|
+
|
|
105
|
+
- **Read-only:** it never fetches, checks out, commits or rebases; all git access goes through one read-only
|
|
106
|
+
allow-list. [Read-only guarantee](https://local-review.edspencer.net/guarantees/read-only/)
|
|
107
|
+
- **Never deletes:** your review lives in `~/.local-review`, never in your repos; writes are atomic, nothing is
|
|
108
|
+
unlinked, and it's snapshotted hourly. [Never-delete guarantee](https://local-review.edspencer.net/guarantees/never-delete/)
|
|
109
|
+
- **Localhost only:** it binds 127.0.0.1 and rejects non-localhost `Host` and `Origin` headers.
|
|
110
|
+
[Localhost security](https://local-review.edspencer.net/guarantees/localhost-security/)
|
|
111
|
+
|
|
112
|
+
And so you can't mistake it for github.com, its header is deep plum with an amber underline and a "not on GitHub" pill.
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md), and [AGENTS.md](AGENTS.md) for coding agents
|
|
117
|
+
working on this repo. Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
|
|
118
|
+
How AI was used to build it: [AI-DECLARATION.md](AI-DECLARATION.md).
|
|
119
|
+
Releases: [RELEASING.md](RELEASING.md).
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT, see [LICENSE](LICENSE).
|
|
124
|
+
|
|
125
|
+
The fonts bundled in `server/fonts/` for the stack picture are [Inter](https://github.com/rsms/inter) and
|
|
126
|
+
[JetBrains Mono](https://github.com/JetBrains/JetBrainsMono), both under the SIL Open Font License 1.1
|
|
127
|
+
(`server/fonts/*-OFL.txt`). npm dependencies keep their own licenses.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Line anchors: how an inline thread follows its code across amends and rebases.
|
|
2
|
+
//
|
|
3
|
+
// When a thread is created the server stores an `anchor`: the commit it was made on, its location there, the
|
|
4
|
+
// exact text of the commented line(s) and up to CONTEXT_LINES lines of context on each side. To place the
|
|
5
|
+
// thread on another commit (or after its code changed) we look for that text on the same side of the same
|
|
6
|
+
// file (following renames), score each match by how much surrounding context still fits, and break ties by
|
|
7
|
+
// distance from the last known line. No match => the thread is "outdated". Pure functions, no I/O.
|
|
8
|
+
// The rules are documented on the docs site: website/src/content/docs/concepts/following-rebases.md ("Line anchoring").
|
|
9
|
+
export const CONTEXT_LINES = 3;
|
|
10
|
+
/** True for a range whose start is on the other side from its end (start_side set and != side). */
|
|
11
|
+
export const isMixed = (l) => !!l.start_line && !!l.start_side && l.start_side !== (l.side ?? 'right');
|
|
12
|
+
/** Split file content into lines (1-based line n = result[n-1]); CRs and the final newline are dropped. */
|
|
13
|
+
export function splitLines(content) {
|
|
14
|
+
const ls = content.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
|
|
15
|
+
if (ls.length && ls[ls.length - 1] === '')
|
|
16
|
+
ls.pop();
|
|
17
|
+
return ls;
|
|
18
|
+
}
|
|
19
|
+
const toLines = (s) => (s === undefined ? [] : s.split('\n'));
|
|
20
|
+
const loose = (s) => s.replace(/\s+/g, ' ').trim();
|
|
21
|
+
export function sideContent(f, side) {
|
|
22
|
+
return side === 'left' ? f.oldContent : f.newContent;
|
|
23
|
+
}
|
|
24
|
+
/** Find the file for `path` in a commit's diff: by new path, then by pre-rename path (it was renamed here). */
|
|
25
|
+
export function findFile(files, ...paths) {
|
|
26
|
+
for (const p of paths) {
|
|
27
|
+
if (!p)
|
|
28
|
+
continue;
|
|
29
|
+
const f = files.find((x) => x.path === p) ?? files.find((x) => x.oldPath === p);
|
|
30
|
+
if (f)
|
|
31
|
+
return f;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
export function makeAnchor(files, commit, loc) {
|
|
36
|
+
const f = findFile(files, loc.path);
|
|
37
|
+
if (!f)
|
|
38
|
+
return null;
|
|
39
|
+
const content = sideContent(f, loc.side);
|
|
40
|
+
if (content == null)
|
|
41
|
+
return null;
|
|
42
|
+
const lines = splitLines(content);
|
|
43
|
+
if (isMixed(loc)) {
|
|
44
|
+
// anchor the end line as a single line, and remember the start line's text on its own side
|
|
45
|
+
const sc = sideContent(f, loc.start_side);
|
|
46
|
+
const sl = sc == null ? [] : splitLines(sc);
|
|
47
|
+
if (loc.line < 1 || loc.line > lines.length || loc.start_line < 1 || loc.start_line > sl.length)
|
|
48
|
+
return null;
|
|
49
|
+
const a = makeAnchor(files, commit, { path: loc.path, side: loc.side, line: loc.line });
|
|
50
|
+
if (!a)
|
|
51
|
+
return null;
|
|
52
|
+
return { ...a, start_side: loc.start_side, start_line: loc.start_line, start_text: sl[loc.start_line - 1] };
|
|
53
|
+
}
|
|
54
|
+
const start = loc.start_line ?? loc.line;
|
|
55
|
+
if (start < 1 || loc.line > lines.length || start > loc.line)
|
|
56
|
+
return null;
|
|
57
|
+
const a = { commit, path: f.path, side: loc.side, line: loc.line, text: lines.slice(start - 1, loc.line).join('\n') };
|
|
58
|
+
if (f.oldPath && f.oldPath !== f.path)
|
|
59
|
+
a.old_path = f.oldPath;
|
|
60
|
+
if (loc.start_line)
|
|
61
|
+
a.start_line = loc.start_line;
|
|
62
|
+
const before = lines.slice(Math.max(0, start - 1 - CONTEXT_LINES), start - 1);
|
|
63
|
+
const after = lines.slice(loc.line, loc.line + CONTEXT_LINES);
|
|
64
|
+
if (before.length)
|
|
65
|
+
a.before = before.join('\n');
|
|
66
|
+
if (after.length)
|
|
67
|
+
a.after = after.join('\n');
|
|
68
|
+
return a;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Find where `anchor`'s text is in `lines`. Exact matches are preferred; if there are none, matches that
|
|
72
|
+
* differ only in whitespace are used. Among matches: most context lines that still fit, then nearest to
|
|
73
|
+
* `hint` (1-based start line), then earliest. Returns the 1-based start line, or null.
|
|
74
|
+
*/
|
|
75
|
+
export function findAnchorText(lines, anchor, hint) {
|
|
76
|
+
return searchAnchor(lines, anchor, hint)?.line ?? null;
|
|
77
|
+
}
|
|
78
|
+
function searchAnchor(lines, anchor, hint) {
|
|
79
|
+
const text = toLines(anchor.text);
|
|
80
|
+
const before = toLines(anchor.before);
|
|
81
|
+
const after = toLines(anchor.after);
|
|
82
|
+
const k = text.length;
|
|
83
|
+
const matchesAt = (i, eq) => {
|
|
84
|
+
for (let j = 0; j < k; j++)
|
|
85
|
+
if (!eq(lines[i + j], text[j]))
|
|
86
|
+
return false;
|
|
87
|
+
return true;
|
|
88
|
+
};
|
|
89
|
+
const collect = (eq) => {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (let i = 0; i + k <= lines.length; i++)
|
|
92
|
+
if (matchesAt(i, eq))
|
|
93
|
+
out.push(i);
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
let cands = collect((a, b) => a === b);
|
|
97
|
+
const exact = cands.length > 0;
|
|
98
|
+
if (!exact)
|
|
99
|
+
cands = collect((a, b) => loose(a) === loose(b));
|
|
100
|
+
if (!cands.length)
|
|
101
|
+
return null;
|
|
102
|
+
const score = (i) => {
|
|
103
|
+
let s = 0;
|
|
104
|
+
for (let j = 0; j < before.length; j++) {
|
|
105
|
+
const l = lines[i - before.length + j];
|
|
106
|
+
if (l !== undefined && loose(l) === loose(before[j]))
|
|
107
|
+
s++;
|
|
108
|
+
}
|
|
109
|
+
for (let j = 0; j < after.length; j++) {
|
|
110
|
+
const l = lines[i + k + j];
|
|
111
|
+
if (l !== undefined && loose(l) === loose(after[j]))
|
|
112
|
+
s++;
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
};
|
|
116
|
+
const blank = text.every((t) => !t.trim());
|
|
117
|
+
let best = null;
|
|
118
|
+
for (const i of cands) {
|
|
119
|
+
const s = score(i);
|
|
120
|
+
if (blank && s === 0)
|
|
121
|
+
continue; // a blank line matches everywhere; demand some context
|
|
122
|
+
const d = Math.abs(i + 1 - hint);
|
|
123
|
+
if (!best || s > best.s || (s === best.s && d < best.d))
|
|
124
|
+
best = { i, s, d };
|
|
125
|
+
}
|
|
126
|
+
return best ? { line: best.i + 1, score: best.s, exact } : null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Where a thread goes on commit `sha` whose diff is `files`.
|
|
130
|
+
* - Anchored on `sha` itself, or not anchored at all: its stored path/side/line, as is.
|
|
131
|
+
* - Anchored on another commit: relocated by its anchor text (following renames).
|
|
132
|
+
* - Outdated when its file isn't in the diff, the side has no content, or the text can't be found.
|
|
133
|
+
*/
|
|
134
|
+
export function placeThread(t, files, sha) {
|
|
135
|
+
if (!t.path || !t.line)
|
|
136
|
+
return { outdated: false };
|
|
137
|
+
const a = t.anchor;
|
|
138
|
+
const side = t.side ?? a?.side ?? 'right';
|
|
139
|
+
if (!a || a.commit === sha) {
|
|
140
|
+
const f = findFile(files, t.path);
|
|
141
|
+
const content = f && sideContent(f, side);
|
|
142
|
+
if (content == null || t.line > splitLines(content).length)
|
|
143
|
+
return { outdated: true };
|
|
144
|
+
const loc = { outdated: false, path: f.path, side, line: t.line };
|
|
145
|
+
if (t.start_line)
|
|
146
|
+
loc.start_line = t.start_line;
|
|
147
|
+
if (isMixed({ side, start_line: t.start_line, start_side: t.start_side })) {
|
|
148
|
+
const sc = sideContent(f, t.start_side);
|
|
149
|
+
// the start line must exist on its side; if not, keep the end line only
|
|
150
|
+
if (sc != null && t.start_line <= splitLines(sc).length)
|
|
151
|
+
loc.start_side = t.start_side;
|
|
152
|
+
else
|
|
153
|
+
delete loc.start_line;
|
|
154
|
+
}
|
|
155
|
+
return loc;
|
|
156
|
+
}
|
|
157
|
+
// hint = expected first line of anchor.text (just the end line for a mixed-side range)
|
|
158
|
+
const hint = isMixed(a) ? t.line : (t.start_line ?? t.line ?? a.start_line ?? a.line);
|
|
159
|
+
let f = findFile(files, t.path, a.path, a.old_path);
|
|
160
|
+
let hit = null;
|
|
161
|
+
if (f) {
|
|
162
|
+
const content = sideContent(f, a.side);
|
|
163
|
+
if (content != null)
|
|
164
|
+
hit = searchAnchor(splitLines(content), a, hint);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// The file isn't in this diff under any known name. If it was added by this commit and then renamed by
|
|
168
|
+
// a fixup, git sees a plain add under the new name, so look in the other files, but only accept an
|
|
169
|
+
// exact match whose context mostly fits (strong evidence it's the same code).
|
|
170
|
+
const ctxLines = toLines(a.before).length + toLines(a.after).length;
|
|
171
|
+
const need = Math.min(ctxLines, 3);
|
|
172
|
+
if (a.text.trim() && need > 0) {
|
|
173
|
+
for (const g of files) {
|
|
174
|
+
const content = sideContent(g, a.side);
|
|
175
|
+
if (content == null)
|
|
176
|
+
continue;
|
|
177
|
+
const h = searchAnchor(splitLines(content), a, hint);
|
|
178
|
+
if (h && h.exact && h.score >= need && (!hit || h.score > hit.score)) {
|
|
179
|
+
hit = h;
|
|
180
|
+
f = g;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!f || !hit)
|
|
186
|
+
return { outdated: true };
|
|
187
|
+
if (isMixed(a)) {
|
|
188
|
+
// end line found; look for the start line's text on its own side, near where the shift puts it
|
|
189
|
+
const loc = { outdated: false, path: f.path, side: a.side, line: hit.line };
|
|
190
|
+
const sc = sideContent(f, a.start_side);
|
|
191
|
+
if (sc != null && a.start_text !== undefined) {
|
|
192
|
+
const startHint = a.start_line + (hit.line - a.line);
|
|
193
|
+
const s = searchAnchor(splitLines(sc), { text: a.start_text }, startHint);
|
|
194
|
+
if (s && s.exact) {
|
|
195
|
+
loc.start_line = s.line;
|
|
196
|
+
loc.start_side = a.start_side;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return loc; // start not found: degrade to a single-line thread on the end line
|
|
200
|
+
}
|
|
201
|
+
const start = hit.line;
|
|
202
|
+
const span = a.start_line ? a.line - a.start_line : 0;
|
|
203
|
+
const loc = { outdated: false, path: f.path, side: a.side, line: start + span };
|
|
204
|
+
if (span > 0)
|
|
205
|
+
loc.start_line = start;
|
|
206
|
+
return loc;
|
|
207
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// A project's images: projects/<slug>/assets/<sha256 prefix>.<ext>, added by `local-review asset add` or an upload
|
|
2
|
+
// in the UI, and referred to from markdown as `assets/<name>` (shared/assets.ts). Files are named by their content,
|
|
3
|
+
// so adding the same bytes twice is a no-op, and an asset file is never replaced or removed: it's written once,
|
|
4
|
+
// create-only, through safefs. Beside one may sit `<name>.yaml` with a `url:` where the image is published, which
|
|
5
|
+
// `plan` uses in place of the local reference (GitHub can't show a file on this machine).
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import YAML from 'yaml';
|
|
10
|
+
import { ASSET_HASH_LENGTH, ASSET_NAME, MAX_ASSET_BYTES, assetMarkdown, assetRefs, rewriteAssetRefs } from '../shared/assets.js';
|
|
11
|
+
import { ExistsError, atomicWrite } from './safefs.js';
|
|
12
|
+
export class AssetError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
constructor(status, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export const assetsDir = (projectDir) => path.join(projectDir, 'assets');
|
|
20
|
+
const startsWith = (b, bytes, at = 0) => b.length >= at + bytes.length && bytes.every((x, i) => b[at + i] === x);
|
|
21
|
+
const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
|
|
22
|
+
/** Whether `b` is an SVG document: UTF-8 text whose root element (after any prolog) is `<svg`. */
|
|
23
|
+
function isSvg(b) {
|
|
24
|
+
let s;
|
|
25
|
+
try {
|
|
26
|
+
s = new TextDecoder('utf-8', { fatal: true }).decode(b);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (s.includes('\0'))
|
|
32
|
+
return false;
|
|
33
|
+
s = s.replace(/^/, '');
|
|
34
|
+
// the prolog: an XML declaration, comments, processing instructions and a DOCTYPE without an internal subset
|
|
35
|
+
// (entity declarations have no business in an image)
|
|
36
|
+
const prolog = /^\s*(?:<\?xml\b[^>]*\?>|<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE\s+svg\b[^[>]*>)/i;
|
|
37
|
+
for (let m = prolog.exec(s); m; m = prolog.exec(s))
|
|
38
|
+
s = s.slice(m[0].length);
|
|
39
|
+
return /^\s*<svg[\s>/]/.test(s) && /<\/svg\s*>\s*(?:<!--[\s\S]*?-->\s*)*$|^\s*<svg\b[^>]*\/>\s*$/.test(s);
|
|
40
|
+
}
|
|
41
|
+
/** The image type `bytes` actually are, by their magic numbers (SVG: by parsing the start), or null. */
|
|
42
|
+
export function sniff(bytes) {
|
|
43
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
44
|
+
return 'png';
|
|
45
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff]))
|
|
46
|
+
return 'jpg';
|
|
47
|
+
if (startsWith(bytes, ascii('GIF87a')) || startsWith(bytes, ascii('GIF89a')))
|
|
48
|
+
return 'gif';
|
|
49
|
+
if (startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8))
|
|
50
|
+
return 'webp';
|
|
51
|
+
return isSvg(bytes) ? 'svg' : null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Add an image to the project's assets folder, named by its content. `claimed` is the type the caller says it is
|
|
55
|
+
* (from a Content-Type or a file extension); the bytes must really be that type. Re-adding the same bytes is a
|
|
56
|
+
* no-op. Never replaces a file.
|
|
57
|
+
*/
|
|
58
|
+
export function addAsset(projectDir, bytes, claimed, alt) {
|
|
59
|
+
if (!claimed)
|
|
60
|
+
throw new AssetError(415, 'not an image type Local Review takes: PNG, JPEG, GIF, WebP or SVG');
|
|
61
|
+
if (bytes.length === 0)
|
|
62
|
+
throw new AssetError(400, 'the image is empty');
|
|
63
|
+
if (bytes.length > MAX_ASSET_BYTES)
|
|
64
|
+
throw new AssetError(413, `the image is ${mb(bytes.length)}; the limit is ${mb(MAX_ASSET_BYTES)}`);
|
|
65
|
+
const actual = sniff(bytes);
|
|
66
|
+
if (!actual)
|
|
67
|
+
throw new AssetError(415, `the file isn't a ${claimed.toUpperCase()} image (its contents don't match any image type Local Review takes)`);
|
|
68
|
+
if (actual !== claimed)
|
|
69
|
+
throw new AssetError(415, `the file says it's ${claimed.toUpperCase()} but its contents are ${actual.toUpperCase()}`);
|
|
70
|
+
const name = `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, ASSET_HASH_LENGTH)}.${actual}`;
|
|
71
|
+
const file = path.join(assetsDir(projectDir), name);
|
|
72
|
+
let created = true;
|
|
73
|
+
try {
|
|
74
|
+
atomicWrite(file, bytes, { createOnly: true });
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
if (!(e instanceof ExistsError))
|
|
78
|
+
throw e;
|
|
79
|
+
// the same name: the same image, unless the file was changed by hand (then it's left alone, and we say so)
|
|
80
|
+
if (!Buffer.from(bytes).equals(fs.readFileSync(file)))
|
|
81
|
+
throw new AssetError(409, `${file} already exists with different contents; not replacing it`);
|
|
82
|
+
created = false;
|
|
83
|
+
}
|
|
84
|
+
return { name, file, markdown: assetMarkdown(name, alt), created };
|
|
85
|
+
}
|
|
86
|
+
const mb = (n) => `${(n / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`;
|
|
87
|
+
/** An asset's file, if `name` is a valid asset name and it's a regular file (not a symlink) in the folder. */
|
|
88
|
+
export function assetFile(projectDir, name) {
|
|
89
|
+
if (!ASSET_NAME.test(name))
|
|
90
|
+
return null;
|
|
91
|
+
const file = path.join(assetsDir(projectDir), name);
|
|
92
|
+
try {
|
|
93
|
+
const st = fs.lstatSync(file);
|
|
94
|
+
return st.isFile() ? file : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** `<name>.yaml` beside an asset: where it's published. Only `url:` is read; anything else in it is kept. */
|
|
101
|
+
export const assetRecordPath = (projectDir, name) => path.join(assetsDir(projectDir), `${name}.yaml`);
|
|
102
|
+
/** The published URL recorded for an asset, or null (no record, no url:, or a record that doesn't parse). */
|
|
103
|
+
export function publishedUrl(projectDir, name) {
|
|
104
|
+
let doc;
|
|
105
|
+
try {
|
|
106
|
+
doc = YAML.parse(fs.readFileSync(assetRecordPath(projectDir, name), 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const url = doc && typeof doc === 'object' ? doc.url : null;
|
|
112
|
+
return typeof url === 'string' && isHttpUrl(url) ? url : null;
|
|
113
|
+
}
|
|
114
|
+
export const isHttpUrl = (u) => /^https?:\/\/[^\s]+$/.test(u) && u.length <= 2000;
|
|
115
|
+
/**
|
|
116
|
+
* Record (or change) where an asset is published, in `<name>.yaml`, keeping anything else a person or agent wrote
|
|
117
|
+
* there. The asset must exist. A record that doesn't parse is never overwritten.
|
|
118
|
+
*/
|
|
119
|
+
export function setPublishedUrl(projectDir, name, url) {
|
|
120
|
+
if (!ASSET_NAME.test(name))
|
|
121
|
+
throw new AssetError(400, `bad asset name '${name}' (expected <16 hex digits>.<png|jpg|gif|webp|svg>)`);
|
|
122
|
+
if (!assetFile(projectDir, name))
|
|
123
|
+
throw new AssetError(404, `no asset ${name} in ${assetsDir(projectDir)}`);
|
|
124
|
+
if (!isHttpUrl(url))
|
|
125
|
+
throw new AssetError(400, `not an http(s) URL: ${url}`);
|
|
126
|
+
const file = assetRecordPath(projectDir, name);
|
|
127
|
+
let doc = new YAML.Document({});
|
|
128
|
+
if (fs.existsSync(file)) {
|
|
129
|
+
doc = YAML.parseDocument(fs.readFileSync(file, 'utf8'));
|
|
130
|
+
if (doc.errors.length || (doc.contents !== null && !YAML.isMap(doc.contents)))
|
|
131
|
+
throw new AssetError(409, `${file} doesn't parse as a YAML mapping; not overwriting it`);
|
|
132
|
+
if (doc.contents === null)
|
|
133
|
+
doc = new YAML.Document({});
|
|
134
|
+
}
|
|
135
|
+
doc.set('url', url);
|
|
136
|
+
atomicWrite(file, doc.toString({ lineWidth: 0 }));
|
|
137
|
+
return file;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* For `plan`: the assets a body refers to, and the body with each reference that has a published URL rewritten
|
|
141
|
+
* to it. References without one are left as `assets/<name>` (the plan warns about them).
|
|
142
|
+
*/
|
|
143
|
+
export function resolveAssetRefs(projectDir, body) {
|
|
144
|
+
const assets = assetRefs(body).map((name) => ({ name, file: assetFile(projectDir, name), url: publishedUrl(projectDir, name) }));
|
|
145
|
+
const urls = new Map(assets.map((a) => [a.name, a.url]));
|
|
146
|
+
return { body: rewriteAssetRefs(body, (n) => urls.get(n) ?? null), assets };
|
|
147
|
+
}
|