relay-ide 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/README.md +259 -0
- package/dist/bin/claude-remote-cli.js +390 -0
- package/dist/bin/relay-ide.js +390 -0
- package/dist/frontend/assets/abap-BdImnpbu.js +1 -0
- package/dist/frontend/assets/actionscript-3-CoDkCxhg.js +1 -0
- package/dist/frontend/assets/ada-bCR0ucgS.js +1 -0
- package/dist/frontend/assets/andromeeda-C4gqWexZ.js +1 -0
- package/dist/frontend/assets/angular-html-DA-rfuFy.js +1 -0
- package/dist/frontend/assets/angular-ts-BrjP3tb8.js +1 -0
- package/dist/frontend/assets/apache-Pmp26Uib.js +1 -0
- package/dist/frontend/assets/apex-D8_7TLub.js +1 -0
- package/dist/frontend/assets/apl-CORt7UWP.js +1 -0
- package/dist/frontend/assets/applescript-Co6uUVPk.js +1 -0
- package/dist/frontend/assets/ara-BRHolxvo.js +1 -0
- package/dist/frontend/assets/asciidoc-Ve4PFQV2.js +1 -0
- package/dist/frontend/assets/asm-D_Q5rh1f.js +1 -0
- package/dist/frontend/assets/astro-HNnZUWAn.js +1 -0
- package/dist/frontend/assets/aurora-x-D-2ljcwZ.js +1 -0
- package/dist/frontend/assets/awk-DMzUqQB5.js +1 -0
- package/dist/frontend/assets/ayu-dark-DYE7WIF3.js +1 -0
- package/dist/frontend/assets/ayu-light-BA47KaF1.js +1 -0
- package/dist/frontend/assets/ayu-mirage-32ctXXKs.js +1 -0
- package/dist/frontend/assets/ballerina-BFfxhgS-.js +1 -0
- package/dist/frontend/assets/bat-BkioyH1T.js +1 -0
- package/dist/frontend/assets/beancount-k_qm7-4y.js +1 -0
- package/dist/frontend/assets/berry-uYugtg8r.js +1 -0
- package/dist/frontend/assets/bibtex-CHM0blh-.js +1 -0
- package/dist/frontend/assets/bicep-Bmn6On1c.js +1 -0
- package/dist/frontend/assets/bird2-BIv1doCn.js +1 -0
- package/dist/frontend/assets/blade-BjGOyj-B.js +1 -0
- package/dist/frontend/assets/bsl-BO_Y6i37.js +1 -0
- package/dist/frontend/assets/c-BIGW1oBm.js +1 -0
- package/dist/frontend/assets/c3-eo99z4R2.js +1 -0
- package/dist/frontend/assets/cadence-Bv_4Rxtq.js +1 -0
- package/dist/frontend/assets/cairo-KRGpt6FW.js +1 -0
- package/dist/frontend/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- package/dist/frontend/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- package/dist/frontend/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- package/dist/frontend/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- package/dist/frontend/assets/clarity-D53aC0YG.js +1 -0
- package/dist/frontend/assets/clojure-P80f7IUj.js +1 -0
- package/dist/frontend/assets/cmake-D1j8_8rp.js +1 -0
- package/dist/frontend/assets/cobol-nBiQ_Alo.js +1 -0
- package/dist/frontend/assets/codeowners-Bp6g37R7.js +1 -0
- package/dist/frontend/assets/codeql-DsOJ9woJ.js +1 -0
- package/dist/frontend/assets/coffee-Ch7k5sss.js +1 -0
- package/dist/frontend/assets/common-lisp-Cg-RD9OK.js +1 -0
- package/dist/frontend/assets/coq-DkFqJrB1.js +1 -0
- package/dist/frontend/assets/cpp-CofmeUqb.js +1 -0
- package/dist/frontend/assets/crystal-DNxU26gB.js +1 -0
- package/dist/frontend/assets/csharp-COcwbKMJ.js +1 -0
- package/dist/frontend/assets/css-CLj8gQPS.js +1 -0
- package/dist/frontend/assets/csv-fuZLfV_i.js +1 -0
- package/dist/frontend/assets/cue-D82EKSYY.js +1 -0
- package/dist/frontend/assets/cypher-COkxafJQ.js +1 -0
- package/dist/frontend/assets/d-85-TOEBH.js +1 -0
- package/dist/frontend/assets/dark-plus-C3mMm8J8.js +1 -0
- package/dist/frontend/assets/dart-bE4Kk8sk.js +1 -0
- package/dist/frontend/assets/dax-CEL-wOlO.js +1 -0
- package/dist/frontend/assets/desktop-BmXAJ9_W.js +1 -0
- package/dist/frontend/assets/diff-D97Zzqfu.js +1 -0
- package/dist/frontend/assets/docker-BcOcwvcX.js +1 -0
- package/dist/frontend/assets/dotenv-Da5cRb03.js +1 -0
- package/dist/frontend/assets/dracula-BzJJZx-M.js +1 -0
- package/dist/frontend/assets/dracula-soft-BXkSAIEj.js +1 -0
- package/dist/frontend/assets/dream-maker-BtqSS_iP.js +1 -0
- package/dist/frontend/assets/edge-FbVlp4U3.js +1 -0
- package/dist/frontend/assets/elixir-CkH2-t6x.js +1 -0
- package/dist/frontend/assets/elm-DbKCFpqz.js +1 -0
- package/dist/frontend/assets/emacs-lisp-CXvaQtF9.js +1 -0
- package/dist/frontend/assets/erb-BYCe7drp.js +1 -0
- package/dist/frontend/assets/erlang-DsQrWhSR.js +1 -0
- package/dist/frontend/assets/everforest-dark-BgDCqdQA.js +1 -0
- package/dist/frontend/assets/everforest-light-C8M2exoo.js +1 -0
- package/dist/frontend/assets/fennel-BYunw83y.js +1 -0
- package/dist/frontend/assets/fish-BvzEVeQv.js +1 -0
- package/dist/frontend/assets/fluent-C4IJs8-o.js +1 -0
- package/dist/frontend/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- package/dist/frontend/assets/fortran-free-form-BxgE0vQu.js +1 -0
- package/dist/frontend/assets/fsharp-CXgrBDvD.js +1 -0
- package/dist/frontend/assets/gdresource-BOOCDP_w.js +1 -0
- package/dist/frontend/assets/gdscript-C5YyOfLZ.js +1 -0
- package/dist/frontend/assets/gdshader-DkwncUOv.js +1 -0
- package/dist/frontend/assets/genie-D0YGMca9.js +1 -0
- package/dist/frontend/assets/gherkin-DyxjwDmM.js +1 -0
- package/dist/frontend/assets/git-commit-F4YmCXRG.js +1 -0
- package/dist/frontend/assets/git-rebase-r7XF79zn.js +1 -0
- package/dist/frontend/assets/github-dark-DHJKELXO.js +1 -0
- package/dist/frontend/assets/github-dark-default-Cuk6v7N8.js +1 -0
- package/dist/frontend/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- package/dist/frontend/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- package/dist/frontend/assets/github-light-DAi9KRSo.js +1 -0
- package/dist/frontend/assets/github-light-default-D7oLnXFd.js +1 -0
- package/dist/frontend/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- package/dist/frontend/assets/gleam-BspZqrRM.js +1 -0
- package/dist/frontend/assets/glimmer-js-ByusRIyA.js +1 -0
- package/dist/frontend/assets/glimmer-ts-BfAWNZQY.js +1 -0
- package/dist/frontend/assets/glsl-DplSGwfg.js +1 -0
- package/dist/frontend/assets/gn-n2N0HUVH.js +1 -0
- package/dist/frontend/assets/gnuplot-DdkO51Og.js +1 -0
- package/dist/frontend/assets/go-C27-OAKa.js +1 -0
- package/dist/frontend/assets/graphql-ChdNCCLP.js +1 -0
- package/dist/frontend/assets/groovy-gcz8RCvz.js +1 -0
- package/dist/frontend/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- package/dist/frontend/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- package/dist/frontend/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- package/dist/frontend/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- package/dist/frontend/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- package/dist/frontend/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- package/dist/frontend/assets/hack-i7_Ulhet.js +1 -0
- package/dist/frontend/assets/haml-D5jkg6IW.js +1 -0
- package/dist/frontend/assets/handlebars-BpdQsYii.js +1 -0
- package/dist/frontend/assets/haskell-Df6bDoY_.js +1 -0
- package/dist/frontend/assets/haxe-CzTSHFRz.js +1 -0
- package/dist/frontend/assets/hcl-BWvSN4gD.js +1 -0
- package/dist/frontend/assets/hjson-D5-asLiD.js +1 -0
- package/dist/frontend/assets/hlsl-D3lLCCz7.js +1 -0
- package/dist/frontend/assets/horizon-BUw7H-hv.js +1 -0
- package/dist/frontend/assets/horizon-bright-CUuTKBJd.js +1 -0
- package/dist/frontend/assets/houston-DnULxvSX.js +1 -0
- package/dist/frontend/assets/html-derivative-DlHx6ybY.js +1 -0
- package/dist/frontend/assets/html-pp8916En.js +1 -0
- package/dist/frontend/assets/http-jrhK8wxY.js +1 -0
- package/dist/frontend/assets/hurl-irOxFIW8.js +1 -0
- package/dist/frontend/assets/hxml-Bvhsp5Yf.js +1 -0
- package/dist/frontend/assets/hy-DFXneXwc.js +1 -0
- package/dist/frontend/assets/imba-DGztddWO.js +1 -0
- package/dist/frontend/assets/ini-BEwlwnbL.js +1 -0
- package/dist/frontend/assets/java-CylS5w8V.js +1 -0
- package/dist/frontend/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/frontend/assets/jinja-f2NsQr07.js +1 -0
- package/dist/frontend/assets/jison-wvAkD_A8.js +1 -0
- package/dist/frontend/assets/json-Cp-IABpG.js +1 -0
- package/dist/frontend/assets/json5-C9tS-k6U.js +1 -0
- package/dist/frontend/assets/jsonc-Des-eS-w.js +1 -0
- package/dist/frontend/assets/jsonl-DcaNXYhu.js +1 -0
- package/dist/frontend/assets/jsonnet-DFQXde-d.js +1 -0
- package/dist/frontend/assets/jssm-C2t-YnRu.js +1 -0
- package/dist/frontend/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/frontend/assets/julia-CxzCAyBv.js +1 -0
- package/dist/frontend/assets/just-VxiPbLrw.js +1 -0
- package/dist/frontend/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- package/dist/frontend/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- package/dist/frontend/assets/kanagawa-wave-DWedfzmr.js +1 -0
- package/dist/frontend/assets/kdl-DV7GczEv.js +1 -0
- package/dist/frontend/assets/kotlin-BdnUsdx6.js +1 -0
- package/dist/frontend/assets/kusto-wEQ09or8.js +1 -0
- package/dist/frontend/assets/laserwave-DUszq2jm.js +1 -0
- package/dist/frontend/assets/latex-CWtU0Tv5.js +1 -0
- package/dist/frontend/assets/lean-BZvkOJ9d.js +1 -0
- package/dist/frontend/assets/less-B1dDrJ26.js +1 -0
- package/dist/frontend/assets/light-plus-B7mTdjB0.js +1 -0
- package/dist/frontend/assets/liquid-C0sCDyMI.js +1 -0
- package/dist/frontend/assets/llvm-DjAJT7YJ.js +1 -0
- package/dist/frontend/assets/log-2UxHyX5q.js +1 -0
- package/dist/frontend/assets/logo-BtOb2qkB.js +1 -0
- package/dist/frontend/assets/lua-BaeVxFsk.js +1 -0
- package/dist/frontend/assets/luau-C-HG3fhB.js +1 -0
- package/dist/frontend/assets/main-CL5_Wlhv.css +32 -0
- package/dist/frontend/assets/main-Czet4Z1x.js +371 -0
- package/dist/frontend/assets/make-CHLpvVh8.js +1 -0
- package/dist/frontend/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/frontend/assets/marko-DjSrsDqO.js +1 -0
- package/dist/frontend/assets/material-theme-D5KoaKCx.js +1 -0
- package/dist/frontend/assets/material-theme-darker-BfHTSMKl.js +1 -0
- package/dist/frontend/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- package/dist/frontend/assets/material-theme-ocean-CyktbL80.js +1 -0
- package/dist/frontend/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- package/dist/frontend/assets/matlab-D7o27uSR.js +1 -0
- package/dist/frontend/assets/mdc-DTYItulj.js +1 -0
- package/dist/frontend/assets/mdx-Cmh6b_Ma.js +1 -0
- package/dist/frontend/assets/mermaid-mWjccvbQ.js +1 -0
- package/dist/frontend/assets/min-dark-CafNBF8u.js +1 -0
- package/dist/frontend/assets/min-light-CTRr51gU.js +1 -0
- package/dist/frontend/assets/mipsasm-CKIfxQSi.js +1 -0
- package/dist/frontend/assets/mojo-rZm6bMo-.js +1 -0
- package/dist/frontend/assets/monokai-D4h5O-jR.js +1 -0
- package/dist/frontend/assets/moonbit-_H4v1dQx.js +1 -0
- package/dist/frontend/assets/move-IF9eRakj.js +1 -0
- package/dist/frontend/assets/narrat-DRg8JJMk.js +1 -0
- package/dist/frontend/assets/nextflow-C-mBbutL.js +1 -0
- package/dist/frontend/assets/nextflow-groovy-vE_lwT2v.js +1 -0
- package/dist/frontend/assets/nginx-BpAMiNFr.js +1 -0
- package/dist/frontend/assets/night-owl-C39BiMTA.js +1 -0
- package/dist/frontend/assets/night-owl-light-CMTm3GFP.js +1 -0
- package/dist/frontend/assets/nim-BIad80T-.js +1 -0
- package/dist/frontend/assets/nix-CwoSXNpI.js +1 -0
- package/dist/frontend/assets/nord-Ddv68eIx.js +1 -0
- package/dist/frontend/assets/nushell-Cz2AlsmD.js +1 -0
- package/dist/frontend/assets/objective-c-DXmwc3jG.js +1 -0
- package/dist/frontend/assets/objective-cpp-CLxacb5B.js +1 -0
- package/dist/frontend/assets/ocaml-C0hk2d4L.js +1 -0
- package/dist/frontend/assets/odin-BBf5iR-q.js +1 -0
- package/dist/frontend/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- package/dist/frontend/assets/one-light-C3Wv6jpd.js +1 -0
- package/dist/frontend/assets/openscad-C4EeE6gA.js +1 -0
- package/dist/frontend/assets/pascal-D93ZcfNL.js +1 -0
- package/dist/frontend/assets/perl-NvoQZIq0.js +1 -0
- package/dist/frontend/assets/php-R6g_5hLQ.js +1 -0
- package/dist/frontend/assets/pkl-u5AG7uiY.js +1 -0
- package/dist/frontend/assets/plastic-3e1v2bzS.js +1 -0
- package/dist/frontend/assets/plsql-ChMvpjG-.js +1 -0
- package/dist/frontend/assets/po-BTJTHyun.js +1 -0
- package/dist/frontend/assets/poimandres-CS3Unz2-.js +1 -0
- package/dist/frontend/assets/polar-C0HS_06l.js +1 -0
- package/dist/frontend/assets/postcss-CXtECtnM.js +1 -0
- package/dist/frontend/assets/powerquery-CEu0bR-o.js +1 -0
- package/dist/frontend/assets/powershell-Dpen1YoG.js +1 -0
- package/dist/frontend/assets/prisma-Dd19v3D-.js +1 -0
- package/dist/frontend/assets/prolog-CbFg5uaA.js +1 -0
- package/dist/frontend/assets/proto-C7zT0LnQ.js +1 -0
- package/dist/frontend/assets/pug-DKIMFp6K.js +1 -0
- package/dist/frontend/assets/puppet-BMWR74SV.js +1 -0
- package/dist/frontend/assets/purescript-CklMAg4u.js +1 -0
- package/dist/frontend/assets/python-B6aJPvgy.js +1 -0
- package/dist/frontend/assets/qml-3beO22l8.js +1 -0
- package/dist/frontend/assets/qmldir-C8lEn-DE.js +1 -0
- package/dist/frontend/assets/qss-IeuSbFQv.js +1 -0
- package/dist/frontend/assets/r-Dspwwk_N.js +1 -0
- package/dist/frontend/assets/racket-BqYA7rlc.js +1 -0
- package/dist/frontend/assets/raku-DXvB9xmW.js +1 -0
- package/dist/frontend/assets/razor-BDqjjVU7.js +1 -0
- package/dist/frontend/assets/red-bN70gL4F.js +1 -0
- package/dist/frontend/assets/reg-C-SQnVFl.js +1 -0
- package/dist/frontend/assets/regexp-CDVJQ6XC.js +1 -0
- package/dist/frontend/assets/rel-C3B-1QV4.js +1 -0
- package/dist/frontend/assets/riscv-BM1_JUlF.js +1 -0
- package/dist/frontend/assets/ron-D8l8udqQ.js +1 -0
- package/dist/frontend/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- package/dist/frontend/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- package/dist/frontend/assets/rose-pine-qdsjHGoJ.js +1 -0
- package/dist/frontend/assets/rosmsg-BJDFO7_C.js +1 -0
- package/dist/frontend/assets/rst-CRjBmOyv.js +1 -0
- package/dist/frontend/assets/ruby-Wjq7vjNf.js +1 -0
- package/dist/frontend/assets/rust-B1yitclQ.js +1 -0
- package/dist/frontend/assets/sas-cz2c8ADy.js +1 -0
- package/dist/frontend/assets/sass-Cj5Yp3dK.js +1 -0
- package/dist/frontend/assets/scala-C151Ov-r.js +1 -0
- package/dist/frontend/assets/scheme-C98Dy4si.js +1 -0
- package/dist/frontend/assets/scss-D5BDwBP9.js +1 -0
- package/dist/frontend/assets/sdbl-DVxCFoDh.js +1 -0
- package/dist/frontend/assets/shaderlab-Dg9Lc6iA.js +1 -0
- package/dist/frontend/assets/shellscript-Yzrsuije.js +1 -0
- package/dist/frontend/assets/shellsession-BADoaaVG.js +1 -0
- package/dist/frontend/assets/slack-dark-BthQWCQV.js +1 -0
- package/dist/frontend/assets/slack-ochin-DqwNpetd.js +1 -0
- package/dist/frontend/assets/smalltalk-BERRCDM3.js +1 -0
- package/dist/frontend/assets/snazzy-light-Bw305WKR.js +1 -0
- package/dist/frontend/assets/solarized-dark-DXbdFlpD.js +1 -0
- package/dist/frontend/assets/solarized-light-L9t79GZl.js +1 -0
- package/dist/frontend/assets/solidity-rGO070M0.js +1 -0
- package/dist/frontend/assets/soy-8wufbnw4.js +1 -0
- package/dist/frontend/assets/sparql-rVzFXLq3.js +1 -0
- package/dist/frontend/assets/splunk-BtCnVYZw.js +1 -0
- package/dist/frontend/assets/sql-BLtJtn59.js +1 -0
- package/dist/frontend/assets/ssh-config-_ykCGR6B.js +1 -0
- package/dist/frontend/assets/stata-BH5u7GGu.js +1 -0
- package/dist/frontend/assets/stylus-BEDo0Tqx.js +1 -0
- package/dist/frontend/assets/surrealql-Bq5Q-fJD.js +1 -0
- package/dist/frontend/assets/svelte-Cy7k_4gC.js +1 -0
- package/dist/frontend/assets/swift-D82vCrfD.js +1 -0
- package/dist/frontend/assets/synthwave-84-CbfX1IO0.js +1 -0
- package/dist/frontend/assets/system-verilog-CnnmHF94.js +1 -0
- package/dist/frontend/assets/systemd-4A_iFExJ.js +1 -0
- package/dist/frontend/assets/talonscript-CkByrt1z.js +1 -0
- package/dist/frontend/assets/tasl-QIJgUcNo.js +1 -0
- package/dist/frontend/assets/tcl-dwOrl1Do.js +1 -0
- package/dist/frontend/assets/templ-DhtptRzy.js +1 -0
- package/dist/frontend/assets/terraform-BETggiCN.js +1 -0
- package/dist/frontend/assets/tex-idrVyKtj.js +1 -0
- package/dist/frontend/assets/tokyo-night-hegEt444.js +1 -0
- package/dist/frontend/assets/toml-vGWfd6FD.js +1 -0
- package/dist/frontend/assets/ts-tags-DQrlYJgV.js +1 -0
- package/dist/frontend/assets/tsv-B_m7g4N7.js +1 -0
- package/dist/frontend/assets/tsx-COt5Ahok.js +1 -0
- package/dist/frontend/assets/turtle-BsS91CYL.js +1 -0
- package/dist/frontend/assets/twig-xg9kU7Mw.js +1 -0
- package/dist/frontend/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/frontend/assets/typespec-CAFt9gP4.js +1 -0
- package/dist/frontend/assets/typst-DHCkPAjA.js +1 -0
- package/dist/frontend/assets/v-BcVCzyr7.js +1 -0
- package/dist/frontend/assets/vala-CsfeWuGM.js +1 -0
- package/dist/frontend/assets/vb-D17OF-Vu.js +1 -0
- package/dist/frontend/assets/verilog-BQ8w6xss.js +1 -0
- package/dist/frontend/assets/vesper-DU1UobuO.js +1 -0
- package/dist/frontend/assets/vhdl-CeAyd5Ju.js +1 -0
- package/dist/frontend/assets/viml-CJc9bBzg.js +1 -0
- package/dist/frontend/assets/vitesse-black-Bkuqu6BP.js +1 -0
- package/dist/frontend/assets/vitesse-dark-D0r3Knsf.js +1 -0
- package/dist/frontend/assets/vitesse-light-CVO1_9PV.js +1 -0
- package/dist/frontend/assets/vue-D2xRrEX4.js +1 -0
- package/dist/frontend/assets/vue-html-AaS7Mt5G.js +1 -0
- package/dist/frontend/assets/vue-vine-BoDAl6tE.js +1 -0
- package/dist/frontend/assets/vyper-CDx5xZoG.js +1 -0
- package/dist/frontend/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/frontend/assets/wasm-MzD3tlZU.js +1 -0
- package/dist/frontend/assets/wenyan-BV7otONQ.js +1 -0
- package/dist/frontend/assets/wgsl-Dx-B1_4e.js +1 -0
- package/dist/frontend/assets/wikitext-BhOHFoWU.js +1 -0
- package/dist/frontend/assets/wit-5i3qLPDT.js +1 -0
- package/dist/frontend/assets/wolfram-lXgVvXCa.js +1 -0
- package/dist/frontend/assets/xml-sdJ4AIDG.js +1 -0
- package/dist/frontend/assets/xsl-CtQFsRM5.js +1 -0
- package/dist/frontend/assets/yaml-Buea-lGh.js +1 -0
- package/dist/frontend/assets/zenscript-DVFEvuxE.js +1 -0
- package/dist/frontend/assets/zig-VOosw3JB.js +1 -0
- package/dist/frontend/icon-192.png +0 -0
- package/dist/frontend/icon-512.png +0 -0
- package/dist/frontend/icon.svg +8 -0
- package/dist/frontend/index.html +30 -0
- package/dist/frontend/manifest.json +25 -0
- package/dist/frontend/sw.js +66 -0
- package/dist/server/agent-events.js +39 -0
- package/dist/server/analytics.js +885 -0
- package/dist/server/auth.js +65 -0
- package/dist/server/belayer/executor.js +200 -0
- package/dist/server/belayer/intake.js +27 -0
- package/dist/server/belayer/pipeline.js +97 -0
- package/dist/server/belayer/pr-lifecycle.js +69 -0
- package/dist/server/belayer/prompts.js +154 -0
- package/dist/server/belayer/types.js +23 -0
- package/dist/server/branch-linker.js +137 -0
- package/dist/server/browser-content.js +145 -0
- package/dist/server/clipboard.js +63 -0
- package/dist/server/codex-hooks-adapter.js +93 -0
- package/dist/server/config.js +325 -0
- package/dist/server/gh-routes.js +163 -0
- package/dist/server/gh.js +276 -0
- package/dist/server/git-routes.js +154 -0
- package/dist/server/git.js +694 -0
- package/dist/server/github-app.js +218 -0
- package/dist/server/github-graphql.js +178 -0
- package/dist/server/hooks.js +373 -0
- package/dist/server/index.js +1549 -0
- package/dist/server/integration-github.js +137 -0
- package/dist/server/integration-jira.js +210 -0
- package/dist/server/integration-linear.js +176 -0
- package/dist/server/logger.js +18 -0
- package/dist/server/mobile-input-pipeline.js +129 -0
- package/dist/server/opencode-relay.js +53 -0
- package/dist/server/org-dashboard.js +241 -0
- package/dist/server/output-parsers/claude-parser.js +56 -0
- package/dist/server/output-parsers/codex-parser.js +13 -0
- package/dist/server/output-parsers/index.js +14 -0
- package/dist/server/output-parsers/null-parser.js +12 -0
- package/dist/server/output-parsers/opencode-parser.js +77 -0
- package/dist/server/pty-handler.js +586 -0
- package/dist/server/push.js +84 -0
- package/dist/server/review-poller.js +237 -0
- package/dist/server/sdk-handler.js +539 -0
- package/dist/server/service.js +189 -0
- package/dist/server/sessions.js +638 -0
- package/dist/server/telemetry.js +236 -0
- package/dist/server/ticket-transitions.js +166 -0
- package/dist/server/types.js +146 -0
- package/dist/server/utils.js +23 -0
- package/dist/server/watcher.js +661 -0
- package/dist/server/webhook-manager.js +547 -0
- package/dist/server/webhooks.js +73 -0
- package/dist/server/workspace-groups.js +363 -0
- package/dist/server/workspaces.js +1207 -0
- package/dist/server/ws.js +192 -0
- package/dist/test/EmptyState.spec.js +51 -0
- package/dist/test/action-coverage.test.js +139 -0
- package/dist/test/actions/registry.test.js +59 -0
- package/dist/test/actions/shortcuts.test.js +79 -0
- package/dist/test/agent-events.test.js +151 -0
- package/dist/test/analytics.test.js +158 -0
- package/dist/test/attention.test.js +91 -0
- package/dist/test/auth.test.js +105 -0
- package/dist/test/backend-state.test.js +47 -0
- package/dist/test/belayer-executor.test.js +33 -0
- package/dist/test/belayer-intake.test.js +44 -0
- package/dist/test/belayer-pipeline.test.js +113 -0
- package/dist/test/belayer-pr-lifecycle.test.js +26 -0
- package/dist/test/belayer-prompts.test.js +60 -0
- package/dist/test/belayer-types.test.js +69 -0
- package/dist/test/bin/claude-remote-cli.js +214 -0
- package/dist/test/boot-state.test.js +133 -0
- package/dist/test/branch-lifecycle.test.js +75 -0
- package/dist/test/branch-linker.test.js +236 -0
- package/dist/test/branch-rename.test.js +45 -0
- package/dist/test/branch-watcher.test.js +115 -0
- package/dist/test/browser-cli.test.js +91 -0
- package/dist/test/browser-content.test.js +93 -0
- package/dist/test/browser-tabs-ui.test.js +39 -0
- package/dist/test/changed-files-api.test.js +140 -0
- package/dist/test/clipboard.test.js +12 -0
- package/dist/test/codex-hooks-adapter.test.js +237 -0
- package/dist/test/components/EmptyState.spec.js +51 -0
- package/dist/test/components/ErrorToast.spec.js +65 -0
- package/dist/test/components/TuiCheckbox.spec.js +120 -0
- package/dist/test/components/TuiInput.spec.js +186 -0
- package/dist/test/components/leaf-component-migration.spec.js +104 -0
- package/dist/test/config-freshness.test.js +63 -0
- package/dist/test/config.test.js +813 -0
- package/dist/test/diff-summary.test.js +98 -0
- package/dist/test/display-state.test.js +179 -0
- package/dist/test/event-message-types.test.js +32 -0
- package/dist/test/file-tree-utils.test.js +167 -0
- package/dist/test/framework-types.test.js +183 -0
- package/dist/test/frameworks-api.test.js +93 -0
- package/dist/test/frontend/src/lib/pr-state.js +114 -0
- package/dist/test/fs-browse.test.js +246 -0
- package/dist/test/fuzzy-scorer.test.js +145 -0
- package/dist/test/gh-routes.test.js +156 -0
- package/dist/test/git-changed-files.test.js +152 -0
- package/dist/test/git-routes.test.js +146 -0
- package/dist/test/git-utils.test.js +68 -0
- package/dist/test/git-watcher.test.js +110 -0
- package/dist/test/git.test.js +140 -0
- package/dist/test/github-app.test.js +455 -0
- package/dist/test/github-graphql.test.js +301 -0
- package/dist/test/greetings.test.js +83 -0
- package/dist/test/hooks-agent-event.test.js +412 -0
- package/dist/test/hooks.test.js +149 -0
- package/dist/test/integration-github.test.js +220 -0
- package/dist/test/integration-jira.test.js +238 -0
- package/dist/test/integration-linear.test.js +293 -0
- package/dist/test/mobile-input.test.js +235 -0
- package/dist/test/opencode-relay.test.js +107 -0
- package/dist/test/org-dashboard.test.js +349 -0
- package/dist/test/output-parser.test.js +217 -0
- package/dist/test/paths.test.js +32 -0
- package/dist/test/pr-state.test.js +407 -0
- package/dist/test/pr-status.test.js +82 -0
- package/dist/test/presets.test.js +242 -0
- package/dist/test/pty-handler-multi-agent.test.js +149 -0
- package/dist/test/pty-handler.test.js +146 -0
- package/dist/test/pull-requests.test.js +78 -0
- package/dist/test/review-poller.test.js +349 -0
- package/dist/test/server/analytics.js +121 -0
- package/dist/test/server/auth.js +63 -0
- package/dist/test/server/branch-linker.js +124 -0
- package/dist/test/server/clipboard.js +56 -0
- package/dist/test/server/config.js +137 -0
- package/dist/test/server/git.js +308 -0
- package/dist/test/server/hooks.js +196 -0
- package/dist/test/server/index.js +1124 -0
- package/dist/test/server/integration-github.js +117 -0
- package/dist/test/server/integration-jira.js +164 -0
- package/dist/test/server/integration-linear.js +176 -0
- package/dist/test/server/mobile-input-pipeline.js +123 -0
- package/dist/test/server/org-dashboard.js +184 -0
- package/dist/test/server/output-parsers/claude-parser.js +54 -0
- package/dist/test/server/output-parsers/codex-parser.js +13 -0
- package/dist/test/server/output-parsers/index.js +7 -0
- package/dist/test/server/pty-handler.js +310 -0
- package/dist/test/server/push.js +80 -0
- package/dist/test/server/review-poller.js +218 -0
- package/dist/test/server/service.js +169 -0
- package/dist/test/server/sessions.js +434 -0
- package/dist/test/server/ticket-transitions.js +216 -0
- package/dist/test/server/types.js +20 -0
- package/dist/test/server/utils.js +22 -0
- package/dist/test/server/watcher.js +139 -0
- package/dist/test/server/workspaces.js +657 -0
- package/dist/test/server/ws.js +152 -0
- package/dist/test/server-startup.test.js +62 -0
- package/dist/test/service.test.js +43 -0
- package/dist/test/session-analytics-api.test.js +123 -0
- package/dist/test/session-analytics.test.js +425 -0
- package/dist/test/session-intent.test.js +249 -0
- package/dist/test/sessions.test.js +1152 -0
- package/dist/test/sidebar-items.test.js +164 -0
- package/dist/test/stores/boot-state-store.test.js +165 -0
- package/dist/test/stores/sessions-logic.test.js +191 -0
- package/dist/test/stores/toasts-store.test.js +66 -0
- package/dist/test/stores/ui-store.test.js +203 -0
- package/dist/test/stores/unread-store.test.js +97 -0
- package/dist/test/telemetry-api.test.js +54 -0
- package/dist/test/telemetry-sync.test.js +68 -0
- package/dist/test/telemetry.test.js +295 -0
- package/dist/test/terminal-zoom.test.js +102 -0
- package/dist/test/test/analytics.test.js +152 -0
- package/dist/test/test/auth.test.js +95 -0
- package/dist/test/test/branch-linker.test.js +231 -0
- package/dist/test/test/branch-rename.test.js +45 -0
- package/dist/test/test/clipboard.test.js +12 -0
- package/dist/test/test/config.test.js +281 -0
- package/dist/test/test/fs-browse.test.js +202 -0
- package/dist/test/test/git.test.js +67 -0
- package/dist/test/test/hooks.test.js +139 -0
- package/dist/test/test/integration-github.test.js +203 -0
- package/dist/test/test/integration-jira.test.js +294 -0
- package/dist/test/test/integration-linear.test.js +293 -0
- package/dist/test/test/mobile-input.test.js +193 -0
- package/dist/test/test/org-dashboard.test.js +240 -0
- package/dist/test/test/output-parser.test.js +95 -0
- package/dist/test/test/paths.test.js +32 -0
- package/dist/test/test/pr-state.test.js +220 -0
- package/dist/test/test/pull-requests.test.js +67 -0
- package/dist/test/test/review-poller.test.js +235 -0
- package/dist/test/test/service.test.js +43 -0
- package/dist/test/test/sessions.test.js +750 -0
- package/dist/test/test/ticket-transitions.test.js +130 -0
- package/dist/test/test/version.test.js +34 -0
- package/dist/test/test/worktrees.test.js +256 -0
- package/dist/test/ticket-transitions.test.js +312 -0
- package/dist/test/unread.test.js +23 -0
- package/dist/test/version.test.js +34 -0
- package/dist/test/webhook-manager.test.js +484 -0
- package/dist/test/webhooks.test.js +208 -0
- package/dist/test/workspace-groups.test.js +377 -0
- package/dist/test/worktrees.test.js +531 -0
- package/package.json +88 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { startPolling, stopPolling, isPolling, } from '../server/review-poller.js';
|
|
7
|
+
import { saveConfig, DEFAULTS } from '../server/config.js';
|
|
8
|
+
// ─── Shared fixtures ──────────────────────────────────────────────────────────
|
|
9
|
+
let tmpDir;
|
|
10
|
+
let configPath;
|
|
11
|
+
const WORKSPACE_PATH = '/fake/workspace/my-repo';
|
|
12
|
+
before(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-poller-test-'));
|
|
14
|
+
configPath = path.join(tmpDir, 'config.json');
|
|
15
|
+
});
|
|
16
|
+
after(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
// Guarantee no timer leaks between tests
|
|
21
|
+
await stopPolling();
|
|
22
|
+
});
|
|
23
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
24
|
+
/** Builds a minimal GhNotification JSON string suitable for mock exec stdout. */
|
|
25
|
+
function makeNotificationLine(overrides) {
|
|
26
|
+
const { id = 'notif-1', reason = 'review_requested', prNumber = 42, ownerRepo = 'owner/my-repo', updatedAt = new Date().toISOString(), title = 'Test PR', } = overrides;
|
|
27
|
+
return JSON.stringify({
|
|
28
|
+
id,
|
|
29
|
+
reason,
|
|
30
|
+
subject: {
|
|
31
|
+
title,
|
|
32
|
+
url: `https://api.github.com/repos/${ownerRepo}/pulls/${prNumber}`,
|
|
33
|
+
type: 'PullRequest',
|
|
34
|
+
},
|
|
35
|
+
repository: { full_name: ownerRepo },
|
|
36
|
+
updated_at: updatedAt,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Creates a mock execAsync. Routes by command:
|
|
41
|
+
* - `gh api /notifications` → returns notification lines joined by newline
|
|
42
|
+
* - `git remote get-url origin` → returns the configured remote URL
|
|
43
|
+
* - `git fetch ...` → resolves with empty output
|
|
44
|
+
* - `git worktree add ...` → resolves with empty output (unless worktreeError is set)
|
|
45
|
+
*/
|
|
46
|
+
function makeMockExec(opts) {
|
|
47
|
+
return async (cmd, args) => {
|
|
48
|
+
const command = cmd;
|
|
49
|
+
const argv = args;
|
|
50
|
+
opts.onExec?.(command, argv);
|
|
51
|
+
if (command === 'gh' && argv[0] === 'api') {
|
|
52
|
+
if (opts.ghError)
|
|
53
|
+
throw opts.ghError;
|
|
54
|
+
const lines = opts.notificationLines ?? [];
|
|
55
|
+
return { stdout: lines.join('\n'), stderr: '' };
|
|
56
|
+
}
|
|
57
|
+
if (command === 'git' && argv[0] === 'remote') {
|
|
58
|
+
if (opts.gitRemoteError)
|
|
59
|
+
throw opts.gitRemoteError;
|
|
60
|
+
const url = opts.remoteUrl ?? 'https://github.com/owner/my-repo.git';
|
|
61
|
+
return { stdout: url + '\n', stderr: '' };
|
|
62
|
+
}
|
|
63
|
+
if (command === 'git' && argv[0] === 'fetch') {
|
|
64
|
+
return { stdout: '', stderr: '' };
|
|
65
|
+
}
|
|
66
|
+
if (command === 'git' && argv[0] === 'worktree') {
|
|
67
|
+
if (opts.worktreeError)
|
|
68
|
+
throw opts.worktreeError;
|
|
69
|
+
return { stdout: '', stderr: '' };
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Unexpected exec call: ${command} ${argv.join(' ')}`);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Returns a deps object with sensible defaults. Override individual fields as needed. */
|
|
75
|
+
function makeDeps(overrides = {}) {
|
|
76
|
+
return {
|
|
77
|
+
configPath,
|
|
78
|
+
getWorkspacePaths: () => [WORKSPACE_PATH],
|
|
79
|
+
getRepoSettings: () => undefined,
|
|
80
|
+
createSession: async () => { },
|
|
81
|
+
broadcastEvent: () => { },
|
|
82
|
+
execAsync: makeMockExec({}),
|
|
83
|
+
...overrides,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Waits for at least one poll cycle to complete given the interval. */
|
|
87
|
+
function waitForCycles(intervalMs, cycles = 1) {
|
|
88
|
+
return new Promise((resolve) => setTimeout(resolve, intervalMs * cycles + 20));
|
|
89
|
+
}
|
|
90
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
91
|
+
test('isPolling() returns false initially', () => {
|
|
92
|
+
assert.equal(isPolling(), false);
|
|
93
|
+
});
|
|
94
|
+
test('startPolling() sets isPolling() to true', () => {
|
|
95
|
+
saveConfig(configPath, {
|
|
96
|
+
...DEFAULTS,
|
|
97
|
+
automations: { pollIntervalMs: 60_000 },
|
|
98
|
+
});
|
|
99
|
+
startPolling(makeDeps());
|
|
100
|
+
assert.equal(isPolling(), true);
|
|
101
|
+
});
|
|
102
|
+
test('stopPolling() sets isPolling() to false', async () => {
|
|
103
|
+
saveConfig(configPath, {
|
|
104
|
+
...DEFAULTS,
|
|
105
|
+
automations: { pollIntervalMs: 60_000 },
|
|
106
|
+
});
|
|
107
|
+
startPolling(makeDeps());
|
|
108
|
+
assert.equal(isPolling(), true);
|
|
109
|
+
await stopPolling();
|
|
110
|
+
assert.equal(isPolling(), false);
|
|
111
|
+
});
|
|
112
|
+
test('startPolling() is idempotent — calling twice does not create two timers', async () => {
|
|
113
|
+
const INTERVAL = 50;
|
|
114
|
+
let callCount = 0;
|
|
115
|
+
saveConfig(configPath, {
|
|
116
|
+
...DEFAULTS,
|
|
117
|
+
automations: {
|
|
118
|
+
autoCheckoutReviewRequests: true,
|
|
119
|
+
pollIntervalMs: INTERVAL,
|
|
120
|
+
lastPollTimestamp: new Date().toISOString(),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const exec = makeMockExec({
|
|
124
|
+
onExec: (cmd, argv) => {
|
|
125
|
+
if (cmd === 'gh' && argv[0] === 'api')
|
|
126
|
+
callCount++;
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const deps = makeDeps({ execAsync: exec });
|
|
130
|
+
startPolling(deps);
|
|
131
|
+
startPolling(deps); // second call must be a no-op
|
|
132
|
+
await waitForCycles(INTERVAL, 2);
|
|
133
|
+
// Two timer cycles elapsed. If only one timer exists, gh was called ~2 times.
|
|
134
|
+
// If startPolling were NOT idempotent (two timers), we would see ~4 calls.
|
|
135
|
+
assert.ok(callCount <= 3, `Expected at most 3 gh calls (got ${callCount}) — suggests only one timer running`);
|
|
136
|
+
});
|
|
137
|
+
test('first-run guard — when lastPollTimestamp is absent, no notifications are processed', async () => {
|
|
138
|
+
const INTERVAL = 50;
|
|
139
|
+
// Config without lastPollTimestamp — first-run scenario
|
|
140
|
+
saveConfig(configPath, {
|
|
141
|
+
...DEFAULTS,
|
|
142
|
+
automations: {
|
|
143
|
+
autoCheckoutReviewRequests: true,
|
|
144
|
+
pollIntervalMs: INTERVAL,
|
|
145
|
+
// No lastPollTimestamp — module will default to "now"
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const broadcastedEvents = [];
|
|
149
|
+
let fetchCallCount = 0;
|
|
150
|
+
const exec = makeMockExec({
|
|
151
|
+
// Notification is old (well before "now"), so it should NOT be processed
|
|
152
|
+
notificationLines: [
|
|
153
|
+
makeNotificationLine({
|
|
154
|
+
updatedAt: new Date(Date.now() - 60_000).toISOString(), // 1 minute ago
|
|
155
|
+
ownerRepo: 'owner/my-repo',
|
|
156
|
+
}),
|
|
157
|
+
],
|
|
158
|
+
remoteUrl: 'https://github.com/owner/my-repo.git',
|
|
159
|
+
onExec: (cmd, argv) => {
|
|
160
|
+
if (cmd === 'git' && argv[0] === 'fetch')
|
|
161
|
+
fetchCallCount++;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const deps = makeDeps({
|
|
165
|
+
execAsync: exec,
|
|
166
|
+
broadcastEvent: (event, data) => broadcastedEvents.push({ event, data }),
|
|
167
|
+
});
|
|
168
|
+
startPolling(deps);
|
|
169
|
+
await waitForCycles(INTERVAL);
|
|
170
|
+
// The notification predates the first-run "now" baseline, so no checkout should occur
|
|
171
|
+
assert.equal(fetchCallCount, 0, 'git fetch should not be called for historical notifications');
|
|
172
|
+
assert.equal(broadcastedEvents.length, 0, 'No review-checkout events should be broadcast');
|
|
173
|
+
});
|
|
174
|
+
test('JSON parse safety — non-JSON lines in gh output do not crash', async () => {
|
|
175
|
+
const INTERVAL = 50;
|
|
176
|
+
saveConfig(configPath, {
|
|
177
|
+
...DEFAULTS,
|
|
178
|
+
automations: {
|
|
179
|
+
autoCheckoutReviewRequests: true,
|
|
180
|
+
pollIntervalMs: INTERVAL,
|
|
181
|
+
lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(), // 2 min ago
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
// Mix valid JSON with non-JSON warning lines that gh sometimes emits
|
|
185
|
+
const validNotification = makeNotificationLine({
|
|
186
|
+
updatedAt: new Date().toISOString(),
|
|
187
|
+
ownerRepo: 'owner/my-repo',
|
|
188
|
+
prNumber: 7,
|
|
189
|
+
});
|
|
190
|
+
const exec = makeMockExec({
|
|
191
|
+
notificationLines: [
|
|
192
|
+
'Warning: some gh warning message',
|
|
193
|
+
validNotification,
|
|
194
|
+
'another non-JSON line',
|
|
195
|
+
],
|
|
196
|
+
remoteUrl: 'https://github.com/owner/my-repo.git',
|
|
197
|
+
});
|
|
198
|
+
// Just verify it doesn't throw — if parsing crashes, startPolling's setInterval
|
|
199
|
+
// would log an unhandled rejection. We capture broadcastEvent to confirm the
|
|
200
|
+
// valid notification was still processed.
|
|
201
|
+
const broadcastedEvents = [];
|
|
202
|
+
const deps = makeDeps({
|
|
203
|
+
execAsync: exec,
|
|
204
|
+
broadcastEvent: (event, data) => broadcastedEvents.push({ event, data }),
|
|
205
|
+
});
|
|
206
|
+
// Should not throw
|
|
207
|
+
startPolling(deps);
|
|
208
|
+
await waitForCycles(INTERVAL);
|
|
209
|
+
// The valid notification was newer than lastPollTimestamp — should be processed
|
|
210
|
+
const checkoutEvents = broadcastedEvents.filter((e) => e.event === 'review-checkout');
|
|
211
|
+
assert.equal(checkoutEvents.length, 1, 'Valid notification should still be processed despite surrounding non-JSON lines');
|
|
212
|
+
});
|
|
213
|
+
test('poll skips processing when autoCheckoutReviewRequests is disabled', async () => {
|
|
214
|
+
const INTERVAL = 50;
|
|
215
|
+
saveConfig(configPath, {
|
|
216
|
+
...DEFAULTS,
|
|
217
|
+
automations: {
|
|
218
|
+
autoCheckoutReviewRequests: false,
|
|
219
|
+
pollIntervalMs: INTERVAL,
|
|
220
|
+
lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
let ghCallCount = 0;
|
|
224
|
+
const exec = makeMockExec({
|
|
225
|
+
notificationLines: [
|
|
226
|
+
makeNotificationLine({ updatedAt: new Date().toISOString() }),
|
|
227
|
+
],
|
|
228
|
+
onExec: (cmd, argv) => {
|
|
229
|
+
if (cmd === 'gh' && argv[0] === 'api')
|
|
230
|
+
ghCallCount++;
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
startPolling(makeDeps({ execAsync: exec }));
|
|
234
|
+
await waitForCycles(INTERVAL);
|
|
235
|
+
// pollOnce returns early when the flag is off — gh should not even be called
|
|
236
|
+
assert.equal(ghCallCount, 0, 'gh should not be called when autoCheckoutReviewRequests is false');
|
|
237
|
+
});
|
|
238
|
+
test('stopPolling() awaits the in-flight poll before resolving', async () => {
|
|
239
|
+
const DELAY_MS = 100;
|
|
240
|
+
saveConfig(configPath, {
|
|
241
|
+
...DEFAULTS,
|
|
242
|
+
automations: {
|
|
243
|
+
autoCheckoutReviewRequests: true,
|
|
244
|
+
pollIntervalMs: 60_000,
|
|
245
|
+
lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const broadcastedEvents = [];
|
|
249
|
+
// Wrap the normal exec with a deliberate delay so the poll stays in-flight
|
|
250
|
+
const normalExec = makeMockExec({
|
|
251
|
+
notificationLines: [
|
|
252
|
+
makeNotificationLine({
|
|
253
|
+
updatedAt: new Date().toISOString(),
|
|
254
|
+
ownerRepo: 'owner/my-repo',
|
|
255
|
+
}),
|
|
256
|
+
],
|
|
257
|
+
remoteUrl: 'https://github.com/owner/my-repo.git',
|
|
258
|
+
});
|
|
259
|
+
const delayedExec = async (...args) => {
|
|
260
|
+
await new Promise((r) => setTimeout(r, DELAY_MS));
|
|
261
|
+
return normalExec(...args);
|
|
262
|
+
};
|
|
263
|
+
const deps = makeDeps({
|
|
264
|
+
execAsync: delayedExec,
|
|
265
|
+
broadcastEvent: (event, data) => broadcastedEvents.push({ event, data }),
|
|
266
|
+
});
|
|
267
|
+
startPolling(deps);
|
|
268
|
+
// Give the initial poll just enough time to start (but not finish — it takes ~100ms per call)
|
|
269
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
270
|
+
// stopPolling() must await the in-flight poll
|
|
271
|
+
await stopPolling();
|
|
272
|
+
// The poll ran to completion — broadcastEvent must have been called
|
|
273
|
+
const checkoutEvents = broadcastedEvents.filter((e) => e.event === 'review-checkout');
|
|
274
|
+
assert.ok(checkoutEvents.length >= 1, 'broadcastEvent should have been called before stopPolling() returned');
|
|
275
|
+
});
|
|
276
|
+
test('poll-start watermark: lastPollTimestamp saved is the time before the fetch, not after', async () => {
|
|
277
|
+
const INTERVAL = 60_000;
|
|
278
|
+
saveConfig(configPath, {
|
|
279
|
+
...DEFAULTS,
|
|
280
|
+
automations: {
|
|
281
|
+
autoCheckoutReviewRequests: true,
|
|
282
|
+
pollIntervalMs: INTERVAL,
|
|
283
|
+
lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const EXEC_DELAY_MS = 50;
|
|
287
|
+
const normalExec = makeMockExec({
|
|
288
|
+
notificationLines: [],
|
|
289
|
+
remoteUrl: 'https://github.com/owner/my-repo.git',
|
|
290
|
+
});
|
|
291
|
+
const delayedExec = async (...args) => {
|
|
292
|
+
await new Promise((r) => setTimeout(r, EXEC_DELAY_MS));
|
|
293
|
+
return normalExec(...args);
|
|
294
|
+
};
|
|
295
|
+
const deps = makeDeps({ execAsync: delayedExec });
|
|
296
|
+
// Bracket the poll with timestamps
|
|
297
|
+
const beforePoll = Date.now();
|
|
298
|
+
startPolling(deps);
|
|
299
|
+
await stopPolling(); // waits for the initial poll to complete
|
|
300
|
+
const afterPoll = Date.now();
|
|
301
|
+
// Read the config that was written by the poll
|
|
302
|
+
const savedConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
303
|
+
const savedTs = savedConfig.automations?.lastPollTimestamp;
|
|
304
|
+
assert.ok(savedTs !== undefined, 'lastPollTimestamp should have been saved');
|
|
305
|
+
const savedMs = new Date(savedTs).getTime();
|
|
306
|
+
assert.ok(savedMs >= beforePoll, `saved timestamp (${savedTs}) should be >= poll start (${new Date(beforePoll).toISOString()})`);
|
|
307
|
+
assert.ok(savedMs <= afterPoll, `saved timestamp (${savedTs}) should be <= poll end (${new Date(afterPoll).toISOString()})`);
|
|
308
|
+
// The key invariant: the saved timestamp is the poll-START watermark, not poll-end.
|
|
309
|
+
// We verify this by confirming it precedes the time after stopPolling returned.
|
|
310
|
+
// Because exec has a deliberate delay, a poll-END timestamp would be noticeably later.
|
|
311
|
+
// We simply confirm the saved value is a valid ISO string within the expected window.
|
|
312
|
+
assert.ok(!isNaN(savedMs), 'saved lastPollTimestamp should be a valid date');
|
|
313
|
+
});
|
|
314
|
+
test('pollInFlight guard prevents overlapping poll cycles', async () => {
|
|
315
|
+
const INTERVAL_MS = 10;
|
|
316
|
+
const EXEC_DELAY_MS = 100; // each poll takes ~100ms — far longer than the interval
|
|
317
|
+
saveConfig(configPath, {
|
|
318
|
+
...DEFAULTS,
|
|
319
|
+
automations: {
|
|
320
|
+
autoCheckoutReviewRequests: true,
|
|
321
|
+
pollIntervalMs: INTERVAL_MS,
|
|
322
|
+
lastPollTimestamp: new Date(Date.now() - 120_000).toISOString(),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
let ghCallCount = 0;
|
|
326
|
+
const normalExec = makeMockExec({
|
|
327
|
+
notificationLines: [],
|
|
328
|
+
remoteUrl: 'https://github.com/owner/my-repo.git',
|
|
329
|
+
onExec: (cmd, argv) => {
|
|
330
|
+
if (cmd === 'gh' && argv[0] === 'api')
|
|
331
|
+
ghCallCount++;
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const delayedExec = async (...args) => {
|
|
335
|
+
await new Promise((r) => setTimeout(r, EXEC_DELAY_MS));
|
|
336
|
+
return normalExec(...args);
|
|
337
|
+
};
|
|
338
|
+
const deps = makeDeps({ execAsync: delayedExec });
|
|
339
|
+
startPolling(deps);
|
|
340
|
+
// Wait long enough for several timer ticks to fire (10ms interval × ~15 ticks = 150ms)
|
|
341
|
+
// but each poll takes 100ms, so without the guard we would expect many concurrent calls.
|
|
342
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
343
|
+
await stopPolling();
|
|
344
|
+
// Without the pollInFlight guard, 150ms / 10ms = ~15 timer fires would each spawn a poll,
|
|
345
|
+
// meaning gh could be called ~15 times. With the guard, at most 2 polls can complete
|
|
346
|
+
// in 150ms (one starting at t=0 finishing at ~100ms, one starting at ~100ms finishing at ~200ms).
|
|
347
|
+
assert.ok(ghCallCount <= 3, `Expected at most 3 gh calls due to pollInFlight guard (got ${ghCallCount})`);
|
|
348
|
+
assert.ok(ghCallCount >= 1, `Expected at least 1 gh call to confirm polling ran (got ${ghCallCount})`);
|
|
349
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
let db = null;
|
|
6
|
+
let insertStmt = null;
|
|
7
|
+
const SCHEMA = `
|
|
8
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
10
|
+
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
|
|
11
|
+
category TEXT NOT NULL, -- 'session', 'ui', 'agent', 'navigation', 'workspace'
|
|
12
|
+
action TEXT NOT NULL,
|
|
13
|
+
target TEXT,
|
|
14
|
+
properties TEXT,
|
|
15
|
+
session_id TEXT,
|
|
16
|
+
device TEXT
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_events_category_action ON events(category, action);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_events_target ON events(target);
|
|
21
|
+
`;
|
|
22
|
+
const INSERT_SQL = 'INSERT INTO events (category, action, target, properties, session_id, device) VALUES (?, ?, ?, ?, ?, ?)';
|
|
23
|
+
export function initAnalytics(configDir) {
|
|
24
|
+
if (db) {
|
|
25
|
+
db.close();
|
|
26
|
+
db = null;
|
|
27
|
+
insertStmt = null;
|
|
28
|
+
}
|
|
29
|
+
const dbPath = path.join(configDir, 'analytics.db');
|
|
30
|
+
db = new Database(dbPath);
|
|
31
|
+
db.pragma('journal_mode = WAL');
|
|
32
|
+
db.exec(SCHEMA);
|
|
33
|
+
insertStmt = db.prepare(INSERT_SQL);
|
|
34
|
+
}
|
|
35
|
+
export function closeAnalytics() {
|
|
36
|
+
if (db) {
|
|
37
|
+
db.close();
|
|
38
|
+
db = null;
|
|
39
|
+
insertStmt = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function runInsert(stmt, event) {
|
|
43
|
+
stmt.run(event.category, event.action, event.target ?? null, event.properties ? JSON.stringify(event.properties) : null, event.session_id ?? null, event.device ?? null);
|
|
44
|
+
}
|
|
45
|
+
export function trackEvent(event) {
|
|
46
|
+
if (!insertStmt)
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
runInsert(insertStmt, event);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Analytics write failure is non-fatal
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function getDbPath(configDir) {
|
|
56
|
+
return path.join(configDir, 'analytics.db');
|
|
57
|
+
}
|
|
58
|
+
export function getDbSize(configDir) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.statSync(getDbPath(configDir)).size;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function createAnalyticsRouter(configDir) {
|
|
67
|
+
const router = Router();
|
|
68
|
+
// POST /analytics/events — batch ingest from frontend
|
|
69
|
+
router.post('/events', (req, res) => {
|
|
70
|
+
const { events } = req.body;
|
|
71
|
+
if (!Array.isArray(events)) {
|
|
72
|
+
res.status(400).json({ error: 'events array required' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!db || !insertStmt) {
|
|
76
|
+
res.status(503).json({ error: 'Analytics not initialized' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const stmt = insertStmt;
|
|
80
|
+
const insertMany = db.transaction((evts) => {
|
|
81
|
+
let inserted = 0;
|
|
82
|
+
for (const evt of evts) {
|
|
83
|
+
if (!evt.category || !evt.action)
|
|
84
|
+
continue;
|
|
85
|
+
runInsert(stmt, evt);
|
|
86
|
+
inserted++;
|
|
87
|
+
}
|
|
88
|
+
return inserted;
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
const inserted = insertMany(events);
|
|
92
|
+
res.json({ ok: true, count: inserted });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
res.status(500).json({ error: 'Failed to write events' });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// GET /analytics/size — DB file size in bytes
|
|
99
|
+
router.get('/size', (_req, res) => {
|
|
100
|
+
res.json({ bytes: getDbSize(configDir) });
|
|
101
|
+
});
|
|
102
|
+
// DELETE /analytics/events — truncate events table
|
|
103
|
+
router.delete('/events', (_req, res) => {
|
|
104
|
+
if (!db) {
|
|
105
|
+
res.status(503).json({ error: 'Analytics not initialized' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
db.exec('DELETE FROM events');
|
|
110
|
+
try {
|
|
111
|
+
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
112
|
+
}
|
|
113
|
+
catch { /* best-effort */ }
|
|
114
|
+
res.json({ ok: true });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
res.status(500).json({ error: 'Failed to clear analytics' });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return router;
|
|
121
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const scrypt = promisify(crypto.scrypt);
|
|
4
|
+
const SCRYPT_KEYLEN = 64;
|
|
5
|
+
const MAX_ATTEMPTS = 5;
|
|
6
|
+
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
|
7
|
+
const attemptMap = new Map();
|
|
8
|
+
export async function hashPin(pin) {
|
|
9
|
+
const salt = crypto.randomBytes(16).toString('hex');
|
|
10
|
+
const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
|
|
11
|
+
return `scrypt:${salt}:${derived.toString('hex')}`;
|
|
12
|
+
}
|
|
13
|
+
export async function verifyPin(pin, hash) {
|
|
14
|
+
if (hash.startsWith('scrypt:')) {
|
|
15
|
+
const [, salt, storedHashHex] = hash.split(':');
|
|
16
|
+
if (!salt || !storedHashHex)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
const storedBuf = Buffer.from(storedHashHex, 'hex');
|
|
20
|
+
if (storedBuf.length !== SCRYPT_KEYLEN)
|
|
21
|
+
return false;
|
|
22
|
+
const derived = await scrypt(pin, salt, SCRYPT_KEYLEN);
|
|
23
|
+
return crypto.timingSafeEqual(storedBuf, derived);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Legacy bcrypt hashes are migrated at startup; if one reaches here, reject it
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
export function isRateLimited(ip) {
|
|
33
|
+
const entry = attemptMap.get(ip);
|
|
34
|
+
if (!entry)
|
|
35
|
+
return false;
|
|
36
|
+
if (entry.lockedUntil) {
|
|
37
|
+
if (Date.now() < entry.lockedUntil) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
attemptMap.delete(ip);
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export function recordFailedAttempt(ip) {
|
|
45
|
+
const entry = attemptMap.get(ip) ?? { count: 0, lockedUntil: null };
|
|
46
|
+
entry.count += 1;
|
|
47
|
+
if (entry.count >= MAX_ATTEMPTS) {
|
|
48
|
+
entry.lockedUntil = Date.now() + LOCKOUT_DURATION_MS;
|
|
49
|
+
}
|
|
50
|
+
attemptMap.set(ip, entry);
|
|
51
|
+
}
|
|
52
|
+
export function clearRateLimit(ip) {
|
|
53
|
+
attemptMap.delete(ip);
|
|
54
|
+
}
|
|
55
|
+
export function generateCookieToken() {
|
|
56
|
+
return crypto.randomBytes(32).toString('hex');
|
|
57
|
+
}
|
|
58
|
+
export function isLegacyHash(hash) {
|
|
59
|
+
return !!hash && !hash.startsWith('scrypt:');
|
|
60
|
+
}
|
|
61
|
+
export function _resetForTesting() {
|
|
62
|
+
attemptMap.clear();
|
|
63
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { Router } from 'express';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
8
|
+
const CACHE_TTL_MS = 60_000;
|
|
9
|
+
let cache = null;
|
|
10
|
+
/** Clears the branch linker cache (call when sessions are created or ended). */
|
|
11
|
+
export function invalidateBranchLinkerCache() {
|
|
12
|
+
cache = null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Extracts all ticket IDs from a branch name.
|
|
16
|
+
* Returns an array of normalized ticket IDs (e.g. "PROJ-123", "GH-456").
|
|
17
|
+
*/
|
|
18
|
+
function extractTicketIds(branchName) {
|
|
19
|
+
const ids = [];
|
|
20
|
+
// Jira/Linear style: PROJECT-123 (2+ uppercase letters, dash, digits)
|
|
21
|
+
// Skip "GH" prefix — that's our GitHub Issues namespace, handled separately below.
|
|
22
|
+
const jiraRegex = /([A-Z]{2,}-\d+)/gi;
|
|
23
|
+
let match;
|
|
24
|
+
while ((match = jiraRegex.exec(branchName)) !== null) {
|
|
25
|
+
if (match[1] && match[1].toUpperCase().split('-')[0] !== 'GH') {
|
|
26
|
+
ids.push(match[1].toUpperCase());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// GitHub Issues: gh-123 at word boundaries (start/end or preceded/followed by dash or slash)
|
|
30
|
+
const ghRegex = /(?:^|[-/])gh-(\d+)(?:[-/]|$)/gi;
|
|
31
|
+
while ((match = ghRegex.exec(branchName)) !== null) {
|
|
32
|
+
ids.push(`GH-${match[1]}`);
|
|
33
|
+
}
|
|
34
|
+
return ids;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates and returns an Express Router that handles all /branch-linker routes.
|
|
38
|
+
*
|
|
39
|
+
* Caller is responsible for mounting and applying auth middleware:
|
|
40
|
+
* app.use('/branch-linker', requireAuth, createBranchLinkerRouter({ configPath }));
|
|
41
|
+
*/
|
|
42
|
+
export function createBranchLinkerRouter(deps) {
|
|
43
|
+
const { configPath } = deps;
|
|
44
|
+
const exec = deps.execAsync ?? execFileAsync;
|
|
45
|
+
const getActiveBranchNames = deps.getActiveBranchNames ?? (() => new Map());
|
|
46
|
+
const router = Router();
|
|
47
|
+
function getConfig() {
|
|
48
|
+
return loadConfig(configPath);
|
|
49
|
+
}
|
|
50
|
+
/** Core link-building logic, usable both from the HTTP handler and internal callers. */
|
|
51
|
+
async function fetchLinks() {
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
const workspacePaths = config.workspaces ?? [];
|
|
54
|
+
if (workspacePaths.length === 0) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
// Return cached result if still fresh
|
|
59
|
+
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) {
|
|
60
|
+
return cache.links;
|
|
61
|
+
}
|
|
62
|
+
// Get active branch names per repo from sessions
|
|
63
|
+
const activeBranchNames = getActiveBranchNames();
|
|
64
|
+
// Fetch branches per workspace using Promise.allSettled (partial failures are non-fatal)
|
|
65
|
+
const results = await Promise.allSettled(workspacePaths.map(async (wsPath) => {
|
|
66
|
+
let stdout;
|
|
67
|
+
try {
|
|
68
|
+
({ stdout } = await exec('git', ['branch', '--format=%(refname:short)'], { cwd: wsPath, timeout: GIT_TIMEOUT_MS }));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Not a git repo or git not available — non-fatal
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const repoName = path.basename(wsPath);
|
|
75
|
+
const activeInRepo = activeBranchNames.get(wsPath) ?? new Set();
|
|
76
|
+
const branchNames = stdout.split('\n').map((b) => b.trim()).filter(Boolean);
|
|
77
|
+
const links = [];
|
|
78
|
+
for (const branchName of branchNames) {
|
|
79
|
+
const ticketIds = extractTicketIds(branchName);
|
|
80
|
+
for (const ticketId of ticketIds) {
|
|
81
|
+
links.push({
|
|
82
|
+
ticketId,
|
|
83
|
+
link: {
|
|
84
|
+
repoPath: wsPath,
|
|
85
|
+
repoName,
|
|
86
|
+
branchName,
|
|
87
|
+
hasActiveSession: activeInRepo.has(branchName),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return links;
|
|
93
|
+
}));
|
|
94
|
+
// Build the ticket -> BranchLink[] map
|
|
95
|
+
const linksMap = new Map();
|
|
96
|
+
for (const result of results) {
|
|
97
|
+
if (result.status === 'fulfilled') {
|
|
98
|
+
for (const { ticketId, link } of result.value) {
|
|
99
|
+
const existing = linksMap.get(ticketId);
|
|
100
|
+
if (existing) {
|
|
101
|
+
existing.push(link);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
linksMap.set(ticketId, [link]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Convert Map to plain object for JSON serialization
|
|
110
|
+
const response = {};
|
|
111
|
+
for (const [ticketId, links] of linksMap) {
|
|
112
|
+
response[ticketId] = links;
|
|
113
|
+
}
|
|
114
|
+
// Update module-level cache
|
|
115
|
+
cache = { links: response, fetchedAt: now };
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
// GET /branch-linker/links — map of ticketId -> BranchLink[]
|
|
119
|
+
router.get('/links', async (_req, res) => {
|
|
120
|
+
const response = await fetchLinks();
|
|
121
|
+
res.json(response);
|
|
122
|
+
});
|
|
123
|
+
return Object.assign(router, { fetchLinks });
|
|
124
|
+
}
|