@armanckeser/downstream 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +133 -2
- package/SKILL.md +150 -0
- package/bin/downstream.mjs +19 -0
- package/dist/cli/downstream.js +543 -0
- package/dist/domain/agent.js +3 -0
- package/dist/domain/approval.js +190 -0
- package/dist/domain/delivery.js +21 -0
- package/dist/domain/home.js +1 -0
- package/dist/domain/inbox.js +245 -0
- package/dist/domain/model.js +180 -0
- package/dist/domain/next-move.js +34 -0
- package/dist/domain/pr-detail.js +45 -0
- package/dist/server/actions.js +564 -0
- package/dist/server/agent-host.js +230 -0
- package/dist/server/analyze/git.js +161 -0
- package/dist/server/analyze/graph.js +403 -0
- package/dist/server/analyze/py-graph.js +141 -0
- package/dist/server/analyze/py.js +346 -0
- package/dist/server/analyze/ts-decls.js +299 -0
- package/dist/server/analyze/ts-project.js +212 -0
- package/dist/server/gh.js +51 -0
- package/dist/server/github.js +264 -0
- package/dist/server/home-store.js +127 -0
- package/dist/server/home.js +325 -0
- package/dist/server/install.js +12 -0
- package/dist/server/main.js +228 -0
- package/dist/server/prompts.js +32 -0
- package/dist/server/publish.js +136 -0
- package/dist/server/repos.js +164 -0
- package/dist/server/stale.js +50 -0
- package/dist/server/store.js +269 -0
- package/dist/server/workspace.js +251 -0
- package/package.json +63 -4
- package/web/dist/assets/abap-CLvhMVsD.js +1 -0
- package/web/dist/assets/actionscript-3-Cs6JJXT5.js +1 -0
- package/web/dist/assets/ada-X3Y2mlq2.js +1 -0
- package/web/dist/assets/ahk-DwADkxyd.js +1 -0
- package/web/dist/assets/ahk2-_6rzW9BS.js +1 -0
- package/web/dist/assets/andromeeda-vGVdxbeo.js +1 -0
- package/web/dist/assets/angular-html-iITFzleb.js +1 -0
- package/web/dist/assets/angular-ts-qNGaWUZr.js +1 -0
- package/web/dist/assets/apache-U0d_L8uA.js +1 -0
- package/web/dist/assets/apex-KFGFyKwG.js +1 -0
- package/web/dist/assets/apl-DAcxbtpZ.js +1 -0
- package/web/dist/assets/applescript-CCn79oCD.js +1 -0
- package/web/dist/assets/ara-4CJ0cIlV.js +1 -0
- package/web/dist/assets/asciidoc-SCjQUq34.js +1 -0
- package/web/dist/assets/asm-Cmm7eHzH.js +1 -0
- package/web/dist/assets/astro-CxPQu5s8.js +1 -0
- package/web/dist/assets/aurora-x-CDeNXAV0.js +1 -0
- package/web/dist/assets/awk-BWXHIvNe.js +1 -0
- package/web/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
- package/web/dist/assets/ayu-light-C3h-C4tm.js +1 -0
- package/web/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
- package/web/dist/assets/ballerina-B7ZEbQpA.js +1 -0
- package/web/dist/assets/bat-qdxAdBWQ.js +1 -0
- package/web/dist/assets/beancount-C2IN4Vk8.js +1 -0
- package/web/dist/assets/berry-DKpUyyne.js +1 -0
- package/web/dist/assets/bibtex-Ci_nEsc7.js +1 -0
- package/web/dist/assets/bicep-CUHmPFLl.js +1 -0
- package/web/dist/assets/bird2-CYIUgYqB.js +1 -0
- package/web/dist/assets/blade-DJrnOqL4.js +1 -0
- package/web/dist/assets/bsl-_VjlB9mv.js +1 -0
- package/web/dist/assets/c-DJLzn8qa.js +1 -0
- package/web/dist/assets/c3-D0apG41V.js +1 -0
- package/web/dist/assets/cadence-CQ2zXKGN.js +1 -0
- package/web/dist/assets/cairo-DLTphjLi.js +1 -0
- package/web/dist/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
- package/web/dist/assets/catppuccin-latte-CtsyrtPq.js +1 -0
- package/web/dist/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
- package/web/dist/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
- package/web/dist/assets/chapel-BHYX99F3.js +1 -0
- package/web/dist/assets/clarity-CtfBBspg.js +1 -0
- package/web/dist/assets/clojure-DqKBuwfJ.js +1 -0
- package/web/dist/assets/cmake-Bj61d0ZC.js +1 -0
- package/web/dist/assets/cobol-C_LDam8n.js +1 -0
- package/web/dist/assets/codeowners-C8r90Shi.js +1 -0
- package/web/dist/assets/codeql-oeQT6MSM.js +1 -0
- package/web/dist/assets/coffee-CcuifkxZ.js +1 -0
- package/web/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
- package/web/dist/assets/coq-CGaSfhwR.js +1 -0
- package/web/dist/assets/cpp-E7p80IG9.js +1 -0
- package/web/dist/assets/crystal-iYHODIir.js +1 -0
- package/web/dist/assets/csharp-dmMxLVLW.js +1 -0
- package/web/dist/assets/css-Bk6njqO4.js +1 -0
- package/web/dist/assets/csv-Dx-8-gkx.js +1 -0
- package/web/dist/assets/cue-CE9AQfxI.js +1 -0
- package/web/dist/assets/cypher-ClKdZ_lG.js +1 -0
- package/web/dist/assets/d-qD-0Kul2.js +1 -0
- package/web/dist/assets/dark-plus-Cs2F2srj.js +1 -0
- package/web/dist/assets/dart-CHZY9Xhz.js +1 -0
- package/web/dist/assets/dax-BkyTk9wS.js +1 -0
- package/web/dist/assets/desktop-Dlh5hvp9.js +1 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/docker-IyjqRm3v.js +1 -0
- package/web/dist/assets/dotenv-CuH5my32.js +1 -0
- package/web/dist/assets/dracula-BHWKrbxM.js +1 -0
- package/web/dist/assets/dracula-soft-5eyTD99u.js +1 -0
- package/web/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
- package/web/dist/assets/edge-Ct7_SkpN.js +1 -0
- package/web/dist/assets/elixir-YTzSQL2h.js +1 -0
- package/web/dist/assets/elm-bNkwlHr8.js +1 -0
- package/web/dist/assets/emacs-lisp-DR2vtWaj.js +1 -0
- package/web/dist/assets/erb-uFBspDdG.js +1 -0
- package/web/dist/assets/erlang-Cphh6RMH.js +1 -0
- package/web/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
- package/web/dist/assets/everforest-light-Df2xbC6M.js +1 -0
- package/web/dist/assets/fennel-DQxkIbk2.js +1 -0
- package/web/dist/assets/fish-BJitypiv.js +1 -0
- package/web/dist/assets/fluent-C03EYrpw.js +1 -0
- package/web/dist/assets/fortran-fixed-form-DEKoE2YW.js +1 -0
- package/web/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
- package/web/dist/assets/fsharp-D13ZGOAj.js +1 -0
- package/web/dist/assets/gdresource-xMC7jr7x.js +1 -0
- package/web/dist/assets/gdscript-DYvRD3hg.js +1 -0
- package/web/dist/assets/gdshader-CBce3t8t.js +1 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/genie-CV2tkWYe.js +1 -0
- package/web/dist/assets/gherkin-DExj1W_8.js +1 -0
- package/web/dist/assets/git-commit-BSykSTBG.js +1 -0
- package/web/dist/assets/git-rebase-CSzaMu4k.js +1 -0
- package/web/dist/assets/github-dark-C-LZuMrd.js +1 -0
- package/web/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
- package/web/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
- package/web/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
- package/web/dist/assets/github-light-EUqPIrTm.js +1 -0
- package/web/dist/assets/github-light-default-BXViO-2h.js +1 -0
- package/web/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
- package/web/dist/assets/gleam-CSRkHgEL.js +1 -0
- package/web/dist/assets/glimmer-js-WrpQIag3.js +1 -0
- package/web/dist/assets/glimmer-ts-BkR7s81M.js +1 -0
- package/web/dist/assets/glsl-r6ahr8UN.js +1 -0
- package/web/dist/assets/gn-CAXYAgV-.js +1 -0
- package/web/dist/assets/gnuplot-7GGW24-e.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/graphql-vcFzEvUM.js +1 -0
- package/web/dist/assets/groovy-CacY0gHj.js +1 -0
- package/web/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
- package/web/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
- package/web/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
- package/web/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
- package/web/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
- package/web/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
- package/web/dist/assets/hack-DV1TStQR.js +1 -0
- package/web/dist/assets/haml-BzEpEFnM.js +1 -0
- package/web/dist/assets/handlebars-Bw2ai2tn.js +1 -0
- package/web/dist/assets/haskell-D8IpX4py.js +1 -0
- package/web/dist/assets/haxe-C8oCnwJJ.js +1 -0
- package/web/dist/assets/hcl-Dh228itO.js +1 -0
- package/web/dist/assets/hjson-CxZEssPk.js +1 -0
- package/web/dist/assets/hlsl-Cvrh5tZx.js +1 -0
- package/web/dist/assets/horizon-CE9ld1lL.js +1 -0
- package/web/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
- package/web/dist/assets/houston-CsvMBhTu.js +1 -0
- package/web/dist/assets/html-CVWJgEI4.js +1 -0
- package/web/dist/assets/html-derivative-kTFEcoss.js +1 -0
- package/web/dist/assets/http-DXC6PGgt.js +1 -0
- package/web/dist/assets/hurl-Bpd8P_ef.js +1 -0
- package/web/dist/assets/hxml-Dcm88srW.js +1 -0
- package/web/dist/assets/hy-CZbG8q4J.js +1 -0
- package/web/dist/assets/imba-DsUTQ-LC.js +1 -0
- package/web/dist/assets/index-DpYMsPxu.js +1673 -0
- package/web/dist/assets/index-Zh-Bd2u6.css +2 -0
- package/web/dist/assets/ini-B5eOa1yu.js +1 -0
- package/web/dist/assets/java-Bsmh9nXE.js +1 -0
- package/web/dist/assets/javascript-CeyaFN_P.js +1 -0
- package/web/dist/assets/jinja-BuqTDofy.js +1 -0
- package/web/dist/assets/jison-BYfmmaBZ.js +1 -0
- package/web/dist/assets/json-Bhl6cDM_.js +1 -0
- package/web/dist/assets/json5-BR5RXkoi.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsonl-CmCQp5Yx.js +1 -0
- package/web/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
- package/web/dist/assets/jssm-DXw9l8Rf.js +1 -0
- package/web/dist/assets/jsx-BSUHjwDX.js +1 -0
- package/web/dist/assets/julia-CWrNF0wf.js +1 -0
- package/web/dist/assets/just-wLw7gWsD.js +1 -0
- package/web/dist/assets/kanagawa-dragon-Bozd7klA.js +1 -0
- package/web/dist/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
- package/web/dist/assets/kanagawa-wave-BML5mF2J.js +1 -0
- package/web/dist/assets/kdl-CsD5j6eV.js +1 -0
- package/web/dist/assets/kotlin-DhhofPvG.js +1 -0
- package/web/dist/assets/kusto-C7mF5XQf.js +1 -0
- package/web/dist/assets/laserwave-C_8bwKvT.js +1 -0
- package/web/dist/assets/latex-DyWaeH0P.js +1 -0
- package/web/dist/assets/lean-CewbzKMR.js +1 -0
- package/web/dist/assets/less-DVTAwKKz.js +1 -0
- package/web/dist/assets/light-plus-DVQuIRkW.js +1 -0
- package/web/dist/assets/liquid-DeCc5Rg2.js +1 -0
- package/web/dist/assets/llvm-DqHOMS-s.js +1 -0
- package/web/dist/assets/log-BNLmms1o.js +1 -0
- package/web/dist/assets/logo-Cluzi2Zq.js +1 -0
- package/web/dist/assets/lua-Ba-NltBp.js +1 -0
- package/web/dist/assets/luau-VGWVLoqX.js +1 -0
- package/web/dist/assets/make-Dixweg8N.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/marko-DGb2HMVj.js +1 -0
- package/web/dist/assets/material-theme-Bm3Qr25_.js +1 -0
- package/web/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
- package/web/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
- package/web/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
- package/web/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
- package/web/dist/assets/matlab-D7qyCx1q.js +1 -0
- package/web/dist/assets/mdc-CL7bhRQ-.js +1 -0
- package/web/dist/assets/mdx-DQZ5AkYe.js +1 -0
- package/web/dist/assets/mermaid-BxpfTIzt.js +1 -0
- package/web/dist/assets/min-dark-BSWPekZh.js +1 -0
- package/web/dist/assets/min-light-DDpmG2fV.js +1 -0
- package/web/dist/assets/mipsasm-BMqwQI7S.js +1 -0
- package/web/dist/assets/mojo-fg40_ntQ.js +1 -0
- package/web/dist/assets/monokai-BscAjiQZ.js +1 -0
- package/web/dist/assets/moonbit-D9W784E_.js +1 -0
- package/web/dist/assets/move-BYnGhZoM.js +1 -0
- package/web/dist/assets/narrat-_X_XdTYD.js +1 -0
- package/web/dist/assets/nextflow-CSgcYZcx.js +1 -0
- package/web/dist/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
- package/web/dist/assets/nginx-CbyuJibN.js +1 -0
- package/web/dist/assets/night-owl-DhmEMT88.js +1 -0
- package/web/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
- package/web/dist/assets/nim-C7LemgDR.js +1 -0
- package/web/dist/assets/nix-IvuFDN5E.js +1 -0
- package/web/dist/assets/nord-Cb4Vim4T.js +1 -0
- package/web/dist/assets/nsis-CjNLYCLg.js +1 -0
- package/web/dist/assets/nushell--0fT3PI1.js +1 -0
- package/web/dist/assets/objective-c-D1A_Heim.js +1 -0
- package/web/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
- package/web/dist/assets/ocaml-O90oeIOV.js +1 -0
- package/web/dist/assets/odin-B1RWQWA5.js +1 -0
- package/web/dist/assets/one-dark-pro-C6YyR8OD.js +1 -0
- package/web/dist/assets/one-light-D7Lr4KcI.js +1 -0
- package/web/dist/assets/openscad-BUDT5pXO.js +1 -0
- package/web/dist/assets/org-wrbLazmP.js +1 -0
- package/web/dist/assets/pascal-4ZHwLPI5.js +1 -0
- package/web/dist/assets/perl-DarHXyEu.js +1 -0
- package/web/dist/assets/php--54M0-pj.js +1 -0
- package/web/dist/assets/pierre-dark-CpLgRqie.js +1 -0
- package/web/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
- package/web/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
- package/web/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
- package/web/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
- package/web/dist/assets/pierre-light-CoaEpmwp.js +1 -0
- package/web/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
- package/web/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
- package/web/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
- package/web/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
- package/web/dist/assets/pkl-ot-7Btpt.js +1 -0
- package/web/dist/assets/plastic-DQwYfKfQ.js +1 -0
- package/web/dist/assets/plsql-DGHpHOYJ.js +1 -0
- package/web/dist/assets/po-BiJDBrnU.js +1 -0
- package/web/dist/assets/poimandres-DRFjx7u4.js +1 -0
- package/web/dist/assets/polar-C7UOKdEL.js +1 -0
- package/web/dist/assets/postcss-BXeXVLqQ.js +1 -0
- package/web/dist/assets/powerquery-DNMTfnFr.js +1 -0
- package/web/dist/assets/powershell-QtSnmXcu.js +1 -0
- package/web/dist/assets/prisma-DMgNhnYS.js +1 -0
- package/web/dist/assets/prolog-iXnhIJG7.js +1 -0
- package/web/dist/assets/proto-DB4EqR-F.js +1 -0
- package/web/dist/assets/pug-CFrC1rDC.js +1 -0
- package/web/dist/assets/puppet-CDv2pdJW.js +1 -0
- package/web/dist/assets/purescript-9MfHhQsQ.js +1 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/qml-Go4B46qX.js +1 -0
- package/web/dist/assets/qmldir-DCQb3MpD.js +1 -0
- package/web/dist/assets/qss-Fe1Jh2GI.js +1 -0
- package/web/dist/assets/r-Bpu17F3f.js +1 -0
- package/web/dist/assets/racket-DcIDlBhZ.js +1 -0
- package/web/dist/assets/raku-B3gFvitq.js +1 -0
- package/web/dist/assets/razor-CeZTEIIg.js +1 -0
- package/web/dist/assets/rbs-C0MpOrj4.js +1 -0
- package/web/dist/assets/red-CYlAq2b0.js +1 -0
- package/web/dist/assets/reg-CRGYupPL.js +1 -0
- package/web/dist/assets/regexp-D4K6CiTG.js +1 -0
- package/web/dist/assets/rel-BtDbiS_P.js +1 -0
- package/web/dist/assets/riscv-Ckw8ddFX.js +1 -0
- package/web/dist/assets/ron-VUp2lXgN.js +1 -0
- package/web/dist/assets/rose-pine-aMsaMD1D.js +1 -0
- package/web/dist/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
- package/web/dist/assets/rose-pine-moon-DO-qXg-I.js +1 -0
- package/web/dist/assets/rosmsg-CAekHB0j.js +1 -0
- package/web/dist/assets/rst-Dy2Bl1Fg.js +1 -0
- package/web/dist/assets/ruby-CNqr2Wuz.js +1 -0
- package/web/dist/assets/rust-DpiIf5JO.js +1 -0
- package/web/dist/assets/sas-B6MsvXFb.js +1 -0
- package/web/dist/assets/sass-DXrisJhu.js +1 -0
- package/web/dist/assets/scala-BiFt_7mu.js +1 -0
- package/web/dist/assets/scheme-DQCgrYNe.js +1 -0
- package/web/dist/assets/scss-CHuFWGi8.js +1 -0
- package/web/dist/assets/sdbl-bTVj8UrX.js +1 -0
- package/web/dist/assets/shaderlab-TOUzSsQk.js +1 -0
- package/web/dist/assets/shellscript-EEdMjEbs.js +1 -0
- package/web/dist/assets/shellsession-U_I5Q2cJ.js +1 -0
- package/web/dist/assets/slack-dark-DnToyrRv.js +1 -0
- package/web/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
- package/web/dist/assets/smalltalk-BHpQb5nM.js +1 -0
- package/web/dist/assets/smithy-Dgaz7aur.js +1 -0
- package/web/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
- package/web/dist/assets/solarized-dark-b97czJOk.js +1 -0
- package/web/dist/assets/solarized-light-DFPxpIYQ.js +1 -0
- package/web/dist/assets/solidity-Bj7E1mRn.js +1 -0
- package/web/dist/assets/soy-BnCBZkG3.js +1 -0
- package/web/dist/assets/sparql-D_iOobhT.js +1 -0
- package/web/dist/assets/splunk-BC2Px7Mm.js +1 -0
- package/web/dist/assets/sql-5dStcFSw.js +1 -0
- package/web/dist/assets/ssh-config-BgfXC-Er.js +1 -0
- package/web/dist/assets/stata-B5iSQdtS.js +1 -0
- package/web/dist/assets/stylus-B6D30XZt.js +1 -0
- package/web/dist/assets/surrealql-D4K5msa4.js +1 -0
- package/web/dist/assets/svelte-Dn-_7BgE.js +1 -0
- package/web/dist/assets/swift-CyEgAFGc.js +1 -0
- package/web/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
- package/web/dist/assets/system-verilog-Dh5ihWbf.js +1 -0
- package/web/dist/assets/systemd-BxMlprV5.js +1 -0
- package/web/dist/assets/talonscript-CohzipZa.js +1 -0
- package/web/dist/assets/tasl-DMoTqEGO.js +1 -0
- package/web/dist/assets/tcl-CZd0xW_V.js +1 -0
- package/web/dist/assets/templ-BTe8yLW8.js +1 -0
- package/web/dist/assets/terraform-DswuEJGm.js +1 -0
- package/web/dist/assets/tex-BBG77enc.js +1 -0
- package/web/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
- package/web/dist/assets/toml-CcmNWLt0.js +1 -0
- package/web/dist/assets/ts-tags-BzJXXnMD.js +1 -0
- package/web/dist/assets/tsv-sltzmVWM.js +1 -0
- package/web/dist/assets/tsx-Cj2inrAP.js +1 -0
- package/web/dist/assets/turtle-ByJddavk.js +1 -0
- package/web/dist/assets/twig-BH3HU2q4.js +1 -0
- package/web/dist/assets/typescript-DpDozqSz.js +1 -0
- package/web/dist/assets/typespec-BGhR2SqG.js +1 -0
- package/web/dist/assets/typst-GHzvrUad.js +1 -0
- package/web/dist/assets/v-DBj4K9d_.js +1 -0
- package/web/dist/assets/vala-zf12oZj6.js +1 -0
- package/web/dist/assets/vb-DOwzaHZ0.js +1 -0
- package/web/dist/assets/verilog-B2gwz-sJ.js +1 -0
- package/web/dist/assets/vesper-DdrHHSXu.js +1 -0
- package/web/dist/assets/vhdl-BroJfC0k.js +1 -0
- package/web/dist/assets/viml-DvXPmvsu.js +1 -0
- package/web/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
- package/web/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
- package/web/dist/assets/vitesse-light-VbXTXTou.js +1 -0
- package/web/dist/assets/vue-CqkncxvB.js +1 -0
- package/web/dist/assets/vue-html-DrD64fqb.js +1 -0
- package/web/dist/assets/vue-vine-BJy37wv2.js +1 -0
- package/web/dist/assets/vyper-CgoNMtux.js +1 -0
- package/web/dist/assets/wasm-BnjxR4X6.js +1 -0
- package/web/dist/assets/wasm-ByWQv1Qj.js +1 -0
- package/web/dist/assets/wenyan-C8pVoKbM.js +1 -0
- package/web/dist/assets/wgsl-BsKzXJz4.js +1 -0
- package/web/dist/assets/wikitext-ClFFjSW2.js +1 -0
- package/web/dist/assets/wit-DdvCle-K.js +1 -0
- package/web/dist/assets/wolfram-DLL8P-h_.js +1 -0
- package/web/dist/assets/xml-CtXDvb3g.js +1 -0
- package/web/dist/assets/xsl-9J2XPEqv.js +1 -0
- package/web/dist/assets/yaml-CKsrpaXo.js +1 -0
- package/web/dist/assets/zenscript-BnlCZFoB.js +1 -0
- package/web/dist/assets/zig-CMLA9XwU.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Turning the review you and the agent arrived at into what the PR author sees.
|
|
2
|
+
// The conversation stays private; only threads marked to go out are sent, in
|
|
3
|
+
// the words drafted for the author, after the user presses Send.
|
|
4
|
+
import { severityLabel } from "../domain/model.js";
|
|
5
|
+
import { git } from "./analyze/git.js";
|
|
6
|
+
import { gh } from "./gh.js";
|
|
7
|
+
/** Which threads go out by default: open findings. Anything else only when someone marks it. */
|
|
8
|
+
export function goesOut(n) {
|
|
9
|
+
if (n.outgoing)
|
|
10
|
+
return n.outgoing.include;
|
|
11
|
+
return n.kind === "finding" && n.status === "open";
|
|
12
|
+
}
|
|
13
|
+
/** The comment as the PR author reads it. Written in the agent's draft if there is one. */
|
|
14
|
+
export function commentBody(n) {
|
|
15
|
+
if (n.outgoing?.body.trim())
|
|
16
|
+
return n.outgoing.body.trim();
|
|
17
|
+
const parts = [];
|
|
18
|
+
const label = n.kind === "finding" && n.severity ? `**${capitalize(severityLabel[n.severity])}:** ` : "";
|
|
19
|
+
parts.push(`${label}${n.title}`);
|
|
20
|
+
if (n.body)
|
|
21
|
+
parts.push(n.body);
|
|
22
|
+
if (n.fix)
|
|
23
|
+
parts.push(`**Fix:** ${n.fix}`);
|
|
24
|
+
if (n.suggestion && n.lines && n.suggestion.lines.start === n.lines.start && n.suggestion.lines.end === n.lines.end) {
|
|
25
|
+
parts.push("```suggestion\n" + n.suggestion.code.replace(/\n$/, "") + "\n```");
|
|
26
|
+
}
|
|
27
|
+
else if (n.suggestion) {
|
|
28
|
+
parts.push("```\n" + n.suggestion.code.replace(/\n$/, "") + "\n```");
|
|
29
|
+
}
|
|
30
|
+
if (n.impact)
|
|
31
|
+
parts.push(`**If we skip it:** ${n.impact}`);
|
|
32
|
+
return parts.join("\n\n");
|
|
33
|
+
}
|
|
34
|
+
const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
|
|
35
|
+
function diffLines(root, review, path) {
|
|
36
|
+
const right = new Set();
|
|
37
|
+
const left = new Set();
|
|
38
|
+
if (!review.baseSha)
|
|
39
|
+
return { right, left };
|
|
40
|
+
const range = review.head === "WORKTREE" ? [review.baseSha] : [review.baseSha, review.head];
|
|
41
|
+
let patch = "";
|
|
42
|
+
try {
|
|
43
|
+
patch = git(root, ["diff", "-U3", ...range, "--", path]);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { right, left };
|
|
47
|
+
}
|
|
48
|
+
for (const m of patch.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
49
|
+
const [os, ol, ns, nl] = [Number(m[1]), m[2] === undefined ? 1 : Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
|
|
50
|
+
for (let i = 0; i < nl; i++)
|
|
51
|
+
right.add(ns + i);
|
|
52
|
+
for (let i = 0; i < ol; i++)
|
|
53
|
+
left.add(os + i);
|
|
54
|
+
}
|
|
55
|
+
return { right, left };
|
|
56
|
+
}
|
|
57
|
+
export function draft(root, state, overrides = {}) {
|
|
58
|
+
const { review } = state;
|
|
59
|
+
const cache = new Map();
|
|
60
|
+
const comments = [];
|
|
61
|
+
for (const n of [...state.notes].sort((a, b) => a.number - b.number)) {
|
|
62
|
+
if (!goesOut(n) || !n.file)
|
|
63
|
+
continue;
|
|
64
|
+
const line = n.lines?.end ?? state.symbols.find((s) => s.id === n.symbolId)?.range?.start ?? null;
|
|
65
|
+
if (line == null)
|
|
66
|
+
continue;
|
|
67
|
+
const side = n.side === "old" ? "LEFT" : "RIGHT";
|
|
68
|
+
if (!cache.has(n.file))
|
|
69
|
+
cache.set(n.file, diffLines(root, review, n.file));
|
|
70
|
+
const lines = cache.get(n.file);
|
|
71
|
+
const pool = side === "RIGHT" ? lines.right : lines.left;
|
|
72
|
+
const startLine = n.lines && n.lines.start !== n.lines.end ? n.lines.start : null;
|
|
73
|
+
const inline = review.mode === "diff" && pool.has(line) && (startLine === null || pool.has(startLine));
|
|
74
|
+
comments.push({ noteId: n.id, number: n.number, path: n.file, line, startLine, side, body: commentBody(n), inline });
|
|
75
|
+
}
|
|
76
|
+
const verdict = review.verdicts.find((v) => v.by === "user") ?? review.verdicts.find((v) => v.by === "agent");
|
|
77
|
+
const event = overrides.event ?? (verdict?.value === "approve" ? "APPROVE" : verdict?.value === "changes" ? "REQUEST_CHANGES" : "COMMENT");
|
|
78
|
+
let body = (overrides.body ?? review.outgoingBody).trim();
|
|
79
|
+
if (!body)
|
|
80
|
+
body = defaultBody(state, comments);
|
|
81
|
+
const outside = comments.filter((c) => !c.inline);
|
|
82
|
+
if (outside.length) {
|
|
83
|
+
body += "\n\n" + outside.map((c) => `**${c.path}:${c.startLine ? `${c.startLine}-` : ""}${c.line}**\n\n${c.body}`).join("\n\n---\n\n");
|
|
84
|
+
}
|
|
85
|
+
const repo = review.pr ? review.repo : null;
|
|
86
|
+
const markdown = [body, ...comments.filter((c) => c.inline).map((c) => `**${c.path}:${c.startLine ? `${c.startLine}-` : ""}${c.line}**\n\n${c.body}`)].join("\n\n---\n\n");
|
|
87
|
+
return { event, body, comments, target: review.pr ? { pr: review.pr, repo } : null, markdown };
|
|
88
|
+
}
|
|
89
|
+
function defaultBody(state, comments) {
|
|
90
|
+
const findings = state.notes.filter((n) => goesOut(n) && n.kind === "finding");
|
|
91
|
+
const must = findings.filter((n) => n.severity === "blocker").map((n) => n.title.replace(/\.$/, "").toLowerCase());
|
|
92
|
+
const lines = [];
|
|
93
|
+
const first = state.review.summary.split(/\n\s*\n/)[0]?.trim();
|
|
94
|
+
if (first)
|
|
95
|
+
lines.push(first);
|
|
96
|
+
if (!comments.length)
|
|
97
|
+
lines.push("Looks good. Ship.");
|
|
98
|
+
// No "#n" here: on GitHub that links to an issue. Name the findings instead.
|
|
99
|
+
else if (must.length)
|
|
100
|
+
lines.push(`Before merging: ${must.join("; ")}. The rest can follow.`);
|
|
101
|
+
else
|
|
102
|
+
lines.push(`${comments.length} comment${comments.length === 1 ? "" : "s"} inline, none blocking.`);
|
|
103
|
+
return lines.join("\n\n");
|
|
104
|
+
}
|
|
105
|
+
/** The PR moved on after the review was opened: its comments would sit on code that has been replaced. */
|
|
106
|
+
export class HeadMoved extends Error {
|
|
107
|
+
reviewed;
|
|
108
|
+
current;
|
|
109
|
+
constructor(reviewed, current, pr) {
|
|
110
|
+
super(`#${pr} changed since you opened this review (${reviewed.slice(0, 7)} → ${current.slice(0, 7)}), so these comments would land on replaced code. Re-open it with \`downstream open --pr ${pr}\` to review what's there now, or send anyway.`);
|
|
111
|
+
this.reviewed = reviewed;
|
|
112
|
+
this.current = current;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Sends the review. Refuses when the PR's head moved since it was opened, unless `force`. */
|
|
116
|
+
export function publish(state, d, opts = {}) {
|
|
117
|
+
const { review } = state;
|
|
118
|
+
if (!review.pr || !d.target?.repo)
|
|
119
|
+
throw new Error("Only pull request reviews can be sent. Open with `downstream open --pr <n>`, or copy the markdown.");
|
|
120
|
+
if (!opts.force) {
|
|
121
|
+
const current = gh(["pr", "view", String(review.pr), "--repo", d.target.repo, "--json", "headRefOid", "--jq", ".headRefOid"], { account: review.account }).trim();
|
|
122
|
+
if (current && current !== review.head)
|
|
123
|
+
throw new HeadMoved(review.head, current, review.pr);
|
|
124
|
+
}
|
|
125
|
+
const payload = {
|
|
126
|
+
commit_id: review.head,
|
|
127
|
+
event: d.event,
|
|
128
|
+
body: d.body,
|
|
129
|
+
comments: d.comments
|
|
130
|
+
.filter((c) => c.inline)
|
|
131
|
+
.map((c) => ({ path: c.path, line: c.line, side: c.side, ...(c.startLine ? { start_line: c.startLine, start_side: c.side } : {}), body: c.body })),
|
|
132
|
+
};
|
|
133
|
+
const out = gh(["api", "-X", "POST", `repos/${d.target.repo}/pulls/${review.pr}/reviews`, "--input", "-"], { account: review.account, input: JSON.stringify(payload) });
|
|
134
|
+
const res = JSON.parse(out);
|
|
135
|
+
return { url: res.html_url, comments: payload.comments.length };
|
|
136
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The repositories one Downstream server serves. Each keeps its own review
|
|
2
|
+
// database in `<repo>/.downstream/`; the server finds a repo's clone from its
|
|
3
|
+
// GitHub name, and clones it when you have none.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { Store } from "./store.js";
|
|
10
|
+
import { Workspace } from "./workspace.js";
|
|
11
|
+
import { ActionError } from "./actions.js";
|
|
12
|
+
import { downstreamHome } from "./home-store.js";
|
|
13
|
+
import { ghAsync } from "./gh.js";
|
|
14
|
+
export class RepoContext {
|
|
15
|
+
store;
|
|
16
|
+
workspaces = new Map();
|
|
17
|
+
id;
|
|
18
|
+
root;
|
|
19
|
+
nameWithOwner;
|
|
20
|
+
constructor(id, root, nameWithOwner) {
|
|
21
|
+
this.id = id;
|
|
22
|
+
this.root = root;
|
|
23
|
+
this.nameWithOwner = nameWithOwner;
|
|
24
|
+
const dir = path.join(root, ".downstream");
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
excludeFromGit(root);
|
|
27
|
+
this.store = new Store(path.join(dir, "review.db"));
|
|
28
|
+
}
|
|
29
|
+
workspace(reviewId) {
|
|
30
|
+
let ws = this.workspaces.get(reviewId);
|
|
31
|
+
if (!ws) {
|
|
32
|
+
const review = this.store.review(reviewId);
|
|
33
|
+
if (!review)
|
|
34
|
+
throw new ActionError(`No review ${reviewId}`, 404);
|
|
35
|
+
ws = new Workspace(this.store.reviewRoot(reviewId) || this.root, review);
|
|
36
|
+
this.workspaces.set(reviewId, ws);
|
|
37
|
+
}
|
|
38
|
+
return ws;
|
|
39
|
+
}
|
|
40
|
+
dropWorkspace(reviewId) {
|
|
41
|
+
this.workspaces.delete(reviewId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Short, readable and stable: `downstream-1a2b3c`. The URL of a repo's reviews. */
|
|
45
|
+
export function repoId(root) {
|
|
46
|
+
const slug = path.basename(root).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "repo";
|
|
47
|
+
return `${slug}-${createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 6)}`;
|
|
48
|
+
}
|
|
49
|
+
/** `owner/name` from the origin remote, read from .git/config so a scan of many repos runs no git processes. */
|
|
50
|
+
export function originOf(root) {
|
|
51
|
+
const config = path.join(root, ".git", "config");
|
|
52
|
+
if (!existsSync(config))
|
|
53
|
+
return null;
|
|
54
|
+
const text = readFileSync(config, "utf8");
|
|
55
|
+
const origin = text.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
|
|
56
|
+
const m = origin?.[1]?.match(/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
57
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
58
|
+
}
|
|
59
|
+
const SKIP = new Set(["node_modules", "Library", "Applications", "dist", "build", "vendor", "target"]);
|
|
60
|
+
const SCAN_DEPTH = 4;
|
|
61
|
+
const RESCAN_AFTER_MS = 5 * 60_000;
|
|
62
|
+
export class RepoRegistry {
|
|
63
|
+
loaded = new Map();
|
|
64
|
+
scannedAt = 0;
|
|
65
|
+
/** Every repo's review events, as ("event", repo, reviewId, event): the home page and agent sessions follow them. */
|
|
66
|
+
bus = new EventEmitter();
|
|
67
|
+
home;
|
|
68
|
+
constructor(home) {
|
|
69
|
+
this.home = home;
|
|
70
|
+
this.bus.setMaxListeners(100);
|
|
71
|
+
}
|
|
72
|
+
/** A repository the CLI is running in, or one the home page needs. Idempotent. */
|
|
73
|
+
register(root) {
|
|
74
|
+
const abs = path.resolve(root);
|
|
75
|
+
const id = repoId(abs);
|
|
76
|
+
const existing = this.loaded.get(id);
|
|
77
|
+
if (existing)
|
|
78
|
+
return existing;
|
|
79
|
+
const ctx = new RepoContext(id, abs, originOf(abs));
|
|
80
|
+
this.home.putRepo({ id, root: abs, nameWithOwner: ctx.nameWithOwner });
|
|
81
|
+
this.loaded.set(id, ctx);
|
|
82
|
+
ctx.store.bus.on("event", (reviewId, e) => this.bus.emit("event", ctx, reviewId, e));
|
|
83
|
+
return ctx;
|
|
84
|
+
}
|
|
85
|
+
get(id) {
|
|
86
|
+
const loaded = this.loaded.get(id);
|
|
87
|
+
if (loaded)
|
|
88
|
+
return loaded;
|
|
89
|
+
const row = this.home.repos().find((r) => r.id === id);
|
|
90
|
+
return row && existsSync(row.root) ? this.register(row.root) : null;
|
|
91
|
+
}
|
|
92
|
+
/** Every repository with reviews, for the home page. */
|
|
93
|
+
withReviews() {
|
|
94
|
+
return this.home
|
|
95
|
+
.repos()
|
|
96
|
+
.filter((r) => existsSync(path.join(r.root, ".downstream", "review.db")))
|
|
97
|
+
.map((r) => this.register(r.root));
|
|
98
|
+
}
|
|
99
|
+
/** Where `owner/name` is cloned on this machine, looking in the usual places once in a while. */
|
|
100
|
+
find(nameWithOwner) {
|
|
101
|
+
const known = this.home.rootFor(nameWithOwner);
|
|
102
|
+
if (known && existsSync(known))
|
|
103
|
+
return known;
|
|
104
|
+
if (Date.now() - this.scannedAt < RESCAN_AFTER_MS)
|
|
105
|
+
return null;
|
|
106
|
+
this.scannedAt = Date.now();
|
|
107
|
+
for (const { dir, depth } of searchRoots())
|
|
108
|
+
scan(dir, depth, (root, origin) => this.home.putRepo({ id: repoId(root), root, nameWithOwner: origin }));
|
|
109
|
+
const found = this.home.rootFor(nameWithOwner);
|
|
110
|
+
return found && existsSync(found) ? found : null;
|
|
111
|
+
}
|
|
112
|
+
/** A partial clone (no file contents until needed) under Downstream's home, for repos you don't have. */
|
|
113
|
+
async clone(nameWithOwner, account) {
|
|
114
|
+
const dest = path.join(downstreamHome(), "repos", ...nameWithOwner.split("/"));
|
|
115
|
+
if (!existsSync(path.join(dest, ".git"))) {
|
|
116
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
117
|
+
await ghAsync(["repo", "clone", nameWithOwner, dest, "--", "--filter=blob:none"], { account });
|
|
118
|
+
}
|
|
119
|
+
this.register(dest);
|
|
120
|
+
return dest;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Code folders are searched deep; the home directory itself only one level, for `~/myrepo`. */
|
|
124
|
+
function searchRoots() {
|
|
125
|
+
const env = process.env.DOWNSTREAM_REPO_ROOTS;
|
|
126
|
+
if (env)
|
|
127
|
+
return env.split(path.delimiter).filter(Boolean).map((dir) => ({ dir, depth: SCAN_DEPTH }));
|
|
128
|
+
const home = homedir();
|
|
129
|
+
const deep = ["projects", "code", "src", "dev"].map((d) => ({ dir: path.join(home, d), depth: SCAN_DEPTH }));
|
|
130
|
+
return [...deep, { dir: home, depth: 1 }].filter((r) => existsSync(r.dir));
|
|
131
|
+
}
|
|
132
|
+
function scan(dir, depth, found) {
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (entries.some((e) => e.name === ".git" && e.isDirectory())) {
|
|
141
|
+
const origin = originOf(dir);
|
|
142
|
+
if (origin)
|
|
143
|
+
found(dir, origin);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (depth === 0)
|
|
147
|
+
return;
|
|
148
|
+
for (const e of entries) {
|
|
149
|
+
if (!e.isDirectory() || e.name.startsWith(".") || SKIP.has(e.name))
|
|
150
|
+
continue;
|
|
151
|
+
scan(path.join(dir, e.name), depth - 1, found);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function excludeFromGit(root) {
|
|
155
|
+
const exclude = path.join(root, ".git", "info", "exclude");
|
|
156
|
+
try {
|
|
157
|
+
const text = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
158
|
+
if (!text.split("\n").includes(".downstream/"))
|
|
159
|
+
appendFileSync(exclude, `${text.endsWith("\n") || !text ? "" : "\n"}.downstream/\n`);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* worktrees keep info/ elsewhere; not worth failing over */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Deciding a stale pull request: each decision does exactly what its label in
|
|
2
|
+
// the sweep says, on GitHub and here, and nothing else.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { staleChoices } from "../domain/inbox.js";
|
|
5
|
+
import { ghAsync } from "./gh.js";
|
|
6
|
+
export const StaleDecision = z.discriminatedUnion("kind", [
|
|
7
|
+
z.object({ kind: z.literal("remove-request") }),
|
|
8
|
+
z.object({ kind: z.literal("comment"), body: z.string().trim().min(1) }),
|
|
9
|
+
z.object({ kind: z.literal("close"), reason: z.string().trim().min(1) }),
|
|
10
|
+
z.object({ kind: z.literal("hide") }),
|
|
11
|
+
]);
|
|
12
|
+
/** The decision isn't one this PR offers (closing someone else's PR, removing a request nobody made). */
|
|
13
|
+
export class NotOffered extends Error {
|
|
14
|
+
}
|
|
15
|
+
/** GitHub (through gh) said no; its first lines say why. */
|
|
16
|
+
export class GitHubRefused extends Error {
|
|
17
|
+
}
|
|
18
|
+
async function write(args, account) {
|
|
19
|
+
try {
|
|
20
|
+
await ghAsync(args, { account });
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
const err = e;
|
|
24
|
+
throw new GitHubRefused((err.stderr?.toString().trim() || err.message).split("\n").slice(0, 2).join(" "));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Carry out `decision` on `item` as `account`. GitHub first: if it refuses,
|
|
29
|
+
* nothing is hidden. Hiding comes after, so the PR's own update from the write
|
|
30
|
+
* is older than the hide and doesn't bring it straight back.
|
|
31
|
+
*/
|
|
32
|
+
export async function decide(account, item, decision, hide) {
|
|
33
|
+
const offered = staleChoices(item).some((c) => c.kind === decision.kind);
|
|
34
|
+
if (!offered)
|
|
35
|
+
throw new NotOffered(`#${item.lead.number} doesn't offer "${decision.kind}".`);
|
|
36
|
+
const { repo, number } = item.lead;
|
|
37
|
+
switch (decision.kind) {
|
|
38
|
+
case "remove-request":
|
|
39
|
+
await write(["api", "-X", "DELETE", `repos/${repo}/pulls/${number}/requested_reviewers`, "-f", `reviewers[]=${account}`], account);
|
|
40
|
+
return hide();
|
|
41
|
+
case "comment":
|
|
42
|
+
await write(["api", "-X", "POST", `repos/${repo}/issues/${number}/comments`, "-f", `body=${decision.body}`], account);
|
|
43
|
+
return hide();
|
|
44
|
+
case "close":
|
|
45
|
+
await write(["pr", "close", String(number), "--repo", repo, "--comment", decision.reason], account);
|
|
46
|
+
return;
|
|
47
|
+
case "hide":
|
|
48
|
+
return hide();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// SQLite is the source of truth for a review. The browser and the agent both
|
|
2
|
+
// write through actions (server/actions.ts); the agent may read with plain SQL
|
|
3
|
+
// through a read-only connection (`downstream sql`), which cannot write.
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
const SCHEMA = `
|
|
7
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
8
|
+
CREATE TABLE IF NOT EXISTS reviews (id TEXT PRIMARY KEY, data TEXT NOT NULL, root TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
9
|
+
CREATE TABLE IF NOT EXISTS files (review_id TEXT NOT NULL, path TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY (review_id, path));
|
|
10
|
+
CREATE TABLE IF NOT EXISTS symbols (review_id TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, manual INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (review_id, id));
|
|
11
|
+
CREATE TABLE IF NOT EXISTS edges (review_id TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, manual INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (review_id, id));
|
|
12
|
+
CREATE TABLE IF NOT EXISTS steps (review_id TEXT NOT NULL, id TEXT NOT NULL, ord INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY (review_id, id));
|
|
13
|
+
CREATE TABLE IF NOT EXISTS notes (review_id TEXT NOT NULL, id TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (review_id, id));
|
|
14
|
+
CREATE TABLE IF NOT EXISTS replies (review_id TEXT NOT NULL, id TEXT NOT NULL, note_id TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (review_id, id));
|
|
15
|
+
CREATE TABLE IF NOT EXISTS app_state (review_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (review_id, key));
|
|
16
|
+
CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, review_id TEXT NOT NULL, type TEXT NOT NULL, actor TEXT NOT NULL, payload TEXT NOT NULL, at TEXT NOT NULL);
|
|
17
|
+
|
|
18
|
+
-- Flat views for ad-hoc agent queries: downstream sql "select * from v_notes where status = 'open'"
|
|
19
|
+
CREATE VIEW IF NOT EXISTS v_symbols AS SELECT review_id, id, json_extract(data,'$.name') AS name, json_extract(data,'$.kind') AS kind,
|
|
20
|
+
json_extract(data,'$.file') AS file, json_extract(data,'$.status') AS status, json_extract(data,'$.entry') AS entry,
|
|
21
|
+
json_extract(data,'$.range.start') AS start_line, json_extract(data,'$.range.end') AS end_line, json_extract(data,'$.summary') AS summary FROM symbols;
|
|
22
|
+
CREATE VIEW IF NOT EXISTS v_edges AS SELECT review_id, id, json_extract(data,'$.from') AS from_id, json_extract(data,'$.to') AS to_id,
|
|
23
|
+
json_extract(data,'$.kind') AS kind, json_extract(data,'$.change') AS change, json_extract(data,'$.line') AS line FROM edges;
|
|
24
|
+
CREATE VIEW IF NOT EXISTS v_notes AS SELECT review_id, id, json_extract(data,'$.kind') AS kind, json_extract(data,'$.severity') AS severity,
|
|
25
|
+
json_extract(data,'$.status') AS status, json_extract(data,'$.author') AS author, json_extract(data,'$.title') AS title,
|
|
26
|
+
json_extract(data,'$.symbolId') AS symbol_id, json_extract(data,'$.body') AS body, created_at FROM notes;
|
|
27
|
+
CREATE VIEW IF NOT EXISTS v_replies AS SELECT review_id, id, note_id, json_extract(data,'$.author') AS author, json_extract(data,'$.body') AS body, created_at FROM replies;
|
|
28
|
+
`;
|
|
29
|
+
const NOTE_DEFAULTS = { number: 0, category: null, fix: "", impact: "", outgoing: null };
|
|
30
|
+
export const now = () => new Date().toISOString();
|
|
31
|
+
export const newId = (prefix) => `${prefix}_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36).slice(-3)}`;
|
|
32
|
+
export class Store {
|
|
33
|
+
db;
|
|
34
|
+
bus = new EventEmitter();
|
|
35
|
+
waiters = 0;
|
|
36
|
+
lastAgentActivity = null;
|
|
37
|
+
constructor(file) {
|
|
38
|
+
this.db = new DatabaseSync(file);
|
|
39
|
+
this.db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
|
|
40
|
+
this.db.exec(SCHEMA);
|
|
41
|
+
this.bus.setMaxListeners(100);
|
|
42
|
+
}
|
|
43
|
+
// -- reviews --------------------------------------------------------------
|
|
44
|
+
currentReviewId() {
|
|
45
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = 'current'").get();
|
|
46
|
+
return row?.value ?? null;
|
|
47
|
+
}
|
|
48
|
+
setCurrent(id) {
|
|
49
|
+
this.db.prepare("INSERT INTO meta (key, value) VALUES ('current', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(id);
|
|
50
|
+
}
|
|
51
|
+
listReviews() {
|
|
52
|
+
return this.db.prepare("SELECT data FROM reviews ORDER BY created_at DESC").all().map((r) => JSON.parse(r.data));
|
|
53
|
+
}
|
|
54
|
+
review(id) {
|
|
55
|
+
const row = this.db.prepare("SELECT data FROM reviews WHERE id = ?").get(id);
|
|
56
|
+
return row ? { outgoingBody: "", published: null, ...JSON.parse(row.data) } : null;
|
|
57
|
+
}
|
|
58
|
+
reviewRoot(id) {
|
|
59
|
+
const row = this.db.prepare("SELECT root FROM reviews WHERE id = ?").get(id);
|
|
60
|
+
return row?.root ?? null;
|
|
61
|
+
}
|
|
62
|
+
saveReview(review, root) {
|
|
63
|
+
const existing = this.reviewRoot(review.id);
|
|
64
|
+
this.db
|
|
65
|
+
.prepare("INSERT INTO reviews (id, data, root, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data")
|
|
66
|
+
.run(review.id, JSON.stringify(review), root ?? existing ?? "", review.createdAt);
|
|
67
|
+
}
|
|
68
|
+
// -- analysis ---------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Replace the analyzed part of a review, keeping what people wrote: symbol
|
|
71
|
+
* summaries and entry flags survive a re-analysis, as do manual symbols/edges.
|
|
72
|
+
*/
|
|
73
|
+
replaceAnalysis(reviewId, files, symbols, edges) {
|
|
74
|
+
const prior = new Map(this.symbols(reviewId).map((s) => [s.id, s]));
|
|
75
|
+
this.tx(() => {
|
|
76
|
+
this.db.prepare("DELETE FROM files WHERE review_id = ?").run(reviewId);
|
|
77
|
+
this.db.prepare("DELETE FROM symbols WHERE review_id = ? AND manual = 0").run(reviewId);
|
|
78
|
+
this.db.prepare("DELETE FROM edges WHERE review_id = ? AND manual = 0").run(reviewId);
|
|
79
|
+
const insFile = this.db.prepare("INSERT INTO files (review_id, path, data) VALUES (?, ?, ?)");
|
|
80
|
+
for (const f of files)
|
|
81
|
+
insFile.run(reviewId, f.path, JSON.stringify(f));
|
|
82
|
+
const insSym = this.db.prepare("INSERT OR IGNORE INTO symbols (review_id, id, data, manual) VALUES (?, ?, ?, 0)");
|
|
83
|
+
for (const s of symbols) {
|
|
84
|
+
const before = prior.get(s.id);
|
|
85
|
+
if (before) {
|
|
86
|
+
s.summary = before.summary ?? s.summary;
|
|
87
|
+
if (before.entry !== s.entry && this.entryWasSetByHand(reviewId, s.id))
|
|
88
|
+
s.entry = before.entry;
|
|
89
|
+
}
|
|
90
|
+
insSym.run(reviewId, s.id, JSON.stringify(s));
|
|
91
|
+
}
|
|
92
|
+
const insEdge = this.db.prepare("INSERT OR IGNORE INTO edges (review_id, id, data, manual) VALUES (?, ?, ?, 0)");
|
|
93
|
+
for (const e of edges)
|
|
94
|
+
insEdge.run(reviewId, e.id, JSON.stringify(e));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
entryWasSetByHand(reviewId, symbolId) {
|
|
98
|
+
const row = this.db
|
|
99
|
+
.prepare("SELECT 1 FROM events WHERE review_id = ? AND type = 'symbol.annotated' AND json_extract(payload, '$.id') = ? AND json_extract(payload, '$.entry') IS NOT NULL LIMIT 1")
|
|
100
|
+
.get(reviewId, symbolId);
|
|
101
|
+
return !!row;
|
|
102
|
+
}
|
|
103
|
+
files(reviewId) {
|
|
104
|
+
return this.db.prepare("SELECT data FROM files WHERE review_id = ? ORDER BY path").all(reviewId).map((r) => JSON.parse(r.data));
|
|
105
|
+
}
|
|
106
|
+
symbols(reviewId) {
|
|
107
|
+
return this.db.prepare("SELECT data FROM symbols WHERE review_id = ?").all(reviewId).map((r) => JSON.parse(r.data));
|
|
108
|
+
}
|
|
109
|
+
symbol(reviewId, id) {
|
|
110
|
+
const row = this.db.prepare("SELECT data FROM symbols WHERE review_id = ? AND id = ?").get(reviewId, id);
|
|
111
|
+
return row ? JSON.parse(row.data) : null;
|
|
112
|
+
}
|
|
113
|
+
putSymbol(reviewId, s, manual = false) {
|
|
114
|
+
this.db
|
|
115
|
+
.prepare("INSERT INTO symbols (review_id, id, data, manual) VALUES (?, ?, ?, ?) ON CONFLICT(review_id, id) DO UPDATE SET data = excluded.data, manual = MAX(symbols.manual, excluded.manual)")
|
|
116
|
+
.run(reviewId, s.id, JSON.stringify(s), manual ? 1 : 0);
|
|
117
|
+
}
|
|
118
|
+
deleteSymbol(reviewId, id) {
|
|
119
|
+
this.db.prepare("DELETE FROM symbols WHERE review_id = ? AND id = ?").run(reviewId, id);
|
|
120
|
+
this.db.prepare("DELETE FROM edges WHERE review_id = ? AND (json_extract(data,'$.from') = ? OR json_extract(data,'$.to') = ?)").run(reviewId, id, id);
|
|
121
|
+
}
|
|
122
|
+
edges(reviewId) {
|
|
123
|
+
return this.db.prepare("SELECT data FROM edges WHERE review_id = ?").all(reviewId).map((r) => JSON.parse(r.data));
|
|
124
|
+
}
|
|
125
|
+
putEdge(reviewId, e, manual = false) {
|
|
126
|
+
this.db
|
|
127
|
+
.prepare("INSERT INTO edges (review_id, id, data, manual) VALUES (?, ?, ?, ?) ON CONFLICT(review_id, id) DO UPDATE SET data = excluded.data")
|
|
128
|
+
.run(reviewId, e.id, JSON.stringify(e), manual ? 1 : 0);
|
|
129
|
+
}
|
|
130
|
+
deleteEdge(reviewId, id) {
|
|
131
|
+
this.db.prepare("DELETE FROM edges WHERE review_id = ? AND id = ?").run(reviewId, id);
|
|
132
|
+
}
|
|
133
|
+
// -- walkthrough --------------------------------------------------------------
|
|
134
|
+
steps(reviewId) {
|
|
135
|
+
return this.db.prepare("SELECT data FROM steps WHERE review_id = ? ORDER BY ord").all(reviewId).map((r) => JSON.parse(r.data));
|
|
136
|
+
}
|
|
137
|
+
replaceSteps(reviewId, steps) {
|
|
138
|
+
this.tx(() => {
|
|
139
|
+
this.db.prepare("DELETE FROM steps WHERE review_id = ?").run(reviewId);
|
|
140
|
+
const ins = this.db.prepare("INSERT INTO steps (review_id, id, ord, data) VALUES (?, ?, ?, ?)");
|
|
141
|
+
for (const s of steps)
|
|
142
|
+
ins.run(reviewId, s.id, s.order, JSON.stringify(s));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// -- threads --------------------------------------------------------------------
|
|
146
|
+
notes(reviewId) {
|
|
147
|
+
const notes = this.db.prepare("SELECT data FROM notes WHERE review_id = ? ORDER BY created_at").all(reviewId).map((r) => JSON.parse(r.data));
|
|
148
|
+
const replies = this.db.prepare("SELECT data FROM replies WHERE review_id = ? ORDER BY created_at").all(reviewId).map((r) => JSON.parse(r.data));
|
|
149
|
+
const byNote = Map.groupBy(replies, (r) => r.noteId);
|
|
150
|
+
// Reviews from before numbering get numbers in creation order.
|
|
151
|
+
let next = Math.max(0, ...notes.map((n) => n.number ?? 0));
|
|
152
|
+
return notes.map((n) => ({ ...NOTE_DEFAULTS, ...n, number: n.number || ++next, replies: byNote.get(n.id) ?? [] }));
|
|
153
|
+
}
|
|
154
|
+
nextNoteNumber(reviewId) {
|
|
155
|
+
return Math.max(0, ...this.notes(reviewId).map((n) => n.number)) + 1;
|
|
156
|
+
}
|
|
157
|
+
note(reviewId, id) {
|
|
158
|
+
const row = this.db.prepare("SELECT data FROM notes WHERE review_id = ? AND id = ?").get(reviewId, id);
|
|
159
|
+
if (!row)
|
|
160
|
+
return null;
|
|
161
|
+
const note = { ...NOTE_DEFAULTS, ...JSON.parse(row.data) };
|
|
162
|
+
const replies = this.db.prepare("SELECT data FROM replies WHERE review_id = ? AND note_id = ? ORDER BY created_at").all(reviewId, id).map((r) => JSON.parse(r.data));
|
|
163
|
+
return { ...note, replies };
|
|
164
|
+
}
|
|
165
|
+
putNote(reviewId, note) {
|
|
166
|
+
const { replies: _replies, ...rest } = note;
|
|
167
|
+
this.db
|
|
168
|
+
.prepare("INSERT INTO notes (review_id, id, data, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(review_id, id) DO UPDATE SET data = excluded.data")
|
|
169
|
+
.run(reviewId, note.id, JSON.stringify({ ...rest, replies: [] }), note.createdAt);
|
|
170
|
+
}
|
|
171
|
+
addReply(reviewId, reply) {
|
|
172
|
+
this.db.prepare("INSERT INTO replies (review_id, id, note_id, data, created_at) VALUES (?, ?, ?, ?, ?)").run(reviewId, reply.id, reply.noteId, JSON.stringify(reply), reply.createdAt);
|
|
173
|
+
}
|
|
174
|
+
// -- application state -------------------------------------------------------------
|
|
175
|
+
getAppState(reviewId, key) {
|
|
176
|
+
const row = this.db.prepare("SELECT value FROM app_state WHERE review_id = ? AND key = ?").get(reviewId, key);
|
|
177
|
+
return row ? JSON.parse(row.value) : null;
|
|
178
|
+
}
|
|
179
|
+
setAppState(reviewId, key, value) {
|
|
180
|
+
this.db
|
|
181
|
+
.prepare("INSERT INTO app_state (review_id, key, value, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(review_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at")
|
|
182
|
+
.run(reviewId, key, JSON.stringify(value), now());
|
|
183
|
+
}
|
|
184
|
+
// -- events --------------------------------------------------------------------------
|
|
185
|
+
emit(reviewId, type, actor, payload = {}) {
|
|
186
|
+
const at = now();
|
|
187
|
+
const res = this.db.prepare("INSERT INTO events (review_id, type, actor, payload, at) VALUES (?, ?, ?, ?, ?)").run(reviewId, type, actor, JSON.stringify(payload), at);
|
|
188
|
+
const event = { id: Number(res.lastInsertRowid), type, actor, payload, at };
|
|
189
|
+
if (actor === "agent")
|
|
190
|
+
this.touchAgent();
|
|
191
|
+
this.bus.emit("event", reviewId, event);
|
|
192
|
+
return event;
|
|
193
|
+
}
|
|
194
|
+
events(reviewId, after, actor) {
|
|
195
|
+
const rows = (actor
|
|
196
|
+
? this.db.prepare("SELECT * FROM events WHERE review_id = ? AND id > ? AND actor = ? ORDER BY id").all(reviewId, after, actor)
|
|
197
|
+
: this.db.prepare("SELECT * FROM events WHERE review_id = ? AND id > ? ORDER BY id").all(reviewId, after));
|
|
198
|
+
return rows.map((r) => ({ id: r.id, type: r.type, actor: r.actor, payload: JSON.parse(r.payload), at: r.at }));
|
|
199
|
+
}
|
|
200
|
+
cursor(reviewId) {
|
|
201
|
+
const row = this.db.prepare("SELECT MAX(id) AS id FROM events WHERE review_id = ?").get(reviewId);
|
|
202
|
+
return row.id ?? 0;
|
|
203
|
+
}
|
|
204
|
+
// -- presence ----------------------------------------------------------------------------
|
|
205
|
+
touchAgent() {
|
|
206
|
+
this.lastAgentActivity = now();
|
|
207
|
+
}
|
|
208
|
+
waiterJoined() {
|
|
209
|
+
this.waiters++;
|
|
210
|
+
this.touchAgent();
|
|
211
|
+
this.bus.emit("presence");
|
|
212
|
+
}
|
|
213
|
+
waiterLeft() {
|
|
214
|
+
this.waiters = Math.max(0, this.waiters - 1);
|
|
215
|
+
this.touchAgent();
|
|
216
|
+
this.bus.emit("presence");
|
|
217
|
+
}
|
|
218
|
+
/** Record that the agent was handed these user events. Kept as an event so the browser hears it live and it survives restarts. */
|
|
219
|
+
markRead(reviewId, events) {
|
|
220
|
+
if (events.length === 0)
|
|
221
|
+
return;
|
|
222
|
+
const through = events.reduce((latest, e) => (e.at > latest ? e.at : latest), events[0].at);
|
|
223
|
+
this.emit(reviewId, "agent.read", "system", { through });
|
|
224
|
+
// A wait that returns at once never joined as a waiter, so nothing else marks the agent as here.
|
|
225
|
+
this.touchAgent();
|
|
226
|
+
}
|
|
227
|
+
readThrough(reviewId) {
|
|
228
|
+
const row = this.db
|
|
229
|
+
.prepare("SELECT json_extract(payload, '$.through') AS through FROM events WHERE review_id = ? AND type = 'agent.read' ORDER BY id DESC LIMIT 1")
|
|
230
|
+
.get(reviewId);
|
|
231
|
+
return row?.through ?? null;
|
|
232
|
+
}
|
|
233
|
+
presence() {
|
|
234
|
+
const recent = this.lastAgentActivity && Date.now() - Date.parse(this.lastAgentActivity) < 90_000;
|
|
235
|
+
return { agent: this.waiters > 0 ? "listening" : recent ? "working" : "away", lastAgentActivity: this.lastAgentActivity };
|
|
236
|
+
}
|
|
237
|
+
state(reviewId) {
|
|
238
|
+
const review = this.review(reviewId);
|
|
239
|
+
if (!review)
|
|
240
|
+
return null;
|
|
241
|
+
return {
|
|
242
|
+
review,
|
|
243
|
+
files: this.files(reviewId),
|
|
244
|
+
symbols: this.symbols(reviewId),
|
|
245
|
+
edges: this.edges(reviewId),
|
|
246
|
+
steps: this.steps(reviewId),
|
|
247
|
+
notes: this.notes(reviewId),
|
|
248
|
+
presence: this.presence(),
|
|
249
|
+
cursor: this.cursor(reviewId),
|
|
250
|
+
readThrough: this.readThrough(reviewId),
|
|
251
|
+
visited: this.getAppState(reviewId, "visited") ?? [],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
tx(fn) {
|
|
255
|
+
this.db.exec("BEGIN");
|
|
256
|
+
try {
|
|
257
|
+
fn();
|
|
258
|
+
this.db.exec("COMMIT");
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
this.db.exec("ROLLBACK");
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** A connection that structurally cannot write. Backs `downstream sql`. */
|
|
267
|
+
export function readOnly(file) {
|
|
268
|
+
return new DatabaseSync(file, { readOnly: true });
|
|
269
|
+
}
|