@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,212 @@
|
|
|
1
|
+
// A TypeScript language service over one side of the change. Used for call
|
|
2
|
+
// resolution, "who calls this", and IDE-grade hover in the review surface.
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { WORKTREE } from "./git.js";
|
|
7
|
+
import { extractDecls, isTsLike } from "./ts-decls.js";
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
allowJs: true,
|
|
10
|
+
checkJs: false,
|
|
11
|
+
jsx: ts.JsxEmit.Preserve,
|
|
12
|
+
target: ts.ScriptTarget.ESNext,
|
|
13
|
+
module: ts.ModuleKind.ESNext,
|
|
14
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
15
|
+
allowImportingTsExtensions: true,
|
|
16
|
+
resolveJsonModule: true,
|
|
17
|
+
esModuleInterop: true,
|
|
18
|
+
skipLibCheck: true,
|
|
19
|
+
noEmit: true,
|
|
20
|
+
strict: false,
|
|
21
|
+
};
|
|
22
|
+
const MAX_PROJECT_FILES = 4000;
|
|
23
|
+
/** Disk outside the reviewed side (lib, node_modules) doesn't change during a review; probe it once. */
|
|
24
|
+
const fsCache = {
|
|
25
|
+
files: new Map(),
|
|
26
|
+
dirs: new Map(),
|
|
27
|
+
subdirs: new Map(),
|
|
28
|
+
real: new Map(),
|
|
29
|
+
text: new Map(),
|
|
30
|
+
};
|
|
31
|
+
function memo(m, k, f) {
|
|
32
|
+
if (m.has(k))
|
|
33
|
+
return m.get(k);
|
|
34
|
+
const v = f();
|
|
35
|
+
m.set(k, v);
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
export function clearFsCache() {
|
|
39
|
+
for (const m of Object.values(fsCache))
|
|
40
|
+
m.clear();
|
|
41
|
+
}
|
|
42
|
+
export class TsProject {
|
|
43
|
+
service;
|
|
44
|
+
declCache = new Map();
|
|
45
|
+
files;
|
|
46
|
+
root;
|
|
47
|
+
source;
|
|
48
|
+
constructor(root, source, rootFiles, configPath, registry = ts.createDocumentRegistry()) {
|
|
49
|
+
this.root = root;
|
|
50
|
+
this.source = source;
|
|
51
|
+
let options = DEFAULTS;
|
|
52
|
+
let names = rootFiles.map((f) => this.abs(f));
|
|
53
|
+
if (configPath) {
|
|
54
|
+
const parsed = parseConfig(configPath, (p) => this.readAbs(p));
|
|
55
|
+
if (parsed) {
|
|
56
|
+
options = { ...DEFAULTS, ...parsed.options, noEmit: true, allowJs: true };
|
|
57
|
+
if (parsed.fileNames.length > 0 && parsed.fileNames.length <= MAX_PROJECT_FILES) {
|
|
58
|
+
names = [...new Set([...parsed.fileNames.map((f) => path.normalize(f)), ...names])];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
this.files = names;
|
|
63
|
+
const host = {
|
|
64
|
+
getScriptFileNames: () => this.files,
|
|
65
|
+
getScriptVersion: () => "1",
|
|
66
|
+
getScriptSnapshot: (f) => {
|
|
67
|
+
const text = this.readAbs(f);
|
|
68
|
+
return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
|
|
69
|
+
},
|
|
70
|
+
getCurrentDirectory: () => root,
|
|
71
|
+
getCompilationSettings: () => options,
|
|
72
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
73
|
+
fileExists: (f) => this.fileExists(f),
|
|
74
|
+
readFile: (f) => this.readAbs(f),
|
|
75
|
+
readDirectory: ts.sys.readDirectory,
|
|
76
|
+
directoryExists: (d) => memo(fsCache.dirs, d, () => ts.sys.directoryExists(d)),
|
|
77
|
+
getDirectories: (d) => memo(fsCache.subdirs, d, () => ts.sys.getDirectories(d)),
|
|
78
|
+
realpath: (f) => memo(fsCache.real, f, () => ts.sys.realpath?.(f) ?? f),
|
|
79
|
+
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
|
|
80
|
+
};
|
|
81
|
+
this.service = ts.createLanguageService(host, registry);
|
|
82
|
+
}
|
|
83
|
+
abs(rel) {
|
|
84
|
+
return path.normalize(path.join(this.root, rel));
|
|
85
|
+
}
|
|
86
|
+
rel(abs) {
|
|
87
|
+
const r = path.relative(this.root, path.normalize(abs)).split(path.sep).join("/");
|
|
88
|
+
if (r.startsWith("..") || path.isAbsolute(r) || r.includes("node_modules/"))
|
|
89
|
+
return null;
|
|
90
|
+
return r;
|
|
91
|
+
}
|
|
92
|
+
/** Repo files come from the reviewed side; everything else (lib, node_modules) from disk. */
|
|
93
|
+
fileExists(abs) {
|
|
94
|
+
const rel = this.rel(abs);
|
|
95
|
+
if (rel !== null && this.source.label !== WORKTREE)
|
|
96
|
+
return this.source.exists(rel) || memo(fsCache.files, abs, () => ts.sys.fileExists(abs));
|
|
97
|
+
return memo(fsCache.files, abs, () => ts.sys.fileExists(abs));
|
|
98
|
+
}
|
|
99
|
+
readAbs(abs) {
|
|
100
|
+
const rel = this.rel(abs);
|
|
101
|
+
if (rel !== null && this.source.label !== WORKTREE) {
|
|
102
|
+
const text = this.source.read(rel);
|
|
103
|
+
if (text !== null)
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
const load = () => {
|
|
107
|
+
try {
|
|
108
|
+
return existsSync(abs) ? readFileSync(abs, "utf8") : undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
// Repo files in the working tree can change mid-review (fixes); only cache the rest.
|
|
115
|
+
return rel === null ? memo(fsCache.text, abs, load) : load();
|
|
116
|
+
}
|
|
117
|
+
program() {
|
|
118
|
+
return this.service.getProgram();
|
|
119
|
+
}
|
|
120
|
+
sourceFile(rel) {
|
|
121
|
+
return this.program().getSourceFile(this.abs(rel));
|
|
122
|
+
}
|
|
123
|
+
/** Declarations for a repo file, extracted from the program's own AST so checker lookups line up. */
|
|
124
|
+
declsFor(rel) {
|
|
125
|
+
const hit = this.declCache.get(rel);
|
|
126
|
+
if (hit)
|
|
127
|
+
return hit;
|
|
128
|
+
const sf = this.sourceFile(rel);
|
|
129
|
+
if (!sf)
|
|
130
|
+
return null;
|
|
131
|
+
const entry = { sf, decls: extractDecls(sf) };
|
|
132
|
+
this.declCache.set(rel, entry);
|
|
133
|
+
return entry;
|
|
134
|
+
}
|
|
135
|
+
position(rel, line, col) {
|
|
136
|
+
const sf = this.sourceFile(rel);
|
|
137
|
+
if (!sf)
|
|
138
|
+
return null;
|
|
139
|
+
const lineCount = sf.getLineStarts().length;
|
|
140
|
+
if (line < 1 || line > lineCount)
|
|
141
|
+
return null;
|
|
142
|
+
const start = sf.getLineStarts()[line - 1];
|
|
143
|
+
const lineEnd = line < lineCount ? sf.getLineStarts()[line] - 1 : sf.text.length;
|
|
144
|
+
return Math.min(start + col, lineEnd);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function parseConfig(configPath, read) {
|
|
148
|
+
const raw = ts.readConfigFile(configPath, (p) => read(p));
|
|
149
|
+
if (raw.error)
|
|
150
|
+
return null;
|
|
151
|
+
return ts.parseJsonConfigFileContent(raw.config, ts.sys, path.dirname(configPath), undefined, configPath);
|
|
152
|
+
}
|
|
153
|
+
/** The tsconfig that actually includes a file: walk up, try the common names, honor `references`. */
|
|
154
|
+
export function findConfig(root, rel) {
|
|
155
|
+
const absFile = path.normalize(path.join(root, rel));
|
|
156
|
+
let dir = path.dirname(absFile);
|
|
157
|
+
const rootNorm = path.normalize(root);
|
|
158
|
+
while (dir.startsWith(rootNorm)) {
|
|
159
|
+
for (const name of ["tsconfig.json", "tsconfig.app.json", "jsconfig.json"]) {
|
|
160
|
+
const candidate = path.join(dir, name);
|
|
161
|
+
if (!existsSync(candidate))
|
|
162
|
+
continue;
|
|
163
|
+
const parsed = parseConfig(candidate, (p) => (existsSync(p) ? readFileSync(p, "utf8") : undefined));
|
|
164
|
+
if (!parsed)
|
|
165
|
+
continue;
|
|
166
|
+
if (parsed.fileNames.some((f) => path.normalize(f) === absFile))
|
|
167
|
+
return candidate;
|
|
168
|
+
for (const ref of parsed.projectReferences ?? []) {
|
|
169
|
+
const refPath = ref.path.endsWith(".json") ? ref.path : path.join(ref.path, "tsconfig.json");
|
|
170
|
+
const sub = parseConfig(refPath, (p) => (existsSync(p) ? readFileSync(p, "utf8") : undefined));
|
|
171
|
+
if (sub?.fileNames.some((f) => path.normalize(f) === absFile))
|
|
172
|
+
return refPath;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const up = path.dirname(dir);
|
|
176
|
+
if (up === dir)
|
|
177
|
+
break;
|
|
178
|
+
dir = up;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/** One project per tsconfig, so monorepos resolve each package with its own settings. */
|
|
183
|
+
export class TsProjects {
|
|
184
|
+
byConfig = new Map();
|
|
185
|
+
configOf = new Map();
|
|
186
|
+
/** Shared so packages in one repo parse node_modules types once. */
|
|
187
|
+
registry = ts.createDocumentRegistry();
|
|
188
|
+
root;
|
|
189
|
+
source;
|
|
190
|
+
seedFiles;
|
|
191
|
+
constructor(root, source, seedFiles) {
|
|
192
|
+
this.root = root;
|
|
193
|
+
this.source = source;
|
|
194
|
+
this.seedFiles = seedFiles;
|
|
195
|
+
}
|
|
196
|
+
for(rel) {
|
|
197
|
+
if (!isTsLike(rel))
|
|
198
|
+
return null;
|
|
199
|
+
let key = this.configOf.get(rel);
|
|
200
|
+
if (!key) {
|
|
201
|
+
key = findConfig(this.root, rel) ?? "<none>";
|
|
202
|
+
this.configOf.set(rel, key);
|
|
203
|
+
}
|
|
204
|
+
let project = this.byConfig.get(key);
|
|
205
|
+
if (!project) {
|
|
206
|
+
const seeds = this.seedFiles.filter((f) => isTsLike(f) && this.source.exists(f));
|
|
207
|
+
project = new TsProject(this.root, this.source, seeds.length ? seeds : [rel], key === "<none>" ? null : key, this.registry);
|
|
208
|
+
this.byConfig.set(key, project);
|
|
209
|
+
}
|
|
210
|
+
return project;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Every GitHub CLI call goes through here, so each one runs as the account it
|
|
2
|
+
// belongs to. People with a work and a personal account have both logged in to
|
|
3
|
+
// gh; the active one flips, so nothing may rely on it.
|
|
4
|
+
import { execFileSync, execFile } from "node:child_process";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
/**
|
|
8
|
+
* Always from a neutral directory, with the repository passed as `--repo`:
|
|
9
|
+
* directory-aware gh wrappers pick an account from the working directory and
|
|
10
|
+
* would override the token we set.
|
|
11
|
+
*/
|
|
12
|
+
const NEUTRAL = tmpdir();
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const tokens = new Map();
|
|
15
|
+
/** `gh auth token --user <login>`: the account's own token, whichever account is active. */
|
|
16
|
+
export function tokenFor(account) {
|
|
17
|
+
let token = tokens.get(account);
|
|
18
|
+
if (!token) {
|
|
19
|
+
token = execFileSync("gh", ["auth", "token", "--user", account], { cwd: NEUTRAL, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
20
|
+
tokens.set(account, token);
|
|
21
|
+
}
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
export function ghEnv(account) {
|
|
25
|
+
return account ? { ...process.env, GH_TOKEN: tokenFor(account) } : process.env;
|
|
26
|
+
}
|
|
27
|
+
export function gh(args, opts = {}) {
|
|
28
|
+
return execFileSync("gh", args, {
|
|
29
|
+
cwd: NEUTRAL,
|
|
30
|
+
env: ghEnv(opts.account ?? null),
|
|
31
|
+
input: opts.input,
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
stdio: [opts.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
34
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export async function ghAsync(args, opts = {}) {
|
|
38
|
+
const { stdout } = await run("gh", args, { cwd: NEUTRAL, env: ghEnv(opts.account ?? null), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
39
|
+
return stdout;
|
|
40
|
+
}
|
|
41
|
+
/** The github.com accounts gh is logged in to, active one first. */
|
|
42
|
+
export function accounts() {
|
|
43
|
+
try {
|
|
44
|
+
const out = JSON.parse(gh(["auth", "status", "--json", "hosts"]));
|
|
45
|
+
const list = (out.hosts["github.com"] ?? []).filter((a) => a.state === "success");
|
|
46
|
+
return [...list].sort((a, b) => Number(b.active) - Number(a.active)).map((a) => a.login);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The open pull requests an account is part of, read from GitHub in one round
|
|
2
|
+
// of searches and reduced to PrFacts, plus what the account itself has been
|
|
3
|
+
// doing lately (its event feed), which is the best read of what it's focused on.
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { PrFacts } from "../domain/inbox.js";
|
|
6
|
+
import { ghAsync } from "./gh.js";
|
|
7
|
+
const PR_FIELDS = `
|
|
8
|
+
number title url updatedAt isDraft
|
|
9
|
+
repository { nameWithOwner }
|
|
10
|
+
baseRefName headRefName headRefOid
|
|
11
|
+
reviewDecision mergeable
|
|
12
|
+
author { __typename login avatarUrl(size: 48) ... on User { name } }
|
|
13
|
+
stack { number size }
|
|
14
|
+
stackEntry { position }
|
|
15
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
|
16
|
+
reviewThreads(first: 100) { nodes { isResolved } }
|
|
17
|
+
latestReviews(first: 30) { nodes { author { login } state submittedAt } }
|
|
18
|
+
timelineItems(last: 40, itemTypes: [PULL_REQUEST_COMMIT, HEAD_REF_FORCE_PUSHED_EVENT, ISSUE_COMMENT, PULL_REQUEST_REVIEW]) { nodes {
|
|
19
|
+
__typename
|
|
20
|
+
... on PullRequestCommit { commit { committedDate } }
|
|
21
|
+
... on HeadRefForcePushedEvent { createdAt actor { login } }
|
|
22
|
+
... on IssueComment { createdAt author { login } }
|
|
23
|
+
... on PullRequestReview { submittedAt state author { login } }
|
|
24
|
+
} }
|
|
25
|
+
`;
|
|
26
|
+
const QUERY = `query($q: String!) { search(query: $q, type: ISSUE, first: 100) { nodes { ... on PullRequest { ${PR_FIELDS} } } } }`;
|
|
27
|
+
/**
|
|
28
|
+
* Direct review requests only (`user-review-requested`): team and CODEOWNERS
|
|
29
|
+
* requests are mostly noise, and an action list is only useful while it's short.
|
|
30
|
+
* `reviewed-by` catches PRs you reviewed that changed since.
|
|
31
|
+
*/
|
|
32
|
+
const SEARCHES = [
|
|
33
|
+
{ q: "is:pr is:open archived:false user-review-requested:@me", requested: true },
|
|
34
|
+
{ q: "is:pr is:open archived:false reviewed-by:@me -author:@me", requested: false },
|
|
35
|
+
{ q: "is:pr is:open archived:false author:@me", requested: false },
|
|
36
|
+
];
|
|
37
|
+
const Login = z.object({ login: z.string() });
|
|
38
|
+
const TimelineItem = z.discriminatedUnion("__typename", [
|
|
39
|
+
z.object({ __typename: z.literal("PullRequestCommit"), commit: z.object({ committedDate: z.string() }) }),
|
|
40
|
+
z.object({ __typename: z.literal("HeadRefForcePushedEvent"), createdAt: z.string(), actor: Login.nullable() }),
|
|
41
|
+
z.object({ __typename: z.literal("IssueComment"), createdAt: z.string(), author: Login.nullable() }),
|
|
42
|
+
z.object({ __typename: z.literal("PullRequestReview"), submittedAt: z.string().nullable(), state: z.string(), author: Login.nullable() }),
|
|
43
|
+
]);
|
|
44
|
+
const Node = z.object({
|
|
45
|
+
number: z.number(),
|
|
46
|
+
title: z.string(),
|
|
47
|
+
url: z.string(),
|
|
48
|
+
updatedAt: z.string(),
|
|
49
|
+
isDraft: z.boolean(),
|
|
50
|
+
repository: z.object({ nameWithOwner: z.string() }),
|
|
51
|
+
baseRefName: z.string(),
|
|
52
|
+
headRefName: z.string(),
|
|
53
|
+
headRefOid: z.string(),
|
|
54
|
+
reviewDecision: z.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]).nullable(),
|
|
55
|
+
mergeable: z.enum(["MERGEABLE", "CONFLICTING", "UNKNOWN"]),
|
|
56
|
+
author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string(), name: z.string().nullable().optional() }).nullable(),
|
|
57
|
+
stack: z.object({ number: z.number(), size: z.number() }).nullable().optional(),
|
|
58
|
+
stackEntry: z.object({ position: z.number() }).nullable().optional(),
|
|
59
|
+
commits: z.object({ nodes: z.array(z.object({ commit: z.object({ statusCheckRollup: z.object({ state: z.string() }).nullable() }) })) }),
|
|
60
|
+
reviewThreads: z.object({ nodes: z.array(z.object({ isResolved: z.boolean() })) }),
|
|
61
|
+
latestReviews: z.object({ nodes: z.array(z.object({ author: Login.nullable(), state: z.string(), submittedAt: z.string().nullable() })) }),
|
|
62
|
+
timelineItems: z.object({ nodes: z.array(TimelineItem) }),
|
|
63
|
+
});
|
|
64
|
+
const Response = z.object({ data: z.object({ search: z.object({ nodes: z.array(z.unknown()) }) }) });
|
|
65
|
+
const CHECKS = { SUCCESS: "passing", FAILURE: "failing", ERROR: "failing", PENDING: "pending", EXPECTED: "pending" };
|
|
66
|
+
const MERGEABLE = { MERGEABLE: "mergeable", CONFLICTING: "conflicting", UNKNOWN: "unknown" };
|
|
67
|
+
const DECISION = { APPROVED: "approved", CHANGES_REQUESTED: "changes_requested", REVIEW_REQUIRED: "review_required" };
|
|
68
|
+
function toFacts(node, account, requested) {
|
|
69
|
+
const me = account.toLowerCase();
|
|
70
|
+
const myReviewAt = lastReviewBy(node, me);
|
|
71
|
+
const rollup = node.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null;
|
|
72
|
+
return PrFacts.parse({
|
|
73
|
+
repo: node.repository.nameWithOwner,
|
|
74
|
+
number: node.number,
|
|
75
|
+
title: node.title,
|
|
76
|
+
url: node.url,
|
|
77
|
+
author: node.author?.login ?? "ghost",
|
|
78
|
+
authorKind: node.author?.__typename === "Bot" ? "bot" : "person",
|
|
79
|
+
authorName: node.author?.name || null,
|
|
80
|
+
authorAvatar: node.author?.avatarUrl ?? null,
|
|
81
|
+
touched: null,
|
|
82
|
+
isDraft: node.isDraft,
|
|
83
|
+
updatedAt: node.updatedAt,
|
|
84
|
+
baseRef: node.baseRefName,
|
|
85
|
+
headRef: node.headRefName,
|
|
86
|
+
headSha: node.headRefOid,
|
|
87
|
+
reviewDecision: node.reviewDecision ? DECISION[node.reviewDecision] : null,
|
|
88
|
+
checks: rollup ? (CHECKS[rollup] ?? "pending") : "none",
|
|
89
|
+
mergeable: MERGEABLE[node.mergeable],
|
|
90
|
+
unresolvedThreads: node.reviewThreads.nodes.filter((t) => !t.isResolved).length,
|
|
91
|
+
role: node.author?.login.toLowerCase() === me ? "author" : "reviewer",
|
|
92
|
+
reviewRequested: requested,
|
|
93
|
+
myReviewAt,
|
|
94
|
+
authorSince: myReviewAt ? authorActAfter(node, myReviewAt) : null,
|
|
95
|
+
stack: node.stack && node.stackEntry ? { number: node.stack.number, size: node.stack.size, position: node.stackEntry.position } : null,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* When `me` last reviewed. `latestReviews` drops dismissed reviews, and repos
|
|
100
|
+
* that dismiss stale approvals on push would make a reviewed PR look unread, so
|
|
101
|
+
* the timeline's reviews count too.
|
|
102
|
+
*/
|
|
103
|
+
function lastReviewBy(node, me) {
|
|
104
|
+
const latest = node.latestReviews.nodes.filter((r) => r.author?.login.toLowerCase() === me && r.state !== "PENDING").map((r) => r.submittedAt);
|
|
105
|
+
const timeline = node.timelineItems.nodes.map((item) => item.__typename === "PullRequestReview" && item.author?.login.toLowerCase() === me && item.state !== "PENDING" ? item.submittedAt : null);
|
|
106
|
+
return [...latest, ...timeline].filter((at) => at !== null).sort().at(-1) ?? null;
|
|
107
|
+
}
|
|
108
|
+
const isAuthor = (node, who) => !!who && who.login === node.author?.login;
|
|
109
|
+
/**
|
|
110
|
+
* The author's latest push or reply after `after`. A force-push event carries
|
|
111
|
+
* the push time; a plain push only shows as commits, whose committed date is the
|
|
112
|
+
* best GraphQL offers, so a commit made before your review and pushed after it
|
|
113
|
+
* is missed.
|
|
114
|
+
*/
|
|
115
|
+
function authorActAfter(node, after) {
|
|
116
|
+
const acts = node.timelineItems.nodes.flatMap((item) => {
|
|
117
|
+
switch (item.__typename) {
|
|
118
|
+
case "PullRequestCommit":
|
|
119
|
+
return [{ how: "pushed", at: item.commit.committedDate }];
|
|
120
|
+
case "HeadRefForcePushedEvent":
|
|
121
|
+
return isAuthor(node, item.actor) ? [{ how: "pushed", at: item.createdAt }] : [];
|
|
122
|
+
case "IssueComment":
|
|
123
|
+
return isAuthor(node, item.author) ? [{ how: "replied", at: item.createdAt }] : [];
|
|
124
|
+
case "PullRequestReview":
|
|
125
|
+
return isAuthor(node, item.author) && item.submittedAt ? [{ how: "replied", at: item.submittedAt }] : [];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return acts.filter((a) => Date.parse(a.at) > Date.parse(after)).sort((a, b) => b.at.localeCompare(a.at))[0] ?? null;
|
|
129
|
+
}
|
|
130
|
+
export async function fetchPrs(account) {
|
|
131
|
+
const results = await Promise.all(SEARCHES.map(async ({ q, requested }) => {
|
|
132
|
+
const out = await once(() => ghAsync(["api", "graphql", "-f", `q=${q}`, "-f", `query=${QUERY}`], { account }));
|
|
133
|
+
return Response.parse(JSON.parse(out))
|
|
134
|
+
.data.search.nodes.filter((n) => n && typeof n === "object" && "number" in n)
|
|
135
|
+
.map((n) => toFacts(Node.parse(n), account, requested));
|
|
136
|
+
}));
|
|
137
|
+
return results.flat();
|
|
138
|
+
}
|
|
139
|
+
/** GitHub search fails now and then for no reason it reports; one more try clears nearly all of them. */
|
|
140
|
+
async function once(run) {
|
|
141
|
+
try {
|
|
142
|
+
return await run();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return run();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const Event = z.object({
|
|
149
|
+
type: z.string(),
|
|
150
|
+
created_at: z.string(),
|
|
151
|
+
repo: z.object({ name: z.string() }),
|
|
152
|
+
payload: z.object({
|
|
153
|
+
ref: z.string().nullable().optional(),
|
|
154
|
+
pull_request: z.object({ number: z.number() }).optional(),
|
|
155
|
+
issue: z.object({ number: z.number(), pull_request: z.unknown().optional() }).optional(),
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
const TOUCH_HOW = {
|
|
159
|
+
PullRequestReviewEvent: "reviewed",
|
|
160
|
+
PullRequestReviewCommentEvent: "commented",
|
|
161
|
+
IssueCommentEvent: "commented",
|
|
162
|
+
PullRequestEvent: "opened",
|
|
163
|
+
PushEvent: "pushed",
|
|
164
|
+
};
|
|
165
|
+
/** What the account did on GitHub in roughly the last week: up to 300 events, newest first. */
|
|
166
|
+
export async function fetchTouches(account) {
|
|
167
|
+
const out = await once(() => ghAsync(["api", `/users/${account}/events?per_page=100`, "--paginate", "--slurp"], { account }));
|
|
168
|
+
const events = z.array(z.array(Event)).parse(JSON.parse(out)).flat();
|
|
169
|
+
const touches = events.flatMap((e) => {
|
|
170
|
+
const how = TOUCH_HOW[e.type];
|
|
171
|
+
if (!how)
|
|
172
|
+
return [];
|
|
173
|
+
const number = e.payload.pull_request?.number ?? (e.payload.issue?.pull_request ? e.payload.issue.number : undefined);
|
|
174
|
+
const branch = e.type === "PushEvent" ? e.payload.ref?.replace(/^refs\/heads\//, "") : undefined;
|
|
175
|
+
if (number === undefined && !branch)
|
|
176
|
+
return [];
|
|
177
|
+
return [{ repo: e.repo.name, number: number ?? null, branch: branch ?? null, at: e.created_at, how, author: null }];
|
|
178
|
+
});
|
|
179
|
+
return withAuthors(touches, account);
|
|
180
|
+
}
|
|
181
|
+
/** The event feed leaves out whose PR it was; one query fills that in, so "who you're working with" includes merged PRs. */
|
|
182
|
+
async function withAuthors(touches, account) {
|
|
183
|
+
const prs = [...new Map(touches.filter((t) => t.number !== null).map((t) => [`${t.repo}#${t.number}`, t])).values()].slice(0, 60);
|
|
184
|
+
if (prs.length === 0)
|
|
185
|
+
return touches;
|
|
186
|
+
const fields = prs.map((t, i) => {
|
|
187
|
+
const [owner, name] = t.repo.split("/");
|
|
188
|
+
return `p${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { pullRequest(number: ${t.number}) { author { login } } }`;
|
|
189
|
+
});
|
|
190
|
+
const out = await ghAsync(["api", "graphql", "-f", `query=query { ${fields.join("\n")} }`], { account }).catch(() => null);
|
|
191
|
+
if (!out)
|
|
192
|
+
return touches;
|
|
193
|
+
const data = JSON.parse(out).data ?? {};
|
|
194
|
+
const authors = new Map(prs.map((t, i) => [`${t.repo}#${t.number}`, data[`p${i}`]?.pullRequest?.author?.login ?? null]));
|
|
195
|
+
return touches.map((t) => (t.number === null ? t : { ...t, author: authors.get(`${t.repo}#${t.number}`) ?? null }));
|
|
196
|
+
}
|
|
197
|
+
/** The account's avatar, for the account menu. */
|
|
198
|
+
export async function fetchAvatar(account) {
|
|
199
|
+
const out = await ghAsync(["api", "user", "--jq", ".avatar_url"], { account }).catch(() => "");
|
|
200
|
+
return out.trim() || null;
|
|
201
|
+
}
|
|
202
|
+
const PERSON = "login avatarUrl(size: 40) ... on User { name }";
|
|
203
|
+
const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) {
|
|
204
|
+
body additions deletions changedFiles createdAt
|
|
205
|
+
commitCount: commits { totalCount }
|
|
206
|
+
files(first: 100) { nodes { path additions deletions changeType } }
|
|
207
|
+
latestReviews(first: 30) { nodes { author { ${PERSON} } state } }
|
|
208
|
+
reviewRequests(first: 30) { nodes { requestedReviewer { __typename ... on User { login name avatarUrl(size: 40) } ... on Bot { login avatarUrl(size: 40) } ... on Team { name } } } }
|
|
209
|
+
lastCommit: commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name conclusion status } ... on StatusContext { context state } } } } } } }
|
|
210
|
+
} } }`;
|
|
211
|
+
const Author = z.object({ login: z.string(), avatarUrl: z.string().optional(), name: z.string().nullable().optional() });
|
|
212
|
+
const Detail = z.object({
|
|
213
|
+
body: z.string(),
|
|
214
|
+
additions: z.number(),
|
|
215
|
+
deletions: z.number(),
|
|
216
|
+
changedFiles: z.number(),
|
|
217
|
+
createdAt: z.string(),
|
|
218
|
+
commitCount: z.object({ totalCount: z.number() }),
|
|
219
|
+
files: z.object({ nodes: z.array(z.object({ path: z.string(), additions: z.number(), deletions: z.number(), changeType: z.string() })) }).nullable(),
|
|
220
|
+
latestReviews: z.object({ nodes: z.array(z.object({ author: Author.nullable(), state: z.string() })) }),
|
|
221
|
+
reviewRequests: z.object({ nodes: z.array(z.object({ requestedReviewer: z.object({ __typename: z.string(), login: z.string().optional(), name: z.string().nullable().optional(), avatarUrl: z.string().optional() }).nullable() })) }).nullable(),
|
|
222
|
+
lastCommit: z.object({ nodes: z.array(z.object({ commit: z.object({ statusCheckRollup: z.object({ contexts: z.object({ nodes: z.array(z.object({ __typename: z.string(), name: z.string().optional(), conclusion: z.string().nullable().optional(), status: z.string().optional(), context: z.string().optional(), state: z.string().optional() })) }) }).nullable() }) })) }),
|
|
223
|
+
});
|
|
224
|
+
const REVIEW_STATE = { APPROVED: "approved", CHANGES_REQUESTED: "changes_requested", COMMENTED: "commented", DISMISSED: "dismissed" };
|
|
225
|
+
const CHANGE = { ADDED: "added", DELETED: "deleted", MODIFIED: "modified", RENAMED: "renamed", COPIED: "copied" };
|
|
226
|
+
const FAILED = new Set(["FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "ERROR"]);
|
|
227
|
+
const PASSED = new Set(["SUCCESS", "NEUTRAL"]);
|
|
228
|
+
/** One PR in full, for the preview. */
|
|
229
|
+
export async function fetchDetail(account, repo, number) {
|
|
230
|
+
const [owner, name] = repo.split("/");
|
|
231
|
+
const out = await once(() => ghAsync(["api", "graphql", "-F", `owner=${owner}`, "-F", `name=${name}`, "-F", `number=${number}`, "-f", `query=${DETAIL_QUERY}`], { account }));
|
|
232
|
+
const pr = Detail.parse(JSON.parse(out).data.repository.pullRequest);
|
|
233
|
+
const checks = { passing: 0, failing: [], pending: [] };
|
|
234
|
+
for (const c of pr.lastCommit.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []) {
|
|
235
|
+
const label = c.name ?? c.context ?? "check";
|
|
236
|
+
const outcome = c.__typename === "CheckRun" ? (c.status === "COMPLETED" ? (c.conclusion ?? "") : "PENDING") : (c.state ?? "");
|
|
237
|
+
if (FAILED.has(outcome))
|
|
238
|
+
checks.failing.push(label);
|
|
239
|
+
else if (PASSED.has(outcome))
|
|
240
|
+
checks.passing++;
|
|
241
|
+
else if (outcome === "PENDING" || outcome === "EXPECTED")
|
|
242
|
+
checks.pending.push(label);
|
|
243
|
+
}
|
|
244
|
+
const requested = (pr.reviewRequests?.nodes ?? []).flatMap((r) => {
|
|
245
|
+
const who = r.requestedReviewer;
|
|
246
|
+
if (!who)
|
|
247
|
+
return [];
|
|
248
|
+
if (who.__typename === "Team")
|
|
249
|
+
return [{ kind: "team", name: who.name ?? "team" }];
|
|
250
|
+
return [{ kind: "person", login: who.login ?? "ghost", name: who.name ?? null, avatar: who.avatarUrl ?? null }];
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
body: pr.body,
|
|
254
|
+
createdAt: pr.createdAt,
|
|
255
|
+
additions: pr.additions,
|
|
256
|
+
deletions: pr.deletions,
|
|
257
|
+
changedFiles: pr.changedFiles,
|
|
258
|
+
commits: pr.commitCount.totalCount,
|
|
259
|
+
files: (pr.files?.nodes ?? []).map((f) => ({ path: f.path, additions: f.additions, deletions: f.deletions, change: CHANGE[f.changeType] ?? "changed" })),
|
|
260
|
+
reviews: pr.latestReviews.nodes.flatMap((r) => (r.author && REVIEW_STATE[r.state] ? [{ login: r.author.login, name: r.author.name ?? null, avatar: r.author.avatarUrl ?? null, state: REVIEW_STATE[r.state] }] : [])),
|
|
261
|
+
requested,
|
|
262
|
+
checks,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// What Downstream remembers across repositories: which repos it knows and where
|
|
2
|
+
// they're cloned, which account you're looking through, where you moved PRs in
|
|
3
|
+
// the matrix, and the agent sessions it started for you.
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { Quadrant } from "../domain/inbox.js";
|
|
10
|
+
import { now } from "./store.js";
|
|
11
|
+
/** Everything global lives here; `DOWNSTREAM_HOME` moves it (tests, a second install). */
|
|
12
|
+
export function downstreamHome() {
|
|
13
|
+
const dir = process.env.DOWNSTREAM_HOME ?? path.join(homedir(), ".downstream");
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
const PLACEMENTS = [...Quadrant.options, "hidden"];
|
|
18
|
+
const TRIAGE = (table) => `CREATE TABLE IF NOT EXISTS ${table} (account TEXT NOT NULL, item_key TEXT NOT NULL, quadrant TEXT NOT NULL CHECK (quadrant IN (${PLACEMENTS.map((p) => `'${p}'`).join(", ")})), at TEXT NOT NULL, PRIMARY KEY (account, item_key));`;
|
|
19
|
+
const SCHEMA = `
|
|
20
|
+
CREATE TABLE IF NOT EXISTS repos (id TEXT PRIMARY KEY, root TEXT NOT NULL UNIQUE, name_with_owner TEXT, seen_at TEXT NOT NULL);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
22
|
+
${TRIAGE("triage")}
|
|
23
|
+
CREATE TABLE IF NOT EXISTS launches (account TEXT NOT NULL, repo TEXT NOT NULL, pr INTEGER NOT NULL, root TEXT NOT NULL, session_id TEXT, at TEXT NOT NULL, PRIMARY KEY (repo, pr));
|
|
24
|
+
`;
|
|
25
|
+
export class HomeStore {
|
|
26
|
+
db;
|
|
27
|
+
/** "changed" whenever anything the home page shows may differ. */
|
|
28
|
+
bus = new EventEmitter();
|
|
29
|
+
constructor(file = path.join(downstreamHome(), "home.db")) {
|
|
30
|
+
this.db = new DatabaseSync(file);
|
|
31
|
+
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
32
|
+
this.db.exec(SCHEMA);
|
|
33
|
+
this.renameQuadrants();
|
|
34
|
+
this.allowEveryPlacement();
|
|
35
|
+
this.bus.setMaxListeners(100);
|
|
36
|
+
}
|
|
37
|
+
changed() {
|
|
38
|
+
this.bus.emit("changed");
|
|
39
|
+
}
|
|
40
|
+
// -- repositories ---------------------------------------------------------
|
|
41
|
+
repos() {
|
|
42
|
+
return this.db.prepare("SELECT id, root, name_with_owner FROM repos ORDER BY seen_at DESC").all().map((r) => ({ id: r.id, root: r.root, nameWithOwner: r.name_with_owner }));
|
|
43
|
+
}
|
|
44
|
+
putRepo(row) {
|
|
45
|
+
this.db
|
|
46
|
+
.prepare("INSERT INTO repos (id, root, name_with_owner, seen_at) VALUES (?, ?, ?, ?) ON CONFLICT(root) DO UPDATE SET name_with_owner = COALESCE(excluded.name_with_owner, repos.name_with_owner), seen_at = excluded.seen_at")
|
|
47
|
+
.run(row.id, row.root, row.nameWithOwner, now());
|
|
48
|
+
}
|
|
49
|
+
rootFor(nameWithOwner) {
|
|
50
|
+
const row = this.db.prepare("SELECT root FROM repos WHERE lower(name_with_owner) = lower(?) ORDER BY seen_at DESC LIMIT 1").get(nameWithOwner);
|
|
51
|
+
return row?.root ?? null;
|
|
52
|
+
}
|
|
53
|
+
// -- the account you look through ------------------------------------------------
|
|
54
|
+
account() {
|
|
55
|
+
const row = this.db.prepare("SELECT value FROM settings WHERE key = 'account'").get();
|
|
56
|
+
return row?.value ?? null;
|
|
57
|
+
}
|
|
58
|
+
setAccount(login) {
|
|
59
|
+
this.db.prepare("INSERT INTO settings (key, value) VALUES ('account', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(login);
|
|
60
|
+
this.changed();
|
|
61
|
+
}
|
|
62
|
+
// -- where you moved things in the matrix ------------------------------------------
|
|
63
|
+
choices(account) {
|
|
64
|
+
const rows = this.db.prepare("SELECT item_key, quadrant, at FROM triage WHERE account = ?").all(account);
|
|
65
|
+
return Object.fromEntries(rows.map((r) => [r.item_key, { quadrant: r.quadrant, at: r.at }]));
|
|
66
|
+
}
|
|
67
|
+
/** Choices made before Schedule and Later became Next and Someday keep their meaning. */
|
|
68
|
+
renameQuadrants() {
|
|
69
|
+
const old = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'choices'").get();
|
|
70
|
+
if (!old)
|
|
71
|
+
return;
|
|
72
|
+
this.tx(() => {
|
|
73
|
+
this.db.exec(`INSERT OR IGNORE INTO triage (account, item_key, quadrant, at)
|
|
74
|
+
SELECT account, item_key, CASE quadrant WHEN 'schedule' THEN 'next' WHEN 'later' THEN 'someday' ELSE quadrant END, at FROM choices`);
|
|
75
|
+
this.db.exec("DROP TABLE choices");
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* SQLite can't change a CHECK in place: a triage table made before a placement
|
|
80
|
+
* existed ("hidden") is rebuilt with today's list, keeping every choice.
|
|
81
|
+
*/
|
|
82
|
+
allowEveryPlacement() {
|
|
83
|
+
const row = this.db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'triage'").get();
|
|
84
|
+
if (!row || PLACEMENTS.every((p) => row.sql.includes(`'${p}'`)))
|
|
85
|
+
return;
|
|
86
|
+
this.tx(() => {
|
|
87
|
+
this.db.exec(TRIAGE("triage_next"));
|
|
88
|
+
this.db.exec("INSERT INTO triage_next (account, item_key, quadrant, at) SELECT account, item_key, quadrant, at FROM triage");
|
|
89
|
+
this.db.exec("DROP TABLE triage");
|
|
90
|
+
this.db.exec("ALTER TABLE triage_next RENAME TO triage");
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** `null` hands the item back to Downstream's read; "hidden" hides it from every list until it changes. */
|
|
94
|
+
choose(account, key, quadrant) {
|
|
95
|
+
if (quadrant === null)
|
|
96
|
+
this.db.prepare("DELETE FROM triage WHERE account = ? AND item_key = ?").run(account, key);
|
|
97
|
+
else
|
|
98
|
+
this.db.prepare("INSERT INTO triage (account, item_key, quadrant, at) VALUES (?, ?, ?, ?) ON CONFLICT (account, item_key) DO UPDATE SET quadrant = excluded.quadrant, at = excluded.at").run(account, key, quadrant, now());
|
|
99
|
+
this.changed();
|
|
100
|
+
}
|
|
101
|
+
// -- sessions Downstream started ----------------------------------------------------
|
|
102
|
+
launch(repo, pr) {
|
|
103
|
+
const row = this.db.prepare("SELECT account, repo, pr, root, session_id, at FROM launches WHERE lower(repo) = lower(?) AND pr = ?").get(repo, pr);
|
|
104
|
+
return row ? { account: row.account, repo: row.repo, pr: row.pr, root: row.root, sessionId: row.session_id, at: row.at } : null;
|
|
105
|
+
}
|
|
106
|
+
putLaunch(l) {
|
|
107
|
+
this.db
|
|
108
|
+
.prepare("INSERT INTO launches (account, repo, pr, root, session_id, at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(repo, pr) DO UPDATE SET account = excluded.account, root = excluded.root, session_id = COALESCE(excluded.session_id, launches.session_id), at = excluded.at")
|
|
109
|
+
.run(l.account, l.repo, l.pr, l.root, l.sessionId, now());
|
|
110
|
+
}
|
|
111
|
+
/** The account a PR's session was started with, so its gh calls (and the review it sends) use the same one. */
|
|
112
|
+
accountFor(root, pr) {
|
|
113
|
+
const row = this.db.prepare("SELECT account FROM launches WHERE root = ? AND pr = ?").get(root, pr);
|
|
114
|
+
return row?.account ?? null;
|
|
115
|
+
}
|
|
116
|
+
tx(fn) {
|
|
117
|
+
this.db.exec("BEGIN");
|
|
118
|
+
try {
|
|
119
|
+
fn();
|
|
120
|
+
this.db.exec("COMMIT");
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
this.db.exec("ROLLBACK");
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|