feat/updates #1
610 changed files with 27317 additions and 1329 deletions
|
|
@ -41,7 +41,7 @@ steps:
|
|||
settings:
|
||||
api_key:
|
||||
from_secret: forgejo_token
|
||||
base_url: https://forge.lthn.ai
|
||||
base_url: https://forge.lthn.io
|
||||
files:
|
||||
- bin/bugseti-linux-amd64.tar.gz
|
||||
- bin/bugseti-linux-amd64.tar.gz.sha256
|
||||
|
|
|
|||
|
|
@ -1,40 +1,50 @@
|
|||
module github.com/host-uk/core/cmd/bugseti
|
||||
module forge.lthn.ai/core/cli/cmd/bugseti
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/cli v0.0.0
|
||||
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
|
||||
forge.lthn.ai/core/cli/internal/bugseti/updater v0.0.0
|
||||
github.com/Snider/Borg v0.2.0
|
||||
github.com/host-uk/core v0.0.0
|
||||
github.com/host-uk/core/internal/bugseti v0.0.0
|
||||
github.com/host-uk/core/internal/bugseti/updater v0.0.0
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
|
||||
)
|
||||
|
||||
replace github.com/host-uk/core => ../..
|
||||
replace forge.lthn.ai/core/cli => ../..
|
||||
|
||||
replace github.com/host-uk/core/internal/bugseti => ../../internal/bugseti
|
||||
replace forge.lthn.ai/core/cli/internal/bugseti => ../../internal/bugseti
|
||||
|
||||
replace github.com/host-uk/core/internal/bugseti/updater => ../../internal/bugseti/updater
|
||||
replace forge.lthn.ai/core/cli/internal/bugseti/updater => ../../internal/bugseti/updater
|
||||
|
||||
require (
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/Snider/Enchantrix v0.0.2 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.7.0 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
|
|
@ -42,20 +52,34 @@ require (
|
|||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lmittmann/tint v1.1.2 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mark3labs/mcp-go v0.43.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
|
|
@ -15,8 +17,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
|||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
|
|
@ -27,14 +31,18 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
|
|
@ -47,6 +55,7 @@ github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRko
|
|||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
|
|
@ -55,6 +64,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
|
|
@ -76,6 +87,8 @@ github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
|||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
|
|
@ -85,6 +98,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
|
|
@ -99,6 +113,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
|
|
@ -106,17 +121,26 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
|
|||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// BugSETI - "Distributed Bug Fixing like SETI@home but for code"
|
||||
//
|
||||
// The application runs as a system tray app that:
|
||||
// - Pulls OSS issues from GitHub
|
||||
// - Pulls OSS issues from Forgejo
|
||||
// - Uses AI to prepare context for each issue
|
||||
// - Presents issues to users for fixing
|
||||
// - Automates PR submission
|
||||
|
|
@ -16,9 +16,9 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/cmd/bugseti/icons"
|
||||
"github.com/host-uk/core/internal/bugseti"
|
||||
"github.com/host-uk/core/internal/bugseti/updater"
|
||||
"forge.lthn.ai/core/cli/cmd/bugseti/icons"
|
||||
"forge.lthn.ai/core/cli/internal/bugseti"
|
||||
"forge.lthn.ai/core/cli/internal/bugseti/updater"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
)
|
||||
|
|
@ -39,13 +39,20 @@ func main() {
|
|||
log.Printf("Warning: Could not load config: %v", err)
|
||||
}
|
||||
|
||||
// Check Forgejo API availability
|
||||
forgeClient, err := bugseti.CheckForge()
|
||||
if err != nil {
|
||||
log.Fatalf("Forgejo check failed: %v\n\nConfigure with: core forge config --url URL --token TOKEN", err)
|
||||
}
|
||||
|
||||
// Initialize core services
|
||||
notifyService := bugseti.NewNotifyService(configService)
|
||||
statsService := bugseti.NewStatsService(configService)
|
||||
fetcherService := bugseti.NewFetcherService(configService, notifyService)
|
||||
fetcherService := bugseti.NewFetcherService(configService, notifyService, forgeClient)
|
||||
queueService := bugseti.NewQueueService(configService)
|
||||
seederService := bugseti.NewSeederService(configService)
|
||||
submitService := bugseti.NewSubmitService(configService, notifyService, statsService)
|
||||
seederService := bugseti.NewSeederService(configService, forgeClient.URL(), forgeClient.Token())
|
||||
submitService := bugseti.NewSubmitService(configService, notifyService, statsService, forgeClient)
|
||||
hubService := bugseti.NewHubService(configService)
|
||||
versionService := bugseti.NewVersionService()
|
||||
workspaceService := NewWorkspaceService(configService)
|
||||
|
||||
|
|
@ -69,6 +76,7 @@ func main() {
|
|||
application.NewService(submitService),
|
||||
application.NewService(versionService),
|
||||
application.NewService(workspaceService),
|
||||
application.NewService(hubService),
|
||||
application.NewService(trayService),
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +115,19 @@ func main() {
|
|||
log.Println(" - Waiting for issues...")
|
||||
log.Printf(" - Version: %s (%s)", bugseti.GetVersion(), bugseti.GetChannel())
|
||||
|
||||
// Attempt hub registration (non-blocking)
|
||||
if hubURL := configService.GetHubURL(); hubURL != "" {
|
||||
if err := hubService.AutoRegister(); err != nil {
|
||||
log.Printf(" - Hub: auto-register skipped: %v", err)
|
||||
} else if err := hubService.Register(); err != nil {
|
||||
log.Printf(" - Hub: registration failed: %v", err)
|
||||
} else {
|
||||
log.Println(" - Hub: registered with portal")
|
||||
}
|
||||
} else {
|
||||
log.Println(" - Hub: not configured (set hubUrl in config)")
|
||||
}
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/host-uk/core/internal/bugseti"
|
||||
"forge.lthn.ai/core/cli/internal/bugseti"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,22 @@ import (
|
|||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Borg/pkg/tim"
|
||||
"github.com/host-uk/core/internal/bugseti"
|
||||
"github.com/host-uk/core/pkg/io/datanode"
|
||||
"forge.lthn.ai/core/cli/internal/bugseti"
|
||||
"forge.lthn.ai/core/cli/pkg/io/datanode"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultMaxWorkspaces is the fallback upper bound when config is unavailable.
|
||||
defaultMaxWorkspaces = 100
|
||||
// defaultWorkspaceTTL is the fallback TTL when config is unavailable.
|
||||
defaultWorkspaceTTL = 24 * time.Hour
|
||||
// sweepInterval is how often the background sweeper runs.
|
||||
sweepInterval = 5 * time.Minute
|
||||
)
|
||||
|
||||
// WorkspaceService manages DataNode-backed workspaces for issues.
|
||||
|
|
@ -20,8 +30,10 @@ import (
|
|||
// snapshotted, packaged as a TIM container, or shipped as a crash report.
|
||||
type WorkspaceService struct {
|
||||
config *bugseti.ConfigService
|
||||
workspaces map[string]*Workspace // issue ID → workspace
|
||||
workspaces map[string]*Workspace // issue ID -> workspace
|
||||
mu sync.RWMutex
|
||||
done chan struct{} // signals the background sweeper to stop
|
||||
stopped chan struct{} // closed when the sweeper goroutine exits
|
||||
}
|
||||
|
||||
// Workspace tracks a DataNode-backed workspace for an issue.
|
||||
|
|
@ -47,10 +59,13 @@ type CrashReport struct {
|
|||
}
|
||||
|
||||
// NewWorkspaceService creates a new WorkspaceService.
|
||||
// Call Start() to begin the background TTL sweeper.
|
||||
func NewWorkspaceService(config *bugseti.ConfigService) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
config: config,
|
||||
workspaces: make(map[string]*Workspace),
|
||||
done: make(chan struct{}),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +74,56 @@ func (w *WorkspaceService) ServiceName() string {
|
|||
return "WorkspaceService"
|
||||
}
|
||||
|
||||
// Start launches the background sweeper goroutine that periodically
|
||||
// evicts expired workspaces. This prevents unbounded map growth even
|
||||
// when no new Capture calls arrive.
|
||||
func (w *WorkspaceService) Start() {
|
||||
go func() {
|
||||
defer close(w.stopped)
|
||||
ticker := time.NewTicker(sweepInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.mu.Lock()
|
||||
evicted := w.cleanup()
|
||||
w.mu.Unlock()
|
||||
if evicted > 0 {
|
||||
log.Printf("Workspace sweeper: evicted %d stale entries, %d remaining", evicted, w.ActiveWorkspaces())
|
||||
}
|
||||
case <-w.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("Workspace sweeper started (interval=%s, ttl=%s, max=%d)",
|
||||
sweepInterval, w.ttl(), w.maxCap())
|
||||
}
|
||||
|
||||
// Stop signals the background sweeper to exit and waits for it to finish.
|
||||
func (w *WorkspaceService) Stop() {
|
||||
close(w.done)
|
||||
<-w.stopped
|
||||
log.Printf("Workspace sweeper stopped")
|
||||
}
|
||||
|
||||
// ttl returns the configured workspace TTL, falling back to the default.
|
||||
func (w *WorkspaceService) ttl() time.Duration {
|
||||
if w.config != nil {
|
||||
return w.config.GetWorkspaceTTL()
|
||||
}
|
||||
return defaultWorkspaceTTL
|
||||
}
|
||||
|
||||
// maxCap returns the configured max workspace count, falling back to the default.
|
||||
func (w *WorkspaceService) maxCap() int {
|
||||
if w.config != nil {
|
||||
return w.config.GetMaxWorkspaces()
|
||||
}
|
||||
return defaultMaxWorkspaces
|
||||
}
|
||||
|
||||
// Capture loads a filesystem workspace into a DataNode Medium.
|
||||
// Call this after git clone to create the in-memory snapshot.
|
||||
func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error {
|
||||
|
|
@ -109,6 +174,7 @@ func (w *WorkspaceService) Capture(issue *bugseti.Issue, diskPath string) error
|
|||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.cleanup()
|
||||
w.workspaces[issue.ID] = &Workspace{
|
||||
Issue: issue,
|
||||
Medium: m,
|
||||
|
|
@ -240,6 +306,46 @@ func (w *WorkspaceService) SaveCrashReport(report *CrashReport) (string, error)
|
|||
return path, nil
|
||||
}
|
||||
|
||||
// cleanup evicts expired workspaces and enforces the max size cap.
|
||||
// Must be called with w.mu held for writing.
|
||||
// Returns the number of evicted entries.
|
||||
func (w *WorkspaceService) cleanup() int {
|
||||
now := time.Now()
|
||||
ttl := w.ttl()
|
||||
cap := w.maxCap()
|
||||
evicted := 0
|
||||
|
||||
// First pass: evict entries older than TTL.
|
||||
for id, ws := range w.workspaces {
|
||||
if now.Sub(ws.CreatedAt) > ttl {
|
||||
delete(w.workspaces, id)
|
||||
evicted++
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: if still over cap, evict oldest entries.
|
||||
if len(w.workspaces) > cap {
|
||||
type entry struct {
|
||||
id string
|
||||
createdAt time.Time
|
||||
}
|
||||
entries := make([]entry, 0, len(w.workspaces))
|
||||
for id, ws := range w.workspaces {
|
||||
entries = append(entries, entry{id, ws.CreatedAt})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].createdAt.Before(entries[j].createdAt)
|
||||
})
|
||||
toEvict := len(w.workspaces) - cap
|
||||
for i := 0; i < toEvict; i++ {
|
||||
delete(w.workspaces, entries[i].id)
|
||||
evicted++
|
||||
}
|
||||
}
|
||||
|
||||
return evicted
|
||||
}
|
||||
|
||||
// Release removes a workspace from memory.
|
||||
func (w *WorkspaceService) Release(issueID string) {
|
||||
w.mu.Lock()
|
||||
|
|
|
|||
151
cmd/bugseti/workspace_test.go
Normal file
151
cmd/bugseti/workspace_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/internal/bugseti"
|
||||
)
|
||||
|
||||
func TestCleanup_TTL(t *testing.T) {
|
||||
svc := NewWorkspaceService(bugseti.NewConfigService())
|
||||
|
||||
// Seed with entries that are older than TTL.
|
||||
svc.mu.Lock()
|
||||
for i := 0; i < 5; i++ {
|
||||
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
|
||||
CreatedAt: time.Now().Add(-25 * time.Hour),
|
||||
}
|
||||
}
|
||||
// Add one fresh entry.
|
||||
svc.workspaces["fresh"] = &Workspace{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
svc.cleanup()
|
||||
svc.mu.Unlock()
|
||||
|
||||
if got := svc.ActiveWorkspaces(); got != 1 {
|
||||
t.Errorf("expected 1 workspace after TTL cleanup, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup_MaxSize(t *testing.T) {
|
||||
svc := NewWorkspaceService(bugseti.NewConfigService())
|
||||
|
||||
maxCap := svc.maxCap()
|
||||
|
||||
// Fill beyond the cap with fresh entries.
|
||||
svc.mu.Lock()
|
||||
for i := 0; i < maxCap+20; i++ {
|
||||
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
|
||||
CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
|
||||
}
|
||||
}
|
||||
svc.cleanup()
|
||||
svc.mu.Unlock()
|
||||
|
||||
if got := svc.ActiveWorkspaces(); got != maxCap {
|
||||
t.Errorf("expected %d workspaces after cap cleanup, got %d", maxCap, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup_EvictsOldestWhenOverCap(t *testing.T) {
|
||||
svc := NewWorkspaceService(bugseti.NewConfigService())
|
||||
|
||||
maxCap := svc.maxCap()
|
||||
|
||||
// Create maxCap+1 entries; the newest should survive.
|
||||
svc.mu.Lock()
|
||||
for i := 0; i <= maxCap; i++ {
|
||||
svc.workspaces[fmt.Sprintf("ws-%d", i)] = &Workspace{
|
||||
CreatedAt: time.Now().Add(-time.Duration(maxCap-i) * time.Minute),
|
||||
}
|
||||
}
|
||||
svc.cleanup()
|
||||
svc.mu.Unlock()
|
||||
|
||||
// The newest entry (ws-<maxCap>) should still exist.
|
||||
newest := fmt.Sprintf("ws-%d", maxCap)
|
||||
|
||||
svc.mu.RLock()
|
||||
_, exists := svc.workspaces[newest]
|
||||
svc.mu.RUnlock()
|
||||
if !exists {
|
||||
t.Error("expected newest workspace to survive eviction")
|
||||
}
|
||||
|
||||
// The oldest entry (ws-0) should have been evicted.
|
||||
svc.mu.RLock()
|
||||
_, exists = svc.workspaces["ws-0"]
|
||||
svc.mu.RUnlock()
|
||||
if exists {
|
||||
t.Error("expected oldest workspace to be evicted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup_ReturnsEvictedCount(t *testing.T) {
|
||||
svc := NewWorkspaceService(bugseti.NewConfigService())
|
||||
|
||||
svc.mu.Lock()
|
||||
for i := 0; i < 3; i++ {
|
||||
svc.workspaces[fmt.Sprintf("old-%d", i)] = &Workspace{
|
||||
CreatedAt: time.Now().Add(-25 * time.Hour),
|
||||
}
|
||||
}
|
||||
svc.workspaces["fresh"] = &Workspace{
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
evicted := svc.cleanup()
|
||||
svc.mu.Unlock()
|
||||
|
||||
if evicted != 3 {
|
||||
t.Errorf("expected 3 evicted entries, got %d", evicted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
svc := NewWorkspaceService(bugseti.NewConfigService())
|
||||
svc.Start()
|
||||
|
||||
// Add a stale entry while the sweeper is running.
|
||||
svc.mu.Lock()
|
||||
svc.workspaces["stale"] = &Workspace{
|
||||
CreatedAt: time.Now().Add(-25 * time.Hour),
|
||||
}
|
||||
svc.mu.Unlock()
|
||||
|
||||
// Stop should return without hanging.
|
||||
svc.Stop()
|
||||
}
|
||||
|
||||
func TestConfigurableTTL(t *testing.T) {
|
||||
cfg := bugseti.NewConfigService()
|
||||
svc := NewWorkspaceService(cfg)
|
||||
|
||||
// Default TTL should be 24h (1440 minutes).
|
||||
if got := svc.ttl(); got != 24*time.Hour {
|
||||
t.Errorf("expected default TTL of 24h, got %s", got)
|
||||
}
|
||||
|
||||
// Default max cap should be 100.
|
||||
if got := svc.maxCap(); got != 100 {
|
||||
t.Errorf("expected default max cap of 100, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilConfigFallback(t *testing.T) {
|
||||
svc := &WorkspaceService{
|
||||
config: nil,
|
||||
workspaces: make(map[string]*Workspace),
|
||||
done: make(chan struct{}),
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
|
||||
if got := svc.ttl(); got != defaultWorkspaceTTL {
|
||||
t.Errorf("expected fallback TTL %s, got %s", defaultWorkspaceTTL, got)
|
||||
}
|
||||
if got := svc.maxCap(); got != defaultMaxWorkspaces {
|
||||
t.Errorf("expected fallback max cap %d, got %d", defaultMaxWorkspaces, got)
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ go build -tags nowatcher -o ../../bin/core-app .
|
|||
## CRITICAL WARNINGS
|
||||
|
||||
- **DO NOT push to GitHub** — GitHub remotes have been removed deliberately. The host-uk org is flagged.
|
||||
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.ai / git.lthn.ai) is the source of truth.
|
||||
- **DO NOT add GitHub as a remote** — Forge (forge.lthn.io / git.lthn.ai) is the source of truth.
|
||||
- **DO NOT modify files outside `cmd/core-app/`** — This is a workspace module, keep changes scoped.
|
||||
- **DO NOT remove the `-tags nowatcher` build flag** — It will fail without libwatcher-c.
|
||||
- **DO NOT change the PHP-ZTS path** — It must be the ZTS variant, not the default Homebrew PHP.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
module github.com/host-uk/core/cmd/core-app
|
||||
module forge.lthn.ai/core/cli/cmd/core-app
|
||||
|
||||
go 1.25.5
|
||||
|
||||
|
|
@ -64,4 +64,4 @@ require (
|
|||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
replace github.com/host-uk/core => ../..
|
||||
replace forge.lthn.ai/core/cli => ../..
|
||||
|
|
|
|||
46
cmd/core-app/laravel/app/Http/Middleware/QuotaMiddleware.php
Normal file
46
cmd/core-app/laravel/app/Http/Middleware/QuotaMiddleware.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\AllowanceService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class QuotaMiddleware
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AllowanceService $allowanceService,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$agentId = $request->header('X-Agent-ID', $request->input('agent_id', ''));
|
||||
$model = $request->input('model', '');
|
||||
|
||||
if ($agentId === '') {
|
||||
return response()->json([
|
||||
'error' => 'agent_id is required',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$result = $this->allowanceService->check($agentId, $model);
|
||||
|
||||
if (! $result['allowed']) {
|
||||
return response()->json([
|
||||
'error' => 'quota_exceeded',
|
||||
'status' => $result['status'],
|
||||
'reason' => $result['reason'],
|
||||
'remaining_tokens' => $result['remaining_tokens'],
|
||||
'remaining_jobs' => $result['remaining_jobs'],
|
||||
], 429);
|
||||
}
|
||||
|
||||
// Attach quota info to request for downstream use
|
||||
$request->merge(['_quota' => $result]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
111
cmd/core-app/laravel/app/Livewire/Dashboard/ActivityFeed.php
Normal file
111
cmd/core-app/laravel/app/Livewire/Dashboard/ActivityFeed.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ActivityFeed extends Component
|
||||
{
|
||||
public array $entries = [];
|
||||
public string $agentFilter = 'all';
|
||||
public string $typeFilter = 'all';
|
||||
public bool $showOnlyQuestions = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadEntries();
|
||||
}
|
||||
|
||||
public function loadEntries(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real-time WebSocket feed
|
||||
$this->entries = [
|
||||
[
|
||||
'id' => 'act-001',
|
||||
'agent' => 'Athena',
|
||||
'type' => 'code_write',
|
||||
'message' => 'Created AgentFleet Livewire component',
|
||||
'job' => '#96',
|
||||
'timestamp' => now()->subMinutes(2)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-002',
|
||||
'agent' => 'Athena',
|
||||
'type' => 'tool_call',
|
||||
'message' => 'Read file: cmd/core-app/laravel/composer.json',
|
||||
'job' => '#96',
|
||||
'timestamp' => now()->subMinutes(5)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-003',
|
||||
'agent' => 'Clotho',
|
||||
'type' => 'question',
|
||||
'message' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
|
||||
'job' => '#84',
|
||||
'timestamp' => now()->subMinutes(8)->toIso8601String(),
|
||||
'is_question' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'act-004',
|
||||
'agent' => 'Virgil',
|
||||
'type' => 'pr_created',
|
||||
'message' => 'Opened PR #89: fix WebSocket reconnection logic',
|
||||
'job' => '#89',
|
||||
'timestamp' => now()->subMinutes(15)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-005',
|
||||
'agent' => 'Virgil',
|
||||
'type' => 'test_run',
|
||||
'message' => 'All 47 tests passed (0.8s)',
|
||||
'job' => '#89',
|
||||
'timestamp' => now()->subMinutes(18)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-006',
|
||||
'agent' => 'Athena',
|
||||
'type' => 'git_push',
|
||||
'message' => 'Pushed branch feat/agentic-dashboard',
|
||||
'job' => '#96',
|
||||
'timestamp' => now()->subMinutes(22)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
[
|
||||
'id' => 'act-007',
|
||||
'agent' => 'Clotho',
|
||||
'type' => 'code_write',
|
||||
'message' => 'Added input validation for MCP file_write paths',
|
||||
'job' => '#84',
|
||||
'timestamp' => now()->subMinutes(30)->toIso8601String(),
|
||||
'is_question' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getFilteredEntriesProperty(): array
|
||||
{
|
||||
return array_filter($this->entries, function ($entry) {
|
||||
if ($this->showOnlyQuestions && !$entry['is_question']) {
|
||||
return false;
|
||||
}
|
||||
if ($this->agentFilter !== 'all' && $entry['agent'] !== $this->agentFilter) {
|
||||
return false;
|
||||
}
|
||||
if ($this->typeFilter !== 'all' && $entry['type'] !== $this->typeFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.activity-feed');
|
||||
}
|
||||
}
|
||||
85
cmd/core-app/laravel/app/Livewire/Dashboard/AgentFleet.php
Normal file
85
cmd/core-app/laravel/app/Livewire/Dashboard/AgentFleet.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class AgentFleet extends Component
|
||||
{
|
||||
/** @var array<int, array{name: string, host: string, model: string, status: string, job: string, heartbeat: string, uptime: string}> */
|
||||
public array $agents = [];
|
||||
|
||||
public ?string $selectedAgent = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadAgents();
|
||||
}
|
||||
|
||||
public function loadAgents(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real API calls to Go backend
|
||||
$this->agents = [
|
||||
[
|
||||
'id' => 'athena',
|
||||
'name' => 'Athena',
|
||||
'host' => 'studio.snider.dev',
|
||||
'model' => 'claude-opus-4-6',
|
||||
'status' => 'working',
|
||||
'job' => '#96 agentic dashboard',
|
||||
'heartbeat' => 'green',
|
||||
'uptime' => '4h 23m',
|
||||
'tokens_today' => 142_580,
|
||||
'jobs_completed' => 3,
|
||||
],
|
||||
[
|
||||
'id' => 'virgil',
|
||||
'name' => 'Virgil',
|
||||
'host' => 'studio.snider.dev',
|
||||
'model' => 'claude-opus-4-6',
|
||||
'status' => 'idle',
|
||||
'job' => '',
|
||||
'heartbeat' => 'green',
|
||||
'uptime' => '12h 07m',
|
||||
'tokens_today' => 89_230,
|
||||
'jobs_completed' => 5,
|
||||
],
|
||||
[
|
||||
'id' => 'clotho',
|
||||
'name' => 'Clotho',
|
||||
'host' => 'darwin-au',
|
||||
'model' => 'claude-sonnet-4-5',
|
||||
'status' => 'working',
|
||||
'job' => '#84 security audit',
|
||||
'heartbeat' => 'yellow',
|
||||
'uptime' => '1h 45m',
|
||||
'tokens_today' => 34_100,
|
||||
'jobs_completed' => 1,
|
||||
],
|
||||
[
|
||||
'id' => 'charon',
|
||||
'name' => 'Charon',
|
||||
'host' => 'linux.snider.dev',
|
||||
'model' => 'claude-haiku-4-5',
|
||||
'status' => 'unhealthy',
|
||||
'job' => '',
|
||||
'heartbeat' => 'red',
|
||||
'uptime' => '0m',
|
||||
'tokens_today' => 0,
|
||||
'jobs_completed' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function selectAgent(string $agentId): void
|
||||
{
|
||||
$this->selectedAgent = $this->selectedAgent === $agentId ? null : $agentId;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.agent-fleet');
|
||||
}
|
||||
}
|
||||
93
cmd/core-app/laravel/app/Livewire/Dashboard/HumanActions.php
Normal file
93
cmd/core-app/laravel/app/Livewire/Dashboard/HumanActions.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class HumanActions extends Component
|
||||
{
|
||||
public array $pendingQuestions = [];
|
||||
public array $reviewGates = [];
|
||||
public string $answerText = '';
|
||||
public ?string $answeringId = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadPending();
|
||||
}
|
||||
|
||||
public function loadPending(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real data from Go backend
|
||||
$this->pendingQuestions = [
|
||||
[
|
||||
'id' => 'q-001',
|
||||
'agent' => 'Clotho',
|
||||
'job' => '#84',
|
||||
'question' => 'Should I apply the fix to both the TCP and Unix socket transports, or just TCP?',
|
||||
'asked_at' => now()->subMinutes(8)->toIso8601String(),
|
||||
'context' => 'Working on security audit — found unvalidated input in transport layer.',
|
||||
],
|
||||
];
|
||||
|
||||
$this->reviewGates = [
|
||||
[
|
||||
'id' => 'rg-001',
|
||||
'agent' => 'Virgil',
|
||||
'job' => '#89',
|
||||
'type' => 'pr_review',
|
||||
'title' => 'PR #89: fix WebSocket reconnection logic',
|
||||
'description' => 'Adds exponential backoff and connection state tracking.',
|
||||
'submitted_at' => now()->subMinutes(15)->toIso8601String(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function startAnswer(string $questionId): void
|
||||
{
|
||||
$this->answeringId = $questionId;
|
||||
$this->answerText = '';
|
||||
}
|
||||
|
||||
public function submitAnswer(): void
|
||||
{
|
||||
if (! $this->answeringId || trim($this->answerText) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove answered question from list
|
||||
$this->pendingQuestions = array_values(
|
||||
array_filter($this->pendingQuestions, fn ($q) => $q['id'] !== $this->answeringId)
|
||||
);
|
||||
|
||||
$this->answeringId = null;
|
||||
$this->answerText = '';
|
||||
}
|
||||
|
||||
public function cancelAnswer(): void
|
||||
{
|
||||
$this->answeringId = null;
|
||||
$this->answerText = '';
|
||||
}
|
||||
|
||||
public function approveGate(string $gateId): void
|
||||
{
|
||||
$this->reviewGates = array_values(
|
||||
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
|
||||
);
|
||||
}
|
||||
|
||||
public function rejectGate(string $gateId): void
|
||||
{
|
||||
$this->reviewGates = array_values(
|
||||
array_filter($this->reviewGates, fn ($g) => $g['id'] !== $gateId)
|
||||
);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.human-actions');
|
||||
}
|
||||
}
|
||||
125
cmd/core-app/laravel/app/Livewire/Dashboard/JobQueue.php
Normal file
125
cmd/core-app/laravel/app/Livewire/Dashboard/JobQueue.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class JobQueue extends Component
|
||||
{
|
||||
public array $jobs = [];
|
||||
public string $statusFilter = 'all';
|
||||
public string $agentFilter = 'all';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadJobs();
|
||||
}
|
||||
|
||||
public function loadJobs(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real API calls to Go backend
|
||||
$this->jobs = [
|
||||
[
|
||||
'id' => 'job-001',
|
||||
'issue' => '#96',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'feat(agentic): real-time dashboard',
|
||||
'agent' => 'Athena',
|
||||
'status' => 'in_progress',
|
||||
'priority' => 1,
|
||||
'queued_at' => now()->subMinutes(45)->toIso8601String(),
|
||||
'started_at' => now()->subMinutes(30)->toIso8601String(),
|
||||
],
|
||||
[
|
||||
'id' => 'job-002',
|
||||
'issue' => '#84',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'fix: security audit findings',
|
||||
'agent' => 'Clotho',
|
||||
'status' => 'in_progress',
|
||||
'priority' => 2,
|
||||
'queued_at' => now()->subHours(2)->toIso8601String(),
|
||||
'started_at' => now()->subHours(1)->toIso8601String(),
|
||||
],
|
||||
[
|
||||
'id' => 'job-003',
|
||||
'issue' => '#102',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'feat: add rate limiting to MCP',
|
||||
'agent' => null,
|
||||
'status' => 'queued',
|
||||
'priority' => 3,
|
||||
'queued_at' => now()->subMinutes(10)->toIso8601String(),
|
||||
'started_at' => null,
|
||||
],
|
||||
[
|
||||
'id' => 'job-004',
|
||||
'issue' => '#89',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'fix: WebSocket reconnection',
|
||||
'agent' => 'Virgil',
|
||||
'status' => 'review',
|
||||
'priority' => 2,
|
||||
'queued_at' => now()->subHours(4)->toIso8601String(),
|
||||
'started_at' => now()->subHours(3)->toIso8601String(),
|
||||
],
|
||||
[
|
||||
'id' => 'job-005',
|
||||
'issue' => '#78',
|
||||
'repo' => 'host-uk/core',
|
||||
'title' => 'docs: update CLAUDE.md',
|
||||
'agent' => 'Virgil',
|
||||
'status' => 'completed',
|
||||
'priority' => 4,
|
||||
'queued_at' => now()->subHours(6)->toIso8601String(),
|
||||
'started_at' => now()->subHours(5)->toIso8601String(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
// Livewire auto-updates the view
|
||||
}
|
||||
|
||||
public function cancelJob(string $jobId): void
|
||||
{
|
||||
$this->jobs = array_map(function ($job) use ($jobId) {
|
||||
if ($job['id'] === $jobId && in_array($job['status'], ['queued', 'in_progress'])) {
|
||||
$job['status'] = 'cancelled';
|
||||
}
|
||||
return $job;
|
||||
}, $this->jobs);
|
||||
}
|
||||
|
||||
public function retryJob(string $jobId): void
|
||||
{
|
||||
$this->jobs = array_map(function ($job) use ($jobId) {
|
||||
if ($job['id'] === $jobId && in_array($job['status'], ['failed', 'cancelled'])) {
|
||||
$job['status'] = 'queued';
|
||||
$job['agent'] = null;
|
||||
}
|
||||
return $job;
|
||||
}, $this->jobs);
|
||||
}
|
||||
|
||||
public function getFilteredJobsProperty(): array
|
||||
{
|
||||
return array_filter($this->jobs, function ($job) {
|
||||
if ($this->statusFilter !== 'all' && $job['status'] !== $this->statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if ($this->agentFilter !== 'all' && ($job['agent'] ?? '') !== $this->agentFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.job-queue');
|
||||
}
|
||||
}
|
||||
60
cmd/core-app/laravel/app/Livewire/Dashboard/Metrics.php
Normal file
60
cmd/core-app/laravel/app/Livewire/Dashboard/Metrics.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Metrics extends Component
|
||||
{
|
||||
public array $stats = [];
|
||||
public array $throughputData = [];
|
||||
public array $costBreakdown = [];
|
||||
public float $budgetUsed = 0;
|
||||
public float $budgetLimit = 0;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadMetrics();
|
||||
}
|
||||
|
||||
public function loadMetrics(): void
|
||||
{
|
||||
// Placeholder data — will be replaced with real metrics from Go backend
|
||||
$this->stats = [
|
||||
'jobs_completed' => 12,
|
||||
'prs_merged' => 8,
|
||||
'tokens_used' => 1_245_800,
|
||||
'cost_today' => 18.42,
|
||||
'active_agents' => 3,
|
||||
'queue_depth' => 4,
|
||||
];
|
||||
|
||||
$this->budgetUsed = 18.42;
|
||||
$this->budgetLimit = 50.00;
|
||||
|
||||
// Hourly throughput for chart
|
||||
$this->throughputData = [
|
||||
['hour' => '00:00', 'jobs' => 0, 'tokens' => 0],
|
||||
['hour' => '02:00', 'jobs' => 0, 'tokens' => 0],
|
||||
['hour' => '04:00', 'jobs' => 1, 'tokens' => 45_000],
|
||||
['hour' => '06:00', 'jobs' => 2, 'tokens' => 120_000],
|
||||
['hour' => '08:00', 'jobs' => 3, 'tokens' => 195_000],
|
||||
['hour' => '10:00', 'jobs' => 2, 'tokens' => 280_000],
|
||||
['hour' => '12:00', 'jobs' => 1, 'tokens' => 340_000],
|
||||
['hour' => '14:00', 'jobs' => 3, 'tokens' => 450_000],
|
||||
];
|
||||
|
||||
$this->costBreakdown = [
|
||||
['model' => 'claude-opus-4-6', 'cost' => 12.80, 'tokens' => 856_000],
|
||||
['model' => 'claude-sonnet-4-5', 'cost' => 4.20, 'tokens' => 312_000],
|
||||
['model' => 'claude-haiku-4-5', 'cost' => 1.42, 'tokens' => 77_800],
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.metrics');
|
||||
}
|
||||
}
|
||||
43
cmd/core-app/laravel/app/Models/AgentAllowance.php
Normal file
43
cmd/core-app/laravel/app/Models/AgentAllowance.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AgentAllowance extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'daily_token_limit',
|
||||
'daily_job_limit',
|
||||
'concurrent_jobs',
|
||||
'max_job_duration_minutes',
|
||||
'model_allowlist',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'daily_token_limit' => 'integer',
|
||||
'daily_job_limit' => 'integer',
|
||||
'concurrent_jobs' => 'integer',
|
||||
'max_job_duration_minutes' => 'integer',
|
||||
'model_allowlist' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function usageRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuotaUsage::class, 'agent_id', 'agent_id');
|
||||
}
|
||||
|
||||
public function todayUsage(): ?QuotaUsage
|
||||
{
|
||||
return $this->usageRecords()
|
||||
->where('period_date', now()->toDateString())
|
||||
->first();
|
||||
}
|
||||
}
|
||||
26
cmd/core-app/laravel/app/Models/ModelQuota.php
Normal file
26
cmd/core-app/laravel/app/Models/ModelQuota.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ModelQuota extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'model',
|
||||
'daily_token_budget',
|
||||
'hourly_rate_limit',
|
||||
'cost_ceiling',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'daily_token_budget' => 'integer',
|
||||
'hourly_rate_limit' => 'integer',
|
||||
'cost_ceiling' => 'integer',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
cmd/core-app/laravel/app/Models/QuotaUsage.php
Normal file
36
cmd/core-app/laravel/app/Models/QuotaUsage.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuotaUsage extends Model
|
||||
{
|
||||
protected $table = 'quota_usage';
|
||||
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'tokens_used',
|
||||
'jobs_started',
|
||||
'active_jobs',
|
||||
'period_date',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tokens_used' => 'integer',
|
||||
'jobs_started' => 'integer',
|
||||
'active_jobs' => 'integer',
|
||||
'period_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function allowance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AgentAllowance::class, 'agent_id', 'agent_id');
|
||||
}
|
||||
}
|
||||
29
cmd/core-app/laravel/app/Models/UsageReport.php
Normal file
29
cmd/core-app/laravel/app/Models/UsageReport.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UsageReport extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'agent_id',
|
||||
'job_id',
|
||||
'model',
|
||||
'tokens_in',
|
||||
'tokens_out',
|
||||
'event',
|
||||
'reported_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tokens_in' => 'integer',
|
||||
'tokens_out' => 'integer',
|
||||
'reported_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,29 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Forgejo\ForgejoService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Throwable;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(ForgejoService::class, function ($app): ForgejoService {
|
||||
/** @var array<string, mixed> $config */
|
||||
$config = $app['config']->get('forgejo', []);
|
||||
|
||||
return new ForgejoService(
|
||||
instances: $config['instances'] ?? [],
|
||||
defaultInstance: $config['default'] ?? 'forge',
|
||||
timeout: $config['timeout'] ?? 30,
|
||||
retryTimes: $config['retry_times'] ?? 3,
|
||||
retrySleep: $config['retry_sleep'] ?? 500,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Auto-migrate on first boot. Single-user desktop app with
|
||||
|
|
|
|||
183
cmd/core-app/laravel/app/Services/AllowanceService.php
Normal file
183
cmd/core-app/laravel/app/Services/AllowanceService.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AgentAllowance;
|
||||
use App\Models\ModelQuota;
|
||||
use App\Models\QuotaUsage;
|
||||
use App\Models\UsageReport;
|
||||
|
||||
class AllowanceService
|
||||
{
|
||||
/**
|
||||
* Pre-dispatch check: verify agent has remaining allowance.
|
||||
*
|
||||
* @return array{allowed: bool, status: string, remaining_tokens: int, remaining_jobs: int, reason: ?string}
|
||||
*/
|
||||
public function check(string $agentId, string $model = ''): array
|
||||
{
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'remaining_tokens' => 0,
|
||||
'remaining_jobs' => 0,
|
||||
'reason' => 'no allowance configured for agent',
|
||||
];
|
||||
}
|
||||
|
||||
$usage = QuotaUsage::firstOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
|
||||
$result = [
|
||||
'allowed' => true,
|
||||
'status' => 'ok',
|
||||
'remaining_tokens' => -1,
|
||||
'remaining_jobs' => -1,
|
||||
'reason' => null,
|
||||
];
|
||||
|
||||
// Check model allowlist
|
||||
if ($model !== '' && ! empty($allowance->model_allowlist)) {
|
||||
if (! in_array($model, $allowance->model_allowlist, true)) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => "model not in allowlist: {$model}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily token limit
|
||||
if ($allowance->daily_token_limit > 0) {
|
||||
$remaining = $allowance->daily_token_limit - $usage->tokens_used;
|
||||
$result['remaining_tokens'] = $remaining;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'daily token limit exceeded',
|
||||
]);
|
||||
}
|
||||
|
||||
$ratio = $usage->tokens_used / $allowance->daily_token_limit;
|
||||
if ($ratio >= 0.8) {
|
||||
$result['status'] = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily job limit
|
||||
if ($allowance->daily_job_limit > 0) {
|
||||
$remaining = $allowance->daily_job_limit - $usage->jobs_started;
|
||||
$result['remaining_jobs'] = $remaining;
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'daily job limit exceeded',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrent jobs
|
||||
if ($allowance->concurrent_jobs > 0 && $usage->active_jobs >= $allowance->concurrent_jobs) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => 'concurrent job limit reached',
|
||||
]);
|
||||
}
|
||||
|
||||
// Check global model quota
|
||||
if ($model !== '') {
|
||||
$modelQuota = ModelQuota::where('model', $model)->first();
|
||||
|
||||
if ($modelQuota && $modelQuota->daily_token_budget > 0) {
|
||||
$modelUsage = UsageReport::where('model', $model)
|
||||
->whereDate('reported_at', now()->toDateString())
|
||||
->sum(\DB::raw('tokens_in + tokens_out'));
|
||||
|
||||
if ($modelUsage >= $modelQuota->daily_token_budget) {
|
||||
return array_merge($result, [
|
||||
'allowed' => false,
|
||||
'status' => 'exceeded',
|
||||
'reason' => "global model token budget exceeded for: {$model}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage from an agent runner report.
|
||||
*/
|
||||
public function recordUsage(array $report): void
|
||||
{
|
||||
$agentId = $report['agent_id'];
|
||||
$totalTokens = ($report['tokens_in'] ?? 0) + ($report['tokens_out'] ?? 0);
|
||||
|
||||
$usage = QuotaUsage::firstOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
|
||||
// Persist the raw report
|
||||
UsageReport::create([
|
||||
'agent_id' => $report['agent_id'],
|
||||
'job_id' => $report['job_id'],
|
||||
'model' => $report['model'] ?? null,
|
||||
'tokens_in' => $report['tokens_in'] ?? 0,
|
||||
'tokens_out' => $report['tokens_out'] ?? 0,
|
||||
'event' => $report['event'],
|
||||
'reported_at' => $report['timestamp'] ?? now(),
|
||||
]);
|
||||
|
||||
match ($report['event']) {
|
||||
'job_started' => $usage->increment('jobs_started') || $usage->increment('active_jobs'),
|
||||
'job_completed' => $this->handleCompleted($usage, $totalTokens),
|
||||
'job_failed' => $this->handleFailed($usage, $totalTokens),
|
||||
'job_cancelled' => $this->handleCancelled($usage, $totalTokens),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset daily usage counters for an agent.
|
||||
*/
|
||||
public function resetAgent(string $agentId): void
|
||||
{
|
||||
QuotaUsage::updateOrCreate(
|
||||
['agent_id' => $agentId, 'period_date' => now()->toDateString()],
|
||||
['tokens_used' => 0, 'jobs_started' => 0, 'active_jobs' => 0],
|
||||
);
|
||||
}
|
||||
|
||||
private function handleCompleted(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$usage->increment('tokens_used', $totalTokens);
|
||||
$usage->decrement('active_jobs');
|
||||
}
|
||||
|
||||
private function handleFailed(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$returnAmount = intdiv($totalTokens, 2);
|
||||
$usage->increment('tokens_used', $totalTokens - $returnAmount);
|
||||
$usage->decrement('active_jobs');
|
||||
}
|
||||
|
||||
private function handleCancelled(QuotaUsage $usage, int $totalTokens): void
|
||||
{
|
||||
$usage->decrement('active_jobs');
|
||||
// 100% returned — no token charge
|
||||
}
|
||||
}
|
||||
155
cmd/core-app/laravel/app/Services/Forgejo/ForgejoClient.php
Normal file
155
cmd/core-app/laravel/app/Services/Forgejo/ForgejoClient.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Forgejo;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Low-level HTTP client for a single Forgejo instance.
|
||||
*
|
||||
* Wraps the Laravel HTTP client with token auth, retry, and
|
||||
* base-URL scoping so callers never deal with raw HTTP details.
|
||||
*/
|
||||
class ForgejoClient
|
||||
{
|
||||
private PendingRequest $http;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $baseUrl,
|
||||
private readonly string $token,
|
||||
int $timeout = 30,
|
||||
int $retryTimes = 3,
|
||||
int $retrySleep = 500,
|
||||
) {
|
||||
if ($this->token === '') {
|
||||
throw new RuntimeException("Forgejo API token is required for {$this->baseUrl}");
|
||||
}
|
||||
|
||||
$this->http = Http::baseUrl(rtrim($this->baseUrl, '/') . '/api/v1')
|
||||
->withHeaders([
|
||||
'Authorization' => "token {$this->token}",
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
])
|
||||
->timeout($timeout)
|
||||
->retry($retryTimes, $retrySleep, fn (?\Throwable $e, PendingRequest $req): bool =>
|
||||
$e instanceof \Illuminate\Http\Client\ConnectionException
|
||||
);
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
// ----- Generic verbs -----
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function get(string $path, array $query = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->get($path, $query));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function post(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->post($path, $data));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function patch(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->patch($path, $data));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function put(string $path, array $data = []): array
|
||||
{
|
||||
return $this->decodeOrFail($this->http->put($path, $data));
|
||||
}
|
||||
|
||||
public function delete(string $path): void
|
||||
{
|
||||
$response = $this->http->delete($path);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo DELETE {$path} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a path and return the raw response body as a string.
|
||||
* Useful for endpoints that return non-JSON content (e.g. diffs).
|
||||
*/
|
||||
public function getRaw(string $path, array $query = []): string
|
||||
{
|
||||
$response = $this->http->get($path, $query);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo GET {$path} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate through all pages of a list endpoint.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function paginate(string $path, array $query = [], int $limit = 50): array
|
||||
{
|
||||
$all = [];
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$response = $this->http->get($path, array_merge($query, [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]));
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo GET {$path} page {$page} failed [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
$items = $response->json();
|
||||
|
||||
if (!is_array($items) || $items === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
array_push($all, ...$items);
|
||||
|
||||
// Forgejo returns total count in x-total-count header.
|
||||
$total = (int) $response->header('x-total-count');
|
||||
$page++;
|
||||
} while (count($all) < $total);
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
// ----- Internals -----
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function decodeOrFail(Response $response): array
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
"Forgejo API error [{$response->status()}]: {$response->body()}"
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
}
|
||||
302
cmd/core-app/laravel/app/Services/Forgejo/ForgejoService.php
Normal file
302
cmd/core-app/laravel/app/Services/Forgejo/ForgejoService.php
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Forgejo;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Business-logic layer for Forgejo operations.
|
||||
*
|
||||
* Manages multiple Forgejo instances (forge, dev, qa) and provides
|
||||
* a unified API for issues, pull requests, repositories, and user
|
||||
* management. Mirrors the Go pkg/forge API surface.
|
||||
*/
|
||||
class ForgejoService
|
||||
{
|
||||
/** @var array<string, ForgejoClient> */
|
||||
private array $clients = [];
|
||||
|
||||
private string $defaultInstance;
|
||||
|
||||
/**
|
||||
* @param array<string, array{url: string, token: string}> $instances
|
||||
*/
|
||||
public function __construct(
|
||||
array $instances,
|
||||
string $defaultInstance = 'forge',
|
||||
private readonly int $timeout = 30,
|
||||
private readonly int $retryTimes = 3,
|
||||
private readonly int $retrySleep = 500,
|
||||
) {
|
||||
$this->defaultInstance = $defaultInstance;
|
||||
|
||||
foreach ($instances as $name => $cfg) {
|
||||
if (($cfg['token'] ?? '') === '') {
|
||||
continue; // skip unconfigured instances
|
||||
}
|
||||
|
||||
$this->clients[$name] = new ForgejoClient(
|
||||
baseUrl: $cfg['url'],
|
||||
token: $cfg['token'],
|
||||
timeout: $this->timeout,
|
||||
retryTimes: $this->retryTimes,
|
||||
retrySleep: $this->retrySleep,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Instance resolution
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
public function client(?string $instance = null): ForgejoClient
|
||||
{
|
||||
$name = $instance ?? $this->defaultInstance;
|
||||
|
||||
return $this->clients[$name]
|
||||
?? throw new RuntimeException("Forgejo instance '{$name}' is not configured or has no token");
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function instances(): array
|
||||
{
|
||||
return array_keys($this->clients);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Issue Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $title,
|
||||
string $body = '',
|
||||
array $labels = [],
|
||||
string $assignee = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['title' => $title, 'body' => $body];
|
||||
|
||||
if ($labels !== []) {
|
||||
$data['labels'] = $labels;
|
||||
}
|
||||
if ($assignee !== '') {
|
||||
$data['assignees'] = [$assignee];
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/issues", $data);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function updateIssue(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
array $fields,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->patch("/repos/{$owner}/{$repo}/issues/{$number}", $fields);
|
||||
}
|
||||
|
||||
public function closeIssue(string $owner, string $repo, int $number, ?string $instance = null): array
|
||||
{
|
||||
return $this->updateIssue($owner, $repo, $number, ['state' => 'closed'], $instance);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function addComment(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
string $body,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post(
|
||||
"/repos/{$owner}/{$repo}/issues/{$number}/comments",
|
||||
['body' => $body],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listIssues(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
int $page = 1,
|
||||
int $limit = 50,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->get("/repos/{$owner}/{$repo}/issues", [
|
||||
'state' => $state,
|
||||
'type' => 'issues',
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
]);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Pull Request Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createPR(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $head,
|
||||
string $base,
|
||||
string $title,
|
||||
string $body = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/pulls", [
|
||||
'head' => $head,
|
||||
'base' => $base,
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
public function mergePR(
|
||||
string $owner,
|
||||
string $repo,
|
||||
int $number,
|
||||
string $strategy = 'merge',
|
||||
?string $instance = null,
|
||||
): void {
|
||||
$this->client($instance)->post("/repos/{$owner}/{$repo}/pulls/{$number}/merge", [
|
||||
'Do' => $strategy,
|
||||
'delete_branch_after_merge' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listPRs(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $state = 'open',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->paginate("/repos/{$owner}/{$repo}/pulls", [
|
||||
'state' => $state,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getPRDiff(string $owner, string $repo, int $number, ?string $instance = null): string
|
||||
{
|
||||
return $this->client($instance)->getRaw("/repos/{$owner}/{$repo}/pulls/{$number}.diff");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Repository Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listRepos(string $org, ?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->paginate("/orgs/{$org}/repos");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function getRepo(string $owner, string $name, ?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->get("/repos/{$owner}/{$name}");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createBranch(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $name,
|
||||
string $from = '',
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['new_branch_name' => $name];
|
||||
|
||||
if ($from !== '') {
|
||||
$data['old_branch_name'] = $from;
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/repos/{$owner}/{$repo}/branches", $data);
|
||||
}
|
||||
|
||||
public function deleteBranch(
|
||||
string $owner,
|
||||
string $repo,
|
||||
string $name,
|
||||
?string $instance = null,
|
||||
): void {
|
||||
$this->client($instance)->delete("/repos/{$owner}/{$repo}/branches/{$name}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// User / Token Management
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createUser(
|
||||
string $username,
|
||||
string $email,
|
||||
string $password,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->post('/admin/users', [
|
||||
'username' => $username,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'must_change_password' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function createToken(
|
||||
string $username,
|
||||
string $name,
|
||||
array $scopes = [],
|
||||
?string $instance = null,
|
||||
): array {
|
||||
$data = ['name' => $name];
|
||||
|
||||
if ($scopes !== []) {
|
||||
$data['scopes'] = $scopes;
|
||||
}
|
||||
|
||||
return $this->client($instance)->post("/users/{$username}/tokens", $data);
|
||||
}
|
||||
|
||||
public function revokeToken(string $username, int $tokenId, ?string $instance = null): void
|
||||
{
|
||||
$this->client($instance)->delete("/users/{$username}/tokens/{$tokenId}");
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function addToOrg(
|
||||
string $username,
|
||||
string $org,
|
||||
int $teamId,
|
||||
?string $instance = null,
|
||||
): array {
|
||||
return $this->client($instance)->put("/teams/{$teamId}/members/{$username}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Org Operations
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listOrgs(?string $instance = null): array
|
||||
{
|
||||
return $this->client($instance)->paginate('/user/orgs');
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
|
|
|
|||
51
cmd/core-app/laravel/config/forgejo.php
Normal file
51
cmd/core-app/laravel/config/forgejo.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Forgejo Instance
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The instance name to use when no explicit instance is specified.
|
||||
|
|
||||
*/
|
||||
'default' => env('FORGEJO_DEFAULT', 'forge'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Forgejo Instances
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Each entry defines a Forgejo instance the platform can talk to.
|
||||
| The service auto-routes by matching the configured URL.
|
||||
|
|
||||
| url — Base URL of the Forgejo instance (no trailing slash)
|
||||
| token — Admin API token for the instance
|
||||
|
|
||||
*/
|
||||
'instances' => [
|
||||
'forge' => [
|
||||
'url' => env('FORGEJO_FORGE_URL', 'https://forge.lthn.ai'),
|
||||
'token' => env('FORGEJO_FORGE_TOKEN', ''),
|
||||
],
|
||||
'dev' => [
|
||||
'url' => env('FORGEJO_DEV_URL', 'https://dev.lthn.ai'),
|
||||
'token' => env('FORGEJO_DEV_TOKEN', ''),
|
||||
],
|
||||
'qa' => [
|
||||
'url' => env('FORGEJO_QA_URL', 'https://qa.lthn.ai'),
|
||||
'token' => env('FORGEJO_QA_TOKEN', ''),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Client Settings
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
'timeout' => (int) env('FORGEJO_TIMEOUT', 30),
|
||||
'retry_times' => (int) env('FORGEJO_RETRY_TIMES', 3),
|
||||
'retry_sleep' => (int) env('FORGEJO_RETRY_SLEEP', 500),
|
||||
];
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('agent_allowances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('agent_id')->unique();
|
||||
$table->bigInteger('daily_token_limit')->default(0);
|
||||
$table->integer('daily_job_limit')->default(0);
|
||||
$table->integer('concurrent_jobs')->default(1);
|
||||
$table->integer('max_job_duration_minutes')->default(0);
|
||||
$table->json('model_allowlist')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('quota_usage', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('agent_id')->index();
|
||||
$table->bigInteger('tokens_used')->default(0);
|
||||
$table->integer('jobs_started')->default(0);
|
||||
$table->integer('active_jobs')->default(0);
|
||||
$table->date('period_date')->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['agent_id', 'period_date']);
|
||||
});
|
||||
|
||||
Schema::create('model_quotas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('model')->unique();
|
||||
$table->bigInteger('daily_token_budget')->default(0);
|
||||
$table->integer('hourly_rate_limit')->default(0);
|
||||
$table->bigInteger('cost_ceiling')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('usage_reports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('agent_id')->index();
|
||||
$table->string('job_id')->index();
|
||||
$table->string('model')->nullable();
|
||||
$table->bigInteger('tokens_in')->default(0);
|
||||
$table->bigInteger('tokens_out')->default(0);
|
||||
$table->string('event');
|
||||
$table->timestamp('reported_at');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('repo_limits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('repo')->unique();
|
||||
$table->integer('max_daily_prs')->default(0);
|
||||
$table->integer('max_daily_issues')->default(0);
|
||||
$table->integer('cooldown_after_failure_minutes')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('repo_limits');
|
||||
Schema::dropIfExists('usage_reports');
|
||||
Schema::dropIfExists('model_quotas');
|
||||
Schema::dropIfExists('quota_usage');
|
||||
Schema::dropIfExists('agent_allowances');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $title ?? 'Agentic Dashboard' }} — Core</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: { DEFAULT: '#0d1117', raised: '#161b22', overlay: '#21262d' },
|
||||
border: { DEFAULT: '#30363d', subtle: '#21262d' },
|
||||
accent: { DEFAULT: '#39d0d8', dim: '#1b6b6f' },
|
||||
success: '#238636',
|
||||
warning: '#d29922',
|
||||
danger: '#da3633',
|
||||
muted: '#8b949e',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
|
||||
.heartbeat { animation: pulse-dot 2s ease-in-out infinite; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
|
||||
</style>
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="h-full bg-surface text-gray-200 antialiased">
|
||||
<div class="flex h-full" x-data="{ sidebarOpen: true }">
|
||||
{{-- Sidebar --}}
|
||||
<aside class="flex flex-col w-56 border-r border-border bg-surface-raised shrink-0 transition-all"
|
||||
:class="sidebarOpen ? 'w-56' : 'w-16'">
|
||||
<div class="flex items-center gap-2 px-4 h-14 border-b border-border">
|
||||
<svg class="w-6 h-6 text-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-sm tracking-wide" x-show="sidebarOpen" x-cloak>Agentic</span>
|
||||
</div>
|
||||
<nav class="flex-1 py-2 space-y-0.5 px-2">
|
||||
<a href="{{ route('dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>
|
||||
<span x-show="sidebarOpen">Dashboard</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.agents') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.agents') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span x-show="sidebarOpen">Agent Fleet</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.jobs') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.jobs') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
|
||||
<span x-show="sidebarOpen">Job Queue</span>
|
||||
</a>
|
||||
<a href="{{ route('dashboard.activity') }}"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm rounded-md {{ request()->routeIs('dashboard.activity') ? 'bg-surface-overlay text-white' : 'text-muted hover:bg-surface-overlay hover:text-white' }} transition">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||
<span x-show="sidebarOpen">Activity</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="border-t border-border p-2">
|
||||
<button @click="sidebarOpen = !sidebarOpen"
|
||||
class="flex items-center justify-center w-full px-3 py-2 text-muted hover:text-white rounded-md hover:bg-surface-overlay transition">
|
||||
<svg class="w-4 h-4 transition-transform" :class="sidebarOpen ? '' : 'rotate-180'" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{{-- Main content --}}
|
||||
<main class="flex-1 overflow-auto">
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between h-14 px-6 border-b border-border bg-surface/80 backdrop-blur">
|
||||
<h1 class="text-sm font-semibold">{{ $title ?? 'Dashboard' }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-xs text-muted"
|
||||
x-data="{ connected: true }"
|
||||
x-init="
|
||||
setInterval(() => {
|
||||
connected = navigator.onLine;
|
||||
}, 3000)
|
||||
">
|
||||
<span class="w-2 h-2 rounded-full heartbeat"
|
||||
:class="connected ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
<span x-text="connected ? 'Connected' : 'Disconnected'"></span>
|
||||
</div>
|
||||
<span class="text-xs text-muted font-mono">{{ now()->format('H:i') }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<x-dashboard-layout title="Live Activity">
|
||||
<livewire:dashboard.activity-feed />
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<x-dashboard-layout title="Agent Fleet">
|
||||
<livewire:dashboard.agent-fleet />
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<x-dashboard-layout title="Dashboard">
|
||||
{{-- Metrics overview at top --}}
|
||||
<section class="mb-8">
|
||||
<livewire:dashboard.metrics />
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{{-- Left column: Agent fleet + Human actions --}}
|
||||
<div class="xl:col-span-2 space-y-6">
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Agent Fleet</h2>
|
||||
<livewire:dashboard.agent-fleet />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Job Queue</h2>
|
||||
<livewire:dashboard.job-queue />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{-- Right column: Actions + Activity --}}
|
||||
<div class="space-y-6">
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Human Actions</h2>
|
||||
<livewire:dashboard.human-actions />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-3">Live Activity</h2>
|
||||
<livewire:dashboard.activity-feed />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<x-dashboard-layout title="Job Queue">
|
||||
<livewire:dashboard.job-queue />
|
||||
</x-dashboard-layout>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<div wire:poll.3s="loadEntries">
|
||||
{{-- Filters --}}
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<select wire:model.live="agentFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All agents</option>
|
||||
<option value="Athena">Athena</option>
|
||||
<option value="Virgil">Virgil</option>
|
||||
<option value="Clotho">Clotho</option>
|
||||
<option value="Charon">Charon</option>
|
||||
</select>
|
||||
<select wire:model.live="typeFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All types</option>
|
||||
<option value="code_write">Code write</option>
|
||||
<option value="tool_call">Tool call</option>
|
||||
<option value="test_run">Test run</option>
|
||||
<option value="pr_created">PR created</option>
|
||||
<option value="git_push">Git push</option>
|
||||
<option value="question">Question</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-xs text-muted cursor-pointer">
|
||||
<input type="checkbox" wire:model.live="showOnlyQuestions"
|
||||
class="rounded border-border bg-surface-overlay text-accent focus:ring-accent">
|
||||
Waiting for answer only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Feed --}}
|
||||
<div class="space-y-2 max-h-[600px] overflow-y-auto scrollbar-thin">
|
||||
@forelse ($this->filteredEntries as $entry)
|
||||
<div class="bg-surface-raised border rounded-lg px-4 py-3 transition
|
||||
{{ $entry['is_question'] ? 'border-yellow-500/50 bg-yellow-500/5' : 'border-border' }}">
|
||||
<div class="flex items-start gap-3">
|
||||
{{-- Type icon --}}
|
||||
@php
|
||||
$typeIcons = [
|
||||
'code_write' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
|
||||
'tool_call' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>',
|
||||
'test_run' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>',
|
||||
'pr_created' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>',
|
||||
'git_push' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>',
|
||||
'question' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01"/>',
|
||||
];
|
||||
$iconPath = $typeIcons[$entry['type']] ?? $typeIcons['tool_call'];
|
||||
$iconColor = $entry['is_question'] ? 'text-yellow-400' : 'text-muted';
|
||||
@endphp
|
||||
<svg class="w-4 h-4 mt-0.5 shrink-0 {{ $iconColor }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">{!! $iconPath !!}</svg>
|
||||
|
||||
{{-- Content --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs font-semibold text-gray-300">{{ $entry['agent'] }}</span>
|
||||
<span class="text-[10px] text-muted font-mono">{{ $entry['job'] }}</span>
|
||||
@if ($entry['is_question'])
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400 font-medium">NEEDS ANSWER</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 leading-relaxed">{{ $entry['message'] }}</p>
|
||||
</div>
|
||||
|
||||
{{-- Timestamp --}}
|
||||
<span class="text-[11px] text-muted shrink-0">
|
||||
{{ \Carbon\Carbon::parse($entry['timestamp'])->diffForHumans(short: true) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-muted text-sm">No activity matching filters.</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<div wire:poll.5s="loadAgents">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@foreach ($agents as $agent)
|
||||
<div wire:click="selectAgent('{{ $agent['id'] }}')"
|
||||
class="bg-surface-raised border rounded-lg p-4 cursor-pointer transition hover:border-accent
|
||||
{{ $selectedAgent === $agent['id'] ? 'border-accent' : 'border-border' }}">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full heartbeat
|
||||
{{ $agent['heartbeat'] === 'green' ? 'bg-green-500' : ($agent['heartbeat'] === 'yellow' ? 'bg-yellow-500' : 'bg-red-500') }}"></span>
|
||||
<span class="font-semibold text-sm">{{ $agent['name'] }}</span>
|
||||
</div>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider
|
||||
{{ $agent['status'] === 'working' ? 'bg-blue-500/20 text-blue-400' : ($agent['status'] === 'idle' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400') }}">
|
||||
{{ $agent['status'] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Info --}}
|
||||
<div class="space-y-1.5 text-xs text-muted">
|
||||
<div class="flex justify-between">
|
||||
<span>Host</span>
|
||||
<span class="text-gray-300 font-mono">{{ $agent['host'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Model</span>
|
||||
<span class="text-gray-300 font-mono text-[11px]">{{ $agent['model'] }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Uptime</span>
|
||||
<span class="text-gray-300">{{ $agent['uptime'] }}</span>
|
||||
</div>
|
||||
@if ($agent['job'])
|
||||
<div class="flex justify-between">
|
||||
<span>Job</span>
|
||||
<span class="text-accent text-[11px]">{{ $agent['job'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Expanded detail --}}
|
||||
@if ($selectedAgent === $agent['id'])
|
||||
<div class="mt-3 pt-3 border-t border-border space-y-1.5 text-xs text-muted">
|
||||
<div class="flex justify-between">
|
||||
<span>Tokens today</span>
|
||||
<span class="text-gray-300">{{ number_format($agent['tokens_today']) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Jobs completed</span>
|
||||
<span class="text-gray-300">{{ $agent['jobs_completed'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<div wire:poll.3s="loadPending">
|
||||
{{-- Pending questions --}}
|
||||
@if (count($pendingQuestions) > 0)
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-yellow-500 heartbeat"></span>
|
||||
Agent Questions ({{ count($pendingQuestions) }})
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
@foreach ($pendingQuestions as $q)
|
||||
<div class="bg-yellow-500/5 border border-yellow-500/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-yellow-400">{{ $q['agent'] }}</span>
|
||||
<span class="text-[10px] text-muted font-mono">{{ $q['job'] }}</span>
|
||||
<span class="text-[10px] text-muted">{{ \Carbon\Carbon::parse($q['asked_at'])->diffForHumans(short: true) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 mb-2">{{ $q['question'] }}</p>
|
||||
@if (!empty($q['context']))
|
||||
<p class="text-xs text-muted mb-3">{{ $q['context'] }}</p>
|
||||
@endif
|
||||
|
||||
@if ($answeringId === $q['id'])
|
||||
<div class="mt-3">
|
||||
<textarea wire:model="answerText"
|
||||
rows="3"
|
||||
placeholder="Type your answer..."
|
||||
class="w-full bg-surface-overlay border border-border rounded-md px-3 py-2 text-sm text-gray-300 placeholder-muted focus:border-accent focus:outline-none resize-none"></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button wire:click="submitAnswer"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-accent text-surface hover:opacity-90 transition">
|
||||
Send Answer
|
||||
</button>
|
||||
<button wire:click="cancelAnswer"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-surface-overlay text-muted hover:text-white border border-border transition">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<button wire:click="startAnswer('{{ $q['id'] }}')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 transition">
|
||||
Answer
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Review gates --}}
|
||||
@if (count($reviewGates) > 0)
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-purple-500 heartbeat"></span>
|
||||
Review Gates ({{ count($reviewGates) }})
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
@foreach ($reviewGates as $gate)
|
||||
<div class="bg-surface-raised border border-purple-500/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-purple-400">{{ $gate['agent'] }}</span>
|
||||
<span class="text-[10px] text-muted font-mono">{{ $gate['job'] }}</span>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium uppercase">{{ str_replace('_', ' ', $gate['type']) }}</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-300 mb-1">{{ $gate['title'] }}</p>
|
||||
<p class="text-xs text-muted mb-3">{{ $gate['description'] }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button wire:click="approveGate('{{ $gate['id'] }}')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 transition">
|
||||
Approve
|
||||
</button>
|
||||
<button wire:click="rejectGate('{{ $gate['id'] }}')"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (count($pendingQuestions) === 0 && count($reviewGates) === 0)
|
||||
<div class="text-center py-12 text-muted">
|
||||
<svg class="w-8 h-8 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<p class="text-sm">No pending actions. All agents are autonomous.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<div wire:poll.5s="loadJobs">
|
||||
{{-- Filters --}}
|
||||
<div class="flex flex-wrap gap-3 mb-4">
|
||||
<select wire:model.live="statusFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select wire:model.live="agentFilter"
|
||||
class="bg-surface-overlay border border-border rounded-md px-3 py-1.5 text-xs text-gray-300 focus:border-accent focus:outline-none">
|
||||
<option value="all">All agents</option>
|
||||
<option value="Athena">Athena</option>
|
||||
<option value="Virgil">Virgil</option>
|
||||
<option value="Clotho">Clotho</option>
|
||||
<option value="Charon">Charon</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-xs text-muted uppercase tracking-wider">
|
||||
<th class="text-left px-4 py-3 font-medium">Job</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Issue</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Agent</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Priority</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Queued</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
@forelse ($this->filteredJobs as $job)
|
||||
<tr class="hover:bg-surface-overlay/50 transition">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-mono text-xs text-muted">{{ $job['id'] }}</div>
|
||||
<div class="text-xs text-gray-300 mt-0.5 truncate max-w-[200px]">{{ $job['title'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-accent font-mono text-xs">{{ $job['issue'] }}</span>
|
||||
<div class="text-[11px] text-muted">{{ $job['repo'] }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
{{ $job['agent'] ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@php
|
||||
$statusColors = [
|
||||
'queued' => 'bg-yellow-500/20 text-yellow-400',
|
||||
'in_progress' => 'bg-blue-500/20 text-blue-400',
|
||||
'review' => 'bg-purple-500/20 text-purple-400',
|
||||
'completed' => 'bg-green-500/20 text-green-400',
|
||||
'failed' => 'bg-red-500/20 text-red-400',
|
||||
'cancelled' => 'bg-gray-500/20 text-gray-400',
|
||||
];
|
||||
@endphp
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wider {{ $statusColors[$job['status']] ?? '' }}">
|
||||
{{ str_replace('_', ' ', $job['status']) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-mono text-muted">P{{ $job['priority'] }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-muted">
|
||||
{{ \Carbon\Carbon::parse($job['queued_at'])->diffForHumans(short: true) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@if (in_array($job['status'], ['queued', 'in_progress']))
|
||||
<button wire:click="cancelJob('{{ $job['id'] }}')"
|
||||
class="text-[11px] px-2 py-1 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20 transition">
|
||||
Cancel
|
||||
</button>
|
||||
@endif
|
||||
@if (in_array($job['status'], ['failed', 'cancelled']))
|
||||
<button wire:click="retryJob('{{ $job['id'] }}')"
|
||||
class="text-[11px] px-2 py-1 rounded bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition">
|
||||
Retry
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-8 text-center text-muted text-sm">No jobs match the selected filters.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<div wire:poll.10s="loadMetrics">
|
||||
{{-- Stat cards --}}
|
||||
<div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 mb-6">
|
||||
@php
|
||||
$statCards = [
|
||||
['label' => 'Jobs Completed', 'value' => $stats['jobs_completed'], 'icon' => 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-green-400'],
|
||||
['label' => 'PRs Merged', 'value' => $stats['prs_merged'], 'icon' => 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', 'color' => 'text-purple-400'],
|
||||
['label' => 'Tokens Used', 'value' => number_format($stats['tokens_used']), 'icon' => 'M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z', 'color' => 'text-blue-400'],
|
||||
['label' => 'Cost Today', 'value' => '$' . number_format($stats['cost_today'], 2), 'icon' => 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z', 'color' => 'text-yellow-400'],
|
||||
['label' => 'Active Agents', 'value' => $stats['active_agents'], 'icon' => 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z', 'color' => 'text-accent'],
|
||||
['label' => 'Queue Depth', 'value' => $stats['queue_depth'], 'icon' => 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', 'color' => 'text-orange-400'],
|
||||
];
|
||||
@endphp
|
||||
@foreach ($statCards as $card)
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4 {{ $card['color'] }}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ $card['icon'] }}"/>
|
||||
</svg>
|
||||
<span class="text-[11px] text-muted uppercase tracking-wider">{{ $card['label'] }}</span>
|
||||
</div>
|
||||
<div class="text-xl font-bold font-mono {{ $card['color'] }}">{{ $card['value'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Budget gauge --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-5">
|
||||
<h3 class="text-sm font-semibold mb-4">Budget</h3>
|
||||
<div class="flex items-end gap-3 mb-3">
|
||||
<span class="text-3xl font-bold font-mono text-accent">${{ number_format($budgetUsed, 2) }}</span>
|
||||
<span class="text-sm text-muted mb-1">/ ${{ number_format($budgetLimit, 2) }}</span>
|
||||
</div>
|
||||
@php
|
||||
$pct = $budgetLimit > 0 ? min(100, ($budgetUsed / $budgetLimit) * 100) : 0;
|
||||
$barColor = $pct > 80 ? 'bg-red-500' : ($pct > 60 ? 'bg-yellow-500' : 'bg-accent');
|
||||
@endphp
|
||||
<div class="w-full h-3 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div class="{{ $barColor }} h-full rounded-full transition-all duration-500" style="width: {{ $pct }}%"></div>
|
||||
</div>
|
||||
<div class="text-xs text-muted mt-2">{{ number_format($pct, 0) }}% of daily budget used</div>
|
||||
</div>
|
||||
|
||||
{{-- Cost breakdown by model --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-5">
|
||||
<h3 class="text-sm font-semibold mb-4">Cost by Model</h3>
|
||||
<div class="space-y-3">
|
||||
@foreach ($costBreakdown as $model)
|
||||
@php
|
||||
$modelPct = $budgetUsed > 0 ? ($model['cost'] / $budgetUsed) * 100 : 0;
|
||||
$modelColors = [
|
||||
'claude-opus-4-6' => 'bg-purple-500',
|
||||
'claude-sonnet-4-5' => 'bg-blue-500',
|
||||
'claude-haiku-4-5' => 'bg-green-500',
|
||||
];
|
||||
$barCol = $modelColors[$model['model']] ?? 'bg-gray-500';
|
||||
@endphp
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs mb-1">
|
||||
<span class="font-mono text-gray-300">{{ $model['model'] }}</span>
|
||||
<span class="text-muted">${{ number_format($model['cost'], 2) }} ({{ number_format($model['tokens']) }} tokens)</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-surface-overlay rounded-full overflow-hidden">
|
||||
<div class="{{ $barCol }} h-full rounded-full transition-all duration-500" style="width: {{ $modelPct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Throughput chart --}}
|
||||
<div class="bg-surface-raised border border-border rounded-lg p-5 mt-6"
|
||||
x-data="{
|
||||
chart: null,
|
||||
init() {
|
||||
this.chart = new ApexCharts(this.$refs.chart, {
|
||||
chart: {
|
||||
type: 'area',
|
||||
height: 240,
|
||||
background: 'transparent',
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
},
|
||||
theme: { mode: 'dark' },
|
||||
colors: ['#39d0d8', '#8b5cf6'],
|
||||
series: [
|
||||
{ name: 'Jobs', data: {{ json_encode(array_column($throughputData, 'jobs')) }} },
|
||||
{ name: 'Tokens (k)', data: {{ json_encode(array_map(fn($t) => round($t / 1000, 1), array_column($throughputData, 'tokens'))) }} },
|
||||
],
|
||||
xaxis: {
|
||||
categories: {{ json_encode(array_column($throughputData, 'hour')) }},
|
||||
labels: { style: { colors: '#8b949e', fontSize: '11px' } },
|
||||
},
|
||||
yaxis: [
|
||||
{ labels: { style: { colors: '#39d0d8' } }, title: { text: 'Jobs', style: { color: '#39d0d8' } } },
|
||||
{ opposite: true, labels: { style: { colors: '#8b5cf6' } }, title: { text: 'Tokens (k)', style: { color: '#8b5cf6' } } },
|
||||
],
|
||||
grid: { borderColor: '#21262d', strokeDashArray: 3 },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
|
||||
dataLabels: { enabled: false },
|
||||
legend: { labels: { colors: '#8b949e' } },
|
||||
tooltip: { theme: 'dark' },
|
||||
});
|
||||
this.chart.render();
|
||||
}
|
||||
}">
|
||||
<h3 class="text-sm font-semibold mb-4">Throughput</h3>
|
||||
<div x-ref="chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
146
cmd/core-app/laravel/routes/api.php
Normal file
146
cmd/core-app/laravel/routes/api.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AgentAllowance;
|
||||
use App\Models\ModelQuota;
|
||||
use App\Models\RepoLimit;
|
||||
use App\Services\AllowanceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowance API Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Endpoints for managing agent quotas, checking allowances, and recording
|
||||
| usage. Protected endpoints use QuotaMiddleware for enforcement.
|
||||
|
|
||||
*/
|
||||
|
||||
// Health check for quota service
|
||||
Route::get('/allowances/health', fn () => response()->json(['status' => 'ok']));
|
||||
|
||||
// Agent allowance CRUD
|
||||
Route::prefix('allowances/agents')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return AgentAllowance::all();
|
||||
});
|
||||
|
||||
Route::get('/{agentId}', function (string $agentId) {
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return response()->json(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
return $allowance;
|
||||
});
|
||||
|
||||
Route::post('/', function (Request $request) {
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string|unique:agent_allowances,agent_id',
|
||||
'daily_token_limit' => 'integer|min:0',
|
||||
'daily_job_limit' => 'integer|min:0',
|
||||
'concurrent_jobs' => 'integer|min:0',
|
||||
'max_job_duration_minutes' => 'integer|min:0',
|
||||
'model_allowlist' => 'array',
|
||||
'model_allowlist.*' => 'string',
|
||||
]);
|
||||
|
||||
return AgentAllowance::create($validated);
|
||||
});
|
||||
|
||||
Route::put('/{agentId}', function (Request $request, string $agentId) {
|
||||
$allowance = AgentAllowance::where('agent_id', $agentId)->first();
|
||||
|
||||
if (! $allowance) {
|
||||
return response()->json(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'daily_token_limit' => 'integer|min:0',
|
||||
'daily_job_limit' => 'integer|min:0',
|
||||
'concurrent_jobs' => 'integer|min:0',
|
||||
'max_job_duration_minutes' => 'integer|min:0',
|
||||
'model_allowlist' => 'array',
|
||||
'model_allowlist.*' => 'string',
|
||||
]);
|
||||
|
||||
$allowance->update($validated);
|
||||
|
||||
return $allowance;
|
||||
});
|
||||
|
||||
Route::delete('/{agentId}', function (string $agentId) {
|
||||
AgentAllowance::where('agent_id', $agentId)->delete();
|
||||
|
||||
return response()->json(['status' => 'deleted']);
|
||||
});
|
||||
});
|
||||
|
||||
// Quota check endpoint
|
||||
Route::get('/allowances/check/{agentId}', function (Request $request, string $agentId, AllowanceService $svc) {
|
||||
$model = $request->query('model', '');
|
||||
|
||||
return response()->json($svc->check($agentId, $model));
|
||||
});
|
||||
|
||||
// Usage reporting endpoint
|
||||
Route::post('/allowances/usage', function (Request $request, AllowanceService $svc) {
|
||||
$validated = $request->validate([
|
||||
'agent_id' => 'required|string',
|
||||
'job_id' => 'required|string',
|
||||
'model' => 'nullable|string',
|
||||
'tokens_in' => 'integer|min:0',
|
||||
'tokens_out' => 'integer|min:0',
|
||||
'event' => 'required|in:job_started,job_completed,job_failed,job_cancelled',
|
||||
'timestamp' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$svc->recordUsage($validated);
|
||||
|
||||
return response()->json(['status' => 'recorded']);
|
||||
});
|
||||
|
||||
// Daily reset endpoint
|
||||
Route::post('/allowances/reset/{agentId}', function (string $agentId, AllowanceService $svc) {
|
||||
$svc->resetAgent($agentId);
|
||||
|
||||
return response()->json(['status' => 'reset']);
|
||||
});
|
||||
|
||||
// Model quota management
|
||||
Route::prefix('allowances/models')->group(function () {
|
||||
Route::get('/', fn () => ModelQuota::all());
|
||||
|
||||
Route::post('/', function (Request $request) {
|
||||
$validated = $request->validate([
|
||||
'model' => 'required|string|unique:model_quotas,model',
|
||||
'daily_token_budget' => 'integer|min:0',
|
||||
'hourly_rate_limit' => 'integer|min:0',
|
||||
'cost_ceiling' => 'integer|min:0',
|
||||
]);
|
||||
|
||||
return ModelQuota::create($validated);
|
||||
});
|
||||
|
||||
Route::put('/{model}', function (Request $request, string $model) {
|
||||
$quota = ModelQuota::where('model', $model)->first();
|
||||
|
||||
if (! $quota) {
|
||||
return response()->json(['error' => 'not found'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'daily_token_budget' => 'integer|min:0',
|
||||
'hourly_rate_limit' => 'integer|min:0',
|
||||
'cost_ceiling' => 'integer|min:0',
|
||||
]);
|
||||
|
||||
$quota->update($validated);
|
||||
|
||||
return $quota;
|
||||
});
|
||||
});
|
||||
|
|
@ -7,3 +7,9 @@ use Illuminate\Support\Facades\Route;
|
|||
Route::get('/', function () {
|
||||
return view('welcome');
|
||||
});
|
||||
|
||||
// Agentic Dashboard
|
||||
Route::get('/dashboard', fn () => view('dashboard.index'))->name('dashboard');
|
||||
Route::get('/dashboard/agents', fn () => view('dashboard.agents'))->name('dashboard.agents');
|
||||
Route::get('/dashboard/jobs', fn () => view('dashboard.jobs'))->name('dashboard.jobs');
|
||||
Route::get('/dashboard/activity', fn () => view('dashboard.activity'))->name('dashboard.activity');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Forgejo;
|
||||
|
||||
use App\Services\Forgejo\ForgejoClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class ForgejoClientTest extends TestCase
|
||||
{
|
||||
private const BASE_URL = 'https://forge.test';
|
||||
private const TOKEN = 'test-token-abc123';
|
||||
|
||||
// ---- Construction ----
|
||||
|
||||
public function test_constructor_good(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN);
|
||||
|
||||
$this->assertSame(self::BASE_URL, $client->baseUrl());
|
||||
}
|
||||
|
||||
public function test_constructor_bad_empty_token(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('API token is required');
|
||||
|
||||
new ForgejoClient(self::BASE_URL, '');
|
||||
}
|
||||
|
||||
// ---- GET ----
|
||||
|
||||
public function test_get_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo' => Http::response(['id' => 1, 'name' => 'repo'], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->get('/repos/owner/repo');
|
||||
|
||||
$this->assertSame(1, $result['id']);
|
||||
$this->assertSame('repo', $result['name']);
|
||||
}
|
||||
|
||||
public function test_get_bad_server_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo' => Http::response('Internal Server Error', 500),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Forgejo API error [500]');
|
||||
|
||||
$client->get('/repos/owner/repo');
|
||||
}
|
||||
|
||||
// ---- POST ----
|
||||
|
||||
public function test_post_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/issues' => Http::response(['number' => 42], 201),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->post('/repos/owner/repo/issues', ['title' => 'Bug']);
|
||||
|
||||
$this->assertSame(42, $result['number']);
|
||||
}
|
||||
|
||||
// ---- PATCH ----
|
||||
|
||||
public function test_patch_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/issues/1' => Http::response(['state' => 'closed'], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->patch('/repos/owner/repo/issues/1', ['state' => 'closed']);
|
||||
|
||||
$this->assertSame('closed', $result['state']);
|
||||
}
|
||||
|
||||
// ---- PUT ----
|
||||
|
||||
public function test_put_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/teams/5/members/alice' => Http::response([], 204),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$result = $client->put('/teams/5/members/alice');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
}
|
||||
|
||||
// ---- DELETE ----
|
||||
|
||||
public function test_delete_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/branches/old' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
|
||||
// Should not throw
|
||||
$client->delete('/repos/owner/repo/branches/old');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_delete_bad_not_found(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/branches/gone' => Http::response('Not Found', 404),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('failed [404]');
|
||||
|
||||
$client->delete('/repos/owner/repo/branches/gone');
|
||||
}
|
||||
|
||||
// ---- getRaw ----
|
||||
|
||||
public function test_getRaw_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/owner/repo/pulls/1.diff' => Http::response(
|
||||
"diff --git a/file.txt b/file.txt\n",
|
||||
200,
|
||||
['Content-Type' => 'text/plain'],
|
||||
),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$diff = $client->getRaw('/repos/owner/repo/pulls/1.diff');
|
||||
|
||||
$this->assertStringContainsString('diff --git', $diff);
|
||||
}
|
||||
|
||||
// ---- Pagination ----
|
||||
|
||||
public function test_paginate_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/orgs/myorg/repos?page=1&limit=2' => Http::response(
|
||||
[['id' => 1], ['id' => 2]],
|
||||
200,
|
||||
['x-total-count' => '3'],
|
||||
),
|
||||
'forge.test/api/v1/orgs/myorg/repos?page=2&limit=2' => Http::response(
|
||||
[['id' => 3]],
|
||||
200,
|
||||
['x-total-count' => '3'],
|
||||
),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$repos = $client->paginate('/orgs/myorg/repos', [], 2);
|
||||
|
||||
$this->assertCount(3, $repos);
|
||||
$this->assertSame(1, $repos[0]['id']);
|
||||
$this->assertSame(3, $repos[2]['id']);
|
||||
}
|
||||
|
||||
public function test_paginate_good_empty(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/orgs/empty/repos?page=1&limit=50' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$repos = $client->paginate('/orgs/empty/repos');
|
||||
|
||||
$this->assertSame([], $repos);
|
||||
}
|
||||
|
||||
// ---- Auth header ----
|
||||
|
||||
public function test_auth_header_sent(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/user' => Http::response(['login' => 'bot'], 200),
|
||||
]);
|
||||
|
||||
$client = new ForgejoClient(self::BASE_URL, self::TOKEN, retryTimes: 0);
|
||||
$client->get('/user');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->hasHeader('Authorization', 'token ' . self::TOKEN);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Forgejo;
|
||||
|
||||
use App\Services\Forgejo\ForgejoService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Orchestra\Testbench\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class ForgejoServiceTest extends TestCase
|
||||
{
|
||||
private const INSTANCES = [
|
||||
'forge' => ['url' => 'https://forge.test', 'token' => 'tok-forge'],
|
||||
'dev' => ['url' => 'https://dev.test', 'token' => 'tok-dev'],
|
||||
];
|
||||
|
||||
private function service(): ForgejoService
|
||||
{
|
||||
return new ForgejoService(
|
||||
instances: self::INSTANCES,
|
||||
defaultInstance: 'forge',
|
||||
timeout: 5,
|
||||
retryTimes: 0,
|
||||
retrySleep: 0,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Instance management ----
|
||||
|
||||
public function test_instances_good(): void
|
||||
{
|
||||
$svc = $this->service();
|
||||
|
||||
$this->assertSame(['forge', 'dev'], $svc->instances());
|
||||
}
|
||||
|
||||
public function test_instances_skips_empty_token(): void
|
||||
{
|
||||
$svc = new ForgejoService(
|
||||
instances: [
|
||||
'forge' => ['url' => 'https://forge.test', 'token' => 'tok'],
|
||||
'qa' => ['url' => 'https://qa.test', 'token' => ''],
|
||||
],
|
||||
);
|
||||
|
||||
$this->assertSame(['forge'], $svc->instances());
|
||||
}
|
||||
|
||||
public function test_client_bad_unknown_instance(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("instance 'nope' is not configured");
|
||||
|
||||
$this->service()->client('nope');
|
||||
}
|
||||
|
||||
// ---- Issues ----
|
||||
|
||||
public function test_createIssue_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues' => Http::response([
|
||||
'number' => 99,
|
||||
'title' => 'New bug',
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createIssue('org', 'repo', 'New bug', 'Description');
|
||||
|
||||
$this->assertSame(99, $result['number']);
|
||||
|
||||
Http::assertSent(fn ($r) => $r['title'] === 'New bug' && $r['body'] === 'Description');
|
||||
}
|
||||
|
||||
public function test_createIssue_good_with_labels_and_assignee(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues' => Http::response(['number' => 1], 201),
|
||||
]);
|
||||
|
||||
$this->service()->createIssue('org', 'repo', 'Task', assignee: 'alice', labels: [1, 2]);
|
||||
|
||||
Http::assertSent(fn ($r) => $r['assignees'] === ['alice'] && $r['labels'] === [1, 2]);
|
||||
}
|
||||
|
||||
public function test_closeIssue_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues/5' => Http::response(['state' => 'closed'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->service()->closeIssue('org', 'repo', 5);
|
||||
|
||||
$this->assertSame('closed', $result['state']);
|
||||
}
|
||||
|
||||
public function test_addComment_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues/5/comments' => Http::response(['id' => 100], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->addComment('org', 'repo', 5, 'LGTM');
|
||||
|
||||
$this->assertSame(100, $result['id']);
|
||||
}
|
||||
|
||||
public function test_listIssues_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/issues*' => Http::response([
|
||||
['number' => 1],
|
||||
['number' => 2],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$issues = $this->service()->listIssues('org', 'repo');
|
||||
|
||||
$this->assertCount(2, $issues);
|
||||
}
|
||||
|
||||
// ---- Pull Requests ----
|
||||
|
||||
public function test_createPR_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/pulls' => Http::response([
|
||||
'number' => 10,
|
||||
'title' => 'Feature X',
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createPR('org', 'repo', 'feat/x', 'main', 'Feature X');
|
||||
|
||||
$this->assertSame(10, $result['number']);
|
||||
}
|
||||
|
||||
public function test_mergePR_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/pulls/10/merge' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
// Should not throw
|
||||
$this->service()->mergePR('org', 'repo', 10, 'squash');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_getPRDiff_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/pulls/10.diff' => Http::response(
|
||||
"diff --git a/f.go b/f.go\n+new line\n",
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$diff = $this->service()->getPRDiff('org', 'repo', 10);
|
||||
|
||||
$this->assertStringContainsString('diff --git', $diff);
|
||||
}
|
||||
|
||||
// ---- Repositories ----
|
||||
|
||||
public function test_getRepo_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/core' => Http::response(['full_name' => 'org/core'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->service()->getRepo('org', 'core');
|
||||
|
||||
$this->assertSame('org/core', $result['full_name']);
|
||||
}
|
||||
|
||||
public function test_createBranch_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/branches' => Http::response(['name' => 'feat/y'], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createBranch('org', 'repo', 'feat/y', 'main');
|
||||
|
||||
$this->assertSame('feat/y', $result['name']);
|
||||
|
||||
Http::assertSent(fn ($r) =>
|
||||
$r['new_branch_name'] === 'feat/y' && $r['old_branch_name'] === 'main'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_deleteBranch_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/repos/org/repo/branches/old' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$this->service()->deleteBranch('org', 'repo', 'old');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ---- User / Token Management ----
|
||||
|
||||
public function test_createUser_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/admin/users' => Http::response(['login' => 'bot'], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createUser('bot', 'bot@test.io', 's3cret');
|
||||
|
||||
$this->assertSame('bot', $result['login']);
|
||||
|
||||
Http::assertSent(fn ($r) =>
|
||||
$r['username'] === 'bot'
|
||||
&& $r['must_change_password'] === false
|
||||
);
|
||||
}
|
||||
|
||||
public function test_createToken_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/users/bot/tokens' => Http::response(['sha1' => 'abc123'], 201),
|
||||
]);
|
||||
|
||||
$result = $this->service()->createToken('bot', 'ci-token', ['repo', 'user']);
|
||||
|
||||
$this->assertSame('abc123', $result['sha1']);
|
||||
}
|
||||
|
||||
public function test_revokeToken_good(): void
|
||||
{
|
||||
Http::fake([
|
||||
'forge.test/api/v1/users/bot/tokens/42' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$this->service()->revokeToken('bot', 42);
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
// ---- Multi-instance routing ----
|
||||
|
||||
public function test_explicit_instance_routing(): void
|
||||
{
|
||||
Http::fake([
|
||||
'dev.test/api/v1/repos/org/repo' => Http::response(['full_name' => 'org/repo'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->service()->getRepo('org', 'repo', instance: 'dev');
|
||||
|
||||
$this->assertSame('org/repo', $result['full_name']);
|
||||
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), 'dev.test'));
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"log"
|
||||
"runtime"
|
||||
|
||||
"github.com/host-uk/core/cmd/core-app/icons"
|
||||
"forge.lthn.ai/core/cli/cmd/core-app/icons"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/mcp/ide"
|
||||
"forge.lthn.ai/core/cli/pkg/mcp/ide"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/mcp/ide"
|
||||
"forge.lthn.ai/core/cli/pkg/mcp/ide"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module github.com/host-uk/core/cmd/core-ide
|
||||
module forge.lthn.ai/core/cli/cmd/core-ide
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/host-uk/core v0.0.0
|
||||
forge.lthn.ai/core/cli v0.0.0
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
|
||||
)
|
||||
|
||||
|
|
@ -54,4 +54,4 @@ require (
|
|||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
replace github.com/host-uk/core => ../..
|
||||
replace forge.lthn.ai/core/cli => ../..
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/host-uk/core/pkg/mcp/ide"
|
||||
"github.com/host-uk/core/pkg/ws"
|
||||
"forge.lthn.ai/core/cli/pkg/mcp/ide"
|
||||
"forge.lthn.ai/core/cli/pkg/ws"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/cmd/core-ide/icons"
|
||||
"github.com/host-uk/core/pkg/mcp/ide"
|
||||
"github.com/host-uk/core/pkg/ws"
|
||||
"forge.lthn.ai/core/cli/cmd/core-ide/icons"
|
||||
"forge.lthn.ai/core/cli/pkg/mcp/ide"
|
||||
"forge.lthn.ai/core/cli/pkg/ws"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/host-uk/core/pkg/ws"
|
||||
"forge.lthn.ai/core/cli/pkg/ws"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//
|
||||
// When a Go tool requests ?go-get=1, this server responds with HTML
|
||||
// containing <meta name="go-import"> tags that map dappco.re module
|
||||
// paths to their Git repositories on forge.lthn.ai.
|
||||
// paths to their Git repositories on forge.lthn.io.
|
||||
//
|
||||
// For browser requests (no ?go-get=1), it redirects to the Forgejo
|
||||
// repository web UI.
|
||||
|
|
@ -22,7 +22,7 @@ var modules = map[string]string{
|
|||
}
|
||||
|
||||
const (
|
||||
forgeBase = "https://forge.lthn.ai"
|
||||
forgeBase = "https://forge.lthn.io"
|
||||
vanityHost = "dappco.re"
|
||||
defaultAddr = ":8080"
|
||||
)
|
||||
|
|
|
|||
150
docs/plans/2026-02-13-bugseti-hub-service-design.md
Normal file
150
docs/plans/2026-02-13-bugseti-hub-service-design.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# BugSETI HubService Design
|
||||
|
||||
## Overview
|
||||
|
||||
A thin HTTP client service in the BugSETI desktop app that coordinates with the agentic portal's `/api/bugseti/*` endpoints. Prevents duplicate work across the 11 community testers, aggregates stats for leaderboard, and registers client instances.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Target | Direct to portal API | Endpoints built for this purpose |
|
||||
| Auth | Auto-register via forge token | No manual key management for users |
|
||||
| Sync strategy | Lazy/manual | User-triggered claims, manual stats sync |
|
||||
| Offline mode | Offline-first | Queue failed writes, retry on reconnect |
|
||||
| Approach | Thin HTTP client (net/http) | Matches existing patterns, no deps |
|
||||
|
||||
## Architecture
|
||||
|
||||
**File:** `internal/bugseti/hub.go` + `hub_test.go`
|
||||
|
||||
```
|
||||
HubService
|
||||
├── HTTP client (net/http, 10s timeout)
|
||||
├── Auth: auto-register via forge token → cached ak_ token
|
||||
├── Config: HubURL, HubToken, ClientID in ConfigService
|
||||
├── Offline-first: queue failed writes, drain on next success
|
||||
└── Lazy sync: user-triggered, no background goroutines
|
||||
```
|
||||
|
||||
**Dependencies:** ConfigService only.
|
||||
|
||||
**Integration:**
|
||||
- QueueService calls `hub.ClaimIssue()` when user picks an issue
|
||||
- SubmitService calls `hub.UpdateStatus("completed")` after PR
|
||||
- TrayService calls `hub.GetLeaderboard()` from UI
|
||||
- main.go calls `hub.Register()` on startup
|
||||
|
||||
## Data Types
|
||||
|
||||
```go
|
||||
type HubClient struct {
|
||||
ClientID string // UUID, generated once, persisted in config
|
||||
Name string // e.g. "Snider's MacBook"
|
||||
Version string // bugseti.GetVersion()
|
||||
OS string // runtime.GOOS
|
||||
Arch string // runtime.GOARCH
|
||||
}
|
||||
|
||||
type HubClaim struct {
|
||||
IssueID string // "owner/repo#123"
|
||||
Repo string
|
||||
IssueNumber int
|
||||
Title string
|
||||
URL string
|
||||
Status string // claimed|in_progress|completed|skipped
|
||||
ClaimedAt time.Time
|
||||
PRUrl string
|
||||
PRNumber int
|
||||
}
|
||||
|
||||
type LeaderboardEntry struct {
|
||||
Rank int
|
||||
ClientName string
|
||||
IssuesCompleted int
|
||||
PRsSubmitted int
|
||||
PRsMerged int
|
||||
CurrentStreak int
|
||||
}
|
||||
|
||||
type GlobalStats struct {
|
||||
TotalParticipants int
|
||||
ActiveParticipants int
|
||||
TotalIssuesCompleted int
|
||||
TotalPRsMerged int
|
||||
ActiveClaims int
|
||||
}
|
||||
```
|
||||
|
||||
## API Mapping
|
||||
|
||||
| Method | HTTP | Endpoint | Trigger |
|
||||
|--------|------|----------|---------|
|
||||
| `Register()` | POST /register | App startup |
|
||||
| `Heartbeat()` | POST /heartbeat | Manual / periodic if enabled |
|
||||
| `ClaimIssue(issue)` | POST /issues/claim | User picks issue |
|
||||
| `UpdateStatus(id, status)` | PATCH /issues/{id}/status | PR submitted, skip |
|
||||
| `ReleaseClaim(id)` | DELETE /issues/{id}/claim | User abandons |
|
||||
| `IsIssueClaimed(id)` | GET /issues/{id} | Before showing issue |
|
||||
| `ListClaims(filters)` | GET /issues/claimed | UI active claims view |
|
||||
| `SyncStats(stats)` | POST /stats/sync | Manual from UI |
|
||||
| `GetLeaderboard(limit)` | GET /leaderboard | UI leaderboard view |
|
||||
| `GetGlobalStats()` | GET /stats | UI stats dashboard |
|
||||
|
||||
## Auto-Register Flow
|
||||
|
||||
New endpoint on portal:
|
||||
|
||||
```
|
||||
POST /api/bugseti/auth/forge
|
||||
Body: { "forge_url": "https://forge.lthn.io", "forge_token": "..." }
|
||||
```
|
||||
|
||||
Portal validates token against Forgejo API (`/api/v1/user`), creates an AgentApiKey with `bugseti.read` + `bugseti.write` scopes, returns `{ "api_key": "ak_..." }`.
|
||||
|
||||
HubService caches the `ak_` token in config.json. On 401, clears cached token and re-registers.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Behaviour |
|
||||
|-------|-----------|
|
||||
| Network unreachable | Log, queue write ops, return cached reads |
|
||||
| 401 Unauthorised | Clear token, re-register via forge |
|
||||
| 409 Conflict (claim) | Return "already claimed" — not an error |
|
||||
| 404 (claim not found) | Return nil |
|
||||
| 429 Rate limited | Back off, queue the op |
|
||||
| 5xx Server error | Log, queue write ops |
|
||||
|
||||
**Pending operations queue:**
|
||||
- Failed writes stored in `[]PendingOp`, persisted to `$DataDir/hub_pending.json`
|
||||
- Drained on next successful user-triggered call (no background goroutine)
|
||||
- Each op has: method, path, body, created_at
|
||||
|
||||
## Config Changes
|
||||
|
||||
New fields in `Config` struct:
|
||||
|
||||
```go
|
||||
HubURL string `json:"hubUrl,omitempty"` // portal API base URL
|
||||
HubToken string `json:"hubToken,omitempty"` // cached ak_ token
|
||||
ClientID string `json:"clientId,omitempty"` // UUID, generated once
|
||||
ClientName string `json:"clientName,omitempty"` // display name
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `internal/bugseti/hub.go` | New — HubService |
|
||||
| `internal/bugseti/hub_test.go` | New — httptest-based tests |
|
||||
| `internal/bugseti/config.go` | Edit — add Hub* + ClientID fields |
|
||||
| `cmd/bugseti/main.go` | Edit — create + register HubService |
|
||||
| `cmd/bugseti/tray.go` | Edit — leaderboard/stats menu items |
|
||||
| Laravel: auth controller | New — `/api/bugseti/auth/forge` |
|
||||
|
||||
## Testing
|
||||
|
||||
- `httptest.NewServer` mocks for all endpoints
|
||||
- Test success, network error, 409 conflict, 401 re-auth flows
|
||||
- Test pending ops queue: add when offline, drain on reconnect
|
||||
- `_Good`, `_Bad`, `_Ugly` naming convention
|
||||
1620
docs/plans/2026-02-13-bugseti-hub-service-plan.md
Normal file
1620
docs/plans/2026-02-13-bugseti-hub-service-plan.md
Normal file
File diff suppressed because it is too large
Load diff
19
go.mod
19
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module github.com/host-uk/core
|
||||
module forge.lthn.ai/core/cli
|
||||
|
||||
go 1.25.5
|
||||
|
||||
|
|
@ -6,7 +6,6 @@ require (
|
|||
code.gitea.io/sdk/gitea v0.23.2
|
||||
github.com/Snider/Borg v0.2.0
|
||||
github.com/getkin/kin-openapi v0.133.0
|
||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1
|
||||
github.com/leaanthony/debme v1.2.1
|
||||
github.com/leaanthony/gosod v1.0.4
|
||||
|
|
@ -39,6 +38,8 @@ require (
|
|||
github.com/Snider/Enchantrix v0.0.2 // indirect
|
||||
github.com/TwiN/go-color v1.4.1 // indirect
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
|
|
@ -72,9 +73,11 @@ require (
|
|||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
|
|
@ -86,11 +89,13 @@ require (
|
|||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/lmittmann/tint v1.1.2 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
|
|
@ -98,8 +103,12 @@ require (
|
|||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/parquet-go/bitpack v1.0.0 // indirect
|
||||
github.com/parquet-go/jsonlite v1.0.0 // indirect
|
||||
github.com/parquet-go/parquet-go v0.27.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
|
|
@ -120,9 +129,9 @@ require (
|
|||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twpayne/go-geom v1.6.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/unpoller/unifi/v5 v5.17.0 // indirect
|
||||
github.com/wI2L/jsondiff v0.7.0 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect
|
||||
|
|
@ -131,10 +140,14 @@ require (
|
|||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
|
|
|
|||
218
go.sum
218
go.sum
|
|
@ -1,336 +1,193 @@
|
|||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
||||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0/go.mod h1:ZglEEDj+qkxYUb+SQIeqGtFxQrbaMYqIOgahNKb7uxs=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
|
||||
github.com/Snider/Borg v0.2.0/go.mod h1:TqlKnfRo9okioHbgrZPfWjQsztBV0Nfskz4Om1/vdMY=
|
||||
github.com/TwiN/go-color v1.4.1 h1:mqG0P/KBgHKVqmtL5ye7K0/Gr4l6hTksPgTgMk3mUzc=
|
||||
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
|
||||
github.com/TwiN/go-color v1.4.1/go.mod h1:WcPf/jtiW95WBIsEeY1Lc/b8aaWoiqQpu5cf8WFxu+s=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
|
||||
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
|
||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555 h1:v5LWtsFypIhFzZpTx+mY64D5TyCI+CqJY8hmqmEx23E=
|
||||
github.com/host-uk/core/internal/core-ide v0.0.0-20260204004957-989b7e1e6555/go.mod h1:YWAcL4vml/IMkYVKqf5J4ukTINVH1zGw0G8vg/qlops=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4=
|
||||
github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
|
||||
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oasdiff/oasdiff v1.11.9 h1:M/pIY4K1MWnML0DkAdUQU/CnJdNDr2z2hpD0lpKSccM=
|
||||
github.com/oasdiff/oasdiff v1.11.9/go.mod h1:4qorAPsG2EE/lXEs+FGzAJcYHXS3G7XghfqkCFPKzNQ=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/ollama/ollama v0.15.4 h1:y841GH5lsi5j5BTFyX/E+UOC3Yiw+JBfdjBVRGw+I0M=
|
||||
github.com/ollama/ollama v0.15.4/go.mod h1:4Yn3jw2hZ4VqyJ1XciYawDRE8bzv4RT3JiVZR1kCfwE=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
|
||||
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
|
||||
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
|
||||
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
|
||||
github.com/parquet-go/parquet-go v0.27.0 h1:vHWK2xaHbj+v1DYps03yDRpEsdtOeKbhiXUaixoPb3g=
|
||||
github.com/parquet-go/parquet-go v0.27.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg=
|
||||
github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
|
||||
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/unpoller/unifi/v5 v5.17.0 h1:e2yES/35+/Ddd6BsXOjXRhsO663uqI99PKleS9plF/w=
|
||||
github.com/unpoller/unifi/v5 v5.17.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
|
||||
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
|
|
@ -348,44 +205,35 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
|
|
|
|||
|
|
@ -19,7 +19,17 @@ type ConfigService struct {
|
|||
|
||||
// Config holds all BugSETI configuration.
|
||||
type Config struct {
|
||||
// Authentication
|
||||
// Authentication — Forgejo API (resolved via pkg/forge config if empty)
|
||||
ForgeURL string `json:"forgeUrl,omitempty"`
|
||||
ForgeToken string `json:"forgeToken,omitempty"`
|
||||
|
||||
// Hub coordination (agentic portal)
|
||||
HubURL string `json:"hubUrl,omitempty"`
|
||||
HubToken string `json:"hubToken,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
ClientName string `json:"clientName,omitempty"`
|
||||
|
||||
// Deprecated: use ForgeToken. Kept for migration.
|
||||
GitHubToken string `json:"githubToken,omitempty"`
|
||||
|
||||
// Repositories
|
||||
|
|
@ -52,6 +62,10 @@ type Config struct {
|
|||
MaxConcurrentIssues int `json:"maxConcurrentIssues"`
|
||||
AutoSeedContext bool `json:"autoSeedContext"`
|
||||
|
||||
// Workspace cache
|
||||
MaxWorkspaces int `json:"maxWorkspaces"` // Upper bound on cached workspace entries (0 = default 100)
|
||||
WorkspaceTTLMinutes int `json:"workspaceTtlMinutes"` // TTL for workspace entries in minutes (0 = default 1440 = 24h)
|
||||
|
||||
// Updates
|
||||
UpdateChannel string `json:"updateChannel"` // stable, beta, nightly
|
||||
AutoUpdate bool `json:"autoUpdate"` // Automatically install updates
|
||||
|
|
@ -99,6 +113,8 @@ func NewConfigService() *ConfigService {
|
|||
AutoSeedContext: true,
|
||||
DataDir: bugsetiDir,
|
||||
MarketplaceMCPRoot: "",
|
||||
MaxWorkspaces: 100,
|
||||
WorkspaceTTLMinutes: 1440, // 24 hours
|
||||
UpdateChannel: "stable",
|
||||
AutoUpdate: false,
|
||||
UpdateCheckInterval: 6, // Check every 6 hours
|
||||
|
|
@ -149,7 +165,7 @@ func (c *ConfigService) saveUnsafe() error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0644)
|
||||
return os.WriteFile(c.path, data, 0600)
|
||||
}
|
||||
|
||||
// mergeDefaults fills in default values for any unset fields.
|
||||
|
|
@ -169,6 +185,12 @@ func (c *ConfigService) mergeDefaults(config *Config) {
|
|||
if config.DataDir == "" {
|
||||
config.DataDir = c.config.DataDir
|
||||
}
|
||||
if config.MaxWorkspaces == 0 {
|
||||
config.MaxWorkspaces = 100
|
||||
}
|
||||
if config.WorkspaceTTLMinutes == 0 {
|
||||
config.WorkspaceTTLMinutes = 1440
|
||||
}
|
||||
if config.UpdateChannel == "" {
|
||||
config.UpdateChannel = "stable"
|
||||
}
|
||||
|
|
@ -406,6 +428,26 @@ func (c *ConfigService) SetAutoSeedEnabled(enabled bool) error {
|
|||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// GetMaxWorkspaces returns the maximum number of cached workspaces.
|
||||
func (c *ConfigService) GetMaxWorkspaces() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.config.MaxWorkspaces <= 0 {
|
||||
return 100
|
||||
}
|
||||
return c.config.MaxWorkspaces
|
||||
}
|
||||
|
||||
// GetWorkspaceTTL returns the workspace TTL as a time.Duration.
|
||||
func (c *ConfigService) GetWorkspaceTTL() time.Duration {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if c.config.WorkspaceTTLMinutes <= 0 {
|
||||
return 24 * time.Hour
|
||||
}
|
||||
return time.Duration(c.config.WorkspaceTTLMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// UpdateSettings holds update-related configuration.
|
||||
type UpdateSettings struct {
|
||||
Channel string `json:"channel"`
|
||||
|
|
@ -496,6 +538,96 @@ func (c *ConfigService) SetLastUpdateCheck(t time.Time) error {
|
|||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// GetForgeURL returns the configured Forge URL (may be empty to use pkg/forge defaults).
|
||||
func (c *ConfigService) GetForgeURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.config.ForgeURL
|
||||
}
|
||||
|
||||
// GetForgeToken returns the configured Forge token (may be empty to use pkg/forge defaults).
|
||||
func (c *ConfigService) GetForgeToken() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.config.ForgeToken
|
||||
}
|
||||
|
||||
// SetForgeURL sets the Forge URL.
|
||||
func (c *ConfigService) SetForgeURL(url string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.config.ForgeURL = url
|
||||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// SetForgeToken sets the Forge token.
|
||||
func (c *ConfigService) SetForgeToken(token string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.config.ForgeToken = token
|
||||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// GetHubURL returns the configured Hub URL.
|
||||
func (c *ConfigService) GetHubURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.config.HubURL
|
||||
}
|
||||
|
||||
// SetHubURL sets the Hub URL.
|
||||
func (c *ConfigService) SetHubURL(url string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.config.HubURL = url
|
||||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// GetHubToken returns the configured Hub token.
|
||||
func (c *ConfigService) GetHubToken() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.config.HubToken
|
||||
}
|
||||
|
||||
// SetHubToken sets the Hub token.
|
||||
func (c *ConfigService) SetHubToken(token string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.config.HubToken = token
|
||||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// GetClientID returns the configured client ID.
|
||||
func (c *ConfigService) GetClientID() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.config.ClientID
|
||||
}
|
||||
|
||||
// SetClientID sets the client ID.
|
||||
func (c *ConfigService) SetClientID(id string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.config.ClientID = id
|
||||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// GetClientName returns the configured client name.
|
||||
func (c *ConfigService) GetClientName() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.config.ClientName
|
||||
}
|
||||
|
||||
// SetClientName sets the client name.
|
||||
func (c *ConfigService) SetClientName(name string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.config.ClientName = name
|
||||
return c.saveUnsafe()
|
||||
}
|
||||
|
||||
// ShouldCheckForUpdates returns true if it's time to check for updates.
|
||||
func (c *ConfigService) ShouldCheckForUpdates() bool {
|
||||
c.mu.RLock()
|
||||
|
|
|
|||
37
internal/bugseti/config_test.go
Normal file
37
internal/bugseti/config_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigPermissions(t *testing.T) {
|
||||
// Get a temporary file path
|
||||
f, err := os.CreateTemp("", "bugseti-config-*.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := f.Name()
|
||||
f.Close()
|
||||
os.Remove(name) // Ensure it doesn't exist
|
||||
defer os.Remove(name)
|
||||
|
||||
c := &ConfigService{
|
||||
path: name,
|
||||
config: &Config{},
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mode := info.Mode().Perm()
|
||||
if mode != 0600 {
|
||||
t.Errorf("expected file permissions 0600, got %04o", mode)
|
||||
}
|
||||
}
|
||||
|
|
@ -106,7 +106,23 @@ func loadEthicsGuard(ctx context.Context, rootHint string) *EthicsGuard {
|
|||
}
|
||||
|
||||
func (g *EthicsGuard) SanitizeEnv(value string) string {
|
||||
return sanitizeInline(value, maxEnvRunes)
|
||||
return stripShellMeta(sanitizeInline(value, maxEnvRunes))
|
||||
}
|
||||
|
||||
// stripShellMeta removes shell metacharacters that could allow command
|
||||
// injection when a value is interpolated inside a shell environment variable.
|
||||
func stripShellMeta(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '`', '$', ';', '|', '&', '(', ')', '{', '}', '<', '>', '!', '\\', '\'', '"', '\n', '\r':
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (g *EthicsGuard) SanitizeTitle(value string) string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package bugseti
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeInline_Good(t *testing.T) {
|
||||
input := "Hello world"
|
||||
|
|
@ -26,3 +28,47 @@ func TestSanitizeMultiline_Ugly(t *testing.T) {
|
|||
t.Fatalf("expected %q, got %q", "ab\ncd", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnv_Good(t *testing.T) {
|
||||
g := &EthicsGuard{}
|
||||
input := "owner/repo-name"
|
||||
output := g.SanitizeEnv(input)
|
||||
if output != input {
|
||||
t.Fatalf("expected %q, got %q", input, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnv_Bad(t *testing.T) {
|
||||
g := &EthicsGuard{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"backtick", "owner/repo`whoami`", "owner/repowhoami"},
|
||||
{"dollar", "owner/repo$(id)", "owner/repoid"},
|
||||
{"semicolon", "owner/repo;rm -rf /", "owner/reporm -rf /"},
|
||||
{"pipe", "owner/repo|cat /etc/passwd", "owner/repocat /etc/passwd"},
|
||||
{"ampersand", "owner/repo&&echo pwned", "owner/repoecho pwned"},
|
||||
{"mixed", "`$;|&(){}<>!\\'\"\n\r", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
output := g.SanitizeEnv(tc.input)
|
||||
if output != tc.expected {
|
||||
t.Fatalf("expected %q, got %q", tc.expected, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripShellMeta_Ugly(t *testing.T) {
|
||||
// All metacharacters should be stripped, leaving empty string
|
||||
input := "`$;|&(){}<>!\\'\""
|
||||
output := stripShellMeta(input)
|
||||
if output != "" {
|
||||
t.Fatalf("expected empty string, got %q", output)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/forge"
|
||||
)
|
||||
|
||||
// FetcherService fetches issues from configured OSS repositories.
|
||||
type FetcherService struct {
|
||||
config *ConfigService
|
||||
notify *NotifyService
|
||||
forge *forge.Client
|
||||
running bool
|
||||
mu sync.RWMutex
|
||||
stopCh chan struct{}
|
||||
|
|
@ -23,10 +23,11 @@ type FetcherService struct {
|
|||
}
|
||||
|
||||
// NewFetcherService creates a new FetcherService.
|
||||
func NewFetcherService(config *ConfigService, notify *NotifyService) *FetcherService {
|
||||
func NewFetcherService(config *ConfigService, notify *NotifyService, forgeClient *forge.Client) *FetcherService {
|
||||
return &FetcherService{
|
||||
config: config,
|
||||
notify: notify,
|
||||
forge: forgeClient,
|
||||
issuesCh: make(chan []*Issue, 10),
|
||||
}
|
||||
}
|
||||
|
|
@ -133,68 +134,50 @@ func (f *FetcherService) fetchAll() {
|
|||
}
|
||||
}
|
||||
|
||||
// fetchFromRepo fetches issues from a single repository using GitHub CLI.
|
||||
// fetchFromRepo fetches issues from a single repository using the Forgejo API.
|
||||
func (f *FetcherService) fetchFromRepo(repo string) ([]*Issue, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
owner, repoName, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build query for good first issues
|
||||
labels := f.config.GetLabels()
|
||||
if len(labels) == 0 {
|
||||
labels = []string{"good first issue", "help wanted", "beginner-friendly"}
|
||||
}
|
||||
|
||||
labelQuery := strings.Join(labels, ",")
|
||||
|
||||
// Use gh CLI to fetch issues
|
||||
cmd := exec.CommandContext(ctx, "gh", "issue", "list",
|
||||
"--repo", repo,
|
||||
"--label", labelQuery,
|
||||
"--state", "open",
|
||||
"--limit", "20",
|
||||
"--json", "number,title,body,url,labels,createdAt,author")
|
||||
|
||||
output, err := cmd.Output()
|
||||
forgeIssues, err := f.forge.ListIssues(owner, repoName, forge.ListIssuesOpts{
|
||||
State: "open",
|
||||
Labels: labels,
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gh issue list failed: %w", err)
|
||||
return nil, fmt.Errorf("forge list issues failed: %w", err)
|
||||
}
|
||||
|
||||
var ghIssues []struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
}
|
||||
issues := make([]*Issue, 0, len(forgeIssues))
|
||||
for _, fi := range forgeIssues {
|
||||
labelNames := make([]string, len(fi.Labels))
|
||||
for i, l := range fi.Labels {
|
||||
labelNames[i] = l.Name
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &ghIssues); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse gh output: %w", err)
|
||||
}
|
||||
|
||||
issues := make([]*Issue, 0, len(ghIssues))
|
||||
for _, gi := range ghIssues {
|
||||
labels := make([]string, len(gi.Labels))
|
||||
for i, l := range gi.Labels {
|
||||
labels[i] = l.Name
|
||||
author := ""
|
||||
if fi.Poster != nil {
|
||||
author = fi.Poster.UserName
|
||||
}
|
||||
|
||||
issues = append(issues, &Issue{
|
||||
ID: fmt.Sprintf("%s#%d", repo, gi.Number),
|
||||
Number: gi.Number,
|
||||
ID: fmt.Sprintf("%s#%d", repo, fi.Index),
|
||||
Number: int(fi.Index),
|
||||
Repo: repo,
|
||||
Title: gi.Title,
|
||||
Body: gi.Body,
|
||||
URL: gi.URL,
|
||||
Labels: labels,
|
||||
Author: gi.Author.Login,
|
||||
CreatedAt: gi.CreatedAt,
|
||||
Priority: calculatePriority(labels),
|
||||
Title: fi.Title,
|
||||
Body: fi.Body,
|
||||
URL: fi.HTMLURL,
|
||||
Labels: labelNames,
|
||||
Author: author,
|
||||
CreatedAt: fi.Created,
|
||||
Priority: calculatePriority(labelNames),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -203,71 +186,68 @@ func (f *FetcherService) fetchFromRepo(repo string) ([]*Issue, error) {
|
|||
|
||||
// FetchIssue fetches a single issue by repo and number.
|
||||
func (f *FetcherService) FetchIssue(repo string, number int) (*Issue, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gh", "issue", "view",
|
||||
"--repo", repo,
|
||||
fmt.Sprintf("%d", number),
|
||||
"--json", "number,title,body,url,labels,createdAt,author,comments")
|
||||
|
||||
output, err := cmd.Output()
|
||||
owner, repoName, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gh issue view failed: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ghIssue struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
Comments []struct {
|
||||
Body string `json:"body"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
} `json:"comments"`
|
||||
fi, err := f.forge.GetIssue(owner, repoName, int64(number))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forge get issue failed: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &ghIssue); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse gh output: %w", err)
|
||||
labelNames := make([]string, len(fi.Labels))
|
||||
for i, l := range fi.Labels {
|
||||
labelNames[i] = l.Name
|
||||
}
|
||||
|
||||
labels := make([]string, len(ghIssue.Labels))
|
||||
for i, l := range ghIssue.Labels {
|
||||
labels[i] = l.Name
|
||||
author := ""
|
||||
if fi.Poster != nil {
|
||||
author = fi.Poster.UserName
|
||||
}
|
||||
|
||||
comments := make([]Comment, len(ghIssue.Comments))
|
||||
for i, c := range ghIssue.Comments {
|
||||
comments[i] = Comment{
|
||||
Author: c.Author.Login,
|
||||
Body: c.Body,
|
||||
// Fetch comments
|
||||
forgeComments, err := f.forge.ListIssueComments(owner, repoName, int64(number))
|
||||
if err != nil {
|
||||
log.Printf("Warning: could not fetch comments for %s#%d: %v", repo, number, err)
|
||||
}
|
||||
|
||||
comments := make([]Comment, 0, len(forgeComments))
|
||||
for _, c := range forgeComments {
|
||||
commentAuthor := ""
|
||||
if c.Poster != nil {
|
||||
commentAuthor = c.Poster.UserName
|
||||
}
|
||||
comments = append(comments, Comment{
|
||||
Author: commentAuthor,
|
||||
Body: c.Body,
|
||||
})
|
||||
}
|
||||
|
||||
return &Issue{
|
||||
ID: fmt.Sprintf("%s#%d", repo, ghIssue.Number),
|
||||
Number: ghIssue.Number,
|
||||
ID: fmt.Sprintf("%s#%d", repo, fi.Index),
|
||||
Number: int(fi.Index),
|
||||
Repo: repo,
|
||||
Title: ghIssue.Title,
|
||||
Body: ghIssue.Body,
|
||||
URL: ghIssue.URL,
|
||||
Labels: labels,
|
||||
Author: ghIssue.Author.Login,
|
||||
CreatedAt: ghIssue.CreatedAt,
|
||||
Priority: calculatePriority(labels),
|
||||
Title: fi.Title,
|
||||
Body: fi.Body,
|
||||
URL: fi.HTMLURL,
|
||||
Labels: labelNames,
|
||||
Author: author,
|
||||
CreatedAt: fi.Created,
|
||||
Priority: calculatePriority(labelNames),
|
||||
Comments: comments,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitRepo splits "owner/repo" into owner and repo parts.
|
||||
func splitRepo(repo string) (string, string, error) {
|
||||
parts := strings.SplitN(repo, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("invalid repo format %q, expected owner/repo", repo)
|
||||
}
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
|
||||
// calculatePriority assigns a priority score based on labels.
|
||||
func calculatePriority(labels []string) int {
|
||||
priority := 50 // Default priority
|
||||
|
|
|
|||
407
internal/bugseti/fetcher_test.go
Normal file
407
internal/bugseti/fetcher_test.go
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testConfigService creates a ConfigService with in-memory config for testing.
|
||||
func testConfigService(t *testing.T, repos []string, labels []string) *ConfigService {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cs := &ConfigService{
|
||||
path: dir + "/config.json",
|
||||
config: &Config{
|
||||
WatchedRepos: repos,
|
||||
Labels: labels,
|
||||
FetchInterval: 15,
|
||||
DataDir: dir,
|
||||
},
|
||||
}
|
||||
return cs
|
||||
}
|
||||
|
||||
// TestHelperProcess is invoked by the test binary when GO_TEST_HELPER_PROCESS
|
||||
// is set. It prints the value of GO_TEST_HELPER_OUTPUT and optionally exits
|
||||
// with a non-zero code. Kept for future exec.Command mocking.
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(os.Stdout, os.Getenv("GO_TEST_HELPER_OUTPUT"))
|
||||
if os.Getenv("GO_TEST_HELPER_EXIT_ERROR") == "1" {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// ---- NewFetcherService ----
|
||||
|
||||
func TestNewFetcherService_Good(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
notify := NewNotifyService(cfg)
|
||||
f := NewFetcherService(cfg, notify, nil)
|
||||
|
||||
require.NotNil(t, f)
|
||||
assert.Equal(t, "FetcherService", f.ServiceName())
|
||||
assert.False(t, f.IsRunning())
|
||||
assert.NotNil(t, f.Issues())
|
||||
}
|
||||
|
||||
// ---- Start / Pause / IsRunning lifecycle ----
|
||||
|
||||
func TestStartPause_Good(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
notify := NewNotifyService(cfg)
|
||||
f := NewFetcherService(cfg, notify, nil)
|
||||
|
||||
require.NoError(t, f.Start())
|
||||
assert.True(t, f.IsRunning())
|
||||
|
||||
// Starting again is a no-op.
|
||||
require.NoError(t, f.Start())
|
||||
assert.True(t, f.IsRunning())
|
||||
|
||||
f.Pause()
|
||||
assert.False(t, f.IsRunning())
|
||||
|
||||
// Pausing again is a no-op.
|
||||
f.Pause()
|
||||
assert.False(t, f.IsRunning())
|
||||
}
|
||||
|
||||
// ---- calculatePriority ----
|
||||
|
||||
func TestCalculatePriority_Good(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labels []string
|
||||
expected int
|
||||
}{
|
||||
{"no labels", nil, 50},
|
||||
{"good first issue", []string{"good first issue"}, 80},
|
||||
{"help wanted", []string{"Help Wanted"}, 70},
|
||||
{"beginner", []string{"beginner-friendly"}, 75},
|
||||
{"easy", []string{"Easy"}, 70},
|
||||
{"bug", []string{"bug"}, 60},
|
||||
{"documentation", []string{"Documentation"}, 55},
|
||||
{"priority", []string{"high-priority"}, 65},
|
||||
{"combined", []string{"good first issue", "bug"}, 90},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, calculatePriority(tt.labels))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePriority_Bad(t *testing.T) {
|
||||
// Unknown labels should not change priority from default.
|
||||
assert.Equal(t, 50, calculatePriority([]string{"unknown-label", "something-else"}))
|
||||
}
|
||||
|
||||
// ---- Label query construction ----
|
||||
|
||||
func TestLabelQuery_Good(t *testing.T) {
|
||||
// When config has custom labels, fetchFromRepo should use them.
|
||||
cfg := testConfigService(t, []string{"owner/repo"}, []string{"custom-label", "another"})
|
||||
labels := cfg.GetLabels()
|
||||
labelQuery := strings.Join(labels, ",")
|
||||
assert.Equal(t, "custom-label,another", labelQuery)
|
||||
}
|
||||
|
||||
func TestLabelQuery_Bad(t *testing.T) {
|
||||
// When config has empty labels, fetchFromRepo falls back to defaults.
|
||||
cfg := testConfigService(t, []string{"owner/repo"}, nil)
|
||||
labels := cfg.GetLabels()
|
||||
if len(labels) == 0 {
|
||||
labels = []string{"good first issue", "help wanted", "beginner-friendly"}
|
||||
}
|
||||
labelQuery := strings.Join(labels, ",")
|
||||
assert.Equal(t, "good first issue,help wanted,beginner-friendly", labelQuery)
|
||||
}
|
||||
|
||||
// ---- fetchFromRepo with mocked gh CLI output ----
|
||||
|
||||
func TestFetchFromRepo_Good(t *testing.T) {
|
||||
ghIssues := []struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
}{
|
||||
{
|
||||
Number: 42,
|
||||
Title: "Fix login bug",
|
||||
Body: "The login page crashes",
|
||||
URL: "https://github.com/test/repo/issues/42",
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
ghIssues[0].Author.Login = "octocat"
|
||||
ghIssues[0].Labels = []struct {
|
||||
Name string `json:"name"`
|
||||
}{
|
||||
{Name: "good first issue"},
|
||||
{Name: "bug"},
|
||||
}
|
||||
|
||||
output, err := json.Marshal(ghIssues)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We can't easily intercept exec.CommandContext in the production code
|
||||
// without refactoring, so we test the JSON parsing path by directly
|
||||
// calling json.Unmarshal the same way fetchFromRepo does.
|
||||
var parsed []struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(output, &parsed))
|
||||
require.Len(t, parsed, 1)
|
||||
|
||||
gi := parsed[0]
|
||||
labels := make([]string, len(gi.Labels))
|
||||
for i, l := range gi.Labels {
|
||||
labels[i] = l.Name
|
||||
}
|
||||
|
||||
issue := &Issue{
|
||||
ID: fmt.Sprintf("%s#%d", "test/repo", gi.Number),
|
||||
Number: gi.Number,
|
||||
Repo: "test/repo",
|
||||
Title: gi.Title,
|
||||
Body: gi.Body,
|
||||
URL: gi.URL,
|
||||
Labels: labels,
|
||||
Author: gi.Author.Login,
|
||||
CreatedAt: gi.CreatedAt,
|
||||
Priority: calculatePriority(labels),
|
||||
}
|
||||
|
||||
assert.Equal(t, "test/repo#42", issue.ID)
|
||||
assert.Equal(t, 42, issue.Number)
|
||||
assert.Equal(t, "Fix login bug", issue.Title)
|
||||
assert.Equal(t, "octocat", issue.Author)
|
||||
assert.Equal(t, []string{"good first issue", "bug"}, issue.Labels)
|
||||
assert.Equal(t, 90, issue.Priority) // 50 + 30 (good first issue) + 10 (bug)
|
||||
}
|
||||
|
||||
func TestFetchFromRepo_Bad_InvalidJSON(t *testing.T) {
|
||||
// Simulate gh returning invalid JSON.
|
||||
var ghIssues []struct {
|
||||
Number int `json:"number"`
|
||||
}
|
||||
err := json.Unmarshal([]byte(`not json at all`), &ghIssues)
|
||||
assert.Error(t, err, "invalid JSON should produce an error")
|
||||
}
|
||||
|
||||
func TestFetchFromRepo_Bad_GhNotInstalled(t *testing.T) {
|
||||
// Verify that a missing executable produces an exec error.
|
||||
cmd := exec.Command("gh-nonexistent-binary-12345")
|
||||
_, err := cmd.Output()
|
||||
assert.Error(t, err, "missing binary should produce an error")
|
||||
}
|
||||
|
||||
// ---- fetchAll: no repos configured ----
|
||||
|
||||
func TestFetchAll_Bad_NoRepos(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
notify := NewNotifyService(cfg)
|
||||
f := NewFetcherService(cfg, notify, nil)
|
||||
|
||||
// fetchAll with no repos should not panic and should not send to channel.
|
||||
f.fetchAll()
|
||||
|
||||
// Channel should be empty.
|
||||
select {
|
||||
case <-f.issuesCh:
|
||||
t.Fatal("expected no issues on channel when no repos configured")
|
||||
default:
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Channel backpressure ----
|
||||
|
||||
func TestChannelBackpressure_Ugly(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
notify := NewNotifyService(cfg)
|
||||
f := NewFetcherService(cfg, notify, nil)
|
||||
|
||||
// Fill the channel to capacity (buffer size is 10).
|
||||
for i := 0; i < 10; i++ {
|
||||
f.issuesCh <- []*Issue{{ID: fmt.Sprintf("test#%d", i)}}
|
||||
}
|
||||
|
||||
// Now try to send via the select path (same logic as fetchAll).
|
||||
// This should be a non-blocking drop, not a deadlock.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
issues := []*Issue{{ID: "overflow#1"}}
|
||||
select {
|
||||
case f.issuesCh <- issues:
|
||||
// Shouldn't happen — channel is full.
|
||||
t.Error("expected channel send to be skipped due to backpressure")
|
||||
default:
|
||||
// This is the expected path — channel full, message dropped.
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// success — did not deadlock
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backpressure test timed out — possible deadlock")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FetchIssue single-issue parsing ----
|
||||
|
||||
func TestFetchIssue_Good_Parse(t *testing.T) {
|
||||
// Test the JSON parsing and Issue construction for FetchIssue.
|
||||
ghIssue := struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
Comments []struct {
|
||||
Body string `json:"body"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
} `json:"comments"`
|
||||
}{
|
||||
Number: 99,
|
||||
Title: "Add dark mode",
|
||||
Body: "Please add dark mode support",
|
||||
URL: "https://github.com/test/repo/issues/99",
|
||||
CreatedAt: time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
ghIssue.Author.Login = "contributor"
|
||||
ghIssue.Labels = []struct {
|
||||
Name string `json:"name"`
|
||||
}{
|
||||
{Name: "help wanted"},
|
||||
}
|
||||
ghIssue.Comments = []struct {
|
||||
Body string `json:"body"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
}{
|
||||
{Body: "I can work on this"},
|
||||
}
|
||||
ghIssue.Comments[0].Author.Login = "volunteer"
|
||||
|
||||
data, err := json.Marshal(ghIssue)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-parse as the function would.
|
||||
var parsed struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
Labels []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
Comments []struct {
|
||||
Body string `json:"body"`
|
||||
Author struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"author"`
|
||||
} `json:"comments"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &parsed))
|
||||
|
||||
labels := make([]string, len(parsed.Labels))
|
||||
for i, l := range parsed.Labels {
|
||||
labels[i] = l.Name
|
||||
}
|
||||
comments := make([]Comment, len(parsed.Comments))
|
||||
for i, c := range parsed.Comments {
|
||||
comments[i] = Comment{Author: c.Author.Login, Body: c.Body}
|
||||
}
|
||||
|
||||
issue := &Issue{
|
||||
ID: fmt.Sprintf("%s#%d", "test/repo", parsed.Number),
|
||||
Number: parsed.Number,
|
||||
Repo: "test/repo",
|
||||
Title: parsed.Title,
|
||||
Body: parsed.Body,
|
||||
URL: parsed.URL,
|
||||
Labels: labels,
|
||||
Author: parsed.Author.Login,
|
||||
CreatedAt: parsed.CreatedAt,
|
||||
Priority: calculatePriority(labels),
|
||||
Comments: comments,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test/repo#99", issue.ID)
|
||||
assert.Equal(t, "contributor", issue.Author)
|
||||
assert.Equal(t, 70, issue.Priority) // 50 + 20 (help wanted)
|
||||
require.Len(t, issue.Comments, 1)
|
||||
assert.Equal(t, "volunteer", issue.Comments[0].Author)
|
||||
assert.Equal(t, "I can work on this", issue.Comments[0].Body)
|
||||
}
|
||||
|
||||
// ---- Issues() channel accessor ----
|
||||
|
||||
func TestIssuesChannel_Good(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
notify := NewNotifyService(cfg)
|
||||
f := NewFetcherService(cfg, notify, nil)
|
||||
|
||||
ch := f.Issues()
|
||||
require.NotNil(t, ch)
|
||||
|
||||
// Send and receive through the channel.
|
||||
go func() {
|
||||
f.issuesCh <- []*Issue{{ID: "test#1", Title: "Test issue"}}
|
||||
}()
|
||||
|
||||
select {
|
||||
case issues := <-ch:
|
||||
require.Len(t, issues, 1)
|
||||
assert.Equal(t, "test#1", issues[0].ID)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for issues on channel")
|
||||
}
|
||||
}
|
||||
22
internal/bugseti/ghcheck.go
Normal file
22
internal/bugseti/ghcheck.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/cli/pkg/forge"
|
||||
)
|
||||
|
||||
// CheckForge verifies that the Forgejo API is configured and reachable.
|
||||
// Returns nil if a token is configured and the API responds, or an error
|
||||
// with actionable instructions for the user.
|
||||
func CheckForge() (*forge.Client, error) {
|
||||
client, err := forge.NewFromConfig("", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify the token works by fetching the current user
|
||||
if _, err := client.GetCurrentUser(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
23
internal/bugseti/ghcheck_test.go
Normal file
23
internal/bugseti/ghcheck_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckForge_Bad_MissingConfig(t *testing.T) {
|
||||
// Clear any env-based forge config to ensure CheckForge fails
|
||||
t.Setenv("FORGE_TOKEN", "")
|
||||
t.Setenv("FORGE_URL", "")
|
||||
|
||||
// Point HOME to a temp dir so no config file is found
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
_, err := CheckForge()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when forge is not configured")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,32 @@
|
|||
module github.com/host-uk/core/internal/bugseti
|
||||
module forge.lthn.ai/core/cli/internal/bugseti
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/mark3labs/mcp-go v0.43.2
|
||||
require (
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
576
internal/bugseti/hub.go
Normal file
576
internal/bugseti/hub.go
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
// Package bugseti provides services for the BugSETI distributed bug fixing application.
|
||||
package bugseti
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/forge"
|
||||
)
|
||||
|
||||
// HubService coordinates with the agentic portal for issue assignment and leaderboard.
|
||||
type HubService struct {
|
||||
config *ConfigService
|
||||
client *http.Client
|
||||
connected bool
|
||||
pending []PendingOp
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// PendingOp represents an operation queued for retry when the hub is unreachable.
|
||||
type PendingOp struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// HubClaim represents a claimed issue from the hub.
|
||||
type HubClaim struct {
|
||||
ID string `json:"id"`
|
||||
IssueURL string `json:"issueUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClaimedAt time.Time `json:"claimedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// LeaderboardEntry represents a single entry on the leaderboard.
|
||||
type LeaderboardEntry struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName"`
|
||||
Score int `json:"score"`
|
||||
PRsMerged int `json:"prsMerged"`
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// GlobalStats holds aggregate statistics from the hub.
|
||||
type GlobalStats struct {
|
||||
TotalClients int `json:"totalClients"`
|
||||
TotalClaims int `json:"totalClaims"`
|
||||
TotalPRsMerged int `json:"totalPrsMerged"`
|
||||
ActiveClaims int `json:"activeClaims"`
|
||||
IssuesAvailable int `json:"issuesAvailable"`
|
||||
}
|
||||
|
||||
// ConflictError indicates a 409 response from the hub (e.g. issue already claimed).
|
||||
type ConflictError struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *ConflictError) Error() string {
|
||||
return fmt.Sprintf("conflict: status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
// NotFoundError indicates a 404 response from the hub.
|
||||
type NotFoundError struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return fmt.Sprintf("not found: status %d", e.StatusCode)
|
||||
}
|
||||
|
||||
// NewHubService creates a new HubService with the given config.
|
||||
// If the config has no ClientID, one is generated and persisted.
|
||||
func NewHubService(config *ConfigService) *HubService {
|
||||
h := &HubService{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
pending: make([]PendingOp, 0),
|
||||
}
|
||||
|
||||
// Generate client ID if not set.
|
||||
if config.GetClientID() == "" {
|
||||
id := generateClientID()
|
||||
_ = config.SetClientID(id)
|
||||
}
|
||||
|
||||
h.loadPendingOps()
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// ServiceName returns the service name for Wails.
|
||||
func (h *HubService) ServiceName() string {
|
||||
return "HubService"
|
||||
}
|
||||
|
||||
// GetClientID returns the client ID from config.
|
||||
func (h *HubService) GetClientID() string {
|
||||
return h.config.GetClientID()
|
||||
}
|
||||
|
||||
// IsConnected returns whether the hub was reachable on the last request.
|
||||
func (h *HubService) IsConnected() bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.connected
|
||||
}
|
||||
|
||||
// generateClientID creates a random hex string (16 bytes = 32 hex chars).
|
||||
func generateClientID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback: this should never happen with crypto/rand.
|
||||
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// doRequest builds and executes an HTTP request against the hub API.
|
||||
// It returns the raw *http.Response and any transport-level error.
|
||||
func (h *HubService) doRequest(method, path string, body interface{}) (*http.Response, error) {
|
||||
hubURL := h.config.GetHubURL()
|
||||
if hubURL == "" {
|
||||
return nil, fmt.Errorf("hub URL not configured")
|
||||
}
|
||||
|
||||
fullURL := hubURL + "/api/bugseti" + path
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
token := h.config.GetHubToken()
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
h.mu.Lock()
|
||||
h.connected = false
|
||||
h.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.connected = true
|
||||
h.mu.Unlock()
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// doJSON executes an HTTP request and decodes the JSON response into dest.
|
||||
// It handles common error status codes with typed errors.
|
||||
func (h *HubService) doJSON(method, path string, body, dest interface{}) error {
|
||||
resp, err := h.doRequest(method, path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
return fmt.Errorf("unauthorised")
|
||||
case resp.StatusCode == http.StatusConflict:
|
||||
return &ConflictError{StatusCode: resp.StatusCode}
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return &NotFoundError{StatusCode: resp.StatusCode}
|
||||
case resp.StatusCode >= 400:
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("hub error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
if dest != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// queueOp marshals body to JSON and appends a PendingOp to the queue.
|
||||
func (h *HubService) queueOp(method, path string, body interface{}) {
|
||||
var raw json.RawMessage
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
log.Printf("BugSETI: queueOp marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
raw = data
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.pending = append(h.pending, PendingOp{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Body: raw,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
h.mu.Unlock()
|
||||
|
||||
h.savePendingOps()
|
||||
}
|
||||
|
||||
// drainPendingOps replays queued operations against the hub.
|
||||
// 5xx/transport errors are kept for retry; 4xx responses are dropped (stale).
|
||||
func (h *HubService) drainPendingOps() {
|
||||
h.mu.Lock()
|
||||
ops := h.pending
|
||||
h.pending = make([]PendingOp, 0)
|
||||
h.mu.Unlock()
|
||||
|
||||
if len(ops) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var failed []PendingOp
|
||||
for _, op := range ops {
|
||||
var body interface{}
|
||||
if len(op.Body) > 0 {
|
||||
body = json.RawMessage(op.Body)
|
||||
}
|
||||
|
||||
resp, err := h.doRequest(op.Method, op.Path, body)
|
||||
if err != nil {
|
||||
// Transport error — keep for retry.
|
||||
failed = append(failed, op)
|
||||
continue
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
// Server error — keep for retry.
|
||||
failed = append(failed, op)
|
||||
} // 4xx are dropped (stale).
|
||||
}
|
||||
|
||||
if len(failed) > 0 {
|
||||
h.mu.Lock()
|
||||
h.pending = append(failed, h.pending...)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
h.savePendingOps()
|
||||
}
|
||||
|
||||
// savePendingOps persists the pending operations queue to disk.
|
||||
func (h *HubService) savePendingOps() {
|
||||
dataDir := h.config.GetDataDir()
|
||||
if dataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
data, err := json.Marshal(h.pending)
|
||||
h.mu.RUnlock()
|
||||
if err != nil {
|
||||
log.Printf("BugSETI: savePendingOps marshal error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, "hub_pending.json")
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
log.Printf("BugSETI: savePendingOps write error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loadPendingOps loads the pending operations queue from disk.
|
||||
// Errors are silently ignored (the file may not exist yet).
|
||||
func (h *HubService) loadPendingOps() {
|
||||
dataDir := h.config.GetDataDir()
|
||||
if dataDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, "hub_pending.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var ops []PendingOp
|
||||
if err := json.Unmarshal(data, &ops); err != nil {
|
||||
return
|
||||
}
|
||||
h.pending = ops
|
||||
}
|
||||
|
||||
// PendingCount returns the number of queued pending operations.
|
||||
func (h *HubService) PendingCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.pending)
|
||||
}
|
||||
|
||||
// ---- Task 4: Auto-Register via Forge Token ----
|
||||
|
||||
// AutoRegister exchanges a Forge API token for a hub API key.
|
||||
// If a hub token is already configured, this is a no-op.
|
||||
func (h *HubService) AutoRegister() error {
|
||||
// Skip if already registered.
|
||||
if h.config.GetHubToken() != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hubURL := h.config.GetHubURL()
|
||||
if hubURL == "" {
|
||||
return fmt.Errorf("hub URL not configured")
|
||||
}
|
||||
|
||||
// Resolve forge credentials from config/env.
|
||||
forgeURL := h.config.GetForgeURL()
|
||||
forgeToken := h.config.GetForgeToken()
|
||||
if forgeToken == "" {
|
||||
resolvedURL, resolvedToken, err := forge.ResolveConfig(forgeURL, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve forge config: %w", err)
|
||||
}
|
||||
forgeURL = resolvedURL
|
||||
forgeToken = resolvedToken
|
||||
}
|
||||
|
||||
if forgeToken == "" {
|
||||
return fmt.Errorf("no forge token available (set FORGE_TOKEN or run: core forge config --token TOKEN)")
|
||||
}
|
||||
|
||||
// Build request body.
|
||||
payload := map[string]string{
|
||||
"forge_url": forgeURL,
|
||||
"forge_token": forgeToken,
|
||||
"client_id": h.config.GetClientID(),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal auto-register body: %w", err)
|
||||
}
|
||||
|
||||
// POST directly (no bearer token yet).
|
||||
resp, err := h.client.Post(hubURL+"/api/bugseti/auth/forge", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
h.mu.Lock()
|
||||
h.connected = false
|
||||
h.mu.Unlock()
|
||||
return fmt.Errorf("auto-register request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
h.mu.Lock()
|
||||
h.connected = true
|
||||
h.mu.Unlock()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("auto-register failed %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decode auto-register response: %w", err)
|
||||
}
|
||||
|
||||
if err := h.config.SetHubToken(result.APIKey); err != nil {
|
||||
return fmt.Errorf("cache hub token: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("BugSETI: auto-registered with hub, token cached")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Task 5: Write Operations ----
|
||||
|
||||
// Register registers this client with the hub.
|
||||
func (h *HubService) Register() error {
|
||||
h.drainPendingOps()
|
||||
|
||||
name := h.config.GetClientName()
|
||||
clientID := h.config.GetClientID()
|
||||
if name == "" {
|
||||
if len(clientID) >= 8 {
|
||||
name = "BugSETI-" + clientID[:8]
|
||||
} else {
|
||||
name = "BugSETI-" + clientID
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]string{
|
||||
"client_id": clientID,
|
||||
"name": name,
|
||||
"version": GetVersion(),
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
}
|
||||
|
||||
return h.doJSON("POST", "/register", body, nil)
|
||||
}
|
||||
|
||||
// Heartbeat sends a heartbeat to the hub.
|
||||
func (h *HubService) Heartbeat() error {
|
||||
body := map[string]string{
|
||||
"client_id": h.config.GetClientID(),
|
||||
}
|
||||
return h.doJSON("POST", "/heartbeat", body, nil)
|
||||
}
|
||||
|
||||
// ClaimIssue claims an issue on the hub, returning the claim details.
|
||||
// Returns a ConflictError if the issue is already claimed by another client.
|
||||
func (h *HubService) ClaimIssue(issue *Issue) (*HubClaim, error) {
|
||||
h.drainPendingOps()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"client_id": h.config.GetClientID(),
|
||||
"issue_id": issue.ID,
|
||||
"repo": issue.Repo,
|
||||
"issue_number": issue.Number,
|
||||
"title": issue.Title,
|
||||
"url": issue.URL,
|
||||
}
|
||||
|
||||
var claim HubClaim
|
||||
if err := h.doJSON("POST", "/issues/claim", body, &claim); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claim, nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates the status of a claimed issue on the hub.
|
||||
func (h *HubService) UpdateStatus(issueID, status, prURL string, prNumber int) error {
|
||||
body := map[string]interface{}{
|
||||
"client_id": h.config.GetClientID(),
|
||||
"status": status,
|
||||
}
|
||||
if prURL != "" {
|
||||
body["pr_url"] = prURL
|
||||
}
|
||||
if prNumber > 0 {
|
||||
body["pr_number"] = prNumber
|
||||
}
|
||||
|
||||
path := "/issues/" + url.PathEscape(issueID) + "/status"
|
||||
return h.doJSON("PATCH", path, body, nil)
|
||||
}
|
||||
|
||||
// ReleaseClaim releases a previously claimed issue back to the pool.
|
||||
func (h *HubService) ReleaseClaim(issueID string) error {
|
||||
body := map[string]string{
|
||||
"client_id": h.config.GetClientID(),
|
||||
}
|
||||
|
||||
path := "/issues/" + url.PathEscape(issueID) + "/claim"
|
||||
return h.doJSON("DELETE", path, body, nil)
|
||||
}
|
||||
|
||||
// SyncStats uploads local statistics to the hub.
|
||||
func (h *HubService) SyncStats(stats *Stats) error {
|
||||
// Build repos_contributed as a flat string slice from the map keys.
|
||||
repos := make([]string, 0, len(stats.ReposContributed))
|
||||
for k := range stats.ReposContributed {
|
||||
repos = append(repos, k)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"client_id": h.config.GetClientID(),
|
||||
"stats": map[string]interface{}{
|
||||
"issues_attempted": stats.IssuesAttempted,
|
||||
"issues_completed": stats.IssuesCompleted,
|
||||
"issues_skipped": stats.IssuesSkipped,
|
||||
"prs_submitted": stats.PRsSubmitted,
|
||||
"prs_merged": stats.PRsMerged,
|
||||
"prs_rejected": stats.PRsRejected,
|
||||
"current_streak": stats.CurrentStreak,
|
||||
"longest_streak": stats.LongestStreak,
|
||||
"total_time_minutes": int(stats.TotalTimeSpent.Minutes()),
|
||||
"repos_contributed": repos,
|
||||
},
|
||||
}
|
||||
|
||||
return h.doJSON("POST", "/stats/sync", body, nil)
|
||||
}
|
||||
|
||||
// ---- Task 6: Read Operations ----
|
||||
|
||||
// IsIssueClaimed checks whether an issue is currently claimed on the hub.
|
||||
// Returns the claim if it exists, or (nil, nil) if the issue is not claimed (404).
|
||||
func (h *HubService) IsIssueClaimed(issueID string) (*HubClaim, error) {
|
||||
path := "/issues/" + url.PathEscape(issueID)
|
||||
|
||||
var claim HubClaim
|
||||
if err := h.doJSON("GET", path, nil, &claim); err != nil {
|
||||
if _, ok := err.(*NotFoundError); ok {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &claim, nil
|
||||
}
|
||||
|
||||
// ListClaims returns claimed issues, optionally filtered by status and/or repo.
|
||||
func (h *HubService) ListClaims(status, repo string) ([]*HubClaim, error) {
|
||||
params := url.Values{}
|
||||
if status != "" {
|
||||
params.Set("status", status)
|
||||
}
|
||||
if repo != "" {
|
||||
params.Set("repo", repo)
|
||||
}
|
||||
|
||||
path := "/issues/claimed"
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
|
||||
var claims []*HubClaim
|
||||
if err := h.doJSON("GET", path, nil, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// leaderboardResponse wraps the hub leaderboard JSON envelope.
|
||||
type leaderboardResponse struct {
|
||||
Entries []LeaderboardEntry `json:"entries"`
|
||||
TotalParticipants int `json:"totalParticipants"`
|
||||
}
|
||||
|
||||
// GetLeaderboard fetches the top N leaderboard entries from the hub.
|
||||
func (h *HubService) GetLeaderboard(limit int) ([]LeaderboardEntry, int, error) {
|
||||
path := fmt.Sprintf("/leaderboard?limit=%d", limit)
|
||||
|
||||
var resp leaderboardResponse
|
||||
if err := h.doJSON("GET", path, nil, &resp); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return resp.Entries, resp.TotalParticipants, nil
|
||||
}
|
||||
|
||||
// GetGlobalStats fetches aggregate statistics from the hub.
|
||||
func (h *HubService) GetGlobalStats() (*GlobalStats, error) {
|
||||
var stats GlobalStats
|
||||
if err := h.doJSON("GET", "/stats", nil, &stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
558
internal/bugseti/hub_test.go
Normal file
558
internal/bugseti/hub_test.go
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testHubService(t *testing.T, serverURL string) *HubService {
|
||||
t.Helper()
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
if serverURL != "" {
|
||||
cfg.config.HubURL = serverURL
|
||||
}
|
||||
return NewHubService(cfg)
|
||||
}
|
||||
|
||||
// ---- NewHubService ----
|
||||
|
||||
func TestNewHubService_Good(t *testing.T) {
|
||||
h := testHubService(t, "")
|
||||
require.NotNil(t, h)
|
||||
assert.NotNil(t, h.config)
|
||||
assert.NotNil(t, h.client)
|
||||
assert.False(t, h.IsConnected())
|
||||
}
|
||||
|
||||
func TestHubServiceName_Good(t *testing.T) {
|
||||
h := testHubService(t, "")
|
||||
assert.Equal(t, "HubService", h.ServiceName())
|
||||
}
|
||||
|
||||
func TestNewHubService_Good_GeneratesClientID(t *testing.T) {
|
||||
h := testHubService(t, "")
|
||||
id := h.GetClientID()
|
||||
assert.NotEmpty(t, id)
|
||||
// 16 bytes = 32 hex characters
|
||||
assert.Len(t, id, 32)
|
||||
}
|
||||
|
||||
func TestNewHubService_Good_ReusesClientID(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.ClientID = "existing-client-id"
|
||||
|
||||
h := NewHubService(cfg)
|
||||
assert.Equal(t, "existing-client-id", h.GetClientID())
|
||||
}
|
||||
|
||||
// ---- doRequest ----
|
||||
|
||||
func TestDoRequest_Good(t *testing.T) {
|
||||
var gotAuth string
|
||||
var gotContentType string
|
||||
var gotAccept string
|
||||
var gotBody map[string]string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotContentType = r.Header.Get("Content-Type")
|
||||
gotAccept = r.Header.Get("Accept")
|
||||
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "test-token-123"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
body := map[string]string{"key": "value"}
|
||||
resp, err := h.doRequest("POST", "/test", body)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "Bearer test-token-123", gotAuth)
|
||||
assert.Equal(t, "application/json", gotContentType)
|
||||
assert.Equal(t, "application/json", gotAccept)
|
||||
assert.Equal(t, "value", gotBody["key"])
|
||||
assert.True(t, h.IsConnected())
|
||||
}
|
||||
|
||||
func TestDoRequest_Bad_NoHubURL(t *testing.T) {
|
||||
h := testHubService(t, "")
|
||||
|
||||
resp, err := h.doRequest("GET", "/test", nil)
|
||||
assert.Nil(t, resp)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "hub URL not configured")
|
||||
}
|
||||
|
||||
func TestDoRequest_Bad_NetworkError(t *testing.T) {
|
||||
// Point to a port where nothing is listening.
|
||||
h := testHubService(t, "http://127.0.0.1:1")
|
||||
|
||||
resp, err := h.doRequest("GET", "/test", nil)
|
||||
assert.Nil(t, resp)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, h.IsConnected())
|
||||
}
|
||||
|
||||
// ---- Task 4: AutoRegister ----
|
||||
|
||||
func TestAutoRegister_Good(t *testing.T) {
|
||||
var gotBody map[string]string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/bugseti/auth/forge", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"api_key":"ak_test_12345"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.ForgeURL = "https://forge.example.com"
|
||||
cfg.config.ForgeToken = "forge-tok-abc"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
err := h.AutoRegister()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify token was cached.
|
||||
assert.Equal(t, "ak_test_12345", h.config.GetHubToken())
|
||||
|
||||
// Verify request body.
|
||||
assert.Equal(t, "https://forge.example.com", gotBody["forge_url"])
|
||||
assert.Equal(t, "forge-tok-abc", gotBody["forge_token"])
|
||||
assert.NotEmpty(t, gotBody["client_id"])
|
||||
}
|
||||
|
||||
func TestAutoRegister_Bad_NoForgeToken(t *testing.T) {
|
||||
// Isolate from user's real ~/.core/config.yaml and env vars.
|
||||
origHome := os.Getenv("HOME")
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("FORGE_TOKEN", "")
|
||||
t.Setenv("FORGE_URL", "")
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = "https://hub.example.com"
|
||||
// No forge token set, and env/config are empty in test.
|
||||
h := NewHubService(cfg)
|
||||
|
||||
err := h.AutoRegister()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no forge token available")
|
||||
}
|
||||
|
||||
func TestAutoRegister_Good_SkipsIfAlreadyRegistered(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = "https://hub.example.com"
|
||||
cfg.config.HubToken = "existing-token"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
err := h.AutoRegister()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token should remain unchanged.
|
||||
assert.Equal(t, "existing-token", h.config.GetHubToken())
|
||||
}
|
||||
|
||||
// ---- Task 5: Write Operations ----
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotBody map[string]string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
cfg.config.ClientName = "MyBugSETI"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
err := h.Register()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/api/bugseti/register", gotPath)
|
||||
assert.Equal(t, "POST", gotMethod)
|
||||
assert.Equal(t, "MyBugSETI", gotBody["name"])
|
||||
assert.NotEmpty(t, gotBody["client_id"])
|
||||
assert.NotEmpty(t, gotBody["version"])
|
||||
assert.NotEmpty(t, gotBody["os"])
|
||||
assert.NotEmpty(t, gotBody["arch"])
|
||||
}
|
||||
|
||||
func TestHeartbeat_Good(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
err := h.Heartbeat()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/api/bugseti/heartbeat", gotPath)
|
||||
assert.Equal(t, "POST", gotMethod)
|
||||
}
|
||||
|
||||
func TestClaimIssue_Good(t *testing.T) {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
expires := now.Add(30 * time.Minute)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/bugseti/issues/claim", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
assert.Equal(t, "issue-42", body["issue_id"])
|
||||
assert.Equal(t, "org/repo", body["repo"])
|
||||
assert.Equal(t, float64(42), body["issue_number"])
|
||||
assert.Equal(t, "Fix the bug", body["title"])
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
resp := HubClaim{
|
||||
ID: "claim-1",
|
||||
IssueURL: "https://github.com/org/repo/issues/42",
|
||||
ClientID: "test",
|
||||
ClaimedAt: now,
|
||||
ExpiresAt: expires,
|
||||
Status: "claimed",
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
issue := &Issue{
|
||||
ID: "issue-42",
|
||||
Number: 42,
|
||||
Repo: "org/repo",
|
||||
Title: "Fix the bug",
|
||||
URL: "https://github.com/org/repo/issues/42",
|
||||
}
|
||||
|
||||
claim, err := h.ClaimIssue(issue)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claim)
|
||||
assert.Equal(t, "claim-1", claim.ID)
|
||||
assert.Equal(t, "claimed", claim.Status)
|
||||
}
|
||||
|
||||
func TestClaimIssue_Bad_Conflict(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
issue := &Issue{ID: "issue-99", Number: 99, Repo: "org/repo", Title: "Already claimed"}
|
||||
|
||||
claim, err := h.ClaimIssue(issue)
|
||||
assert.Nil(t, claim)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *ConflictError
|
||||
assert.ErrorAs(t, err, &conflictErr)
|
||||
}
|
||||
|
||||
func TestUpdateStatus_Good(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotBody map[string]interface{}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
err := h.UpdateStatus("issue-42", "completed", "https://github.com/org/repo/pull/10", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PATCH", gotMethod)
|
||||
assert.Equal(t, "/api/bugseti/issues/issue-42/status", gotPath)
|
||||
assert.Equal(t, "completed", gotBody["status"])
|
||||
assert.Equal(t, "https://github.com/org/repo/pull/10", gotBody["pr_url"])
|
||||
assert.Equal(t, float64(10), gotBody["pr_number"])
|
||||
}
|
||||
|
||||
func TestSyncStats_Good(t *testing.T) {
|
||||
var gotBody map[string]interface{}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/bugseti/stats/sync", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
stats := &Stats{
|
||||
IssuesAttempted: 10,
|
||||
IssuesCompleted: 7,
|
||||
IssuesSkipped: 3,
|
||||
PRsSubmitted: 6,
|
||||
PRsMerged: 5,
|
||||
PRsRejected: 1,
|
||||
CurrentStreak: 3,
|
||||
LongestStreak: 5,
|
||||
TotalTimeSpent: 90 * time.Minute,
|
||||
ReposContributed: map[string]*RepoStats{
|
||||
"org/repo-a": {Name: "org/repo-a"},
|
||||
"org/repo-b": {Name: "org/repo-b"},
|
||||
},
|
||||
}
|
||||
|
||||
err := h.SyncStats(stats)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, gotBody["client_id"])
|
||||
statsMap, ok := gotBody["stats"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, float64(10), statsMap["issues_attempted"])
|
||||
assert.Equal(t, float64(7), statsMap["issues_completed"])
|
||||
assert.Equal(t, float64(3), statsMap["issues_skipped"])
|
||||
assert.Equal(t, float64(6), statsMap["prs_submitted"])
|
||||
assert.Equal(t, float64(5), statsMap["prs_merged"])
|
||||
assert.Equal(t, float64(1), statsMap["prs_rejected"])
|
||||
assert.Equal(t, float64(3), statsMap["current_streak"])
|
||||
assert.Equal(t, float64(5), statsMap["longest_streak"])
|
||||
assert.Equal(t, float64(90), statsMap["total_time_minutes"])
|
||||
|
||||
reposRaw, ok := statsMap["repos_contributed"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, reposRaw, 2)
|
||||
}
|
||||
|
||||
// ---- Task 6: Read Operations ----
|
||||
|
||||
func TestIsIssueClaimed_Good_Claimed(t *testing.T) {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/bugseti/issues/issue-42", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
claim := HubClaim{
|
||||
ID: "claim-1",
|
||||
IssueURL: "https://github.com/org/repo/issues/42",
|
||||
ClientID: "client-abc",
|
||||
ClaimedAt: now,
|
||||
Status: "claimed",
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(claim)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
claim, err := h.IsIssueClaimed("issue-42")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, claim)
|
||||
assert.Equal(t, "claim-1", claim.ID)
|
||||
assert.Equal(t, "claimed", claim.Status)
|
||||
}
|
||||
|
||||
func TestIsIssueClaimed_Good_NotClaimed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
claim, err := h.IsIssueClaimed("issue-999")
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, claim)
|
||||
}
|
||||
|
||||
func TestGetLeaderboard_Good(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/bugseti/leaderboard", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "10", r.URL.Query().Get("limit"))
|
||||
|
||||
resp := leaderboardResponse{
|
||||
Entries: []LeaderboardEntry{
|
||||
{ClientID: "a", ClientName: "Alice", Score: 100, PRsMerged: 10, Rank: 1},
|
||||
{ClientID: "b", ClientName: "Bob", Score: 80, PRsMerged: 8, Rank: 2},
|
||||
},
|
||||
TotalParticipants: 42,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
entries, total, err := h.GetLeaderboard(10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 42, total)
|
||||
require.Len(t, entries, 2)
|
||||
assert.Equal(t, "Alice", entries[0].ClientName)
|
||||
assert.Equal(t, 1, entries[0].Rank)
|
||||
assert.Equal(t, "Bob", entries[1].ClientName)
|
||||
}
|
||||
|
||||
func TestGetGlobalStats_Good(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/bugseti/stats", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
stats := GlobalStats{
|
||||
TotalClients: 100,
|
||||
TotalClaims: 500,
|
||||
TotalPRsMerged: 300,
|
||||
ActiveClaims: 25,
|
||||
IssuesAvailable: 150,
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(stats)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
stats, err := h.GetGlobalStats()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stats)
|
||||
assert.Equal(t, 100, stats.TotalClients)
|
||||
assert.Equal(t, 500, stats.TotalClaims)
|
||||
assert.Equal(t, 300, stats.TotalPRsMerged)
|
||||
assert.Equal(t, 25, stats.ActiveClaims)
|
||||
assert.Equal(t, 150, stats.IssuesAvailable)
|
||||
}
|
||||
|
||||
// ---- Task 7: Pending Operations Queue ----
|
||||
|
||||
func TestPendingOps_Good_QueueAndDrain(t *testing.T) {
|
||||
var callCount int32
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = srv.URL
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
// Manually queue a pending op (simulates a previous failed request).
|
||||
h.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
|
||||
assert.Equal(t, 1, h.PendingCount())
|
||||
|
||||
// Register() calls drainPendingOps() first, then sends its own request.
|
||||
err := h.Register()
|
||||
require.NoError(t, err)
|
||||
|
||||
// At least 2 calls: 1 from drain (the queued heartbeat) + 1 from Register itself.
|
||||
assert.GreaterOrEqual(t, callCount, int32(2))
|
||||
assert.Equal(t, 0, h.PendingCount())
|
||||
}
|
||||
|
||||
func TestPendingOps_Good_PersistAndLoad(t *testing.T) {
|
||||
cfg1 := testConfigService(t, nil, nil)
|
||||
cfg1.config.HubURL = "https://hub.example.com"
|
||||
cfg1.config.HubToken = "tok"
|
||||
h1 := NewHubService(cfg1)
|
||||
|
||||
// Queue an op — this also calls savePendingOps.
|
||||
h1.queueOp("POST", "/heartbeat", map[string]string{"client_id": "test"})
|
||||
assert.Equal(t, 1, h1.PendingCount())
|
||||
|
||||
// Create a second HubService with the same data dir.
|
||||
// NewHubService calls loadPendingOps() in its constructor.
|
||||
cfg2 := testConfigService(t, nil, nil)
|
||||
cfg2.config.DataDir = cfg1.config.DataDir // Share the same data dir.
|
||||
cfg2.config.HubURL = "https://hub.example.com"
|
||||
cfg2.config.HubToken = "tok"
|
||||
h2 := NewHubService(cfg2)
|
||||
|
||||
assert.Equal(t, 1, h2.PendingCount())
|
||||
}
|
||||
|
||||
func TestPendingCount_Good(t *testing.T) {
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
cfg.config.HubURL = "https://hub.example.com"
|
||||
cfg.config.HubToken = "tok"
|
||||
h := NewHubService(cfg)
|
||||
|
||||
assert.Equal(t, 0, h.PendingCount())
|
||||
|
||||
h.queueOp("POST", "/test1", nil)
|
||||
assert.Equal(t, 1, h.PendingCount())
|
||||
|
||||
h.queueOp("POST", "/test2", map[string]string{"key": "val"})
|
||||
assert.Equal(t, 2, h.PendingCount())
|
||||
}
|
||||
|
|
@ -99,11 +99,17 @@ func (h *issueHeap) Pop() any {
|
|||
func NewQueueService(config *ConfigService) *QueueService {
|
||||
q := &QueueService{
|
||||
config: config,
|
||||
issues: make(issueHeap, 0),
|
||||
seen: make(map[string]bool),
|
||||
}
|
||||
heap.Init(&q.issues)
|
||||
q.load() // Load persisted queue
|
||||
|
||||
// Hold the lock for the entire initialization sequence so that all
|
||||
// shared state (issues, seen, current) is fully populated before
|
||||
// any concurrent caller can observe the service.
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
q.issues = make(issueHeap, 0)
|
||||
q.seen = make(map[string]bool)
|
||||
q.load() // Load persisted queue (overwrites issues/seen if file exists)
|
||||
return q
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +251,7 @@ type queueState struct {
|
|||
Seen []string `json:"seen"`
|
||||
}
|
||||
|
||||
// save persists the queue to disk.
|
||||
// save persists the queue to disk. Must be called with q.mu held.
|
||||
func (q *QueueService) save() {
|
||||
dataDir := q.config.GetDataDir()
|
||||
if dataDir == "" {
|
||||
|
|
@ -276,7 +282,7 @@ func (q *QueueService) save() {
|
|||
}
|
||||
}
|
||||
|
||||
// load restores the queue from disk.
|
||||
// load restores the queue from disk. Must be called with q.mu held.
|
||||
func (q *QueueService) load() {
|
||||
dataDir := q.config.GetDataDir()
|
||||
if dataDir == "" {
|
||||
|
|
|
|||
|
|
@ -11,18 +11,24 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SeederService prepares context for issues using the seed-agent-developer skill.
|
||||
type SeederService struct {
|
||||
config *ConfigService
|
||||
mu sync.Mutex
|
||||
config *ConfigService
|
||||
forgeURL string
|
||||
forgeToken string
|
||||
}
|
||||
|
||||
// NewSeederService creates a new SeederService.
|
||||
func NewSeederService(config *ConfigService) *SeederService {
|
||||
func NewSeederService(config *ConfigService, forgeURL, forgeToken string) *SeederService {
|
||||
return &SeederService{
|
||||
config: config,
|
||||
config: config,
|
||||
forgeURL: forgeURL,
|
||||
forgeToken: forgeToken,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +39,9 @@ func (s *SeederService) ServiceName() string {
|
|||
|
||||
// SeedIssue prepares context for an issue by calling the seed-agent-developer skill.
|
||||
func (s *SeederService) SeedIssue(issue *Issue) (*IssueContext, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if issue == nil {
|
||||
return nil, fmt.Errorf("issue is nil")
|
||||
}
|
||||
|
|
@ -76,7 +85,18 @@ func (s *SeederService) prepareWorkspace(issue *Issue) (string, error) {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", issue.Repo, workDir, "--", "--depth=1")
|
||||
cloneURL := fmt.Sprintf("%s/%s.git", strings.TrimRight(s.forgeURL, "/"), issue.Repo)
|
||||
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", cloneURL, workDir)
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("GIT_ASKPASS=echo"),
|
||||
fmt.Sprintf("GIT_TERMINAL_PROMPT=0"),
|
||||
)
|
||||
if s.forgeToken != "" {
|
||||
// Use token auth via URL for HTTPS clones
|
||||
cloneURL = fmt.Sprintf("%s/%s.git", strings.TrimRight(s.forgeURL, "/"), issue.Repo)
|
||||
cloneURL = strings.Replace(cloneURL, "://", fmt.Sprintf("://bugseti:%s@", s.forgeToken), 1)
|
||||
cmd = exec.CommandContext(ctx, "git", "clone", "--depth=1", cloneURL, workDir)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
|
|
@ -338,6 +358,14 @@ func sanitizeIssueContext(ctx *IssueContext, guard *EthicsGuard) *IssueContext {
|
|||
|
||||
// GetWorkspaceDir returns the workspace directory for an issue.
|
||||
func (s *SeederService) GetWorkspaceDir(issue *Issue) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.getWorkspaceDir(issue)
|
||||
}
|
||||
|
||||
// getWorkspaceDir is the lock-free implementation; caller must hold s.mu.
|
||||
func (s *SeederService) getWorkspaceDir(issue *Issue) string {
|
||||
baseDir := s.config.GetWorkspaceDir()
|
||||
if baseDir == "" {
|
||||
baseDir = filepath.Join(os.TempDir(), "bugseti")
|
||||
|
|
@ -347,6 +375,9 @@ func (s *SeederService) GetWorkspaceDir(issue *Issue) string {
|
|||
|
||||
// CleanupWorkspace removes the workspace for an issue.
|
||||
func (s *SeederService) CleanupWorkspace(issue *Issue) error {
|
||||
workDir := s.GetWorkspaceDir(issue)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
workDir := s.getWorkspaceDir(issue)
|
||||
return os.RemoveAll(workDir)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ package bugseti
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
forgejo "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/forge"
|
||||
)
|
||||
|
||||
// SubmitService handles the PR submission flow.
|
||||
|
|
@ -18,14 +20,16 @@ type SubmitService struct {
|
|||
config *ConfigService
|
||||
notify *NotifyService
|
||||
stats *StatsService
|
||||
forge *forge.Client
|
||||
}
|
||||
|
||||
// NewSubmitService creates a new SubmitService.
|
||||
func NewSubmitService(config *ConfigService, notify *NotifyService, stats *StatsService) *SubmitService {
|
||||
func NewSubmitService(config *ConfigService, notify *NotifyService, stats *StatsService, forgeClient *forge.Client) *SubmitService {
|
||||
return &SubmitService{
|
||||
config: config,
|
||||
notify: notify,
|
||||
stats: stats,
|
||||
forge: forgeClient,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +59,7 @@ type PRResult struct {
|
|||
}
|
||||
|
||||
// Submit creates a pull request for the given issue.
|
||||
// Flow: Fork -> Branch -> Commit -> PR
|
||||
// Flow: Fork -> Branch -> Commit -> Push -> PR
|
||||
func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
|
||||
if submission == nil || submission.Issue == nil {
|
||||
return nil, fmt.Errorf("invalid submission")
|
||||
|
|
@ -70,8 +74,13 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
|
|||
guard := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot())
|
||||
issueTitle := guard.SanitizeTitle(issue.Title)
|
||||
|
||||
owner, repoName, err := splitRepo(issue.Repo)
|
||||
if err != nil {
|
||||
return &PRResult{Success: false, Error: err.Error()}, err
|
||||
}
|
||||
|
||||
// Step 1: Ensure we have a fork
|
||||
forkOwner, err := s.ensureFork(issue.Repo)
|
||||
forkOwner, err := s.ensureFork(owner, repoName)
|
||||
if err != nil {
|
||||
return &PRResult{Success: false, Error: fmt.Sprintf("fork failed: %v", err)}, err
|
||||
}
|
||||
|
|
@ -97,7 +106,7 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
|
|||
}
|
||||
|
||||
// Step 4: Push to fork
|
||||
if err := s.pushToFork(workDir, forkOwner, branch); err != nil {
|
||||
if err := s.pushToFork(workDir, forkOwner, repoName, branch); err != nil {
|
||||
return &PRResult{Success: false, Error: fmt.Sprintf("push failed: %v", err)}, err
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +123,7 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
|
|||
}
|
||||
prBody = guard.SanitizeBody(prBody)
|
||||
|
||||
prURL, prNumber, err := s.createPR(issue.Repo, forkOwner, branch, prTitle, prBody)
|
||||
prURL, prNumber, err := s.createPR(owner, repoName, forkOwner, branch, prTitle, prBody)
|
||||
if err != nil {
|
||||
return &PRResult{Success: false, Error: fmt.Sprintf("PR creation failed: %v", err)}, err
|
||||
}
|
||||
|
|
@ -133,39 +142,31 @@ func (s *SubmitService) Submit(submission *PRSubmission) (*PRResult, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// ensureFork ensures a fork exists for the repo.
|
||||
func (s *SubmitService) ensureFork(repo string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Check if fork exists
|
||||
parts := strings.Split(repo, "/")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid repo format: %s", repo)
|
||||
}
|
||||
|
||||
// ensureFork ensures a fork exists for the repo, returns the fork owner's username.
|
||||
func (s *SubmitService) ensureFork(owner, repo string) (string, error) {
|
||||
// Get current user
|
||||
cmd := exec.CommandContext(ctx, "gh", "api", "user", "--jq", ".login")
|
||||
output, err := cmd.Output()
|
||||
user, err := s.forge.GetCurrentUser()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user: %w", err)
|
||||
return "", fmt.Errorf("failed to get current user: %w", err)
|
||||
}
|
||||
username := strings.TrimSpace(string(output))
|
||||
username := user.UserName
|
||||
|
||||
// Check if fork exists
|
||||
forkRepo := fmt.Sprintf("%s/%s", username, parts[1])
|
||||
cmd = exec.CommandContext(ctx, "gh", "repo", "view", forkRepo, "--json", "name")
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Fork doesn't exist, create it
|
||||
log.Printf("Creating fork of %s...", repo)
|
||||
cmd = exec.CommandContext(ctx, "gh", "repo", "fork", repo, "--clone=false")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to create fork: %w", err)
|
||||
}
|
||||
// Wait a bit for GitHub to process
|
||||
time.Sleep(2 * time.Second)
|
||||
// Check if fork already exists
|
||||
_, err = s.forge.GetRepo(username, repo)
|
||||
if err == nil {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
// Fork doesn't exist, create it
|
||||
log.Printf("Creating fork of %s/%s...", owner, repo)
|
||||
_, err = s.forge.ForkRepo(owner, repo, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create fork: %w", err)
|
||||
}
|
||||
|
||||
// Wait for Forgejo to process the fork
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return username, nil
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +178,9 @@ func (s *SubmitService) createBranch(workDir, branch string) error {
|
|||
// Fetch latest from upstream
|
||||
cmd := exec.CommandContext(ctx, "git", "fetch", "origin")
|
||||
cmd.Dir = workDir
|
||||
cmd.Run() // Ignore errors
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("WARNING: git fetch origin failed in %s: %v (proceeding with potentially stale data)", workDir, err)
|
||||
}
|
||||
|
||||
// Create and checkout new branch
|
||||
cmd = exec.CommandContext(ctx, "git", "checkout", "-b", branch)
|
||||
|
|
@ -239,7 +242,7 @@ func (s *SubmitService) commitChanges(workDir string, files []string, message st
|
|||
}
|
||||
|
||||
// pushToFork pushes the branch to the user's fork.
|
||||
func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error {
|
||||
func (s *SubmitService) pushToFork(workDir, forkOwner, repoName, branch string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -248,29 +251,12 @@ func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error {
|
|||
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", forkRemote)
|
||||
cmd.Dir = workDir
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Get the origin URL and construct fork URL
|
||||
cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
|
||||
cmd.Dir = workDir
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get origin URL: %w", err)
|
||||
}
|
||||
// Construct fork URL using the forge instance URL
|
||||
forkURL := fmt.Sprintf("%s/%s/%s.git", strings.TrimRight(s.forge.URL(), "/"), forkOwner, repoName)
|
||||
|
||||
originURL := strings.TrimSpace(string(output))
|
||||
// Replace original owner with fork owner
|
||||
var forkURL string
|
||||
if strings.HasPrefix(originURL, "https://") {
|
||||
// https://github.com/owner/repo.git
|
||||
parts := strings.Split(originURL, "/")
|
||||
if len(parts) >= 4 {
|
||||
parts[len(parts)-2] = forkOwner
|
||||
forkURL = strings.Join(parts, "/")
|
||||
}
|
||||
} else {
|
||||
// git@github.com:owner/repo.git
|
||||
forkURL = strings.Replace(originURL, ":", fmt.Sprintf(":%s/", forkOwner), 1)
|
||||
forkURL = strings.Replace(forkURL, strings.Split(forkURL, "/")[0]+"/", "", 1)
|
||||
forkURL = fmt.Sprintf("git@github.com:%s/%s", forkOwner, filepath.Base(originURL))
|
||||
// Embed token for HTTPS push auth
|
||||
if s.forge.Token() != "" {
|
||||
forkURL = strings.Replace(forkURL, "://", fmt.Sprintf("://bugseti:%s@", s.forge.Token()), 1)
|
||||
}
|
||||
|
||||
cmd = exec.CommandContext(ctx, "git", "remote", "add", forkRemote, forkURL)
|
||||
|
|
@ -292,36 +278,19 @@ func (s *SubmitService) pushToFork(workDir, forkOwner, branch string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// createPR creates a pull request using GitHub CLI.
|
||||
func (s *SubmitService) createPR(repo, forkOwner, branch, title, body string) (string, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create PR
|
||||
cmd := exec.CommandContext(ctx, "gh", "pr", "create",
|
||||
"--repo", repo,
|
||||
"--head", fmt.Sprintf("%s:%s", forkOwner, branch),
|
||||
"--title", title,
|
||||
"--body", body,
|
||||
"--json", "url,number")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", 0, fmt.Errorf("failed to create PR: %s: %w", stderr.String(), err)
|
||||
// createPR creates a pull request using the Forgejo API.
|
||||
func (s *SubmitService) createPR(owner, repo, forkOwner, branch, title, body string) (string, int, error) {
|
||||
pr, err := s.forge.CreatePullRequest(owner, repo, forgejo.CreatePullRequestOption{
|
||||
Head: fmt.Sprintf("%s:%s", forkOwner, branch),
|
||||
Base: "main",
|
||||
Title: title,
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("failed to create PR: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
|
||||
return "", 0, fmt.Errorf("failed to parse PR response: %w", err)
|
||||
}
|
||||
|
||||
return result.URL, result.Number, nil
|
||||
return pr.HTMLURL, int(pr.Index), nil
|
||||
}
|
||||
|
||||
// generatePRBody creates a default PR body for an issue.
|
||||
|
|
@ -344,60 +313,44 @@ func (s *SubmitService) generatePRBody(issue *Issue) string {
|
|||
body.WriteString("<!-- Describe how you tested your changes -->\n\n")
|
||||
|
||||
body.WriteString("---\n\n")
|
||||
body.WriteString("*Submitted via [BugSETI](https://github.com/host-uk/core) - Distributed Bug Fixing*\n")
|
||||
body.WriteString("*Submitted via [BugSETI](https://bugseti.app) - Distributed Bug Fixing*\n")
|
||||
|
||||
return body.String()
|
||||
}
|
||||
|
||||
// GetPRStatus checks the status of a submitted PR.
|
||||
func (s *SubmitService) GetPRStatus(repo string, prNumber int) (*PRStatus, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
owner, repoName, err := splitRepo(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gh", "pr", "view",
|
||||
"--repo", repo,
|
||||
fmt.Sprintf("%d", prNumber),
|
||||
"--json", "state,mergeable,reviews,statusCheckRollup")
|
||||
|
||||
output, err := cmd.Output()
|
||||
pr, err := s.forge.GetPullRequest(owner, repoName, int64(prNumber))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get PR status: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
State string `json:"state"`
|
||||
Mergeable string `json:"mergeable"`
|
||||
StatusCheckRollup []struct {
|
||||
State string `json:"state"`
|
||||
} `json:"statusCheckRollup"`
|
||||
Reviews []struct {
|
||||
State string `json:"state"`
|
||||
} `json:"reviews"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(output, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PR status: %w", err)
|
||||
}
|
||||
|
||||
status := &PRStatus{
|
||||
State: result.State,
|
||||
Mergeable: result.Mergeable == "MERGEABLE",
|
||||
State: string(pr.State),
|
||||
Mergeable: pr.Mergeable,
|
||||
}
|
||||
|
||||
// Check CI status
|
||||
status.CIPassing = true
|
||||
for _, check := range result.StatusCheckRollup {
|
||||
if check.State != "SUCCESS" && check.State != "NEUTRAL" {
|
||||
status.CIPassing = false
|
||||
break
|
||||
// Check CI status via combined commit status
|
||||
if pr.Head != nil {
|
||||
combined, err := s.forge.GetCombinedStatus(owner, repoName, pr.Head.Sha)
|
||||
if err == nil && combined != nil {
|
||||
status.CIPassing = combined.State == forgejo.StatusSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// Check review status
|
||||
for _, review := range result.Reviews {
|
||||
if review.State == "APPROVED" {
|
||||
status.Approved = true
|
||||
break
|
||||
reviews, err := s.forge.ListPRReviews(owner, repoName, int64(prNumber))
|
||||
if err == nil {
|
||||
for _, review := range reviews {
|
||||
if review.State == forgejo.ReviewStateApproved {
|
||||
status.Approved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
234
internal/bugseti/submit_test.go
Normal file
234
internal/bugseti/submit_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package bugseti
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testSubmitService(t *testing.T) *SubmitService {
|
||||
t.Helper()
|
||||
cfg := testConfigService(t, nil, nil)
|
||||
notify := &NotifyService{enabled: false, config: cfg}
|
||||
stats := &StatsService{
|
||||
config: cfg,
|
||||
stats: &Stats{
|
||||
ReposContributed: make(map[string]*RepoStats),
|
||||
DailyActivity: make(map[string]*DayStats),
|
||||
},
|
||||
}
|
||||
return NewSubmitService(cfg, notify, stats, nil)
|
||||
}
|
||||
|
||||
// --- NewSubmitService / ServiceName ---
|
||||
|
||||
func TestNewSubmitService_Good(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil SubmitService")
|
||||
}
|
||||
if s.config == nil || s.notify == nil || s.stats == nil {
|
||||
t.Fatal("expected all dependencies set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceName_Good(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
if got := s.ServiceName(); got != "SubmitService" {
|
||||
t.Fatalf("expected %q, got %q", "SubmitService", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Submit validation ---
|
||||
|
||||
func TestSubmit_Bad_NilSubmission(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
_, err := s.Submit(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil submission")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid submission") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_Bad_NilIssue(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
_, err := s.Submit(&PRSubmission{Issue: nil})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil issue")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid submission") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmit_Bad_EmptyWorkDir(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
_, err := s.Submit(&PRSubmission{
|
||||
Issue: &Issue{Number: 1, Repo: "owner/repo", Title: "test"},
|
||||
WorkDir: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty work directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "work directory not specified") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- generatePRBody ---
|
||||
|
||||
func TestGeneratePRBody_Good_Basic(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
issue := &Issue{Number: 42, Repo: "owner/repo", Title: "A bug"}
|
||||
body := s.generatePRBody(issue)
|
||||
|
||||
if !strings.Contains(body, "#42") {
|
||||
t.Fatal("PR body should reference issue number")
|
||||
}
|
||||
if !strings.Contains(body, "## Summary") {
|
||||
t.Fatal("PR body should have Summary section")
|
||||
}
|
||||
if !strings.Contains(body, "## Changes") {
|
||||
t.Fatal("PR body should have Changes section")
|
||||
}
|
||||
if !strings.Contains(body, "## Testing") {
|
||||
t.Fatal("PR body should have Testing section")
|
||||
}
|
||||
if !strings.Contains(body, "BugSETI") {
|
||||
t.Fatal("PR body should have BugSETI attribution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePRBody_Good_WithContext(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
issue := &Issue{
|
||||
Number: 7,
|
||||
Repo: "owner/repo",
|
||||
Title: "Fix login",
|
||||
Context: &IssueContext{
|
||||
Summary: "The login endpoint returns 500 on empty password.",
|
||||
},
|
||||
}
|
||||
body := s.generatePRBody(issue)
|
||||
|
||||
if !strings.Contains(body, "## Context") {
|
||||
t.Fatal("PR body should have Context section when context exists")
|
||||
}
|
||||
if !strings.Contains(body, "login endpoint returns 500") {
|
||||
t.Fatal("PR body should include context summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePRBody_Good_WithoutContext(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
issue := &Issue{Number: 7, Repo: "owner/repo", Title: "Fix login"}
|
||||
body := s.generatePRBody(issue)
|
||||
|
||||
if strings.Contains(body, "## Context") {
|
||||
t.Fatal("PR body should omit Context section when no context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePRBody_Good_EmptyContextSummary(t *testing.T) {
|
||||
s := testSubmitService(t)
|
||||
issue := &Issue{
|
||||
Number: 7,
|
||||
Repo: "owner/repo",
|
||||
Title: "Fix login",
|
||||
Context: &IssueContext{Summary: ""},
|
||||
}
|
||||
body := s.generatePRBody(issue)
|
||||
|
||||
if strings.Contains(body, "## Context") {
|
||||
t.Fatal("PR body should omit Context section when summary is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// --- PRSubmission / PRResult struct tests ---
|
||||
|
||||
func TestPRSubmission_Good_Defaults(t *testing.T) {
|
||||
sub := &PRSubmission{
|
||||
Issue: &Issue{Number: 10, Repo: "o/r"},
|
||||
WorkDir: "/tmp/work",
|
||||
}
|
||||
if sub.Branch != "" {
|
||||
t.Fatal("expected empty branch to be default")
|
||||
}
|
||||
if sub.Title != "" {
|
||||
t.Fatal("expected empty title to be default")
|
||||
}
|
||||
if sub.CommitMsg != "" {
|
||||
t.Fatal("expected empty commit msg to be default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRResult_Good_Success(t *testing.T) {
|
||||
r := &PRResult{
|
||||
Success: true,
|
||||
PRURL: "https://forge.lthn.ai/o/r/pulls/1",
|
||||
PRNumber: 1,
|
||||
ForkOwner: "me",
|
||||
}
|
||||
if !r.Success {
|
||||
t.Fatal("expected success")
|
||||
}
|
||||
if r.Error != "" {
|
||||
t.Fatal("expected no error on success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRResult_Good_Failure(t *testing.T) {
|
||||
r := &PRResult{
|
||||
Success: false,
|
||||
Error: "fork failed: something",
|
||||
}
|
||||
if r.Success {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if r.Error == "" {
|
||||
t.Fatal("expected error message")
|
||||
}
|
||||
}
|
||||
|
||||
// --- PRStatus struct ---
|
||||
|
||||
func TestPRStatus_Good(t *testing.T) {
|
||||
s := &PRStatus{
|
||||
State: "open",
|
||||
Mergeable: true,
|
||||
CIPassing: true,
|
||||
Approved: false,
|
||||
}
|
||||
if s.State != "open" {
|
||||
t.Fatalf("expected open, got %s", s.State)
|
||||
}
|
||||
if !s.Mergeable {
|
||||
t.Fatal("expected mergeable")
|
||||
}
|
||||
if s.Approved {
|
||||
t.Fatal("expected not approved")
|
||||
}
|
||||
}
|
||||
|
||||
// --- splitRepo ---
|
||||
|
||||
func TestSplitRepo_Good(t *testing.T) {
|
||||
owner, repo, err := splitRepo("myorg/myrepo")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != "myorg" || repo != "myrepo" {
|
||||
t.Fatalf("expected myorg/myrepo, got %s/%s", owner, repo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRepo_Bad(t *testing.T) {
|
||||
_, _, err := splitRepo("invalidrepo")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid repo format")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid repo format") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,30 @@
|
|||
module github.com/host-uk/core/internal/bugseti/updater
|
||||
module forge.lthn.ai/core/cli/internal/bugseti/updater
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/host-uk/core/internal/bugseti v0.0.0
|
||||
golang.org/x/mod v0.25.0
|
||||
forge.lthn.ai/core/cli/internal/bugseti v0.0.0
|
||||
golang.org/x/mod v0.32.0
|
||||
)
|
||||
|
||||
replace github.com/host-uk/core/internal/bugseti => ../
|
||||
require (
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mark3labs/mcp-go v0.43.2 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace forge.lthn.ai/core/cli/internal/bugseti => ../
|
||||
|
|
|
|||
|
|
@ -1,2 +1,28 @@
|
|||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 h1:HTCWpzyWQOHDWt3LzI6/d2jvUDsw/vgGRWm/8BTvcqI=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/internal/bugseti"
|
||||
"forge.lthn.ai/core/cli/internal/bugseti"
|
||||
)
|
||||
|
||||
// Service provides update functionality and Wails bindings.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
// Version information - these are set at build time via ldflags
|
||||
// Example: go build -ldflags "-X github.com/host-uk/core/internal/bugseti.Version=1.0.0"
|
||||
// Example: go build -ldflags "-X forge.lthn.ai/core/cli/internal/bugseti.Version=1.0.0"
|
||||
var (
|
||||
// Version is the semantic version (e.g., "1.0.0", "1.0.0-beta.1", "nightly-20260205")
|
||||
Version = "dev"
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentci"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/config"
|
||||
"forge.lthn.ai/core/cli/pkg/agentci"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/config"
|
||||
)
|
||||
|
||||
// AddAgentCommands registers the 'agent' subcommand group under 'ai'.
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
// Style aliases from shared package
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
ragcmd "github.com/host-uk/core/internal/cmd/rag"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
ragcmd "forge.lthn.ai/core/cli/internal/cmd/rag"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/log"
|
||||
)
|
||||
|
||||
// AddDispatchCommands registers the 'dispatch' subcommand group under 'ai'.
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/agentic"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// task:commit command flags
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/ai"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/ai"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/config"
|
||||
"github.com/host-uk/core/pkg/ratelimit"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/config"
|
||||
"forge.lthn.ai/core/cli/pkg/ratelimit"
|
||||
)
|
||||
|
||||
// AddRateLimitCommands registers the 'ratelimits' subcommand group under 'ai'.
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/host-uk/core/pkg/ai"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/agentic"
|
||||
"forge.lthn.ai/core/cli/pkg/ai"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// tasks command flags
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/host-uk/core/pkg/ai"
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/agentic"
|
||||
"forge.lthn.ai/core/cli/pkg/ai"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// task:update command flags
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package ai
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/host-uk/core/pkg/log"
|
||||
"github.com/host-uk/core/pkg/ratelimit"
|
||||
"forge.lthn.ai/core/cli/pkg/log"
|
||||
"forge.lthn.ai/core/cli/pkg/ratelimit"
|
||||
)
|
||||
|
||||
// executeWithRateLimit wraps an agent execution with rate limiting logic.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import (
|
|||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/release"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/release"
|
||||
)
|
||||
|
||||
func runChangelog(fromRef, toRef string) error {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
package ci
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// Style aliases from shared
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
package ci
|
||||
|
||||
import (
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ package ci
|
|||
import (
|
||||
"os"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/release"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/release"
|
||||
)
|
||||
|
||||
func runCIReleaseInit() error {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import (
|
|||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/release"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/release"
|
||||
)
|
||||
|
||||
// runCIPublish publishes pre-built artifacts from dist/.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ package ci
|
|||
import (
|
||||
"os"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/release"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/release"
|
||||
)
|
||||
|
||||
// runCIReleaseVersion shows the determined version.
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package collect
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"github.com/host-uk/core/pkg/io"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/io"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// BitcoinTalk command flags
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
collectpkg "github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
collectpkg "forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// addDispatchCommand adds the 'dispatch' subcommand to the collect parent.
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// Excavate command flags
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// GitHub command flags
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ package collect
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// Market command flags
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ package collect
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// Papers command flags
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ package collect
|
|||
import (
|
||||
"context"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/collect"
|
||||
"github.com/host-uk/core/pkg/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/collect"
|
||||
"forge.lthn.ai/core/cli/pkg/i18n"
|
||||
)
|
||||
|
||||
// addProcessCommand adds the 'process' subcommand to the collect parent.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package config
|
||||
|
||||
import "github.com/host-uk/core/pkg/cli"
|
||||
import "forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddConfigCommands)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"github.com/host-uk/core/pkg/config"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/config"
|
||||
)
|
||||
|
||||
func addGetCommand(parent *cli.Command) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package config
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
||||
func addPathCommand(parent *cli.Command) {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue