From 7ab336622012e55786996e148113e05a6aa38605 Mon Sep 17 00:00:00 2001 From: Alain Thiffault Date: Sun, 25 Jan 2026 23:36:42 +0000 Subject: [PATCH 01/83] fix(labels): improve delete command and fix --id flag type (#865) ## Summary Fix the `tea labels delete` and `tea labels update` commands which were silently ignoring the `--id` flag. ## Problem Both commands used `IntFlag` for the `--id` parameter but called `ctx.Int64("id")` to retrieve the value. This type mismatch caused the ID to always be read as `0`, making the commands useless. **Before (bug):** ```bash $ tea labels delete --id 36 --debug DELETE: .../labels/0 # Wrong! ID ignored ``` **After (fix):** ```bash $ tea labels delete --id 36 --debug GET: .../labels/36 # Verify exists DELETE: .../labels/36 # Correct ID Label 'my-label' (id: 36) deleted successfully ``` ## Changes ### labels/delete.go - Change `IntFlag` to `Int64Flag` to match `ctx.Int64()` usage - Make `--id` flag required - Verify label exists before attempting deletion - Provide clear error messages with label name and ID context - Print success message after deletion ### labels/update.go - Change `IntFlag` to `Int64Flag` to fix the same bug ## Test plan - [x] `go test ./...` passes - [x] `go vet ./...` passes - [x] `gofmt` check passes - [x] Manual testing confirms ID is now correctly passed to API - [ ] CI passes Reviewed-on: https://gitea.com/gitea/tea/pulls/865 Reviewed-by: Lunny Xiao Co-authored-by: Alain Thiffault Co-committed-by: Alain Thiffault --- cmd/labels/delete.go | 26 +++++++++++++++++++++----- cmd/labels/update.go | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/cmd/labels/delete.go b/cmd/labels/delete.go index 0719fef..cc72e39 100644 --- a/cmd/labels/delete.go +++ b/cmd/labels/delete.go @@ -5,6 +5,7 @@ package labels import ( stdctx "context" + "fmt" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" @@ -21,9 +22,10 @@ var CmdLabelDelete = cli.Command{ ArgsUsage: " ", // command does not accept arguments Action: runLabelDelete, Flags: append([]cli.Flag{ - &cli.IntFlag{ - Name: "id", - Usage: "label id", + &cli.Int64Flag{ + Name: "id", + Usage: "label id", + Required: true, }, }, flags.AllDefaultFlags...), } @@ -32,6 +34,20 @@ func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - _, err := ctx.Login.Client().DeleteLabel(ctx.Owner, ctx.Repo, ctx.Int64("id")) - return err + labelID := ctx.Int64("id") + client := ctx.Login.Client() + + // Verify the label exists first + label, _, err := client.GetRepoLabel(ctx.Owner, ctx.Repo, labelID) + if err != nil { + return fmt.Errorf("failed to get label %d: %w", labelID, err) + } + + _, err = client.DeleteLabel(ctx.Owner, ctx.Repo, labelID) + if err != nil { + return fmt.Errorf("failed to delete label '%s' (id: %d): %w", label.Name, labelID, err) + } + + fmt.Printf("Label '%s' (id: %d) deleted successfully\n", label.Name, labelID) + return nil } diff --git a/cmd/labels/update.go b/cmd/labels/update.go index 3f20862..1b55a90 100644 --- a/cmd/labels/update.go +++ b/cmd/labels/update.go @@ -21,7 +21,7 @@ var CmdLabelUpdate = cli.Command{ ArgsUsage: " ", // command does not accept arguments Action: runLabelUpdate, Flags: append([]cli.Flag{ - &cli.IntFlag{ + &cli.Int64Flag{ Name: "id", Usage: "label id", }, From 45260e1a1f25019b723ade92432a077fb276312a Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 19:50:25 +0000 Subject: [PATCH 02/83] bump action versions in CI for PRs disable govulncheck temporarily --- .gitea/workflows/test-pr.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 30a9527..105de7e 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -4,14 +4,14 @@ on: - pull_request jobs: - govulncheck_job: - runs-on: ubuntu-latest - name: Run govulncheck - steps: - - id: govulncheck - uses: golang/govulncheck-action@v1 - with: - go-version-file: 'go.mod' + #govulncheck_job: + # runs-on: ubuntu-latest + # name: Run govulncheck + # steps: + # - id: govulncheck + # uses: golang/govulncheck-action@v1 + # with: + # go-version-file: 'go.mod' check-and-test: runs-on: ubuntu-latest env: @@ -20,8 +20,8 @@ jobs: GITEA_TEA_TEST_USERNAME: "test01" GITEA_TEA_TEST_PASSWORD: "test01" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: lint and build From c2e9265daeb679dc2be7c3c44e4ce76fa912e57a Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 19:53:20 +0000 Subject: [PATCH 03/83] bump more CI actions --- .gitea/workflows/release-nightly.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 691d27d..6d9178c 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -8,11 +8,11 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - run: git fetch --force --tags - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: "go.mod" - name: import gpg @@ -45,7 +45,7 @@ jobs: DOCKER_LATEST: nightly steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # all history for all branches and tags From ae740a66e87f37ad8bde79418d56a2eee1b7d44a Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 19:54:44 +0000 Subject: [PATCH 04/83] update sdk version (#868) Reviewed-on: https://gitea.com/gitea/tea/pulls/868 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- go.mod | 18 +++++++++--------- go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index 8665cc0..8a28a46 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.4 require ( code.gitea.io/gitea-vet v0.2.3 - code.gitea.io/sdk/gitea v0.22.1 + code.gitea.io/sdk/gitea v0.23.2 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de @@ -19,9 +19,9 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.6.1 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.33.0 - golang.org/x/term v0.37.0 + golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -61,7 +61,7 @@ require ( github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -87,11 +87,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 19f8636..632c541 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= -code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= -code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= +code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= @@ -122,8 +122,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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -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/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -220,26 +220,26 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +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-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -250,21 +250,21 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 20da41414557b6bbe554ac72318868aaf115385b Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 22:39:26 +0000 Subject: [PATCH 05/83] Code Cleanup (#869) - switch to golangci-lint for linting - switch to gofmpt for formatting - fix lint and fmt issues that came up from switch to new tools - upgrade go-sdk to 0.23.2 - support pagination for listing tracked times - remove `FixPullHeadSha` workaround (upstream fix has been merged for 5+ years at this point) - standardize on US spelling (previously a mix of US&UK spelling) - remove some unused code - reduce some duplication in parsing state and issue type - reduce some duplication in reading input for secrets and variables - reduce some duplication with PR Review code - report error for when yaml parsing fails - various other misc cleanup Reviewed-on: https://gitea.com/gitea/tea/pulls/869 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- .gitea/workflows/test-pr.yml | 1 - .golangci.yml | 45 +++++++++++++++++ Makefile | 32 +++++------- cmd/actions/secrets/create.go | 49 ++++-------------- cmd/actions/secrets/delete.go | 2 +- cmd/actions/variables/delete.go | 2 +- cmd/actions/variables/set.go | 49 +++++++----------- cmd/attachments/delete.go | 18 ------- cmd/branches/list.go | 2 - cmd/clone.go | 2 +- cmd/flags/generic.go | 31 ++++++++++++ cmd/flags/generic_test.go | 6 +-- cmd/issues/close.go | 3 +- cmd/issues/create.go | 2 +- cmd/issues/edit.go | 2 +- cmd/issues/list.go | 30 +++-------- cmd/issues/reopen.go | 2 +- cmd/issues_test.go | 14 ++--- cmd/labels/create.go | 57 +++++++++++---------- cmd/labels/create_test.go | 2 +- cmd/labels/update.go | 1 - cmd/milestones/create.go | 2 +- cmd/milestones/issues.go | 22 +++----- cmd/milestones/list.go | 16 +++--- cmd/milestones/reopen.go | 3 +- cmd/organizations/create.go | 2 +- cmd/organizations/delete.go | 2 +- cmd/pulls.go | 4 -- cmd/pulls/approve.go | 20 +------- cmd/pulls/checkout.go | 2 +- cmd/pulls/clean.go | 2 +- cmd/pulls/close.go | 2 +- cmd/pulls/create.go | 2 +- cmd/pulls/edit.go | 2 +- cmd/pulls/list.go | 12 ++--- cmd/pulls/reject.go | 24 ++------- cmd/pulls/reopen.go | 2 +- cmd/pulls/review_helpers.go | 40 +++++++++++++++ cmd/repos/list.go | 15 ++++-- cmd/repos/migrate.go | 1 - cmd/repos/search.go | 2 +- cmd/times/list.go | 16 ++++-- cmd/webhooks/delete.go | 2 +- cmd/webhooks/delete_test.go | 4 +- docs/CLI.md | 8 +++ modules/config/config.go | 13 ++--- modules/context/context.go | 6 +++ modules/git/branch.go | 2 +- modules/git/repo_test.go | 2 +- modules/interact/pull_merge.go | 7 ++- modules/print/notification.go | 2 +- modules/print/pull.go | 7 ++- modules/print/times.go | 2 +- modules/print/user.go | 2 +- modules/task/login_create.go | 2 +- modules/task/milestone_create.go | 1 - modules/task/pull_checkout.go | 4 -- modules/task/pull_clean.go | 20 +++----- modules/task/repo_clone.go | 2 +- modules/utils/input.go | 87 ++++++++++++++++++++++++++++++++ modules/utils/path.go | 8 +-- modules/workaround/pull.go | 29 ----------- 62 files changed, 399 insertions(+), 356 deletions(-) create mode 100644 .golangci.yml create mode 100644 cmd/pulls/review_helpers.go create mode 100644 modules/utils/input.go delete mode 100644 modules/workaround/pull.go diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 105de7e..968d8b6 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -30,7 +30,6 @@ jobs: make vet make lint make fmt-check - make misspell-check make docs-check make build - run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8502078 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,45 @@ +version: "2" + +formatters: + enable: + - gofumpt + +linters: + default: none + enable: + - govet + - revive + - misspell + - ineffassign + - unused + + settings: + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: if-return + - name: increment-decrement + - name: var-declaration + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + + misspell: + locale: US + ignore-words: + - unknwon + - destory + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Makefile b/Makefile index dbb48c1..735b329 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,10 @@ SHASUM ?= shasum -a 256 export PATH := $($(GO) env GOPATH)/bin:$(PATH) GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") -GOFMT ?= gofmt -s + +# Tool packages with pinned versions +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.7.0 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 ifneq ($(DRONE_TAG),) VERSION ?= $(subst v,,$(DRONE_TAG)) @@ -49,7 +52,7 @@ clean: .PHONY: fmt fmt: - $(GOFMT) -w $(GOFILES) + $(GO) run $(GOFUMPT_PACKAGE) -w $(GOFILES) .PHONY: vet vet: @@ -60,21 +63,17 @@ vet: $(GO) vet -vettool=$(VET_TOOL) $(PACKAGES) .PHONY: lint -lint: install-lint-tools - $(GO) run github.com/mgechev/revive@v1.3.2 -config .revive.toml ./... || exit 1 +lint: + $(GO) run $(GOLANGCI_LINT_PACKAGE) run -.PHONY: misspell-check -misspell-check: install-lint-tools - $(GO) run github.com/client9/misspell/cmd/misspell@latest -error -i unknwon,destory $(GOFILES) - -.PHONY: misspell -misspell: install-lint-tools - $(GO) run github.com/client9/misspell/cmd/misspell@latest -w -i unknwon $(GOFILES) +.PHONY: lint-fix +lint-fix: + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix .PHONY: fmt-check fmt-check: - # get all go files and run go fmt on them - @diff=$$($(GOFMT) -d $(GOFILES)); \ + # get all go files and run gofumpt on them + @diff=$$($(GO) run $(GOFUMPT_PACKAGE) -d $(GOFILES)); \ if [ -n "$$diff" ]; then \ echo "Please run 'make fmt' and commit the result:"; \ echo "$${diff}"; \ @@ -124,10 +123,3 @@ $(EXECUTABLE): $(SOURCES) build-image: docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) . -install-lint-tools: - @hash revive > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GO) install github.com/mgechev/revive@v1.3.2; \ - fi - @hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \ - $(GO) install github.com/client9/misspell/cmd/misspell@latest; \ - fi diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go index d573398..b9b13a6 100644 --- a/cmd/actions/secrets/create.go +++ b/cmd/actions/secrets/create.go @@ -6,17 +6,13 @@ package secrets import ( stdctx "context" "fmt" - "io" - "os" - "strings" - "syscall" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/utils" "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" - "golang.org/x/term" ) // CmdSecretsCreate represents a sub command to create action secrets @@ -48,42 +44,19 @@ func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error { client := c.Login.Client() secretName := cmd.Args().First() - var secretValue string - // Determine how to get the secret value - if cmd.String("file") != "" { - // Read from file - content, err := os.ReadFile(cmd.String("file")) - if err != nil { - return fmt.Errorf("failed to read file: %w", err) - } - secretValue = strings.TrimSpace(string(content)) - } else if cmd.Bool("stdin") { - // Read from stdin - content, err := io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("failed to read from stdin: %w", err) - } - secretValue = strings.TrimSpace(string(content)) - } else if cmd.Args().Len() >= 2 { - // Use provided argument - secretValue = cmd.Args().Get(1) - } else { - // Interactive prompt (hidden input) - fmt.Printf("Enter secret value for '%s': ", secretName) - byteValue, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read secret value: %w", err) - } - fmt.Println() // Add newline after hidden input - secretValue = string(byteValue) + // Read secret value using the utility + secretValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{ + ResourceName: "secret", + PromptMsg: fmt.Sprintf("Enter secret value for '%s'", secretName), + Hidden: true, + AllowEmpty: false, + }) + if err != nil { + return err } - if secretValue == "" { - return fmt.Errorf("secret value cannot be empty") - } - - _, err := client.CreateRepoActionSecret(c.Owner, c.Repo, gitea.CreateSecretOption{ + _, err = client.CreateRepoActionSecret(c.Owner, c.Repo, gitea.CreateSecretOption{ Name: secretName, Data: secretValue, }) diff --git a/cmd/actions/secrets/delete.go b/cmd/actions/secrets/delete.go index 255f24d..a031043 100644 --- a/cmd/actions/secrets/delete.go +++ b/cmd/actions/secrets/delete.go @@ -45,7 +45,7 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { var response string fmt.Scanln(&response) if response != "y" && response != "Y" && response != "yes" { - fmt.Println("Deletion cancelled.") + fmt.Println("Deletion canceled.") return nil } } diff --git a/cmd/actions/variables/delete.go b/cmd/actions/variables/delete.go index cf73fb1..b81ac64 100644 --- a/cmd/actions/variables/delete.go +++ b/cmd/actions/variables/delete.go @@ -45,7 +45,7 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { var response string fmt.Scanln(&response) if response != "y" && response != "Y" && response != "yes" { - fmt.Println("Deletion cancelled.") + fmt.Println("Deletion canceled.") return nil } } diff --git a/cmd/actions/variables/set.go b/cmd/actions/variables/set.go index 03a4cac..7a504c5 100644 --- a/cmd/actions/variables/set.go +++ b/cmd/actions/variables/set.go @@ -6,13 +6,11 @@ package variables import ( stdctx "context" "fmt" - "io" - "os" "regexp" - "strings" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/utils" "github.com/urfave/cli/v3" ) @@ -46,39 +44,26 @@ func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error { client := c.Login.Client() variableName := cmd.Args().First() - var variableValue string - - // Determine how to get the variable value - if cmd.String("file") != "" { - // Read from file - content, err := os.ReadFile(cmd.String("file")) - if err != nil { - return fmt.Errorf("failed to read file: %w", err) - } - variableValue = strings.TrimSpace(string(content)) - } else if cmd.Bool("stdin") { - // Read from stdin - content, err := io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("failed to read from stdin: %w", err) - } - variableValue = strings.TrimSpace(string(content)) - } else if cmd.Args().Len() >= 2 { - // Use provided argument - variableValue = cmd.Args().Get(1) - } else { - // Interactive prompt - fmt.Printf("Enter variable value for '%s': ", variableName) - var input string - fmt.Scanln(&input) - variableValue = input + if err := validateVariableName(variableName); err != nil { + return err } - if variableValue == "" { - return fmt.Errorf("variable value cannot be empty") + // Read variable value using the utility + variableValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{ + ResourceName: "variable", + PromptMsg: fmt.Sprintf("Enter variable value for '%s'", variableName), + Hidden: false, + AllowEmpty: false, + }) + if err != nil { + return err } - _, err := client.CreateRepoActionVariable(c.Owner, c.Repo, variableName, variableValue) + if err := validateVariableValue(variableValue); err != nil { + return err + } + + _, err = client.CreateRepoActionVariable(c.Owner, c.Repo, variableName, variableValue) if err != nil { return err } diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go index 9abad42..238d92b 100644 --- a/cmd/attachments/delete.go +++ b/cmd/attachments/delete.go @@ -81,21 +81,3 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { return nil } - -func getReleaseAttachmentByName(owner, repo string, release int64, name string, client *gitea.Client) (*gitea.Attachment, error) { - al, _, err := client.ListReleaseAttachments(owner, repo, release, gitea.ListReleaseAttachmentsOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return nil, err - } - if len(al) == 0 { - return nil, fmt.Errorf("Release does not have any attachments") - } - for _, a := range al { - if a.Name == name { - return a, nil - } - } - return nil, fmt.Errorf("Attachment does not exist") -} diff --git a/cmd/branches/list.go b/cmd/branches/list.go index ce47330..098b5f8 100644 --- a/cmd/branches/list.go +++ b/cmd/branches/list.go @@ -52,7 +52,6 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{ ListOptions: flags.GetListOptions(), }) - if err != nil { return err } @@ -60,7 +59,6 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{ ListOptions: flags.GetListOptions(), }) - if err != nil { return err } diff --git a/cmd/clone.go b/cmd/clone.go index 7acb771..2bf24d4 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -58,7 +58,7 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { var ( login *config.Login = teaCmd.Login - owner string = teaCmd.Login.User + owner string repo string ) diff --git a/cmd/flags/generic.go b/cmd/flags/generic.go index f942a70..af2692a 100644 --- a/cmd/flags/generic.go +++ b/cmd/flags/generic.go @@ -141,3 +141,34 @@ var NotificationStateFlag = NewCsvFlag( func FieldsFlag(availableFields, defaultFields []string) *CsvFlag { return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields) } + +// ParseState parses a state string and returns the corresponding gitea.StateType +func ParseState(stateStr string) (gitea.StateType, error) { + switch stateStr { + case "all": + return gitea.StateAll, nil + case "", "open": + return gitea.StateOpen, nil + case "closed": + return gitea.StateClosed, nil + default: + return "", errors.New("unknown state '" + stateStr + "'") + } +} + +// ParseIssueKind parses a kind string and returns the corresponding gitea.IssueType. +// If kindStr is empty, returns the provided defaultKind. +func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueType, error) { + switch kindStr { + case "": + return defaultKind, nil + case "all": + return gitea.IssueTypeAll, nil + case "issue", "issues": + return gitea.IssueTypeIssue, nil + case "pull", "pulls", "pr": + return gitea.IssueTypePull, nil + default: + return "", errors.New("unknown kind '" + kindStr + "'") + } +} diff --git a/cmd/flags/generic_test.go b/cmd/flags/generic_test.go index 9f290b5..184afbf 100644 --- a/cmd/flags/generic_test.go +++ b/cmd/flags/generic_test.go @@ -55,7 +55,7 @@ func TestPaginationFlags(t *testing.T) { expectedPage: 2, expectedLimit: 20, }, - { //TODO: Should no paging be applied as -1 or a separate flag? It's not obvious that page=-1 turns off paging and limit is ignored + { // TODO: Should no paging be applied as -1 or a separate flag? It's not obvious that page=-1 turns off paging and limit is ignored name: "no paging", args: []string{"test", "--limit", "20", "--page", "-1"}, expectedPage: -1, @@ -78,8 +78,8 @@ func TestPaginationFlags(t *testing.T) { require.NoError(t, err) }) } - } + func TestPaginationFailures(t *testing.T) { cases := []struct { name string @@ -102,7 +102,7 @@ func TestPaginationFailures(t *testing.T) { expectedError: ErrPage, }, { - //urfave does not validate all flags in one pass + // urfave does not validate all flags in one pass name: "negative paging and paging", args: []string{"test", "--page", "-2", "--limit", "-10"}, expectedError: ErrPage, diff --git a/cmd/issues/close.go b/cmd/issues/close.go index 42caaf6..d70c25a 100644 --- a/cmd/issues/close.go +++ b/cmd/issues/close.go @@ -5,7 +5,6 @@ package issues import ( stdctx "context" - "errors" "fmt" "code.gitea.io/tea/cmd/flags" @@ -35,7 +34,7 @@ func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOpti ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if ctx.Args().Len() == 0 { - return errors.New(ctx.Command.ArgsUsage) + return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } indices, err := utils.ArgsToIndices(ctx.Args().Slice()) diff --git a/cmd/issues/create.go b/cmd/issues/create.go index 84593d3..2eb2699 100644 --- a/cmd/issues/create.go +++ b/cmd/issues/create.go @@ -29,7 +29,7 @@ func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - if ctx.NumFlags() == 0 { + if ctx.IsInteractiveMode() { err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo) if err != nil && !interact.IsQuitting(err) { return err diff --git a/cmd/issues/edit.go b/cmd/issues/edit.go index 2e49437..a673e35 100644 --- a/cmd/issues/edit.go +++ b/cmd/issues/edit.go @@ -49,7 +49,7 @@ func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() for _, opts.Index = range indices { - if ctx.NumFlags() == 0 { + if ctx.IsInteractiveMode() { var err error opts, err = interact.EditIssue(*ctx, opts.Index) if err != nil { diff --git a/cmd/issues/list.go b/cmd/issues/list.go index 3bca2b4..dd1a376 100644 --- a/cmd/issues/list.go +++ b/cmd/issues/list.go @@ -5,7 +5,6 @@ package issues import ( stdctx "context" - "fmt" "time" "code.gitea.io/tea/cmd/flags" @@ -36,31 +35,16 @@ var CmdIssuesList = cli.Command{ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) - state := gitea.StateOpen - switch ctx.String("state") { - case "all": - state = gitea.StateAll - case "", "open": - state = gitea.StateOpen - case "closed": - state = gitea.StateClosed - default: - return fmt.Errorf("unknown state '%s'", ctx.String("state")) + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err } - kind := gitea.IssueTypeIssue - switch ctx.String("kind") { - case "", "issues", "issue": - kind = gitea.IssueTypeIssue - case "pulls", "pull", "pr": - kind = gitea.IssueTypePull - case "all": - kind = gitea.IssueTypeAll - default: - return fmt.Errorf("unknown kind '%s'", ctx.String("kind")) + kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeIssue) + if err != nil { + return err } - var err error var from, until time.Time if ctx.IsSet("from") { from, err = dateparse.ParseLocal(ctx.String("from")) @@ -97,7 +81,6 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { Since: from, Before: until, }) - if err != nil { return err } @@ -116,7 +99,6 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { Before: until, Owner: owner, }) - if err != nil { return err } diff --git a/cmd/issues/reopen.go b/cmd/issues/reopen.go index df2fd6a..05e9c61 100644 --- a/cmd/issues/reopen.go +++ b/cmd/issues/reopen.go @@ -20,7 +20,7 @@ var CmdIssuesReopen = cli.Command{ Description: `Change state of one or more issues to 'open'`, ArgsUsage: " [...]", Action: func(ctx context.Context, cmd *cli.Command) error { - var s = gitea.StateOpen + s := gitea.StateOpen return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s}) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/issues_test.go b/cmd/issues_test.go index 9aa162b..2785624 100644 --- a/cmd/issues_test.go +++ b/cmd/issues_test.go @@ -26,7 +26,7 @@ const ( ) func createTestIssue(comments int, isClosed bool) gitea.Issue { - var issue = gitea.Issue{ + issue := gitea.Issue{ ID: 42, Index: 1, Title: "Test issue", @@ -55,11 +55,11 @@ func createTestIssue(comments int, isClosed bool) gitea.Issue { {UserName: "testUser3"}, }, HTMLURL: "", - Closed: nil, //2025-11-10T21:20:19Z + Closed: nil, // 2025-11-10T21:20:19Z } if isClosed { - var closed = time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC) + closed := time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC) issue.Closed = &closed } @@ -70,7 +70,6 @@ func createTestIssue(comments int, isClosed bool) gitea.Issue { } return issue - } func createTestIssueComments(comments int) []gitea.Comment { @@ -90,7 +89,6 @@ func createTestIssueComments(comments int) []gitea.Comment { } return result - } func TestRunIssueDetailAsJSON(t *testing.T) { @@ -99,9 +97,6 @@ func TestRunIssueDetailAsJSON(t *testing.T) { issue gitea.Issue comments []gitea.Comment flagComments bool - flagOutput string - flagOut string - closed bool } cmd := cli.Command{ @@ -205,7 +200,7 @@ func TestRunIssueDetailAsJSON(t *testing.T) { require.NotEmpty(t, out, "Unexpected empty output from runIssueDetailAsJSON") - //setting expectations + // setting expectations var expectedLabels []labelData expectedLabels = []labelData{} @@ -266,5 +261,4 @@ func TestRunIssueDetailAsJSON(t *testing.T) { assert.Equal(t, expected, actual, "Expected structs differ from expected one") }) } - } diff --git a/cmd/labels/create.go b/cmd/labels/create.go index ec72b32..38e08d7 100644 --- a/cmd/labels/create.go +++ b/cmd/labels/create.go @@ -50,40 +50,43 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error { ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) labelFile := ctx.String("file") - var err error if len(labelFile) == 0 { - _, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ + _, _, err := ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ Name: ctx.String("name"), Color: ctx.String("color"), Description: ctx.String("description"), }) - } else { - f, err := os.Open(labelFile) - if err != nil { - return err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - var i = 1 - for scanner.Scan() { - line := scanner.Text() - color, name, description := splitLabelLine(line) - if color == "" || name == "" { - log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line) - } else { - _, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ - Name: name, - Color: color, - Description: description, - }) - } - - i++ - } + return err } - return err + f, err := os.Open(labelFile) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + i := 1 + for scanner.Scan() { + line := scanner.Text() + color, name, description := splitLabelLine(line) + if color == "" || name == "" { + log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line) + } else { + _, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ + Name: name, + Color: color, + Description: description, + }) + if err != nil { + return err + } + } + + i++ + } + + return nil } func splitLabelLine(line string) (string, string, string) { diff --git a/cmd/labels/create_test.go b/cmd/labels/create_test.go index eacdb2f..f192cea 100644 --- a/cmd/labels/create_test.go +++ b/cmd/labels/create_test.go @@ -20,7 +20,7 @@ func TestParseLabelLine(t *testing.T) { ` scanner := bufio.NewScanner(strings.NewReader(labels)) - var i = 1 + i := 1 for scanner.Scan() { line := scanner.Text() color, name, description := splitLabelLine(line) diff --git a/cmd/labels/update.go b/cmd/labels/update.go index 1b55a90..e1d69db 100644 --- a/cmd/labels/update.go +++ b/cmd/labels/update.go @@ -67,7 +67,6 @@ func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error { Color: pColor, Description: pDescription, }) - if err != nil { return err } diff --git a/cmd/milestones/create.go b/cmd/milestones/create.go index e6d1865..86180b5 100644 --- a/cmd/milestones/create.go +++ b/cmd/milestones/create.go @@ -67,7 +67,7 @@ func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error { state = gitea.StateClosed } - if ctx.NumFlags() == 0 { + if ctx.IsInteractiveMode() { if err := interact.CreateMilestone(ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) { return err } diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go index 7d056ba..1f0a3c5 100644 --- a/cmd/milestones/issues.go +++ b/cmd/milestones/issues.go @@ -75,29 +75,23 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) client := ctx.Login.Client() - state := gitea.StateOpen - switch ctx.String("state") { - case "all": - state = gitea.StateAll - case "closed": - state = gitea.StateClosed + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err } - kind := gitea.IssueTypeAll - switch ctx.String("kind") { - case "issue": - kind = gitea.IssueTypeIssue - case "pull": - kind = gitea.IssueTypePull + kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll) + if err != nil { + return err } if ctx.Args().Len() != 1 { - return fmt.Errorf("Must specify milestone name") + return fmt.Errorf("milestone name is required") } milestone := ctx.Args().First() // make sure milestone exist - _, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone) + _, _, err = client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone) if err != nil { return err } diff --git a/cmd/milestones/list.go b/cmd/milestones/list.go index e229c56..f09d587 100644 --- a/cmd/milestones/list.go +++ b/cmd/milestones/list.go @@ -48,15 +48,12 @@ func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { return err } - state := gitea.StateOpen - switch ctx.String("state") { - case "all": - state = gitea.StateAll - if !cmd.IsSet("fields") { // add to default fields - fields = append(fields, "state") - } - case "closed": - state = gitea.StateClosed + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + if state == gitea.StateAll && !cmd.IsSet("fields") { + fields = append(fields, "state") } client := ctx.Login.Client() @@ -64,7 +61,6 @@ func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { ListOptions: flags.GetListOptions(), State: state, }) - if err != nil { return err } diff --git a/cmd/milestones/reopen.go b/cmd/milestones/reopen.go index 077ff64..c530600 100644 --- a/cmd/milestones/reopen.go +++ b/cmd/milestones/reopen.go @@ -5,7 +5,6 @@ package milestones import ( stdctx "context" - "errors" "fmt" "code.gitea.io/tea/cmd/flags" @@ -33,7 +32,7 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if ctx.Args().Len() == 0 { - return errors.New(ctx.Command.ArgsUsage) + return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } state := gitea.StateOpen diff --git a/cmd/organizations/create.go b/cmd/organizations/create.go index 523d03c..da96a22 100644 --- a/cmd/organizations/create.go +++ b/cmd/organizations/create.go @@ -56,7 +56,7 @@ func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) if ctx.Args().Len() < 1 { - return fmt.Errorf("You have to specify the organization name you want to create") + return fmt.Errorf("organization name is required") } var visibility gitea.VisibleType diff --git a/cmd/organizations/delete.go b/cmd/organizations/delete.go index b5d8f32..2cdbd2d 100644 --- a/cmd/organizations/delete.go +++ b/cmd/organizations/delete.go @@ -33,7 +33,7 @@ func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() if ctx.Args().Len() < 1 { - return fmt.Errorf("You have to specify the organization name you want to delete") + return fmt.Errorf("organization name is required") } response, err := client.DeleteOrg(ctx.Args().First()) diff --git a/cmd/pulls.go b/cmd/pulls.go index e4defa9..e051928 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -12,7 +12,6 @@ import ( "code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/utils" - "code.gitea.io/tea/modules/workaround" "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" @@ -67,9 +66,6 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { if err != nil { return err } - if err := workaround.FixPullHeadSha(client, pr); err != nil { - return err - } reviews, _, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ ListOptions: gitea.ListOptions{Page: -1}, diff --git a/cmd/pulls/approve.go b/cmd/pulls/approve.go index e691c4b..2f5529d 100644 --- a/cmd/pulls/approve.go +++ b/cmd/pulls/approve.go @@ -4,16 +4,11 @@ package pulls import ( - "fmt" - "strings" - stdctx "context" "code.gitea.io/sdk/gitea" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" - "code.gitea.io/tea/modules/task" - "code.gitea.io/tea/modules/utils" "github.com/urfave/cli/v3" ) @@ -26,20 +21,7 @@ var CmdPullsApprove = cli.Command{ ArgsUsage: " []", Action: func(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - - if ctx.Args().Len() == 0 { - return fmt.Errorf("Must specify a PR index") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - comment := strings.Join(ctx.Args().Tail(), " ") - - return task.CreatePullReview(ctx, idx, gitea.ReviewStateApproved, comment, nil) + return runPullReview(ctx, gitea.ReviewStateApproved, false) }, Flags: flags.AllDefaultFlags, } diff --git a/cmd/pulls/checkout.go b/cmd/pulls/checkout.go index 31e1867..d6b11eb 100644 --- a/cmd/pulls/checkout.go +++ b/cmd/pulls/checkout.go @@ -40,7 +40,7 @@ func runPullsCheckout(_ stdctx.Context, cmd *cli.Command) error { RemoteRepo: true, }) if ctx.Args().Len() != 1 { - return fmt.Errorf("Must specify a PR index") + return fmt.Errorf("pull request index is required") } idx, err := utils.ArgToIndex(ctx.Args().First()) if err != nil { diff --git a/cmd/pulls/clean.go b/cmd/pulls/clean.go index 3aaec2a..76194ea 100644 --- a/cmd/pulls/clean.go +++ b/cmd/pulls/clean.go @@ -35,7 +35,7 @@ func runPullsClean(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{LocalRepo: true}) if ctx.Args().Len() != 1 { - return fmt.Errorf("Must specify a PR index") + return fmt.Errorf("pull request index is required") } idx, err := utils.ArgToIndex(ctx.Args().First()) diff --git a/cmd/pulls/close.go b/cmd/pulls/close.go index 20ca9f0..74f7a58 100644 --- a/cmd/pulls/close.go +++ b/cmd/pulls/close.go @@ -19,7 +19,7 @@ var CmdPullsClose = cli.Command{ Description: `Change state of one or more pull requests to 'closed'`, ArgsUsage: " [...]", Action: func(ctx context.Context, cmd *cli.Command) error { - var s = gitea.StateClosed + s := gitea.StateClosed return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s}) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index 6bd4a38..b1dd6a3 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -48,7 +48,7 @@ func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error { }) // no args -> interactive mode - if ctx.NumFlags() == 0 { + if ctx.IsInteractiveMode() { if err := interact.CreatePull(ctx); err != nil && !interact.IsQuitting(err) { return err } diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go index 615dbef..2635344 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -20,7 +20,7 @@ func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullReques ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if ctx.Args().Len() == 0 { - return fmt.Errorf("Please provide a Pull Request index") + return fmt.Errorf("pull request index is required") } indices, err := utils.ArgsToIndices(ctx.Args().Slice()) diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go index f63c4fc..a13d7e4 100644 --- a/cmd/pulls/list.go +++ b/cmd/pulls/list.go @@ -33,21 +33,15 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - state := gitea.StateOpen - switch ctx.String("state") { - case "all": - state = gitea.StateAll - case "open": - state = gitea.StateOpen - case "closed": - state = gitea.StateClosed + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err } prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ ListOptions: flags.GetListOptions(), State: state, }) - if err != nil { return err } diff --git a/cmd/pulls/reject.go b/cmd/pulls/reject.go index 277fcd3..b5a75de 100644 --- a/cmd/pulls/reject.go +++ b/cmd/pulls/reject.go @@ -5,15 +5,10 @@ package pulls import ( stdctx "context" - "fmt" - "strings" - - "code.gitea.io/tea/cmd/flags" - "code.gitea.io/tea/modules/context" - "code.gitea.io/tea/modules/task" - "code.gitea.io/tea/modules/utils" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" "github.com/urfave/cli/v3" ) @@ -25,20 +20,7 @@ var CmdPullsReject = cli.Command{ ArgsUsage: " ", Action: func(_ stdctx.Context, cmd *cli.Command) error { ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - - if ctx.Args().Len() < 2 { - return fmt.Errorf("Must specify a PR index and comment") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - comment := strings.Join(ctx.Args().Tail(), " ") - - return task.CreatePullReview(ctx, idx, gitea.ReviewStateRequestChanges, comment, nil) + return runPullReview(ctx, gitea.ReviewStateRequestChanges, true) }, Flags: flags.AllDefaultFlags, } diff --git a/cmd/pulls/reopen.go b/cmd/pulls/reopen.go index 682d0f8..f05ea08 100644 --- a/cmd/pulls/reopen.go +++ b/cmd/pulls/reopen.go @@ -20,7 +20,7 @@ var CmdPullsReopen = cli.Command{ Description: `Change state of one or more pull requests to 'open'`, ArgsUsage: " [...]", Action: func(ctx context.Context, cmd *cli.Command) error { - var s = gitea.StateOpen + s := gitea.StateOpen return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s}) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go new file mode 100644 index 0000000..cd539f4 --- /dev/null +++ b/cmd/pulls/review_helpers.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/task" + "code.gitea.io/tea/modules/utils" +) + +// runPullReview handles the common logic for approving/rejecting pull requests +func runPullReview(ctx *context.TeaContext, state gitea.ReviewStateType, requireComment bool) error { + ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + + minArgs := 1 + if requireComment { + minArgs = 2 + } + + if ctx.Args().Len() < minArgs { + if requireComment { + return fmt.Errorf("pull request index and comment are required") + } + return fmt.Errorf("pull request index is required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + comment := strings.Join(ctx.Args().Tail(), " ") + + return task.CreatePullReview(ctx, idx, state, comment, nil) +} diff --git a/cmd/repos/list.go b/cmd/repos/list.go index bea0243..e09d4e5 100644 --- a/cmd/repos/list.go +++ b/cmd/repos/list.go @@ -68,16 +68,23 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { ListOptions: flags.GetListOptions(), StarredByUserID: user.ID, }) + if err != nil { + return err + } } else if teaCmd.Bool("watched") { + var err error rps, _, err = client.GetMyWatchedRepos() // TODO: this does not expose pagination.. + if err != nil { + return err + } } else { + var err error rps, _, err = client.ListMyRepos(gitea.ListReposOptions{ ListOptions: flags.GetListOptions(), }) - } - - if err != nil { - return err + if err != nil { + return err + } } reposFiltered := rps diff --git a/cmd/repos/migrate.go b/cmd/repos/migrate.go index af73ecb..6f13fec 100644 --- a/cmd/repos/migrate.go +++ b/cmd/repos/migrate.go @@ -157,7 +157,6 @@ func runRepoMigrate(_ stdctx.Context, cmd *cli.Command) error { } repo, _, err = client.MigrateRepo(opts) - if err != nil { return err } diff --git a/cmd/repos/search.go b/cmd/repos/search.go index 9b8ad3b..d93a592 100644 --- a/cmd/repos/search.go +++ b/cmd/repos/search.go @@ -62,7 +62,7 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { var ownerID int64 if teaCmd.IsSet("owner") { - // test if owner is a organisation + // test if owner is an organization org, _, err := client.GetOrg(teaCmd.String("owner")) if err != nil { // HACK: the client does not return a response on 404, so we can't check res.StatusCode diff --git a/cmd/times/list.go b/cmd/times/list.go index 0e8d0e9..9a21c71 100644 --- a/cmd/times/list.go +++ b/cmd/times/list.go @@ -65,6 +65,8 @@ Depending on your permissions on the repository, only your own tracked times mig Usage: "Show all times tracked by you across all repositories (overrides command arguments)", }, timeFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, }, flags.AllDefaultFlags...), } @@ -92,11 +94,15 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { } } - opts := gitea.ListTrackedTimesOptions{Since: from, Before: until} + opts := gitea.ListTrackedTimesOptions{ + ListOptions: flags.GetListOptions(), + Since: from, + Before: until, + } user := ctx.Args().First() if ctx.Bool("mine") { - times, _, err = client.GetMyTrackedTimes() + times, _, err = client.ListMyTrackedTimes(opts) fields = []string{"created", "repo", "issue", "duration"} } else if user == "" { // get all tracked times on the repo @@ -104,9 +110,9 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { fields = []string{"created", "issue", "user", "duration"} } else if strings.HasPrefix(user, "#") { // get all tracked times on the specified issue - issue, err := utils.ArgToIndex(user) - if err != nil { - return err + issue, parseErr := utils.ArgToIndex(user) + if parseErr != nil { + return parseErr } times, _, err = client.ListIssueTrackedTimes(ctx.Owner, ctx.Repo, issue, opts) fields = []string{"created", "user", "duration"} diff --git a/cmd/webhooks/delete.go b/cmd/webhooks/delete.go index 18226d7..7f7348d 100644 --- a/cmd/webhooks/delete.go +++ b/cmd/webhooks/delete.go @@ -63,7 +63,7 @@ func runWebhooksDelete(ctx stdctx.Context, cmd *cli.Command) error { var response string fmt.Scanln(&response) if response != "y" && response != "Y" && response != "yes" { - fmt.Println("Deletion cancelled.") + fmt.Println("Deletion canceled.") return nil } } diff --git a/cmd/webhooks/delete_test.go b/cmd/webhooks/delete_test.go index a74dcd8..6d1a1a8 100644 --- a/cmd/webhooks/delete_test.go +++ b/cmd/webhooks/delete_test.go @@ -435,9 +435,9 @@ func TestDeleteSuccessMessage(t *testing.T) { } func TestDeleteCancellationMessage(t *testing.T) { - expectedMessage := "Deletion cancelled." + expectedMessage := "Deletion canceled." assert.NotEmpty(t, expectedMessage) - assert.Contains(t, expectedMessage, "cancelled") + assert.Contains(t, expectedMessage, "canceled") assert.NotContains(t, expectedMessage, "\n", "Cancellation message should not end with newline") } diff --git a/docs/CLI.md b/docs/CLI.md index fae47c6..44bf1d6 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -849,12 +849,16 @@ Operate on tracked times of a repository's issues & pulls **--from, -f**="": Show only times tracked after this date +**--limit, --lm**="": specify limit of items per page (default: 30) + **--login, -l**="": Use a different Gitea Login. Optional **--mine, -m**: Show all times tracked by you across all repositories (overrides command arguments) **--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) +**--page, -p**="": specify page (default: 1) + **--remote, -R**="": Discover Gitea login from remote. Optional **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional @@ -909,12 +913,16 @@ List tracked times on issues & pulls **--from, -f**="": Show only times tracked after this date +**--limit, --lm**="": specify limit of items per page (default: 30) + **--login, -l**="": Use a different Gitea Login. Optional **--mine, -m**: Show all times tracked by you across all repositories (overrides command arguments) **--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) +**--page, -p**="": specify page (default: 1) + **--remote, -R**="": Discover Gitea login from remote. Optional **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional diff --git a/modules/config/config.go b/modules/config/config.go index 30b59f3..6402697 100644 --- a/modules/config/config.go +++ b/modules/config/config.go @@ -83,14 +83,15 @@ func loadConfig() (err error) { ymlPath := GetConfigPath() exist, _ := utils.FileExist(ymlPath) if exist { - bs, err := os.ReadFile(ymlPath) - if err != nil { - err = fmt.Errorf("Failed to read config file: %s", ymlPath) + bs, readErr := os.ReadFile(ymlPath) + if readErr != nil { + err = fmt.Errorf("failed to read config file %s: %w", ymlPath, readErr) + return } - err = yaml.Unmarshal(bs, &config) - if err != nil { - err = fmt.Errorf("Failed to parse contents of config file: %s", ymlPath) + if unmarshalErr := yaml.Unmarshal(bs, &config); unmarshalErr != nil { + err = fmt.Errorf("failed to parse config file %s: %w", ymlPath, unmarshalErr) + return } } }) diff --git a/modules/context/context.go b/modules/context/context.go index 1a2a61d..8570041 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -46,6 +46,12 @@ func (ctx *TeaContext) GetRemoteRepoHTMLURL() string { return path.Join(ctx.Login.URL, ctx.Owner, ctx.Repo) } +// IsInteractiveMode returns true if the command is running in interactive mode +// (no flags provided and stdout is a terminal) +func (ctx *TeaContext) IsInteractiveMode() bool { + return ctx.Command.NumFlags() == 0 +} + // Ensure checks if requirements on the context are set, and terminates otherwise. func (ctx *TeaContext) Ensure(req CtxRequirement) { if req.LocalRepo && ctx.LocalRepo == nil { diff --git a/modules/git/branch.go b/modules/git/branch.go index d112d29..2f2bc79 100644 --- a/modules/git/branch.go +++ b/modules/git/branch.go @@ -143,7 +143,7 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config. defer iter.Close() var remoteRefName git_plumbing.ReferenceName var localRefName git_plumbing.ReferenceName - var remoteSearchingName = fmt.Sprintf("%s/%s", remoteName, branchName) + remoteSearchingName := fmt.Sprintf("%s/%s", remoteName, branchName) err = iter.ForEach(func(ref *git_plumbing.Reference) error { if ref.Name().IsRemote() && ref.Name().Short() == remoteSearchingName { remoteRefName = ref.Name() diff --git a/modules/git/repo_test.go b/modules/git/repo_test.go index 586caaa..b9b339a 100644 --- a/modules/git/repo_test.go +++ b/modules/git/repo_test.go @@ -37,7 +37,7 @@ func TestRepoFromPath_Worktree(t *testing.T) { // Create an initial commit (required for worktree) readmePath := filepath.Join(mainRepoPath, "README.md") - err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644) + err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644) assert.NoError(t, err) cmd = exec.Command("git", "-C", mainRepoPath, "add", "README.md") assert.NoError(t, cmd.Run()) diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index a47264e..583438e 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -19,7 +19,7 @@ import ( // MergePull interactively creates a PR func MergePull(ctx *context.TeaContext) error { if ctx.LocalRepo == nil { - return fmt.Errorf("Must specify a PR index") + return fmt.Errorf("pull request index is required") } branch, _, err := ctx.LocalRepo.TeaGetCurrentBranchNameAndSHA() @@ -51,9 +51,12 @@ func getPullIndex(ctx *context.TeaContext, branch string) (int64, error) { // paginated fetch var prs []*gitea.PullRequest - var err error for { + var err error prs, _, err = c.ListRepoPullRequests(ctx.Owner, ctx.Repo, opts) + if err != nil { + return 0, err + } if len(prs) == 0 { return 0, fmt.Errorf("No open PRs found") } diff --git a/modules/print/notification.go b/modules/print/notification.go index 487a782..4fd457c 100644 --- a/modules/print/notification.go +++ b/modules/print/notification.go @@ -12,7 +12,7 @@ import ( // NotificationsList prints a listing of notification threads func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) { - var printables = make([]printable, len(news)) + printables := make([]printable, len(news)) for i, x := range news { printables[i] = &printableNotification{x} } diff --git a/modules/print/pull.go b/modules/print/pull.go index 1c05b97..179dc25 100644 --- a/modules/print/pull.go +++ b/modules/print/pull.go @@ -111,7 +111,6 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)] = review } } - } } @@ -173,7 +172,7 @@ var PullFields = []string{ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) { labelMap := map[int64]string{} - var printables = make([]printable, len(pulls)) + printables := make([]printable, len(pulls)) machineReadable := isMachineReadable(output) for i, x := range pulls { @@ -227,13 +226,13 @@ func (x printablePull) FormatField(field string, machineReadable bool) string { } return "" case "labels": - var labels = make([]string, len(x.Labels)) + labels := make([]string, len(x.Labels)) for i, l := range x.Labels { labels[i] = (*x.formattedLabels)[l.ID] } return strings.Join(labels, " ") case "assignees": - var assignees = make([]string, len(x.Assignees)) + assignees := make([]string, len(x.Assignees)) for i, a := range x.Assignees { assignees[i] = formatUserName(a) } diff --git a/modules/print/times.go b/modules/print/times.go index d5022cc..a9d8932 100644 --- a/modules/print/times.go +++ b/modules/print/times.go @@ -11,7 +11,7 @@ import ( // TrackedTimesList print list of tracked times to stdout func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) { - var printables = make([]printable, len(times)) + printables := make([]printable, len(times)) var totalDuration int64 for i, t := range times { totalDuration += t.Time diff --git a/modules/print/user.go b/modules/print/user.go index 924428b..437538d 100644 --- a/modules/print/user.go +++ b/modules/print/user.go @@ -53,7 +53,7 @@ func UserDetails(user *gitea.User) { // UserList prints a listing of the users func UserList(user []*gitea.User, output string, fields []string) { - var printables = make([]printable, len(user)) + printables := make([]printable, len(user)) for i, u := range user { printables[i] = &printableUser{u} } diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 44f3956..523caa1 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -51,7 +51,7 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe // checks ... // ... if we have a url if len(giteaURL) == 0 { - return fmt.Errorf("You have to input Gitea server URL") + return fmt.Errorf("Gitea server URL is required") } // ... if there already exist a login with same name diff --git a/modules/task/milestone_create.go b/modules/task/milestone_create.go index d08e778..bbde1df 100644 --- a/modules/task/milestone_create.go +++ b/modules/task/milestone_create.go @@ -15,7 +15,6 @@ import ( // CreateMilestone creates a milestone in the given repo and prints the result func CreateMilestone(login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error { - // title is required if len(title) == 0 { return fmt.Errorf("Title is required") diff --git a/modules/task/pull_checkout.go b/modules/task/pull_checkout.go index aadbafe..2e6e8b1 100644 --- a/modules/task/pull_checkout.go +++ b/modules/task/pull_checkout.go @@ -9,7 +9,6 @@ import ( "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/config" local_git "code.gitea.io/tea/modules/git" - "code.gitea.io/tea/modules/workaround" "github.com/go-git/go-git/v5" git_config "github.com/go-git/go-git/v5/config" @@ -29,9 +28,6 @@ func PullCheckout( if err != nil { return fmt.Errorf("couldn't fetch PR: %s", err) } - if err := workaround.FixPullHeadSha(client, pr); err != nil { - return err - } // FIXME: should use ctx.LocalRepo..? localRepo, err := local_git.RepoForWorkdir() diff --git a/modules/task/pull_clean.go b/modules/task/pull_clean.go index 9c5f78f..c09647c 100644 --- a/modules/task/pull_clean.go +++ b/modules/task/pull_clean.go @@ -8,7 +8,6 @@ import ( "code.gitea.io/tea/modules/config" local_git "code.gitea.io/tea/modules/git" - "code.gitea.io/tea/modules/workaround" "code.gitea.io/sdk/gitea" git_config "github.com/go-git/go-git/v5/config" @@ -33,9 +32,6 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign if err != nil { return err } - if err := workaround.FixPullHeadSha(client, pr); err != nil { - return err - } if pr.State == gitea.StateOpen { return fmt.Errorf("PR is still open, won't delete branches") @@ -96,15 +92,15 @@ call me again with the --ignore-sha flag`, remoteBranch) if !remoteDeleted && pr.Head.Repository.Permissions.Push { fmt.Printf("Deleting remote branch %s\n", remoteBranch) - url, err := r.TeaRemoteURL(branch.Remote) - if err != nil { - return err + url, urlErr := r.TeaRemoteURL(branch.Remote) + if urlErr != nil { + return urlErr } - auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback) - if err != nil { - return err + auth, authErr := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback) + if authErr != nil { + return authErr } - err = r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth) + return r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth) } - return err + return nil } diff --git a/modules/task/repo_clone.go b/modules/task/repo_clone.go index 8461e81..1a56147 100644 --- a/modules/task/repo_clone.go +++ b/modules/task/repo_clone.go @@ -40,7 +40,7 @@ func RepoClone( return nil, err } - // default path behaviour as native git + // default path behavior as native git if path == "" { path = repoName } diff --git a/modules/utils/input.go b/modules/utils/input.go new file mode 100644 index 0000000..cd51094 --- /dev/null +++ b/modules/utils/input.go @@ -0,0 +1,87 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + "syscall" + + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// ReadValueOptions contains options for reading a value from various sources +type ReadValueOptions struct { + // ResourceName is the name of the resource (e.g., "secret", "variable") + ResourceName string + // PromptMsg is the message to display when prompting interactively + PromptMsg string + // Hidden determines if the input should be hidden (for secrets/passwords) + Hidden bool + // AllowEmpty determines if empty values are allowed + AllowEmpty bool +} + +// ReadValue reads a value from various sources in the following priority order: +// 1. From a file specified by --file flag +// 2. From stdin if --stdin flag is set +// 3. From command arguments (second argument) +// 4. Interactive prompt +func ReadValue(cmd *cli.Command, opts ReadValueOptions) (string, error) { + var value string + + // 1. Read from file + if filePath := cmd.String("file"); filePath != "" { + content, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to read file: %w", err) + } + value = strings.TrimSpace(string(content)) + } else if cmd.Bool("stdin") { + // 2. Read from stdin + content, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("failed to read from stdin: %w", err) + } + value = strings.TrimSpace(string(content)) + } else if cmd.Args().Len() >= 2 { + // 3. Use provided argument + value = cmd.Args().Get(1) + } else { + // 4. Interactive prompt + if opts.PromptMsg == "" { + opts.PromptMsg = fmt.Sprintf("Enter %s value", opts.ResourceName) + } + fmt.Printf("%s: ", opts.PromptMsg) + + if opts.Hidden { + // Hidden input for secrets/passwords + byteValue, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("failed to read %s value: %w", opts.ResourceName, err) + } + fmt.Println() // Add newline after hidden input + value = string(byteValue) + } else { + // Regular visible input - read entire line including spaces + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read %s value: %w", opts.ResourceName, err) + } + value = strings.TrimSpace(input) + } + } + + // Validate non-empty if required + if !opts.AllowEmpty && value == "" { + return "", fmt.Errorf("%s value cannot be empty", opts.ResourceName) + } + + return value, nil +} diff --git a/modules/utils/path.go b/modules/utils/path.go index 0d713b3..16621aa 100644 --- a/modules/utils/path.go +++ b/modules/utils/path.go @@ -62,9 +62,9 @@ func AbsPathWithExpansion(p string) (string, error) { } if p == "~" { return u.HomeDir, nil - } else if strings.HasPrefix(p, "~/") { - return filepath.Join(u.HomeDir, p[2:]), nil - } else { - return filepath.Abs(p) } + if strings.HasPrefix(p, "~/") { + return filepath.Join(u.HomeDir, p[2:]), nil + } + return filepath.Abs(p) } diff --git a/modules/workaround/pull.go b/modules/workaround/pull.go deleted file mode 100644 index 81f9b75..0000000 --- a/modules/workaround/pull.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workaround - -import ( - "fmt" - - "code.gitea.io/sdk/gitea" -) - -// FixPullHeadSha is a workaround for https://github.com/go-gitea/gitea/issues/12675 -// When no head sha is available, this is because the branch got deleted in the base repo. -// pr.Head.Ref points in this case not to the head repo branch name, but the base repo ref, -// which stays available to resolve the commit sha. -func FixPullHeadSha(client *gitea.Client, pr *gitea.PullRequest) error { - owner := pr.Base.Repository.Owner.UserName - repo := pr.Base.Repository.Name - if pr.Head != nil && pr.Head.Sha == "" { - refs, _, err := client.GetRepoRefs(owner, repo, pr.Head.Ref) - if err != nil { - return err - } else if len(refs) == 0 { - return fmt.Errorf("unable to resolve PR ref '%s'", pr.Head.Ref) - } - pr.Head.Sha = refs[0].Object.SHA - } - return nil -} From f638dba99bee55cda1576557aed62833b6bca00a Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 22:58:25 +0000 Subject: [PATCH 06/83] More improvements (#870) - no duplicate logins - link to html page rather than api in output - client side pagination of watched repos Reviewed-on: https://gitea.com/gitea/tea/pulls/870 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- cmd/notifications/mark_as.go | 8 ++++++-- cmd/repos/list.go | 37 ++++++++++++++++++++++++++++++++++-- modules/config/login.go | 7 +++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/cmd/notifications/mark_as.go b/cmd/notifications/mark_as.go index a9d1525..fb25e00 100644 --- a/cmd/notifications/mark_as.go +++ b/cmd/notifications/mark_as.go @@ -130,8 +130,12 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt if err != nil { return err } - // FIXME: this is an API URL, we want to display a web ui link.. - fmt.Println(n.Subject.URL) + // Use LatestCommentHTMLURL if available, otherwise fall back to HTMLURL + if n.Subject.LatestCommentHTMLURL != "" { + fmt.Println(n.Subject.LatestCommentHTMLURL) + } else { + fmt.Println(n.Subject.HTMLURL) + } return nil } diff --git a/cmd/repos/list.go b/cmd/repos/list.go index e09d4e5..5b9209f 100644 --- a/cmd/repos/list.go +++ b/cmd/repos/list.go @@ -72,11 +72,13 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { return err } } else if teaCmd.Bool("watched") { - var err error - rps, _, err = client.GetMyWatchedRepos() // TODO: this does not expose pagination.. + // GetMyWatchedRepos doesn't expose server-side pagination, + // so we implement client-side pagination as a workaround + allRepos, _, err := client.GetMyWatchedRepos() if err != nil { return err } + rps = paginateRepos(allRepos, flags.GetListOptions()) } else { var err error rps, _, err = client.ListMyRepos(gitea.ListReposOptions{ @@ -123,3 +125,34 @@ func filterReposByType(repos []*gitea.Repository, t gitea.RepoType) []*gitea.Rep } return filtered } + +// paginateRepos implements client-side pagination for repositories. +// This is a workaround for API endpoints that don't support server-side pagination. +func paginateRepos(repos []*gitea.Repository, opts gitea.ListOptions) []*gitea.Repository { + if len(repos) == 0 { + return repos + } + + pageSize := opts.PageSize + if pageSize <= 0 { + pageSize = flags.PaginationLimitFlag.Value + } + + page := opts.Page + if page < 1 { + page = 1 + } + + start := (page - 1) * pageSize + end := start + pageSize + + if start >= len(repos) { + return []*gitea.Repository{} + } + + if end > len(repos) { + end = len(repos) + } + + return repos[start:end] +} diff --git a/modules/config/login.go b/modules/config/login.go index fb71b9e..0e7f79f 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -170,6 +170,13 @@ func AddLogin(login *Login) error { return err } + // Check for duplicate login names + for _, existing := range config.Logins { + if strings.EqualFold(existing.Name, login.Name) { + return fmt.Errorf("login name '%s' already exists", login.Name) + } + } + // save login to global var config.Logins = append(config.Logins, *login) From 4f8cb7ef19ec5ab9fe6f8b9346ad1d4dc301ab76 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 22:59:22 +0000 Subject: [PATCH 07/83] helpful error messages (#871) Reviewed-on: https://gitea.com/gitea/tea/pulls/871 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- cmd/milestones/issues.go | 20 +++++++++++++------- cmd/organizations/delete.go | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go index 1f0a3c5..440f245 100644 --- a/cmd/milestones/issues.go +++ b/cmd/milestones/issues.go @@ -132,13 +132,16 @@ func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error { // make sure milestone exist mile, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, mileName) if err != nil { - return err + return fmt.Errorf("failed to get milestone '%s': %w", mileName, err) } _, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ Milestone: &mile.ID, }) - return err + if err != nil { + return fmt.Errorf("failed to add issue #%d to milestone '%s': %w", idx, mileName, err) + } + return nil } func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error { @@ -153,25 +156,28 @@ func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error { issueIndex := ctx.Args().Get(1) idx, err := utils.ArgToIndex(issueIndex) if err != nil { - return err + return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err) } issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx) if err != nil { - return err + return fmt.Errorf("failed to get issue #%d: %w", idx, err) } if issue.Milestone == nil { - return fmt.Errorf("issue is not assigned to a milestone") + return fmt.Errorf("issue #%d is not assigned to a milestone", idx) } if issue.Milestone.Title != mileName { - return fmt.Errorf("issue is not assigned to this milestone") + return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName) } zero := int64(0) _, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ Milestone: &zero, }) - return err + if err != nil { + return fmt.Errorf("failed to remove issue #%d from milestone '%s': %w", idx, mileName, err) + } + return nil } diff --git a/cmd/organizations/delete.go b/cmd/organizations/delete.go index 2cdbd2d..5fb747e 100644 --- a/cmd/organizations/delete.go +++ b/cmd/organizations/delete.go @@ -38,7 +38,7 @@ func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error { response, err := client.DeleteOrg(ctx.Args().First()) if response != nil && response.StatusCode == 404 { - return fmt.Errorf("The given organization does not exist") + return fmt.Errorf("organization not found: %s", ctx.Args().First()) } return err From 0be14de5c2fa7e5e7e2d9c7b5eae039982acecd4 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 23:02:00 +0000 Subject: [PATCH 08/83] bump devcontainer --- .devcontainer/devcontainer.json | 38 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a36b29..7ca40f7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,20 +1,20 @@ -{ - "name": "Tea DevContainer", - "image": "mcr.microsoft.com/devcontainers/go:1.24-bullseye", - "features": { - "ghcr.io/devcontainers/features/git-lfs:1.2.5": {} - }, - "customizations": { - "vscode": { - "settings": {}, - "extensions": [ - "editorconfig.editorconfig", - "golang.go", - "stylelint.vscode-stylelint", - "DavidAnson.vscode-markdownlint", - "ms-azuretools.vscode-docker", - "GitHub.vscode-pull-request-github" - ] - } - } +{ + "name": "Tea DevContainer", + "image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", + "features": { + "ghcr.io/devcontainers/features/git-lfs:1.2.5": {} + }, + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "editorconfig.editorconfig", + "golang.go", + "stylelint.vscode-stylelint", + "DavidAnson.vscode-markdownlint", + "ms-azuretools.vscode-docker", + "GitHub.vscode-pull-request-github" + ] + } + } } \ No newline at end of file From 629872d1e915c482777d797f1e1706e376365ad1 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 23:05:45 +0000 Subject: [PATCH 09/83] nix flake update (#872) Reviewed-on: https://gitea.com/gitea/tea/pulls/872 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- flake.lock | 6 +++--- flake.nix | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 45ebada..6f8aa77 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1740547748, - "narHash": "sha256-Ly2fBL1LscV+KyCqPRufUBuiw+zmWrlJzpWOWbahplg=", + "lastModified": 1770015011, + "narHash": "sha256-7vUo0qWCl/rip+fzr6lcMlz9I0tN/8m7d5Bla/rS2kk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3a05eebede89661660945da1f151959900903b6a", + "rev": "f08e6b11a5ed43637a8ac444dd44118bc7d273b9", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e4d25d7..d3e4928 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ devShells.default = pkgs.mkShell { name = "tea-dev-environment"; buildInputs = with pkgs; [ - go_1_24 + go_1_25 gopls gnumake # Add other dependencies here if needed From c2180048a041a5bdccda7254ae84e3e7f7d3f3e7 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 2 Feb 2026 23:16:39 +0000 Subject: [PATCH 10/83] Split up Context (#873) Reviewed-on: https://gitea.com/gitea/tea/pulls/873 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- modules/context/context.go | 188 ----------------------------- modules/context/context_login.go | 49 ++++++++ modules/context/context_remote.go | 58 +++++++++ modules/context/context_repo.go | 79 ++++++++++++ modules/context/context_require.go | 44 +++++++ 5 files changed, 230 insertions(+), 188 deletions(-) create mode 100644 modules/context/context_login.go create mode 100644 modules/context/context_remote.go create mode 100644 modules/context/context_repo.go create mode 100644 modules/context/context_require.go diff --git a/modules/context/context.go b/modules/context/context.go index 8570041..1fd5c51 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -9,12 +9,8 @@ import ( "log" "os" "path" - "strconv" - "strings" - "time" "code.gitea.io/tea/modules/config" - "code.gitea.io/tea/modules/debug" "code.gitea.io/tea/modules/git" "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" @@ -52,41 +48,6 @@ func (ctx *TeaContext) IsInteractiveMode() bool { return ctx.Command.NumFlags() == 0 } -// Ensure checks if requirements on the context are set, and terminates otherwise. -func (ctx *TeaContext) Ensure(req CtxRequirement) { - if req.LocalRepo && ctx.LocalRepo == nil { - fmt.Println("Local repository required: Execute from a repo dir, or specify a path with --repo.") - os.Exit(1) - } - - if req.RemoteRepo && len(ctx.RepoSlug) == 0 { - fmt.Println("Remote repository required: Specify ID via --repo or execute from a local git repo.") - os.Exit(1) - } - - if req.Org && len(ctx.Org) == 0 { - fmt.Println("Organization required: Specify organization via --org.") - os.Exit(1) - } - - if req.Global && !ctx.IsGlobal { - fmt.Println("Global scope required: Specify --global.") - os.Exit(1) - } -} - -// CtxRequirement specifies context needed for operation -type CtxRequirement struct { - // ensures a local git repo is available & ctx.LocalRepo is set. Implies .RemoteRepo - LocalRepo bool - // ensures ctx.RepoSlug, .Owner, .Repo are set - RemoteRepo bool - // ensures ctx.Org is set - Org bool - // ensures ctx.IsGlobal is true - Global bool -} - // InitCommand resolves the application context, and returns the active login, and if // available the repo slug. It does this by reading the config file for logins, parsing // the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from @@ -193,152 +154,3 @@ and then run your command again.`) c.Output = cmd.String("output") return &c } - -// contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo -func contextFromLocalRepo(repoPath, remoteValue string) (*git.TeaRepo, *config.Login, string, error) { - repo, err := git.RepoFromPath(repoPath) - if err != nil { - return nil, nil, "", err - } - gitConfig, err := repo.Config() - if err != nil { - return repo, nil, "", err - } - debug.Printf("Get git config %v of %s in repo %s", gitConfig, remoteValue, repoPath) - - if len(gitConfig.Remotes) == 0 { - return repo, nil, "", errNotAGiteaRepo - } - - // When no preferred value is given, choose a remote to find a - // matching login based on its URL. - if len(gitConfig.Remotes) > 1 && len(remoteValue) == 0 { - // if master branch is present, use it as the default remote - mainBranches := []string{"main", "master", "trunk"} - for _, b := range mainBranches { - masterBranch, ok := gitConfig.Branches[b] - if ok { - if len(masterBranch.Remote) > 0 { - remoteValue = masterBranch.Remote - } - break - } - } - // if no branch has matched, default to origin or upstream remote. - if len(remoteValue) == 0 { - if _, ok := gitConfig.Remotes["upstream"]; ok { - remoteValue = "upstream" - } else if _, ok := gitConfig.Remotes["origin"]; ok { - remoteValue = "origin" - } - } - } - // make sure a remote is selected - if len(remoteValue) == 0 { - for remote := range gitConfig.Remotes { - remoteValue = remote - break - } - } - - remoteConfig, ok := gitConfig.Remotes[remoteValue] - if !ok || remoteConfig == nil { - return repo, nil, "", fmt.Errorf("remote '%s' not found in this Git repository", remoteValue) - } - - debug.Printf("Get remote configurations %v of %s in repo %s", remoteConfig, remoteValue, repoPath) - - logins, err := config.GetLogins() - if err != nil { - return repo, nil, "", err - } - for _, u := range remoteConfig.URLs { - if l, p, err := MatchLogins(u, logins); err == nil { - return repo, l, p, nil - } - } - - return repo, nil, "", errNotAGiteaRepo -} - -// MatchLogins matches the given remoteURL against the provided logins and returns -// the first matching login -// remoteURL could be like: -// -// https://gitea.com/owner/repo.git -// http://gitea.com/owner/repo.git -// ssh://gitea.com/owner/repo.git -// git@gitea.com:owner/repo.git -func MatchLogins(remoteURL string, logins []config.Login) (*config.Login, string, error) { - for _, l := range logins { - debug.Printf("Matching remote URL '%s' against %v login", remoteURL, l) - sshHost := l.GetSSHHost() - atIdx := strings.Index(remoteURL, "@") - colonIdx := strings.Index(remoteURL, ":") - if atIdx > 0 && colonIdx > atIdx { - domain := remoteURL[atIdx+1 : colonIdx] - if domain == sshHost { - return &l, strings.TrimSuffix(remoteURL[colonIdx+1:], ".git"), nil - } - } else { - p, err := git.ParseURL(remoteURL) - if err != nil { - return nil, "", fmt.Errorf("git remote URL parse failed: %s", err.Error()) - } - - switch { - case strings.EqualFold(p.Scheme, "http") || strings.EqualFold(p.Scheme, "https"): - if strings.HasPrefix(remoteURL, l.URL) { - ps := strings.Split(p.Path, "/") - path := strings.Join(ps[len(ps)-2:], "/") - return &l, strings.TrimSuffix(path, ".git"), nil - } - case strings.EqualFold(p.Scheme, "ssh"): - if sshHost == p.Host || sshHost == p.Hostname() { - return &l, strings.TrimLeft(p.Path, "/"), nil - } - default: - // unknown scheme - return nil, "", fmt.Errorf("git remote URL parse failed: %s", "unknown scheme "+p.Scheme) - } - } - } - return nil, "", errNotAGiteaRepo -} - -// GetLoginByEnvVar returns a login based on environment variables, or nil if no login can be created -func GetLoginByEnvVar() *config.Login { - var token string - - giteaToken := os.Getenv("GITEA_TOKEN") - githubToken := os.Getenv("GH_TOKEN") - giteaInstanceURL := os.Getenv("GITEA_INSTANCE_URL") - instanceInsecure := os.Getenv("GITEA_INSTANCE_INSECURE") - insecure := false - if len(instanceInsecure) > 0 { - insecure, _ = strconv.ParseBool(instanceInsecure) - } - - // if no tokens are set, or no instance url for gitea fail fast - if len(giteaInstanceURL) == 0 || (len(giteaToken) == 0 && len(githubToken) == 0) { - return nil - } - - token = giteaToken - if len(giteaToken) == 0 { - token = githubToken - } - - return &config.Login{ - Name: "GITEA_LOGIN_VIA_ENV", - URL: giteaInstanceURL, - Token: token, - Insecure: insecure, - SSHKey: "", - SSHCertPrincipal: "", - SSHKeyFingerprint: "", - SSHAgent: false, - Created: time.Now().Unix(), - VersionCheck: false, - } -} diff --git a/modules/context/context_login.go b/modules/context/context_login.go new file mode 100644 index 0000000..5ebf7cb --- /dev/null +++ b/modules/context/context_login.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "os" + "strconv" + "time" + + "code.gitea.io/tea/modules/config" +) + +// GetLoginByEnvVar returns a login based on environment variables, or nil if no login can be created +func GetLoginByEnvVar() *config.Login { + var token string + + giteaToken := os.Getenv("GITEA_TOKEN") + githubToken := os.Getenv("GH_TOKEN") + giteaInstanceURL := os.Getenv("GITEA_INSTANCE_URL") + instanceInsecure := os.Getenv("GITEA_INSTANCE_INSECURE") + insecure := false + if len(instanceInsecure) > 0 { + insecure, _ = strconv.ParseBool(instanceInsecure) + } + + // if no tokens are set, or no instance url for gitea fail fast + if len(giteaInstanceURL) == 0 || (len(giteaToken) == 0 && len(githubToken) == 0) { + return nil + } + + token = giteaToken + if len(giteaToken) == 0 { + token = githubToken + } + + return &config.Login{ + Name: "GITEA_LOGIN_VIA_ENV", + URL: giteaInstanceURL, + Token: token, + Insecure: insecure, + SSHKey: "", + SSHCertPrincipal: "", + SSHKeyFingerprint: "", + SSHAgent: false, + Created: time.Now().Unix(), + VersionCheck: false, + } +} diff --git a/modules/context/context_remote.go b/modules/context/context_remote.go new file mode 100644 index 0000000..61dc4bd --- /dev/null +++ b/modules/context/context_remote.go @@ -0,0 +1,58 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "fmt" + "strings" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/debug" + "code.gitea.io/tea/modules/git" +) + +// MatchLogins matches the given remoteURL against the provided logins and returns +// the first matching login +// remoteURL could be like: +// +// https://gitea.com/owner/repo.git +// http://gitea.com/owner/repo.git +// ssh://gitea.com/owner/repo.git +// git@gitea.com:owner/repo.git +func MatchLogins(remoteURL string, logins []config.Login) (*config.Login, string, error) { + for _, l := range logins { + debug.Printf("Matching remote URL '%s' against %v login", remoteURL, l) + sshHost := l.GetSSHHost() + atIdx := strings.Index(remoteURL, "@") + colonIdx := strings.Index(remoteURL, ":") + if atIdx > 0 && colonIdx > atIdx { + domain := remoteURL[atIdx+1 : colonIdx] + if domain == sshHost { + return &l, strings.TrimSuffix(remoteURL[colonIdx+1:], ".git"), nil + } + } else { + p, err := git.ParseURL(remoteURL) + if err != nil { + return nil, "", fmt.Errorf("git remote URL parse failed: %s", err.Error()) + } + + switch { + case strings.EqualFold(p.Scheme, "http") || strings.EqualFold(p.Scheme, "https"): + if strings.HasPrefix(remoteURL, l.URL) { + ps := strings.Split(p.Path, "/") + path := strings.Join(ps[len(ps)-2:], "/") + return &l, strings.TrimSuffix(path, ".git"), nil + } + case strings.EqualFold(p.Scheme, "ssh"): + if sshHost == p.Host || sshHost == p.Hostname() { + return &l, strings.TrimLeft(p.Path, "/"), nil + } + default: + // unknown scheme + return nil, "", fmt.Errorf("git remote URL parse failed: %s", "unknown scheme "+p.Scheme) + } + } + } + return nil, "", errNotAGiteaRepo +} diff --git a/modules/context/context_repo.go b/modules/context/context_repo.go new file mode 100644 index 0000000..6072d0e --- /dev/null +++ b/modules/context/context_repo.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "fmt" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/debug" + "code.gitea.io/tea/modules/git" +) + +// contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo +func contextFromLocalRepo(repoPath, remoteValue string) (*git.TeaRepo, *config.Login, string, error) { + repo, err := git.RepoFromPath(repoPath) + if err != nil { + return nil, nil, "", err + } + gitConfig, err := repo.Config() + if err != nil { + return repo, nil, "", err + } + debug.Printf("Get git config %v of %s in repo %s", gitConfig, remoteValue, repoPath) + + if len(gitConfig.Remotes) == 0 { + return repo, nil, "", errNotAGiteaRepo + } + + // When no preferred value is given, choose a remote to find a + // matching login based on its URL. + if len(gitConfig.Remotes) > 1 && len(remoteValue) == 0 { + // if master branch is present, use it as the default remote + mainBranches := []string{"main", "master", "trunk"} + for _, b := range mainBranches { + masterBranch, ok := gitConfig.Branches[b] + if ok { + if len(masterBranch.Remote) > 0 { + remoteValue = masterBranch.Remote + } + break + } + } + // if no branch has matched, default to origin or upstream remote. + if len(remoteValue) == 0 { + if _, ok := gitConfig.Remotes["upstream"]; ok { + remoteValue = "upstream" + } else if _, ok := gitConfig.Remotes["origin"]; ok { + remoteValue = "origin" + } + } + } + // make sure a remote is selected + if len(remoteValue) == 0 { + for remote := range gitConfig.Remotes { + remoteValue = remote + break + } + } + + remoteConfig, ok := gitConfig.Remotes[remoteValue] + if !ok || remoteConfig == nil { + return repo, nil, "", fmt.Errorf("remote '%s' not found in this Git repository", remoteValue) + } + + debug.Printf("Get remote configurations %v of %s in repo %s", remoteConfig, remoteValue, repoPath) + + logins, err := config.GetLogins() + if err != nil { + return repo, nil, "", err + } + for _, u := range remoteConfig.URLs { + if l, p, err := MatchLogins(u, logins); err == nil { + return repo, l, p, nil + } + } + + return repo, nil, "", errNotAGiteaRepo +} diff --git a/modules/context/context_require.go b/modules/context/context_require.go new file mode 100644 index 0000000..2e97618 --- /dev/null +++ b/modules/context/context_require.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "fmt" + "os" +) + +// Ensure checks if requirements on the context are set, and terminates otherwise. +func (ctx *TeaContext) Ensure(req CtxRequirement) { + if req.LocalRepo && ctx.LocalRepo == nil { + fmt.Println("Local repository required: Execute from a repo dir, or specify a path with --repo.") + os.Exit(1) + } + + if req.RemoteRepo && len(ctx.RepoSlug) == 0 { + fmt.Println("Remote repository required: Specify ID via --repo or execute from a local git repo.") + os.Exit(1) + } + + if req.Org && len(ctx.Org) == 0 { + fmt.Println("Organization required: Specify organization via --org.") + os.Exit(1) + } + + if req.Global && !ctx.IsGlobal { + fmt.Println("Global scope required: Specify --global.") + os.Exit(1) + } +} + +// CtxRequirement specifies context needed for operation +type CtxRequirement struct { + // ensures a local git repo is available & ctx.LocalRepo is set. Implies .RemoteRepo + LocalRepo bool + // ensures ctx.RepoSlug, .Owner, .Repo are set + RemoteRepo bool + // ensures ctx.Org is set + Org bool + // ensures ctx.IsGlobal is true + Global bool +} From 7801310a18813b25629b92223f69bf534590a81b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 3 Feb 2026 01:00:03 +0000 Subject: [PATCH 11/83] fix(deps): update module github.com/olekukonko/tablewriter to v1.1.3 (#875) Reviewed-on: https://gitea.com/gitea/tea/pulls/875 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 8 ++++---- go.sum | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 8a28a46..d10af98 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/enescakir/emoji v1.0.0 github.com/go-git/go-git/v5 v5.16.4 github.com/muesli/termenv v0.16.0 - github.com/olekukonko/tablewriter v1.1.1 + github.com/olekukonko/tablewriter v1.1.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 @@ -43,9 +43,9 @@ require ( github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/clipperhouse/displaywidth v0.3.1 // indirect + github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect @@ -76,7 +76,7 @@ require ( github.com/muesli/reflow v0.3.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.1.0 // indirect - github.com/olekukonko/ll v0.1.2 // indirect + github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 632c541..78b6802 100644 --- a/go.sum +++ b/go.sum @@ -73,10 +73,14 @@ github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGl github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.3.1 h1:k07iN9gD32177o1y4O1jQMzbLdCrsGJh+blirVYybsk= github.com/clipperhouse/displaywidth v0.3.1/go.mod h1:tgLJKKyaDOCadywag3agw4snxS5kYEuYR6Y9+qWDDYM= +github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= +github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= @@ -167,8 +171,12 @@ github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5 github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.2 h1:lkg/k/9mlsy0SxO5aC+WEpbdT5K83ddnNhAepz7TQc0= github.com/olekukonko/ll v0.1.2/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= +github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/tablewriter v1.1.1 h1:b3reP6GCfrHwmKkYwNRFh2rxidGHcT6cgxj/sHiDDx0= github.com/olekukonko/tablewriter v1.1.1/go.mod h1:De/bIcTF+gpBDB3Alv3fEsZA+9unTsSzAg/ZGADCtn4= +github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= +github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= From 383c5fdc0313d7a5e8d7aad3fd534c9251d742cd Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 3 Feb 2026 01:00:21 +0000 Subject: [PATCH 12/83] fix(deps): update module github.com/urfave/cli/v3 to v3.6.2 (#876) Reviewed-on: https://gitea.com/gitea/tea/pulls/876 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d10af98..8f9cdff 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 - github.com/urfave/cli/v3 v3.6.1 + github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.33.0 golang.org/x/term v0.39.0 diff --git a/go.sum b/go.sum index 78b6802..0a1185f 100644 --- a/go.sum +++ b/go.sum @@ -213,6 +213,8 @@ github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7v github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= +github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From 864face284cd175306dae74e92a05f38c86dbb5f Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 3 Feb 2026 01:06:03 +0000 Subject: [PATCH 13/83] fix(deps): update module golang.org/x/oauth2 to v0.34.0 (#878) Reviewed-on: https://gitea.com/gitea/tea/pulls/878 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 8f9cdff..08a18af 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.47.0 - golang.org/x/oauth2 v0.33.0 + golang.org/x/oauth2 v0.34.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 0a1185f..e015202 100644 --- a/go.sum +++ b/go.sum @@ -246,6 +246,8 @@ golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= From 6414a5e00ed5f75d744041d042ef880dfd67035c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 3 Feb 2026 01:06:13 +0000 Subject: [PATCH 14/83] chore(deps): update docker.gitea.com/gitea docker tag to v1.25.4 (#877) Reviewed-on: https://gitea.com/gitea/tea/pulls/877 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 968d8b6..059b91e 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -39,7 +39,7 @@ jobs: make unit-test-coverage services: gitea: - image: docker.gitea.com/gitea:1.24.5 + image: docker.gitea.com/gitea:1.25.4 cmd: - bash - -c From 82d8a14c73623cac9676689f01d3d177abde8882 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 3 Feb 2026 20:24:21 +0000 Subject: [PATCH 15/83] Add `api` subcommand for arbitrary api calls not covered by existing subcommands (#879) Reviewed-on: https://gitea.com/gitea/tea/pulls/879 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- Makefile | 2 +- cmd/api.go | 274 ++++++++++++++++++++++++++++++++++++++++ cmd/cmd.go | 1 + cmd/login/helper.go | 43 ++++--- docs/CLI.md | 22 ++++ main.go | 16 ++- modules/api/client.go | 105 +++++++++++++++ modules/auth/oauth.go | 61 +-------- modules/config/login.go | 149 +++++++++++++--------- 9 files changed, 540 insertions(+), 133 deletions(-) create mode 100644 cmd/api.go create mode 100644 modules/api/client.go diff --git a/Makefile b/Makefile index 735b329..ae0038b 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ export PATH := $($(GO) env GOPATH)/bin:$(PATH) GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") # Tool packages with pinned versions -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.7.0 +GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 ifneq ($(DRONE_TAG),) diff --git a/cmd/api.go b/cmd/api.go new file mode 100644 index 0000000..d50c925 --- /dev/null +++ b/cmd/api.go @@ -0,0 +1,274 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "encoding/json" + "fmt" + "io" + "os" + "strconv" + "strings" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" + "golang.org/x/term" +) + +// CmdApi represents the api command +var CmdApi = cli.Command{ + Name: "api", + Usage: "Make an authenticated API request", + Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. + +The endpoint argument is the path to the API endpoint, which will be prefixed +with /api/v1/ if it doesn't start with /api/ or http(s)://. + +Placeholders like {owner} and {repo} in the endpoint will be replaced with +values from the current repository context. + +Use -f for string fields and -F for typed fields (numbers, booleans, null). +With -F, prefix value with @ to read from file (@- for stdin).`, + ArgsUsage: "", + Action: runApi, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "method", + Aliases: []string{"X"}, + Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)", + Value: "GET", + }, + &cli.StringSliceFlag{ + Name: "field", + Aliases: []string{"f"}, + Usage: "Add a string field to the request body (key=value)", + }, + &cli.StringSliceFlag{ + Name: "Field", + Aliases: []string{"F"}, + Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)", + }, + &cli.StringSliceFlag{ + Name: "header", + Aliases: []string{"H"}, + Usage: "Add a custom header (key:value)", + }, + &cli.BoolFlag{ + Name: "include", + Aliases: []string{"i"}, + Usage: "Include HTTP status and response headers in output (written to stderr)", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Write response body to file instead of stdout (use '-' for stdout)", + }, + }, flags.LoginRepoFlags...), +} + +func runApi(_ stdctx.Context, cmd *cli.Command) error { + ctx := context.InitCommand(cmd) + + // Get the endpoint argument + if cmd.NArg() < 1 { + return fmt.Errorf("endpoint argument required") + } + endpoint := cmd.Args().First() + + // Expand placeholders in endpoint + endpoint = expandPlaceholders(endpoint, ctx) + + // Parse headers + headers := make(map[string]string) + for _, h := range cmd.StringSlice("header") { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid header format: %q (expected key:value)", h) + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build request body from fields + var body io.Reader + stringFields := cmd.StringSlice("field") + typedFields := cmd.StringSlice("Field") + + if len(stringFields) > 0 || len(typedFields) > 0 { + bodyMap := make(map[string]any) + + // Process string fields (-f) + for _, f := range stringFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + bodyMap[parts[0]] = parts[1] + } + + // Process typed fields (-F) + for _, f := range typedFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + value := parts[1] + + parsedValue, err := parseTypedValue(value) + if err != nil { + return fmt.Errorf("failed to parse field %q: %w", key, err) + } + bodyMap[key] = parsedValue + } + + bodyBytes, err := json.Marshal(bodyMap) + if err != nil { + return fmt.Errorf("failed to encode request body: %w", err) + } + body = strings.NewReader(string(bodyBytes)) + } + + // Create API client and make request + client := api.NewClient(ctx.Login) + method := strings.ToUpper(cmd.String("method")) + + resp, err := client.Do(method, endpoint, body, headers) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Print headers to stderr if requested (so redirects/pipes work correctly) + if cmd.Bool("include") { + fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status) + for key, values := range resp.Header { + for _, value := range values { + fmt.Fprintf(os.Stderr, "%s: %s\n", key, value) + } + } + fmt.Fprintln(os.Stderr) + } + + // Determine output destination + outputPath := cmd.String("output") + forceStdout := outputPath == "-" + outputToStdout := outputPath == "" || forceStdout + + // Check for binary output to terminal (skip warning if user explicitly forced stdout) + if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) { + fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o ' to save to a file,") + fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.") + return nil + } + + var output io.Writer = os.Stdout + if !outputToStdout { + file, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer file.Close() + output = file + } + + // Copy response body to output + _, err = io.Copy(output, resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Add newline for better terminal display + if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) { + fmt.Println() + } + + return nil +} + +// parseTypedValue parses a value for -F flag, handling: +// - @filename: read content from file +// - @-: read content from stdin +// - true/false: boolean +// - null: nil +// - numbers: int or float +// - otherwise: string +func parseTypedValue(value string) (any, error) { + // Handle file references + if strings.HasPrefix(value, "@") { + filename := value[1:] + var content []byte + var err error + + if filename == "-" { + content, err = io.ReadAll(os.Stdin) + } else { + content, err = os.ReadFile(filename) + } + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", value, err) + } + return strings.TrimSuffix(string(content), "\n"), nil + } + + // Handle null + if value == "null" { + return nil, nil + } + + // Handle booleans + if value == "true" { + return true, nil + } + if value == "false" { + return false, nil + } + + // Handle integers + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i, nil + } + + // Handle floats + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f, nil + } + + // Default to string + return value, nil +} + +// isTextContentType returns true if the content type indicates text data +func isTextContentType(contentType string) bool { + if contentType == "" { + return true // assume text if unknown + } + contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset + + return strings.HasPrefix(contentType, "text/") || + strings.Contains(contentType, "json") || + strings.Contains(contentType, "xml") || + strings.Contains(contentType, "javascript") || + strings.Contains(contentType, "yaml") || + strings.Contains(contentType, "toml") +} + +// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint +func expandPlaceholders(endpoint string, ctx *context.TeaContext) string { + endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner) + endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo) + + // Get current branch if available + if ctx.LocalRepo != nil { + if branch, err := ctx.LocalRepo.Head(); err == nil { + branchName := branch.Name().Short() + endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName) + } + } + + return endpoint +} diff --git a/cmd/cmd.go b/cmd/cmd.go index ac0a141..2b9e852 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -59,6 +59,7 @@ func App() *cli.Command { &CmdAdmin, + &CmdApi, &CmdGenerateManPage, }, EnableShellCompletion: true, diff --git a/cmd/login/helper.go b/cmd/login/helper.go index 14dd60d..ab788f3 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -11,9 +11,7 @@ import ( "net/url" "os" "strings" - "time" - "code.gitea.io/tea/modules/auth" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/task" "github.com/urfave/cli/v3" @@ -59,6 +57,13 @@ var CmdLoginHelper = cli.Command{ { Name: "get", Description: "Get token to auth", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "login", + Aliases: []string{"l"}, + Usage: "Use a specific login", + }, + }, Action: func(_ context.Context, cmd *cli.Command) error { wants := map[string]string{} s := bufio.NewScanner(os.Stdin) @@ -93,10 +98,21 @@ var CmdLoginHelper = cli.Command{ wants["protocol"] = "http" } - userConfig := config.GetLoginByHost(wants["host"]) - if userConfig == nil { - log.Fatal("host not exists") - } else if len(userConfig.Token) == 0 { + // Use --login flag if provided, otherwise fall back to host lookup + var userConfig *config.Login + if loginName := cmd.String("login"); loginName != "" { + userConfig = config.GetLoginByName(loginName) + if userConfig == nil { + log.Fatalf("Login '%s' not found", loginName) + } + } else { + userConfig = config.GetLoginByHost(wants["host"]) + if userConfig == nil { + log.Fatalf("No login found for host '%s'", wants["host"]) + } + } + + if len(userConfig.Token) == 0 { log.Fatal("User no set") } @@ -105,18 +121,9 @@ var CmdLoginHelper = cli.Command{ return err } - if userConfig.TokenExpiry > 0 && time.Now().Unix() > userConfig.TokenExpiry { - // Token is expired, refresh it - err = auth.RefreshAccessToken(userConfig) - if err != nil { - return err - } - - // Once token is refreshed, get the latest from the updated config - refreshedConfig := config.GetLoginByHost(wants["host"]) - if refreshedConfig != nil { - userConfig = refreshedConfig - } + // Refresh token if expired or near expiry (updates userConfig in place) + if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil { + return err } _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token) diff --git a/docs/CLI.md b/docs/CLI.md index 44bf1d6..59467dc 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1712,3 +1712,25 @@ List Users **--remote, -R**="": Discover Gitea login from remote. Optional **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +## api + +Make an authenticated API request + +**--Field, -F**="": Add a typed field to the request body (key=value, @file, or @- for stdin) + +**--field, -f**="": Add a string field to the request body (key=value) + +**--header, -H**="": Add a custom header (key:value) + +**--include, -i**: Include HTTP status and response headers in output (written to stderr) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--method, -X**="": HTTP method (GET, POST, PUT, PATCH, DELETE) (default: "GET") + +**--output, -o**="": Write response body to file instead of stdout (use '-' for stdout) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional diff --git a/main.go b/main.go index cc38c38..393c617 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ import ( func main() { app := cmd.App() app.Flags = append(app.Flags, debug.CliFlag()) - err := app.Run(context.Background(), os.Args) + err := app.Run(context.Background(), preprocessArgs(os.Args)) if err != nil { // app.Run already exits for errors implementing ErrorCoder, // so we only handle generic errors with code 1 here. @@ -24,3 +24,17 @@ func main() { os.Exit(1) } } + +// preprocessArgs normalizes command-line arguments. +// Converts "-o-" to "-o -" for the api command's output flag. +func preprocessArgs(args []string) []string { + result := make([]string, 0, len(args)+1) + for _, arg := range args { + if arg == "-o-" { + result = append(result, "-o", "-") + } else { + result = append(result, arg) + } + } + return result +} diff --git a/modules/api/client.go b/modules/api/client.go new file mode 100644 index 0000000..4c00546 --- /dev/null +++ b/modules/api/client.go @@ -0,0 +1,105 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import ( + "crypto/tls" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + + "code.gitea.io/tea/modules/config" +) + +// Client provides direct HTTP access to Gitea API +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// NewClient creates a new API client from a Login config +func NewClient(login *config.Login) *Client { + // Refresh OAuth token if expired or near expiry + if err := login.RefreshOAuthTokenIfNeeded(); err != nil { + log.Printf("Warning: failed to refresh OAuth token: %v", err) + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure}, + }, + } + + return &Client{ + baseURL: strings.TrimSuffix(login.URL, "/"), + token: login.Token, + httpClient: httpClient, + } +} + +// Do executes an HTTP request with authentication headers +func (c *Client) Do(method, endpoint string, body io.Reader, headers map[string]string) (*http.Response, error) { + // Build the full URL + reqURL, err := c.buildURL(endpoint) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set authentication header + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + + // Set default content type for requests with body + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + // Apply custom headers (can override defaults) + for key, value := range headers { + req.Header.Set(key, value) + } + + return c.httpClient.Do(req) +} + +// buildURL constructs the full URL from an endpoint +func (c *Client) buildURL(endpoint string) (string, error) { + // If endpoint is already a full URL, validate it matches the login's host + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + endpointURL, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("invalid URL: %w", err) + } + baseURL, err := url.Parse(c.baseURL) + if err != nil { + return "", fmt.Errorf("invalid base URL: %w", err) + } + if endpointURL.Host != baseURL.Host { + return "", fmt.Errorf("URL host %q does not match login host %q (token would be sent to wrong server)", endpointURL.Host, baseURL.Host) + } + return endpoint, nil + } + + // Ensure endpoint starts with / + if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + + // Auto-prefix /api/v1/ if not present + if !strings.HasPrefix(endpoint, "/api/") { + endpoint = "/api/v1" + endpoint + } + + return c.baseURL + endpoint, nil +} diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 33d92ec..8584261 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -27,9 +27,6 @@ import ( // Constants for OAuth2 PKCE flow const ( - // default client ID included in most Gitea instances - defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" - // default scopes to request defaultScopes = "admin,user,issue,misc,notification,organization,package,repository" @@ -65,7 +62,7 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error { Name: name, URL: giteaURL, Insecure: insecure, - ClientID: defaultClientID, + ClientID: config.DefaultClientID, RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort), Port: redirectPort, } @@ -82,7 +79,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { // Set defaults if needed if opts.ClientID == "" { - opts.ClientID = defaultClientID + opts.ClientID = config.DefaultClientID } // If the redirect URL is specified, parse it to extract port if needed @@ -414,55 +411,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure return nil } -// RefreshAccessToken manually renews an expired access token using the refresh token +// RefreshAccessToken manually renews an access token using the refresh token. +// This is used by the "tea login oauth-refresh" command for explicit token refresh. +// For automatic threshold-based refresh, use login.Client() which handles it internally. func RefreshAccessToken(login *config.Login) error { - if login.RefreshToken == "" { - return fmt.Errorf("no refresh token available") - } - - // Check if token actually needs refreshing - if login.TokenExpiry > 0 && time.Now().Unix() < login.TokenExpiry { - // Token is still valid, no need to refresh - return nil - } - - // Create an expired Token object - expiredToken := &oauth2.Token{ - AccessToken: login.Token, - RefreshToken: login.RefreshToken, - // Set expiry in the past to force refresh - Expiry: time.Unix(login.TokenExpiry, 0), - } - - // Set up the OAuth2 config - ctx := context.Background() - ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(login.Insecure)) - - // Configure the OAuth2 endpoints - oauth2Config := &oauth2.Config{ - ClientID: defaultClientID, - Endpoint: oauth2.Endpoint{ - TokenURL: fmt.Sprintf("%s/login/oauth/access_token", login.URL), - }, - } - - // Refresh the token - newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() - if err != nil { - return fmt.Errorf("failed to refresh token: %s", err) - } - - // Update login with new token information - login.Token = newToken.AccessToken - - if newToken.RefreshToken != "" { - login.RefreshToken = newToken.RefreshToken - } - - if !newToken.Expiry.IsZero() { - login.TokenExpiry = newToken.Expiry.Unix() - } - - // Save updated login to config - return config.UpdateLogin(login) + return login.RefreshOAuthToken() } diff --git a/modules/config/login.go b/modules/config/login.go index 0e7f79f..db8522f 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -24,6 +24,13 @@ import ( "golang.org/x/oauth2" ) +// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens. +// This is used by config.Login.Client() for automatic token refresh. +const TokenRefreshThreshold = 5 * time.Minute + +// DefaultClientID is the default OAuth2 client ID included in most Gitea instances +const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" + // Login represents a login to a gitea server, you even could add multiple logins for one gitea server type Login struct { Name string `yaml:"name"` @@ -129,21 +136,31 @@ func GetLoginByToken(token string) *Login { // GetLoginByHost finds a login by it's server URL func GetLoginByHost(host string) *Login { + logins := GetLoginsByHost(host) + if len(logins) > 0 { + return logins[0] + } + return nil +} + +// GetLoginsByHost returns all logins matching a host +func GetLoginsByHost(host string) []*Login { err := loadConfig() if err != nil { log.Fatal(err) } - for _, l := range config.Logins { - loginURL, err := url.Parse(l.URL) + var matches []*Login + for i := range config.Logins { + loginURL, err := url.Parse(config.Logins[i].URL) if err != nil { log.Fatal(err) } if loginURL.Host == host { - return &l + matches = append(matches, &config.Logins[i]) } } - return nil + return matches } // DeleteLogin delete a login by name from config @@ -208,63 +225,79 @@ func UpdateLogin(login *Login) error { return saveConfig() } +// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry. +// Returns nil without doing anything if no refresh is needed. +func (l *Login) RefreshOAuthTokenIfNeeded() error { + if l.RefreshToken == "" || l.TokenExpiry == 0 { + return nil + } + expiryTime := time.Unix(l.TokenExpiry, 0) + if time.Now().Add(TokenRefreshThreshold).After(expiryTime) { + return l.RefreshOAuthToken() + } + return nil +} + +// RefreshOAuthToken refreshes the OAuth access token using the refresh token. +// It updates the login with new token information and saves it to config. +func (l *Login) RefreshOAuthToken() error { + if l.RefreshToken == "" { + return fmt.Errorf("no refresh token available") + } + + // Create a Token object with current values + currentToken := &oauth2.Token{ + AccessToken: l.Token, + RefreshToken: l.RefreshToken, + Expiry: time.Unix(l.TokenExpiry, 0), + } + + // Set up the OAuth2 config + ctx := context.Background() + + // Create HTTP client, respecting the login's TLS settings + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure}, + }, + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + // Configure the OAuth2 endpoints + oauth2Config := &oauth2.Config{ + ClientID: DefaultClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), + }, + } + + // Refresh the token + newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token() + if err != nil { + return fmt.Errorf("failed to refresh token: %w", err) + } + + // Update login with new token information + l.Token = newToken.AccessToken + + if newToken.RefreshToken != "" { + l.RefreshToken = newToken.RefreshToken + } + + if !newToken.Expiry.IsZero() { + l.TokenExpiry = newToken.Expiry.Unix() + } + + // Save updated login to config + return UpdateLogin(l) +} + // Client returns a client to operate Gitea API. You may provide additional modifiers // for the client like gitea.SetBasicAuth() for customization func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { - // Check if token needs refreshing (if we have a refresh token and expiry time) - if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry { - // Since we can't directly call auth.RefreshAccessToken due to import cycles, - // we'll implement the token refresh logic here. - // Create an expired Token object - expiredToken := &oauth2.Token{ - AccessToken: l.Token, - RefreshToken: l.RefreshToken, - // Set expiry in the past to force refresh - Expiry: time.Unix(l.TokenExpiry, 0), - } - - // Set up the OAuth2 config - ctx := context.Background() - - // Create HTTP client with proper insecure settings - httpClient := &http.Client{} - if l.Insecure { - httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } - } - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - - // Configure the OAuth2 endpoints - oauth2Config := &oauth2.Config{ - ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go - Endpoint: oauth2.Endpoint{ - TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), - }, - } - - // Refresh the token - newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() - if err != nil { - log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) - } - // Update login with new token information - l.Token = newToken.AccessToken - - if newToken.RefreshToken != "" { - l.RefreshToken = newToken.RefreshToken - } - - if !newToken.Expiry.IsZero() { - l.TokenExpiry = newToken.Expiry.Unix() - } - - // Save updated login to config - if err := UpdateLogin(l); err != nil { - log.Fatalf("Failed to save refreshed token: %s\n", err) - } + // Refresh OAuth token if expired or near expiry + if err := l.RefreshOAuthTokenIfNeeded(); err != nil { + log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) } httpClient := &http.Client{} From 0d5bf60632276cafcc67f93f0939516f734c8862 Mon Sep 17 00:00:00 2001 From: a1012112796 <1012112796@qq.com> Date: Tue, 3 Feb 2026 20:36:04 +0000 Subject: [PATCH 16/83] support create agit flow pull request (#867) while looks the alibaba has not maintain [`git-repo-go`](https://github.com/alibaba/git-repo-go/) tool, to make agit flow pull requst can be create quickly. add creating agit flow pull request feature in tea tool example: ```SHELL tea pulls create --agit --remote=origin --topic=test-topic --title="hello world" --description="test1 test 2 test 3" ``` Signed-off-by: a1012112796 <1012112796@qq.com> Reviewed-on: https://gitea.com/gitea/tea/pulls/867 Co-authored-by: a1012112796 <1012112796@qq.com> Co-committed-by: a1012112796 <1012112796@qq.com> --- cmd/pulls/create.go | 20 +++++++++++ docs/CLI.md | 4 +++ modules/git/branch.go | 46 ++++++++++++++++++++++++ modules/git/repo.go | 12 +++++++ modules/interact/pull_create.go | 63 ++++++++++++++++++++++++++++++++ modules/task/pull_create.go | 64 +++++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+) diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index b1dd6a3..bdeb879 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -37,6 +37,14 @@ var CmdPullsCreate = cli.Command{ Usage: "Enable maintainers to push to the base branch of created pull", Value: true, }, + &cli.BoolFlag{ + Name: "agit", + Usage: "Create an agit flow pull request", + }, + &cli.StringFlag{ + Name: "topic", + Usage: "Topic name for agit flow pull request", + }, }, flags.IssuePRCreateFlags...), } @@ -61,6 +69,18 @@ func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error { return err } + if ctx.Bool("agit") { + return task.CreateAgitFlowPull( + ctx, + ctx.String("remote"), + ctx.String("head"), + ctx.String("base"), + ctx.String("topic"), + opts, + interact.PromptPassword, + ) + } + var allowMaintainerEdits *bool if ctx.IsSet("allow-maintainer-edits") { allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits")) diff --git a/docs/CLI.md b/docs/CLI.md index 59467dc..2bca4e0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -345,6 +345,8 @@ Deletes local & remote feature-branches for a closed pull request Create a pull-request +**--agit**: Create an agit flow pull request + **--allow-maintainer-edits, --edits**: Enable maintainers to push to the base branch of created pull **--assignees, -a**="": Comma-separated list of usernames to assign @@ -371,6 +373,8 @@ Create a pull-request **--title, -t**="": +**--topic**="": Topic name for agit flow pull request + ### close Change state of one or more pull requests to 'closed' diff --git a/modules/git/branch.go b/modules/git/branch.go index 2f2bc79..7963c2e 100644 --- a/modules/git/branch.go +++ b/modules/git/branch.go @@ -4,8 +4,10 @@ package git import ( + "encoding/base64" "fmt" "strings" + "unicode" "github.com/go-git/go-git/v5" git_config "github.com/go-git/go-git/v5/config" @@ -247,3 +249,47 @@ func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) { return localHead.Name().Short(), localHead.Hash().String(), nil } + +// PushToCreatAgitFlowPR pushes the given head to the refs/for// ref on the remote to create an agit flow PR. +func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, description string, auth git_transport.AuthMethod) error { + if !strings.HasPrefix(head, "refs/") { + head = "refs/heads/" + head + } + + ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic) + + pushOptions := make(map[string]string) + if len(title) > 0 { + pushOptions["title"] = b64Encode(title) + } + if len(description) > 0 { + pushOptions["description"] = b64Encode(description) + } + + opts := &git.PushOptions{ + RemoteName: remoteName, + RefSpecs: []git_config.RefSpec{git_config.RefSpec(ref)}, + Options: pushOptions, + Auth: auth, + } + + return r.Push(opts) +} + +// b64Encode implements base64 encode for string if necessary. +func b64Encode(s string) string { + if strings.Contains(s, "\n") || !isASCII(s) { + return "{base64}" + base64.StdEncoding.EncodeToString([]byte(s)) + } + return s +} + +// isASCII indicates string contains only ASCII. +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/modules/git/repo.go b/modules/git/repo.go index 8baec1f..938e4e6 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -4,6 +4,8 @@ package git import ( + "net/url" + "github.com/go-git/go-git/v5" ) @@ -33,3 +35,13 @@ func RepoFromPath(path string) (*TeaRepo, error) { return &TeaRepo{repo}, nil } + +// RemoteURL returns the URL of the given remote +func (r TeaRepo) RemoteURL(remoteName string) (*url.URL, error) { + remote, err := r.Remote(remoteName) + if err != nil { + return nil, err + } + + return url.Parse(remote.Config().URLs[0]) +} diff --git a/modules/interact/pull_create.go b/modules/interact/pull_create.go index 6233405..802d6e6 100644 --- a/modules/interact/pull_create.go +++ b/modules/interact/pull_create.go @@ -7,6 +7,7 @@ import ( "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/task" + "code.gitea.io/tea/modules/theme" "github.com/charmbracelet/huh" ) @@ -16,6 +17,8 @@ func CreatePull(ctx *context.TeaContext) (err error) { var ( base, head string allowMaintainerEdits = true + + agit bool ) // owner, repo @@ -37,6 +40,66 @@ func CreatePull(ctx *context.TeaContext) (err error) { } } + if err := huh.NewConfirm(). + Title("Do you want to create an agit flow pull request?"). + Value(&agit). + WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + + if agit { + var ( + topic string + baseRemote string + ) + + topic = headBranch + + head = "HEAD" + baseRemote = "origin" + + if err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Target branch:"). + Value(&base). + Validate(huh.ValidateNotEmpty()), + + huh.NewInput(). + Title("Source repo remote:"). + Value(&baseRemote), + + huh.NewInput(). + Title("Topic branch:"). + Value(&topic). + Validate(validator), + + huh.NewInput(). + Title("Head branch:"). + Value(&head). + Validate(validator), + ), + ).Run(); err != nil { + return err + } + + opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)} + if err = promptIssueProperties(ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil { + return err + } + + return task.CreateAgitFlowPull( + ctx, + baseRemote, + head, + base, + topic, + &opts, + PromptPassword, + ) + } + if err := huh.NewForm( huh.NewGroup( huh.NewInput(). diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index b07a28a..424854d 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -153,3 +153,67 @@ func GetDefaultPRTitle(header string) string { return title } + +// CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result +func CreateAgitFlowPull(ctx *context.TeaContext, remote, head, base, topic string, + opts *gitea.CreateIssueOption, + callback func(string) (string, error)) (err error) { + // default is default branch + if len(base) == 0 { + base, err = GetDefaultPRBase(ctx.Login, ctx.Owner, ctx.Repo) + if err != nil { + return err + } + } + + // default is current one + if len(head) == 0 { + if ctx.LocalRepo == nil { + return fmt.Errorf("no local git repo detected, please specify topic branch") + } + headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo) + if err != nil { + return err + } + + head = GetHeadSpec(headOwner, headBranch, ctx.Owner) + } + + if len(remote) == 0 { + return fmt.Errorf("remote is required for agit flow PR") + } + + if len(topic) == 0 { + topic = head + } + + if head == base || topic == base { + return fmt.Errorf("can't create PR from %s to %s", topic, base) + } + + // default is head branch name + if len(opts.Title) == 0 { + opts.Title = GetDefaultPRTitle(head) + } + // title is required + if len(opts.Title) == 0 { + return fmt.Errorf("title is required") + } + + localRepo, err := local_git.RepoForWorkdir() + if err != nil { + return err + } + + url, err := localRepo.RemoteURL(remote) + if err != nil { + return err + } + + auth, err := local_git.GetAuthForURL(url, ctx.Login.Token, ctx.Login.SSHKey, callback) + if err != nil { + return err + } + + return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth) +} From ae9eb4f2c02313c2933afadfa379737e55f163d3 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 3 Feb 2026 23:48:18 +0000 Subject: [PATCH 17/83] Add locking to ensure safe concurrent access to config file (#881) Reviewed-on: https://gitea.com/gitea/tea/pulls/881 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- cmd/login/oauth_refresh.go | 19 +++- go.mod | 2 +- go.sum | 12 -- modules/auth/oauth.go | 69 +++++++++--- modules/config/config.go | 29 ++++- modules/config/lock.go | 97 ++++++++++++++++ modules/config/lock_test.go | 182 +++++++++++++++++++++++++++++++ modules/config/lock_unix.go | 39 +++++++ modules/config/lock_unix_test.go | 82 ++++++++++++++ modules/config/lock_windows.go | 48 ++++++++ modules/config/login.go | 177 ++++++++++++++++-------------- modules/task/pull_create.go | 3 +- 12 files changed, 645 insertions(+), 114 deletions(-) create mode 100644 modules/config/lock.go create mode 100644 modules/config/lock_test.go create mode 100644 modules/config/lock_unix.go create mode 100644 modules/config/lock_unix_test.go create mode 100644 modules/config/lock_windows.go diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go index ec4dc27..4369cdb 100644 --- a/cmd/login/oauth_refresh.go +++ b/cmd/login/oauth_refresh.go @@ -17,7 +17,7 @@ import ( var CmdLoginOAuthRefresh = cli.Command{ Name: "oauth-refresh", Usage: "Refresh an OAuth token", - Description: "Manually refresh an expired OAuth token. Usually only used when troubleshooting authentication.", + Description: "Manually refresh an expired OAuth token. If the refresh token is also expired, opens a browser for re-authentication.", ArgsUsage: "[]", Action: runLoginOAuthRefresh, } @@ -48,12 +48,21 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) } - // Refresh the token + // Try to refresh the token err := auth.RefreshAccessToken(login) - if err != nil { - return fmt.Errorf("failed to refresh token: %s", err) + if err == nil { + fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) + return nil } - fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) + // Refresh failed - fall back to browser-based re-authentication + fmt.Printf("Token refresh failed: %s\n", err) + fmt.Println("Opening browser for re-authentication...") + + if err := auth.ReauthenticateLogin(login); err != nil { + return fmt.Errorf("re-authentication failed: %s", err) + } + + fmt.Printf("Successfully re-authenticated %s\n", loginName) return nil } diff --git a/go.mod b/go.mod index 08a18af..6d78822 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.34.0 + golang.org/x/sys v0.40.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -89,7 +90,6 @@ require ( github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index e015202..0fa89d6 100644 --- a/go.sum +++ b/go.sum @@ -71,14 +71,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/displaywidth v0.3.1 h1:k07iN9gD32177o1y4O1jQMzbLdCrsGJh+blirVYybsk= -github.com/clipperhouse/displaywidth v0.3.1/go.mod h1:tgLJKKyaDOCadywag3agw4snxS5kYEuYR6Y9+qWDDYM= github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= @@ -169,12 +165,8 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.2 h1:lkg/k/9mlsy0SxO5aC+WEpbdT5K83ddnNhAepz7TQc0= -github.com/olekukonko/ll v0.1.2/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= -github.com/olekukonko/tablewriter v1.1.1 h1:b3reP6GCfrHwmKkYwNRFh2rxidGHcT6cgxj/sHiDDx0= -github.com/olekukonko/tablewriter v1.1.1/go.mod h1:De/bIcTF+gpBDB3Alv3fEsZA+9unTsSzAg/ZGADCtn4= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -211,8 +203,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= -github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= -github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -244,8 +234,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 8584261..867a475 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -71,12 +71,24 @@ func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error { // OAuthLoginWithFullOptions performs an OAuth2 PKCE login flow with full options control func OAuthLoginWithFullOptions(opts OAuthOptions) error { - // Normalize URL - serverURL, err := utils.NormalizeURL(opts.URL) + serverURL, token, err := performBrowserOAuthFlow(opts) if err != nil { - return fmt.Errorf("unable to parse URL: %s", err) + return err } + return createLoginFromToken(opts.Name, serverURL, token, opts.Insecure) +} + +// performBrowserOAuthFlow performs the browser-based OAuth2 PKCE flow and returns the token. +// This is the shared implementation used by both new logins and re-authentication. +func performBrowserOAuthFlow(opts OAuthOptions) (serverURL string, token *oauth2.Token, err error) { + // Normalize URL + normalizedURL, err := utils.NormalizeURL(opts.URL) + if err != nil { + return "", nil, fmt.Errorf("unable to parse URL: %s", err) + } + serverURL = normalizedURL.String() + // Set defaults if needed if opts.ClientID == "" { opts.ClientID = config.DefaultClientID @@ -107,7 +119,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { // Generate code verifier (random string) codeVerifier, err := generateCodeVerifier(codeVerifierLength) if err != nil { - return fmt.Errorf("failed to generate code verifier: %s", err) + return "", nil, fmt.Errorf("failed to generate code verifier: %s", err) } // Generate code challenge (SHA256 hash of code verifier) @@ -118,8 +130,8 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(opts.Insecure)) // Configure the OAuth2 endpoints - authURL := fmt.Sprintf("%s/login/oauth/authorize", serverURL) - tokenURL := fmt.Sprintf("%s/login/oauth/access_token", serverURL) + authURL := fmt.Sprintf("%s/login/oauth/authorize", normalizedURL) + tokenURL := fmt.Sprintf("%s/login/oauth/access_token", normalizedURL) oauth2Config := &oauth2.Config{ ClientID: opts.ClientID, @@ -141,7 +153,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { // Generate state parameter to protect against CSRF state, err := generateCodeVerifier(32) if err != nil { - return fmt.Errorf("failed to generate state: %s", err) + return "", nil, fmt.Errorf("failed to generate state: %s", err) } // Get the authorization URL @@ -156,7 +168,7 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { strings.Contains(err.Error(), "redirect") { fmt.Println("\nError: Redirect URL not registered in Gitea") fmt.Println("\nTo fix this, you need to register the redirect URL in Gitea:") - fmt.Printf("1. Go to your Gitea instance: %s\n", serverURL) + fmt.Printf("1. Go to your Gitea instance: %s\n", normalizedURL) fmt.Println("2. Sign in and go to Settings > Applications") fmt.Println("3. Register a new OAuth2 application with:") fmt.Printf(" - Application Name: tea-cli (or any name)\n") @@ -165,22 +177,21 @@ func OAuthLoginWithFullOptions(opts OAuthOptions) error { fmt.Printf(" tea login add --oauth --client-id YOUR_CLIENT_ID --redirect-url %s\n", opts.RedirectURL) fmt.Println("\nAlternatively, you can use a token-based login: tea login add") } - return fmt.Errorf("authorization failed: %s", err) + return "", nil, fmt.Errorf("authorization failed: %s", err) } // Verify state to prevent CSRF attacks if state != receivedState { - return fmt.Errorf("state mismatch, possible CSRF attack") + return "", nil, fmt.Errorf("state mismatch, possible CSRF attack") } // Exchange authorization code for token - token, err := oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + token, err = oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) if err != nil { - return fmt.Errorf("token exchange failed: %s", err) + return "", nil, fmt.Errorf("token exchange failed: %s", err) } - // Create login with token data - return createLoginFromToken(opts.Name, serverURL.String(), token, opts.Insecure) + return serverURL, token, nil } // createHTTPClient creates an HTTP client with optional insecure setting @@ -417,3 +428,33 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure func RefreshAccessToken(login *config.Login) error { return login.RefreshOAuthToken() } + +// ReauthenticateLogin performs a full browser-based OAuth flow to get new tokens +// for an existing login. This is used when the refresh token is expired or invalid. +func ReauthenticateLogin(login *config.Login) error { + opts := OAuthOptions{ + Name: login.Name, + URL: login.URL, + Insecure: login.Insecure, + ClientID: config.DefaultClientID, + RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort), + Port: redirectPort, + } + + _, token, err := performBrowserOAuthFlow(opts) + if err != nil { + return err + } + + // Update the existing login with new token data + login.Token = token.AccessToken + if token.RefreshToken != "" { + login.RefreshToken = token.RefreshToken + } + if !token.Expiry.IsZero() { + login.TokenExpiry = token.Expiry.Unix() + } + + // Save updated login + return config.SaveLoginTokens(login) +} diff --git a/modules/config/config.go b/modules/config/config.go index 6402697..f958b57 100644 --- a/modules/config/config.go +++ b/modules/config/config.go @@ -98,8 +98,33 @@ func loadConfig() (err error) { return } -// saveConfig save config to file -func saveConfig() error { +// reloadConfigFromDisk re-reads the config file from disk, bypassing the sync.Once. +// This is used after acquiring a lock to ensure we have the latest config state. +// The caller must hold the config lock. +func reloadConfigFromDisk() error { + ymlPath := GetConfigPath() + exist, _ := utils.FileExist(ymlPath) + if !exist { + // No config file yet, start with empty config + config = LocalConfig{} + return nil + } + + bs, err := os.ReadFile(ymlPath) + if err != nil { + return fmt.Errorf("failed to read config file %s: %w", ymlPath, err) + } + + if err := yaml.Unmarshal(bs, &config); err != nil { + return fmt.Errorf("failed to parse config file %s: %w", ymlPath, err) + } + + return nil +} + +// saveConfigUnsafe saves config to file without acquiring a lock. +// Caller must hold the config lock. +func saveConfigUnsafe() error { ymlPath := GetConfigPath() bs, err := yaml.Marshal(config) if err != nil { diff --git a/modules/config/lock.go b/modules/config/lock.go new file mode 100644 index 0000000..e8160cc --- /dev/null +++ b/modules/config/lock.go @@ -0,0 +1,97 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "os" + "sync" + "time" +) + +const ( + // LockTimeout is the default timeout for acquiring the config file lock. + LockTimeout = 5 * time.Second + + // mutexPollInterval is how often to retry acquiring the in-process mutex. + mutexPollInterval = 10 * time.Millisecond + + // fileLockPollInterval is how often to retry acquiring the file lock. + fileLockPollInterval = 50 * time.Millisecond +) + +// configMutex protects in-process concurrent access to the config. +var configMutex sync.Mutex + +// acquireConfigLock acquires both the in-process mutex and a file lock. +// Returns an unlock function that must be called to release both locks. +// The timeout applies to acquiring the file lock; the mutex acquisition +// uses the same timeout via a TryLock loop. +func acquireConfigLock(lockPath string, timeout time.Duration) (unlock func() error, err error) { + // Try to acquire mutex with timeout + deadline := time.Now().Add(timeout) + for { + if configMutex.TryLock() { + break + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timeout waiting for config mutex") + } + time.Sleep(mutexPollInterval) + } + + // Mutex acquired, now try file lock + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + configMutex.Unlock() + return nil, fmt.Errorf("failed to open lock file: %w", err) + } + + // Try to acquire file lock with remaining timeout + remaining := max(time.Until(deadline), 0) + + if err := lockFile(file, remaining); err != nil { + file.Close() + configMutex.Unlock() + return nil, fmt.Errorf("failed to acquire file lock: %w", err) + } + + // Return unlock function + return func() error { + unlockErr := unlockFile(file) + closeErr := file.Close() + configMutex.Unlock() + if unlockErr != nil { + return unlockErr + } + return closeErr + }, nil +} + +// getConfigLockPath returns the path to the lock file for the config. +func getConfigLockPath() string { + return GetConfigPath() + ".lock" +} + +// withConfigLock executes the given function while holding the config lock. +// It acquires the lock, reloads the config from disk, executes fn, and releases the lock. +func withConfigLock(fn func() error) (retErr error) { + lockPath := getConfigLockPath() + unlock, err := acquireConfigLock(lockPath, LockTimeout) + if err != nil { + return fmt.Errorf("failed to acquire config lock: %w", err) + } + defer func() { + if unlockErr := unlock(); unlockErr != nil && retErr == nil { + retErr = fmt.Errorf("failed to release config lock: %w", unlockErr) + } + }() + + // Reload config from disk to get latest state + if err := reloadConfigFromDisk(); err != nil { + return err + } + + return fn() +} diff --git a/modules/config/lock_test.go b/modules/config/lock_test.go new file mode 100644 index 0000000..28e9323 --- /dev/null +++ b/modules/config/lock_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestConfigLock_BasicLockUnlock(t *testing.T) { + // Create a temp directory for test + tmpDir, err := os.MkdirTemp("", "tea-lock-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + lockPath := filepath.Join(tmpDir, "config.yml.lock") + + // Should be able to acquire lock + unlock, err := acquireConfigLock(lockPath, 5*time.Second) + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + + // Should be able to release lock + err = unlock() + if err != nil { + t.Fatalf("failed to release lock: %v", err) + } +} + +func TestConfigLock_MutexProtection(t *testing.T) { + // Create a temp directory for test + tmpDir, err := os.MkdirTemp("", "tea-lock-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + lockPath := filepath.Join(tmpDir, "config.yml.lock") + + // Acquire lock + unlock, err := acquireConfigLock(lockPath, 5*time.Second) + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + + // Try to acquire again from same process - should block/timeout due to mutex + done := make(chan bool) + go func() { + _, err := acquireConfigLock(lockPath, 100*time.Millisecond) + done <- (err != nil) // Should timeout/fail + }() + + select { + case failed := <-done: + if !failed { + t.Error("second lock acquisition should have failed due to mutex") + } + case <-time.After(2 * time.Second): + t.Error("test timed out") + } + + if err := unlock(); err != nil { + t.Errorf("failed to unlock: %v", err) + } +} + +func TestReloadConfigFromDisk(t *testing.T) { + // Save original config state + originalConfig := config + + // Create a temp config file + tmpDir, err := os.MkdirTemp("", "tea-reload-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // We can't easily change GetConfigPath, so we test that reloadConfigFromDisk + // handles a missing file gracefully (returns nil and resets config) + config = LocalConfig{Logins: []Login{{Name: "test"}}} + + // Call reload - since the actual config path likely exists or doesn't, + // we just verify it doesn't panic and returns without error or with expected error + err = reloadConfigFromDisk() + // The function should either succeed or return an error, not panic + if err != nil { + // This is acceptable - config file might not exist in test environment + t.Logf("reloadConfigFromDisk returned error (expected in test env): %v", err) + } + + // Restore original config + config = originalConfig +} + +func TestWithConfigLock(t *testing.T) { + executed := false + err := withConfigLock(func() error { + executed = true + return nil + }) + if err != nil { + t.Errorf("withConfigLock returned error: %v", err) + } + if !executed { + t.Error("function was not executed") + } +} + +func TestWithConfigLock_PropagatesError(t *testing.T) { + expectedErr := fmt.Errorf("test error") + err := withConfigLock(func() error { + return expectedErr + }) + + if err != expectedErr { + t.Errorf("expected error %v, got %v", expectedErr, err) + } +} + +func TestDoubleCheckedLocking_SimulatedRefresh(t *testing.T) { + // This test simulates the double-checked locking pattern + // by having multiple goroutines try to "refresh" simultaneously + + var ( + refreshCount int + mu sync.Mutex + ) + + // Simulate what RefreshOAuthToken does with double-check + simulatedRefresh := func(tokenExpiry *int64) error { + // First check (without lock) + if *tokenExpiry > time.Now().Unix() { + return nil // Token still valid + } + + return withConfigLock(func() error { + // Double-check after acquiring lock + if *tokenExpiry > time.Now().Unix() { + return nil // Another goroutine refreshed it + } + + // Simulate refresh + mu.Lock() + refreshCount++ + mu.Unlock() + + time.Sleep(50 * time.Millisecond) // Simulate API call + *tokenExpiry = time.Now().Add(1 * time.Hour).Unix() + return nil + }) + } + + // Start with expired token + tokenExpiry := time.Now().Add(-1 * time.Hour).Unix() + + // Launch multiple goroutines trying to refresh + var wg sync.WaitGroup + for range 5 { + wg.Add(1) + go func() { + defer wg.Done() + if err := simulatedRefresh(&tokenExpiry); err != nil { + t.Errorf("refresh failed: %v", err) + } + }() + } + + wg.Wait() + + // Should only have refreshed once due to double-checked locking + if refreshCount != 1 { + t.Errorf("expected 1 refresh, got %d", refreshCount) + } +} diff --git a/modules/config/lock_unix.go b/modules/config/lock_unix.go new file mode 100644 index 0000000..cb64859 --- /dev/null +++ b/modules/config/lock_unix.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build unix + +package config + +import ( + "fmt" + "os" + "syscall" + "time" +) + +// lockFile acquires an exclusive lock on the file using flock. +// It polls with non-blocking flock until timeout. +func lockFile(file *os.File, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for { + err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return nil + } + if err != syscall.EWOULDBLOCK { + return fmt.Errorf("flock failed: %w", err) + } + + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for file lock") + } + time.Sleep(fileLockPollInterval) + } +} + +// unlockFile releases the lock on the file. +func unlockFile(file *os.File) error { + return syscall.Flock(int(file.Fd()), syscall.LOCK_UN) +} diff --git a/modules/config/lock_unix_test.go b/modules/config/lock_unix_test.go new file mode 100644 index 0000000..f8ab2f8 --- /dev/null +++ b/modules/config/lock_unix_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build unix + +package config + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestConfigLock_CrossProcess(t *testing.T) { + // Create a temp directory for test + tmpDir, err := os.MkdirTemp("", "tea-lock-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + lockPath := filepath.Join(tmpDir, "config.yml.lock") + + // Acquire lock in main process + unlock, err := acquireConfigLock(lockPath, 5*time.Second) + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + defer unlock() + + // Spawn a subprocess that tries to acquire the same lock + // The subprocess should fail to acquire within timeout + script := fmt.Sprintf(` +package main + +import ( + "os" + "syscall" +) + +func main() { + file, err := os.OpenFile(%q, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + os.Exit(2) + } + defer file.Close() + + // Try non-blocking lock + err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + // Lock is held - expected behavior + os.Exit(0) + } + // Lock was acquired - unexpected + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + os.Exit(1) +} +`, lockPath) + + // Write and run the test script + scriptPath := filepath.Join(tmpDir, "locktest.go") + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + t.Fatalf("failed to write test script: %v", err) + } + + cmd := exec.Command("go", "run", scriptPath) + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 1 { + t.Error("subprocess acquired lock when it should have been held") + } else if exitErr.ExitCode() == 2 { + t.Errorf("subprocess failed to open lock file: %v", err) + } + } else { + t.Errorf("subprocess execution failed: %v", err) + } + } + // Exit code 0 means lock was properly held - success +} diff --git a/modules/config/lock_windows.go b/modules/config/lock_windows.go new file mode 100644 index 0000000..acf7387 --- /dev/null +++ b/modules/config/lock_windows.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +package config + +import ( + "fmt" + "os" + "time" + + "golang.org/x/sys/windows" +) + +// lockFile acquires an exclusive lock on the file using LockFileEx. +// It polls with non-blocking LockFileEx until timeout. +func lockFile(file *os.File, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + handle := windows.Handle(file.Fd()) + + // LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY + const flags = windows.LOCKFILE_EXCLUSIVE_LOCK | windows.LOCKFILE_FAIL_IMMEDIATELY + + for { + // Lock the first byte (advisory lock) + var overlapped windows.Overlapped + err := windows.LockFileEx(handle, flags, 0, 1, 0, &overlapped) + if err == nil { + return nil + } + if err != windows.ERROR_LOCK_VIOLATION { + return fmt.Errorf("LockFileEx failed: %w", err) + } + + if time.Now().After(deadline) { + return fmt.Errorf("timeout waiting for file lock") + } + time.Sleep(fileLockPollInterval) + } +} + +// unlockFile releases the lock on the file. +func unlockFile(file *os.File) error { + handle := windows.Handle(file.Fd()) + var overlapped windows.Overlapped + return windows.UnlockFileEx(handle, 0, 1, 0, &overlapped) +} diff --git a/modules/config/login.go b/modules/config/login.go index db8522f..dd5af07 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -84,24 +84,22 @@ func GetDefaultLogin() (*Login, error) { // SetDefaultLogin set the default login by name (case insensitive) func SetDefaultLogin(name string) error { - if err := loadConfig(); err != nil { - return err - } - - loginExist := false - for i := range config.Logins { - config.Logins[i].Default = false - if strings.ToLower(config.Logins[i].Name) == strings.ToLower(name) { - config.Logins[i].Default = true - loginExist = true + return withConfigLock(func() error { + loginExist := false + for i := range config.Logins { + config.Logins[i].Default = false + if strings.EqualFold(config.Logins[i].Name, name) { + config.Logins[i].Default = true + loginExist = true + } } - } - if !loginExist { - return fmt.Errorf("login '%s' not found", name) - } + if !loginExist { + return fmt.Errorf("login '%s' not found", name) + } - return saveConfig() + return saveConfigUnsafe() + }) } // GetLoginByName get login by name (case insensitive) @@ -112,7 +110,7 @@ func GetLoginByName(name string) *Login { } for _, l := range config.Logins { - if strings.ToLower(l.Name) == strings.ToLower(name) { + if strings.EqualFold(l.Name, name) { return &l } } @@ -165,64 +163,56 @@ func GetLoginsByHost(host string) []*Login { // DeleteLogin delete a login by name from config func DeleteLogin(name string) error { - idx := -1 - for i, l := range config.Logins { - if l.Name == name { - idx = i - break + return withConfigLock(func() error { + idx := -1 + for i, l := range config.Logins { + if strings.EqualFold(l.Name, name) { + idx = i + break + } + } + if idx == -1 { + return fmt.Errorf("can not delete login '%s', does not exist", name) } - } - if idx == -1 { - return fmt.Errorf("can not delete login '%s', does not exist", name) - } - config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...) + config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...) - return saveConfig() + return saveConfigUnsafe() + }) } // AddLogin save a login to config func AddLogin(login *Login) error { - if err := loadConfig(); err != nil { - return err - } - - // Check for duplicate login names - for _, existing := range config.Logins { - if strings.EqualFold(existing.Name, login.Name) { - return fmt.Errorf("login name '%s' already exists", login.Name) + return withConfigLock(func() error { + // Check for duplicate login names + for _, existing := range config.Logins { + if strings.EqualFold(existing.Name, login.Name) { + return fmt.Errorf("login name '%s' already exists", login.Name) + } } - } - // save login to global var - config.Logins = append(config.Logins, *login) + // save login to global var + config.Logins = append(config.Logins, *login) - // save login to config file - return saveConfig() + // save login to config file + return saveConfigUnsafe() + }) } -// UpdateLogin updates an existing login in the config -func UpdateLogin(login *Login) error { - if err := loadConfig(); err != nil { - return err - } - - // Find and update the login - found := false - for i, l := range config.Logins { - if l.Name == login.Name { - config.Logins[i] = *login - found = true - break +// SaveLoginTokens updates the token fields for an existing login. +// This is used after browser-based re-authentication to save new tokens. +func SaveLoginTokens(login *Login) error { + return withConfigLock(func() error { + for i, l := range config.Logins { + if strings.EqualFold(l.Name, login.Name) { + config.Logins[i].Token = login.Token + config.Logins[i].RefreshToken = login.RefreshToken + config.Logins[i].TokenExpiry = login.TokenExpiry + return saveConfigUnsafe() + } } - } - - if !found { return fmt.Errorf("login %s not found", login.Name) - } - - // Save updated config - return saveConfig() + }) } // RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry. @@ -240,22 +230,65 @@ func (l *Login) RefreshOAuthTokenIfNeeded() error { // RefreshOAuthToken refreshes the OAuth access token using the refresh token. // It updates the login with new token information and saves it to config. +// Uses double-checked locking to avoid unnecessary refresh calls when multiple +// processes race to refresh the same token. func (l *Login) RefreshOAuthToken() error { if l.RefreshToken == "" { return fmt.Errorf("no refresh token available") } - // Create a Token object with current values + return withConfigLock(func() error { + // Double-check: after acquiring lock, re-read config and check if + // another process already refreshed the token + for i, login := range config.Logins { + if login.Name == l.Name { + // Check if token was refreshed by another process + if login.TokenExpiry != l.TokenExpiry && login.TokenExpiry > 0 { + expiryTime := time.Unix(login.TokenExpiry, 0) + if time.Now().Add(TokenRefreshThreshold).Before(expiryTime) { + // Token was refreshed by another process, update our copy + l.Token = login.Token + l.RefreshToken = login.RefreshToken + l.TokenExpiry = login.TokenExpiry + return nil + } + } + + // Still need to refresh - proceed with OAuth call + newToken, err := doOAuthRefresh(l) + if err != nil { + return err + } + + // Update login with new token information + l.Token = newToken.AccessToken + if newToken.RefreshToken != "" { + l.RefreshToken = newToken.RefreshToken + } + if !newToken.Expiry.IsZero() { + l.TokenExpiry = newToken.Expiry.Unix() + } + + // Update in config slice and save + config.Logins[i] = *l + return saveConfigUnsafe() + } + } + + return fmt.Errorf("login %s not found", l.Name) + }) +} + +// doOAuthRefresh performs the actual OAuth token refresh API call. +func doOAuthRefresh(l *Login) (*oauth2.Token, error) { currentToken := &oauth2.Token{ AccessToken: l.Token, RefreshToken: l.RefreshToken, Expiry: time.Unix(l.TokenExpiry, 0), } - // Set up the OAuth2 config ctx := context.Background() - // Create HTTP client, respecting the login's TLS settings httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure}, @@ -263,7 +296,6 @@ func (l *Login) RefreshOAuthToken() error { } ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - // Configure the OAuth2 endpoints oauth2Config := &oauth2.Config{ ClientID: DefaultClientID, Endpoint: oauth2.Endpoint{ @@ -271,25 +303,12 @@ func (l *Login) RefreshOAuthToken() error { }, } - // Refresh the token newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token() if err != nil { - return fmt.Errorf("failed to refresh token: %w", err) + return nil, fmt.Errorf("failed to refresh token: %w", err) } - // Update login with new token information - l.Token = newToken.AccessToken - - if newToken.RefreshToken != "" { - l.RefreshToken = newToken.RefreshToken - } - - if !newToken.Expiry.IsZero() { - l.TokenExpiry = newToken.Expiry.Unix() - } - - // Save updated login to config - return UpdateLogin(l) + return newToken, nil } // Client returns a client to operate Gitea API. You may provide additional modifiers diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index 424854d..33e2f5a 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -157,7 +157,8 @@ func GetDefaultPRTitle(header string) string { // CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result func CreateAgitFlowPull(ctx *context.TeaContext, remote, head, base, topic string, opts *gitea.CreateIssueOption, - callback func(string) (string, error)) (err error) { + callback func(string) (string, error), +) (err error) { // default is default branch if len(base) == 0 { base, err = GetDefaultPRBase(ctx.Login, ctx.Owner, ctx.Repo) From 233ffe45081e49c2b6fb2d7528a877a3cc228653 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 4 Feb 2026 00:15:53 +0000 Subject: [PATCH 18/83] chore(deps): update actions/setup-go action to v6 (#883) Reviewed-on: https://gitea.com/gitea/tea/pulls/883 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/release-tag.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 8254c56..f274a5f 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -13,7 +13,7 @@ jobs: with: fetch-depth: 0 - run: git fetch --force --tags - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: import gpg From e5342660fa3c6872dd54a4bb3a35643b76072b41 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 4 Feb 2026 00:16:06 +0000 Subject: [PATCH 19/83] chore(deps): update actions/checkout action to v6 (#882) Reviewed-on: https://gitea.com/gitea/tea/pulls/882 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/release-tag.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index f274a5f..70e80da 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -9,7 +9,7 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - run: git fetch --force --tags @@ -46,7 +46,7 @@ jobs: DOCKER_LATEST: nightly steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 # all history for all branches and tags From 037d1aad234532cdf92eac3facd9a09a7ea82f6c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 4 Feb 2026 00:56:04 +0000 Subject: [PATCH 20/83] fix(deps): update module github.com/charmbracelet/lipgloss to v2 (#885) Reviewed-on: https://gitea.com/gitea/tea/pulls/885 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 3 ++- go.sum | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6d78822..4f2f422 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 github.com/enescakir/emoji v1.0.0 github.com/go-git/go-git/v5 v5.16.4 github.com/muesli/termenv v0.16.0 @@ -38,7 +39,7 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect diff --git a/go.sum b/go.sum index 0fa89d6..7fc2f02 100644 --- a/go.sum +++ b/go.sum @@ -45,12 +45,15 @@ github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGs github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= From a47ac265d24bd52141ac7e6d9fbd948c245598f4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 4 Feb 2026 00:56:27 +0000 Subject: [PATCH 21/83] chore(deps): update mcr.microsoft.com/devcontainers/go docker tag to v2 (#884) Reviewed-on: https://gitea.com/gitea/tea/pulls/884 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7ca40f7..cf82368 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Tea DevContainer", - "image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", + "image": "mcr.microsoft.com/devcontainers/go:2.0-trixie", "features": { "ghcr.io/devcontainers/features/git-lfs:1.2.5": {} }, From 29488a1f46affa4b17adc1548e8bb8c3dcfebbfe Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Wed, 4 Feb 2026 19:27:25 +0000 Subject: [PATCH 22/83] build w/ go1.25 (#886) Reviewed-on: https://gitea.com/gitea/tea/pulls/886 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- .gitea/workflows/release-nightly.yml | 4 ++ .gitea/workflows/release-tag.yml | 4 ++ .goreleaser.yaml | 2 +- go.mod | 64 ++++++++++---------- go.sum | 88 +++++++++++++++++++++------- 5 files changed, 108 insertions(+), 54 deletions(-) diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 6d9178c..ad63939 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -21,6 +21,9 @@ jobs: with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} + - name: get SDK version + id: sdk_version + run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT" - name: goreleaser uses: goreleaser/goreleaser-action@v6 with: @@ -28,6 +31,7 @@ jobs: version: "~> v1" args: release --nightly env: + SDK_VERSION: ${{ steps.sdk_version.outputs.version }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 70e80da..6c713c3 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -22,6 +22,9 @@ jobs: with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} + - name: get SDK version + id: sdk_version + run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT" - name: goreleaser uses: goreleaser/goreleaser-action@v6 with: @@ -29,6 +32,7 @@ jobs: version: "~> v1" args: release env: + SDK_VERSION: ${{ steps.sdk_version.outputs.version }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} AWS_REGION: ${{ secrets.AWS_REGION }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index faafdb9..3b62713 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -58,7 +58,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X code.gitea.io/tea/cmd.Version={{ .Version }} + - -s -w -X "code.gitea.io/tea/cmd.Version={{ trimprefix .Summary "v" }}" -X "code.gitea.io/tea/cmd.Tags=" -X "code.gitea.io/tea/cmd.SDK={{ .Env.SDK_VERSION }}" binary: >- {{ .ProjectName }}- {{- .Version }}- diff --git a/go.mod b/go.mod index 4f2f422..22b29e9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module code.gitea.io/tea -go 1.24.4 +go 1.25 require ( code.gitea.io/gitea-vet v0.2.3 @@ -10,8 +10,7 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 + github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2 github.com/enescakir/emoji v1.0.0 github.com/go-git/go-git/v5 v5.16.4 github.com/muesli/termenv v0.16.0 @@ -28,45 +27,46 @@ require ( ) require ( - dario.cat/mergo v1.0.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.1.6 // indirect - github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/clipperhouse/displaywidth v0.6.2 // indirect + github.com/charmbracelet/bubbles v0.21.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee // indirect + github.com/charmbracelet/x/exp/strings v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.1 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.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.6.2 // indirect + github.com/go-git/go-billy/v5 v5.7.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -77,22 +77,22 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect - github.com/olekukonko/errors v1.1.0 // indirect - github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.4 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.8 // indirect - github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/net v0.48.0 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/tools v0.41.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 7fc2f02..c25fa08 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= @@ -15,14 +17,20 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +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/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= @@ -41,53 +49,64 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= -github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2 h1:jvxZhg+J/80xXR7cE07p0/aFE1BrxkUw0R2CH04CZOM= +github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2/go.mod h1:D4YudnJlpIa3bcKpFSigAEWd31pQMgYu3pFE94b/1mc= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250609102027-b60490452b30 h1:lF42GCGfbMxx4SOYkjChVoUDexdM/hQ4DWnAHcJ/6K0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250609102027-b60490452b30/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee h1:B/JPEPNGIHyyhCPM483B+cfJQ1+9S2YBPWoTAJw3Ut0= +github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= -github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +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/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -95,6 +114,8 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -115,6 +136,8 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +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= @@ -133,6 +156,10 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +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/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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= @@ -140,8 +167,8 @@ 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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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= @@ -168,14 +195,20 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/ll v0.1.4 h1:QcDaO9quz213xqHZr0gElOcYeOSnFeq7HTQ9Wu4O1wE= +github.com/olekukonko/ll v0.1.4/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -193,9 +226,13 @@ github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597 h1:nZY1S2jo+VtDr github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597/go.mod h1:F8CBHSOjnzjx9EeXyWJTAzJyVxN+Y8JH2WjLMn4utiw= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +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/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -216,8 +253,12 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -230,6 +271,7 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbR golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -237,6 +279,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -268,6 +312,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 982adb4d022903b79d268890c22f6cce280b94dc Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Wed, 4 Feb 2026 19:37:39 +0000 Subject: [PATCH 23/83] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 0c6acc9..1cf66a2 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,6 @@ ABOUT More info about Gitea itself on https://about.gitea.com. ``` -- [Compare features with other git forge CLIs](./FEATURE-COMPARISON.md) - tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API. ## Installation From 49a9032d8a202c1f2e663b159459a3bbefa54942 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Thu, 5 Feb 2026 18:05:43 +0000 Subject: [PATCH 24/83] Move versions/filelocker into dedicated subpackages, and consistent headers in http requests (#888) - move filelocker logic into dedicated subpackage - consistent useragent in requests Reviewed-on: https://gitea.com/gitea/tea/pulls/888 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- .goreleaser.yaml | 2 +- Makefile | 2 +- cmd/cmd.go | 38 +------- go.mod | 1 - go.sum | 54 +---------- modules/api/client.go | 5 +- modules/auth/oauth.go | 13 +-- modules/config/lock.go | 30 ++---- modules/config/login.go | 7 +- modules/filelock/filelock.go | 70 ++++++++++++++ modules/filelock/filelock_test.go | 92 +++++++++++++++++++ .../filelock_unix.go} | 4 +- .../filelock_windows.go} | 4 +- modules/httputil/httputil.go | 38 ++++++++ modules/version/version.go | 44 +++++++++ 15 files changed, 277 insertions(+), 127 deletions(-) create mode 100644 modules/filelock/filelock.go create mode 100644 modules/filelock/filelock_test.go rename modules/{config/lock_unix.go => filelock/filelock_unix.go} (93%) rename modules/{config/lock_windows.go => filelock/filelock_windows.go} (95%) create mode 100644 modules/httputil/httputil.go create mode 100644 modules/version/version.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3b62713..43c490a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -58,7 +58,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X "code.gitea.io/tea/cmd.Version={{ trimprefix .Summary "v" }}" -X "code.gitea.io/tea/cmd.Tags=" -X "code.gitea.io/tea/cmd.SDK={{ .Env.SDK_VERSION }}" + - -s -w -X "code.gitea.io/tea/modules/version.Version={{ trimprefix .Summary "v" }}" -X "code.gitea.io/tea/modules/version.Tags=" -X "code.gitea.io/tea/modules/version.SDK={{ .Env.SDK_VERSION }}" binary: >- {{ .ProjectName }}- {{- .Version }}- diff --git a/Makefile b/Makefile index ae0038b..1d15dac 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ TEA_VERSION_TAG ?= $(shell sed 's/+/_/' <<< $(TEA_VERSION)) TAGS ?= SDK ?= $(shell $(GO) list -f '{{.Version}}' -m code.gitea.io/sdk/gitea) -LDFLAGS := -X "code.gitea.io/tea/cmd.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/cmd.Tags=$(TAGS)" -X "code.gitea.io/tea/cmd.SDK=$(SDK)" -s -w +LDFLAGS := -X "code.gitea.io/tea/modules/version.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/modules/version.Tags=$(TAGS)" -X "code.gitea.io/tea/modules/version.SDK=$(SDK)" -s -w # override to allow passing additional goflags via make CLI override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' diff --git a/cmd/cmd.go b/cmd/cmd.go index 2b9e852..243f2b9 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -6,23 +6,11 @@ package cmd // import "code.gitea.io/tea" import ( "fmt" - "runtime" - "strings" + "code.gitea.io/tea/modules/version" "github.com/urfave/cli/v3" ) -// Version holds the current tea version -// If the Version is moved to another package or name changed, -// build flags in .goreleaser.yaml or Makefile need to be updated accordingly. -var Version = "development" - -// Tags holds the build tags used -var Tags = "" - -// SDK holds the sdk version from go.mod -var SDK = "" - // App creates and returns a tea Command with all subcommands set // it was separated from main so docs can be generated for it func App() *cli.Command { @@ -34,7 +22,7 @@ func App() *cli.Command { Usage: "command line tool to interact with Gitea", Description: appDescription, CustomHelpTemplate: helpTemplate, - Version: formatVersion(), + Version: version.Format(), Commands: []*cli.Command{ &CmdLogin, &CmdLogout, @@ -66,22 +54,6 @@ func App() *cli.Command { } } -func formatVersion() string { - version := fmt.Sprintf("Version: %s\tgolang: %s", - bold(Version), - strings.ReplaceAll(runtime.Version(), "go", "")) - - if len(Tags) != 0 { - version += fmt.Sprintf("\tbuilt with: %s", strings.Replace(Tags, " ", ", ", -1)) - } - - if len(SDK) != 0 { - version += fmt.Sprintf("\tgo-sdk: %s", SDK) - } - - return version -} - var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on one or multiple Gitea instances & provides local helpers like 'tea pr checkout'. @@ -91,7 +63,7 @@ upstream repo. tea assumes that local git state is published on the remote befor doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. ` -var helpTemplate = bold(` +var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", ` {{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + ` {{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}} @@ -133,7 +105,3 @@ var helpTemplate = bold(` If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea. More info about Gitea itself on https://about.gitea.com. ` - -func bold(t string) string { - return fmt.Sprintf("\033[1m%s\033[0m", t) -} diff --git a/go.mod b/go.mod index 22b29e9..65d0b68 100644 --- a/go.mod +++ b/go.mod @@ -90,7 +90,6 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.41.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index c25fa08..501abce 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= @@ -15,22 +13,16 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6 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.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 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/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= @@ -47,12 +39,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= @@ -73,12 +61,8 @@ github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9 github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250609102027-b60490452b30 h1:lF42GCGfbMxx4SOYkjChVoUDexdM/hQ4DWnAHcJ/6K0= github.com/charmbracelet/x/exp/golden v0.0.0-20250609102027-b60490452b30/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= -github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee h1:B/JPEPNGIHyyhCPM483B+cfJQ1+9S2YBPWoTAJw3Ut0= github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -93,18 +77,12 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 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/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 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= @@ -112,8 +90,6 @@ 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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -134,8 +110,6 @@ 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.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 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= @@ -154,8 +128,6 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 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/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -193,20 +165,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= -github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= -github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM= -github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/ll v0.1.4 h1:QcDaO9quz213xqHZr0gElOcYeOSnFeq7HTQ9Wu4O1wE= github.com/olekukonko/ll v0.1.4/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= -github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 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/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -224,13 +190,9 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597 h1:nZY1S2jo+VtDrUfjO9XYI137O41hhRkxZNV5Fb5ixCA= github.com/seletskiy/tplutil v0.0.0-20200921103632-f880f6245597/go.mod h1:F8CBHSOjnzjx9EeXyWJTAzJyVxN+Y8JH2WjLMn4utiw= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 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/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= @@ -250,13 +212,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -269,16 +226,13 @@ golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= @@ -310,8 +264,6 @@ 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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/modules/api/client.go b/modules/api/client.go index 4c00546..80e2d46 100644 --- a/modules/api/client.go +++ b/modules/api/client.go @@ -13,6 +13,7 @@ import ( "strings" "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/httputil" ) // Client provides direct HTTP access to Gitea API @@ -30,9 +31,9 @@ func NewClient(login *config.Login) *Client { } httpClient := &http.Client{ - Transport: &http.Transport{ + Transport: httputil.WrapTransport(&http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure}, - }, + }), } return &Client{ diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 867a475..2861da6 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -18,6 +18,7 @@ import ( "time" "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/httputil" "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/utils" @@ -196,15 +197,11 @@ func performBrowserOAuthFlow(opts OAuthOptions) (serverURL string, token *oauth2 // createHTTPClient creates an HTTP client with optional insecure setting func createHTTPClient(insecure bool) *http.Client { - client := &http.Client{} - if insecure { - client = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } + return &http.Client{ + Transport: httputil.WrapTransport(&http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + }), } - return client } // generateCodeVerifier creates a cryptographically random string for PKCE diff --git a/modules/config/lock.go b/modules/config/lock.go index e8160cc..7a5a032 100644 --- a/modules/config/lock.go +++ b/modules/config/lock.go @@ -5,9 +5,10 @@ package config import ( "fmt" - "os" "sync" "time" + + "code.gitea.io/tea/modules/filelock" ) const ( @@ -16,9 +17,6 @@ const ( // mutexPollInterval is how often to retry acquiring the in-process mutex. mutexPollInterval = 10 * time.Millisecond - - // fileLockPollInterval is how often to retry acquiring the file lock. - fileLockPollInterval = 50 * time.Millisecond ) // configMutex protects in-process concurrent access to the config. @@ -42,30 +40,20 @@ func acquireConfigLock(lockPath string, timeout time.Duration) (unlock func() er } // Mutex acquired, now try file lock - file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + remaining := max(time.Until(deadline), 0) + locker := filelock.New(lockPath, remaining) + + fileUnlock, err := locker.Acquire() if err != nil { configMutex.Unlock() - return nil, fmt.Errorf("failed to open lock file: %w", err) - } - - // Try to acquire file lock with remaining timeout - remaining := max(time.Until(deadline), 0) - - if err := lockFile(file, remaining); err != nil { - file.Close() - configMutex.Unlock() - return nil, fmt.Errorf("failed to acquire file lock: %w", err) + return nil, err } // Return unlock function return func() error { - unlockErr := unlockFile(file) - closeErr := file.Close() + unlockErr := fileUnlock() configMutex.Unlock() - if unlockErr != nil { - return unlockErr - } - return closeErr + return unlockErr }, nil } diff --git a/modules/config/login.go b/modules/config/login.go index dd5af07..4bda954 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/debug" + "code.gitea.io/tea/modules/httputil" "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" "github.com/charmbracelet/huh" @@ -290,9 +291,9 @@ func doOAuthRefresh(l *Login) (*oauth2.Token, error) { ctx := context.Background() httpClient := &http.Client{ - Transport: &http.Transport{ + Transport: httputil.WrapTransport(&http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure}, - }, + }), } ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) @@ -336,7 +337,7 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...) } - options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient)) + options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) if debug.IsDebug() { options = append(options, gitea.SetDebugMode()) } diff --git a/modules/filelock/filelock.go b/modules/filelock/filelock.go new file mode 100644 index 0000000..a4f58ba --- /dev/null +++ b/modules/filelock/filelock.go @@ -0,0 +1,70 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package filelock + +import ( + "fmt" + "os" + "time" +) + +const ( + // DefaultTimeout is the default timeout for acquiring a file lock. + DefaultTimeout = 5 * time.Second + + // FileLockPollInterval is how often to retry acquiring the file lock. + FileLockPollInterval = 50 * time.Millisecond +) + +// Locker provides file-based locking with timeout. +type Locker struct { + path string + timeout time.Duration +} + +// New creates a Locker for the given lock file path. +func New(lockPath string, timeout time.Duration) *Locker { + return &Locker{ + path: lockPath, + timeout: timeout, + } +} + +// WithLock executes fn while holding the lock. +func (l *Locker) WithLock(fn func() error) (retErr error) { + unlock, err := l.Acquire() + if err != nil { + return err + } + defer func() { + if unlockErr := unlock(); unlockErr != nil && retErr == nil { + retErr = fmt.Errorf("failed to release file lock: %w", unlockErr) + } + }() + + return fn() +} + +// Acquire acquires the file lock and returns an unlock function. +// The caller must call the unlock function to release the lock. +func (l *Locker) Acquire() (unlock func() error, err error) { + file, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("failed to open lock file: %w", err) + } + + if err := lockFile(file, l.timeout); err != nil { + file.Close() + return nil, fmt.Errorf("failed to acquire file lock: %w", err) + } + + return func() error { + unlockErr := unlockFile(file) + closeErr := file.Close() + if unlockErr != nil { + return unlockErr + } + return closeErr + }, nil +} diff --git a/modules/filelock/filelock_test.go b/modules/filelock/filelock_test.go new file mode 100644 index 0000000..887da85 --- /dev/null +++ b/modules/filelock/filelock_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package filelock + +import ( + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestLocker_WithLock(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "test.lock") + + locker := New(lockPath, DefaultTimeout) + + counter := 0 + err := locker.WithLock(func() error { + counter++ + return nil + }) + if err != nil { + t.Fatalf("WithLock failed: %v", err) + } + if counter != 1 { + t.Errorf("Expected counter to be 1, got %d", counter) + } + + // Lock file should have been created + if _, err := os.Stat(lockPath); os.IsNotExist(err) { + t.Error("Lock file should have been created") + } +} + +func TestLocker_Acquire(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "test.lock") + + locker := New(lockPath, DefaultTimeout) + + unlock, err := locker.Acquire() + if err != nil { + t.Fatalf("Acquire failed: %v", err) + } + + // Lock should be held + if unlock == nil { + t.Fatal("unlock function should not be nil") + } + + // Release the lock + if err := unlock(); err != nil { + t.Fatalf("unlock failed: %v", err) + } +} + +func TestLocker_ConcurrentAccess(t *testing.T) { + tmpDir := t.TempDir() + lockPath := filepath.Join(tmpDir, "test.lock") + + locker := New(lockPath, 5*time.Second) + + var wg sync.WaitGroup + counter := 0 + numGoroutines := 10 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := locker.WithLock(func() error { + // Read-modify-write to check for race conditions + tmp := counter + time.Sleep(1 * time.Millisecond) + counter = tmp + 1 + return nil + }) + if err != nil { + t.Errorf("WithLock failed: %v", err) + } + }() + } + + wg.Wait() + + if counter != numGoroutines { + t.Errorf("Expected counter to be %d, got %d (possible race condition)", numGoroutines, counter) + } +} diff --git a/modules/config/lock_unix.go b/modules/filelock/filelock_unix.go similarity index 93% rename from modules/config/lock_unix.go rename to modules/filelock/filelock_unix.go index cb64859..d5f165d 100644 --- a/modules/config/lock_unix.go +++ b/modules/filelock/filelock_unix.go @@ -3,7 +3,7 @@ //go:build unix -package config +package filelock import ( "fmt" @@ -29,7 +29,7 @@ func lockFile(file *os.File, timeout time.Duration) error { if time.Now().After(deadline) { return fmt.Errorf("timeout waiting for file lock") } - time.Sleep(fileLockPollInterval) + time.Sleep(FileLockPollInterval) } } diff --git a/modules/config/lock_windows.go b/modules/filelock/filelock_windows.go similarity index 95% rename from modules/config/lock_windows.go rename to modules/filelock/filelock_windows.go index acf7387..89ed1df 100644 --- a/modules/config/lock_windows.go +++ b/modules/filelock/filelock_windows.go @@ -3,7 +3,7 @@ //go:build windows -package config +package filelock import ( "fmt" @@ -36,7 +36,7 @@ func lockFile(file *os.File, timeout time.Duration) error { if time.Now().After(deadline) { return fmt.Errorf("timeout waiting for file lock") } - time.Sleep(fileLockPollInterval) + time.Sleep(FileLockPollInterval) } } diff --git a/modules/httputil/httputil.go b/modules/httputil/httputil.go new file mode 100644 index 0000000..e0dbe3a --- /dev/null +++ b/modules/httputil/httputil.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package httputil + +import ( + "fmt" + "net/http" + "runtime" + + "code.gitea.io/tea/modules/version" +) + +// UserAgent returns the standard User-Agent string for tea. +func UserAgent() string { + ua := fmt.Sprintf("tea/%s (%s/%s)", version.Version, runtime.GOOS, runtime.GOARCH) + if version.SDK != "" { + ua += fmt.Sprintf(" go-sdk/%s", version.SDK) + } + return ua +} + +// WrapTransport wraps an http.RoundTripper to add the User-Agent header. +func WrapTransport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &userAgentTransport{base: base} +} + +type userAgentTransport struct { + base http.RoundTripper +} + +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", UserAgent()) + return t.base.RoundTrip(req) +} diff --git a/modules/version/version.go b/modules/version/version.go new file mode 100644 index 0000000..cefbb72 --- /dev/null +++ b/modules/version/version.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package version + +import ( + "fmt" + "runtime" + "strings" +) + +// Version holds the current tea version. +// This is set at build time via ldflags. +// If the Version is moved to another package or name changed, +// build flags in .goreleaser.yaml or Makefile need to be updated accordingly. +var Version = "development" + +// Tags holds the build tags used +var Tags = "" + +// SDK holds the sdk version from go.mod +var SDK = "" + +// Format returns a human-readable version string including +// go version, build tags, and SDK version when available. +func Format() string { + s := fmt.Sprintf("Version: %s\tgolang: %s", + bold(Version), + strings.ReplaceAll(runtime.Version(), "go", "")) + + if len(Tags) != 0 { + s += fmt.Sprintf("\tbuilt with: %s", strings.ReplaceAll(Tags, " ", ", ")) + } + + if len(SDK) != 0 { + s += fmt.Sprintf("\tgo-sdk: %s", SDK) + } + + return s +} + +func bold(t string) string { + return fmt.Sprintf("\033[1m%s\033[0m", t) +} From 3595f8f89d3c90faf34233bead4922215dc051d2 Mon Sep 17 00:00:00 2001 From: boozedog Date: Sat, 7 Feb 2026 16:02:20 +0000 Subject: [PATCH 25/83] fixed minor typo and grammar issue (#892) Reviewed-on: https://gitea.com/gitea/tea/pulls/892 Reviewed-by: techknowlogick Co-authored-by: boozedog Co-committed-by: boozedog --- cmd/login/helper.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/login/helper.go b/cmd/login/helper.go index ab788f3..dee092d 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -93,7 +93,7 @@ var CmdLoginHelper = cli.Command{ } if len(wants["host"]) == 0 { - log.Fatal("Require hostname") + log.Fatal("Hostname is required") } else if len(wants["protocol"]) == 0 { wants["protocol"] = "http" } @@ -113,7 +113,7 @@ var CmdLoginHelper = cli.Command{ } if len(userConfig.Token) == 0 { - log.Fatal("User no set") + log.Fatal("User not set") } host, err := url.Parse(userConfig.URL) From e644cc49d40d2abda329b190cf6e9455dd195fd6 Mon Sep 17 00:00:00 2001 From: Michal Suchanek Date: Sun, 8 Feb 2026 00:21:47 +0000 Subject: [PATCH 26/83] Revert "Login requires a http/https login URL and revmoe SSH as a login method. SSH will be optional (#826)" (#891) This reverts commit 90f8624ae78ac9be7f7cf301f9ce5bd1779bdb78. Fixes: #890 --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/891 Reviewed-by: Lunny Xiao Co-authored-by: Michal Suchanek Co-committed-by: Michal Suchanek --- modules/context/context.go | 2 +- modules/interact/login.go | 71 +++++++++++++++++++++--------------- modules/task/login_create.go | 5 ++- modules/utils/validate.go | 22 ++++++----- 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/modules/context/context.go b/modules/context/context.go index 1fd5c51..8fe5a76 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -105,7 +105,7 @@ func InitCommand(cmd *cli.Command) *TeaContext { // override config user with env variable envLogin := GetLoginByEnvVar() if envLogin != nil { - _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "") + _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", "") if err != nil { log.Fatal(err.Error()) } diff --git a/modules/interact/login.go b/modules/interact/login.go index a8a3b75..58b9002 100644 --- a/modules/interact/login.go +++ b/modules/interact/login.go @@ -86,7 +86,7 @@ func CreateLogin() error { printTitleAndContent("Name of new Login: ", name) - loginMethod, err := promptSelectV2("Login with: ", []string{"token", "oauth"}) + loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate", "oauth"}) if err != nil { return err } @@ -104,7 +104,7 @@ func CreateLogin() error { printTitleAndContent("Allow Insecure connections:", strconv.FormatBool(insecure)) return auth.OAuthLoginWithOptions(name, giteaURL, insecure) - case "token": + default: // token var hasToken bool if err := huh.NewConfirm(). Title("Do you have an access token?"). @@ -154,7 +154,7 @@ func CreateLogin() error { Value(&tokenScopes). Validate(func(s []string) error { if len(s) == 0 { - return errors.New("at least one scope is required") + return errors.New("At least one scope is required") } return nil }). @@ -176,36 +176,26 @@ func CreateLogin() error { } printTitleAndContent("OTP (if applicable):", otp) } - default: - return fmt.Errorf("unknown login method: %s", loginMethod) - } - - var optSettings bool - if err := huh.NewConfirm(). - Title("Set Optional settings:"). - Value(&optSettings). - WithTheme(theme.GetTheme()). - Run(); err != nil { - return err - } - printTitleAndContent("Set Optional settings:", strconv.FormatBool(optSettings)) - - if optSettings { - pubKeys := task.ListSSHPubkey() - emptyOpt := "Auto-discovery SSH Key in ~/.ssh and ssh-agent" - pubKeys = append([]string{emptyOpt}, pubKeys...) - - sshKey, err = promptSelect("Select ssh-key: ", pubKeys, "", "", "") - if err != nil { + case "ssh-key/certificate": + if err := huh.NewInput(). + Title("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):"). + Value(&sshKey). + WithTheme(theme.GetTheme()). + Run(); err != nil { return err } - if sshKey == emptyOpt { - sshKey = "" - } + printTitleAndContent("SSH Key/Certificate Path (leave empty for auto-discovery in ~/.ssh and ssh-agent):", sshKey) - printTitleAndContent("SSH Key Path (leave empty for auto-discovery) in ~/.ssh and ssh-agent):", sshKey) - - if sshKey != "" { + if sshKey == "" { + pubKeys := task.ListSSHPubkey() + if len(pubKeys) == 0 { + fmt.Println("No SSH keys found in ~/.ssh or ssh-agent") + return nil + } + sshKey, err = promptSelect("Select ssh-key: ", pubKeys, "", "", "") + if err != nil { + return err + } printTitleAndContent("Selected ssh-key:", sshKey) // ssh certificate @@ -229,6 +219,27 @@ func CreateLogin() error { } } } + } + + var optSettings bool + if err := huh.NewConfirm(). + Title("Set Optional settings:"). + Value(&optSettings). + WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + printTitleAndContent("Set Optional settings:", strconv.FormatBool(optSettings)) + + if optSettings { + if err := huh.NewInput(). + Title("SSH Key Path (leave empty for auto-discovery):"). + Value(&sshKey). + WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + printTitleAndContent("SSH Key Path (leave empty for auto-discovery):", sshKey) if err := huh.NewConfirm(). Title("Allow Insecure connections:"). diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 523caa1..8a2fbce 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -68,6 +68,9 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe token, user, passwd, + sshAgent, + sshKey, + sshCertPrincipal, ) if err != nil { return err @@ -92,7 +95,7 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe VersionCheck: versionCheck, } - if len(token) == 0 { + if len(token) == 0 && sshCertPrincipal == "" && !sshAgent && sshKey == "" { if login.Token, err = generateToken(login, user, passwd, otp, scopes); err != nil { return err } diff --git a/modules/utils/validate.go b/modules/utils/validate.go index bedf1d8..9924c2f 100644 --- a/modules/utils/validate.go +++ b/modules/utils/validate.go @@ -14,21 +14,25 @@ func ValidateAuthenticationMethod( token string, user string, passwd string, + sshAgent bool, + sshKey string, + sshCertPrincipal string, ) (*url.URL, error) { // Normalize URL serverURL, err := NormalizeURL(giteaURL) if err != nil { - return nil, fmt.Errorf("unable to parse URL: %s", err) + return nil, fmt.Errorf("Unable to parse URL: %s", err) } - // .. if we have enough information to authenticate - if len(token) == 0 && (len(user)+len(passwd)) == 0 { - return nil, fmt.Errorf("no token set") - } else if len(user) != 0 && len(passwd) == 0 { - return nil, fmt.Errorf("no password set") - } else if len(user) == 0 && len(passwd) != 0 { - return nil, fmt.Errorf("no user set") + if !sshAgent && sshCertPrincipal == "" && sshKey == "" { + // .. if we have enough information to authenticate + if len(token) == 0 && (len(user)+len(passwd)) == 0 { + return nil, fmt.Errorf("No token set") + } else if len(user) != 0 && len(passwd) == 0 { + return nil, fmt.Errorf("No password set") + } else if len(user) == 0 && len(passwd) != 0 { + return nil, fmt.Errorf("No user set") + } } - return serverURL, nil } From 59656dfcd2052bba3d80c31a2c1dbd921f752488 Mon Sep 17 00:00:00 2001 From: Michal Suchanek Date: Sun, 8 Feb 2026 18:11:54 +0000 Subject: [PATCH 27/83] Require non-empty token in GetLoginByToken (#895) Fixes: #893 Reviewed-on: https://gitea.com/gitea/tea/pulls/895 Reviewed-by: Lunny Xiao Co-authored-by: Michal Suchanek Co-committed-by: Michal Suchanek --- modules/config/login.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/config/login.go b/modules/config/login.go index 4bda954..fd6c5df 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -120,6 +120,9 @@ func GetLoginByName(name string) *Login { // GetLoginByToken get login by token func GetLoginByToken(token string) *Login { + if token == "" { + return nil + } err := loadConfig() if err != nil { log.Fatal(err) From 47f74ea696e3d8c8c3ac7a78fbf7b69397a819aa Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 Feb 2026 04:56:34 +0000 Subject: [PATCH 28/83] fix(deps): update module golang.org/x/oauth2 to v0.35.0 (#900) Reviewed-on: https://gitea.com/gitea/tea/pulls/900 Reviewed-by: Lunny Xiao Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 65d0b68..4c233e3 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.47.0 - golang.org/x/oauth2 v0.34.0 + golang.org/x/oauth2 v0.35.0 golang.org/x/sys v0.40.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 501abce..3bc044d 100644 --- a/go.sum +++ b/go.sum @@ -237,6 +237,8 @@ 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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= From 873a44f8979f1d7a92e41ed802d3cdc6dd4e72a2 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 Feb 2026 05:22:44 +0000 Subject: [PATCH 29/83] fix(deps): update module golang.org/x/sys to v0.41.0 (#901) Reviewed-on: https://gitea.com/gitea/tea/pulls/901 Reviewed-by: Lunny Xiao Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 4c233e3..fd994f6 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.35.0 - golang.org/x/sys v0.40.0 + golang.org/x/sys v0.41.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 3bc044d..a5d2799 100644 --- a/go.sum +++ b/go.sum @@ -255,6 +255,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc 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/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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= From 1093ef1524ad2e53ba6c5f8a47f2d22481e5b37c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 10 Feb 2026 00:42:45 +0000 Subject: [PATCH 30/83] fix(deps): update module github.com/go-git/go-git/v5 to v5.16.5 (#904) Reviewed-on: https://gitea.com/gitea/tea/pulls/904 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index fd994f6..58e1af9 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2 github.com/enescakir/emoji v1.0.0 - github.com/go-git/go-git/v5 v5.16.4 + github.com/go-git/go-git/v5 v5.16.5 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 diff --git a/go.sum b/go.sum index a5d2799..956fc76 100644 --- a/go.sum +++ b/go.sum @@ -116,6 +116,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj 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-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= +github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= 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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= From ea795775af6278ac334f50665fa851b210e65c35 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 10 Feb 2026 00:43:00 +0000 Subject: [PATCH 31/83] fix(deps): update module golang.org/x/crypto to v0.48.0 (#905) Reviewed-on: https://gitea.com/gitea/tea/pulls/905 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 6 +++--- go.sum | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 58e1af9..919c87b 100644 --- a/go.mod +++ b/go.mod @@ -19,10 +19,10 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.6.2 - golang.org/x/crypto v0.47.0 + golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.35.0 golang.org/x/sys v0.41.0 - golang.org/x/term v0.39.0 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -90,7 +90,7 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 956fc76..8f013af 100644 --- a/go.sum +++ b/go.sum @@ -225,6 +225,8 @@ golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5 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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -262,11 +264,15 @@ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= From 2152d99f2d9cdc473c89890a27988c6385c1b81a Mon Sep 17 00:00:00 2001 From: yousfi saad Date: Wed, 11 Feb 2026 00:40:06 +0000 Subject: [PATCH 32/83] Add tea actions runs and workflows commands (#880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive workflow execution tracking for Gitea Actions using tea CLI ## Features ### tea actions runs list - List workflow runs with filtering (status, branch, event, actor, time) - Time filters: relative (24h, 7d) and absolute dates - Status symbols: ✓ success, ✘ failure, ⭮ pending, ⊘ skipped/cancelled, ⚠ blocked - Multiple output formats: table, json, yaml, csv, tsv ### tea actions runs view - View run details with metadata (ID, status, workflow, branch, event, trigger info) - Shows jobs table with status, runner, duration - Optional --jobs flag to toggle jobs display ### tea actions runs delete - Delete/cancel workflow runs with confirmation prompt - Supports --confirm/-y to skip prompt ### tea actions runs logs - View job logs for all jobs or specific job (--job ) - **New: --follow/-f flag for real-time log following** (like tail -f) - Polls API every 2 seconds, only shows new content - Auto-detects completion and exits ### tea actions workflows list - List workflow files (.yml and .yaml) in repository - Searches in .gitea/workflows and .github/workflows - Shows active (✓) or inactive (✗) status based on recent runs - Displays workflow name, path, and file size ## Commands `tea actions runs list --status success --since 24h` `tea actions runs view 123` `tea actions runs delete 123 --confirm` `tea actions runs logs 123 --job 456 --follow` `tea actions workflows list` ## Tests - 19 unit tests across all commands - Full test suite passing - Manual testing successful --------- Co-authored-by: Lunny Xiao Co-authored-by: techknowlogick Reviewed-on: https://gitea.com/gitea/tea/pulls/880 Reviewed-by: Lunny Xiao Co-authored-by: yousfi saad Co-committed-by: yousfi saad --- cmd/actions.go | 9 +- cmd/actions/runs.go | 31 +++++ cmd/actions/runs/delete.go | 65 ++++++++++ cmd/actions/runs/list.go | 144 ++++++++++++++++++++++ cmd/actions/runs/list_test.go | 77 ++++++++++++ cmd/actions/runs/logs.go | 169 ++++++++++++++++++++++++++ cmd/actions/runs/view.go | 75 ++++++++++++ cmd/actions/workflows.go | 28 +++++ cmd/actions/workflows/list.go | 86 +++++++++++++ docs/CLI.md | 96 +++++++++++++++ modules/print/actions_runs.go | 188 +++++++++++++++++++++++++++++ modules/print/actions_runs_test.go | 174 ++++++++++++++++++++++++++ 12 files changed, 1138 insertions(+), 4 deletions(-) create mode 100644 cmd/actions/runs.go create mode 100644 cmd/actions/runs/delete.go create mode 100644 cmd/actions/runs/list.go create mode 100644 cmd/actions/runs/list_test.go create mode 100644 cmd/actions/runs/logs.go create mode 100644 cmd/actions/runs/view.go create mode 100644 cmd/actions/workflows.go create mode 100644 cmd/actions/workflows/list.go create mode 100644 modules/print/actions_runs.go create mode 100644 modules/print/actions_runs_test.go diff --git a/cmd/actions.go b/cmd/actions.go index c6aeb0d..df5b283 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -17,11 +17,13 @@ var CmdActions = cli.Command{ Aliases: []string{"action"}, Category: catEntities, Usage: "Manage repository actions", - Description: "Manage repository actions including secrets, variables, and workflows", + Description: "Manage repository actions including secrets, variables, and workflow runs", Action: runActionsDefault, Commands: []*cli.Command{ &actions.CmdActionsSecrets, &actions.CmdActionsVariables, + &actions.CmdActionsRuns, + &actions.CmdActionsWorkflows, }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -40,7 +42,6 @@ var CmdActions = cli.Command{ }, } -func runActionsDefault(ctx stdctx.Context, cmd *cli.Command) error { - // Default to showing help - return cli.ShowCommandHelp(ctx, cmd, "actions") +func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error { + return cli.ShowSubcommandHelp(cmd) } diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go new file mode 100644 index 0000000..10130cf --- /dev/null +++ b/cmd/actions/runs.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/actions/runs" + + "github.com/urfave/cli/v3" +) + +// CmdActionsRuns represents the actions runs command +var CmdActionsRuns = cli.Command{ + Name: "runs", + Aliases: []string{"run"}, + Usage: "Manage workflow runs", + Description: "List, view, and manage workflow runs for repository actions", + Action: runRunsDefault, + Commands: []*cli.Command{ + &runs.CmdRunsList, + &runs.CmdRunsView, + &runs.CmdRunsDelete, + &runs.CmdRunsLogs, + }, +} + +func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return runs.RunRunsList(ctx, cmd) +} diff --git a/cmd/actions/runs/delete.go b/cmd/actions/runs/delete.go new file mode 100644 index 0000000..5e7d9eb --- /dev/null +++ b/cmd/actions/runs/delete.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdRunsDelete represents a sub command to delete/cancel workflow runs +var CmdRunsDelete = cli.Command{ + Name: "delete", + Aliases: []string{"remove", "rm", "cancel"}, + Usage: "Delete or cancel a workflow run", + Description: "Delete (cancel) a workflow run from the repository", + ArgsUsage: "", + Action: runRunsDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm deletion without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runRunsDelete(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c := context.InitCommand(cmd) + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to delete run %d? [y/N] ", runID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + _, err = client.DeleteRepoActionRun(c.Owner, c.Repo, runID) + if err != nil { + return fmt.Errorf("failed to delete run: %w", err) + } + + fmt.Printf("Run %d deleted successfully\n", runID) + return nil +} diff --git a/cmd/actions/runs/list.go b/cmd/actions/runs/list.go new file mode 100644 index 0000000..8e3cdb0 --- /dev/null +++ b/cmd/actions/runs/list.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "time" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdRunsList represents a sub command to list workflow runs +var CmdRunsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List workflow runs", + Description: "List workflow runs for repository actions with optional filtering", + Action: RunRunsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + &cli.StringFlag{ + Name: "status", + Usage: "Filter by status (success, failure, pending, queued, in_progress, skipped, canceled)", + }, + &cli.StringFlag{ + Name: "branch", + Usage: "Filter by branch name", + }, + &cli.StringFlag{ + Name: "event", + Usage: "Filter by event type (push, pull_request, etc.)", + }, + &cli.StringFlag{ + Name: "actor", + Usage: "Filter by actor username (who triggered the run)", + }, + &cli.StringFlag{ + Name: "since", + Usage: "Show runs started after this time (e.g., '24h', '2024-01-01')", + }, + &cli.StringFlag{ + Name: "until", + Usage: "Show runs started before this time (e.g., '2024-01-01')", + }, + }, flags.AllDefaultFlags...), +} + +// parseTimeFlag parses time flags like "24h" or "2024-01-01" +func parseTimeFlag(value string) (time.Time, error) { + if value == "" { + return time.Time{}, nil + } + + // Try parsing as duration (e.g., "24h", "168h") + if duration, err := time.ParseDuration(value); err == nil { + return time.Now().Add(-duration), nil + } + + // Try parsing as date + formats := []string{ + "2006-01-02", + "2006-01-02 15:04", + "2006-01-02T15:04:05", + time.RFC3339, + } + + for _, format := range formats { + if t, err := time.Parse(format, value); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unable to parse time: %s", value) +} + +// RunRunsList lists workflow runs +func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + client := c.Login.Client() + + // Parse time filters + since, err := parseTimeFlag(cmd.String("since")) + if err != nil { + return fmt.Errorf("invalid --since value: %w", err) + } + + until, err := parseTimeFlag(cmd.String("until")) + if err != nil { + return fmt.Errorf("invalid --until value: %w", err) + } + + // Build list options + listOpts := flags.GetListOptions() + + runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ + ListOptions: listOpts, + Status: cmd.String("status"), + Branch: cmd.String("branch"), + Event: cmd.String("event"), + Actor: cmd.String("actor"), + }) + if err != nil { + return err + } + + if runs == nil { + print.ActionRunsList(nil, c.Output) + return nil + } + + // Filter by time if specified + filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until) + + print.ActionRunsList(filteredRuns, c.Output) + return nil +} + +// filterRunsByTime filters runs based on time range +func filterRunsByTime(runs []*gitea.ActionWorkflowRun, since, until time.Time) []*gitea.ActionWorkflowRun { + if since.IsZero() && until.IsZero() { + return runs + } + + var filtered []*gitea.ActionWorkflowRun + for _, run := range runs { + if !since.IsZero() && run.StartedAt.Before(since) { + continue + } + if !until.IsZero() && run.StartedAt.After(until) { + continue + } + filtered = append(filtered, run) + } + + return filtered +} diff --git a/cmd/actions/runs/list_test.go b/cmd/actions/runs/list_test.go new file mode 100644 index 0000000..176eef7 --- /dev/null +++ b/cmd/actions/runs/list_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + "testing" + "time" + + "code.gitea.io/sdk/gitea" +) + +func TestFilterRunsByTime(t *testing.T) { + now := time.Now() + runs := []*gitea.ActionWorkflowRun{ + {ID: 1, StartedAt: now.Add(-1 * time.Hour)}, + {ID: 2, StartedAt: now.Add(-2 * time.Hour)}, + {ID: 3, StartedAt: now.Add(-3 * time.Hour)}, + {ID: 4, StartedAt: now.Add(-4 * time.Hour)}, + {ID: 5, StartedAt: now.Add(-5 * time.Hour)}, + } + + tests := []struct { + name string + since time.Time + until time.Time + expected []int64 + }{ + { + name: "no filter", + since: time.Time{}, + until: time.Time{}, + expected: []int64{1, 2, 3, 4, 5}, + }, + { + name: "since 2.5 hours ago", + since: now.Add(-150 * time.Minute), + until: time.Time{}, + expected: []int64{1, 2}, + }, + { + name: "until 2.5 hours ago", + since: time.Time{}, + until: now.Add(-150 * time.Minute), + expected: []int64{3, 4, 5}, + }, + { + name: "between 2 and 4 hours ago", + since: now.Add(-4 * time.Hour), + until: now.Add(-2 * time.Hour), + expected: []int64{2, 3, 4}, + }, + { + name: "filter excludes all", + since: now.Add(-30 * time.Minute), + until: time.Time{}, + expected: []int64{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterRunsByTime(runs, tt.since, tt.until) + + if len(result) != len(tt.expected) { + t.Errorf("filterRunsByTime() returned %d runs, want %d", len(result), len(tt.expected)) + return + } + + for i, run := range result { + if run.ID != tt.expected[i] { + t.Errorf("filterRunsByTime()[%d].ID = %d, want %d", i, run.ID, tt.expected[i]) + } + } + }) + } +} diff --git a/cmd/actions/runs/logs.go b/cmd/actions/runs/logs.go new file mode 100644 index 0000000..7a2637e --- /dev/null +++ b/cmd/actions/runs/logs.go @@ -0,0 +1,169 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + "time" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdRunsLogs represents a sub command to view workflow run logs +var CmdRunsLogs = cli.Command{ + Name: "logs", + Aliases: []string{"log"}, + Usage: "View workflow run logs", + Description: "View logs for a workflow run or specific job", + ArgsUsage: "", + Action: runRunsLogs, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "job", + Usage: "specific job ID to view logs for (if omitted, shows all jobs)", + }, + &cli.BoolFlag{ + Name: "follow", + Aliases: []string{"f"}, + Usage: "follow log output (like tail -f), requires job to be in progress", + }, + }, flags.AllDefaultFlags...), +} + +func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c := context.InitCommand(cmd) + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + // Check if follow mode is enabled + follow := cmd.Bool("follow") + + // If specific job ID provided, fetch only that job's logs + jobIDStr := cmd.String("job") + if jobIDStr != "" { + jobID, err := strconv.ParseInt(jobIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid job ID: %s", jobIDStr) + } + + if follow { + return followJobLogs(client, c, jobID, "") + } + + logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get logs for job %d: %w", jobID, err) + } + + fmt.Printf("Logs for job %d:\n", jobID) + fmt.Printf("---\n%s\n", string(logs)) + return nil + } + + // Otherwise, fetch all jobs and their logs + jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ + ListOptions: flags.GetListOptions(), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if len(jobs.Jobs) == 0 { + fmt.Printf("No jobs found for run %d\n", runID) + return nil + } + + // If following and multiple jobs, require --job flag + if follow && len(jobs.Jobs) > 1 { + return fmt.Errorf("--follow requires --job when run has multiple jobs (found %d jobs)", len(jobs.Jobs)) + } + + // If following with single job, follow it + if follow && len(jobs.Jobs) == 1 { + return followJobLogs(client, c, jobs.Jobs[0].ID, jobs.Jobs[0].Name) + } + + // Fetch logs for each job + for i, job := range jobs.Jobs { + if i > 0 { + fmt.Println() + } + + fmt.Printf("Job: %s (ID: %d)\n", job.Name, job.ID) + fmt.Printf("Status: %s\n", job.Status) + fmt.Println("---") + + logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, job.ID) + if err != nil { + fmt.Printf("Error fetching logs: %v\n", err) + continue + } + + fmt.Println(string(logs)) + } + + return nil +} + +// followJobLogs continuously fetches and displays logs for a running job +func followJobLogs(client *gitea.Client, c *context.TeaContext, jobID int64, jobName string) error { + var lastLogLength int + + if jobName != "" { + fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID) + } else { + fmt.Printf("Following logs for job %d (press Ctrl+C to stop)...\n", jobID) + } + fmt.Println("---") + + for { + // Fetch job status + job, _, err := client.GetRepoActionJob(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + + // Check if job is still running + isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending" + + // Fetch logs + logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get logs: %w", err) + } + + // Display new content only + if len(logs) > lastLogLength { + newLogs := string(logs)[lastLogLength:] + fmt.Print(newLogs) + lastLogLength = len(logs) + } + + // If job is complete, exit + if !isRunning { + fmt.Printf("\n---\nJob completed with status: %s\n", job.Status) + break + } + + // Wait before next poll + time.Sleep(2 * time.Second) + } + + return nil +} diff --git a/cmd/actions/runs/view.go b/cmd/actions/runs/view.go new file mode 100644 index 0000000..621ef67 --- /dev/null +++ b/cmd/actions/runs/view.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runs + +import ( + stdctx "context" + "fmt" + "strconv" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdRunsView represents a sub command to view workflow run details +var CmdRunsView = cli.Command{ + Name: "view", + Aliases: []string{"show", "get"}, + Usage: "View workflow run details", + Description: "View details of a specific workflow run including jobs", + ArgsUsage: "", + Action: runRunsView, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "jobs", + Usage: "show jobs table", + Value: true, + }, + }, flags.AllDefaultFlags...), +} + +func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("run ID is required") + } + + c := context.InitCommand(cmd) + client := c.Login.Client() + + runIDStr := cmd.Args().First() + runID, err := strconv.ParseInt(runIDStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid run ID: %s", runIDStr) + } + + // Fetch run details + run, _, err := client.GetRepoActionRun(c.Owner, c.Repo, runID) + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + // Print run details + print.ActionRunDetails(run) + + // Fetch and print jobs if requested + if cmd.Bool("jobs") { + jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ + ListOptions: flags.GetListOptions(), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if jobs != nil && len(jobs.Jobs) > 0 { + fmt.Printf("\nJobs:\n\n") + print.ActionWorkflowJobsList(jobs.Jobs, c.Output) + } + } + + return nil +} diff --git a/cmd/actions/workflows.go b/cmd/actions/workflows.go new file mode 100644 index 0000000..440783e --- /dev/null +++ b/cmd/actions/workflows.go @@ -0,0 +1,28 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/actions/workflows" + + "github.com/urfave/cli/v3" +) + +// CmdActionsWorkflows represents the actions workflows command +var CmdActionsWorkflows = cli.Command{ + Name: "workflows", + Aliases: []string{"workflow"}, + Usage: "Manage repository workflows", + Description: "List and manage repository action workflows", + Action: runWorkflowsDefault, + Commands: []*cli.Command{ + &workflows.CmdWorkflowsList, + }, +} + +func runWorkflowsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return workflows.RunWorkflowsList(ctx, cmd) +} diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go new file mode 100644 index 0000000..d40be22 --- /dev/null +++ b/cmd/actions/workflows/list.go @@ -0,0 +1,86 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + "path/filepath" + "strings" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsList represents a sub command to list workflows +var CmdWorkflowsList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List repository workflows", + Description: "List workflow files in the repository with active/inactive status", + Action: RunWorkflowsList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.AllDefaultFlags...), +} + +// RunWorkflowsList lists workflow files in the repository +func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { + c := context.InitCommand(cmd) + client := c.Login.Client() + + // Try to list workflow files from .gitea/workflows directory + var workflows []*gitea.ContentsResponse + + // Try .gitea/workflows first, then .github/workflows + workflowDir := ".gitea/workflows" + contents, _, err := client.ListContents(c.Owner, c.Repo, "", workflowDir) + if err != nil { + workflowDir = ".github/workflows" + contents, _, err = client.ListContents(c.Owner, c.Repo, "", workflowDir) + if err != nil { + fmt.Printf("No workflow files found\n") + return nil + } + } + + // Filter for workflow files (.yml and .yaml) + for _, content := range contents { + if content.Type == "file" { + ext := strings.ToLower(filepath.Ext(content.Name)) + if ext == ".yml" || ext == ".yaml" { + content.Path = workflowDir + "/" + content.Name + workflows = append(workflows, content) + } + } + } + + if len(workflows) == 0 { + fmt.Printf("No workflow files found\n") + return nil + } + + // Check which workflows have runs to determine active status + workflowStatus := make(map[string]bool) + + // Get recent runs to check activity + runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ + ListOptions: flags.GetListOptions(), + }) + if err == nil && runs != nil { + for _, run := range runs.WorkflowRuns { + // Extract workflow file name from path + workflowFile := filepath.Base(run.Path) + workflowStatus[workflowFile] = true + } + } + + print.WorkflowsList(workflows, workflowStatus, c.Output) + return nil +} diff --git a/docs/CLI.md b/docs/CLI.md index 2bca4e0..c5ab769 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1389,6 +1389,102 @@ Delete an action variable **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +### runs, run + +Manage workflow runs + +#### list, ls + +List workflow runs + +**--actor**="": Filter by actor username (who triggered the run) + +**--branch**="": Filter by branch name + +**--event**="": Filter by event type (push, pull_request, etc.) + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--since**="": Show runs started after this time (e.g., '24h', '2024-01-01') + +**--status**="": Filter by status (success, failure, pending, queued, in_progress, skipped, canceled) + +**--until**="": Show runs started before this time (e.g., '2024-01-01') + +#### view, show, get + +View workflow run details + +**--jobs**: show jobs table + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### delete, remove, rm, cancel + +Delete or cancel a workflow run + +**--confirm, -y**: confirm deletion without prompting + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### logs, log + +View workflow run logs + +**--follow, -f**: follow log output (like tail -f), requires job to be in progress + +**--job**="": specific job ID to view logs for (if omitted, shows all jobs) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### workflows, workflow + +Manage repository workflows + +#### list, ls + +List repository workflows + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ## webhooks, webhook, hooks, hook Manage webhooks diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go new file mode 100644 index 0000000..c2e2a40 --- /dev/null +++ b/modules/print/actions_runs.go @@ -0,0 +1,188 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "fmt" + "time" + + "code.gitea.io/sdk/gitea" +) + +// formatDurationMinutes formats duration in a human-readable way +func formatDurationMinutes(started, completed time.Time) string { + if started.IsZero() { + return "" + } + + end := completed + if end.IsZero() { + end = time.Now() + } + + duration := end.Sub(started) + if duration < time.Minute { + return fmt.Sprintf("%ds", int(duration.Seconds())) + } + if duration < time.Hour { + return fmt.Sprintf("%dm", int(duration.Minutes())) + } + hours := int(duration.Hours()) + minutes := int(duration.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", hours, minutes) +} + +// getWorkflowDisplayName returns the display title or falls back to path +func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string { + if run.DisplayTitle != "" { + return run.DisplayTitle + } + return run.Path +} + +// ActionRunsList prints a list of workflow runs +func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) { + t := table{ + headers: []string{ + "ID", + "Status", + "Workflow", + "Branch", + "Event", + "Started", + "Duration", + }, + } + + machineReadable := isMachineReadable(output) + + for _, run := range runs { + workflowName := getWorkflowDisplayName(run) + duration := formatDurationMinutes(run.StartedAt, run.CompletedAt) + + t.addRow( + fmt.Sprintf("%d", run.ID), + run.Status, + workflowName, + run.HeadBranch, + run.Event, + FormatTime(run.StartedAt, machineReadable), + duration, + ) + } + + if len(runs) == 0 { + fmt.Printf("No workflow runs found\n") + return + } + + t.sort(0, true) + t.print(output) +} + +// ActionRunDetails prints detailed information about a workflow run +func ActionRunDetails(run *gitea.ActionWorkflowRun) { + workflowName := getWorkflowDisplayName(run) + + fmt.Printf("Run ID: %d\n", run.ID) + fmt.Printf("Run Number: %d\n", run.RunNumber) + fmt.Printf("Status: %s\n", run.Status) + if run.Conclusion != "" { + fmt.Printf("Conclusion: %s\n", run.Conclusion) + } + fmt.Printf("Workflow: %s\n", workflowName) + fmt.Printf("Path: %s\n", run.Path) + fmt.Printf("Branch: %s\n", run.HeadBranch) + fmt.Printf("Event: %s\n", run.Event) + fmt.Printf("Head SHA: %s\n", run.HeadSha) + fmt.Printf("Started: %s\n", FormatTime(run.StartedAt, false)) + if !run.CompletedAt.IsZero() { + fmt.Printf("Completed: %s\n", FormatTime(run.CompletedAt, false)) + duration := formatDurationMinutes(run.StartedAt, run.CompletedAt) + fmt.Printf("Duration: %s\n", duration) + } + if run.RunAttempt > 1 { + fmt.Printf("Attempt: %d\n", run.RunAttempt) + } + if run.Actor != nil { + fmt.Printf("Triggered by: %s\n", run.Actor.UserName) + } + if run.HTMLURL != "" { + fmt.Printf("URL: %s\n", run.HTMLURL) + } +} + +// ActionWorkflowJobsList prints a list of workflow jobs +func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) { + t := table{ + headers: []string{ + "ID", + "Name", + "Status", + "Runner", + "Started", + "Duration", + }, + } + + machineReadable := isMachineReadable(output) + + for _, job := range jobs { + duration := formatDurationMinutes(job.StartedAt, job.CompletedAt) + runner := job.RunnerName + if runner == "" { + runner = "-" + } + + t.addRow( + fmt.Sprintf("%d", job.ID), + job.Name, + job.Status, + runner, + FormatTime(job.StartedAt, machineReadable), + duration, + ) + } + + if len(jobs) == 0 { + fmt.Printf("No jobs found\n") + return + } + + t.sort(0, true) + t.print(output) +} + +// WorkflowsList prints a list of workflow files with active status +func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) { + t := table{ + headers: []string{ + "Active", + "Name", + "Path", + }, + } + + machineReadable := isMachineReadable(output) + + for _, workflow := range workflows { + // Check if this workflow file is active (has runs) + isActive := activeStatus[workflow.Name] + activeIndicator := formatBoolean(isActive, !machineReadable) + + t.addRow( + activeIndicator, + workflow.Name, + workflow.Path, + ) + } + + if len(workflows) == 0 { + fmt.Printf("No workflows found\n") + return + } + + t.sort(1, true) // Sort by name column + t.print(output) +} diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go new file mode 100644 index 0000000..cf4283e --- /dev/null +++ b/modules/print/actions_runs_test.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "testing" + "time" + + "code.gitea.io/sdk/gitea" +) + +func TestActionRunsListEmpty(t *testing.T) { + // Test with empty runs - should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionRunsList panicked with empty list: %v", r) + } + }() + + ActionRunsList([]*gitea.ActionWorkflowRun{}, "") +} + +func TestActionRunsListWithData(t *testing.T) { + runs := []*gitea.ActionWorkflowRun{ + { + ID: 1, + Status: "success", + DisplayTitle: "Test Workflow", + HeadBranch: "main", + Event: "push", + StartedAt: time.Now().Add(-1 * time.Hour), + CompletedAt: time.Now().Add(-30 * time.Minute), + }, + { + ID: 2, + Status: "in_progress", + Path: ".gitea/workflows/test.yml", + HeadBranch: "feature", + Event: "pull_request", + StartedAt: time.Now().Add(-10 * time.Minute), + }, + } + + // Test that it doesn't panic with real data + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionRunsList panicked with data: %v", r) + } + }() + + ActionRunsList(runs, "") +} + +func TestActionRunDetails(t *testing.T) { + run := &gitea.ActionWorkflowRun{ + ID: 123, + RunNumber: 42, + Status: "success", + Conclusion: "success", + DisplayTitle: "Build and Test", + Path: ".gitea/workflows/ci.yml", + HeadBranch: "main", + Event: "push", + HeadSha: "abc123def456", + StartedAt: time.Now().Add(-2 * time.Hour), + CompletedAt: time.Now().Add(-1 * time.Hour), + RunAttempt: 1, + Actor: &gitea.User{ + UserName: "testuser", + }, + HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/123", + } + + // Test that it doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionRunDetails panicked: %v", r) + } + }() + + ActionRunDetails(run) +} + +func TestActionWorkflowJobsListEmpty(t *testing.T) { + // Test with empty jobs - should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowJobsList panicked with empty list: %v", r) + } + }() + + ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, "") +} + +func TestActionWorkflowJobsListWithData(t *testing.T) { + jobs := []*gitea.ActionWorkflowJob{ + { + ID: 1, + Name: "build", + Status: "success", + RunnerName: "runner-1", + StartedAt: time.Now().Add(-30 * time.Minute), + CompletedAt: time.Now().Add(-20 * time.Minute), + }, + { + ID: 2, + Name: "test", + Status: "in_progress", + RunnerName: "runner-2", + StartedAt: time.Now().Add(-5 * time.Minute), + }, + } + + // Test that it doesn't panic with real data + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowJobsList panicked with data: %v", r) + } + }() + + ActionWorkflowJobsList(jobs, "") +} + +func TestFormatDurationMinutes(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + started time.Time + completed time.Time + expected string + }{ + { + name: "zero started", + started: time.Time{}, + completed: now, + expected: "", + }, + { + name: "30 seconds", + started: now.Add(-30 * time.Second), + completed: now, + expected: "30s", + }, + { + name: "5 minutes", + started: now.Add(-5 * time.Minute), + completed: now, + expected: "5m", + }, + { + name: "in progress (no completed)", + started: now.Add(-1 * time.Hour), + completed: time.Time{}, + expected: "1h0m", + }, + { + name: "2 hours 30 minutes", + started: now.Add(-150 * time.Minute), + completed: now, + expected: "2h30m", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := formatDurationMinutes(test.started, test.completed) + if result != test.expected { + t.Errorf("formatDurationMinutes() = %q, want %q", result, test.expected) + } + }) + } +} From dfd400f15b54704cdf2791c9c5fe5c1a4efd4089 Mon Sep 17 00:00:00 2001 From: Michal Suchanek Date: Thu, 12 Feb 2026 16:16:53 +0000 Subject: [PATCH 33/83] Fix termenv OSC RGBA handling (#907) Fixes: #889 Reviewed-on: https://gitea.com/gitea/tea/pulls/907 Reviewed-by: techknowlogick Co-authored-by: Michal Suchanek Co-committed-by: Michal Suchanek --- go.mod | 2 ++ go.sum | 16 ++-------------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index 919c87b..fdd3980 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/muesli/termenv v0.16.0 => github.com/hramrach/termenv v0.16.1-0.20260212100405-cc30261f3059 + require ( dario.cat/mergo v1.0.2 // indirect github.com/42wim/httpsig v1.2.3 // indirect diff --git a/go.sum b/go.sum index 8f013af..c65780c 100644 --- a/go.sum +++ b/go.sum @@ -114,8 +114,6 @@ github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9n 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-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -128,6 +126,8 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hramrach/termenv v0.16.1-0.20260212100405-cc30261f3059 h1:xxfLFNkkQNJqA7Tieg/oBg/7Wk24pbEFK1VnbkrnTo8= +github.com/hramrach/termenv v0.16.1-0.20260212100405-cc30261f3059/go.mod h1:jeqvVfGyGmpCFfP9fK4yIWvxcMb8ApE3EPBq5fCzaaU= 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/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= @@ -163,8 +163,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= @@ -223,8 +221,6 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= @@ -239,8 +235,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v 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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -257,20 +251,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 87c8c3d6e0d96b173b52fee1d8b4aad25bd2dc52 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Mon, 16 Feb 2026 03:37:44 +0000 Subject: [PATCH 34/83] Fix new tty prompt (#897) Fix #827 --------- Co-authored-by: silverwind Reviewed-on: https://gitea.com/gitea/tea/pulls/897 Reviewed-by: silverwind --- modules/context/context.go | 10 ++++- modules/context/context_prompt_test.go | 52 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 modules/context/context_prompt_test.go diff --git a/modules/context/context.go b/modules/context/context.go index 8fe5a76..4f6c9f4 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -18,6 +18,7 @@ import ( "github.com/charmbracelet/huh" gogit "github.com/go-git/go-git/v5" "github.com/urfave/cli/v3" + "golang.org/x/term" ) var errNotAGiteaRepo = errors.New("No Gitea login found. You might want to specify --repo (and --login) to work outside of a repository") @@ -48,6 +49,10 @@ func (ctx *TeaContext) IsInteractiveMode() bool { return ctx.Command.NumFlags() == 0 } +func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool { + return login != nil && !login.Default && canPrompt +} + // InitCommand resolves the application context, and returns the active login, and if // available the repo slug. It does this by reading the config file for logins, parsing // the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from @@ -131,7 +136,8 @@ and then run your command again.`) } // Only prompt for confirmation if the fallback login is not explicitly set as default - if !c.Login.Default { + canPrompt := term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) + if shouldPromptFallbackLogin(c.Login, canPrompt) { fallback := false if err := huh.NewConfirm(). Title(fmt.Sprintf("NOTE: no gitea login detected, whether falling back to login '%s'?", c.Login.Name)). @@ -143,6 +149,8 @@ and then run your command again.`) if !fallback { os.Exit(1) } + } else if !c.Login.Default { + fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name) } } diff --git a/modules/context/context_prompt_test.go b/modules/context/context_prompt_test.go new file mode 100644 index 0000000..ae0fded --- /dev/null +++ b/modules/context/context_prompt_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "testing" + + "code.gitea.io/tea/modules/config" +) + +func TestShouldPromptFallbackLogin(t *testing.T) { + tests := []struct { + name string + login *config.Login + canPrompt bool + expected bool + }{ + { + name: "no login", + login: nil, + canPrompt: true, + expected: false, + }, + { + name: "default login", + login: &config.Login{Default: true}, + canPrompt: true, + expected: false, + }, + { + name: "non-default no prompt", + login: &config.Login{Default: false}, + canPrompt: false, + expected: false, + }, + { + name: "non-default prompt", + login: &config.Login{Default: false}, + canPrompt: true, + expected: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldPromptFallbackLogin(test.login, test.canPrompt); got != test.expected { + t.Fatalf("expected %v, got %v", test.expected, got) + } + }) + } +} From bdf15a57bea15901bcd149aa152da68c2117db03 Mon Sep 17 00:00:00 2001 From: Alain Thiffault Date: Thu, 19 Feb 2026 15:16:21 +0000 Subject: [PATCH 35/83] feat(pulls): add JSON output support for single PR view (#864) Reviewed-on: https://gitea.com/gitea/tea/pulls/864 Reviewed-by: Lunny Xiao Co-authored-by: Alain Thiffault Co-committed-by: Alain Thiffault --- cmd/pulls.go | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/cmd/pulls.go b/cmd/pulls.go index e051928..7318246 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -5,18 +5,67 @@ package cmd import ( stdctx "context" + "encoding/json" "fmt" + "time" + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/pulls" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/utils" - "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" ) +type pullLabelData struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type pullReviewData struct { + ID int64 `json:"id"` + Reviewer string `json:"reviewer"` + State gitea.ReviewStateType `json:"state"` + Body string `json:"body"` + Created time.Time `json:"created"` +} + +type pullCommentData struct { + ID int64 `json:"id"` + Author string `json:"author"` + Created time.Time `json:"created"` + Body string `json:"body"` +} + +type pullData struct { + ID int64 `json:"id"` + Index int64 `json:"index"` + Title string `json:"title"` + State gitea.StateType `json:"state"` + Created *time.Time `json:"created"` + Updated *time.Time `json:"updated"` + Labels []pullLabelData `json:"labels"` + User string `json:"user"` + Body string `json:"body"` + Assignees []string `json:"assignees"` + URL string `json:"url"` + Base string `json:"base"` + Head string `json:"head"` + HeadSha string `json:"headSha"` + DiffURL string `json:"diffUrl"` + Mergeable bool `json:"mergeable"` + HasMerged bool `json:"hasMerged"` + MergedAt *time.Time `json:"mergedAt"` + MergedBy string `json:"mergedBy,omitempty"` + ClosedAt *time.Time `json:"closedAt"` + Reviews []pullReviewData `json:"reviews"` + Comments []pullCommentData `json:"comments"` +} + // CmdPulls is the main command to operate on PRs var CmdPulls = cli.Command{ Name: "pulls", @@ -74,6 +123,13 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { fmt.Printf("error while loading reviews: %v\n", err) } + if ctx.IsSet("output") { + switch ctx.String("output") { + case "json": + return runPullDetailAsJSON(ctx, pr, reviews) + } + } + ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha) if err != nil { fmt.Printf("error while loading CI: %v\n", err) @@ -90,3 +146,85 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { return nil } + +func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews []*gitea.PullReview) error { + c := ctx.Login.Client() + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} + + labelSlice := make([]pullLabelData, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labelSlice = append(labelSlice, pullLabelData{label.Name, label.Color, label.Description}) + } + + assigneesSlice := make([]string, 0, len(pr.Assignees)) + for _, assignee := range pr.Assignees { + assigneesSlice = append(assigneesSlice, assignee.UserName) + } + + reviewsSlice := make([]pullReviewData, 0, len(reviews)) + for _, review := range reviews { + reviewsSlice = append(reviewsSlice, pullReviewData{ + ID: review.ID, + Reviewer: review.Reviewer.UserName, + State: review.State, + Body: review.Body, + Created: review.Submitted, + }) + } + + mergedBy := "" + if pr.MergedBy != nil { + mergedBy = pr.MergedBy.UserName + } + + pullSlice := pullData{ + ID: pr.ID, + Index: pr.Index, + Title: pr.Title, + State: pr.State, + Created: pr.Created, + Updated: pr.Updated, + User: pr.Poster.UserName, + Body: pr.Body, + Labels: labelSlice, + Assignees: assigneesSlice, + URL: pr.HTMLURL, + Base: pr.Base.Ref, + Head: pr.Head.Ref, + HeadSha: pr.Head.Sha, + DiffURL: pr.DiffURL, + Mergeable: pr.Mergeable, + HasMerged: pr.HasMerged, + MergedAt: pr.Merged, + MergedBy: mergedBy, + ClosedAt: pr.Closed, + Reviews: reviewsSlice, + Comments: make([]pullCommentData, 0), + } + + if ctx.Bool("comments") { + comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, pr.Index, opts) + if err != nil { + return err + } + + pullSlice.Comments = make([]pullCommentData, 0, len(comments)) + for _, comment := range comments { + pullSlice.Comments = append(pullSlice.Comments, pullCommentData{ + ID: comment.ID, + Author: comment.Poster.UserName, + Body: comment.Body, + Created: comment.Created, + }) + } + } + + jsonData, err := json.MarshalIndent(pullSlice, "", "\t") + if err != nil { + return err + } + + _, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData) + + return err +} From 93d4d3cc5534baf955eda4b5096a6e51a3f945b5 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 19 Feb 2026 15:19:45 +0000 Subject: [PATCH 36/83] Skip token uniqueness check when using SSH authentication (#898) Co-authored-by: techknowlogick Co-authored-by: silverwind Reviewed-on: https://gitea.com/gitea/tea/pulls/898 Co-authored-by: Lunny Xiao Co-committed-by: Lunny Xiao --- modules/task/login_create.go | 14 ++++++-- modules/task/login_create_test.go | 57 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 modules/task/login_create_test.go diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 8a2fbce..00af245 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -59,8 +59,10 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe return fmt.Errorf("login name '%s' has already been used", login.Name) } // ... if we already use this token - if login := config.GetLoginByToken(token); login != nil { - return fmt.Errorf("token already been used, delete login '%s' first", login.Name) + if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) { + if login := config.GetLoginByToken(token); login != nil { + return fmt.Errorf("token already been used, delete login '%s' first", login.Name) + } } serverURL, err := utils.ValidateAuthenticationMethod( @@ -141,6 +143,14 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe return nil } +func shouldCheckTokenUniqueness(token string, sshAgent bool, sshKey, sshCertPrincipal, sshKeyFingerprint string) bool { + if sshAgent || sshKey != "" || sshCertPrincipal != "" || sshKeyFingerprint != "" { + return false + } + + return true +} + // generateToken creates a new token when given BasicAuth credentials func generateToken(login config.Login, user, pass, otp, scopes string) (string, error) { opts := []gitea.ClientOption{gitea.SetBasicAuth(user, pass)} diff --git a/modules/task/login_create_test.go b/modules/task/login_create_test.go new file mode 100644 index 0000000..593d37c --- /dev/null +++ b/modules/task/login_create_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import "testing" + +func TestShouldCheckTokenUniqueness(t *testing.T) { + tests := []struct { + name string + token string + sshAgent bool + sshKey string + sshCertPrincipal string + sshKeyFingerprint string + wantCheckUniqueness bool + }{ + { + name: "token only", + token: "token", + wantCheckUniqueness: true, + }, + { + name: "token with ssh agent", + token: "token", + sshAgent: true, + wantCheckUniqueness: false, + }, + { + name: "token with ssh key path", + token: "token", + sshKey: "~/.ssh/id_ed25519", + wantCheckUniqueness: false, + }, + { + name: "token with ssh cert principal", + token: "token", + sshCertPrincipal: "principal", + wantCheckUniqueness: false, + }, + { + name: "token with ssh key fingerprint", + token: "token", + sshKeyFingerprint: "SHA256:example", + wantCheckUniqueness: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldCheckTokenUniqueness(tt.token, tt.sshAgent, tt.sshKey, tt.sshCertPrincipal, tt.sshKeyFingerprint) + if got != tt.wantCheckUniqueness { + t.Fatalf("expected %v, got %v", tt.wantCheckUniqueness, got) + } + }) + } +} From 0b1147bfc032f894e9d5a6f96970c25eef39d6a3 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Thu, 19 Feb 2026 18:41:21 +0000 Subject: [PATCH 37/83] build for windows aarch64 too --- .goreleaser.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 43c490a..cdab207 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -38,8 +38,6 @@ builds: - goos: windows goarch: arm goarm: "7" - - goos: windows - goarch: arm64 - goos: freebsd goarch: ppc64le - goos: freebsd From fab70f83c11eaccd30d710f7bd97107e44057d6e Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 19 Feb 2026 18:57:23 +0000 Subject: [PATCH 38/83] Fix issue detail view ignoring --owner flag (#899) Co-authored-by: techknowlogick Reviewed-on: https://gitea.com/gitea/tea/pulls/899 Co-authored-by: Lunny Xiao Co-committed-by: Lunny Xiao --- cmd/issues.go | 3 ++ cmd/issues_test.go | 77 ++++++++++++++++++++++++++++++++++++++++ modules/config/config.go | 7 ++++ 3 files changed, 87 insertions(+) diff --git a/cmd/issues.go b/cmd/issues.go index 05ce2f0..c9ff28e 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -81,6 +81,9 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error { func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { ctx := context.InitCommand(cmd) + if ctx.IsSet("owner") { + ctx.Owner = ctx.String("owner") + } ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) idx, err := utils.ArgToIndex(index) diff --git a/cmd/issues_test.go b/cmd/issues_test.go index 2785624..48c79ff 100644 --- a/cmd/issues_test.go +++ b/cmd/issues_test.go @@ -5,6 +5,7 @@ package cmd import ( "bytes" + stdctx "context" "encoding/json" "fmt" "net/http" @@ -13,6 +14,7 @@ import ( "time" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/context" "github.com/stretchr/testify/assert" @@ -262,3 +264,78 @@ func TestRunIssueDetailAsJSON(t *testing.T) { }) } } + +func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { + issueIndex := int64(12) + expectedOwner := "overrideOwner" + expectedRepo := "overrideRepo" + issue := gitea.Issue{ + ID: 99, + Index: issueIndex, + Title: "Owner override test", + State: gitea.StateOpen, + Created: time.Date(2025, 11, 1, 10, 0, 0, 0, time.UTC), + Poster: &gitea.User{ + UserName: "tester", + }, + HTMLURL: "https://example.test/issues/12", + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", expectedOwner, expectedRepo, issueIndex): + jsonIssue, err := json.Marshal(issue) + require.NoError(t, err, "Testing setup failed: failed to marshal issue") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(jsonIssue) + require.NoError(t, err, "Testing setup failed: failed to write issue") + case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", expectedOwner, expectedRepo, issueIndex): + jsonReactions, err := json.Marshal([]gitea.Reaction{}) + require.NoError(t, err, "Testing setup failed: failed to marshal reactions") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(jsonReactions) + require.NoError(t, err, "Testing setup failed: failed to write reactions") + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + defer server.Close() + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: server.URL, + Token: "token", + User: "loginUser", + Default: true, + }}, + }) + + cmd := cli.Command{ + Name: "issues", + Flags: []cli.Flag{ + &flags.LoginFlag, + &flags.RepoFlag, + &flags.RemoteFlag, + &flags.OutputFlag, + &cli.StringFlag{Name: "owner"}, + &cli.BoolFlag{Name: "comments"}, + }, + } + var outBuffer bytes.Buffer + var errBuffer bytes.Buffer + cmd.Writer = &outBuffer + cmd.ErrWriter = &errBuffer + require.NoError(t, cmd.Set("login", "testLogin")) + require.NoError(t, cmd.Set("repo", expectedRepo)) + require.NoError(t, cmd.Set("owner", expectedOwner)) + require.NoError(t, cmd.Set("output", "json")) + require.NoError(t, cmd.Set("comments", "false")) + + err := runIssueDetail(stdctx.Background(), &cmd, fmt.Sprintf("%d", issueIndex)) + require.NoError(t, err, "Expected runIssueDetail to succeed") +} diff --git a/modules/config/config.go b/modules/config/config.go index f958b57..d5be554 100644 --- a/modules/config/config.go +++ b/modules/config/config.go @@ -122,6 +122,13 @@ func reloadConfigFromDisk() error { return nil } +// SetConfigForTesting replaces the in-memory config and marks it as loaded. +// This allows tests to inject config without relying on file-based loading. +func SetConfigForTesting(cfg LocalConfig) { + loadConfigOnce.Do(func() {}) // ensure sync.Once is spent + config = cfg +} + // saveConfigUnsafe saves config to file without acquiring a lock. // Caller must hold the config lock. func saveConfigUnsafe() error { From e3c550ff22c28d911e50547de8390067ec845fed Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Thu, 19 Feb 2026 19:23:44 +0000 Subject: [PATCH 39/83] fix: authentication via env variables repo argument (#809) --------- Co-authored-by: techknowlogick Reviewed-on: https://gitea.com/gitea/tea/pulls/809 Co-authored-by: Nikolaos Karaolidis Co-committed-by: Nikolaos Karaolidis --- modules/context/context.go | 20 +++++++++++++------- modules/context/context_login.go | 2 ++ modules/context/context_repo.go | 6 +++++- modules/context/context_test.go | 22 +++++++++++++++++++++- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/modules/context/context.go b/modules/context/context.go index 4f6c9f4..6cd5159 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -92,9 +92,19 @@ func InitCommand(cmd *cli.Command) *TeaContext { } } + // Create env login before repo context detection so it participates in remote URL matching + var extraLogins []config.Login + envLogin := GetLoginByEnvVar() + if envLogin != nil { + if _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", ""); err != nil { + log.Fatal(err.Error()) + } + extraLogins = append(extraLogins, *envLogin) + } + // try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir, // otherwise attempt PWD. if no repo is found, continue with default login - if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag); err != nil { + if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil { if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { // we can deal with that, commands needing the optional values use ctx.Ensure() } else { @@ -107,13 +117,9 @@ func InitCommand(cmd *cli.Command) *TeaContext { c.RepoSlug = repoFlag } - // override config user with env variable - envLogin := GetLoginByEnvVar() + // If env vars are set, always use the env login (but repo slug was already + // resolved by contextFromLocalRepo with the env login in the match list) if envLogin != nil { - _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", "") - if err != nil { - log.Fatal(err.Error()) - } c.Login = envLogin } diff --git a/modules/context/context_login.go b/modules/context/context_login.go index 5ebf7cb..76223d1 100644 --- a/modules/context/context_login.go +++ b/modules/context/context_login.go @@ -18,6 +18,7 @@ func GetLoginByEnvVar() *config.Login { giteaToken := os.Getenv("GITEA_TOKEN") githubToken := os.Getenv("GH_TOKEN") giteaInstanceURL := os.Getenv("GITEA_INSTANCE_URL") + giteaInstanceSSHHost := os.Getenv("GITEA_INSTANCE_SSH_HOST") instanceInsecure := os.Getenv("GITEA_INSTANCE_INSECURE") insecure := false if len(instanceInsecure) > 0 { @@ -38,6 +39,7 @@ func GetLoginByEnvVar() *config.Login { Name: "GITEA_LOGIN_VIA_ENV", URL: giteaInstanceURL, Token: token, + SSHHost: giteaInstanceSSHHost, Insecure: insecure, SSHKey: "", SSHCertPrincipal: "", diff --git a/modules/context/context_repo.go b/modules/context/context_repo.go index 6072d0e..ca44bfb 100644 --- a/modules/context/context_repo.go +++ b/modules/context/context_repo.go @@ -12,7 +12,7 @@ import ( ) // contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo -func contextFromLocalRepo(repoPath, remoteValue string) (*git.TeaRepo, *config.Login, string, error) { +func contextFromLocalRepo(repoPath, remoteValue string, extraLogins []config.Login) (*git.TeaRepo, *config.Login, string, error) { repo, err := git.RepoFromPath(repoPath) if err != nil { return nil, nil, "", err @@ -69,6 +69,10 @@ func contextFromLocalRepo(repoPath, remoteValue string) (*git.TeaRepo, *config.L if err != nil { return repo, nil, "", err } + // Prepend extra logins (e.g. from env vars) so they are matched first + if len(extraLogins) > 0 { + logins = append(extraLogins, logins...) + } for _, u := range remoteConfig.URLs { if l, p, err := MatchLogins(u, logins); err == nil { return repo, l, p, nil diff --git a/modules/context/context_test.go b/modules/context/context_test.go index 8ffba7c..9b73f88 100644 --- a/modules/context/context_test.go +++ b/modules/context/context_test.go @@ -31,17 +31,37 @@ func Test_MatchLogins(t *testing.T) { expectedRepoPath: "owner/repo", hasError: false, }, + { + remoteURL: "git@custom-ssh.example.com:owner/repo.git", + logins: []config.Login{{Name: "env", URL: "https://gitea.example.com", SSHHost: "custom-ssh.example.com"}}, + matchedLoginName: "env", + expectedRepoPath: "owner/repo", + hasError: false, + }, + { + remoteURL: "https://gitea.example.com/owner/repo.git", + logins: []config.Login{ + {Name: "env", URL: "https://gitea.example.com"}, + {Name: "config", URL: "https://gitea.example.com"}, + }, + matchedLoginName: "env", + expectedRepoPath: "owner/repo", + hasError: false, + }, } for _, kase := range kases { t.Run(kase.remoteURL, func(t *testing.T) { - _, repoPath, err := MatchLogins(kase.remoteURL, kase.logins) + login, repoPath, err := MatchLogins(kase.remoteURL, kase.logins) if (err != nil) != kase.hasError { t.Errorf("Expected error: %v, got: %v", kase.hasError, err) } if repoPath != kase.expectedRepoPath { t.Errorf("Expected repo path: %s, got: %s", kase.expectedRepoPath, repoPath) } + if !kase.hasError && login.Name != kase.matchedLoginName { + t.Errorf("Expected login name: %s, got: %s", kase.matchedLoginName, login.Name) + } }) } } From c031db241317fc30216cafe61271b0d02ba7b94b Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Thu, 26 Feb 2026 17:43:46 +0000 Subject: [PATCH 40/83] Parse multiple values in api subcommand (#911) Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- cmd/api.go | 143 +++++++++-- cmd/api_test.go | 645 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/CLI.md | 2 + 3 files changed, 768 insertions(+), 22 deletions(-) create mode 100644 cmd/api_test.go diff --git a/cmd/api.go b/cmd/api.go index d50c925..6041fe3 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -4,6 +4,7 @@ package cmd import ( + "bytes" stdctx "context" "encoding/json" "fmt" @@ -20,23 +21,11 @@ import ( "golang.org/x/term" ) -// CmdApi represents the api command -var CmdApi = cli.Command{ - Name: "api", - Usage: "Make an authenticated API request", - Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. - -The endpoint argument is the path to the API endpoint, which will be prefixed -with /api/v1/ if it doesn't start with /api/ or http(s)://. - -Placeholders like {owner} and {repo} in the endpoint will be replaced with -values from the current repository context. - -Use -f for string fields and -F for typed fields (numbers, booleans, null). -With -F, prefix value with @ to read from file (@- for stdin).`, - ArgsUsage: "", - Action: runApi, - Flags: append([]cli.Flag{ +// apiFlags returns a fresh set of flag instances for the api command. +// This is a factory function so that each invocation gets independent flag +// objects, avoiding shared hasBeenSet state across tests. +func apiFlags() []cli.Flag { + return []cli.Flag{ &cli.StringFlag{ Name: "method", Aliases: []string{"X"}, @@ -58,6 +47,11 @@ With -F, prefix value with @ to read from file (@- for stdin).`, Aliases: []string{"H"}, Usage: "Add a custom header (key:value)", }, + &cli.StringFlag{ + Name: "data", + Aliases: []string{"d"}, + Usage: "Raw JSON request body (use @file to read from file, @- for stdin)", + }, &cli.BoolFlag{ Name: "include", Aliases: []string{"i"}, @@ -68,7 +62,39 @@ With -F, prefix value with @ to read from file (@- for stdin).`, Aliases: []string{"o"}, Usage: "Write response body to file instead of stdout (use '-' for stdout)", }, - }, flags.LoginRepoFlags...), + } +} + +// CmdApi represents the api command +var CmdApi = cli.Command{ + Name: "api", + Category: catHelpers, + DisableSliceFlagSeparator: true, + Usage: "Make an authenticated API request", + Description: `Makes an authenticated HTTP request to the Gitea API and prints the response. + +The endpoint argument is the path to the API endpoint, which will be prefixed +with /api/v1/ if it doesn't start with /api/ or http(s)://. + +Placeholders like {owner} and {repo} in the endpoint will be replaced with +values from the current repository context. + +Use -f for string fields and -F for typed fields (numbers, booleans, null). +With -F, prefix value with @ to read from file (@- for stdin). Values starting +with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force +string type (e.g., -F key="null" for literal string "null"). + +Use -d/--data to send a raw JSON body. Use @file to read from a file, or @- +to read from stdin. The -d flag cannot be combined with -f or -F. + +When a request body is provided via -f, -F, or -d, the method defaults to POST +unless explicitly set with -X/--method. + +Note: if your endpoint contains ? or &, quote it to prevent shell expansion +(e.g., '/repos/{owner}/{repo}/issues?state=open').`, + ArgsUsage: "", + Action: runApi, + Flags: append(apiFlags(), flags.LoginRepoFlags...), } func runApi(_ stdctx.Context, cmd *cli.Command) error { @@ -97,8 +123,39 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { var body io.Reader stringFields := cmd.StringSlice("field") typedFields := cmd.StringSlice("Field") + dataRaw := cmd.String("data") - if len(stringFields) > 0 || len(typedFields) > 0 { + if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { + return fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") + } + + if dataRaw != "" { + var dataBytes []byte + var dataSource string + if strings.HasPrefix(dataRaw, "@") { + filename := dataRaw[1:] + var err error + if filename == "-" { + dataBytes, err = io.ReadAll(os.Stdin) + dataSource = "stdin" + } else { + dataBytes, err = os.ReadFile(filename) + dataSource = filename + } + if err != nil { + return fmt.Errorf("failed to read %q: %w", dataRaw, err) + } + } else { + dataBytes = []byte(dataRaw) + } + if !json.Valid(dataBytes) { + if dataSource != "" { + return fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) + } + return fmt.Errorf("--data/-d value is not valid JSON") + } + body = bytes.NewReader(dataBytes) + } else if len(stringFields) > 0 || len(typedFields) > 0 { bodyMap := make(map[string]any) // Process string fields (-f) @@ -107,7 +164,14 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { if len(parts) != 2 { return fmt.Errorf("invalid field format: %q (expected key=value)", f) } - bodyMap[parts[0]] = parts[1] + key := parts[0] + if key == "" { + return fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return fmt.Errorf("duplicate field key %q", key) + } + bodyMap[key] = parts[1] } // Process typed fields (-F) @@ -117,6 +181,12 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("invalid field format: %q (expected key=value)", f) } key := parts[0] + if key == "" { + return fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return fmt.Errorf("duplicate field key %q", key) + } value := parts[1] parsedValue, err := parseTypedValue(value) @@ -130,12 +200,19 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return fmt.Errorf("failed to encode request body: %w", err) } - body = strings.NewReader(string(bodyBytes)) + body = bytes.NewReader(bodyBytes) } // Create API client and make request client := api.NewClient(ctx.Login) method := strings.ToUpper(cmd.String("method")) + if !cmd.IsSet("method") { + if body != nil { + method = "POST" + } else { + method = "GET" + } + } resp, err := client.Do(method, endpoint, body, headers) if err != nil { @@ -193,12 +270,16 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { // parseTypedValue parses a value for -F flag, handling: // - @filename: read content from file // - @-: read content from stdin +// - "quoted": literal string (prevents type parsing) // - true/false: boolean // - null: nil // - numbers: int or float +// - []/{}: JSON arrays/objects // - otherwise: string func parseTypedValue(value string) (any, error) { - // Handle file references + // Handle file references. + // Note: if multiple fields use @- (stdin), only the first will get data; + // subsequent reads will return empty since stdin is consumed once. if strings.HasPrefix(value, "@") { filename := value[1:] var content []byte @@ -215,6 +296,16 @@ func parseTypedValue(value string) (any, error) { return strings.TrimSuffix(string(content), "\n"), nil } + // Handle quoted strings (literal strings, no type parsing). + // Uses strconv.Unquote so escape sequences like \" are handled correctly. + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + unquoted, err := strconv.Unquote(value) + if err != nil { + return nil, fmt.Errorf("invalid quoted string %s: %w", value, err) + } + return unquoted, nil + } + // Handle null if value == "null" { return nil, nil @@ -238,6 +329,14 @@ func parseTypedValue(value string) (any, error) { return f, nil } + // Handle JSON arrays and objects + if len(value) > 0 && (value[0] == '[' || value[0] == '{') { + var jsonVal any + if err := json.Unmarshal([]byte(value), &jsonVal); err == nil { + return jsonVal, nil + } + } + // Default to string return value, nil } diff --git a/cmd/api_test.go b/cmd/api_test.go new file mode 100644 index 0000000..62449f3 --- /dev/null +++ b/cmd/api_test.go @@ -0,0 +1,645 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/context" + tea_git "code.gitea.io/tea/modules/git" + + gogit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestParseTypedValue(t *testing.T) { + t.Run("null", func(t *testing.T) { + v, err := parseTypedValue("null") + require.NoError(t, err) + assert.Nil(t, v) + }) + + t.Run("bool true", func(t *testing.T) { + v, err := parseTypedValue("true") + require.NoError(t, err) + assert.Equal(t, true, v) + }) + + t.Run("bool false", func(t *testing.T) { + v, err := parseTypedValue("false") + require.NoError(t, err) + assert.Equal(t, false, v) + }) + + t.Run("integer", func(t *testing.T) { + v, err := parseTypedValue("42") + require.NoError(t, err) + assert.Equal(t, int64(42), v) + }) + + t.Run("float", func(t *testing.T) { + v, err := parseTypedValue("3.14") + require.NoError(t, err) + assert.Equal(t, 3.14, v) + }) + + t.Run("string", func(t *testing.T) { + v, err := parseTypedValue("hello") + require.NoError(t, err) + assert.Equal(t, "hello", v) + }) + + t.Run("JSON array", func(t *testing.T) { + v, err := parseTypedValue("[1,2,3]") + require.NoError(t, err) + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, v) + }) + + t.Run("JSON object", func(t *testing.T) { + v, err := parseTypedValue(`{"key":"val"}`) + require.NoError(t, err) + assert.Equal(t, map[string]any{"key": "val"}, v) + }) + + t.Run("invalid JSON array falls back to string", func(t *testing.T) { + v, err := parseTypedValue("[not json") + require.NoError(t, err) + assert.Equal(t, "[not json", v) + }) + + t.Run("invalid JSON object falls back to string", func(t *testing.T) { + v, err := parseTypedValue("{not json") + require.NoError(t, err) + assert.Equal(t, "{not json", v) + }) + + t.Run("file reference", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("file content\n"), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "file content", v) + }) + + t.Run("file reference without trailing newline", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("no newline"), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "no newline", v) + }) + + t.Run("empty file reference", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "empty.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte(""), 0o644)) + v, err := parseTypedValue("@" + tmpFile) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("nonexistent file reference", func(t *testing.T) { + _, err := parseTypedValue("@/nonexistent/file.txt") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") + }) + + t.Run("negative integer", func(t *testing.T) { + v, err := parseTypedValue("-42") + require.NoError(t, err) + assert.Equal(t, int64(-42), v) + }) + + t.Run("negative float", func(t *testing.T) { + v, err := parseTypedValue("-3.14") + require.NoError(t, err) + assert.Equal(t, -3.14, v) + }) + + t.Run("scientific notation", func(t *testing.T) { + v, err := parseTypedValue("1.5e10") + require.NoError(t, err) + assert.Equal(t, 1.5e10, v) + }) + + t.Run("empty string", func(t *testing.T) { + v, err := parseTypedValue("") + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("string starting with number", func(t *testing.T) { + v, err := parseTypedValue("123abc") + require.NoError(t, err) + assert.Equal(t, "123abc", v) + }) + + t.Run("nested JSON object", func(t *testing.T) { + v, err := parseTypedValue(`{"user":{"name":"alice","id":1}}`) + require.NoError(t, err) + expected := map[string]any{ + "user": map[string]any{ + "name": "alice", + "id": float64(1), + }, + } + assert.Equal(t, expected, v) + }) + + t.Run("complex JSON array", func(t *testing.T) { + v, err := parseTypedValue(`[{"id":1},{"id":2}]`) + require.NoError(t, err) + expected := []any{ + map[string]any{"id": float64(1)}, + map[string]any{"id": float64(2)}, + } + assert.Equal(t, expected, v) + }) + + t.Run("quoted string prevents type parsing", func(t *testing.T) { + v, err := parseTypedValue(`"null"`) + require.NoError(t, err) + assert.Equal(t, "null", v) + }) + + t.Run("quoted true becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"true"`) + require.NoError(t, err) + assert.Equal(t, "true", v) + }) + + t.Run("quoted false becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"false"`) + require.NoError(t, err) + assert.Equal(t, "false", v) + }) + + t.Run("quoted number becomes string", func(t *testing.T) { + v, err := parseTypedValue(`"123"`) + require.NoError(t, err) + assert.Equal(t, "123", v) + }) + + t.Run("quoted empty string", func(t *testing.T) { + v, err := parseTypedValue(`""`) + require.NoError(t, err) + assert.Equal(t, "", v) + }) + + t.Run("quoted string with spaces", func(t *testing.T) { + v, err := parseTypedValue(`"hello world"`) + require.NoError(t, err) + assert.Equal(t, "hello world", v) + }) + + t.Run("single quote not treated as quote", func(t *testing.T) { + v, err := parseTypedValue(`'hello'`) + require.NoError(t, err) + assert.Equal(t, "'hello'", v) + }) + + t.Run("unmatched quote at start only", func(t *testing.T) { + v, err := parseTypedValue(`"hello`) + require.NoError(t, err) + assert.Equal(t, `"hello`, v) + }) + + t.Run("unmatched quote at end only", func(t *testing.T) { + v, err := parseTypedValue(`hello"`) + require.NoError(t, err) + assert.Equal(t, `hello"`, v) + }) + + t.Run("quoted string with escaped quote", func(t *testing.T) { + v, err := parseTypedValue(`"hello \"world\""`) + require.NoError(t, err) + assert.Equal(t, `hello "world"`, v) + }) + + t.Run("quoted string with backslash-n", func(t *testing.T) { + v, err := parseTypedValue(`"line1\nline2"`) + require.NoError(t, err) + assert.Equal(t, "line1\nline2", v) + }) + + t.Run("quoted string with tab escape", func(t *testing.T) { + v, err := parseTypedValue(`"col1\tcol2"`) + require.NoError(t, err) + assert.Equal(t, "col1\tcol2", v) + }) + + t.Run("quoted string with backslash", func(t *testing.T) { + v, err := parseTypedValue(`"path\\to\\file"`) + require.NoError(t, err) + assert.Equal(t, `path\to\file`, v) + }) + + t.Run("invalid escape sequence in quoted string", func(t *testing.T) { + _, err := parseTypedValue(`"bad \z escape"`) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid quoted string") + }) +} + +// runApiWithArgs sets up a test server that captures requests, configures the +// login to point at it, and runs the api command with the given CLI args. +// Returns the captured HTTP method, body bytes, and any error from the command. +func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) { + t.Helper() + + var mu sync.Mutex + var capturedMethod string + var capturedBody []byte + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, readErr := io.ReadAll(r.Body) + if readErr != nil { + t.Fatalf("failed to read request body: %v", readErr) + } + mu.Lock() + capturedMethod = r.Method + capturedBody = b + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(server.Close) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: server.URL, + Token: "test-token", + User: "testUser", + Default: true, + }}, + }) + + // Use the apiFlags factory to get fresh flag instances, avoiding shared + // hasBeenSet state between tests. Append minimal login/repo flags needed + // for the test harness. + cmd := cli.Command{ + Name: "api", + DisableSliceFlagSeparator: true, + Action: runApi, + Flags: append(apiFlags(), []cli.Flag{ + &cli.StringFlag{Name: "login", Aliases: []string{"l"}}, + &cli.StringFlag{Name: "repo", Aliases: []string{"r"}}, + &cli.StringFlag{Name: "remote", Aliases: []string{"R"}}, + }...), + Writer: io.Discard, + ErrWriter: io.Discard, + } + + fullArgs := append([]string{"api", "--login", "testLogin"}, args...) + runErr := cmd.Run(stdctx.Background(), fullArgs) + + mu.Lock() + defer mu.Unlock() + return capturedMethod, capturedBody, runErr +} + +func TestApiCommaInFieldValue(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{"-f", "body=hello, world", "-X", "POST", "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "hello, world", parsed["body"]) +} + +func TestApiRawDataFlag(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{"-d", `{"title":"test","body":"hello"}`, "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "test", parsed["title"]) + assert.Equal(t, "hello", parsed["body"]) +} + +func TestApiDataFieldMutualExclusion(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-f", "key=val", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--data/-d cannot be combined with --field/-f or --Field/-F") +} + +func TestApiMethodAutoDefault(t *testing.T) { + t.Run("POST when body provided without explicit method", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "/test"}) + require.NoError(t, err) + assert.Equal(t, "POST", method) + }) + + t.Run("explicit method overrides auto-POST", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-X", "PATCH", "/test"}) + require.NoError(t, err) + assert.Equal(t, "PATCH", method) + }) + + t.Run("GET when no body", func(t *testing.T) { + method, _, err := runApiWithArgs(t, []string{"/test"}) + require.NoError(t, err) + assert.Equal(t, "GET", method) + }) +} + +func TestApiMultipleFields(t *testing.T) { + t.Run("multiple -f flags", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-f", "title=Test Issue", + "-f", "body=Description here", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "Test Issue", parsed["title"]) + assert.Equal(t, "Description here", parsed["body"]) + }) + + t.Run("multiple -F flags with different types", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", "milestone=5", + "-F", "closed=true", + "-F", "title=Test", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, float64(5), parsed["milestone"]) + assert.Equal(t, true, parsed["closed"]) + assert.Equal(t, "Test", parsed["title"]) + }) + + t.Run("combining -f and -F flags", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-f", "title=Test", + "-F", "milestone=3", + "-F", "closed=false", + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "Test", parsed["title"]) + assert.Equal(t, float64(3), parsed["milestone"]) + assert.Equal(t, false, parsed["closed"]) + }) + + t.Run("-F with JSON array", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `labels=["bug","enhancement"]`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, []any{"bug", "enhancement"}, parsed["labels"]) + }) + + t.Run("-F with JSON object", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `assignee={"login":"alice","id":123}`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assignee, ok := parsed["assignee"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "alice", assignee["login"]) + assert.Equal(t, float64(123), assignee["id"]) + }) + + t.Run("-F with quoted string to prevent type parsing", func(t *testing.T) { + _, body, err := runApiWithArgs(t, []string{ + "-F", `status="null"`, + "-F", `enabled="true"`, + "-F", `count="42"`, + "-X", "POST", + "/test", + }) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "null", parsed["status"]) + assert.Equal(t, "true", parsed["enabled"]) + assert.Equal(t, "42", parsed["count"]) + }) +} + +func TestApiDataFromFile(t *testing.T) { + t.Run("read JSON from file", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "data.json") + jsonData := `{"title":"From File","body":"File content"}` + require.NoError(t, os.WriteFile(tmpFile, []byte(jsonData), 0o644)) + + _, body, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"}) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + assert.Equal(t, "From File", parsed["title"]) + assert.Equal(t, "File content", parsed["body"]) + }) + + t.Run("invalid JSON in --data flag", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-d", `{invalid json}`, "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid JSON") + }) + + t.Run("invalid JSON from file includes filename", func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "bad.json") + require.NoError(t, os.WriteFile(tmpFile, []byte("not json"), 0o644)) + + _, _, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not valid JSON") + assert.Contains(t, err.Error(), "bad.json") + }) +} + +func TestApiErrorHandling(t *testing.T) { + t.Run("missing endpoint argument", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "endpoint argument required") + }) + + t.Run("invalid field format", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "invalidformat", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid field format") + }) + + t.Run("invalid Field format", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "noequalsign", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid field format") + }) + + t.Run("empty field key with -f", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "=value", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field key cannot be empty") + }) + + t.Run("empty field key with -F", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "=123", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field key cannot be empty") + }) + + t.Run("duplicate field key in -f flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "key=first", "-f", "key=second", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) + + t.Run("duplicate field key in -F flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-F", "key=1", "-F", "key=2", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) + + t.Run("duplicate field key across -f and -F flags", func(t *testing.T) { + _, _, err := runApiWithArgs(t, []string{"-f", "key=string", "-F", "key=123", "-X", "POST", "/test"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate field key") + }) +} + +func TestExpandPlaceholders(t *testing.T) { + t.Run("replaces owner and repo", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "myorg", + Repo: "myrepo", + } + result := expandPlaceholders("/repos/{owner}/{repo}/issues", ctx) + assert.Equal(t, "/repos/myorg/myrepo/issues", result) + }) + + t.Run("replaces multiple occurrences", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches?owner={owner}", ctx) + assert.Equal(t, "/repos/alice/proj/branches?owner=alice", result) + }) + + t.Run("no placeholders returns unchanged", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/api/v1/version", ctx) + assert.Equal(t, "/api/v1/version", result) + }) + + t.Run("empty owner and repo produce empty replacements", func(t *testing.T) { + ctx := &context.TeaContext{} + result := expandPlaceholders("/repos/{owner}/{repo}", ctx) + assert.Equal(t, "/repos//", result) + }) + + t.Run("branch left unreplaced when no local repo", func(t *testing.T) { + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx) + assert.Equal(t, "/repos/alice/proj/branches/{branch}", result) + }) + + t.Run("replaces branch from local repo HEAD", func(t *testing.T) { + tmpDir := t.TempDir() + repo, err := gogit.PlainInit(tmpDir, false) + require.NoError(t, err) + + // Create an initial commit so HEAD points to a branch. + wt, err := repo.Worktree() + require.NoError(t, err) + tmpFile := filepath.Join(tmpDir, "init.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("init"), 0o644)) + _, err = wt.Add("init.txt") + require.NoError(t, err) + _, err = wt.Commit("initial commit", &gogit.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com"}, + }) + require.NoError(t, err) + + // Create and checkout a feature branch. + headRef, err := repo.Head() + require.NoError(t, err) + branchRef := plumbing.NewBranchReferenceName("feature/my-branch") + ref := plumbing.NewHashReference(branchRef, headRef.Hash()) + require.NoError(t, repo.Storer.SetReference(ref)) + require.NoError(t, wt.Checkout(&gogit.CheckoutOptions{Branch: branchRef})) + + ctx := &context.TeaContext{ + Owner: "alice", + Repo: "proj", + LocalRepo: &tea_git.TeaRepo{Repository: repo}, + } + result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx) + assert.Equal(t, "/repos/alice/proj/branches/feature/my-branch", result) + }) +} + +func TestIsTextContentType(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + {"empty string defaults to text", "", true}, + {"plain text", "text/plain", true}, + {"html", "text/html", true}, + {"json", "application/json", true}, + {"json with charset", "application/json; charset=utf-8", true}, + {"xml", "application/xml", true}, + {"javascript", "application/javascript", true}, + {"yaml", "application/yaml", true}, + {"toml", "application/toml", true}, + {"binary", "application/octet-stream", false}, + {"image", "image/png", false}, + {"pdf", "application/pdf", false}, + {"zip", "application/zip", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isTextContentType(tt.contentType) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index c5ab769..f79580f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1819,6 +1819,8 @@ Make an authenticated API request **--Field, -F**="": Add a typed field to the request body (key=value, @file, or @- for stdin) +**--data, -d**="": Raw JSON request body (use @file to read from file, @- for stdin) + **--field, -f**="": Add a string field to the request body (key=value) **--header, -H**="": Add a custom header (key:value) From d019f0dd72702ac7805a164aceea960933e2da28 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 26 Feb 2026 17:59:28 +0000 Subject: [PATCH 41/83] fix(deps): update module github.com/go-git/go-git/v5 to v5.17.0 (#910) Reviewed-on: https://gitea.com/gitea/tea/pulls/910 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index fdd3980..ba3451d 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2 github.com/enescakir/emoji v1.0.0 - github.com/go-git/go-git/v5 v5.16.5 + github.com/go-git/go-git/v5 v5.17.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.3 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 @@ -61,7 +61,7 @@ require ( github.com/fatih/color v1.18.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-billy/v5 v5.8.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect diff --git a/go.sum b/go.sum index c65780c..8ffe8e7 100644 --- a/go.sum +++ b/go.sum @@ -112,10 +112,14 @@ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66D 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-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= 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.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= 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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= From 1ac8492ac7f3fd1b878de7de60f08cc8e05759a9 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Mon, 9 Mar 2026 15:57:54 +0000 Subject: [PATCH 42/83] go 1.26 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index ba3451d..6a4730e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module code.gitea.io/tea -go 1.25 +go 1.26 require ( code.gitea.io/gitea-vet v0.2.3 From 3372c9ec59c136a23439edf0679059eaa35e43c4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 Mar 2026 16:19:28 +0000 Subject: [PATCH 43/83] fix(deps): update module golang.org/x/oauth2 to v0.36.0 (#919) Reviewed-on: https://gitea.com/gitea/tea/pulls/919 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6a4730e..dc70417 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.48.0 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.41.0 golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 8ffe8e7..e03ad9d 100644 --- a/go.sum +++ b/go.sum @@ -241,6 +241,8 @@ 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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= From c797624fcf2361abc57133e0846718714467771e Mon Sep 17 00:00:00 2001 From: Michal Suchanek Date: Mon, 9 Mar 2026 16:36:00 +0000 Subject: [PATCH 44/83] Update to charm libraries v2 (#923) Reviewed-on: https://gitea.com/gitea/tea/pulls/923 Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com> Co-authored-by: Michal Suchanek Co-committed-by: Michal Suchanek --- cmd/comment.go | 2 +- cmd/repos/delete.go | 2 +- go.mod | 33 +++++------ go.sum | 87 ++++++++++++---------------- modules/config/login.go | 2 +- modules/context/context.go | 2 +- modules/interact/comments.go | 2 +- modules/interact/issue_create.go | 2 +- modules/interact/issue_edit.go | 2 +- modules/interact/login.go | 2 +- modules/interact/milestone_create.go | 2 +- modules/interact/print.go | 6 +- modules/interact/prompts.go | 2 +- modules/interact/pull_create.go | 2 +- modules/interact/pull_merge.go | 2 +- modules/interact/pull_review.go | 2 +- modules/print/markdown.go | 24 ++++---- modules/theme/theme.go | 22 +++---- 18 files changed, 94 insertions(+), 104 deletions(-) diff --git a/cmd/comment.go b/cmd/comment.go index a344dcd..2fe93eb 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -19,7 +19,7 @@ import ( "code.gitea.io/tea/modules/utils" "code.gitea.io/sdk/gitea" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/urfave/cli/v3" ) diff --git a/cmd/repos/delete.go b/cmd/repos/delete.go index da44a33..517ea85 100644 --- a/cmd/repos/delete.go +++ b/cmd/repos/delete.go @@ -10,7 +10,7 @@ import ( "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/urfave/cli/v3" ) diff --git a/go.mod b/go.mod index dc70417..39cc4fb 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module code.gitea.io/tea go 1.26 require ( + charm.land/glamour/v2 v2.0.0 + charm.land/huh/v2 v2.0.1 + charm.land/lipgloss/v2 v2.0.1 code.gitea.io/gitea-vet v0.2.3 code.gitea.io/sdk/gitea v0.23.2 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de - github.com/charmbracelet/glamour v0.10.0 - github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2 github.com/enescakir/emoji v1.0.0 github.com/go-git/go-git/v5 v5.17.0 github.com/muesli/termenv v0.16.0 @@ -21,14 +21,14 @@ require ( github.com/urfave/cli/v3 v3.6.2 golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.42.0 golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) -replace github.com/muesli/termenv v0.16.0 => github.com/hramrach/termenv v0.16.1-0.20260212100405-cc30261f3059 - require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // 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 @@ -38,17 +38,17 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect @@ -57,7 +57,6 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -71,13 +70,10 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.4 // indirect @@ -92,6 +88,7 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index e03ad9d..508dcc1 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,13 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +charm.land/huh/v2 v2.0.1 h1:9vhBjlIDuikdPKH+qnoG++GERVxqY0Lkv14xW57lj98= +charm.land/huh/v2 v2.0.1/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= @@ -33,34 +43,26 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= -github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= -github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2 h1:jvxZhg+J/80xXR7cE07p0/aFE1BrxkUw0R2CH04CZOM= -github.com/charmbracelet/lipgloss v1.1.1-0.20260202080749-832bc9d6b9d2/go.mod h1:D4YudnJlpIa3bcKpFSigAEWd31pQMgYu3pFE94b/1mc= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20250609102027-b60490452b30 h1:lF42GCGfbMxx4SOYkjChVoUDexdM/hQ4DWnAHcJ/6K0= -github.com/charmbracelet/x/exp/golden v0.0.0-20250609102027-b60490452b30/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee h1:B/JPEPNGIHyyhCPM483B+cfJQ1+9S2YBPWoTAJw3Ut0= github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= @@ -69,14 +71,14 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= 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/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= @@ -100,8 +102,6 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog= github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -110,14 +110,10 @@ 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-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= 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.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= -github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -130,8 +126,6 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hramrach/termenv v0.16.1-0.20260212100405-cc30261f3059 h1:xxfLFNkkQNJqA7Tieg/oBg/7Wk24pbEFK1VnbkrnTo8= -github.com/hramrach/termenv v0.16.1-0.20260212100405-cc30261f3059/go.mod h1:jeqvVfGyGmpCFfP9fK4yIWvxcMb8ApE3EPBq5fCzaaU= 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/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= @@ -151,22 +145,17 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP 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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= @@ -184,7 +173,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE 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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -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= @@ -239,8 +227,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v 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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -254,11 +240,10 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= diff --git a/modules/config/login.go b/modules/config/login.go index fd6c5df..545ab9b 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -21,7 +21,7 @@ import ( "code.gitea.io/tea/modules/httputil" "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "golang.org/x/oauth2" ) diff --git a/modules/context/context.go b/modules/context/context.go index 6cd5159..901e224 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -15,7 +15,7 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" gogit "github.com/go-git/go-git/v5" "github.com/urfave/cli/v3" "golang.org/x/term" diff --git a/modules/interact/comments.go b/modules/interact/comments.go index 42c7dcc..93b18f0 100644 --- a/modules/interact/comments.go +++ b/modules/interact/comments.go @@ -13,7 +13,7 @@ import ( "code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/theme" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "golang.org/x/term" ) diff --git a/modules/interact/issue_create.go b/modules/interact/issue_create.go index 4d23e9c..bfbafbd 100644 --- a/modules/interact/issue_create.go +++ b/modules/interact/issue_create.go @@ -11,7 +11,7 @@ import ( "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/theme" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // IsQuitting checks if the user has aborted the interactive prompt diff --git a/modules/interact/issue_edit.go b/modules/interact/issue_edit.go index 6876b90..53cdcf2 100644 --- a/modules/interact/issue_edit.go +++ b/modules/interact/issue_edit.go @@ -12,7 +12,7 @@ import ( "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/theme" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // EditIssue interactively edits an issue diff --git a/modules/interact/login.go b/modules/interact/login.go index 58b9002..2b0abab 100644 --- a/modules/interact/login.go +++ b/modules/interact/login.go @@ -17,7 +17,7 @@ import ( "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/theme" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // CreateLogin create an login interactive diff --git a/modules/interact/milestone_create.go b/modules/interact/milestone_create.go index 15166e8..050204e 100644 --- a/modules/interact/milestone_create.go +++ b/modules/interact/milestone_create.go @@ -12,7 +12,7 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/sdk/gitea" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // CreateMilestone interactively creates a milestone diff --git a/modules/interact/print.go b/modules/interact/print.go index 8a213b3..fa2a6cd 100644 --- a/modules/interact/print.go +++ b/modules/interact/print.go @@ -5,16 +5,18 @@ package interact import ( "fmt" + "os" "code.gitea.io/tea/modules/theme" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // printTitleAndContent prints a title and content with the gitea theme func printTitleAndContent(title, content string) { + hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) style := lipgloss.NewStyle(). - Foreground(theme.GetTheme().Blurred.Title.GetForeground()).Bold(true). + Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true). Padding(0, 1) fmt.Print(style.Render(title), content+"\n") } diff --git a/modules/interact/prompts.go b/modules/interact/prompts.go index bb0ee9c..33e5244 100644 --- a/modules/interact/prompts.go +++ b/modules/interact/prompts.go @@ -11,7 +11,7 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // PromptPassword asks for a password and blocks until input was made. diff --git a/modules/interact/pull_create.go b/modules/interact/pull_create.go index 802d6e6..3bd19d2 100644 --- a/modules/interact/pull_create.go +++ b/modules/interact/pull_create.go @@ -9,7 +9,7 @@ import ( "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/theme" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // CreatePull interactively creates a PR diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index 583438e..266acbf 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -13,7 +13,7 @@ import ( "code.gitea.io/tea/modules/utils" "code.gitea.io/sdk/gitea" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // MergePull interactively creates a PR diff --git a/modules/interact/pull_review.go b/modules/interact/pull_review.go index dd48c4d..dfbec15 100644 --- a/modules/interact/pull_review.go +++ b/modules/interact/pull_review.go @@ -14,7 +14,7 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/sdk/gitea" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) var reviewStates = map[string]gitea.ReviewStateType{ diff --git a/modules/print/markdown.go b/modules/print/markdown.go index cfe5540..5db6d27 100644 --- a/modules/print/markdown.go +++ b/modules/print/markdown.go @@ -7,7 +7,7 @@ import ( "fmt" "os" - "github.com/charmbracelet/glamour" + "charm.land/glamour/v2" "golang.org/x/term" ) @@ -15,19 +15,23 @@ import ( // If the input could not be parsed, it is printed unformatted, the error // is returned anyway. func outputMarkdown(markdown string, baseURL string) error { - var styleOption glamour.TermRendererOption + var renderer *glamour.TermRenderer + var err error if IsInteractive() { - styleOption = glamour.WithAutoStyle() + renderer, err = glamour.NewTermRenderer( + glamour.WithBaseURL(baseURL), + glamour.WithPreservedNewLines(), + glamour.WithWordWrap(getWordWrap()), + ) } else { - styleOption = glamour.WithStandardStyle("notty") + renderer, err = glamour.NewTermRenderer( + glamour.WithStandardStyle("notty"), + glamour.WithBaseURL(baseURL), + glamour.WithPreservedNewLines(), + glamour.WithWordWrap(getWordWrap()), + ) } - renderer, err := glamour.NewTermRenderer( - styleOption, - glamour.WithBaseURL(baseURL), - glamour.WithPreservedNewLines(), - glamour.WithWordWrap(getWordWrap()), - ) if err != nil { fmt.Print(markdown) return err diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 04221e8..11c0f12 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -4,20 +4,22 @@ package theme import ( - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" ) -var giteaTheme = func() *huh.Theme { - theme := huh.ThemeCharm() +type myTheme struct {} - title := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"} +func (t myTheme) Theme (isDark bool) *huh.Styles { + theme := huh.ThemeCharm(isDark) + + title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true) theme.Blurred = theme.Focused return theme -}() - -// GetTheme returns the Gitea theme for Huh -func GetTheme() *huh.Theme { - return giteaTheme +} +func GetTheme() myTheme { + var t myTheme + return t } From cd4051ed38036ff599bf549087c67e12015c42f1 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 10 Mar 2026 09:55:10 -0400 Subject: [PATCH 45/83] make vet&fmt pass --- cmd/comment.go | 2 +- modules/config/login.go | 1 + modules/interact/milestone_create.go | 2 +- modules/interact/prompts.go | 1 + modules/interact/pull_merge.go | 2 +- modules/interact/pull_review.go | 2 +- modules/theme/theme.go | 5 +++-- 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cmd/comment.go b/cmd/comment.go index 2fe93eb..9c03cba 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -10,6 +10,7 @@ import ( "io" "strings" + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/context" @@ -18,7 +19,6 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" - "code.gitea.io/sdk/gitea" "charm.land/huh/v2" "github.com/urfave/cli/v3" ) diff --git a/modules/config/login.go b/modules/config/login.go index 545ab9b..fd3200d 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/tea/modules/httputil" "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" + "charm.land/huh/v2" "golang.org/x/oauth2" ) diff --git a/modules/interact/milestone_create.go b/modules/interact/milestone_create.go index 050204e..4f2240f 100644 --- a/modules/interact/milestone_create.go +++ b/modules/interact/milestone_create.go @@ -7,11 +7,11 @@ import ( "fmt" "time" + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/theme" - "code.gitea.io/sdk/gitea" "charm.land/huh/v2" ) diff --git a/modules/interact/prompts.go b/modules/interact/prompts.go index 33e5244..539be86 100644 --- a/modules/interact/prompts.go +++ b/modules/interact/prompts.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" + "charm.land/huh/v2" ) diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index 266acbf..863345e 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -7,12 +7,12 @@ import ( "fmt" "strings" + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/utils" - "code.gitea.io/sdk/gitea" "charm.land/huh/v2" ) diff --git a/modules/interact/pull_review.go b/modules/interact/pull_review.go index dfbec15..6975fa0 100644 --- a/modules/interact/pull_review.go +++ b/modules/interact/pull_review.go @@ -8,12 +8,12 @@ import ( "os" "strconv" + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/theme" - "code.gitea.io/sdk/gitea" "charm.land/huh/v2" ) diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 11c0f12..2b36916 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -9,9 +9,9 @@ import ( "charm.land/lipgloss/v2/compat" ) -type myTheme struct {} +type myTheme struct{} -func (t myTheme) Theme (isDark bool) *huh.Styles { +func (t myTheme) Theme(isDark bool) *huh.Styles { theme := huh.ThemeCharm(isDark) title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} @@ -19,6 +19,7 @@ func (t myTheme) Theme (isDark bool) *huh.Styles { theme.Blurred = theme.Focused return theme } + func GetTheme() myTheme { var t myTheme return t From 0346e1cbb5793c7eeec60b12e86868c5226bc832 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Tue, 10 Mar 2026 10:00:39 -0400 Subject: [PATCH 46/83] add function comment --- modules/theme/theme.go | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 2b36916..89790bb 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -20,6 +20,7 @@ func (t myTheme) Theme(isDark bool) *huh.Styles { return theme } +// GetTheme returns default theme func GetTheme() myTheme { var t myTheme return t From 302c946cb8866d0fd28ee798e749bf7be40e72a6 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 12 Mar 2026 02:49:14 +0000 Subject: [PATCH 47/83] feat: store OAuth tokens in OS keyring via credstore (#926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Introduce `github.com/go-authgate/sdk-go/credstore` to store OAuth tokens securely in the OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Manager), with automatic fallback to an encrypted JSON file - Add `AuthMethod` field to `Login` struct; new OAuth logins are marked `auth_method: oauth` and no longer write `token`/`refresh_token`/`token_expiry` to `config.yml` - Add `GetAccessToken()` / `GetRefreshToken()` / `GetTokenExpiry()` accessors that transparently read from credstore for OAuth logins, with fallback to YAML fields for legacy logins - Update all token reference sites across the codebase to use the new accessors - Non-OAuth logins (token, SSH) are completely unaffected; no migration of existing tokens ## Key files | File | Role | |------|------| | `modules/config/credstore.go` | **New** — credstore wrapper (Load/Save/Delete) | | `modules/config/login.go` | Login struct, token accessors, refresh logic | | `modules/auth/oauth.go` | OAuth flow, token creation / re-authentication | | `modules/api/client.go`, `cmd/login/helper.go`, `cmd/login/oauth_refresh.go` | Token reference updates | | `modules/task/pull_*.go`, `modules/task/repo_clone.go` | Git operation token reference updates | ## Test plan - [x] `go build ./...` compiles successfully - [x] `go test ./...` all tests pass - [x] `tea login add --oauth` completes OAuth flow; verify config.yml has `auth_method: oauth` but no token/refresh_token/token_expiry - [x] `tea repos ls` API calls work (token read from credstore) - [x] `tea login delete ` credstore token is also removed - [x] Existing non-OAuth logins continue to work unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.com/gitea/tea/pulls/926 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/login/helper.go | 4 +- cmd/login/oauth_refresh.go | 2 +- go.mod | 5 ++ go.sum | 14 ++++ modules/api/client.go | 2 +- modules/auth/oauth.go | 27 ++++--- modules/config/credstore.go | 65 ++++++++++++++++ modules/config/login.go | 135 +++++++++++++++++++++++++++++----- modules/task/pull_checkout.go | 2 +- modules/task/pull_clean.go | 2 +- modules/task/pull_create.go | 2 +- modules/task/repo_clone.go | 2 +- modules/theme/theme.go | 12 +-- 13 files changed, 231 insertions(+), 43 deletions(-) create mode 100644 modules/config/credstore.go diff --git a/cmd/login/helper.go b/cmd/login/helper.go index dee092d..f2efcce 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -112,7 +112,7 @@ var CmdLoginHelper = cli.Command{ } } - if len(userConfig.Token) == 0 { + if len(userConfig.GetAccessToken()) == 0 { log.Fatal("User not set") } @@ -126,7 +126,7 @@ var CmdLoginHelper = cli.Command{ return err } - _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token) + _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.GetAccessToken()) if err != nil { return err } diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go index 4369cdb..653882e 100644 --- a/cmd/login/oauth_refresh.go +++ b/cmd/login/oauth_refresh.go @@ -44,7 +44,7 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { } // Check if the login has a refresh token - if login.RefreshToken == "" { + if login.GetRefreshToken() == "" { return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) } diff --git a/go.mod b/go.mod index 39cc4fb..1ac8ea8 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 + github.com/go-authgate/sdk-go v0.2.0 github.com/go-git/go-git/v5 v5.17.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.3 @@ -27,6 +28,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect dario.cat/mergo v1.0.2 // indirect @@ -52,6 +54,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect @@ -61,6 +64,7 @@ require ( 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.8.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect @@ -87,6 +91,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 508dcc1..69c02c9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= @@ -87,6 +89,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -106,6 +110,8 @@ 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/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-authgate/sdk-go v0.2.0 h1:w22f+sAg/YMqnLOcS/4SAuMZXTbPurzkSQBsjb1hcbw= +github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho= 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= @@ -116,10 +122,14 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj 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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +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= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= @@ -190,6 +200,8 @@ github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqR github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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= @@ -208,6 +220,8 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/modules/api/client.go b/modules/api/client.go index 80e2d46..853a2a0 100644 --- a/modules/api/client.go +++ b/modules/api/client.go @@ -38,7 +38,7 @@ func NewClient(login *config.Login) *Client { return &Client{ baseURL: strings.TrimSuffix(login.URL, "/"), - token: login.Token, + token: login.GetAccessToken(), httpClient: httpClient, } } diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 2861da6..82f0b43 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -377,22 +377,17 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure } } - // Create login object + // Create login object with OAuth auth method login := config.Login{ Name: name, URL: serverURL, - Token: token.AccessToken, - RefreshToken: token.RefreshToken, + Token: token.AccessToken, // temporarily set for Client() validation + AuthMethod: config.AuthMethodOAuth, Insecure: insecure, VersionCheck: true, Created: time.Now().Unix(), } - // Set token expiry if available - if !token.Expiry.IsZero() { - login.TokenExpiry = token.Expiry.Unix() - } - // Validate token by getting user info client := login.Client() u, _, err := client.GetMyUserInfo() @@ -400,6 +395,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure return fmt.Errorf("failed to validate token: %s", err) } + // Clear token from YAML fields (will be stored in credstore) + login.Token = "" + // Set user info login.User = u.UserName @@ -415,6 +413,11 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure return err } + // Save tokens to credstore + if err := config.SaveOAuthToken(login.Name, token.AccessToken, token.RefreshToken, token.Expiry); err != nil { + return fmt.Errorf("failed to save token to secure store: %s", err) + } + fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name) return nil } @@ -443,7 +446,11 @@ func ReauthenticateLogin(login *config.Login) error { return err } - // Update the existing login with new token data + if login.IsOAuth() { + return config.SaveOAuthTokenFromOAuth2(login.Name, token, login) + } + + // Legacy path for non-OAuth logins login.Token = token.AccessToken if token.RefreshToken != "" { login.RefreshToken = token.RefreshToken @@ -451,7 +458,5 @@ func ReauthenticateLogin(login *config.Login) error { if !token.Expiry.IsZero() { login.TokenExpiry = token.Expiry.Unix() } - - // Save updated login return config.SaveLoginTokens(login) } diff --git a/modules/config/credstore.go b/modules/config/credstore.go new file mode 100644 index 0000000..18f8791 --- /dev/null +++ b/modules/config/credstore.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "path/filepath" + "sync" + "time" + + "github.com/adrg/xdg" + "github.com/go-authgate/sdk-go/credstore" + "golang.org/x/oauth2" +) + +var ( + tokenStore *credstore.SecureStore[credstore.Token] + tokenStoreOnce sync.Once +) + +func getTokenStore() *credstore.SecureStore[credstore.Token] { + tokenStoreOnce.Do(func() { + filePath := filepath.Join(xdg.ConfigHome, "tea", "credentials.json") + tokenStore = credstore.DefaultTokenSecureStore("tea-cli", filePath) + }) + return tokenStore +} + +// LoadOAuthToken loads OAuth tokens from the secure store. +func LoadOAuthToken(loginName string) (*credstore.Token, error) { + tok, err := getTokenStore().Load(loginName) + if err != nil { + return nil, err + } + return &tok, nil +} + +// SaveOAuthToken saves OAuth tokens to the secure store. +func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error { + return getTokenStore().Save(loginName, credstore.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + ClientID: loginName, + }) +} + +// DeleteOAuthToken removes tokens from the secure store. +func DeleteOAuthToken(loginName string) error { + return getTokenStore().Delete(loginName) +} + +// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to +// the existing login's values for empty refresh token or zero expiry. +func SaveOAuthTokenFromOAuth2(loginName string, token *oauth2.Token, login *Login) error { + refreshToken := token.RefreshToken + if refreshToken == "" { + refreshToken = login.GetRefreshToken() + } + expiry := token.Expiry + if expiry.IsZero() { + expiry = login.GetTokenExpiry() + } + return SaveOAuthToken(loginName, token.AccessToken, refreshToken, expiry) +} diff --git a/modules/config/login.go b/modules/config/login.go index fd3200d..d6ac4c2 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -33,11 +33,14 @@ const TokenRefreshThreshold = 5 * time.Minute // DefaultClientID is the default OAuth2 client ID included in most Gitea instances const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" +// AuthMethodOAuth marks a login as using OAuth with secure credential storage. +const AuthMethodOAuth = "oauth" + // Login represents a login to a gitea server, you even could add multiple logins for one gitea server type Login struct { Name string `yaml:"name"` URL string `yaml:"url"` - Token string `yaml:"token"` + Token string `yaml:"token,omitempty"` Default bool `yaml:"default"` SSHHost string `yaml:"ssh_host"` // optional path to the private key @@ -52,10 +55,66 @@ type Login struct { User string `yaml:"user"` // Created is auto created unix timestamp Created int64 `yaml:"created"` + // AuthMethod indicates the authentication method ("oauth" for OAuth with credstore) + AuthMethod string `yaml:"auth_method,omitempty"` // RefreshToken is used to renew the access token when it expires - RefreshToken string `yaml:"refresh_token"` + RefreshToken string `yaml:"refresh_token,omitempty"` // TokenExpiry is when the token expires (unix timestamp) - TokenExpiry int64 `yaml:"token_expiry"` + TokenExpiry int64 `yaml:"token_expiry,omitempty"` +} + +// IsOAuth returns true if this login uses OAuth with secure credential storage. +func (l *Login) IsOAuth() bool { + return l.AuthMethod == AuthMethodOAuth +} + +// loadOAuthToken loads the OAuth token from credstore, returning nil if +// this is not an OAuth login or if the load fails (caller should fallback). +func (l *Login) loadOAuthToken() *OAuthToken { + if !l.IsOAuth() { + return nil + } + tok, err := LoadOAuthToken(l.Name) + if err != nil { + return nil + } + return &OAuthToken{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresAt: tok.ExpiresAt, + } +} + +// OAuthToken holds the token fields loaded from credstore. +type OAuthToken struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAccessToken returns the effective access token. +// For OAuth logins, reads from credstore. For others, returns l.Token directly. +func (l *Login) GetAccessToken() string { + if tok := l.loadOAuthToken(); tok != nil { + return tok.AccessToken + } + return l.Token +} + +// GetRefreshToken returns the refresh token. +func (l *Login) GetRefreshToken() string { + if tok := l.loadOAuthToken(); tok != nil { + return tok.RefreshToken + } + return l.RefreshToken +} + +// GetTokenExpiry returns the token expiry time. +func (l *Login) GetTokenExpiry() time.Time { + if tok := l.loadOAuthToken(); tok != nil { + return tok.ExpiresAt + } + return time.Unix(l.TokenExpiry, 0) } // GetLogins return all login available by config @@ -180,8 +239,14 @@ func DeleteLogin(name string) error { return fmt.Errorf("can not delete login '%s', does not exist", name) } + isOAuth := config.Logins[idx].IsOAuth() config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...) + // Clean up credstore tokens for OAuth logins + if isOAuth { + _ = DeleteOAuthToken(name) + } + return saveConfigUnsafe() }) } @@ -207,6 +272,9 @@ func AddLogin(login *Login) error { // SaveLoginTokens updates the token fields for an existing login. // This is used after browser-based re-authentication to save new tokens. func SaveLoginTokens(login *Login) error { + if login.IsOAuth() { + return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry()) + } return withConfigLock(func() error { for i, l := range config.Logins { if strings.EqualFold(l.Name, login.Name) { @@ -223,11 +291,21 @@ func SaveLoginTokens(login *Login) error { // RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry. // Returns nil without doing anything if no refresh is needed. func (l *Login) RefreshOAuthTokenIfNeeded() error { + // Load once to avoid multiple credstore reads + if tok := l.loadOAuthToken(); tok != nil { + if tok.RefreshToken == "" || tok.ExpiresAt.IsZero() { + return nil + } + if time.Now().Add(TokenRefreshThreshold).After(tok.ExpiresAt) { + return l.RefreshOAuthToken() + } + return nil + } + // Non-OAuth path: use YAML fields if l.RefreshToken == "" || l.TokenExpiry == 0 { return nil } - expiryTime := time.Unix(l.TokenExpiry, 0) - if time.Now().Add(TokenRefreshThreshold).After(expiryTime) { + if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) { return l.RefreshOAuthToken() } return nil @@ -238,7 +316,7 @@ func (l *Login) RefreshOAuthTokenIfNeeded() error { // Uses double-checked locking to avoid unnecessary refresh calls when multiple // processes race to refresh the same token. func (l *Login) RefreshOAuthToken() error { - if l.RefreshToken == "" { + if l.GetRefreshToken() == "" { return fmt.Errorf("no refresh token available") } @@ -248,13 +326,17 @@ func (l *Login) RefreshOAuthToken() error { for i, login := range config.Logins { if login.Name == l.Name { // Check if token was refreshed by another process - if login.TokenExpiry != l.TokenExpiry && login.TokenExpiry > 0 { - expiryTime := time.Unix(login.TokenExpiry, 0) - if time.Now().Add(TokenRefreshThreshold).Before(expiryTime) { + currentExpiry := login.GetTokenExpiry() + ourExpiry := l.GetTokenExpiry() + if currentExpiry != ourExpiry && !currentExpiry.IsZero() { + if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) { // Token was refreshed by another process, update our copy - l.Token = login.Token - l.RefreshToken = login.RefreshToken - l.TokenExpiry = login.TokenExpiry + if !login.IsOAuth() { + l.Token = login.Token + l.RefreshToken = login.RefreshToken + l.TokenExpiry = login.TokenExpiry + } + // For OAuth logins, credstore already has the latest tokens return nil } } @@ -265,7 +347,12 @@ func (l *Login) RefreshOAuthToken() error { return err } - // Update login with new token information + if l.IsOAuth() { + // Save tokens to credstore; no YAML changes needed + return SaveOAuthTokenFromOAuth2(l.Name, newToken, l) + } + + // Update login with new token information (legacy path) l.Token = newToken.AccessToken if newToken.RefreshToken != "" { l.RefreshToken = newToken.RefreshToken @@ -273,8 +360,6 @@ func (l *Login) RefreshOAuthToken() error { if !newToken.Expiry.IsZero() { l.TokenExpiry = newToken.Expiry.Unix() } - - // Update in config slice and save config.Logins[i] = *l return saveConfigUnsafe() } @@ -286,10 +371,22 @@ func (l *Login) RefreshOAuthToken() error { // doOAuthRefresh performs the actual OAuth token refresh API call. func doOAuthRefresh(l *Login) (*oauth2.Token, error) { + // Build current token from credstore (single load) or YAML fields + var accessToken, refreshToken string + var expiry time.Time + if tok := l.loadOAuthToken(); tok != nil { + accessToken = tok.AccessToken + refreshToken = tok.RefreshToken + expiry = tok.ExpiresAt + } else { + accessToken = l.Token + refreshToken = l.RefreshToken + expiry = time.Unix(l.TokenExpiry, 0) + } currentToken := &oauth2.Token{ - AccessToken: l.Token, - RefreshToken: l.RefreshToken, - Expiry: time.Unix(l.TokenExpiry, 0), + AccessToken: accessToken, + RefreshToken: refreshToken, + Expiry: expiry, } ctx := context.Background() @@ -341,7 +438,7 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...) } - options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) + options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) if debug.IsDebug() { options = append(options, gitea.SetDebugMode()) } diff --git a/modules/task/pull_checkout.go b/modules/task/pull_checkout.go index 2e6e8b1..d73a2ec 100644 --- a/modules/task/pull_checkout.go +++ b/modules/task/pull_checkout.go @@ -85,7 +85,7 @@ func doPRFetch( if err != nil { return "", err } - auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback) + auth, err := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKey, callback) if err != nil { return "", err } diff --git a/modules/task/pull_clean.go b/modules/task/pull_clean.go index c09647c..0f1ad82 100644 --- a/modules/task/pull_clean.go +++ b/modules/task/pull_clean.go @@ -96,7 +96,7 @@ call me again with the --ignore-sha flag`, remoteBranch) if urlErr != nil { return urlErr } - auth, authErr := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback) + auth, authErr := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKey, callback) if authErr != nil { return authErr } diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index 33e2f5a..58c56bb 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -211,7 +211,7 @@ func CreateAgitFlowPull(ctx *context.TeaContext, remote, head, base, topic strin return err } - auth, err := local_git.GetAuthForURL(url, ctx.Login.Token, ctx.Login.SSHKey, callback) + auth, err := local_git.GetAuthForURL(url, ctx.Login.GetAccessToken(), ctx.Login.SSHKey, callback) if err != nil { return err } diff --git a/modules/task/repo_clone.go b/modules/task/repo_clone.go index 1a56147..b23af21 100644 --- a/modules/task/repo_clone.go +++ b/modules/task/repo_clone.go @@ -35,7 +35,7 @@ func RepoClone( return nil, err } - auth, err := local_git.GetAuthForURL(originURL, login.Token, login.SSHKey, callback) + auth, err := local_git.GetAuthForURL(originURL, login.GetAccessToken(), login.SSHKey, callback) if err != nil { return nil, err } diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 89790bb..6819835 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -9,9 +9,11 @@ import ( "charm.land/lipgloss/v2/compat" ) -type myTheme struct{} +// TeaTheme implements the huh.Theme interface with tea-cli styling. +type TeaTheme struct{} -func (t myTheme) Theme(isDark bool) *huh.Styles { +// Theme implements the huh.Theme interface. +func (t TeaTheme) Theme(isDark bool) *huh.Styles { theme := huh.ThemeCharm(isDark) title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} @@ -20,8 +22,8 @@ func (t myTheme) Theme(isDark bool) *huh.Styles { return theme } -// GetTheme returns default theme -func GetTheme() myTheme { - var t myTheme +// GetTheme returns the default theme for huh prompts. +func GetTheme() TeaTheme { + var t TeaTheme return t } From a531faa626a14a6a27134c526165d43dabbe749c Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 12 Mar 2026 03:06:44 +0000 Subject: [PATCH 48/83] feat(repos): add `repo edit` subcommand (#928) ## Summary - Add `tea repo edit` subcommand to update repository properties via the Gitea API - Support flags: `--name`, `--description`/`--desc`, `--website`, `--private`, `--template`, `--archived`, `--default-branch` - Boolean-like flags use string type to distinguish "not set" from "false", following the pattern in `releases/edit.go` ## Test plan - [x] `go build ./...` passes - [x] `go vet ./...` passes - [x] `tea repo edit --help` shows all flags correctly - [x] Manual test: `tea repo edit --private true` on a test repo - [x] Manual test: `tea repo edit --name new-name --description "new desc"` on a test repo Reviewed-on: https://gitea.com/gitea/tea/pulls/928 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/repos.go | 1 + cmd/repos/edit.go | 106 ++++++++++++++++++++++++++++++++++++++++++++++ docs/CLI.md | 26 ++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 cmd/repos/edit.go diff --git a/cmd/repos.go b/cmd/repos.go index 2ab1ab7..a192191 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -32,6 +32,7 @@ var CmdRepos = cli.Command{ &repos.CmdRepoFork, &repos.CmdRepoMigrate, &repos.CmdRepoRm, + &repos.CmdRepoEdit, }, Flags: repos.CmdReposListFlags, } diff --git a/cmd/repos/edit.go b/cmd/repos/edit.go new file mode 100644 index 0000000..aa7d4bc --- /dev/null +++ b/cmd/repos/edit.go @@ -0,0 +1,106 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repos + +import ( + stdctx "context" + "strings" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdRepoEdit represents a sub command of repos to edit one +var CmdRepoEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit repository properties", + Description: "Edit repository properties", + ArgsUsage: " ", // command does not accept arguments + Action: runRepoEdit, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "New name of the repository", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"desc"}, + Usage: "New description of the repository", + }, + &cli.StringFlag{ + Name: "website", + Usage: "New website URL of the repository", + }, + &cli.StringFlag{ + Name: "private", + Usage: "Set private [true/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "template", + Usage: "Set template [true/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "archived", + Usage: "Set archived [true/false]", + DefaultText: "true", + }, + &cli.StringFlag{ + Name: "default-branch", + Usage: "Set default branch", + }, + }, flags.AllDefaultFlags...), +} + +func runRepoEdit(_ stdctx.Context, cmd *cli.Command) error { + ctx := context.InitCommand(cmd) + ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + client := ctx.Login.Client() + + opts := gitea.EditRepoOption{} + + if ctx.IsSet("name") { + val := ctx.String("name") + opts.Name = &val + } + if ctx.IsSet("description") { + val := ctx.String("description") + opts.Description = &val + } + if ctx.IsSet("website") { + val := ctx.String("website") + opts.Website = &val + } + if ctx.IsSet("default-branch") { + val := ctx.String("default-branch") + opts.DefaultBranch = &val + } + if ctx.IsSet("private") { + opts.Private = gitea.OptionalBool(strings.ToLower(ctx.String("private"))[:1] == "t") + } + if ctx.IsSet("template") { + opts.Template = gitea.OptionalBool(strings.ToLower(ctx.String("template"))[:1] == "t") + } + if ctx.IsSet("archived") { + opts.Archived = gitea.OptionalBool(strings.ToLower(ctx.String("archived"))[:1] == "t") + } + + repo, _, err := client.EditRepo(ctx.Owner, ctx.Repo, opts) + if err != nil { + return err + } + + topics, _, err := client.ListRepoTopics(repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) + if err != nil { + return err + } + print.RepoDetails(repo, topics) + return nil +} diff --git a/docs/CLI.md b/docs/CLI.md index f79580f..9ede576 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1199,6 +1199,32 @@ Delete an existing repository **--owner, -O**="": owner of the repo +### edit, e + +Edit repository properties + +**--archived**="": Set archived [true/false] + +**--default-branch**="": Set default branch + +**--description, --desc**="": New description of the repository + +**--login, -l**="": Use a different Gitea Login. Optional + +**--name**="": New name of the repository + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--private**="": Set private [true/false] + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--template**="": Set template [true/false] + +**--website**="": New website URL of the repository + ## branches, branch, b Consult branches From cb9824b4515398606ffd58dc787770e392e44bc2 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 12 Mar 2026 04:22:28 +0000 Subject: [PATCH 49/83] feat(repos): support owner-based repository listing with robust lookup (#931) - Add an owner flag to the repos list command to list repositories for a specific user or organization - Implement owner-based repository listing by detecting whether the owner is an organization or a user and calling the appropriate API - Improve error handling for owner lookup by checking HTTP status codes instead of relying on error string matching - Align repository search logic with the updated owner lookup behavior using HTTP response validation Signed-off-by: Bo-Yi Wu Reviewed-on: https://gitea.com/gitea/tea/pulls/931 Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/repos/list.go | 29 ++++++++++++++++++++++++++++- cmd/repos/search.go | 8 ++++---- docs/CLI.md | 4 ++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/cmd/repos/list.go b/cmd/repos/list.go index 5b9209f..4bb9e44 100644 --- a/cmd/repos/list.go +++ b/cmd/repos/list.go @@ -5,6 +5,8 @@ package repos import ( stdctx "context" + "fmt" + "net/http" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" @@ -32,6 +34,12 @@ var CmdReposListFlags = append([]cli.Flag{ Required: false, Usage: "List your starred repos instead", }, + &cli.StringFlag{ + Name: "owner", + Aliases: []string{"O"}, + Required: false, + Usage: "List repos of a specific owner (org or user)", + }, repoFieldsFlag, &typeFilterFlag, &flags.PaginationPageFlag, @@ -59,7 +67,26 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { } var rps []*gitea.Repository - if teaCmd.Bool("starred") { + if owner := teaCmd.String("owner"); owner != "" { + var err error + _, resp, orgErr := client.GetOrg(owner) + if orgErr != nil { + if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("could not find owner: %w", orgErr) + } + // not an org, treat as user + rps, _, err = client.ListUserRepos(owner, gitea.ListReposOptions{ + ListOptions: flags.GetListOptions(), + }) + } else { + rps, _, err = client.ListOrgRepos(owner, gitea.ListOrgReposOptions{ + ListOptions: flags.GetListOptions(), + }) + } + if err != nil { + return err + } + } else if teaCmd.Bool("starred") { user, _, err := client.GetMyUserInfo() if err != nil { return err diff --git a/cmd/repos/search.go b/cmd/repos/search.go index d93a592..4dbef58 100644 --- a/cmd/repos/search.go +++ b/cmd/repos/search.go @@ -6,6 +6,7 @@ package repos import ( stdctx "context" "fmt" + "net/http" "strings" "code.gitea.io/tea/cmd/flags" @@ -63,11 +64,10 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { var ownerID int64 if teaCmd.IsSet("owner") { // test if owner is an organization - org, _, err := client.GetOrg(teaCmd.String("owner")) + org, resp, err := client.GetOrg(teaCmd.String("owner")) if err != nil { - // HACK: the client does not return a response on 404, so we can't check res.StatusCode - if err.Error() != "404 Not Found" { - return fmt.Errorf("Could not find owner: %s", err) + if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("Could not find owner: %w", err) } // if owner is no org, its a user diff --git a/docs/CLI.md b/docs/CLI.md index 9ede576..2d9b40a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1007,6 +1007,8 @@ Show repository details **--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) +**--owner, -O**="": List repos of a specific owner (org or user) + **--page, -p**="": specify page (default: 1) **--starred, -s**: List your starred repos instead @@ -1029,6 +1031,8 @@ List repositories you have access to **--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) +**--owner, -O**="": List repos of a specific owner (org or user) + **--page, -p**="": specify page (default: 1) **--starred, -s**: List your starred repos instead From ec658cfc3359109bbd67570e03368305b61acbc0 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 12 Mar 2026 05:28:47 +0000 Subject: [PATCH 50/83] chore(deps): update Go dependencies and CI workflow action versions (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Run `go get -u ./...` and `go mod tidy` to update all Go dependencies - Update CI workflow action versions: - `crazy-max/ghaction-import-gpg`: v6 → v7 - `goreleaser/goreleaser-action`: v6 → v7 - `docker/setup-qemu-action`: v3 → v4 - `docker/setup-buildx-action`: v3 → v4 - `docker/login-action`: v3 → v4 - `docker/build-push-action`: v6 → v7 ## Notable Go dependency updates - `github.com/urfave/cli/v3`: v3.6.2 → v3.7.0 - `github.com/ProtonMail/go-crypto`: v1.3.0 → v1.4.0 - `charm.land/huh/v2`: v2.0.1 → v2.0.3 - `golang.org/x/crypto`: v0.48.0 → v0.49.0 - `golang.org/x/net`: v0.49.0 → v0.52.0 Reviewed-on: https://gitea.com/gitea/tea/pulls/932 Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- .gitea/workflows/release-nightly.yml | 12 ++--- .gitea/workflows/release-tag.yml | 12 ++--- go.mod | 34 ++++++------- go.sum | 72 +++++++++++++++------------- 4 files changed, 68 insertions(+), 62 deletions(-) diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index ad63939..49e3a28 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -17,7 +17,7 @@ jobs: go-version-file: "go.mod" - name: import gpg id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} @@ -25,7 +25,7 @@ jobs: id: sdk_version run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT" - name: goreleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser-pro version: "~> v1" @@ -54,19 +54,19 @@ jobs: fetch-depth: 0 # all history for all branches and tags - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker BuildX - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119 with: diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 6c713c3..49d9f2b 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -18,7 +18,7 @@ jobs: go-version-file: 'go.mod' - name: import gpg id: import_gpg - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} @@ -26,7 +26,7 @@ jobs: id: sdk_version run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT" - name: goreleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser-pro version: "~> v1" @@ -55,13 +55,13 @@ jobs: fetch-depth: 0 # all history for all branches and tags - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker BuildX - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} @@ -71,7 +71,7 @@ jobs: run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 env: ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119 with: diff --git a/go.mod b/go.mod index 1ac8ea8..2df58a1 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,8 @@ go 1.26 require ( charm.land/glamour/v2 v2.0.0 - charm.land/huh/v2 v2.0.1 - charm.land/lipgloss/v2 v2.0.1 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.2 code.gitea.io/gitea-vet v0.2.3 code.gitea.io/sdk/gitea v0.23.2 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c @@ -19,11 +19,11 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 - github.com/urfave/cli/v3 v3.6.2 - golang.org/x/crypto v0.48.0 + github.com/urfave/cli/v3 v3.7.0 + golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.42.0 - golang.org/x/term v0.40.0 + golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -34,17 +34,18 @@ require ( 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/ProtonMail/go-crypto v1.4.0 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -64,23 +65,24 @@ require ( 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.8.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.4.0 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect - github.com/olekukonko/ll v0.1.4 // indirect + github.com/olekukonko/ll v0.1.7 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -92,10 +94,10 @@ require ( github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/zalando/go-keyring v0.2.6 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 69c02c9..c68a048 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,10 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= -charm.land/huh/v2 v2.0.1 h1:9vhBjlIDuikdPKH+qnoG++GERVxqY0Lkv14xW57lj98= -charm.land/huh/v2 v2.0.1/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= -charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= @@ -25,8 +25,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6 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/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= +github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -51,10 +51,12 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo= +github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -65,8 +67,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee h1:B/JPEPNGIHyyhCPM483B+cfJQ1+9S2YBPWoTAJw3Ut0= -github.com/charmbracelet/x/exp/slice v0.0.0-20260204111555-7642919e0bee/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa h1:bmNUSF4m+fwrzZAOhluMSZxdM4bk+SWN0Ni2DimCZm8= +github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -122,6 +124,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj 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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +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 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= @@ -138,8 +142,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= -github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -156,8 +160,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg 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/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -170,8 +174,8 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.4 h1:QcDaO9quz213xqHZr0gElOcYeOSnFeq7HTQ9Wu4O1wE= -github.com/olekukonko/ll v0.1.4/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= +github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -209,8 +213,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= -github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= -github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= +github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -227,26 +231,26 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -259,18 +263,18 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -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/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 5f74fb37df0d3896de5676958ea48059727f1ce1 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 12 Mar 2026 17:13:25 +0000 Subject: [PATCH 51/83] chore(deps): update mcr.microsoft.com/devcontainers/go docker tag to v2.1 (#930) Reviewed-on: https://gitea.com/gitea/tea/pulls/930 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cf82368..377be3e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Tea DevContainer", - "image": "mcr.microsoft.com/devcontainers/go:2.0-trixie", + "image": "mcr.microsoft.com/devcontainers/go:2.1-trixie", "features": { "ghcr.io/devcontainers/features/git-lfs:1.2.5": {} }, From 9a462247bd32f01d4e0a5999d733707bb4b748c3 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 13 Mar 2026 00:18:26 +0000 Subject: [PATCH 52/83] fix(deps): update module github.com/olekukonko/tablewriter to v1.1.4 (#933) Reviewed-on: https://gitea.com/gitea/tea/pulls/933 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2df58a1..cf9c70e 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/go-authgate/sdk-go v0.2.0 github.com/go-git/go-git/v5 v5.17.0 github.com/muesli/termenv v0.16.0 - github.com/olekukonko/tablewriter v1.1.3 + github.com/olekukonko/tablewriter v1.1.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 diff --git a/go.sum b/go.sum index c68a048..ea35830 100644 --- a/go.sum +++ b/go.sum @@ -178,6 +178,8 @@ github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= From 21881525a8aed28eac875404d5da86badf1bbaf1 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 15 Mar 2026 23:04:40 +0000 Subject: [PATCH 53/83] chore(deps): update docker.gitea.com/gitea docker tag to v1.25.5 (#934) Reviewed-on: https://gitea.com/gitea/tea/pulls/934 Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com> Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 059b91e..75c46cb 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -39,7 +39,7 @@ jobs: make unit-test-coverage services: gitea: - image: docker.gitea.com/gitea:1.25.4 + image: docker.gitea.com/gitea:1.25.5 cmd: - bash - -c From b05e03416b520cb88228229a086c5cffa423abb9 Mon Sep 17 00:00:00 2001 From: techknowlogick Date: Fri, 27 Mar 2026 03:36:44 +0000 Subject: [PATCH 54/83] replace log.Fatal/os.Exit with error returns (#941) * Use stdlib encoders * Reduce some duplication * Remove global pagination state * Dedupe JSON detail types * Bump golangci-lint Reviewed-on: https://gitea.com/gitea/tea/pulls/941 Co-authored-by: techknowlogick Co-committed-by: techknowlogick --- Makefile | 2 +- README.md | 2 +- cmd/actions/runs/delete.go | 8 +- cmd/actions/runs/list.go | 16 +- cmd/actions/runs/list_test.go | 34 ++++ cmd/actions/runs/logs.go | 10 +- cmd/actions/runs/view.go | 14 +- cmd/actions/secrets/create.go | 8 +- cmd/actions/secrets/delete.go | 10 +- cmd/actions/secrets/list.go | 13 +- cmd/actions/secrets/list_test.go | 35 ++++ cmd/actions/variables/delete.go | 10 +- cmd/actions/variables/list.go | 8 +- cmd/actions/variables/list_test.go | 35 ++++ cmd/actions/variables/set.go | 8 +- cmd/actions/workflows/list.go | 13 +- cmd/admin.go | 5 +- cmd/admin/users/list.go | 11 +- cmd/api.go | 257 ++++++++++++++---------- cmd/api_test.go | 42 ++-- cmd/attachments/create.go | 9 +- cmd/attachments/delete.go | 9 +- cmd/attachments/list.go | 14 +- cmd/branches/list.go | 17 +- cmd/branches/protect.go | 9 +- cmd/clone.go | 5 +- cmd/comment.go | 9 +- cmd/detail_json.go | 93 +++++++++ cmd/flags/generic.go | 31 ++- cmd/flags/generic_test.go | 27 +++ cmd/issues.go | 117 ++++++----- cmd/issues/close.go | 9 +- cmd/issues/create.go | 9 +- cmd/issues/edit.go | 9 +- cmd/issues/list.go | 12 +- cmd/issues_test.go | 125 +++++++----- cmd/labels/create.go | 9 +- cmd/labels/delete.go | 9 +- cmd/labels/list.go | 14 +- cmd/labels/update.go | 10 +- cmd/login.go | 5 +- cmd/login/helper.go | 6 +- cmd/login/list.go | 3 +- cmd/login/oauth_refresh.go | 7 +- cmd/milestones.go | 9 +- cmd/milestones/create.go | 5 +- cmd/milestones/delete.go | 11 +- cmd/milestones/issues.go | 32 ++- cmd/milestones/list.go | 14 +- cmd/milestones/reopen.go | 18 +- cmd/notifications/list.go | 17 +- cmd/notifications/mark_as.go | 24 ++- cmd/open.go | 16 +- cmd/organizations.go | 5 +- cmd/organizations/create.go | 5 +- cmd/organizations/delete.go | 5 +- cmd/organizations/list.go | 11 +- cmd/pulls.go | 81 ++------ cmd/pulls/approve.go | 5 +- cmd/pulls/checkout.go | 11 +- cmd/pulls/clean.go | 9 +- cmd/pulls/create.go | 11 +- cmd/pulls/edit.go | 9 +- cmd/pulls/list.go | 14 +- cmd/pulls/merge.go | 9 +- cmd/pulls/reject.go | 5 +- cmd/pulls/review.go | 9 +- cmd/pulls/review_helpers.go | 4 +- cmd/releases/create.go | 9 +- cmd/releases/delete.go | 9 +- cmd/releases/edit.go | 9 +- cmd/releases/list.go | 14 +- cmd/repos.go | 5 +- cmd/repos/create.go | 6 +- cmd/repos/create_from_template.go | 5 +- cmd/repos/delete.go | 7 +- cmd/repos/edit.go | 9 +- cmd/repos/fork.go | 9 +- cmd/repos/list.go | 18 +- cmd/repos/migrate.go | 6 +- cmd/repos/search.go | 10 +- cmd/times/add.go | 9 +- cmd/times/delete.go | 9 +- cmd/times/list.go | 15 +- cmd/times/reset.go | 9 +- cmd/webhooks.go | 5 +- cmd/webhooks/create.go | 6 +- cmd/webhooks/delete.go | 5 +- cmd/webhooks/list.go | 13 +- cmd/webhooks/update.go | 5 +- cmd/whoami.go | 5 +- main.go | 5 + modules/config/config.go | 20 +- modules/config/lock_test.go | 44 ++-- modules/config/login.go | 15 +- modules/context/context.go | 44 ++-- modules/context/context_require.go | 21 +- modules/context/context_require_test.go | 113 +++++++++++ modules/interact/comments.go | 4 +- modules/interact/pull_merge.go | 2 +- modules/print/actions.go | 12 +- modules/print/actions_runs.go | 18 +- modules/print/actions_runs_test.go | 9 +- modules/print/actions_test.go | 15 +- modules/print/attachment.go | 4 +- modules/print/branch.go | 13 +- modules/print/branch_test.go | 33 +++ modules/print/issue.go | 8 +- modules/print/label.go | 4 +- modules/print/login.go | 4 +- modules/print/milestone.go | 4 +- modules/print/notification.go | 4 +- modules/print/organization.go | 6 +- modules/print/pull.go | 8 +- modules/print/release.go | 4 +- modules/print/repo.go | 6 +- modules/print/repo_test.go | 33 +++ modules/print/table.go | 159 ++++++++------- modules/print/table_test.go | 80 ++++++-- modules/print/times.go | 4 +- modules/print/user.go | 4 +- modules/print/webhook.go | 4 +- modules/print/webhook_test.go | 13 +- modules/task/login_create.go | 8 +- 124 files changed, 1610 insertions(+), 759 deletions(-) create mode 100644 cmd/detail_json.go create mode 100644 modules/context/context_require_test.go create mode 100644 modules/print/branch_test.go create mode 100644 modules/print/repo_test.go diff --git a/Makefile b/Makefile index 1d15dac..487461c 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") # Tool packages with pinned versions GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 ifneq ($(DRONE_TAG),) VERSION ?= $(subst v,,$(DRONE_TAG)) diff --git a/README.md b/README.md index 1cf66a2..6ca016d 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ tea man --out ./tea.man ## Compilation -Make sure you have a current go version installed (1.13 or newer). +Make sure you have a current Go version installed (1.26 or newer). - To compile the source yourself with the recommended flags & tags: ```sh diff --git a/cmd/actions/runs/delete.go b/cmd/actions/runs/delete.go index 5e7d9eb..6ca6954 100644 --- a/cmd/actions/runs/delete.go +++ b/cmd/actions/runs/delete.go @@ -36,7 +36,13 @@ func runRunsDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("run ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() runIDStr := cmd.Args().First() diff --git a/cmd/actions/runs/list.go b/cmd/actions/runs/list.go index 8e3cdb0..73dba88 100644 --- a/cmd/actions/runs/list.go +++ b/cmd/actions/runs/list.go @@ -83,7 +83,13 @@ func parseTimeFlag(value string) (time.Time, error) { // RunRunsList lists workflow runs func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() // Parse time filters @@ -98,7 +104,7 @@ func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { } // Build list options - listOpts := flags.GetListOptions() + listOpts := flags.GetListOptions(cmd) runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ ListOptions: listOpts, @@ -112,15 +118,13 @@ func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error { } if runs == nil { - print.ActionRunsList(nil, c.Output) - return nil + return print.ActionRunsList(nil, c.Output) } // Filter by time if specified filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until) - print.ActionRunsList(filteredRuns, c.Output) - return nil + return print.ActionRunsList(filteredRuns, c.Output) } // filterRunsByTime filters runs based on time range diff --git a/cmd/actions/runs/list_test.go b/cmd/actions/runs/list_test.go index 176eef7..1486e54 100644 --- a/cmd/actions/runs/list_test.go +++ b/cmd/actions/runs/list_test.go @@ -4,10 +4,15 @@ package runs import ( + stdctx "context" + "os" "testing" "time" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestFilterRunsByTime(t *testing.T) { @@ -75,3 +80,32 @@ func TestFilterRunsByTime(t *testing.T) { }) } } + +func TestRunRunsListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdRunsList.Name, + Flags: CmdRunsList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunRunsList(stdctx.Background(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/runs/logs.go b/cmd/actions/runs/logs.go index 7a2637e..bebcf63 100644 --- a/cmd/actions/runs/logs.go +++ b/cmd/actions/runs/logs.go @@ -42,7 +42,13 @@ func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("run ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() runIDStr := cmd.Args().First() @@ -78,7 +84,7 @@ func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error { // Otherwise, fetch all jobs and their logs jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return fmt.Errorf("failed to get jobs: %w", err) diff --git a/cmd/actions/runs/view.go b/cmd/actions/runs/view.go index 621ef67..9bba204 100644 --- a/cmd/actions/runs/view.go +++ b/cmd/actions/runs/view.go @@ -38,7 +38,13 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("run ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() runIDStr := cmd.Args().First() @@ -59,7 +65,7 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { // Fetch and print jobs if requested if cmd.Bool("jobs") { jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return fmt.Errorf("failed to get jobs: %w", err) @@ -67,7 +73,9 @@ func runRunsView(ctx stdctx.Context, cmd *cli.Command) error { if jobs != nil && len(jobs.Jobs) > 0 { fmt.Printf("\nJobs:\n\n") - print.ActionWorkflowJobsList(jobs.Jobs, c.Output) + if err := print.ActionWorkflowJobsList(jobs.Jobs, c.Output); err != nil { + return err + } } } diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go index b9b13a6..e825f34 100644 --- a/cmd/actions/secrets/create.go +++ b/cmd/actions/secrets/create.go @@ -40,7 +40,13 @@ func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("secret name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() secretName := cmd.Args().First() diff --git a/cmd/actions/secrets/delete.go b/cmd/actions/secrets/delete.go index a031043..60d721e 100644 --- a/cmd/actions/secrets/delete.go +++ b/cmd/actions/secrets/delete.go @@ -35,7 +35,13 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("secret name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() secretName := cmd.Args().First() @@ -50,7 +56,7 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { } } - _, err := client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName) + _, err = client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName) if err != nil { return err } diff --git a/cmd/actions/secrets/list.go b/cmd/actions/secrets/list.go index 51b9efb..0903e2e 100644 --- a/cmd/actions/secrets/list.go +++ b/cmd/actions/secrets/list.go @@ -29,16 +29,21 @@ var CmdSecretsList = cli.Command{ // RunSecretsList list action secrets func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() secrets, _, err := client.ListRepoActionSecret(c.Owner, c.Repo, gitea.ListRepoActionSecretOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.ActionSecretsList(secrets, c.Output) - return nil + return print.ActionSecretsList(secrets, c.Output) } diff --git a/cmd/actions/secrets/list_test.go b/cmd/actions/secrets/list_test.go index 89c641a..8660830 100644 --- a/cmd/actions/secrets/list_test.go +++ b/cmd/actions/secrets/list_test.go @@ -4,7 +4,13 @@ package secrets import ( + stdctx "context" + "os" "testing" + + "code.gitea.io/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestSecretsListFlags(t *testing.T) { @@ -61,3 +67,32 @@ func TestSecretsListValidation(t *testing.T) { // This is fine - list commands typically ignore extra args } } + +func TestRunSecretsListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdSecretsList.Name, + Flags: CmdSecretsList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunSecretsList(stdctx.Background(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/variables/delete.go b/cmd/actions/variables/delete.go index b81ac64..f348374 100644 --- a/cmd/actions/variables/delete.go +++ b/cmd/actions/variables/delete.go @@ -35,7 +35,13 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("variable name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() variableName := cmd.Args().First() @@ -50,7 +56,7 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { } } - _, err := client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName) + _, err = client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName) if err != nil { return err } diff --git a/cmd/actions/variables/list.go b/cmd/actions/variables/list.go index 73159fd..fe5ecb7 100644 --- a/cmd/actions/variables/list.go +++ b/cmd/actions/variables/list.go @@ -31,7 +31,13 @@ var CmdVariablesList = cli.Command{ // RunVariablesList list action variables func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() if name := cmd.String("name"); name != "" { diff --git a/cmd/actions/variables/list_test.go b/cmd/actions/variables/list_test.go index f13987f..396487a 100644 --- a/cmd/actions/variables/list_test.go +++ b/cmd/actions/variables/list_test.go @@ -4,7 +4,13 @@ package variables import ( + stdctx "context" + "os" "testing" + + "code.gitea.io/tea/modules/config" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func TestVariablesListFlags(t *testing.T) { @@ -61,3 +67,32 @@ func TestVariablesListValidation(t *testing.T) { // This is fine - list commands typically ignore extra args } } + +func TestRunVariablesListRequiresRepoContext(t *testing.T) { + oldWd, err := os.Getwd() + require.NoError(t, err) + + require.NoError(t, os.Chdir(t.TempDir())) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test", + URL: "https://gitea.example.com", + Token: "token", + User: "tester", + Default: true, + }}, + }) + + cmd := &cli.Command{ + Name: CmdVariablesList.Name, + Flags: CmdVariablesList.Flags, + } + require.NoError(t, cmd.Set("login", "test")) + + err = RunVariablesList(stdctx.Background(), cmd) + require.ErrorContains(t, err, "remote repository required") +} diff --git a/cmd/actions/variables/set.go b/cmd/actions/variables/set.go index 7a504c5..9f2c568 100644 --- a/cmd/actions/variables/set.go +++ b/cmd/actions/variables/set.go @@ -40,7 +40,13 @@ func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("variable name is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() variableName := cmd.Args().First() diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go index d40be22..4cdf10c 100644 --- a/cmd/actions/workflows/list.go +++ b/cmd/actions/workflows/list.go @@ -32,7 +32,13 @@ var CmdWorkflowsList = cli.Command{ // RunWorkflowsList lists workflow files in the repository func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := c.Login.Client() // Try to list workflow files from .gitea/workflows directory @@ -71,7 +77,7 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { // Get recent runs to check activity runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err == nil && runs != nil { for _, run := range runs.WorkflowRuns { @@ -81,6 +87,5 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { } } - print.WorkflowsList(workflows, workflowStatus, c.Output) - return nil + return print.WorkflowsList(workflows, workflowStatus, c.Output) } diff --git a/cmd/admin.go b/cmd/admin.go index 6e33a23..212dc3b 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -44,7 +44,10 @@ var cmdAdminUsers = cli.Command{ } func runAdminUserDetail(_ stdctx.Context, cmd *cli.Command, u string) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() user, _, err := client.GetUserInfo(u) if err != nil { diff --git a/cmd/admin/users/list.go b/cmd/admin/users/list.go index cc831d1..65c545e 100644 --- a/cmd/admin/users/list.go +++ b/cmd/admin/users/list.go @@ -34,7 +34,10 @@ var CmdUserList = cli.Command{ // RunUserList list users func RunUserList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } fields, err := userFieldsFlag.GetValues(cmd) if err != nil { @@ -43,13 +46,11 @@ func RunUserList(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.UserList(users, ctx.Output, fields) - - return nil + return print.UserList(users, ctx.Output, fields) } diff --git a/cmd/api.go b/cmd/api.go index 6041fe3..12c7feb 100644 --- a/cmd/api.go +++ b/cmd/api.go @@ -97,128 +97,39 @@ Note: if your endpoint contains ? or &, quote it to prevent shell expansion Flags: append(apiFlags(), flags.LoginRepoFlags...), } +type preparedAPIRequest struct { + Method string + Endpoint string + Headers map[string]string + Body []byte +} + func runApi(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - - // Get the endpoint argument - if cmd.NArg() < 1 { - return fmt.Errorf("endpoint argument required") + ctx, err := context.InitCommand(cmd) + if err != nil { + return err } - endpoint := cmd.Args().First() - - // Expand placeholders in endpoint - endpoint = expandPlaceholders(endpoint, ctx) - - // Parse headers - headers := make(map[string]string) - for _, h := range cmd.StringSlice("header") { - parts := strings.SplitN(h, ":", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid header format: %q (expected key:value)", h) - } - headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + request, err := prepareAPIRequest(cmd, ctx) + if err != nil { + return err } - // Build request body from fields var body io.Reader - stringFields := cmd.StringSlice("field") - typedFields := cmd.StringSlice("Field") - dataRaw := cmd.String("data") - - if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { - return fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") - } - - if dataRaw != "" { - var dataBytes []byte - var dataSource string - if strings.HasPrefix(dataRaw, "@") { - filename := dataRaw[1:] - var err error - if filename == "-" { - dataBytes, err = io.ReadAll(os.Stdin) - dataSource = "stdin" - } else { - dataBytes, err = os.ReadFile(filename) - dataSource = filename - } - if err != nil { - return fmt.Errorf("failed to read %q: %w", dataRaw, err) - } - } else { - dataBytes = []byte(dataRaw) - } - if !json.Valid(dataBytes) { - if dataSource != "" { - return fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) - } - return fmt.Errorf("--data/-d value is not valid JSON") - } - body = bytes.NewReader(dataBytes) - } else if len(stringFields) > 0 || len(typedFields) > 0 { - bodyMap := make(map[string]any) - - // Process string fields (-f) - for _, f := range stringFields { - parts := strings.SplitN(f, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %q (expected key=value)", f) - } - key := parts[0] - if key == "" { - return fmt.Errorf("field key cannot be empty in %q", f) - } - if _, exists := bodyMap[key]; exists { - return fmt.Errorf("duplicate field key %q", key) - } - bodyMap[key] = parts[1] - } - - // Process typed fields (-F) - for _, f := range typedFields { - parts := strings.SplitN(f, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %q (expected key=value)", f) - } - key := parts[0] - if key == "" { - return fmt.Errorf("field key cannot be empty in %q", f) - } - if _, exists := bodyMap[key]; exists { - return fmt.Errorf("duplicate field key %q", key) - } - value := parts[1] - - parsedValue, err := parseTypedValue(value) - if err != nil { - return fmt.Errorf("failed to parse field %q: %w", key, err) - } - bodyMap[key] = parsedValue - } - - bodyBytes, err := json.Marshal(bodyMap) - if err != nil { - return fmt.Errorf("failed to encode request body: %w", err) - } - body = bytes.NewReader(bodyBytes) + if request.Body != nil { + body = bytes.NewReader(request.Body) } // Create API client and make request client := api.NewClient(ctx.Login) - method := strings.ToUpper(cmd.String("method")) - if !cmd.IsSet("method") { - if body != nil { - method = "POST" - } else { - method = "GET" - } - } - - resp, err := client.Do(method, endpoint, body, headers) + resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers) if err != nil { return fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr) + } + }() // Print headers to stderr if requested (so redirects/pipes work correctly) if cmd.Bool("include") { @@ -249,7 +160,11 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return fmt.Errorf("failed to create output file: %w", err) } - defer file.Close() + defer func() { + if closeErr := file.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", closeErr) + } + }() output = file } @@ -267,6 +182,126 @@ func runApi(_ stdctx.Context, cmd *cli.Command) error { return nil } +func prepareAPIRequest(cmd *cli.Command, ctx *context.TeaContext) (*preparedAPIRequest, error) { + var err error + + // Get the endpoint argument + if cmd.NArg() < 1 { + return nil, fmt.Errorf("endpoint argument required") + } + endpoint := cmd.Args().First() + + // Expand placeholders in endpoint + endpoint = expandPlaceholders(endpoint, ctx) + + // Parse headers + headers := make(map[string]string) + for _, h := range cmd.StringSlice("header") { + parts := strings.SplitN(h, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid header format: %q (expected key:value)", h) + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + // Build request body from fields + var bodyBytes []byte + stringFields := cmd.StringSlice("field") + typedFields := cmd.StringSlice("Field") + dataRaw := cmd.String("data") + + if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) { + return nil, fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F") + } + + if dataRaw != "" { + var dataBytes []byte + var dataSource string + if strings.HasPrefix(dataRaw, "@") { + filename := dataRaw[1:] + if filename == "-" { + dataBytes, err = io.ReadAll(os.Stdin) + dataSource = "stdin" + } else { + dataBytes, err = os.ReadFile(filename) + dataSource = filename + } + if err != nil { + return nil, fmt.Errorf("failed to read %q: %w", dataRaw, err) + } + } else { + dataBytes = []byte(dataRaw) + } + if !json.Valid(dataBytes) { + if dataSource != "" { + return nil, fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource) + } + return nil, fmt.Errorf("--data/-d value is not valid JSON") + } + bodyBytes = dataBytes + } else if len(stringFields) > 0 || len(typedFields) > 0 { + bodyMap := make(map[string]any) + + // Process string fields (-f) + for _, f := range stringFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + if key == "" { + return nil, fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return nil, fmt.Errorf("duplicate field key %q", key) + } + bodyMap[key] = parts[1] + } + + // Process typed fields (-F) + for _, f := range typedFields { + parts := strings.SplitN(f, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f) + } + key := parts[0] + if key == "" { + return nil, fmt.Errorf("field key cannot be empty in %q", f) + } + if _, exists := bodyMap[key]; exists { + return nil, fmt.Errorf("duplicate field key %q", key) + } + value := parts[1] + + parsedValue, err := parseTypedValue(value) + if err != nil { + return nil, fmt.Errorf("failed to parse field %q: %w", key, err) + } + bodyMap[key] = parsedValue + } + + bodyBytes, err = json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("failed to encode request body: %w", err) + } + } + method := strings.ToUpper(cmd.String("method")) + if !cmd.IsSet("method") { + if bodyBytes != nil { + method = "POST" + } else { + method = "GET" + } + } + + return &preparedAPIRequest{ + Method: method, + Endpoint: endpoint, + Headers: headers, + Body: bodyBytes, + }, nil +} + // parseTypedValue parses a value for -F flag, handling: // - @filename: read content from file // - @-: read content from stdin diff --git a/cmd/api_test.go b/cmd/api_test.go index 62449f3..87b242e 100644 --- a/cmd/api_test.go +++ b/cmd/api_test.go @@ -7,11 +7,8 @@ import ( stdctx "context" "encoding/json" "io" - "net/http" - "net/http/httptest" "os" "path/filepath" - "sync" "testing" "code.gitea.io/tea/modules/config" @@ -254,35 +251,18 @@ func TestParseTypedValue(t *testing.T) { }) } -// runApiWithArgs sets up a test server that captures requests, configures the -// login to point at it, and runs the api command with the given CLI args. -// Returns the captured HTTP method, body bytes, and any error from the command. +// runApiWithArgs configures a test login, parses the command line, and captures +// the prepared request without opening sockets or making HTTP requests. func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) { t.Helper() - var mu sync.Mutex var capturedMethod string var capturedBody []byte - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, readErr := io.ReadAll(r.Body) - if readErr != nil { - t.Fatalf("failed to read request body: %v", readErr) - } - mu.Lock() - capturedMethod = r.Method - capturedBody = b - mu.Unlock() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - t.Cleanup(server.Close) - config.SetConfigForTesting(config.LocalConfig{ Logins: []config.Login{{ Name: "testLogin", - URL: server.URL, + URL: "https://gitea.example.com", Token: "test-token", User: "testUser", Default: true, @@ -295,7 +275,19 @@ func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, er cmd := cli.Command{ Name: "api", DisableSliceFlagSeparator: true, - Action: runApi, + Action: func(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + request, err := prepareAPIRequest(cmd, ctx) + if err != nil { + return err + } + capturedMethod = request.Method + capturedBody = append([]byte(nil), request.Body...) + return nil + }, Flags: append(apiFlags(), []cli.Flag{ &cli.StringFlag{Name: "login", Aliases: []string{"l"}}, &cli.StringFlag{Name: "repo", Aliases: []string{"r"}}, @@ -308,8 +300,6 @@ func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, er fullArgs := append([]string{"api", "--login", "testLogin"}, args...) runErr := cmd.Run(stdctx.Background(), fullArgs) - mu.Lock() - defer mu.Unlock() return capturedMethod, capturedBody, runErr } diff --git a/cmd/attachments/create.go b/cmd/attachments/create.go index e970d8f..dd46b46 100644 --- a/cmd/attachments/create.go +++ b/cmd/attachments/create.go @@ -27,8 +27,13 @@ var CmdReleaseAttachmentCreate = cli.Command{ } func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() < 2 { diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go index 238d92b..b9d0625 100644 --- a/cmd/attachments/delete.go +++ b/cmd/attachments/delete.go @@ -32,8 +32,13 @@ var CmdReleaseAttachmentDelete = cli.Command{ } func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() < 2 { diff --git a/cmd/attachments/list.go b/cmd/attachments/list.go index be6c142..c070ddc 100644 --- a/cmd/attachments/list.go +++ b/cmd/attachments/list.go @@ -31,8 +31,13 @@ var CmdReleaseAttachmentList = cli.Command{ // RunReleaseAttachmentList list release attachments func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() tag := ctx.Args().First() @@ -46,14 +51,13 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { } attachments, _, err := ctx.Login.Client().ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.ReleaseAttachmentsList(attachments, ctx.Output) - return nil + return print.ReleaseAttachmentsList(attachments, ctx.Output) } func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { diff --git a/cmd/branches/list.go b/cmd/branches/list.go index 098b5f8..a77389c 100644 --- a/cmd/branches/list.go +++ b/cmd/branches/list.go @@ -38,8 +38,13 @@ var CmdBranchesList = cli.Command{ // RunBranchesList list branches func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } owner := ctx.Owner if ctx.IsSet("owner") { @@ -48,16 +53,15 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { var branches []*gitea.Branch var protections []*gitea.BranchProtection - var err error branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err @@ -68,6 +72,5 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.BranchesList(branches, protections, ctx.Output, fields) - return nil + return print.BranchesList(branches, protections, ctx.Output, fields) } diff --git a/cmd/branches/protect.go b/cmd/branches/protect.go index 15b988b..7b88a39 100644 --- a/cmd/branches/protect.go +++ b/cmd/branches/protect.go @@ -45,8 +45,13 @@ var CmdBranchesUnprotect = cli.Command{ // RunBranchesProtect function to protect/unprotect a list of branches func RunBranchesProtect(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if !cmd.Args().Present() { return fmt.Errorf("must specify at least one branch") diff --git a/cmd/clone.go b/cmd/clone.go index 2bf24d4..f634a5e 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -48,7 +48,10 @@ When a host is specified in the repo-slug, it will override the login specified } func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { - teaCmd := context.InitCommand(cmd) + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } args := teaCmd.Args() if args.Len() < 1 { diff --git a/cmd/comment.go b/cmd/comment.go index 9c03cba..cb69041 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -36,8 +36,13 @@ var CmdAddComment = cli.Command{ } func runAddComment(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } args := ctx.Args() if args.Len() == 0 { diff --git a/cmd/detail_json.go b/cmd/detail_json.go new file mode 100644 index 0000000..7db03e0 --- /dev/null +++ b/cmd/detail_json.go @@ -0,0 +1,93 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "encoding/json" + "io" + "time" + + "code.gitea.io/sdk/gitea" +) + +type detailLabelData struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type detailCommentData struct { + ID int64 `json:"id"` + Author string `json:"author"` + Created time.Time `json:"created"` + Body string `json:"body"` +} + +type detailReviewData struct { + ID int64 `json:"id"` + Reviewer string `json:"reviewer"` + State gitea.ReviewStateType `json:"state"` + Body string `json:"body"` + Created time.Time `json:"created"` +} + +func buildDetailLabels(labels []*gitea.Label) []detailLabelData { + labelSlice := make([]detailLabelData, 0, len(labels)) + for _, label := range labels { + labelSlice = append(labelSlice, detailLabelData{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }) + } + return labelSlice +} + +func buildDetailAssignees(assignees []*gitea.User) []string { + assigneeSlice := make([]string, 0, len(assignees)) + for _, assignee := range assignees { + assigneeSlice = append(assigneeSlice, username(assignee)) + } + return assigneeSlice +} + +func buildDetailComments(comments []*gitea.Comment) []detailCommentData { + commentSlice := make([]detailCommentData, 0, len(comments)) + for _, comment := range comments { + commentSlice = append(commentSlice, detailCommentData{ + ID: comment.ID, + Author: username(comment.Poster), + Body: comment.Body, + Created: comment.Created, + }) + } + return commentSlice +} + +func buildDetailReviews(reviews []*gitea.PullReview) []detailReviewData { + reviewSlice := make([]detailReviewData, 0, len(reviews)) + for _, review := range reviews { + reviewSlice = append(reviewSlice, detailReviewData{ + ID: review.ID, + Reviewer: username(review.Reviewer), + State: review.State, + Body: review.Body, + Created: review.Submitted, + }) + } + return reviewSlice +} + +func username(user *gitea.User) string { + if user == nil { + return "ghost" + } + return user.UserName +} + +func writeIndentedJSON(w io.Writer, data any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", "\t") + return encoder.Encode(data) +} diff --git a/cmd/flags/generic.go b/cmd/flags/generic.go index af2692a..46b1e5d 100644 --- a/cmd/flags/generic.go +++ b/cmd/flags/generic.go @@ -39,16 +39,33 @@ var OutputFlag = cli.StringFlag{ } var ( - paging gitea.ListOptions // ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0). ErrPage = errors.New("page cannot be smaller than 1") // ErrLimit indicates that the provided limit value is invalid (negative). ErrLimit = errors.New("limit cannot be negative") ) -// GetListOptions returns configured paging struct -func GetListOptions() gitea.ListOptions { - return paging +const ( + defaultPageValue = 1 + defaultLimitValue = 30 +) + +// GetListOptions returns list options derived from the active command. +func GetListOptions(cmd *cli.Command) gitea.ListOptions { + page := cmd.Int("page") + if page == 0 { + page = defaultPageValue + } + + pageSize := cmd.Int("limit") + if pageSize == 0 { + pageSize = defaultLimitValue + } + + return gitea.ListOptions{ + Page: page, + PageSize: pageSize, + } } // PaginationFlags provides all pagination related flags @@ -62,14 +79,13 @@ var PaginationPageFlag = cli.IntFlag{ Name: "page", Aliases: []string{"p"}, Usage: "specify page", - Value: 1, + Value: defaultPageValue, Validator: func(i int) error { if i < 1 && i != -1 { return ErrPage } return nil }, - Destination: &paging.Page, } // PaginationLimitFlag provides flag for pagination options @@ -77,14 +93,13 @@ var PaginationLimitFlag = cli.IntFlag{ Name: "limit", Aliases: []string{"lm"}, Usage: "specify limit of items per page", - Value: 30, + Value: defaultLimitValue, Validator: func(i int) error { if i < 0 { return ErrLimit } return nil }, - Destination: &paging.PageSize, } // LoginOutputFlags defines login and output flags that should diff --git a/cmd/flags/generic_test.go b/cmd/flags/generic_test.go index 184afbf..37d4a7f 100644 --- a/cmd/flags/generic_test.go +++ b/cmd/flags/generic_test.go @@ -8,6 +8,7 @@ import ( "io" "testing" + "code.gitea.io/sdk/gitea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -123,3 +124,29 @@ func TestPaginationFailures(t *testing.T) { }) } } + +func TestGetListOptionsDoesNotLeakBetweenCommands(t *testing.T) { + var results []gitea.ListOptions + + run := func(args []string) { + t.Helper() + + cmd := cli.Command{ + Name: "test-paging", + Action: func(_ context.Context, cmd *cli.Command) error { + results = append(results, GetListOptions(cmd)) + return nil + }, + Flags: PaginationFlags, + } + + require.NoError(t, cmd.Run(context.Background(), args)) + } + + run([]string{"test", "--page", "5", "--limit", "10"}) + run([]string{"test"}) + + require.Len(t, results, 2) + assert.Equal(t, gitea.ListOptions{Page: 5, PageSize: 10}, results[0]) + assert.Equal(t, gitea.ListOptions{Page: defaultPageValue, PageSize: defaultLimitValue}, results[1]) +} diff --git a/cmd/issues.go b/cmd/issues.go index c9ff28e..16da65a 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -5,7 +5,6 @@ package cmd import ( stdctx "context" - "encoding/json" "fmt" "time" @@ -20,11 +19,7 @@ import ( "github.com/urfave/cli/v3" ) -type labelData struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` -} +type labelData = detailLabelData type issueData struct { ID int64 `json:"id"` @@ -41,13 +36,17 @@ type issueData struct { Comments []commentData `json:"comments"` } -type commentData struct { - ID int64 `json:"id"` - Author string `json:"author"` - Created time.Time `json:"created"` - Body string `json:"body"` +type issueDetailClient interface { + GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) + GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) } +type issueCommentClient interface { + ListIssueComments(owner, repo string, index int64, opt gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) +} + +type commentData = detailCommentData + // CmdIssues represents to login a gitea server. var CmdIssues = cli.Command{ Name: "issues", @@ -80,17 +79,35 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error { } func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { - ctx := context.InitCommand(cmd) - if ctx.IsSet("owner") { - ctx.Owner = ctx.String("owner") - } - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) - - idx, err := utils.ArgToIndex(index) + ctx, idx, err := resolveIssueDetailContext(cmd, index) if err != nil { return err } - client := ctx.Login.Client() + + return runIssueDetailWithClient(ctx, idx, ctx.Login.Client()) +} + +func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) { + ctx, err := context.InitCommand(cmd) + if err != nil { + return nil, 0, err + } + if ctx.IsSet("owner") { + ctx.Owner = ctx.String("owner") + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return nil, 0, err + } + + idx, err := utils.ArgToIndex(index) + if err != nil { + return nil, 0, err + } + + return ctx, idx, nil +} + +func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDetailClient) error { issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx) if err != nil { return err @@ -120,59 +137,37 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { } func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error { - c := ctx.Login.Client() - opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} + return runIssueDetailAsJSONWithClient(ctx, issue, ctx.Login.Client()) +} - labelSlice := make([]labelData, 0, len(issue.Labels)) - for _, label := range issue.Labels { - labelSlice = append(labelSlice, labelData{label.Name, label.Color, label.Description}) +func runIssueDetailAsJSONWithClient(ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error { + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} + comments := []*gitea.Comment{} + + if ctx.Bool("comments") { + var err error + comments, _, err = c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts) + if err != nil { + return err + } } - assigneesSlice := make([]string, 0, len(issue.Assignees)) - for _, assignee := range issue.Assignees { - assigneesSlice = append(assigneesSlice, assignee.UserName) - } + return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments)) +} - issueSlice := issueData{ +func buildIssueData(issue *gitea.Issue, comments []*gitea.Comment) issueData { + return issueData{ ID: issue.ID, Index: issue.Index, Title: issue.Title, State: issue.State, Created: issue.Created, - User: issue.Poster.UserName, + User: username(issue.Poster), Body: issue.Body, - Labels: labelSlice, - Assignees: assigneesSlice, + Labels: buildDetailLabels(issue.Labels), + Assignees: buildDetailAssignees(issue.Assignees), URL: issue.HTMLURL, ClosedAt: issue.Closed, - Comments: make([]commentData, 0), + Comments: buildDetailComments(comments), } - - if ctx.Bool("comments") { - comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts) - issueSlice.Comments = make([]commentData, 0, len(comments)) - - if err != nil { - return err - } - - for _, comment := range comments { - issueSlice.Comments = append(issueSlice.Comments, commentData{ - ID: comment.ID, - Author: comment.Poster.UserName, - Body: comment.Body, // Selected Field - Created: comment.Created, - }) - } - - } - - jsonData, err := json.MarshalIndent(issueSlice, "", "\t") - if err != nil { - return err - } - - _, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData) - - return err } diff --git a/cmd/issues/close.go b/cmd/issues/close.go index d70c25a..ea6f1b8 100644 --- a/cmd/issues/close.go +++ b/cmd/issues/close.go @@ -31,8 +31,13 @@ var CmdIssuesClose = cli.Command{ // editIssueState abstracts the arg parsing to edit the given issue func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() == 0 { return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } diff --git a/cmd/issues/create.go b/cmd/issues/create.go index 2eb2699..d88ff2c 100644 --- a/cmd/issues/create.go +++ b/cmd/issues/create.go @@ -26,8 +26,13 @@ var CmdIssuesCreate = cli.Command{ } func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.IsInteractiveMode() { err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo) diff --git a/cmd/issues/edit.go b/cmd/issues/edit.go index a673e35..e21cf14 100644 --- a/cmd/issues/edit.go +++ b/cmd/issues/edit.go @@ -30,8 +30,13 @@ use an empty string (eg. --milestone "").`, } func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if !cmd.Args().Present() { return fmt.Errorf("must specify at least one issue index") diff --git a/cmd/issues/list.go b/cmd/issues/list.go index dd1a376..9033f84 100644 --- a/cmd/issues/list.go +++ b/cmd/issues/list.go @@ -33,7 +33,10 @@ var CmdIssuesList = cli.Command{ // RunIssuesList list issues func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } state, err := flags.ParseState(ctx.String("state")) if err != nil { @@ -69,7 +72,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { var issues []*gitea.Issue if ctx.Repo != "" { issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, Type: kind, KeyWord: ctx.String("keyword"), @@ -86,7 +89,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { } } else { issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, Type: kind, KeyWord: ctx.String("keyword"), @@ -109,6 +112,5 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.IssuesPullsList(issues, ctx.Output, fields) - return nil + return print.IssuesPullsList(issues, ctx.Output, fields) } diff --git a/cmd/issues_test.go b/cmd/issues_test.go index 48c79ff..6c8bf75 100644 --- a/cmd/issues_test.go +++ b/cmd/issues_test.go @@ -5,11 +5,8 @@ package cmd import ( "bytes" - stdctx "context" "encoding/json" "fmt" - "net/http" - "net/http/httptest" "testing" "time" @@ -27,6 +24,51 @@ const ( testRepo = "testRepo" ) +type fakeIssueCommentClient struct { + owner string + repo string + index int64 + comments []*gitea.Comment +} + +func (f *fakeIssueCommentClient) ListIssueComments(owner, repo string, index int64, _ gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.comments, nil, nil +} + +type fakeIssueDetailClient struct { + owner string + repo string + index int64 + issue *gitea.Issue + reactions []*gitea.Reaction +} + +func (f *fakeIssueDetailClient) GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.issue, nil, nil +} + +func (f *fakeIssueDetailClient) GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) { + f.owner = owner + f.repo = repo + f.index = index + return f.reactions, nil, nil +} + +func toCommentPointers(comments []gitea.Comment) []*gitea.Comment { + result := make([]*gitea.Comment, 0, len(comments)) + for i := range comments { + comment := comments[i] + result = append(result, &comment) + } + return result +} + func createTestIssue(comments int, isClosed bool) gitea.Issue { issue := gitea.Issue{ ID: 42, @@ -160,25 +202,11 @@ func TestRunIssueDetailAsJSON(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - if path == fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", testOwner, testRepo, testCase.issue.Index) { - jsonComments, err := json.Marshal(testCase.comments) - if err != nil { - require.NoError(t, err, "Testing setup failed: failed to marshal comments") - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err = w.Write(jsonComments) - require.NoError(t, err, "Testing setup failed: failed to write out comments") - } else { - http.NotFound(w, r) - } - }) + client := &fakeIssueCommentClient{ + comments: toCommentPointers(testCase.comments), + } - server := httptest.NewServer(handler) - - testContext.Login.URL = server.URL + testContext.Login.URL = "https://gitea.example.com" testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index) var outBuffer bytes.Buffer @@ -187,16 +215,19 @@ func TestRunIssueDetailAsJSON(t *testing.T) { testContext.ErrWriter = &errBuffer if testCase.flagComments { - _ = testContext.Command.Set("comments", "true") + require.NoError(t, testContext.Set("comments", "true")) } else { - _ = testContext.Command.Set("comments", "false") + require.NoError(t, testContext.Set("comments", "false")) } - err := runIssueDetailAsJSON(&testContext, &testCase.issue) - - server.Close() + err := runIssueDetailAsJSONWithClient(&testContext, &testCase.issue, client) require.NoError(t, err, "Failed to run issue detail as JSON") + if testCase.flagComments { + assert.Equal(t, testOwner, client.owner) + assert.Equal(t, testRepo, client.repo) + assert.Equal(t, testCase.issue.Index, client.index) + } out := outBuffer.String() @@ -269,7 +300,7 @@ func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { issueIndex := int64(12) expectedOwner := "overrideOwner" expectedRepo := "overrideRepo" - issue := gitea.Issue{ + issue := &gitea.Issue{ ID: 99, Index: issueIndex, Title: "Owner override test", @@ -281,34 +312,10 @@ func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { HTMLURL: "https://example.test/issues/12", } - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", expectedOwner, expectedRepo, issueIndex): - jsonIssue, err := json.Marshal(issue) - require.NoError(t, err, "Testing setup failed: failed to marshal issue") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err = w.Write(jsonIssue) - require.NoError(t, err, "Testing setup failed: failed to write issue") - case fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", expectedOwner, expectedRepo, issueIndex): - jsonReactions, err := json.Marshal([]gitea.Reaction{}) - require.NoError(t, err, "Testing setup failed: failed to marshal reactions") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, err = w.Write(jsonReactions) - require.NoError(t, err, "Testing setup failed: failed to write reactions") - default: - http.NotFound(w, r) - } - }) - - server := httptest.NewServer(handler) - defer server.Close() - config.SetConfigForTesting(config.LocalConfig{ Logins: []config.Login{{ Name: "testLogin", - URL: server.URL, + URL: "https://gitea.example.com", Token: "token", User: "loginUser", Default: true, @@ -333,9 +340,19 @@ func TestRunIssueDetailUsesOwnerFlag(t *testing.T) { require.NoError(t, cmd.Set("login", "testLogin")) require.NoError(t, cmd.Set("repo", expectedRepo)) require.NoError(t, cmd.Set("owner", expectedOwner)) - require.NoError(t, cmd.Set("output", "json")) require.NoError(t, cmd.Set("comments", "false")) - err := runIssueDetail(stdctx.Background(), &cmd, fmt.Sprintf("%d", issueIndex)) + teaCtx, idx, err := resolveIssueDetailContext(&cmd, fmt.Sprintf("%d", issueIndex)) + require.NoError(t, err) + + client := &fakeIssueDetailClient{ + issue: issue, + reactions: []*gitea.Reaction{}, + } + + err = runIssueDetailWithClient(teaCtx, idx, client) require.NoError(t, err, "Expected runIssueDetail to succeed") + assert.Equal(t, expectedOwner, client.owner) + assert.Equal(t, expectedRepo, client.repo) + assert.Equal(t, issueIndex, client.index) } diff --git a/cmd/labels/create.go b/cmd/labels/create.go index 38e08d7..5320521 100644 --- a/cmd/labels/create.go +++ b/cmd/labels/create.go @@ -46,8 +46,13 @@ var CmdLabelCreate = cli.Command{ } func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } labelFile := ctx.String("file") if len(labelFile) == 0 { diff --git a/cmd/labels/delete.go b/cmd/labels/delete.go index cc72e39..1199dcc 100644 --- a/cmd/labels/delete.go +++ b/cmd/labels/delete.go @@ -31,8 +31,13 @@ var CmdLabelDelete = cli.Command{ } func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } labelID := ctx.Int64("id") client := ctx.Login.Client() diff --git a/cmd/labels/list.go b/cmd/labels/list.go index fcbe90d..ca66d3a 100644 --- a/cmd/labels/list.go +++ b/cmd/labels/list.go @@ -36,12 +36,17 @@ var CmdLabelsList = cli.Command{ // RunLabelsList list labels. func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err @@ -51,6 +56,5 @@ func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error { return task.LabelsExport(labels, ctx.String("save")) } - print.LabelsList(labels, ctx.Output) - return nil + return print.LabelsList(labels, ctx.Output) } diff --git a/cmd/labels/update.go b/cmd/labels/update.go index e1d69db..9e2cb1d 100644 --- a/cmd/labels/update.go +++ b/cmd/labels/update.go @@ -41,8 +41,13 @@ var CmdLabelUpdate = cli.Command{ } func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } id := ctx.Int64("id") var pName, pColor, pDescription *string @@ -61,7 +66,6 @@ func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error { pDescription = &description } - var err error _, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{ Name: pName, Color: pColor, diff --git a/cmd/login.go b/cmd/login.go index 1c9fe8a..4e427ce 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -42,7 +42,10 @@ func runLogins(ctx context.Context, cmd *cli.Command) error { } func runLoginDetail(name string) error { - l := config.GetLoginByName(name) + l, err := config.GetLoginByName(name) + if err != nil { + return err + } if l == nil { fmt.Printf("Login '%s' do not exist\n\n", name) return nil diff --git a/cmd/login/helper.go b/cmd/login/helper.go index f2efcce..62858ea 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -101,7 +101,11 @@ var CmdLoginHelper = cli.Command{ // Use --login flag if provided, otherwise fall back to host lookup var userConfig *config.Login if loginName := cmd.String("login"); loginName != "" { - userConfig = config.GetLoginByName(loginName) + var lookupErr error + userConfig, lookupErr = config.GetLoginByName(loginName) + if lookupErr != nil { + log.Fatal(lookupErr) + } if userConfig == nil { log.Fatalf("Login '%s' not found", loginName) } diff --git a/cmd/login/list.go b/cmd/login/list.go index ea47515..79c442b 100644 --- a/cmd/login/list.go +++ b/cmd/login/list.go @@ -30,6 +30,5 @@ func RunLoginList(_ context.Context, cmd *cli.Command) error { if err != nil { return err } - print.LoginsList(logins, cmd.String("output")) - return nil + return print.LoginsList(logins, cmd.String("output")) } diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go index 653882e..af6012a 100644 --- a/cmd/login/oauth_refresh.go +++ b/cmd/login/oauth_refresh.go @@ -38,7 +38,10 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { } // Get the login from config - login := config.GetLoginByName(loginName) + login, err := config.GetLoginByName(loginName) + if err != nil { + return err + } if login == nil { return fmt.Errorf("login '%s' not found", loginName) } @@ -49,7 +52,7 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { } // Try to refresh the token - err := auth.RefreshAccessToken(login) + err = auth.RefreshAccessToken(login) if err == nil { fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) return nil diff --git a/cmd/milestones.go b/cmd/milestones.go index dfde115..915fae0 100644 --- a/cmd/milestones.go +++ b/cmd/milestones.go @@ -40,8 +40,13 @@ func runMilestones(ctx stdctx.Context, cmd *cli.Command) error { } func runMilestoneDetail(_ stdctx.Context, cmd *cli.Command, name string) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name) diff --git a/cmd/milestones/create.go b/cmd/milestones/create.go index 86180b5..7447758 100644 --- a/cmd/milestones/create.go +++ b/cmd/milestones/create.go @@ -50,7 +50,10 @@ var CmdMilestonesCreate = cli.Command{ } func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } date := ctx.String("deadline") deadline := &time.Time{} diff --git a/cmd/milestones/delete.go b/cmd/milestones/delete.go index 4274283..e0e3eaa 100644 --- a/cmd/milestones/delete.go +++ b/cmd/milestones/delete.go @@ -24,10 +24,15 @@ var CmdMilestonesDelete = cli.Command{ } func deleteMilestone(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() - _, err := client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First()) + _, err = client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First()) return err } diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go index 440f245..2f89bac 100644 --- a/cmd/milestones/issues.go +++ b/cmd/milestones/issues.go @@ -71,8 +71,13 @@ var CmdMilestoneRemoveIssue = cli.Command{ } func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() state, err := flags.ParseState(ctx.String("state")) @@ -97,7 +102,7 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { } issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), Milestones: []string{milestone}, Type: kind, State: state, @@ -110,13 +115,17 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - print.IssuesPullsList(issues, ctx.Output, fields) - return nil + return print.IssuesPullsList(issues, ctx.Output, fields) } func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() != 2 { return fmt.Errorf("need two arguments") @@ -145,8 +154,13 @@ func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error { } func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() != 2 { return fmt.Errorf("need two arguments") diff --git a/cmd/milestones/list.go b/cmd/milestones/list.go index f09d587..2149a67 100644 --- a/cmd/milestones/list.go +++ b/cmd/milestones/list.go @@ -40,8 +40,13 @@ var CmdMilestonesList = cli.Command{ // RunMilestonesList list milestones func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } fields, err := fieldsFlag.GetValues(cmd) if err != nil { @@ -58,13 +63,12 @@ func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, }) if err != nil { return err } - print.MilestonesList(milestones, ctx.Output, fields) - return nil + return print.MilestonesList(milestones, ctx.Output, fields) } diff --git a/cmd/milestones/reopen.go b/cmd/milestones/reopen.go index c530600..595eb07 100644 --- a/cmd/milestones/reopen.go +++ b/cmd/milestones/reopen.go @@ -29,8 +29,13 @@ var CmdMilestonesReopen = cli.Command{ } func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() == 0 { return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } @@ -41,6 +46,13 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { } client := ctx.Login.Client() + repoURL := "" + if ctx.Args().Len() > 1 { + repoURL, err = ctx.GetRemoteRepoHTMLURL() + if err != nil { + return err + } + } for _, ms := range ctx.Args().Slice() { opts := gitea.EditMilestoneOption{ State: &state, @@ -52,7 +64,7 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { } if ctx.Args().Len() > 1 { - fmt.Printf("%s/milestone/%d\n", ctx.GetRemoteRepoHTMLURL(), milestone.ID) + fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID) } else { print.MilestoneDetails(milestone) } diff --git a/cmd/notifications/list.go b/cmd/notifications/list.go index 521a9b1..334b656 100644 --- a/cmd/notifications/list.go +++ b/cmd/notifications/list.go @@ -5,7 +5,6 @@ package notifications import ( stdctx "context" - "log" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" @@ -64,12 +63,15 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify var news []*gitea.NotificationThread var err error - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() all := ctx.Bool("mine") // This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733) - listOpts := flags.GetListOptions() + listOpts := flags.GetListOptions(cmd) if listOpts.Page == 0 { listOpts.Page = 1 } @@ -91,7 +93,9 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify SubjectTypes: subjects, }) } else { - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } news, _, err = client.ListRepoNotifications(ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{ ListOptions: listOpts, Status: status, @@ -99,9 +103,8 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify }) } if err != nil { - log.Fatal(err) + return err } - print.NotificationsList(news, ctx.Output, fields) - return nil + return print.NotificationsList(news, ctx.Output, fields) } diff --git a/cmd/notifications/mark_as.go b/cmd/notifications/mark_as.go index fb25e00..a191766 100644 --- a/cmd/notifications/mark_as.go +++ b/cmd/notifications/mark_as.go @@ -23,7 +23,10 @@ var CmdNotificationsMarkRead = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter, err := flags.NotificationStateFlag.GetValues(cmd) if err != nil { return err @@ -44,7 +47,10 @@ var CmdNotificationsMarkUnread = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter, err := flags.NotificationStateFlag.GetValues(cmd) if err != nil { return err @@ -65,7 +71,10 @@ var CmdNotificationsMarkPinned = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter, err := flags.NotificationStateFlag.GetValues(cmd) if err != nil { return err @@ -85,7 +94,10 @@ var CmdNotificationsUnpin = cli.Command{ ArgsUsage: "[all | ]", Flags: flags.NotificationFlags, Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } filter := []string{string(gitea.NotifyStatusPinned)} // NOTE: we implicitly mark it as read, to match web UI semantics. marking as unread might be more useful? return markNotificationAs(ctx, filter, gitea.NotifyStatusRead) @@ -109,7 +121,9 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt if allRepos { _, _, err = client.ReadNotifications(opts) } else { - cmd.Ensure(context.CtxRequirement{RemoteRepo: true}) + if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } _, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts) } diff --git a/cmd/open.go b/cmd/open.go index f4f3041..7e6c448 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -28,8 +28,13 @@ var CmdOpen = cli.Command{ } func runOpen(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } var suffix string number := ctx.Args().Get(0) @@ -74,5 +79,10 @@ func runOpen(_ stdctx.Context, cmd *cli.Command) error { suffix = number } - return open.Run(path.Join(ctx.GetRemoteRepoHTMLURL(), suffix)) + repoURL, err := ctx.GetRemoteRepoHTMLURL() + if err != nil { + return err + } + + return open.Run(path.Join(repoURL, suffix)) } diff --git a/cmd/organizations.go b/cmd/organizations.go index 5ccb5a3..cccc54c 100644 --- a/cmd/organizations.go +++ b/cmd/organizations.go @@ -31,7 +31,10 @@ var CmdOrgs = cli.Command{ } func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error { - teaCtx := context.InitCommand(cmd) + teaCtx, err := context.InitCommand(cmd) + if err != nil { + return err + } if teaCtx.Args().Len() == 1 { return runOrganizationDetail(teaCtx) } diff --git a/cmd/organizations/create.go b/cmd/organizations/create.go index da96a22..94be20c 100644 --- a/cmd/organizations/create.go +++ b/cmd/organizations/create.go @@ -53,7 +53,10 @@ var CmdOrganizationCreate = cli.Command{ // RunOrganizationCreate sets up a new organization func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } if ctx.Args().Len() < 1 { return fmt.Errorf("organization name is required") diff --git a/cmd/organizations/delete.go b/cmd/organizations/delete.go index 5fb747e..b88f75d 100644 --- a/cmd/organizations/delete.go +++ b/cmd/organizations/delete.go @@ -28,7 +28,10 @@ var CmdOrganizationDelete = cli.Command{ // RunOrganizationDelete delete user organization func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() diff --git a/cmd/organizations/list.go b/cmd/organizations/list.go index 2c7a267..f279edc 100644 --- a/cmd/organizations/list.go +++ b/cmd/organizations/list.go @@ -29,17 +29,18 @@ var CmdOrganizationList = cli.Command{ // RunOrganizationList list user organizations func RunOrganizationList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() userOrganizations, _, err := client.ListUserOrgs(ctx.Login.User, gitea.ListOrgsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.OrganizationsList(userOrganizations, ctx.Output) - - return nil + return print.OrganizationsList(userOrganizations, ctx.Output) } diff --git a/cmd/pulls.go b/cmd/pulls.go index 7318246..e156428 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -5,7 +5,6 @@ package cmd import ( stdctx "context" - "encoding/json" "fmt" "time" @@ -20,26 +19,11 @@ import ( "github.com/urfave/cli/v3" ) -type pullLabelData struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` -} +type pullLabelData = detailLabelData -type pullReviewData struct { - ID int64 `json:"id"` - Reviewer string `json:"reviewer"` - State gitea.ReviewStateType `json:"state"` - Body string `json:"body"` - Created time.Time `json:"created"` -} +type pullReviewData = detailReviewData -type pullCommentData struct { - ID int64 `json:"id"` - Author string `json:"author"` - Created time.Time `json:"created"` - Body string `json:"body"` -} +type pullCommentData = detailCommentData type pullData struct { ID int64 `json:"id"` @@ -103,8 +87,13 @@ func runPulls(ctx stdctx.Context, cmd *cli.Command) error { } func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } idx, err := utils.ArgToIndex(index) if err != nil { return err @@ -149,28 +138,7 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews []*gitea.PullReview) error { c := ctx.Login.Client() - opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} - - labelSlice := make([]pullLabelData, 0, len(pr.Labels)) - for _, label := range pr.Labels { - labelSlice = append(labelSlice, pullLabelData{label.Name, label.Color, label.Description}) - } - - assigneesSlice := make([]string, 0, len(pr.Assignees)) - for _, assignee := range pr.Assignees { - assigneesSlice = append(assigneesSlice, assignee.UserName) - } - - reviewsSlice := make([]pullReviewData, 0, len(reviews)) - for _, review := range reviews { - reviewsSlice = append(reviewsSlice, pullReviewData{ - ID: review.ID, - Reviewer: review.Reviewer.UserName, - State: review.State, - Body: review.Body, - Created: review.Submitted, - }) - } + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} mergedBy := "" if pr.MergedBy != nil { @@ -184,10 +152,10 @@ func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews State: pr.State, Created: pr.Created, Updated: pr.Updated, - User: pr.Poster.UserName, + User: username(pr.Poster), Body: pr.Body, - Labels: labelSlice, - Assignees: assigneesSlice, + Labels: buildDetailLabels(pr.Labels), + Assignees: buildDetailAssignees(pr.Assignees), URL: pr.HTMLURL, Base: pr.Base.Ref, Head: pr.Head.Ref, @@ -198,7 +166,7 @@ func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews MergedAt: pr.Merged, MergedBy: mergedBy, ClosedAt: pr.Closed, - Reviews: reviewsSlice, + Reviews: buildDetailReviews(reviews), Comments: make([]pullCommentData, 0), } @@ -208,23 +176,8 @@ func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews return err } - pullSlice.Comments = make([]pullCommentData, 0, len(comments)) - for _, comment := range comments { - pullSlice.Comments = append(pullSlice.Comments, pullCommentData{ - ID: comment.ID, - Author: comment.Poster.UserName, - Body: comment.Body, - Created: comment.Created, - }) - } + pullSlice.Comments = buildDetailComments(comments) } - jsonData, err := json.MarshalIndent(pullSlice, "", "\t") - if err != nil { - return err - } - - _, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData) - - return err + return writeIndentedJSON(ctx.Writer, pullSlice) } diff --git a/cmd/pulls/approve.go b/cmd/pulls/approve.go index 2f5529d..ba3b0ed 100644 --- a/cmd/pulls/approve.go +++ b/cmd/pulls/approve.go @@ -20,7 +20,10 @@ var CmdPullsApprove = cli.Command{ Description: "Approve a pull request", ArgsUsage: " []", Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } return runPullReview(ctx, gitea.ReviewStateApproved, false) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/pulls/checkout.go b/cmd/pulls/checkout.go index d6b11eb..1219a5d 100644 --- a/cmd/pulls/checkout.go +++ b/cmd/pulls/checkout.go @@ -34,11 +34,16 @@ var CmdPullsCheckout = cli.Command{ } func runPullsCheckout(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{ + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{ LocalRepo: true, RemoteRepo: true, - }) + }); err != nil { + return err + } if ctx.Args().Len() != 1 { return fmt.Errorf("pull request index is required") } diff --git a/cmd/pulls/clean.go b/cmd/pulls/clean.go index 76194ea..05c1e85 100644 --- a/cmd/pulls/clean.go +++ b/cmd/pulls/clean.go @@ -32,8 +32,13 @@ var CmdPullsClean = cli.Command{ } func runPullsClean(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{LocalRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil { + return err + } if ctx.Args().Len() != 1 { return fmt.Errorf("pull request index is required") } diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index bdeb879..c0f74d7 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -49,11 +49,16 @@ var CmdPullsCreate = cli.Command{ } func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{ + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{ LocalRepo: true, RemoteRepo: true, - }) + }); err != nil { + return err + } // no args -> interactive mode if ctx.IsInteractiveMode() { diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go index 2635344..6fb05b5 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -17,8 +17,13 @@ import ( // editPullState abstracts the arg parsing to edit the given pull request func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() == 0 { return fmt.Errorf("pull request index is required") } diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go index a13d7e4..d87e3af 100644 --- a/cmd/pulls/list.go +++ b/cmd/pulls/list.go @@ -30,8 +30,13 @@ var CmdPullsList = cli.Command{ // RunPullsList return list of pulls func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } state, err := flags.ParseState(ctx.String("state")) if err != nil { @@ -39,7 +44,7 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { } prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), State: state, }) if err != nil { @@ -51,6 +56,5 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.PullsList(prs, ctx.Output, fields) - return nil + return print.PullsList(prs, ctx.Output, fields) } diff --git a/cmd/pulls/merge.go b/cmd/pulls/merge.go index 11f36d1..a8de930 100644 --- a/cmd/pulls/merge.go +++ b/cmd/pulls/merge.go @@ -41,8 +41,13 @@ var CmdPullsMerge = cli.Command{ }, }, flags.AllDefaultFlags...), Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() != 1 { // If no PR index is provided, try interactive mode diff --git a/cmd/pulls/reject.go b/cmd/pulls/reject.go index b5a75de..67f2a70 100644 --- a/cmd/pulls/reject.go +++ b/cmd/pulls/reject.go @@ -19,7 +19,10 @@ var CmdPullsReject = cli.Command{ Description: "Request changes to a pull request", ArgsUsage: " ", Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } return runPullReview(ctx, gitea.ReviewStateRequestChanges, true) }, Flags: flags.AllDefaultFlags, diff --git a/cmd/pulls/review.go b/cmd/pulls/review.go index d5eaadb..58921fa 100644 --- a/cmd/pulls/review.go +++ b/cmd/pulls/review.go @@ -22,8 +22,13 @@ var CmdPullsReview = cli.Command{ Description: "Interactively review a pull request", ArgsUsage: "", Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() != 1 { return fmt.Errorf("must specify a PR index") diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go index cd539f4..c21f84c 100644 --- a/cmd/pulls/review_helpers.go +++ b/cmd/pulls/review_helpers.go @@ -15,7 +15,9 @@ import ( // runPullReview handles the common logic for approving/rejecting pull requests func runPullReview(ctx *context.TeaContext, state gitea.ReviewStateType, requireComment bool) error { - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } minArgs := 1 if requireComment { diff --git a/cmd/releases/create.go b/cmd/releases/create.go index ba1b345..03d5eb7 100644 --- a/cmd/releases/create.go +++ b/cmd/releases/create.go @@ -68,8 +68,13 @@ var CmdReleaseCreate = cli.Command{ } func runReleaseCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } tag := ctx.String("tag") if cmd.Args().Present() { diff --git a/cmd/releases/delete.go b/cmd/releases/delete.go index a3acc8f..e4c7609 100644 --- a/cmd/releases/delete.go +++ b/cmd/releases/delete.go @@ -35,8 +35,13 @@ var CmdReleaseDelete = cli.Command{ } func runReleaseDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if !ctx.Args().Present() { diff --git a/cmd/releases/edit.go b/cmd/releases/edit.go index b1378ff..641a7fc 100644 --- a/cmd/releases/edit.go +++ b/cmd/releases/edit.go @@ -58,8 +58,13 @@ var CmdReleaseEdit = cli.Command{ } func runReleaseEdit(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() var isDraft, isPre *bool diff --git a/cmd/releases/list.go b/cmd/releases/list.go index 431e43a..adb3573 100644 --- a/cmd/releases/list.go +++ b/cmd/releases/list.go @@ -31,18 +31,22 @@ var CmdReleaseList = cli.Command{ // RunReleasesList list releases func RunReleasesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } releases, _, err := ctx.Login.Client().ListReleases(ctx.Owner, ctx.Repo, gitea.ListReleasesOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err } - print.ReleasesList(releases, ctx.Output) - return nil + return print.ReleasesList(releases, ctx.Output) } func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { diff --git a/cmd/repos.go b/cmd/repos.go index a192191..20930a6 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -45,7 +45,10 @@ func runRepos(ctx stdctx.Context, cmd *cli.Command) error { } func runRepoDetail(_ stdctx.Context, cmd *cli.Command, path string) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() repoOwner, repoName := utils.GetOwnerAndRepo(path, ctx.Owner) repo, _, err := client.GetRepo(repoOwner, repoName) diff --git a/cmd/repos/create.go b/cmd/repos/create.go index 3486352..21b19cd 100644 --- a/cmd/repos/create.go +++ b/cmd/repos/create.go @@ -103,11 +103,13 @@ var CmdRepoCreate = cli.Command{ } func runRepoCreate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() var ( repo *gitea.Repository - err error trustmodel gitea.TrustModel ) diff --git a/cmd/repos/create_from_template.go b/cmd/repos/create_from_template.go index 7670980..27a6df3 100644 --- a/cmd/repos/create_from_template.go +++ b/cmd/repos/create_from_template.go @@ -83,7 +83,10 @@ var CmdRepoCreateFromTemplate = cli.Command{ } func runRepoCreateFromTemplate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() templateOwner, templateRepo := utils.GetOwnerAndRepo(ctx.String("template"), ctx.Login.User) diff --git a/cmd/repos/delete.go b/cmd/repos/delete.go index 517ea85..abb9f89 100644 --- a/cmd/repos/delete.go +++ b/cmd/repos/delete.go @@ -46,7 +46,10 @@ var CmdRepoRm = cli.Command{ } func runRepoDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() @@ -76,7 +79,7 @@ func runRepoDelete(_ stdctx.Context, cmd *cli.Command) error { } } - _, err := client.DeleteRepo(owner, repoName) + _, err = client.DeleteRepo(owner, repoName) if err != nil { return err } diff --git a/cmd/repos/edit.go b/cmd/repos/edit.go index aa7d4bc..22a3a55 100644 --- a/cmd/repos/edit.go +++ b/cmd/repos/edit.go @@ -60,8 +60,13 @@ var CmdRepoEdit = cli.Command{ } func runRepoEdit(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() opts := gitea.EditRepoOption{} diff --git a/cmd/repos/fork.go b/cmd/repos/fork.go index 764c027..81dd16e 100644 --- a/cmd/repos/fork.go +++ b/cmd/repos/fork.go @@ -33,8 +33,13 @@ var CmdRepoFork = cli.Command{ } func runRepoFork(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() opts := gitea.CreateForkOption{} diff --git a/cmd/repos/list.go b/cmd/repos/list.go index 4bb9e44..56be1d9 100644 --- a/cmd/repos/list.go +++ b/cmd/repos/list.go @@ -58,7 +58,10 @@ var CmdReposList = cli.Command{ // RunReposList list repositories func RunReposList(_ stdctx.Context, cmd *cli.Command) error { - teaCmd := context.InitCommand(cmd) + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } client := teaCmd.Login.Client() typeFilter, err := getTypeFilter(cmd) @@ -76,11 +79,11 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { } // not an org, treat as user rps, _, err = client.ListUserRepos(owner, gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } else { rps, _, err = client.ListOrgRepos(owner, gitea.ListOrgReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } if err != nil { @@ -92,7 +95,7 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { return err } rps, _, err = client.SearchRepos(gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), StarredByUserID: user.ID, }) if err != nil { @@ -105,11 +108,11 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - rps = paginateRepos(allRepos, flags.GetListOptions()) + rps = paginateRepos(allRepos, flags.GetListOptions(cmd)) } else { var err error rps, _, err = client.ListMyRepos(gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) if err != nil { return err @@ -126,8 +129,7 @@ func RunReposList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.ReposList(reposFiltered, teaCmd.Output, fields) - return nil + return print.ReposList(reposFiltered, teaCmd.Output, fields) } func filterReposByType(repos []*gitea.Repository, t gitea.RepoType) []*gitea.Repository { diff --git a/cmd/repos/migrate.go b/cmd/repos/migrate.go index 6f13fec..3f158b7 100644 --- a/cmd/repos/migrate.go +++ b/cmd/repos/migrate.go @@ -109,11 +109,13 @@ var CmdRepoMigrate = cli.Command{ } func runRepoMigrate(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() var ( repo *gitea.Repository - err error service gitea.GitServiceType ) diff --git a/cmd/repos/search.go b/cmd/repos/search.go index 4dbef58..e395a2d 100644 --- a/cmd/repos/search.go +++ b/cmd/repos/search.go @@ -58,7 +58,10 @@ var CmdReposSearch = cli.Command{ } func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { - teaCmd := context.InitCommand(cmd) + teaCmd, err := context.InitCommand(cmd) + if err != nil { + return err + } client := teaCmd.Login.Client() var ownerID int64 @@ -109,7 +112,7 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { } rps, _, err := client.SearchRepos(gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), OwnerID: ownerID, IsPrivate: isPrivate, IsArchived: isArchived, @@ -127,6 +130,5 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { if err != nil { return err } - print.ReposList(rps, teaCmd.Output, fields) - return nil + return print.ReposList(rps, teaCmd.Output, fields) } diff --git a/cmd/times/add.go b/cmd/times/add.go index 868ffbe..efeca25 100644 --- a/cmd/times/add.go +++ b/cmd/times/add.go @@ -32,8 +32,13 @@ var CmdTrackedTimesAdd = cli.Command{ } func runTrackedTimesAdd(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } if ctx.Args().Len() < 2 { return fmt.Errorf("No issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText) diff --git a/cmd/times/delete.go b/cmd/times/delete.go index d0b57af..b1101b9 100644 --- a/cmd/times/delete.go +++ b/cmd/times/delete.go @@ -26,8 +26,13 @@ var CmdTrackedTimesDelete = cli.Command{ } func runTrackedTimesDelete(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() < 2 { diff --git a/cmd/times/list.go b/cmd/times/list.go index 9a21c71..9eec82c 100644 --- a/cmd/times/list.go +++ b/cmd/times/list.go @@ -72,12 +72,16 @@ Depending on your permissions on the repository, only your own tracked times mig // RunTimesList list repositories func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() var times []*gitea.TrackedTime - var err error var from, until time.Time var fields []string @@ -95,7 +99,7 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { } opts := gitea.ListTrackedTimesOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), Since: from, Before: until, } @@ -133,6 +137,5 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { } } - print.TrackedTimesList(times, ctx.Output, fields, ctx.Bool("total")) - return nil + return print.TrackedTimesList(times, ctx.Output, fields, ctx.Bool("total")) } diff --git a/cmd/times/reset.go b/cmd/times/reset.go index 47c22e5..c2dd1b3 100644 --- a/cmd/times/reset.go +++ b/cmd/times/reset.go @@ -25,8 +25,13 @@ var CmdTrackedTimesReset = cli.Command{ } func runTrackedTimesReset(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) - ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } client := ctx.Login.Client() if ctx.Args().Len() != 1 { diff --git a/cmd/webhooks.go b/cmd/webhooks.go index b17cd04..63f9052 100644 --- a/cmd/webhooks.go +++ b/cmd/webhooks.go @@ -64,7 +64,10 @@ func runWebhooksDefault(ctx stdctx.Context, cmd *cli.Command) error { } func runWebhookDetail(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() webhookID, err := utils.ArgToIndex(cmd.Args().First()) diff --git a/cmd/webhooks/create.go b/cmd/webhooks/create.go index 81bf5fc..57eb468 100644 --- a/cmd/webhooks/create.go +++ b/cmd/webhooks/create.go @@ -59,7 +59,10 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("webhook URL is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() webhookType := gitea.HookType(cmd.String("type")) @@ -95,7 +98,6 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error { } var hook *gitea.Hook - var err error if c.IsGlobal { return fmt.Errorf("global webhooks not yet supported in this version") } else if len(c.Org) > 0 { diff --git a/cmd/webhooks/delete.go b/cmd/webhooks/delete.go index 7f7348d..fe4a5e3 100644 --- a/cmd/webhooks/delete.go +++ b/cmd/webhooks/delete.go @@ -37,7 +37,10 @@ func runWebhooksDelete(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("webhook ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() webhookID, err := utils.ArgToIndex(cmd.Args().First()) diff --git a/cmd/webhooks/list.go b/cmd/webhooks/list.go index 07c2bce..ccabdb2 100644 --- a/cmd/webhooks/list.go +++ b/cmd/webhooks/list.go @@ -30,26 +30,27 @@ var CmdWebhooksList = cli.Command{ // RunWebhooksList list webhooks func RunWebhooksList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() var hooks []*gitea.Hook - var err error if c.IsGlobal { return fmt.Errorf("global webhooks not yet supported in this version") } else if len(c.Org) > 0 { hooks, _, err = client.ListOrgHooks(c.Org, gitea.ListHooksOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } else { hooks, _, err = client.ListRepoHooks(c.Owner, c.Repo, gitea.ListHooksOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) } if err != nil { return err } - print.WebhooksList(hooks, c.Output) - return nil + return print.WebhooksList(hooks, c.Output) } diff --git a/cmd/webhooks/update.go b/cmd/webhooks/update.go index 923a5ea..256f2a9 100644 --- a/cmd/webhooks/update.go +++ b/cmd/webhooks/update.go @@ -61,7 +61,10 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("webhook ID is required") } - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } client := c.Login.Client() webhookID, err := utils.ArgToIndex(cmd.Args().First()) diff --git a/cmd/whoami.go b/cmd/whoami.go index c6fb029..be461a5 100644 --- a/cmd/whoami.go +++ b/cmd/whoami.go @@ -19,7 +19,10 @@ var CmdWhoami = cli.Command{ Usage: "Show current logged in user", ArgsUsage: " ", // command does not accept arguments Action: func(_ stdctx.Context, cmd *cli.Command) error { - ctx := context.InitCommand(cmd) + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } client := ctx.Login.Client() user, _, _ := client.GetMyUserInfo() print.UserDetails(user) diff --git a/main.go b/main.go index 393c617..1ad0405 100644 --- a/main.go +++ b/main.go @@ -6,10 +6,12 @@ package main // import "code.gitea.io/tea" import ( "context" + "errors" "fmt" "os" "code.gitea.io/tea/cmd" + teacontext "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/debug" ) @@ -18,6 +20,9 @@ func main() { app.Flags = append(app.Flags, debug.CliFlag()) err := app.Run(context.Background(), preprocessArgs(os.Args)) if err != nil { + if errors.Is(err, teacontext.ErrCommandCanceled) { + os.Exit(0) + } // app.Run already exits for errors implementing ErrorCoder, // so we only handle generic errors with code 1 here. fmt.Fprintf(app.ErrWriter, "Error: %v\n", err) diff --git a/modules/config/config.go b/modules/config/config.go index d5be554..247c3a9 100644 --- a/modules/config/config.go +++ b/modules/config/config.go @@ -40,12 +40,21 @@ type LocalConfig struct { var ( // config contain if loaded local tea config - config LocalConfig - loadConfigOnce sync.Once + config LocalConfig + loadConfigOnce sync.Once + configPathMu sync.Mutex + configPathTestOverride string ) // GetConfigPath return path to tea config file func GetConfigPath() string { + configPathMu.Lock() + override := configPathTestOverride + configPathMu.Unlock() + if override != "" { + return override + } + configFilePath, err := xdg.ConfigFile("tea/config.yml") var exists bool @@ -71,6 +80,13 @@ func GetConfigPath() string { return configFilePath } +// SetConfigPathForTesting overrides the config path used by helpers in tests. +func SetConfigPathForTesting(path string) { + configPathMu.Lock() + configPathTestOverride = path + configPathMu.Unlock() +} + // GetPreferences returns preferences based on the config file func GetPreferences() Preferences { _ = loadConfig() diff --git a/modules/config/lock_test.go b/modules/config/lock_test.go index 28e9323..2dde519 100644 --- a/modules/config/lock_test.go +++ b/modules/config/lock_test.go @@ -72,28 +72,36 @@ func TestConfigLock_MutexProtection(t *testing.T) { } } +func useTempConfigPath(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.yml") + SetConfigPathForTesting(configPath) + t.Cleanup(func() { + SetConfigPathForTesting("") + }) + + return configPath +} + func TestReloadConfigFromDisk(t *testing.T) { + configPath := useTempConfigPath(t) + // Save original config state originalConfig := config - // Create a temp config file - tmpDir, err := os.MkdirTemp("", "tea-reload-test") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) + config = LocalConfig{Logins: []Login{{Name: "stale"}}} + if err := os.WriteFile(configPath, []byte("logins:\n - name: test\n"), 0o600); err != nil { + t.Fatalf("failed to write temp config: %v", err) } - defer os.RemoveAll(tmpDir) - // We can't easily change GetConfigPath, so we test that reloadConfigFromDisk - // handles a missing file gracefully (returns nil and resets config) - config = LocalConfig{Logins: []Login{{Name: "test"}}} - - // Call reload - since the actual config path likely exists or doesn't, - // we just verify it doesn't panic and returns without error or with expected error - err = reloadConfigFromDisk() - // The function should either succeed or return an error, not panic + err := reloadConfigFromDisk() if err != nil { - // This is acceptable - config file might not exist in test environment - t.Logf("reloadConfigFromDisk returned error (expected in test env): %v", err) + t.Fatalf("reloadConfigFromDisk returned error: %v", err) + } + + if len(config.Logins) != 1 || config.Logins[0].Name != "test" { + t.Fatalf("expected config to reload test login, got %+v", config.Logins) } // Restore original config @@ -101,6 +109,8 @@ func TestReloadConfigFromDisk(t *testing.T) { } func TestWithConfigLock(t *testing.T) { + useTempConfigPath(t) + executed := false err := withConfigLock(func() error { executed = true @@ -115,6 +125,8 @@ func TestWithConfigLock(t *testing.T) { } func TestWithConfigLock_PropagatesError(t *testing.T) { + useTempConfigPath(t) + expectedErr := fmt.Errorf("test error") err := withConfigLock(func() error { return expectedErr @@ -126,6 +138,8 @@ func TestWithConfigLock_PropagatesError(t *testing.T) { } func TestDoubleCheckedLocking_SimulatedRefresh(t *testing.T) { + useTempConfigPath(t) + // This test simulates the double-checked locking pattern // by having multiple goroutines try to "refresh" simultaneously diff --git a/modules/config/login.go b/modules/config/login.go index d6ac4c2..0e83892 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -164,18 +164,17 @@ func SetDefaultLogin(name string) error { } // GetLoginByName get login by name (case insensitive) -func GetLoginByName(name string) *Login { - err := loadConfig() - if err != nil { - log.Fatal(err) +func GetLoginByName(name string) (*Login, error) { + if err := loadConfig(); err != nil { + return nil, err } - for _, l := range config.Logins { - if strings.EqualFold(l.Name, name) { - return &l + for i := range config.Logins { + if strings.EqualFold(config.Logins[i].Name, name) { + return &config.Logins[i], nil } } - return nil + return nil, nil } // GetLoginByToken get login by token diff --git a/modules/context/context.go b/modules/context/context.go index 901e224..b692036 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -6,9 +6,8 @@ package context import ( "errors" "fmt" - "log" "os" - "path" + "strings" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/git" @@ -23,6 +22,9 @@ import ( var errNotAGiteaRepo = errors.New("No Gitea login found. You might want to specify --repo (and --login) to work outside of a repository") +// ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt. +var ErrCommandCanceled = errors.New("command canceled") + // TeaContext contains all context derived during command initialization and wraps cli.Context type TeaContext struct { *cli.Command @@ -38,9 +40,11 @@ type TeaContext struct { // GetRemoteRepoHTMLURL returns the web-ui url of the remote repo, // after ensuring a remote repo is present in the context. -func (ctx *TeaContext) GetRemoteRepoHTMLURL() string { - ctx.Ensure(CtxRequirement{RemoteRepo: true}) - return path.Join(ctx.Login.URL, ctx.Owner, ctx.Repo) +func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) { + if err := ctx.Ensure(CtxRequirement{RemoteRepo: true}); err != nil { + return "", err + } + return strings.TrimRight(ctx.Login.URL, "/") + "/" + ctx.Owner + "/" + ctx.Repo, nil } // IsInteractiveMode returns true if the command is running in interactive mode @@ -57,7 +61,7 @@ func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool { // available the repo slug. It does this by reading the config file for logins, parsing // the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from // command flags. If a local git repo can't be found, repo slug values are unset. -func InitCommand(cmd *cli.Command) *TeaContext { +func InitCommand(cmd *cli.Command) (*TeaContext, error) { // these flags are used as overrides to the context detection via local git repo repoFlag := cmd.String("repo") loginFlag := cmd.String("login") @@ -75,7 +79,7 @@ func InitCommand(cmd *cli.Command) *TeaContext { // check if repoFlag can be interpreted as path to local repo. if len(repoFlag) != 0 { if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil { - log.Fatal(err.Error()) + return nil, err } if repoFlagPathExists { repoPath = repoFlag @@ -88,7 +92,7 @@ func InitCommand(cmd *cli.Command) *TeaContext { if repoPath == "" { if repoPath, err = os.Getwd(); err != nil { - log.Fatal(err.Error()) + return nil, err } } @@ -97,7 +101,7 @@ func InitCommand(cmd *cli.Command) *TeaContext { envLogin := GetLoginByEnvVar() if envLogin != nil { if _, err := utils.ValidateAuthenticationMethod(envLogin.URL, envLogin.Token, "", "", false, "", ""); err != nil { - log.Fatal(err.Error()) + return nil, err } extraLogins = append(extraLogins, *envLogin) } @@ -108,7 +112,7 @@ func InitCommand(cmd *cli.Command) *TeaContext { if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { // we can deal with that, commands needing the optional values use ctx.Ensure() } else { - log.Fatal(err.Error()) + return nil, err } } @@ -125,20 +129,20 @@ func InitCommand(cmd *cli.Command) *TeaContext { // override login from flag, or use default login if repo based detection failed if len(loginFlag) != 0 { - c.Login = config.GetLoginByName(loginFlag) + if c.Login, err = config.GetLoginByName(loginFlag); err != nil { + return nil, err + } if c.Login == nil { - log.Fatalf("Login name '%s' does not exist", loginFlag) + return nil, fmt.Errorf("login name '%s' does not exist", loginFlag) } } else if c.Login == nil { if c.Login, err = config.GetDefaultLogin(); err != nil { if err.Error() == "No available login" { - // TODO: maybe we can directly start interact.CreateLogin() (only if - // we're sure we can interactively!), as gh cli does. - fmt.Println(`No gitea login configured. To start using tea, first run + return nil, fmt.Errorf(`no gitea login configured. To start using tea, first run tea login add -and then run your command again.`) +and then run your command again`) } - os.Exit(1) + return nil, err } // Only prompt for confirmation if the fallback login is not explicitly set as default @@ -150,10 +154,10 @@ and then run your command again.`) Value(&fallback). WithTheme(theme.GetTheme()). Run(); err != nil { - log.Fatalf("Get confirm failed: %v", err) + return nil, fmt.Errorf("get confirm failed: %w", err) } if !fallback { - os.Exit(1) + return nil, ErrCommandCanceled } } else if !c.Login.Default { fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name) @@ -166,5 +170,5 @@ and then run your command again.`) c.IsGlobal = globalFlag c.Command = cmd c.Output = cmd.String("output") - return &c + return &c, nil } diff --git a/modules/context/context_require.go b/modules/context/context_require.go index 2e97618..93a94df 100644 --- a/modules/context/context_require.go +++ b/modules/context/context_require.go @@ -4,31 +4,28 @@ package context import ( - "fmt" - "os" + "errors" ) -// Ensure checks if requirements on the context are set, and terminates otherwise. -func (ctx *TeaContext) Ensure(req CtxRequirement) { +// Ensure checks if requirements on the context are set. +func (ctx *TeaContext) Ensure(req CtxRequirement) error { if req.LocalRepo && ctx.LocalRepo == nil { - fmt.Println("Local repository required: Execute from a repo dir, or specify a path with --repo.") - os.Exit(1) + return errors.New("local repository required: execute from a repo dir, or specify a path with --repo") } if req.RemoteRepo && len(ctx.RepoSlug) == 0 { - fmt.Println("Remote repository required: Specify ID via --repo or execute from a local git repo.") - os.Exit(1) + return errors.New("remote repository required: specify id via --repo or execute from a local git repo") } if req.Org && len(ctx.Org) == 0 { - fmt.Println("Organization required: Specify organization via --org.") - os.Exit(1) + return errors.New("organization required: specify organization via --org") } if req.Global && !ctx.IsGlobal { - fmt.Println("Global scope required: Specify --global.") - os.Exit(1) + return errors.New("global scope required: specify --global") } + + return nil } // CtxRequirement specifies context needed for operation diff --git a/modules/context/context_require_test.go b/modules/context/context_require_test.go new file mode 100644 index 0000000..a3a338c --- /dev/null +++ b/modules/context/context_require_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "testing" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnsureReturnsRequirementErrors(t *testing.T) { + tests := []struct { + name string + ctx TeaContext + req CtxRequirement + wantErr string + }{ + { + name: "missing local repo", + ctx: TeaContext{}, + req: CtxRequirement{LocalRepo: true}, + wantErr: "local repository required", + }, + { + name: "missing remote repo", + ctx: TeaContext{}, + req: CtxRequirement{RemoteRepo: true}, + wantErr: "remote repository required", + }, + { + name: "missing org", + ctx: TeaContext{}, + req: CtxRequirement{Org: true}, + wantErr: "organization required", + }, + { + name: "missing global scope", + ctx: TeaContext{}, + req: CtxRequirement{Global: true}, + wantErr: "global scope required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.ctx.Ensure(tt.req) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestEnsureSucceedsWhenRequirementsMet(t *testing.T) { + ctx := TeaContext{ + LocalRepo: &git.TeaRepo{}, + RepoSlug: "owner/repo", + Owner: "owner", + Repo: "repo", + Org: "myorg", + IsGlobal: true, + } + err := ctx.Ensure(CtxRequirement{ + LocalRepo: true, + RemoteRepo: true, + Org: true, + Global: true, + }) + require.NoError(t, err) +} + +func TestEnsureSucceedsWithNoRequirements(t *testing.T) { + ctx := TeaContext{} + err := ctx.Ensure(CtxRequirement{}) + require.NoError(t, err) +} + +func TestGetRemoteRepoHTMLURL(t *testing.T) { + t.Run("requires remote repo", func(t *testing.T) { + ctx := &TeaContext{} + _, err := ctx.GetRemoteRepoHTMLURL() + require.ErrorContains(t, err, "remote repository required") + }) + + t.Run("returns repo url when context is complete", func(t *testing.T) { + ctx := &TeaContext{ + Login: &config.Login{URL: "https://gitea.example.com"}, + RepoSlug: "owner/repo", + Owner: "owner", + Repo: "repo", + } + + url, err := ctx.GetRemoteRepoHTMLURL() + require.NoError(t, err) + assert.Equal(t, "https://gitea.example.com/owner/repo", url) + }) + + t.Run("trims trailing slash from login URL", func(t *testing.T) { + ctx := &TeaContext{ + Login: &config.Login{URL: "https://gitea.example.com/"}, + RepoSlug: "owner/repo", + Owner: "owner", + Repo: "repo", + } + + url, err := ctx.GetRemoteRepoHTMLURL() + require.NoError(t, err) + assert.Equal(t, "https://gitea.example.com/owner/repo", url) + }) +} diff --git a/modules/interact/comments.go b/modules/interact/comments.go index 93b18f0..844153d 100644 --- a/modules/interact/comments.go +++ b/modules/interact/comments.go @@ -21,7 +21,7 @@ import ( // If that flag is unset, and output is not piped, prompts the user first. func ShowCommentsMaybeInteractive(ctx *context.TeaContext, idx int64, totalComments int) error { if ctx.Bool("comments") { - opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} c := ctx.Login.Client() comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, idx, opts) if err != nil { @@ -40,7 +40,7 @@ func ShowCommentsMaybeInteractive(ctx *context.TeaContext, idx int64, totalComme // ShowCommentsPaginated prompts if issue/pr comments should be shown and continues to do so. func ShowCommentsPaginated(ctx *context.TeaContext, idx int64, totalComments int) error { c := ctx.Login.Client() - opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()} + opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} prompt := "show comments?" commentsLoaded := 0 diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index 863345e..3191677 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -44,7 +44,7 @@ func getPullIndex(ctx *context.TeaContext, branch string) (int64, error) { c := ctx.Login.Client() opts := gitea.ListPullRequestsOptions{ State: gitea.StateOpen, - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(ctx.Command), } selected := "" loadMoreOption := "PR not found? Load more PRs..." diff --git a/modules/print/actions.go b/modules/print/actions.go index 39e8680..d2bb232 100644 --- a/modules/print/actions.go +++ b/modules/print/actions.go @@ -10,7 +10,7 @@ import ( ) // ActionSecretsList prints a list of action secrets -func ActionSecretsList(secrets []*gitea.Secret, output string) { +func ActionSecretsList(secrets []*gitea.Secret, output string) error { t := table{ headers: []string{ "Name", @@ -27,11 +27,11 @@ func ActionSecretsList(secrets []*gitea.Secret, output string) { if len(secrets) == 0 { fmt.Printf("No secrets found\n") - return + return nil } t.sort(0, true) - t.print(output) + return t.print(output) } // ActionVariableDetails prints details of a specific action variable @@ -43,7 +43,7 @@ func ActionVariableDetails(variable *gitea.RepoActionVariable) { } // ActionVariablesList prints a list of action variables -func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) { +func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) error { t := table{ headers: []string{ "Name", @@ -68,9 +68,9 @@ func ActionVariablesList(variables []*gitea.RepoActionVariable, output string) { if len(variables) == 0 { fmt.Printf("No variables found\n") - return + return nil } t.sort(0, true) - t.print(output) + return t.print(output) } diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go index c2e2a40..ff9398f 100644 --- a/modules/print/actions_runs.go +++ b/modules/print/actions_runs.go @@ -42,7 +42,7 @@ func getWorkflowDisplayName(run *gitea.ActionWorkflowRun) string { } // ActionRunsList prints a list of workflow runs -func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) { +func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) error { t := table{ headers: []string{ "ID", @@ -74,11 +74,11 @@ func ActionRunsList(runs []*gitea.ActionWorkflowRun, output string) { if len(runs) == 0 { fmt.Printf("No workflow runs found\n") - return + return nil } t.sort(0, true) - t.print(output) + return t.print(output) } // ActionRunDetails prints detailed information about a workflow run @@ -114,7 +114,7 @@ func ActionRunDetails(run *gitea.ActionWorkflowRun) { } // ActionWorkflowJobsList prints a list of workflow jobs -func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) { +func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) error { t := table{ headers: []string{ "ID", @@ -147,15 +147,15 @@ func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) { if len(jobs) == 0 { fmt.Printf("No jobs found\n") - return + return nil } t.sort(0, true) - t.print(output) + return t.print(output) } // WorkflowsList prints a list of workflow files with active status -func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) { +func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) error { t := table{ headers: []string{ "Active", @@ -180,9 +180,9 @@ func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string] if len(workflows) == 0 { fmt.Printf("No workflows found\n") - return + return nil } t.sort(1, true) // Sort by name column - t.print(output) + return t.print(output) } diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go index cf4283e..8cf5693 100644 --- a/modules/print/actions_runs_test.go +++ b/modules/print/actions_runs_test.go @@ -8,6 +8,7 @@ import ( "time" "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/require" ) func TestActionRunsListEmpty(t *testing.T) { @@ -18,7 +19,7 @@ func TestActionRunsListEmpty(t *testing.T) { } }() - ActionRunsList([]*gitea.ActionWorkflowRun{}, "") + require.NoError(t, ActionRunsList([]*gitea.ActionWorkflowRun{}, "")) } func TestActionRunsListWithData(t *testing.T) { @@ -49,7 +50,7 @@ func TestActionRunsListWithData(t *testing.T) { } }() - ActionRunsList(runs, "") + require.NoError(t, ActionRunsList(runs, "")) } func TestActionRunDetails(t *testing.T) { @@ -90,7 +91,7 @@ func TestActionWorkflowJobsListEmpty(t *testing.T) { } }() - ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, "") + require.NoError(t, ActionWorkflowJobsList([]*gitea.ActionWorkflowJob{}, "")) } func TestActionWorkflowJobsListWithData(t *testing.T) { @@ -119,7 +120,7 @@ func TestActionWorkflowJobsListWithData(t *testing.T) { } }() - ActionWorkflowJobsList(jobs, "") + require.NoError(t, ActionWorkflowJobsList(jobs, "")) } func TestFormatDurationMinutes(t *testing.T) { diff --git a/modules/print/actions_test.go b/modules/print/actions_test.go index 788c934..129e55c 100644 --- a/modules/print/actions_test.go +++ b/modules/print/actions_test.go @@ -11,6 +11,7 @@ import ( "time" "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/require" ) func TestActionSecretsListEmpty(t *testing.T) { @@ -21,7 +22,7 @@ func TestActionSecretsListEmpty(t *testing.T) { } }() - ActionSecretsList([]*gitea.Secret{}, "") + require.NoError(t, ActionSecretsList([]*gitea.Secret{}, "")) } func TestActionSecretsListWithData(t *testing.T) { @@ -43,7 +44,7 @@ func TestActionSecretsListWithData(t *testing.T) { } }() - ActionSecretsList(secrets, "") + require.NoError(t, ActionSecretsList(secrets, "")) // Test JSON output format to verify structure var buf bytes.Buffer @@ -55,7 +56,7 @@ func TestActionSecretsListWithData(t *testing.T) { testTable.addRow(secret.Name, FormatTime(secret.Created, true)) } - testTable.fprint(&buf, "json") + require.NoError(t, testTable.fprint(&buf, "json")) output := buf.String() if !strings.Contains(output, "TEST_SECRET_1") { @@ -92,7 +93,7 @@ func TestActionVariablesListEmpty(t *testing.T) { } }() - ActionVariablesList([]*gitea.RepoActionVariable{}, "") + require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{}, "")) } func TestActionVariablesListWithData(t *testing.T) { @@ -118,7 +119,7 @@ func TestActionVariablesListWithData(t *testing.T) { } }() - ActionVariablesList(variables, "") + require.NoError(t, ActionVariablesList(variables, "")) // Test JSON output format to verify structure and truncation var buf bytes.Buffer @@ -134,7 +135,7 @@ func TestActionVariablesListWithData(t *testing.T) { testTable.addRow(variable.Name, value, strconv.Itoa(int(variable.RepoID))) } - testTable.fprint(&buf, "json") + require.NoError(t, testTable.fprint(&buf, "json")) output := buf.String() if !strings.Contains(output, "TEST_VARIABLE_1") { @@ -165,7 +166,7 @@ func TestActionVariablesListValueTruncation(t *testing.T) { } }() - ActionVariablesList([]*gitea.RepoActionVariable{variable}, "") + require.NoError(t, ActionVariablesList([]*gitea.RepoActionVariable{variable}, "")) // Test the truncation logic directly value := variable.Value diff --git a/modules/print/attachment.go b/modules/print/attachment.go index cdfbb5d..074b85c 100644 --- a/modules/print/attachment.go +++ b/modules/print/attachment.go @@ -17,7 +17,7 @@ func formatByteSize(size int64) string { } // ReleaseAttachmentsList prints a listing of release attachments -func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) { +func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) error { t := tableWithHeader( "Name", "Size", @@ -30,5 +30,5 @@ func ReleaseAttachmentsList(attachments []*gitea.Attachment, output string) { ) } - t.print(output) + return t.print(output) } diff --git a/modules/print/branch.go b/modules/print/branch.go index 1375a30..b5d9150 100644 --- a/modules/print/branch.go +++ b/modules/print/branch.go @@ -10,8 +10,7 @@ import ( ) // BranchesList prints a listing of the branches -func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) { - fmt.Println(fields) +func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtection, output string, fields []string) error { printables := make([]printable, len(branches)) for i, branch := range branches { @@ -25,7 +24,7 @@ func BranchesList(branches []*gitea.Branch, protections []*gitea.BranchProtectio } t := tableFromItems(fields, printables, isMachineReadable(output)) - t.print(output) + return t.print(output) } type printableBranch struct { @@ -54,17 +53,17 @@ func (x printableBranch) FormatField(field string, machineReadable bool) string } merging := "" for _, entry := range x.protection.MergeWhitelistTeams { - approving += entry + "/" + merging += entry + "/" } for _, entry := range x.protection.MergeWhitelistUsernames { - approving += entry + "/" + merging += entry + "/" } pushing := "" for _, entry := range x.protection.PushWhitelistTeams { - approving += entry + "/" + pushing += entry + "/" } for _, entry := range x.protection.PushWhitelistUsernames { - approving += entry + "/" + pushing += entry + "/" } return fmt.Sprintf( "- enable-push: %t\n- approving: %s\n- merging: %s\n- pushing: %s\n", diff --git a/modules/print/branch_test.go b/modules/print/branch_test.go new file mode 100644 index 0000000..d6db9d8 --- /dev/null +++ b/modules/print/branch_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "testing" + + "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/assert" +) + +func TestPrintableBranchProtectionUsesSeparateWhitelists(t *testing.T) { + protection := &gitea.BranchProtection{ + EnablePush: true, + ApprovalsWhitelistTeams: []string{"approve-team"}, + ApprovalsWhitelistUsernames: []string{"approve-user"}, + MergeWhitelistTeams: []string{"merge-team"}, + MergeWhitelistUsernames: []string{"merge-user"}, + PushWhitelistTeams: []string{"push-team"}, + PushWhitelistUsernames: []string{"push-user"}, + } + + result := printableBranch{ + branch: &gitea.Branch{Name: "main"}, + protection: protection, + }.FormatField("protection", false) + + assert.Contains(t, result, "- approving: approve-team/approve-user/") + assert.Contains(t, result, "- merging: merge-team/merge-user/") + assert.Contains(t, result, "- pushing: push-team/push-user/") + assert.NotContains(t, result, "- approving: approve-team/approve-user/merge-team/") +} diff --git a/modules/print/issue.go b/modules/print/issue.go index d9bd3ff..ca4fc5a 100644 --- a/modules/print/issue.go +++ b/modules/print/issue.go @@ -45,8 +45,8 @@ func formatReactions(reactions []*gitea.Reaction) string { } // IssuesPullsList prints a listing of issues & pulls -func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) { - printIssues(issues, output, fields) +func IssuesPullsList(issues []*gitea.Issue, output string, fields []string) error { + return printIssues(issues, output, fields) } // IssueFields are all available fields to print with IssuesList() @@ -73,7 +73,7 @@ var IssueFields = []string{ "repo", } -func printIssues(issues []*gitea.Issue, output string, fields []string) { +func printIssues(issues []*gitea.Issue, output string, fields []string) error { labelMap := map[int64]string{} printables := make([]printable, len(issues)) machineReadable := isMachineReadable(output) @@ -90,7 +90,7 @@ func printIssues(issues []*gitea.Issue, output string, fields []string) { } t := tableFromItems(fields, printables, machineReadable) - t.print(output) + return t.print(output) } type printableIssue struct { diff --git a/modules/print/label.go b/modules/print/label.go index a3ed7e8..4be8753 100644 --- a/modules/print/label.go +++ b/modules/print/label.go @@ -10,7 +10,7 @@ import ( ) // LabelsList prints a listing of labels -func LabelsList(labels []*gitea.Label, output string) { +func LabelsList(labels []*gitea.Label, output string) error { t := tableWithHeader( "Index", "Color", @@ -26,5 +26,5 @@ func LabelsList(labels []*gitea.Label, output string) { label.Description, ) } - t.print(output) + return t.print(output) } diff --git a/modules/print/login.go b/modules/print/login.go index 6a800fc..9e98fe8 100644 --- a/modules/print/login.go +++ b/modules/print/login.go @@ -31,7 +31,7 @@ func LoginDetails(login *config.Login) { } // LoginsList prints a listing of logins -func LoginsList(logins []config.Login, output string) { +func LoginsList(logins []config.Login, output string) error { t := tableWithHeader( "Name", "URL", @@ -50,5 +50,5 @@ func LoginsList(logins []config.Login, output string) { ) } - t.print(output) + return t.print(output) } diff --git a/modules/print/milestone.go b/modules/print/milestone.go index 2be1ada..e53a63c 100644 --- a/modules/print/milestone.go +++ b/modules/print/milestone.go @@ -23,14 +23,14 @@ func MilestoneDetails(milestone *gitea.Milestone) { } // MilestonesList prints a listing of milestones -func MilestonesList(news []*gitea.Milestone, output string, fields []string) { +func MilestonesList(news []*gitea.Milestone, output string, fields []string) error { printables := make([]printable, len(news)) for i, x := range news { printables[i] = &printableMilestone{x} } t := tableFromItems(fields, printables, isMachineReadable(output)) t.sort(0, true) - t.print(output) + return t.print(output) } // MilestoneFields are all available fields to print with MilestonesList diff --git a/modules/print/notification.go b/modules/print/notification.go index 4fd457c..d0e0c1d 100644 --- a/modules/print/notification.go +++ b/modules/print/notification.go @@ -11,13 +11,13 @@ import ( ) // NotificationsList prints a listing of notification threads -func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) { +func NotificationsList(news []*gitea.NotificationThread, output string, fields []string) error { printables := make([]printable, len(news)) for i, x := range news { printables[i] = &printableNotification{x} } t := tableFromItems(fields, printables, isMachineReadable(output)) - t.print(output) + return t.print(output) } // NotificationFields are all available fields to print with NotificationsList diff --git a/modules/print/organization.go b/modules/print/organization.go index 7ec5708..9b627a1 100644 --- a/modules/print/organization.go +++ b/modules/print/organization.go @@ -22,10 +22,10 @@ func OrganizationDetails(org *gitea.Organization) { } // OrganizationsList prints a listing of the organizations -func OrganizationsList(organizations []*gitea.Organization, output string) { +func OrganizationsList(organizations []*gitea.Organization, output string) error { if len(organizations) == 0 { fmt.Println("No organizations found") - return + return nil } t := tableWithHeader( @@ -46,5 +46,5 @@ func OrganizationsList(organizations []*gitea.Organization, output string) { ) } - t.print(output) + return t.print(output) } diff --git a/modules/print/pull.go b/modules/print/pull.go index 179dc25..c4e537e 100644 --- a/modules/print/pull.go +++ b/modules/print/pull.go @@ -138,8 +138,8 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { } // PullsList prints a listing of pulls -func PullsList(prs []*gitea.PullRequest, output string, fields []string) { - printPulls(prs, output, fields) +func PullsList(prs []*gitea.PullRequest, output string, fields []string) error { + return printPulls(prs, output, fields) } // PullFields are all available fields to print with PullsList() @@ -170,7 +170,7 @@ var PullFields = []string{ "comments", } -func printPulls(pulls []*gitea.PullRequest, output string, fields []string) { +func printPulls(pulls []*gitea.PullRequest, output string, fields []string) error { labelMap := map[int64]string{} printables := make([]printable, len(pulls)) machineReadable := isMachineReadable(output) @@ -187,7 +187,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) { } t := tableFromItems(fields, printables, machineReadable) - t.print(output) + return t.print(output) } type printablePull struct { diff --git a/modules/print/release.go b/modules/print/release.go index 8c2428c..07ab141 100644 --- a/modules/print/release.go +++ b/modules/print/release.go @@ -8,7 +8,7 @@ import ( ) // ReleasesList prints a listing of releases -func ReleasesList(releases []*gitea.Release, output string) { +func ReleasesList(releases []*gitea.Release, output string) error { t := tableWithHeader( "Tag-Name", "Title", @@ -33,5 +33,5 @@ func ReleasesList(releases []*gitea.Release, output string) { ) } - t.print(output) + return t.print(output) } diff --git a/modules/print/repo.go b/modules/print/repo.go index a69d2b7..353bc8b 100644 --- a/modules/print/repo.go +++ b/modules/print/repo.go @@ -12,13 +12,13 @@ import ( ) // ReposList prints a listing of the repos -func ReposList(repos []*gitea.Repository, output string, fields []string) { +func ReposList(repos []*gitea.Repository, output string, fields []string) error { printables := make([]printable, len(repos)) for i, r := range repos { printables[i] = &printableRepo{r} } t := tableFromItems(fields, printables, isMachineReadable(output)) - t.print(output) + return t.print(output) } // RepoDetails print an repo formatted to stdout @@ -113,7 +113,7 @@ func (x printableRepo) FormatField(field string, machineReadable bool) string { case "forks": return fmt.Sprintf("%d", x.Forks) case "id": - return x.FullName + return fmt.Sprintf("%d", x.ID) case "name": return x.Name case "owner": diff --git a/modules/print/repo_test.go b/modules/print/repo_test.go new file mode 100644 index 0000000..767d7a0 --- /dev/null +++ b/modules/print/repo_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "bytes" + "encoding/json" + "testing" + + "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/require" +) + +func TestReposListUsesNumericIDField(t *testing.T) { + repos := []*gitea.Repository{{ + ID: 123, + Name: "tea", + Owner: &gitea.User{ + UserName: "gitea", + }, + }} + + buf := &bytes.Buffer{} + tbl := tableFromItems([]string{"id", "name"}, []printable{&printableRepo{repos[0]}}, true) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + require.Equal(t, "123", result[0]["id"]) + require.Equal(t, "tea", result[0]["name"]) +} diff --git a/modules/print/table.go b/modules/print/table.go index 322088a..7f9ca04 100644 --- a/modules/print/table.go +++ b/modules/print/table.go @@ -4,6 +4,8 @@ package print import ( + "bytes" + "encoding/csv" "encoding/json" "fmt" "io" @@ -14,6 +16,7 @@ import ( "strings" "github.com/olekukonko/tablewriter" + "gopkg.in/yaml.v3" ) // table provides infrastructure to easily print (sorted) lists in different formats @@ -72,34 +75,26 @@ func (t table) Less(i, j int) bool { return t.values[i][t.sortColumn] < t.values[j][t.sortColumn] } -func (t *table) print(output string) { - t.fprint(os.Stdout, output) +func (t *table) print(output string) error { + return t.fprint(os.Stdout, output) } -func (t *table) fprint(f io.Writer, output string) { +func (t *table) fprint(f io.Writer, output string) error { switch output { case "", "table": - outputTable(f, t.headers, t.values) + return outputTable(f, t.headers, t.values) case "csv": - outputDsv(f, t.headers, t.values, ",") + return outputDsv(f, t.headers, t.values, ',') case "simple": - outputSimple(f, t.headers, t.values) + return outputSimple(f, t.headers, t.values) case "tsv": - outputDsv(f, t.headers, t.values, "\t") + return outputDsv(f, t.headers, t.values, '\t') case "yml", "yaml": - outputYaml(f, t.headers, t.values) + return outputYaml(f, t.headers, t.values) case "json": - outputJSON(f, t.headers, t.values) + return outputJSON(f, t.headers, t.values) default: - fmt.Fprintf(f, `"unknown output type '%s', available types are: -- csv: comma-separated values -- simple: space-separated values -- table: auto-aligned table format (default) -- tsv: tab-separated values -- yaml: YAML format -- json: JSON format -`, output) - os.Exit(1) + return fmt.Errorf("unknown output type %q, available types are: csv, simple, table, tsv, yaml, json", output) } } @@ -118,41 +113,59 @@ func outputTable(f io.Writer, headers []string, values [][]string) error { } // outputSimple prints structured data as space delimited value -func outputSimple(f io.Writer, headers []string, values [][]string) { +func outputSimple(f io.Writer, headers []string, values [][]string) error { for _, value := range values { - fmt.Fprint(f, strings.Join(value, " ")) - fmt.Fprintf(f, "\n") + if _, err := fmt.Fprintln(f, strings.Join(value, " ")); err != nil { + return err + } } + return nil } -// outputDsv prints structured data as delimiter separated value format -func outputDsv(f io.Writer, headers []string, values [][]string, delimiterOpt ...string) { - delimiter := "," - if len(delimiterOpt) > 0 { - delimiter = delimiterOpt[0] +// outputDsv prints structured data as delimiter separated value format. +func outputDsv(f io.Writer, headers []string, values [][]string, delimiter rune) error { + writer := csv.NewWriter(f) + writer.Comma = delimiter + if err := writer.Write(headers); err != nil { + return err } - fmt.Fprintln(f, "\""+strings.Join(headers, "\""+delimiter+"\"")+"\"") for _, value := range values { - fmt.Fprintf(f, "\"") - fmt.Fprint(f, strings.Join(value, "\""+delimiter+"\"")) - fmt.Fprintf(f, "\"") - fmt.Fprintf(f, "\n") + if err := writer.Write(value); err != nil { + return err + } } + writer.Flush() + return writer.Error() } // outputYaml prints structured data as yaml -func outputYaml(f io.Writer, headers []string, values [][]string) { +func outputYaml(f io.Writer, headers []string, values [][]string) error { + root := &yaml.Node{Kind: yaml.SequenceNode} for _, value := range values { - fmt.Fprintln(f, "-") + row := &yaml.Node{Kind: yaml.MappingNode} for j, val := range value { + row.Content = append(row.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: headers[j], + }) + + valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val} intVal, _ := strconv.Atoi(val) if strconv.Itoa(intVal) == val { - fmt.Fprintf(f, " %s: %s\n", headers[j], val) + valueNode.Tag = "!!int" } else { - fmt.Fprintf(f, " %s: '%s'\n", headers[j], strings.ReplaceAll(val, "'", "''")) + valueNode.Tag = "!!str" } + row.Content = append(row.Content, valueNode) } + root.Content = append(root.Content, row) } + encoder := yaml.NewEncoder(f) + if err := encoder.Encode(root); err != nil { + _ = encoder.Close() + return err + } + return encoder.Close() } var ( @@ -166,42 +179,52 @@ func toSnakeCase(str string) string { return strings.ToLower(snake) } -// outputJSON prints structured data as json -// Since golang's map is unordered, we need to ensure consistent ordering, we have -// to output the JSON ourselves. -func outputJSON(f io.Writer, headers []string, values [][]string) { - fmt.Fprintln(f, "[") - itemCount := len(values) - headersCount := len(headers) - const space = " " - for i, value := range values { - fmt.Fprintf(f, "%s{\n", space) - for j, val := range value { - v, err := json.Marshal(val) - if err != nil { - fmt.Printf("Failed to format JSON for value '%s': %v\n", val, err) - return - } - key, err := json.Marshal(toSnakeCase(headers[j])) - if err != nil { - fmt.Printf("Failed to format JSON for header '%s': %v\n", headers[j], err) - return - } - fmt.Fprintf(f, "%s:%s", key, v) - if j != headersCount-1 { - fmt.Fprintln(f, ",") - } else { - fmt.Fprintln(f) - } - } +// orderedRow preserves header insertion order when marshaled to JSON. +type orderedRow struct { + keys []string + values map[string]string +} - if i != itemCount-1 { - fmt.Fprintf(f, "%s},\n", space) - } else { - fmt.Fprintf(f, "%s}\n", space) +func (o orderedRow) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range o.keys { + if i > 0 { + buf.WriteByte(',') } + key, err := json.Marshal(k) + if err != nil { + return nil, err + } + val, err := json.Marshal(o.values[k]) + if err != nil { + return nil, err + } + buf.Write(key) + buf.WriteByte(':') + buf.Write(val) } - fmt.Fprintln(f, "]") + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// outputJSON prints structured data as json, preserving header field order. +func outputJSON(f io.Writer, headers []string, values [][]string) error { + snakeHeaders := make([]string, len(headers)) + for i, h := range headers { + snakeHeaders[i] = toSnakeCase(h) + } + rows := make([]orderedRow, 0, len(values)) + for _, value := range values { + row := orderedRow{keys: snakeHeaders, values: make(map[string]string, len(headers))} + for j, val := range value { + row.values[snakeHeaders[j]] = val + } + rows = append(rows, row) + } + encoder := json.NewEncoder(f) + encoder.SetIndent("", " ") + return encoder.Encode(rows) } func isMachineReadable(outputFormat string) bool { diff --git a/modules/print/table_test.go b/modules/print/table_test.go index 42c94a3..e39c287 100644 --- a/modules/print/table_test.go +++ b/modules/print/table_test.go @@ -5,10 +5,14 @@ package print import ( "bytes" + "encoding/csv" "encoding/json" + "io" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestToSnakeCase(t *testing.T) { @@ -29,7 +33,7 @@ func TestPrint(t *testing.T) { buf := &bytes.Buffer{} - tData.fprint(buf, "json") + require.NoError(t, tData.fprint(buf, "json")) result := []struct { A string B string @@ -51,22 +55,62 @@ func TestPrint(t *testing.T) { buf.Reset() - tData.fprint(buf, "yaml") + require.NoError(t, tData.fprint(buf, "yaml")) - assert.Equal(t, `- - A: 'new a' - B: 'some bbbb' -- - A: 'AAAAA' - B: 'b2' -- - A: '"abc' - B: '"def' -- - A: '''abc' - B: 'de''f' -- - A: '\abc' - B: '''def\' -`, buf.String()) + var yamlResult []map[string]string + require.NoError(t, yaml.Unmarshal(buf.Bytes(), &yamlResult)) + assert.Equal(t, []map[string]string{ + {"A": "new a", "B": "some bbbb"}, + {"A": "AAAAA", "B": "b2"}, + {"A": "\"abc", "B": "\"def"}, + {"A": "'abc", "B": "de'f"}, + {"A": "\\abc", "B": "'def\\"}, + }, yamlResult) +} + +func TestPrintCSVUsesEscaping(t *testing.T) { + tData := &table{ + headers: []string{"A", "B"}, + values: [][]string{ + {"hello,world", `quote "here"`}, + {"multi\nline", "plain"}, + }, + } + + buf := &bytes.Buffer{} + require.NoError(t, tData.fprint(buf, "csv")) + + reader := csv.NewReader(bytes.NewReader(buf.Bytes())) + records, err := reader.ReadAll() + require.NoError(t, err) + assert.Equal(t, [][]string{ + {"A", "B"}, + {"hello,world", `quote "here"`}, + {"multi\nline", "plain"}, + }, records) +} + +func TestPrintJSONPreservesFieldOrder(t *testing.T) { + tData := &table{ + headers: []string{"Zebra", "Apple", "Mango"}, + values: [][]string{{"z", "a", "m"}}, + } + + buf := &bytes.Buffer{} + require.NoError(t, tData.fprint(buf, "json")) + + // Keys must appear in header order (Zebra, Apple, Mango), not sorted alphabetically + raw := buf.String() + zebraIdx := bytes.Index([]byte(raw), []byte(`"zebra"`)) + appleIdx := bytes.Index([]byte(raw), []byte(`"apple"`)) + mangoIdx := bytes.Index([]byte(raw), []byte(`"mango"`)) + assert.Greater(t, appleIdx, zebraIdx, "apple should appear after zebra") + assert.Greater(t, mangoIdx, appleIdx, "mango should appear after apple") +} + +func TestPrintUnknownOutputReturnsError(t *testing.T) { + tData := &table{headers: []string{"A"}, values: [][]string{{"value"}}} + + err := tData.fprint(io.Discard, "unknown") + require.ErrorContains(t, err, `unknown output type "unknown"`) } diff --git a/modules/print/times.go b/modules/print/times.go index a9d8932..cb3b118 100644 --- a/modules/print/times.go +++ b/modules/print/times.go @@ -10,7 +10,7 @@ import ( ) // TrackedTimesList print list of tracked times to stdout -func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) { +func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []string, printTotal bool) error { printables := make([]printable, len(times)) var totalDuration int64 for i, t := range times { @@ -26,7 +26,7 @@ func TrackedTimesList(times []*gitea.TrackedTime, outputType string, fields []st t.addRowSlice(total) } - t.print(outputType) + return t.print(outputType) } // TrackedTimeFields contains all available fields for printing of tracked times. diff --git a/modules/print/user.go b/modules/print/user.go index 437538d..a60c0ad 100644 --- a/modules/print/user.go +++ b/modules/print/user.go @@ -52,13 +52,13 @@ func UserDetails(user *gitea.User) { } // UserList prints a listing of the users -func UserList(user []*gitea.User, output string, fields []string) { +func UserList(user []*gitea.User, output string, fields []string) error { printables := make([]printable, len(user)) for i, u := range user { printables[i] = &printableUser{u} } t := tableFromItems(fields, printables, isMachineReadable(output)) - t.print(output) + return t.print(output) } // UserFields are the available fields to print with UserList() diff --git a/modules/print/webhook.go b/modules/print/webhook.go index 2110b5e..6193e21 100644 --- a/modules/print/webhook.go +++ b/modules/print/webhook.go @@ -12,7 +12,7 @@ import ( ) // WebhooksList prints a listing of webhooks -func WebhooksList(hooks []*gitea.Hook, output string) { +func WebhooksList(hooks []*gitea.Hook, output string) error { t := tableWithHeader( "ID", "Type", @@ -48,7 +48,7 @@ func WebhooksList(hooks []*gitea.Hook, output string) { ) } - t.print(output) + return t.print(output) } // WebhookDetails prints detailed information about a webhook diff --git a/modules/print/webhook_test.go b/modules/print/webhook_test.go index b7746d3..83963a1 100644 --- a/modules/print/webhook_test.go +++ b/modules/print/webhook_test.go @@ -51,10 +51,7 @@ func TestWebhooksList(t *testing.T) { for _, format := range outputFormats { t.Run("Format_"+format, func(t *testing.T) { - // Should not panic - assert.NotPanics(t, func() { - WebhooksList(hooks, format) - }) + assert.NoError(t, WebhooksList(hooks, format)) }) } } @@ -63,16 +60,12 @@ func TestWebhooksListEmpty(t *testing.T) { // Test with empty hook list hooks := []*gitea.Hook{} - assert.NotPanics(t, func() { - WebhooksList(hooks, "table") - }) + assert.NoError(t, WebhooksList(hooks, "table")) } func TestWebhooksListNil(t *testing.T) { // Test with nil hook list - assert.NotPanics(t, func() { - WebhooksList(nil, "table") - }) + assert.NoError(t, WebhooksList(nil, "table")) } func TestWebhookDetails(t *testing.T) { diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 00af245..0759809 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -55,7 +55,9 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe } // ... if there already exist a login with same name - if login := config.GetLoginByName(name); login != nil { + if login, err := config.GetLoginByName(name); err != nil { + return err + } else if login != nil { return fmt.Errorf("login name '%s' has already been used", login.Name) } // ... if we already use this token @@ -202,7 +204,9 @@ func GenerateLoginName(url, user string) (string, error) { // append user name if login name already exists if len(user) != 0 { - if login := config.GetLoginByName(name); login != nil { + if login, err := config.GetLoginByName(name); err != nil { + return "", err + } else if login != nil { return name + "_" + user, nil } } From 6a7c3e4efa07e4f33c20e8c6f878799278d26f88 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 27 Mar 2026 03:46:50 +0000 Subject: [PATCH 55/83] fix(deps): update module github.com/urfave/cli/v3 to v3.8.0 (#937) Reviewed-on: https://gitea.com/gitea/tea/pulls/937 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cf9c70e..c01c215 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 - github.com/urfave/cli/v3 v3.7.0 + github.com/urfave/cli/v3 v3.8.0 golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.42.0 diff --git a/go.sum b/go.sum index ea35830..2ebf3df 100644 --- a/go.sum +++ b/go.sum @@ -217,6 +217,8 @@ github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7v github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= +github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= From e31a167e5413c0a5a4a96e6b9e73e8df0ecc90ac Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 27 Mar 2026 03:47:06 +0000 Subject: [PATCH 56/83] fix(deps): update module github.com/go-authgate/sdk-go to v0.6.1 (#935) Reviewed-on: https://gitea.com/gitea/tea/pulls/935 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index c01c215..0941b18 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 - github.com/go-authgate/sdk-go v0.2.0 + github.com/go-authgate/sdk-go v0.6.1 github.com/go-git/go-git/v5 v5.17.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 diff --git a/go.sum b/go.sum index 2ebf3df..aa681cc 100644 --- a/go.sum +++ b/go.sum @@ -114,6 +114,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-authgate/sdk-go v0.2.0 h1:w22f+sAg/YMqnLOcS/4SAuMZXTbPurzkSQBsjb1hcbw= github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho= +github.com/go-authgate/sdk-go v0.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624/jw= +github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk= 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= From bfbec3fc00e12d88a5490d42e64a984445135929 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 27 Mar 2026 06:04:29 +0000 Subject: [PATCH 57/83] fix(deps): update module code.gitea.io/sdk/gitea to v0.24.1 (#936) Reviewed-on: https://gitea.com/gitea/tea/pulls/936 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- cmd/actions/secrets/create.go | 3 +-- go.mod | 4 ++-- go.sum | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go index e825f34..26e40e1 100644 --- a/cmd/actions/secrets/create.go +++ b/cmd/actions/secrets/create.go @@ -62,8 +62,7 @@ func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error { return err } - _, err = client.CreateRepoActionSecret(c.Owner, c.Repo, gitea.CreateSecretOption{ - Name: secretName, + _, err = client.CreateRepoActionSecret(c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{ Data: secretValue, }) if err != nil { diff --git a/go.mod b/go.mod index 0941b18..d377dae 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.2 code.gitea.io/gitea-vet v0.2.3 - code.gitea.io/sdk/gitea v0.23.2 + code.gitea.io/sdk/gitea v0.24.1 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de @@ -32,7 +32,7 @@ require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect dario.cat/mergo v1.0.2 // indirect - github.com/42wim/httpsig v1.2.3 // indirect + github.com/42wim/httpsig v1.2.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect diff --git a/go.sum b/go.sum index aa681cc..658ee99 100644 --- a/go.sum +++ b/go.sum @@ -14,12 +14,16 @@ code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= +code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= +code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= 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/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= +github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= From 1e136816638bcb48aac6a3bdc0f3aaec5bb322dc Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 30 Mar 2026 07:00:31 +0000 Subject: [PATCH 58/83] fix(deps): update module github.com/go-git/go-git/v5 to v5.17.1 (#942) Reviewed-on: https://gitea.com/gitea/tea/pulls/942 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d377dae..549c50a 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 github.com/go-authgate/sdk-go v0.6.1 - github.com/go-git/go-git/v5 v5.17.0 + github.com/go-git/go-git/v5 v5.17.1 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 diff --git a/go.sum b/go.sum index 658ee99..6ce72ec 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj 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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= +github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= 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 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= From 366069315fbb8a239f7c0be08d3a70a3110cd5d6 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 1 Apr 2026 18:14:14 +0000 Subject: [PATCH 59/83] fix(deps): update module github.com/go-git/go-git/v5 to v5.17.2 (#943) Reviewed-on: https://gitea.com/gitea/tea/pulls/943 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 549c50a..5f33987 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 github.com/go-authgate/sdk-go v0.6.1 - github.com/go-git/go-git/v5 v5.17.1 + github.com/go-git/go-git/v5 v5.17.2 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 diff --git a/go.sum b/go.sum index 6ce72ec..7276375 100644 --- a/go.sum +++ b/go.sum @@ -132,6 +132,8 @@ github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxe github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= +github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= 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 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= From f329f6fab26031839ddbc94bd9ba8c6c9d1b221b Mon Sep 17 00:00:00 2001 From: appleboy Date: Sun, 5 Apr 2026 05:35:15 +0000 Subject: [PATCH 60/83] feat(pulls): add edit subcommand for pull requests (#944) ## Summary - Add `tea pr edit` subcommand to support editing pull request properties (description, title, milestone, deadline, assignees, labels, reviewers) - Add `--add-reviewers` / `--remove-reviewers` flags for managing PR reviewers via `CreateReviewRequests` / `DeleteReviewRequests` API - Extract shared helpers (`ResolveLabelOpts`, `ApplyLabelChanges`, `ApplyReviewerChanges`, `ResolveMilestoneID`) into `modules/task/labels.go` to reduce duplication between issue and PR editing - Refactor existing `EditIssue` to use the same shared helpers - Wrap original error in `ResolveMilestoneID` to preserve underlying error context ## Usage ```bash # Edit PR description tea pr edit 1 --description "new description" # Edit PR title tea pr edit 1 --title "new title" # Edit multiple fields tea pr edit 1 --title "new title" --description "new desc" --add-labels "bug" # Edit multiple PRs tea pr edit 1 2 3 --add-assignees "user1" # Add reviewers tea pr edit 1 --add-reviewers "user1,user2" # Remove reviewers tea pr edit 1 --remove-reviewers "user1" ``` ## Test plan - [x] `go build .` succeeds - [x] `go test ./...` passes - [x] `make clean && make vet && make lint && make fmt-check && make docs-check && make build` all pass - [x] `tea pr edit --description "test"` updates PR description on a Gitea instance - [x] `tea pr edit --title "test"` updates PR title - [x] `tea pr edit --add-labels "bug"` adds label - [x] `tea pr edit --add-reviewers "user"` requests review - [x] `tea pr edit --remove-reviewers "user"` removes reviewer - [x] Existing `tea issues edit` still works correctly after refactor Reviewed-on: https://gitea.com/gitea/tea/pulls/944 Reviewed-by: Lunny Xiao Co-authored-by: appleboy Co-committed-by: appleboy --- cmd/pulls.go | 1 + cmd/pulls/edit.go | 71 ++++++++++++++++++++++++++++++++++ docs/CLI.md | 30 +++++++++++++++ modules/task/issue_edit.go | 72 ++++++++++++---------------------- modules/task/labels.go | 67 ++++++++++++++++++++++++++++++++ modules/task/pull_edit.go | 79 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+), 48 deletions(-) create mode 100644 modules/task/pull_edit.go diff --git a/cmd/pulls.go b/cmd/pulls.go index e156428..58d6c57 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -72,6 +72,7 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsCreate, &pulls.CmdPullsClose, &pulls.CmdPullsReopen, + &pulls.CmdPullsEdit, &pulls.CmdPullsReview, &pulls.CmdPullsApprove, &pulls.CmdPullsReject, diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go index 6fb05b5..b9090b0 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -6,15 +6,86 @@ package pulls import ( stdctx "context" "fmt" + "strings" + "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/utils" "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" ) +// CmdPullsEdit is the subcommand of pulls to edit pull requests +var CmdPullsEdit = cli.Command{ + Name: "edit", + Aliases: []string{"e"}, + Usage: "Edit one or more pull requests", + Description: `Edit one or more pull requests. To unset a property again, +use an empty string (eg. --milestone "").`, + ArgsUsage: " [...]", + Action: runPullsEdit, + Flags: append(flags.IssuePREditFlags, + &cli.StringFlag{ + Name: "add-reviewers", + Aliases: []string{"r"}, + Usage: "Comma-separated list of usernames to request review from", + }, + &cli.StringFlag{ + Name: "remove-reviewers", + Usage: "Comma-separated list of usernames to remove from reviewers", + }, + ), +} + +func runPullsEdit(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if !cmd.Args().Present() { + return fmt.Errorf("must specify at least one pull request index") + } + + opts, err := flags.GetIssuePREditFlags(ctx) + if err != nil { + return err + } + + if cmd.IsSet("add-reviewers") { + opts.AddReviewers = strings.Split(cmd.String("add-reviewers"), ",") + } + if cmd.IsSet("remove-reviewers") { + opts.RemoveReviewers = strings.Split(cmd.String("remove-reviewers"), ",") + } + + indices, err := utils.ArgsToIndices(ctx.Args().Slice()) + if err != nil { + return err + } + + client := ctx.Login.Client() + for _, opts.Index = range indices { + pr, err := task.EditPull(ctx, client, *opts) + if err != nil { + return err + } + if ctx.Args().Len() > 1 { + fmt.Println(pr.HTMLURL) + } else { + print.PullDetails(pr, nil, nil) + } + } + + return nil +} + // editPullState abstracts the arg parsing to edit the given pull request func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error { ctx, err := context.InitCommand(cmd) diff --git a/docs/CLI.md b/docs/CLI.md index 2d9b40a..8d40cd1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -399,6 +399,36 @@ Change state of one or more pull requests to 'open' **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +### edit, e + +Edit one or more pull requests + +**--add-assignees, -a**="": Comma-separated list of usernames to assign + +**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels + +**--add-reviewers, -r**="": Comma-separated list of usernames to request review from + +**--deadline, -D**="": Deadline timestamp to assign + +**--description, -d**="": + +**--login, -l**="": Use a different Gitea Login. Optional + +**--milestone, -m**="": Milestone to assign + +**--referenced-version, -v**="": commit-hash or tag name to assign + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--remove-labels**="": Comma-separated list of labels to remove + +**--remove-reviewers**="": Comma-separated list of usernames to remove from reviewers + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +**--title, -t**="": + ### review Interactively review a pull request diff --git a/modules/task/issue_edit.go b/modules/task/issue_edit.go index c35ae0e..6a4b9fd 100644 --- a/modules/task/issue_edit.go +++ b/modules/task/issue_edit.go @@ -13,37 +13,30 @@ import ( // EditIssueOption wraps around gitea.EditIssueOption which has bad & incosistent semantics. type EditIssueOption struct { - Index int64 - Title *string - Body *string - Ref *string - Milestone *string - Deadline *time.Time - AddLabels []string - RemoveLabels []string - AddAssignees []string + Index int64 + Title *string + Body *string + Ref *string + Milestone *string + Deadline *time.Time + AddLabels []string + RemoveLabels []string + AddAssignees []string + AddReviewers []string + RemoveReviewers []string // RemoveAssignees []string // NOTE: with the current go-sdk, clearing assignees is not possible. } // Normalizes the options into parameters that can be passed to the sdk. // the returned value will be nil, when no change to this part of the issue is requested. func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, error) { - // labels have a separate API call, so they get their own options. - var addLabelOpts, rmLabelOpts *gitea.IssueLabelsOption - if o.AddLabels != nil && len(o.AddLabels) != 0 { - ids, err := ResolveLabelNames(client, ctx.Owner, ctx.Repo, o.AddLabels) - if err != nil { - return nil, nil, nil, err - } - addLabelOpts = &gitea.IssueLabelsOption{Labels: ids} + addLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, o.AddLabels) + if err != nil { + return nil, nil, nil, err } - - if o.RemoveLabels != nil && len(o.RemoveLabels) != 0 { - ids, err := ResolveLabelNames(client, ctx.Owner, ctx.Repo, o.RemoveLabels) - if err != nil { - return nil, nil, nil, err - } - rmLabelOpts = &gitea.IssueLabelsOption{Labels: ids} + rmLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, o.RemoveLabels) + if err != nil { + return nil, nil, nil, err } issueOpts := gitea.EditIssueOption{} @@ -61,15 +54,11 @@ func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Cli issueOptsDirty = true } if o.Milestone != nil { - if *o.Milestone == "" { - issueOpts.Milestone = gitea.OptionalInt64(0) - } else { - ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, *o.Milestone) - if err != nil { - return nil, nil, nil, fmt.Errorf("Milestone '%s' not found", *o.Milestone) - } - issueOpts.Milestone = &ms.ID + id, err := ResolveMilestoneID(client, ctx.Owner, ctx.Repo, *o.Milestone) + if err != nil { + return nil, nil, nil, err } + issueOpts.Milestone = gitea.OptionalInt64(id) issueOptsDirty = true } if o.Deadline != nil { @@ -79,7 +68,7 @@ func (o EditIssueOption) toSdkOptions(ctx *context.TeaContext, client *gitea.Cli issueOpts.RemoveDeadline = gitea.OptionalBool(true) } } - if o.AddAssignees != nil && len(o.AddAssignees) != 0 { + if len(o.AddAssignees) != 0 { issueOpts.Assignees = o.AddAssignees issueOptsDirty = true } @@ -101,21 +90,8 @@ func EditIssue(ctx *context.TeaContext, client *gitea.Client, opts EditIssueOpti return nil, err } - if rmLabelOpts != nil { - // NOTE: as of 1.17, there is no API to remove multiple labels at once. - for _, id := range rmLabelOpts.Labels { - _, err := client.DeleteIssueLabel(ctx.Owner, ctx.Repo, opts.Index, id) - if err != nil { - return nil, fmt.Errorf("could not remove labels: %s", err) - } - } - } - - if addLabelOpts != nil { - _, _, err := client.AddIssueLabels(ctx.Owner, ctx.Repo, opts.Index, *addLabelOpts) - if err != nil { - return nil, fmt.Errorf("could not add labels: %s", err) - } + if err := ApplyLabelChanges(client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil { + return nil, err } var issue *gitea.Issue diff --git a/modules/task/labels.go b/modules/task/labels.go index c0897cd..8d3c12e 100644 --- a/modules/task/labels.go +++ b/modules/task/labels.go @@ -4,6 +4,8 @@ package task import ( + "fmt" + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/utils" ) @@ -24,3 +26,68 @@ func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []st } return labelIDs, nil } + +// ResolveLabelOpts resolves label names to IssueLabelsOption. Returns nil if names is empty. +func ResolveLabelOpts(client *gitea.Client, owner, repo string, names []string) (*gitea.IssueLabelsOption, error) { + if len(names) == 0 { + return nil, nil + } + ids, err := ResolveLabelNames(client, owner, repo, names) + if err != nil { + return nil, err + } + return &gitea.IssueLabelsOption{Labels: ids}, nil +} + +// ApplyLabelChanges adds and removes labels on an issue or pull request. +func ApplyLabelChanges(client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueLabelsOption) error { + if rm != nil { + // NOTE: as of 1.17, there is no API to remove multiple labels at once. + for _, id := range rm.Labels { + _, err := client.DeleteIssueLabel(owner, repo, index, id) + if err != nil { + return fmt.Errorf("could not remove labels: %s", err) + } + } + } + if add != nil { + _, _, err := client.AddIssueLabels(owner, repo, index, *add) + if err != nil { + return fmt.Errorf("could not add labels: %s", err) + } + } + return nil +} + +// ApplyReviewerChanges adds and removes reviewers on a pull request. +func ApplyReviewerChanges(client *gitea.Client, owner, repo string, index int64, add, rm []string) error { + if len(rm) != 0 { + _, err := client.DeleteReviewRequests(owner, repo, index, gitea.PullReviewRequestOptions{ + Reviewers: rm, + }) + if err != nil { + return fmt.Errorf("could not remove reviewers: %w", err) + } + } + if len(add) != 0 { + _, err := client.CreateReviewRequests(owner, repo, index, gitea.PullReviewRequestOptions{ + Reviewers: add, + }) + if err != nil { + return fmt.Errorf("could not add reviewers: %w", err) + } + } + return nil +} + +// ResolveMilestoneID resolves a milestone name to its ID. Returns 0 for empty name. +func ResolveMilestoneID(client *gitea.Client, owner, repo, name string) (int64, error) { + if name == "" { + return 0, nil + } + ms, _, err := client.GetMilestoneByName(owner, repo, name) + if err != nil { + return 0, fmt.Errorf("could not resolve milestone '%s': %w", name, err) + } + return ms.ID, nil +} diff --git a/modules/task/pull_edit.go b/modules/task/pull_edit.go new file mode 100644 index 0000000..0d73cfd --- /dev/null +++ b/modules/task/pull_edit.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/context" +) + +// EditPull edits a pull request and returns the updated pull request. +func EditPull(ctx *context.TeaContext, client *gitea.Client, opts EditIssueOption) (*gitea.PullRequest, error) { + if client == nil { + client = ctx.Login.Client() + } + + addLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, opts.AddLabels) + if err != nil { + return nil, err + } + rmLabelOpts, err := ResolveLabelOpts(client, ctx.Owner, ctx.Repo, opts.RemoveLabels) + if err != nil { + return nil, err + } + + prOpts := gitea.EditPullRequestOption{} + var prOptsDirty bool + if opts.Title != nil { + prOpts.Title = *opts.Title + prOptsDirty = true + } + if opts.Body != nil { + prOpts.Body = opts.Body + prOptsDirty = true + } + if opts.Milestone != nil { + id, err := ResolveMilestoneID(client, ctx.Owner, ctx.Repo, *opts.Milestone) + if err != nil { + return nil, err + } + prOpts.Milestone = id + prOptsDirty = true + } + if opts.Deadline != nil { + prOpts.Deadline = opts.Deadline + prOptsDirty = true + if opts.Deadline.IsZero() { + prOpts.RemoveDeadline = gitea.OptionalBool(true) + } + } + if len(opts.AddAssignees) != 0 { + prOpts.Assignees = opts.AddAssignees + prOptsDirty = true + } + + if err := ApplyLabelChanges(client, ctx.Owner, ctx.Repo, opts.Index, addLabelOpts, rmLabelOpts); err != nil { + return nil, err + } + + if err := ApplyReviewerChanges(client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil { + return nil, err + } + + var pr *gitea.PullRequest + if prOptsDirty { + pr, _, err = client.EditPullRequest(ctx.Owner, ctx.Repo, opts.Index, prOpts) + if err != nil { + return nil, fmt.Errorf("could not edit pull request: %s", err) + } + } else { + pr, _, err = client.GetPullRequest(ctx.Owner, ctx.Repo, opts.Index) + if err != nil { + return nil, fmt.Errorf("could not get pull request: %s", err) + } + } + return pr, nil +} From 5bb73667d1e836046ec0a85eec2001e13f4b4cec Mon Sep 17 00:00:00 2001 From: appleboy Date: Sun, 5 Apr 2026 16:42:27 +0000 Subject: [PATCH 61/83] docs: add v0.13.0 release notes to CHANGELOG (#945) Add v0.13.0 release notes to CHANGELOG.md covering 21 commits since v0.12.0: 5 new features, 2 enhancements, and dependency updates. Reviewed-on: https://gitea.com/gitea/tea/pulls/945 Reviewed-by: Lunny Xiao Co-authored-by: appleboy Co-committed-by: appleboy --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8273933..5f4d1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05 + +* FEATURES + * Add `tea pr edit` subcommand for pull requests (#944) + * Add `tea repo edit` subcommand (#928) + * Support owner-based repository listing in `tea repo ls` (#931) + * Store OAuth tokens in OS keyring via credstore (#926) + * Support parsing multiple values in `tea api` subcommand (#911) +* ENHANCEMENTS + * Replace log.Fatal/os.Exit with proper error returns (#941) + * Update to charm libraries v2 (#923) +* MISC + * Bump Go version to 1.26 + * Update dependencies: go-git/v5 v5.17.2, gitea SDK v0.24.1, urfave/cli/v3 v3.8.0, oauth2 v0.36.0, tablewriter v1.1.4, go-authgate/sdk-go v0.6.1 + ## [v0.9.1](https://gitea.com/gitea/tea/releases/tag/v0.9.1) - 2023-02-15 * BUGFIXES From 662e339bf9eac80700206c4fa5ec44686c139c5b Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Wed, 8 Apr 2026 03:36:09 +0000 Subject: [PATCH 62/83] feat(pulls): add resolve, unresolve and review-comments subcommands (#948) ## Summary - Add `tea pulls review-comments ` subcommand to list PR review comments with configurable fields (supports table/json/csv/yaml output) - Add `tea pulls resolve ` subcommand to mark a review comment as resolved - Add `tea pulls unresolve ` subcommand to unmark a review comment as resolved - Follow existing approve/reject pattern with shared `runResolveComment` helper in `review_helpers.go` ## Usage ```bash # List review comments for PR #42 tea pulls review-comments 42 # Resolve comment #789 tea pulls resolve 789 # Unresolve comment #789 tea pulls unresolve 789 # Custom output fields tea pulls review-comments 42 --fields id,path,body,resolver --output json ``` ## New Files | File | Description | |------|-------------| | `cmd/pulls/review_comments.go` | `review-comments` subcommand | | `cmd/pulls/resolve.go` | `resolve` subcommand | | `cmd/pulls/unresolve.go` | `unresolve` subcommand | | `modules/task/pull_review_comment.go` | Task layer: list, resolve, unresolve via SDK | | `modules/print/pull_review_comment.go` | Print formatting with `printable` interface | ## Modified Files | File | Description | |------|-------------| | `cmd/pulls.go` | Register 3 new commands | | `cmd/pulls/review_helpers.go` | Add shared `runResolveComment` helper | ## Test Plan - [x] `go build ./...` passes - [x] `go vet ./...` passes - [x] `tea pulls review-comments ` lists comments with IDs - [x] `tea pulls resolve ` resolves successfully - [x] `tea pulls unresolve ` unresolves successfully - [x] `--output json` produces valid JSON output Reviewed-on: https://gitea.com/gitea/tea/pulls/948 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/pulls.go | 3 ++ cmd/pulls/resolve.go | 30 ++++++++++++ cmd/pulls/review_comments.go | 63 ++++++++++++++++++++++++ cmd/pulls/review_helpers.go | 18 +++++++ cmd/pulls/unresolve.go | 30 ++++++++++++ docs/CLI.md | 40 +++++++++++++++ modules/print/pull_review_comment.go | 73 ++++++++++++++++++++++++++++ modules/task/pull_review_comment.go | 60 +++++++++++++++++++++++ 8 files changed, 317 insertions(+) create mode 100644 cmd/pulls/resolve.go create mode 100644 cmd/pulls/review_comments.go create mode 100644 cmd/pulls/unresolve.go create mode 100644 modules/print/pull_review_comment.go create mode 100644 modules/task/pull_review_comment.go diff --git a/cmd/pulls.go b/cmd/pulls.go index 58d6c57..cb49bb3 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -77,6 +77,9 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsApprove, &pulls.CmdPullsReject, &pulls.CmdPullsMerge, + &pulls.CmdPullsReviewComments, + &pulls.CmdPullsResolve, + &pulls.CmdPullsUnresolve, }, } diff --git a/cmd/pulls/resolve.go b/cmd/pulls/resolve.go new file mode 100644 index 0000000..6be0028 --- /dev/null +++ b/cmd/pulls/resolve.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdPullsResolve resolves a review comment on a pull request +var CmdPullsResolve = cli.Command{ + Name: "resolve", + Usage: "Resolve a review comment on a pull request", + Description: "Resolve a review comment on a pull request", + ArgsUsage: "", + Action: func(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runResolveComment(ctx, task.ResolvePullReviewComment) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/cmd/pulls/review_comments.go b/cmd/pulls/review_comments.go new file mode 100644 index 0000000..63b19f2 --- /dev/null +++ b/cmd/pulls/review_comments.go @@ -0,0 +1,63 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/task" + "code.gitea.io/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +var reviewCommentFieldsFlag = flags.FieldsFlag(print.PullReviewCommentFields, []string{ + "id", "path", "line", "body", "reviewer", "resolver", +}) + +// CmdPullsReviewComments lists review comments on a pull request +var CmdPullsReviewComments = cli.Command{ + Name: "review-comments", + Aliases: []string{"rc"}, + Usage: "List review comments on a pull request", + Description: "List review comments on a pull request", + ArgsUsage: "", + Action: runPullsReviewComments, + Flags: append([]cli.Flag{reviewCommentFieldsFlag}, flags.AllDefaultFlags...), +} + +func runPullsReviewComments(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("pull request index is required") + } + + idx, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + comments, err := task.ListPullReviewComments(ctx, idx) + if err != nil { + return err + } + + fields, err := reviewCommentFieldsFlag.GetValues(cmd) + if err != nil { + return err + } + + return print.PullReviewCommentsList(comments, ctx.Output, fields) +} diff --git a/cmd/pulls/review_helpers.go b/cmd/pulls/review_helpers.go index c21f84c..ba844a6 100644 --- a/cmd/pulls/review_helpers.go +++ b/cmd/pulls/review_helpers.go @@ -40,3 +40,21 @@ func runPullReview(ctx *context.TeaContext, state gitea.ReviewStateType, require return task.CreatePullReview(ctx, idx, state, comment, nil) } + +// runResolveComment handles the common logic for resolving/unresolving review comments +func runResolveComment(ctx *context.TeaContext, action func(*context.TeaContext, int64) error) error { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("comment ID is required") + } + + commentID, err := utils.ArgToIndex(ctx.Args().First()) + if err != nil { + return err + } + + return action(ctx, commentID) +} diff --git a/cmd/pulls/unresolve.go b/cmd/pulls/unresolve.go new file mode 100644 index 0000000..1dea389 --- /dev/null +++ b/cmd/pulls/unresolve.go @@ -0,0 +1,30 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pulls + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdPullsUnresolve unresolves a review comment on a pull request +var CmdPullsUnresolve = cli.Command{ + Name: "unresolve", + Usage: "Unresolve a review comment on a pull request", + Description: "Unresolve a review comment on a pull request", + ArgsUsage: "", + Action: func(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + return runResolveComment(ctx, task.UnresolvePullReviewComment) + }, + Flags: flags.AllDefaultFlags, +} diff --git a/docs/CLI.md b/docs/CLI.md index 8d40cd1..bbfe584 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -483,6 +483,46 @@ Merge a pull request **--title, -t**="": Merge commit title +### review-comments, rc + +List review comments on a pull request + +**--fields, -f**="": Comma-separated list of fields to print. Available values: + id,body,reviewer,path,line,resolver,created,updated,url + (default: "id,path,line,body,reviewer,resolver") + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### resolve + +Resolve a review comment on a pull request + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +### unresolve + +Unresolve a review comment on a pull request + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ## labels, label Manage issue labels diff --git a/modules/print/pull_review_comment.go b/modules/print/pull_review_comment.go new file mode 100644 index 0000000..738d7ee --- /dev/null +++ b/modules/print/pull_review_comment.go @@ -0,0 +1,73 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" +) + +// PullReviewCommentFields are all available fields to print with PullReviewCommentsList() +var PullReviewCommentFields = []string{ + "id", + "body", + "reviewer", + "path", + "line", + "resolver", + "created", + "updated", + "url", +} + +// PullReviewCommentsList prints a listing of pull review comments +func PullReviewCommentsList(comments []*gitea.PullReviewComment, output string, fields []string) error { + printables := make([]printable, len(comments)) + for i, c := range comments { + printables[i] = &printablePullReviewComment{c} + } + t := tableFromItems(fields, printables, isMachineReadable(output)) + return t.print(output) +} + +type printablePullReviewComment struct { + *gitea.PullReviewComment +} + +func (x printablePullReviewComment) FormatField(field string, machineReadable bool) string { + switch field { + case "id": + return fmt.Sprintf("%d", x.ID) + case "body": + return x.Body + case "reviewer": + if x.Reviewer != nil { + return formatUserName(x.Reviewer) + } + return "" + case "path": + return x.Path + case "line": + if x.LineNum != 0 { + return fmt.Sprintf("%d", x.LineNum) + } + if x.OldLineNum != 0 { + return fmt.Sprintf("%d", x.OldLineNum) + } + return "" + case "resolver": + if x.Resolver != nil { + return formatUserName(x.Resolver) + } + return "" + case "created": + return FormatTime(x.Created, machineReadable) + case "updated": + return FormatTime(x.Updated, machineReadable) + case "url": + return x.HTMLURL + } + return "" +} diff --git a/modules/task/pull_review_comment.go b/modules/task/pull_review_comment.go new file mode 100644 index 0000000..68eaf78 --- /dev/null +++ b/modules/task/pull_review_comment.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/context" +) + +// ListPullReviewComments lists all review comments across all reviews for a PR +func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) { + c := ctx.Login.Client() + + reviews, _, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: -1}, + }) + if err != nil { + return nil, err + } + + var allComments []*gitea.PullReviewComment + for _, review := range reviews { + comments, _, err := c.ListPullReviewComments(ctx.Owner, ctx.Repo, idx, review.ID) + if err != nil { + return nil, err + } + allComments = append(allComments, comments...) + } + + return allComments, nil +} + +// ResolvePullReviewComment resolves a review comment +func ResolvePullReviewComment(ctx *context.TeaContext, commentID int64) error { + c := ctx.Login.Client() + + _, err := c.ResolvePullReviewComment(ctx.Owner, ctx.Repo, commentID) + if err != nil { + return err + } + + fmt.Printf("Comment %d resolved\n", commentID) + return nil +} + +// UnresolvePullReviewComment unresolves a review comment +func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error { + c := ctx.Login.Client() + + _, err := c.UnresolvePullReviewComment(ctx.Owner, ctx.Repo, commentID) + if err != nil { + return err + } + + fmt.Printf("Comment %d unresolved\n", commentID) + return nil +} From f538c0528272ed7275bb4e71f4277d36538e795a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 8 Apr 2026 03:38:49 +0000 Subject: [PATCH 63/83] refactor: code cleanup across codebase (#947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Extract duplicate \`getReleaseByTag\` into shared \`cmd/releases/utils.go\` - Replace \`log.Fatal\` calls with proper error returns in config and login commands; \`GetLoginByToken\`/\`GetLoginsByHost\`/\`GetLoginByHost\` now return errors - Remove dead \`portChan\` channel in \`modules/auth/oauth.go\` - Fix YAML integer detection to use \`strconv.ParseInt\` (correctly handles negatives and large ints) - Fix \`path.go\` error handling to use \`errors.As\` + \`syscall.ENOTDIR\` instead of string comparison - Extract repeated credential helper key into local variable in \`SetupHelper\` - Use existing \`isRemoteDeleted()\` in \`pull_clean.go\` instead of duplicating the logic - Fix ~30 error message casing violations to follow Go conventions - Use \`fmt.Errorf\` consistently instead of string concatenation in \`generic.go\` Reviewed-on: https://gitea.com/gitea/tea/pulls/947 Reviewed-by: Lunny Xiao Reviewed-by: Bo-Yi Wu (吳柏毅) Co-authored-by: Nicolas Co-committed-by: Nicolas --- cmd/attachments/create.go | 7 ++-- cmd/attachments/delete.go | 9 +++-- cmd/attachments/list.go | 23 ++--------- cmd/clone.go | 8 +++- cmd/comment.go | 2 +- cmd/flags/csvflag.go | 2 +- cmd/flags/generic.go | 5 ++- cmd/flags/issue_pr.go | 2 +- cmd/labels.go | 2 +- cmd/login/delete.go | 7 ++-- cmd/login/edit.go | 3 +- cmd/login/helper.go | 17 ++++---- cmd/releases/create.go | 2 +- cmd/releases/delete.go | 2 +- cmd/releases/edit.go | 2 +- cmd/releases/list.go | 19 --------- cmd/releases/utils.go | 29 ++++++++++++++ cmd/repos/search.go | 2 +- cmd/times/add.go | 2 +- cmd/times/delete.go | 2 +- cmd/times/reset.go | 2 +- modules/auth/oauth.go | 2 - modules/config/config.go | 4 +- modules/config/login.go | 69 ++++++++++++++++++-------------- modules/context/context.go | 2 +- modules/git/branch.go | 4 +- modules/interact/login.go | 6 +-- modules/interact/pull_merge.go | 2 +- modules/print/table.go | 3 +- modules/task/issue_create.go | 2 +- modules/task/login_create.go | 19 +++++---- modules/task/milestone_create.go | 2 +- modules/task/pull_clean.go | 6 +-- modules/task/pull_merge.go | 2 +- modules/utils/path.go | 12 +++--- modules/utils/validate.go | 8 ++-- 36 files changed, 152 insertions(+), 140 deletions(-) create mode 100644 cmd/releases/utils.go diff --git a/cmd/attachments/create.go b/cmd/attachments/create.go index dd46b46..7f8eafe 100644 --- a/cmd/attachments/create.go +++ b/cmd/attachments/create.go @@ -10,6 +10,7 @@ import ( "path/filepath" "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/cmd/releases" "code.gitea.io/tea/modules/context" "github.com/urfave/cli/v3" @@ -37,15 +38,15 @@ func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() if ctx.Args().Len() < 2 { - return fmt.Errorf("No release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText) + return fmt.Errorf("no release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText) } tag := ctx.Args().First() if len(tag) == 0 { - return fmt.Errorf("Release tag needed to create attachment") + return fmt.Errorf("release tag needed to create attachment") } - release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client) + release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) if err != nil { return err } diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go index b9d0625..b6ebd63 100644 --- a/cmd/attachments/delete.go +++ b/cmd/attachments/delete.go @@ -8,6 +8,7 @@ import ( "fmt" "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/cmd/releases" "code.gitea.io/tea/modules/context" "code.gitea.io/sdk/gitea" @@ -42,12 +43,12 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() if ctx.Args().Len() < 2 { - return fmt.Errorf("No release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText) + return fmt.Errorf("no release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText) } tag := ctx.Args().First() if len(tag) == 0 { - return fmt.Errorf("Release tag needed to delete attachment") + return fmt.Errorf("release tag needed to delete attachment") } if !ctx.Bool("confirm") { @@ -55,7 +56,7 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { return nil } - release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client) + release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) if err != nil { return err } @@ -75,7 +76,7 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { } } if attachment == nil { - return fmt.Errorf("Release does not have attachment named '%s'", name) + return fmt.Errorf("release does not have attachment named '%s'", name) } _, err = client.DeleteReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, attachment.ID) diff --git a/cmd/attachments/list.go b/cmd/attachments/list.go index c070ddc..1c91af3 100644 --- a/cmd/attachments/list.go +++ b/cmd/attachments/list.go @@ -8,6 +8,7 @@ import ( "fmt" "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/cmd/releases" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/print" @@ -42,10 +43,10 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { tag := ctx.Args().First() if len(tag) == 0 { - return fmt.Errorf("Release tag needed to list attachments") + return fmt.Errorf("release tag needed to list attachments") } - release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client) + release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) if err != nil { return err } @@ -59,21 +60,3 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { return print.ReleaseAttachmentsList(attachments, ctx.Output) } - -func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { - rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return nil, err - } - if len(rl) == 0 { - return nil, fmt.Errorf("Repo does not have any release") - } - for _, r := range rl { - if r.TagName == tag { - return r, nil - } - } - return nil, fmt.Errorf("Release tag does not exist") -} diff --git a/cmd/clone.go b/cmd/clone.go index f634a5e..d65a5c2 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -76,9 +76,13 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { owner, repo = utils.GetOwnerAndRepo(url.Path, login.User) if url.Host != "" { - login = config.GetLoginByHost(url.Host) + var lookupErr error + login, lookupErr = config.GetLoginByHost(url.Host) + if lookupErr != nil { + return lookupErr + } if login == nil { - return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host) + return fmt.Errorf("no login configured matching host '%s', run 'tea login add' first", url.Host) } debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host) } diff --git a/cmd/comment.go b/cmd/comment.go index cb69041..cdccbe6 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -46,7 +46,7 @@ func runAddComment(_ stdctx.Context, cmd *cli.Command) error { args := ctx.Args() if args.Len() == 0 { - return fmt.Errorf("Please specify issue / pr index") + return fmt.Errorf("please specify issue / pr index") } idx, err := utils.ArgToIndex(ctx.Args().First()) diff --git a/cmd/flags/csvflag.go b/cmd/flags/csvflag.go index a5ab6ec..a881f01 100644 --- a/cmd/flags/csvflag.go +++ b/cmd/flags/csvflag.go @@ -44,7 +44,7 @@ func (f CsvFlag) GetValues(cmd *cli.Command) ([]string, error) { if f.AvailableFields != nil && val != "" { for _, field := range selection { if !utils.Contains(f.AvailableFields, field) { - return nil, fmt.Errorf("Invalid field '%s'", field) + return nil, fmt.Errorf("invalid field '%s'", field) } } } diff --git a/cmd/flags/generic.go b/cmd/flags/generic.go index 46b1e5d..ee1362f 100644 --- a/cmd/flags/generic.go +++ b/cmd/flags/generic.go @@ -5,6 +5,7 @@ package flags import ( "errors" + "fmt" "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" @@ -167,7 +168,7 @@ func ParseState(stateStr string) (gitea.StateType, error) { case "closed": return gitea.StateClosed, nil default: - return "", errors.New("unknown state '" + stateStr + "'") + return "", fmt.Errorf("unknown state '%s'", stateStr) } } @@ -184,6 +185,6 @@ func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueTyp case "pull", "pulls", "pr": return gitea.IssueTypePull, nil default: - return "", errors.New("unknown kind '" + kindStr + "'") + return "", fmt.Errorf("unknown kind '%s'", kindStr) } } diff --git a/cmd/flags/issue_pr.go b/cmd/flags/issue_pr.go index 49aa72a..67a21e8 100644 --- a/cmd/flags/issue_pr.go +++ b/cmd/flags/issue_pr.go @@ -165,7 +165,7 @@ func GetIssuePRCreateFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, e } ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName) if err != nil { - return nil, fmt.Errorf("Milestone '%s' not found", milestoneName) + return nil, fmt.Errorf("milestone '%s' not found", milestoneName) } opts.Milestone = ms.ID } diff --git a/cmd/labels.go b/cmd/labels.go index 9e00b73..cd0aef1 100644 --- a/cmd/labels.go +++ b/cmd/labels.go @@ -37,5 +37,5 @@ func runLabels(ctx context.Context, cmd *cli.Command) error { } func runLabelsDetails(cmd *cli.Command) error { - return fmt.Errorf("Not yet implemented") + return fmt.Errorf("not yet implemented") } diff --git a/cmd/login/delete.go b/cmd/login/delete.go index e0f6e5c..f9039be 100644 --- a/cmd/login/delete.go +++ b/cmd/login/delete.go @@ -5,8 +5,7 @@ package login import ( "context" - "errors" - "log" + "fmt" "code.gitea.io/tea/modules/config" @@ -27,7 +26,7 @@ var CmdLoginDelete = cli.Command{ func RunLoginDelete(_ context.Context, cmd *cli.Command) error { logins, err := config.GetLogins() if err != nil { - log.Fatal(err) + return err } var name string @@ -37,7 +36,7 @@ func RunLoginDelete(_ context.Context, cmd *cli.Command) error { } else if len(logins) == 1 { name = logins[0].Name } else { - return errors.New("Please specify a login name") + return fmt.Errorf("please specify a login name") } return config.DeleteLogin(name) diff --git a/cmd/login/edit.go b/cmd/login/edit.go index 4fa362e..a413715 100644 --- a/cmd/login/edit.go +++ b/cmd/login/edit.go @@ -5,7 +5,6 @@ package login import ( "context" - "log" "os" "os/exec" @@ -34,7 +33,7 @@ func runLoginEdit(_ context.Context, _ *cli.Command) error { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - log.Fatal(err.Error()) + return err } } return open.Start(config.GetConfigPath()) diff --git a/cmd/login/helper.go b/cmd/login/helper.go index 62858ea..174c85f 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -7,7 +7,6 @@ import ( "bufio" "context" "fmt" - "log" "net/url" "os" "strings" @@ -93,7 +92,7 @@ var CmdLoginHelper = cli.Command{ } if len(wants["host"]) == 0 { - log.Fatal("Hostname is required") + return fmt.Errorf("hostname is required") } else if len(wants["protocol"]) == 0 { wants["protocol"] = "http" } @@ -104,20 +103,24 @@ var CmdLoginHelper = cli.Command{ var lookupErr error userConfig, lookupErr = config.GetLoginByName(loginName) if lookupErr != nil { - log.Fatal(lookupErr) + return lookupErr } if userConfig == nil { - log.Fatalf("Login '%s' not found", loginName) + return fmt.Errorf("login '%s' not found", loginName) } } else { - userConfig = config.GetLoginByHost(wants["host"]) + var lookupErr error + userConfig, lookupErr = config.GetLoginByHost(wants["host"]) + if lookupErr != nil { + return lookupErr + } if userConfig == nil { - log.Fatalf("No login found for host '%s'", wants["host"]) + return fmt.Errorf("no login found for host '%s'", wants["host"]) } } if len(userConfig.GetAccessToken()) == 0 { - log.Fatal("User not set") + return fmt.Errorf("user not set") } host, err := url.Parse(userConfig.URL) diff --git a/cmd/releases/create.go b/cmd/releases/create.go index 03d5eb7..bb525b5 100644 --- a/cmd/releases/create.go +++ b/cmd/releases/create.go @@ -104,7 +104,7 @@ func runReleaseCreate(_ stdctx.Context, cmd *cli.Command) error { }) if err != nil { if resp != nil && resp.StatusCode == http.StatusConflict { - return fmt.Errorf("There already is a release for this tag") + return fmt.Errorf("there is already a release for this tag") } return err } diff --git a/cmd/releases/delete.go b/cmd/releases/delete.go index e4c7609..9d6974d 100644 --- a/cmd/releases/delete.go +++ b/cmd/releases/delete.go @@ -55,7 +55,7 @@ func runReleaseDelete(_ stdctx.Context, cmd *cli.Command) error { } for _, tag := range ctx.Args().Slice() { - release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client) + release, err := GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) if err != nil { return err } diff --git a/cmd/releases/edit.go b/cmd/releases/edit.go index 641a7fc..cc53078 100644 --- a/cmd/releases/edit.go +++ b/cmd/releases/edit.go @@ -81,7 +81,7 @@ func runReleaseEdit(_ stdctx.Context, cmd *cli.Command) error { } for _, tag := range ctx.Args().Slice() { - release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client) + release, err := GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) if err != nil { return err } diff --git a/cmd/releases/list.go b/cmd/releases/list.go index adb3573..8d9af53 100644 --- a/cmd/releases/list.go +++ b/cmd/releases/list.go @@ -5,7 +5,6 @@ package releases import ( stdctx "context" - "fmt" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" @@ -48,21 +47,3 @@ func RunReleasesList(_ stdctx.Context, cmd *cli.Command) error { return print.ReleasesList(releases, ctx.Output) } - -func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { - rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return nil, err - } - if len(rl) == 0 { - return nil, fmt.Errorf("Repo does not have any release") - } - for _, r := range rl { - if r.TagName == tag { - return r, nil - } - } - return nil, fmt.Errorf("Release tag does not exist") -} diff --git a/cmd/releases/utils.go b/cmd/releases/utils.go new file mode 100644 index 0000000..1e0ab50 --- /dev/null +++ b/cmd/releases/utils.go @@ -0,0 +1,29 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package releases + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" +) + +// GetReleaseByTag finds a release by its tag name. +func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { + rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{ + ListOptions: gitea.ListOptions{Page: -1}, + }) + if err != nil { + return nil, err + } + if len(rl) == 0 { + return nil, fmt.Errorf("repo does not have any release") + } + for _, r := range rl { + if r.TagName == tag { + return r, nil + } + } + return nil, fmt.Errorf("release tag does not exist") +} diff --git a/cmd/repos/search.go b/cmd/repos/search.go index e395a2d..d4bb0fd 100644 --- a/cmd/repos/search.go +++ b/cmd/repos/search.go @@ -70,7 +70,7 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error { org, resp, err := client.GetOrg(teaCmd.String("owner")) if err != nil { if resp == nil || resp.StatusCode != http.StatusNotFound { - return fmt.Errorf("Could not find owner: %w", err) + return fmt.Errorf("could not find owner: %w", err) } // if owner is no org, its a user diff --git a/cmd/times/add.go b/cmd/times/add.go index efeca25..2877218 100644 --- a/cmd/times/add.go +++ b/cmd/times/add.go @@ -41,7 +41,7 @@ func runTrackedTimesAdd(_ stdctx.Context, cmd *cli.Command) error { } if ctx.Args().Len() < 2 { - return fmt.Errorf("No issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText) + return fmt.Errorf("no issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText) } issue, err := utils.ArgToIndex(ctx.Args().First()) diff --git a/cmd/times/delete.go b/cmd/times/delete.go index b1101b9..cbb9284 100644 --- a/cmd/times/delete.go +++ b/cmd/times/delete.go @@ -36,7 +36,7 @@ func runTrackedTimesDelete(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() if ctx.Args().Len() < 2 { - return fmt.Errorf("No issue or time ID specified.\nUsage:\t%s", ctx.Command.UsageText) + return fmt.Errorf("no issue or time ID specified.\nUsage:\t%s", ctx.Command.UsageText) } issue, err := utils.ArgToIndex(ctx.Args().First()) diff --git a/cmd/times/reset.go b/cmd/times/reset.go index c2dd1b3..08f8f93 100644 --- a/cmd/times/reset.go +++ b/cmd/times/reset.go @@ -35,7 +35,7 @@ func runTrackedTimesReset(_ stdctx.Context, cmd *cli.Command) error { client := ctx.Login.Client() if ctx.Args().Len() != 1 { - return fmt.Errorf("No issue specified.\nUsage:\t%s", ctx.Command.UsageText) + return fmt.Errorf("no issue specified.\nUsage:\t%s", ctx.Command.UsageText) } issue, err := utils.ArgToIndex(ctx.Args().First()) diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 82f0b43..e1fd47c 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -226,7 +226,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt codeChan := make(chan string, 1) stateChan := make(chan string, 1) errChan := make(chan error, 1) - portChan := make(chan int, 1) // Parse the redirect URL to get the path parsedURL, err := url.Parse(opts.RedirectURL) @@ -311,7 +310,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt if port == 0 { addr := listener.Addr().(*net.TCPAddr) port = addr.Port - portChan <- port // Update redirect URL with actual port parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port) diff --git a/modules/config/config.go b/modules/config/config.go index 247c3a9..18a2241 100644 --- a/modules/config/config.go +++ b/modules/config/config.go @@ -5,7 +5,6 @@ package config import ( "fmt" - "log" "os" "path/filepath" "sync" @@ -74,7 +73,8 @@ func GetConfigPath() string { } if err != nil { - log.Fatal("unable to get or create config file") + fmt.Fprintln(os.Stderr, "unable to get or create config file") + os.Exit(1) } return configFilePath diff --git a/modules/config/login.go b/modules/config/login.go index 0e83892..9a7849d 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -8,7 +8,6 @@ import ( "crypto/tls" "errors" "fmt" - "log" "net/http" "net/http/cookiejar" "net/url" @@ -132,7 +131,7 @@ func GetDefaultLogin() (*Login, error) { } if len(config.Logins) == 0 { - return nil, errors.New("No available login") + return nil, errors.New("no available login") } for _, l := range config.Logins { if l.Default { @@ -178,50 +177,51 @@ func GetLoginByName(name string) (*Login, error) { } // GetLoginByToken get login by token -func GetLoginByToken(token string) *Login { +func GetLoginByToken(token string) (*Login, error) { if token == "" { - return nil + return nil, nil } - err := loadConfig() - if err != nil { - log.Fatal(err) + if err := loadConfig(); err != nil { + return nil, err } for _, l := range config.Logins { if l.Token == token { - return &l + return &l, nil } } - return nil + return nil, nil } -// GetLoginByHost finds a login by it's server URL -func GetLoginByHost(host string) *Login { - logins := GetLoginsByHost(host) - if len(logins) > 0 { - return logins[0] +// GetLoginByHost finds a login by its server URL +func GetLoginByHost(host string) (*Login, error) { + logins, err := GetLoginsByHost(host) + if err != nil { + return nil, err } - return nil + if len(logins) > 0 { + return logins[0], nil + } + return nil, nil } // GetLoginsByHost returns all logins matching a host -func GetLoginsByHost(host string) []*Login { - err := loadConfig() - if err != nil { - log.Fatal(err) +func GetLoginsByHost(host string) ([]*Login, error) { + if err := loadConfig(); err != nil { + return nil, err } var matches []*Login for i := range config.Logins { loginURL, err := url.Parse(config.Logins[i].URL) if err != nil { - log.Fatal(err) + return nil, err } if loginURL.Host == host { matches = append(matches, &config.Logins[i]) } } - return matches + return matches, nil } // DeleteLogin delete a login by name from config @@ -417,12 +417,13 @@ func doOAuthRefresh(l *Login) (*oauth2.Token, error) { func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { // Refresh OAuth token if expired or near expiry if err := l.RefreshOAuthTokenIfNeeded(); err != nil { - log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) + fmt.Fprintf(os.Stderr, "Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) + os.Exit(1) } httpClient := &http.Client{} if l.Insecure { - cookieJar, _ := cookiejar.New(nil) + cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error httpClient = &http.Client{ Jar: cookieJar, @@ -443,12 +444,18 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { } if l.SSHCertPrincipal != "" { - l.askForSSHPassphrase() + if err := l.askForSSHPassphrase(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err) + os.Exit(1) + } options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase)) } if l.SSHKeyFingerprint != "" { - l.askForSSHPassphrase() + if err := l.askForSSHPassphrase(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err) + os.Exit(1) + } options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase)) } @@ -456,25 +463,25 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { if err != nil { var versionError *gitea.ErrUnknownVersion if !errors.As(err, &versionError) { - log.Fatal(err) + fmt.Fprintf(os.Stderr, "Failed to create Gitea client: %s\n", err) + os.Exit(1) } fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError) } return client } -func (l *Login) askForSSHPassphrase() { +func (l *Login) askForSSHPassphrase() error { if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" { - if err := huh.NewInput(). + return huh.NewInput(). Title("ssh-key is encrypted please enter the passphrase: "). Validate(huh.ValidateNotEmpty()). EchoMode(huh.EchoModePassword). Value(&l.SSHPassphrase). WithTheme(theme.GetTheme()). - Run(); err != nil { - log.Fatal(err) - } + Run() } + return nil } // GetSSHHost returns SSH host name diff --git a/modules/context/context.go b/modules/context/context.go index b692036..0fe55b3 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -20,7 +20,7 @@ import ( "golang.org/x/term" ) -var errNotAGiteaRepo = errors.New("No Gitea login found. You might want to specify --repo (and --login) to work outside of a repository") +var errNotAGiteaRepo = errors.New("no Gitea login found; you might want to specify --repo (and --login) to work outside of a repository") // ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt. var ErrCommandCanceled = errors.New("command canceled") diff --git a/modules/git/branch.go b/modules/git/branch.go index 7963c2e..6c709ad 100644 --- a/modules/git/branch.go +++ b/modules/git/branch.go @@ -80,7 +80,7 @@ func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *git_config.Branch, return nil, err } if remote == nil { - return nil, fmt.Errorf("No remote found for '%s'", repoURL) + return nil, fmt.Errorf("no remote found for '%s'", repoURL) } remoteName := remote.Config().Name @@ -133,7 +133,7 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config. return nil, err } if remote == nil { - return nil, fmt.Errorf("No remote found for '%s'", repoURL) + return nil, fmt.Errorf("no remote found for '%s'", repoURL) } remoteName := remote.Config().Name diff --git a/modules/interact/login.go b/modules/interact/login.go index 2b0abab..a6aef27 100644 --- a/modules/interact/login.go +++ b/modules/interact/login.go @@ -41,7 +41,7 @@ func CreateLogin() error { } _, err := url.Parse(s) if err != nil { - return fmt.Errorf("Invalid URL: %v", err) + return fmt.Errorf("invalid URL: %v", err) } return nil }). @@ -69,7 +69,7 @@ func CreateLogin() error { } for _, login := range logins { if login.Name == name { - return fmt.Errorf("Login with name '%s' already exists", name) + return fmt.Errorf("login with name '%s' already exists", name) } } return nil @@ -154,7 +154,7 @@ func CreateLogin() error { Value(&tokenScopes). Validate(func(s []string) error { if len(s) == 0 { - return errors.New("At least one scope is required") + return errors.New("at least one scope is required") } return nil }). diff --git a/modules/interact/pull_merge.go b/modules/interact/pull_merge.go index 3191677..f3d9973 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -58,7 +58,7 @@ func getPullIndex(ctx *context.TeaContext, branch string) (int64, error) { return 0, err } if len(prs) == 0 { - return 0, fmt.Errorf("No open PRs found") + return 0, fmt.Errorf("no open PRs found") } opts.ListOptions.Page++ prOptions := make([]string, 0) diff --git a/modules/print/table.go b/modules/print/table.go index 7f9ca04..41dd43f 100644 --- a/modules/print/table.go +++ b/modules/print/table.go @@ -150,8 +150,7 @@ func outputYaml(f io.Writer, headers []string, values [][]string) error { }) valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val} - intVal, _ := strconv.Atoi(val) - if strconv.Itoa(intVal) == val { + if _, err := strconv.ParseInt(val, 10, 64); err == nil { valueNode.Tag = "!!int" } else { valueNode.Tag = "!!str" diff --git a/modules/task/issue_create.go b/modules/task/issue_create.go index 25f843f..f1ccf3b 100644 --- a/modules/task/issue_create.go +++ b/modules/task/issue_create.go @@ -15,7 +15,7 @@ import ( func CreateIssue(login *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error { // title is required if len(opts.Title) == 0 { - return fmt.Errorf("Title is required") + return fmt.Errorf("title is required") } issue, _, err := login.Client().CreateIssue(repoOwner, repoName, opts) diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 0759809..ac45c46 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -20,12 +20,13 @@ import ( func SetupHelper(login config.Login) (ok bool, err error) { // Check that the URL is not blank if login.URL == "" { - return false, fmt.Errorf("Invalid gitea url") + return false, fmt.Errorf("invalid Gitea URL") } // get all helper to URL in git config + helperKey := fmt.Sprintf("credential.%s.helper", login.URL) var currentHelpers []byte - if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", fmt.Sprintf("credential.%s.helper", login.URL)).Output(); err != nil { + if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil { currentHelpers = []byte{} } @@ -37,10 +38,10 @@ func SetupHelper(login config.Login) (ok bool, err error) { } // Add tea helper - if _, err = exec.Command("git", "config", "--global", fmt.Sprintf("credential.%s.helper", login.URL), "").Output(); err != nil { - return false, fmt.Errorf("git config --global %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), err) - } else if _, err = exec.Command("git", "config", "--global", "--add", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper").Output(); err != nil { - return false, fmt.Errorf("git config --global --add %s %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper", err) + if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil { + return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err) + } else if _, err = exec.Command("git", "config", "--global", "--add", helperKey, "!tea login helper").Output(); err != nil { + return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err) } return true, nil @@ -62,7 +63,11 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe } // ... if we already use this token if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) { - if login := config.GetLoginByToken(token); login != nil { + login, err := config.GetLoginByToken(token) + if err != nil { + return err + } + if login != nil { return fmt.Errorf("token already been used, delete login '%s' first", login.Name) } } diff --git a/modules/task/milestone_create.go b/modules/task/milestone_create.go index bbde1df..d6ee093 100644 --- a/modules/task/milestone_create.go +++ b/modules/task/milestone_create.go @@ -17,7 +17,7 @@ import ( func CreateMilestone(login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error { // title is required if len(title) == 0 { - return fmt.Errorf("Title is required") + return fmt.Errorf("title is required") } mile, _, err := login.Client().CreateMilestone(repoOwner, repoName, gitea.CreateMilestoneOption{ diff --git a/modules/task/pull_clean.go b/modules/task/pull_clean.go index 0f1ad82..5b3512b 100644 --- a/modules/task/pull_clean.go +++ b/modules/task/pull_clean.go @@ -39,7 +39,7 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign // if remote head branch is already deleted, pr.Head.Ref points to "pulls//head" remoteBranch := pr.Head.Ref - remoteDeleted := remoteBranch == fmt.Sprintf("refs/pull/%d/head", pr.Index) + remoteDeleted := isRemoteDeleted(pr) if remoteDeleted { remoteBranch = pr.Head.Name // this still holds the original branch name fmt.Printf("Remote branch '%s' already deleted.\n", remoteBranch) @@ -62,9 +62,9 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign } if branch == nil { if ignoreSHA { - return fmt.Errorf("Remote branch %s not found in local repo", remoteBranch) + return fmt.Errorf("remote branch %s not found in local repo", remoteBranch) } - return fmt.Errorf(`Remote branch %s not found in local repo. + return fmt.Errorf(`remote branch %s not found in local repo. Either you don't track this PR, or the local branch has diverged from the remote. If you still want to continue & are sure you don't loose any important commits, call me again with the --ignore-sha flag`, remoteBranch) diff --git a/modules/task/pull_merge.go b/modules/task/pull_merge.go index 8a2018a..1e28a58 100644 --- a/modules/task/pull_merge.go +++ b/modules/task/pull_merge.go @@ -18,7 +18,7 @@ func PullMerge(login *config.Login, repoOwner, repoName string, index int64, opt return err } if !success { - return fmt.Errorf("Failed to merge PR. Is it still open?") + return fmt.Errorf("failed to merge PR, is it still open?") } return nil } diff --git a/modules/utils/path.go b/modules/utils/path.go index 16621aa..aac9ded 100644 --- a/modules/utils/path.go +++ b/modules/utils/path.go @@ -9,6 +9,7 @@ import ( "os/user" "path/filepath" "strings" + "syscall" ) // PathExists returns whether the given file or directory exists or not @@ -38,18 +39,19 @@ func exists(path string, expectDir bool) (bool, error) { if err != nil { if errors.Is(err, os.ErrNotExist) { return false, nil - } else if err.(*os.PathError).Err.Error() == "not a directory" { - // some middle segment of path is a file, cannot traverse - // FIXME: catches error on linux; go does not provide a way to catch this properly.. + } + var pathErr *os.PathError + if errors.As(err, &pathErr) && errors.Is(pathErr.Err, syscall.ENOTDIR) { + // a middle segment of path is a file, cannot traverse return false, nil } return false, err } isDir := f.IsDir() if isDir && !expectDir { - return false, errors.New("A directory with the same name exists") + return false, errors.New("a directory with the same name exists") } else if !isDir && expectDir { - return false, errors.New("A file with the same name exists") + return false, errors.New("a file with the same name exists") } return true, nil } diff --git a/modules/utils/validate.go b/modules/utils/validate.go index 9924c2f..8f16931 100644 --- a/modules/utils/validate.go +++ b/modules/utils/validate.go @@ -21,17 +21,17 @@ func ValidateAuthenticationMethod( // Normalize URL serverURL, err := NormalizeURL(giteaURL) if err != nil { - return nil, fmt.Errorf("Unable to parse URL: %s", err) + return nil, fmt.Errorf("unable to parse URL: %s", err) } if !sshAgent && sshCertPrincipal == "" && sshKey == "" { // .. if we have enough information to authenticate if len(token) == 0 && (len(user)+len(passwd)) == 0 { - return nil, fmt.Errorf("No token set") + return nil, fmt.Errorf("no token set") } else if len(user) != 0 && len(passwd) == 0 { - return nil, fmt.Errorf("No password set") + return nil, fmt.Errorf("no password set") } else if len(user) == 0 && len(passwd) != 0 { - return nil, fmt.Errorf("No user set") + return nil, fmt.Errorf("no user set") } } return serverURL, nil From 0489d8c27547736362428a24276222e900bf4612 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 9 Apr 2026 14:16:37 +0000 Subject: [PATCH 64/83] fix(deps): update module golang.org/x/sys to v0.43.0 (#951) Reviewed-on: https://gitea.com/gitea/tea/pulls/951 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5f33987..a96447c 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/urfave/cli/v3 v3.8.0 golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 7276375..ccddba4 100644 --- a/go.sum +++ b/go.sum @@ -276,6 +276,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= From 53e53e10676dba9650d50ba1aa99bdc5fe56220a Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 9 Apr 2026 20:03:33 +0000 Subject: [PATCH 65/83] feat(workflows): add dispatch, view, enable and disable subcommands (#952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `tea actions workflows dispatch` to trigger `workflow_dispatch` events with `--ref`, `--input key=value`, and `--follow` for log tailing - Add `tea actions workflows view` to show workflow details - Add `tea actions workflows enable` and `disable` to toggle workflow state - Rewrite `workflows list` to use the Workflow API instead of file listing - Remove dead `WorkflowsList` print function that used `ContentsResponse` - Update `CLI.md` and `example-workflows.md` with usage documentation and examples ## Motivation Enable re-triggering specific workflows from the CLI, which is essential for AI-driven PR flows where a specific workflow needs to be re-run after pushing changes. Leverages the 5 workflow API endpoints already supported by the Go SDK (v0.24.1) from go-gitea/gitea#33545: - `ListRepoActionWorkflows` - `GetRepoActionWorkflow` - `DispatchRepoActionWorkflow` (with `returnRunDetails` support) - `EnableRepoActionWorkflow` - `DisableRepoActionWorkflow` ## New commands \`\`\` tea actions workflows ├── list (rewritten to use Workflow API) ├── view (new) ├── dispatch (new) ├── enable (new) └── disable (new) \`\`\` ### Usage examples \`\`\`bash # Dispatch workflow on current branch tea actions workflows dispatch deploy.yml # Dispatch with specific ref and inputs tea actions workflows dispatch deploy.yml --ref main --input env=staging --input version=1.2.3 # Dispatch and follow logs tea actions workflows dispatch ci.yml --ref feature/my-pr --follow # View workflow details tea actions workflows view deploy.yml # Enable/disable workflows tea actions workflows enable deploy.yml tea actions workflows disable deploy.yml --confirm \`\`\` ## Test plan - [x] `go build ./...` passes - [x] `go test ./...` passes - [x] `go vet ./...` passes - [x] `make lint` — 0 issues - [x] `make docs-check` — CLI.md is up to date - [x] Manual test: `tea actions workflows list` shows workflows from API - [x] Manual test: `tea actions workflows dispatch --ref main` triggers a run - [x] Manual test: `tea actions workflows view ` shows details --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/952 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/actions/workflows.go | 4 + cmd/actions/workflows/disable.go | 65 +++++++++++ cmd/actions/workflows/dispatch.go | 174 +++++++++++++++++++++++++++++ cmd/actions/workflows/enable.go | 48 ++++++++ cmd/actions/workflows/list.go | 59 ++-------- cmd/actions/workflows/view.go | 50 +++++++++ docs/CLI.md | 56 +++++++++- docs/example-workflows.md | 87 ++++++++++++++- modules/print/actions_runs.go | 53 ++++++--- modules/print/actions_runs_test.go | 81 ++++++++++++++ 10 files changed, 611 insertions(+), 66 deletions(-) create mode 100644 cmd/actions/workflows/disable.go create mode 100644 cmd/actions/workflows/dispatch.go create mode 100644 cmd/actions/workflows/enable.go create mode 100644 cmd/actions/workflows/view.go diff --git a/cmd/actions/workflows.go b/cmd/actions/workflows.go index 440783e..1dc593a 100644 --- a/cmd/actions/workflows.go +++ b/cmd/actions/workflows.go @@ -20,6 +20,10 @@ var CmdActionsWorkflows = cli.Command{ Action: runWorkflowsDefault, Commands: []*cli.Command{ &workflows.CmdWorkflowsList, + &workflows.CmdWorkflowsView, + &workflows.CmdWorkflowsDispatch, + &workflows.CmdWorkflowsEnable, + &workflows.CmdWorkflowsDisable, }, } diff --git a/cmd/actions/workflows/disable.go b/cmd/actions/workflows/disable.go new file mode 100644 index 0000000..b707ca9 --- /dev/null +++ b/cmd/actions/workflows/disable.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsDisable represents a sub command to disable a workflow +var CmdWorkflowsDisable = cli.Command{ + Name: "disable", + Usage: "Disable a workflow", + Description: "Disable a workflow in the repository", + ArgsUsage: "", + Action: runWorkflowsDisable, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "confirm disable without prompting", + }, + }, flags.AllDefaultFlags...), +} + +func runWorkflowsDisable(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + + if !cmd.Bool("confirm") { + fmt.Printf("Are you sure you want to disable workflow %s? [y/N] ", workflowID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Disable canceled.") + return nil + } + } + + _, err = client.DisableRepoActionWorkflow(c.Owner, c.Repo, workflowID) + if err != nil { + return fmt.Errorf("failed to disable workflow: %w", err) + } + + fmt.Printf("Workflow %s disabled successfully\n", workflowID) + return nil +} diff --git a/cmd/actions/workflows/dispatch.go b/cmd/actions/workflows/dispatch.go new file mode 100644 index 0000000..2d24171 --- /dev/null +++ b/cmd/actions/workflows/dispatch.go @@ -0,0 +1,174 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + "strings" + "time" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsDispatch represents a sub command to dispatch a workflow +var CmdWorkflowsDispatch = cli.Command{ + Name: "dispatch", + Aliases: []string{"trigger", "run"}, + Usage: "Dispatch a workflow run", + Description: "Trigger a workflow_dispatch event for a workflow", + ArgsUsage: "", + Action: runWorkflowsDispatch, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "ref", + Aliases: []string{"r"}, + Usage: "branch or tag to dispatch on (default: current branch)", + }, + &cli.StringSliceFlag{ + Name: "input", + Aliases: []string{"i"}, + Usage: "workflow input in key=value format (can be specified multiple times)", + }, + &cli.BoolFlag{ + Name: "follow", + Aliases: []string{"f"}, + Usage: "follow log output after dispatching", + }, + }, flags.AllDefaultFlags...), +} + +func runWorkflowsDispatch(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + + ref := cmd.String("ref") + if ref == "" { + if c.LocalRepo != nil { + branchName, _, localErr := c.LocalRepo.TeaGetCurrentBranchNameAndSHA() + if localErr == nil && branchName != "" { + ref = branchName + } + } + if ref == "" { + return fmt.Errorf("--ref is required (no local branch detected)") + } + } + + inputs := make(map[string]string) + for _, input := range cmd.StringSlice("input") { + key, value, ok := strings.Cut(input, "=") + if !ok { + return fmt.Errorf("invalid input format %q, expected key=value", input) + } + inputs[key] = value + } + + opt := gitea.CreateActionWorkflowDispatchOption{ + Ref: ref, + Inputs: inputs, + } + + details, _, err := client.DispatchRepoActionWorkflow(c.Owner, c.Repo, workflowID, opt, true) + if err != nil { + return fmt.Errorf("failed to dispatch workflow: %w", err) + } + + print.ActionWorkflowDispatchResult(details) + + if cmd.Bool("follow") && details != nil && details.WorkflowRunID > 0 { + return followDispatchedRun(client, c, details.WorkflowRunID) + } + + return nil +} + +const ( + followPollInterval = 2 * time.Second + followMaxDuration = 30 * time.Minute +) + +// followDispatchedRun waits for the dispatched run to start, then follows its logs +func followDispatchedRun(client *gitea.Client, c *context.TeaContext, runID int64) error { + fmt.Printf("\nWaiting for run %d to start...\n", runID) + + var jobs *gitea.ActionWorkflowJobsResponse + for range 30 { + time.Sleep(followPollInterval) + + var err error + jobs, _, err = client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{}) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + if len(jobs.Jobs) > 0 { + break + } + } + + if jobs == nil || len(jobs.Jobs) == 0 { + return fmt.Errorf("timed out waiting for jobs to appear") + } + + jobID := jobs.Jobs[0].ID + jobName := jobs.Jobs[0].Name + fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID) + fmt.Println("---") + + deadline := time.Now().Add(followMaxDuration) + var lastLogLength int + for time.Now().Before(deadline) { + job, _, err := client.GetRepoActionJob(c.Owner, c.Repo, jobID) + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + + isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending" + + logs, _, logErr := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID) + if logErr != nil && isRunning { + time.Sleep(followPollInterval) + continue + } + + if logErr == nil && len(logs) > lastLogLength { + fmt.Print(string(logs[lastLogLength:])) + lastLogLength = len(logs) + } + + if !isRunning { + if logErr != nil { + fmt.Printf("\n---\nJob completed with status: %s (failed to fetch final logs: %v)\n", job.Status, logErr) + } else { + fmt.Printf("\n---\nJob completed with status: %s\n", job.Status) + } + break + } + + time.Sleep(followPollInterval) + } + + if time.Now().After(deadline) { + return fmt.Errorf("timed out after %s following logs", followMaxDuration) + } + + return nil +} diff --git a/cmd/actions/workflows/enable.go b/cmd/actions/workflows/enable.go new file mode 100644 index 0000000..7ca3af1 --- /dev/null +++ b/cmd/actions/workflows/enable.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsEnable represents a sub command to enable a workflow +var CmdWorkflowsEnable = cli.Command{ + Name: "enable", + Usage: "Enable a workflow", + Description: "Enable a disabled workflow in the repository", + ArgsUsage: "", + Action: runWorkflowsEnable, + Flags: flags.AllDefaultFlags, +} + +func runWorkflowsEnable(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + _, err = client.EnableRepoActionWorkflow(c.Owner, c.Repo, workflowID) + if err != nil { + return fmt.Errorf("failed to enable workflow: %w", err) + } + + fmt.Printf("Workflow %s enabled successfully\n", workflowID) + return nil +} diff --git a/cmd/actions/workflows/list.go b/cmd/actions/workflows/list.go index 4cdf10c..a661cbc 100644 --- a/cmd/actions/workflows/list.go +++ b/cmd/actions/workflows/list.go @@ -6,8 +6,6 @@ package workflows import ( stdctx "context" "fmt" - "path/filepath" - "strings" "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/context" @@ -22,15 +20,12 @@ var CmdWorkflowsList = cli.Command{ Name: "list", Aliases: []string{"ls"}, Usage: "List repository workflows", - Description: "List workflow files in the repository with active/inactive status", + Description: "List workflows in the repository with their status", Action: RunWorkflowsList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), + Flags: flags.AllDefaultFlags, } -// RunWorkflowsList lists workflow files in the repository +// RunWorkflowsList lists workflows in the repository using the workflow API func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { c, err := context.InitCommand(cmd) if err != nil { @@ -41,51 +36,15 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error { } client := c.Login.Client() - // Try to list workflow files from .gitea/workflows directory - var workflows []*gitea.ContentsResponse - - // Try .gitea/workflows first, then .github/workflows - workflowDir := ".gitea/workflows" - contents, _, err := client.ListContents(c.Owner, c.Repo, "", workflowDir) + resp, _, err := client.ListRepoActionWorkflows(c.Owner, c.Repo) if err != nil { - workflowDir = ".github/workflows" - contents, _, err = client.ListContents(c.Owner, c.Repo, "", workflowDir) - if err != nil { - fmt.Printf("No workflow files found\n") - return nil - } + return fmt.Errorf("failed to list workflows: %w", err) } - // Filter for workflow files (.yml and .yaml) - for _, content := range contents { - if content.Type == "file" { - ext := strings.ToLower(filepath.Ext(content.Name)) - if ext == ".yml" || ext == ".yaml" { - content.Path = workflowDir + "/" + content.Name - workflows = append(workflows, content) - } - } + var workflows []*gitea.ActionWorkflow + if resp != nil { + workflows = resp.Workflows } - if len(workflows) == 0 { - fmt.Printf("No workflow files found\n") - return nil - } - - // Check which workflows have runs to determine active status - workflowStatus := make(map[string]bool) - - // Get recent runs to check activity - runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err == nil && runs != nil { - for _, run := range runs.WorkflowRuns { - // Extract workflow file name from path - workflowFile := filepath.Base(run.Path) - workflowStatus[workflowFile] = true - } - } - - return print.WorkflowsList(workflows, workflowStatus, c.Output) + return print.ActionWorkflowsList(workflows, c.Output) } diff --git a/cmd/actions/workflows/view.go b/cmd/actions/workflows/view.go new file mode 100644 index 0000000..d05901f --- /dev/null +++ b/cmd/actions/workflows/view.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package workflows + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdWorkflowsView represents a sub command to view workflow details +var CmdWorkflowsView = cli.Command{ + Name: "view", + Aliases: []string{"show", "get"}, + Usage: "View workflow details", + Description: "View details of a specific workflow", + ArgsUsage: "", + Action: runWorkflowsView, + Flags: flags.AllDefaultFlags, +} + +func runWorkflowsView(ctx stdctx.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("workflow ID is required") + } + + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + workflowID := cmd.Args().First() + wf, _, err := client.GetRepoActionWorkflow(c.Owner, c.Repo, workflowID) + if err != nil { + return fmt.Errorf("failed to get workflow: %w", err) + } + + print.ActionWorkflowDetails(wf) + return nil +} diff --git a/docs/CLI.md b/docs/CLI.md index bbfe584..e9e8621 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1573,13 +1573,65 @@ Manage repository workflows List repository workflows -**--limit, --lm**="": specify limit of items per page (default: 30) +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### view, show, get + +View workflow details **--login, -l**="": Use a different Gitea Login. Optional **--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) -**--page, -p**="": specify page (default: 1) +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### dispatch, trigger, run + +Dispatch a workflow run + +**--follow, -f**: follow log output after dispatching + +**--input, -i**="": workflow input in key=value format (can be specified multiple times) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--ref, -r**="": branch or tag to dispatch on (default: current branch) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### enable + +Enable a workflow + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + +#### disable + +Disable a workflow + +**--confirm, -y**: confirm disable without prompting + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) **--remote, -R**="": Discover Gitea login from remote. Optional diff --git a/docs/example-workflows.md b/docs/example-workflows.md index 018b692..c64fd59 100644 --- a/docs/example-workflows.md +++ b/docs/example-workflows.md @@ -1,8 +1,93 @@ # Gitea actions workflows +## Workflow management with tea + +### List workflows + +```bash +# List all workflows in the repository +tea actions workflows list +``` + +### View workflow details + +```bash +# View details of a specific workflow by ID or filename +tea actions workflows view deploy.yml +``` + +### Dispatch (trigger) a workflow + +```bash +# Dispatch a workflow on the current branch +tea actions workflows dispatch deploy.yml + +# Dispatch on a specific branch +tea actions workflows dispatch deploy.yml --ref main + +# Dispatch with workflow inputs +tea actions workflows dispatch deploy.yml --ref main --input env=staging --input version=1.2.3 + +# Dispatch and follow log output +tea actions workflows dispatch ci.yml --ref feature/my-pr --follow +``` + +### Enable / disable workflows + +```bash +# Disable a workflow +tea actions workflows disable deploy.yml --confirm + +# Enable a workflow +tea actions workflows enable deploy.yml +``` + +## Example: Re-trigger CI from an AI-driven PR flow + +Use `tea actions workflows dispatch` to re-run a specific workflow after +pushing changes in an automated PR workflow: + +```bash +# Push changes to a feature branch, then re-trigger CI +git push origin feature/auto-fix +tea actions workflows dispatch check-and-test --ref feature/auto-fix --follow +``` + +## Example: Dispatch a workflow with `workflow_dispatch` trigger + +```yaml +name: deploy + +on: + workflow_dispatch: + inputs: + env: + description: "Target environment" + required: true + default: "staging" + version: + description: "Version to deploy" + required: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Deploy + run: | + echo "Deploying version ${{ gitea.event.inputs.version }} to ${{ gitea.event.inputs.env }}" +``` + +Trigger this workflow from the CLI: + +```bash +tea actions workflows dispatch deploy.yml --ref main --input env=production --input version=2.0.0 +``` + ## Merge Pull request on approval -``` Yaml +```yaml --- name: Pull request on: diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go index ff9398f..aaf3799 100644 --- a/modules/print/actions_runs.go +++ b/modules/print/actions_runs.go @@ -154,27 +154,23 @@ func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) erro return t.print(output) } -// WorkflowsList prints a list of workflow files with active status -func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) error { +// ActionWorkflowsList prints a list of workflows from the workflow API +func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error { t := table{ headers: []string{ - "Active", + "ID", "Name", "Path", + "State", }, } - machineReadable := isMachineReadable(output) - - for _, workflow := range workflows { - // Check if this workflow file is active (has runs) - isActive := activeStatus[workflow.Name] - activeIndicator := formatBoolean(isActive, !machineReadable) - + for _, wf := range workflows { t.addRow( - activeIndicator, - workflow.Name, - workflow.Path, + wf.ID, + wf.Name, + wf.Path, + wf.State, ) } @@ -186,3 +182,34 @@ func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string] t.sort(1, true) // Sort by name column return t.print(output) } + +// ActionWorkflowDetails prints detailed information about a workflow +func ActionWorkflowDetails(wf *gitea.ActionWorkflow) { + fmt.Printf("ID: %s\n", wf.ID) + fmt.Printf("Name: %s\n", wf.Name) + fmt.Printf("Path: %s\n", wf.Path) + fmt.Printf("State: %s\n", wf.State) + if wf.HTMLURL != "" { + fmt.Printf("URL: %s\n", wf.HTMLURL) + } + if wf.BadgeURL != "" { + fmt.Printf("Badge: %s\n", wf.BadgeURL) + } + if !wf.CreatedAt.IsZero() { + fmt.Printf("Created: %s\n", FormatTime(wf.CreatedAt, false)) + } + if !wf.UpdatedAt.IsZero() { + fmt.Printf("Updated: %s\n", FormatTime(wf.UpdatedAt, false)) + } +} + +// ActionWorkflowDispatchResult prints the result of a workflow dispatch +func ActionWorkflowDispatchResult(details *gitea.RunDetails) { + fmt.Printf("Workflow dispatched successfully\n") + if details != nil { + fmt.Printf("Run ID: %d\n", details.WorkflowRunID) + if details.HTMLURL != "" { + fmt.Printf("URL: %s\n", details.HTMLURL) + } + } +} diff --git a/modules/print/actions_runs_test.go b/modules/print/actions_runs_test.go index 8cf5693..5a3a263 100644 --- a/modules/print/actions_runs_test.go +++ b/modules/print/actions_runs_test.go @@ -123,6 +123,87 @@ func TestActionWorkflowJobsListWithData(t *testing.T) { require.NoError(t, ActionWorkflowJobsList(jobs, "")) } +func TestActionWorkflowsListEmpty(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowsList panicked with empty list: %v", r) + } + }() + + require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, "")) +} + +func TestActionWorkflowsListWithData(t *testing.T) { + workflows := []*gitea.ActionWorkflow{ + { + ID: "1", + Name: "CI", + Path: ".gitea/workflows/ci.yml", + State: "active", + }, + { + ID: "2", + Name: "Deploy", + Path: ".gitea/workflows/deploy.yml", + State: "disabled_manually", + }, + } + + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowsList panicked with data: %v", r) + } + }() + + require.NoError(t, ActionWorkflowsList(workflows, "")) +} + +func TestActionWorkflowDetails(t *testing.T) { + wf := &gitea.ActionWorkflow{ + ID: "1", + Name: "CI Pipeline", + Path: ".gitea/workflows/ci.yml", + State: "active", + HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml", + BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg", + CreatedAt: time.Now().Add(-24 * time.Hour), + UpdatedAt: time.Now().Add(-1 * time.Hour), + } + + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowDetails panicked: %v", r) + } + }() + + ActionWorkflowDetails(wf) +} + +func TestActionWorkflowDispatchResult(t *testing.T) { + details := &gitea.RunDetails{ + WorkflowRunID: 42, + HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42", + } + + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowDispatchResult panicked: %v", r) + } + }() + + ActionWorkflowDispatchResult(details) +} + +func TestActionWorkflowDispatchResultNil(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r) + } + }() + + ActionWorkflowDispatchResult(nil) +} + func TestFormatDurationMinutes(t *testing.T) { now := time.Now() From 84ecd16f9c72c261dfd10afe5fdf9a53865536bc Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 10 Apr 2026 01:40:40 +0000 Subject: [PATCH 66/83] fix(deps): update Go dependencies to latest versions (#955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Upgrade all Go module dependencies to their latest versions - Includes updates to charm.land, golang.org/x, goldmark, go-crypto, and other indirect dependencies - Project builds cleanly with all updates ## Test plan - [x] `go build ./...` passes - [x] CI pipeline passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.com/gitea/tea/pulls/955 Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- go.mod | 37 +++++++++++----------- go.sum | 97 +++++++++++++++++++++++----------------------------------- 2 files changed, 56 insertions(+), 78 deletions(-) diff --git a/go.mod b/go.mod index a96447c..0ca85f6 100644 --- a/go.mod +++ b/go.mod @@ -20,21 +20,20 @@ require ( github.com/stretchr/testify v1.11.1 github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.8.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.43.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - al.essio.dev/pkg/shellescape v1.6.0 // indirect - charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbles/v2 v2.1.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect dario.cat/mergo v1.0.2 // indirect github.com/42wim/httpsig v1.2.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -42,10 +41,10 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -61,28 +60,28 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/fatih/color v1.19.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.8.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/hashicorp/go-version v1.8.0 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect - github.com/olekukonko/ll v0.1.7 // indirect + github.com/olekukonko/ll v0.1.8 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -91,13 +90,13 @@ require ( github.com/skeema/knownhosts v1.3.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.16 // indirect + github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - github.com/zalando/go-keyring v0.2.6 // indirect - golang.org/x/net v0.52.0 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index ccddba4..cdb75ea 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= -al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= -charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= -charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= @@ -12,16 +10,12 @@ charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= -code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= -code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA= gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= -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/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -29,8 +23,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6 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.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= -github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -59,8 +53,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo= -github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg= +github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM= +github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -71,8 +65,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= -github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa h1:bmNUSF4m+fwrzZAOhluMSZxdM4bk+SWN0Ni2DimCZm8= -github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 h1:aEppolah2k9c0LzKX2fk5ryuyQ0Lq8kCOjkvMw1b8o4= +github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -112,12 +106,10 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog= github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= -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/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-authgate/sdk-go v0.2.0 h1:w22f+sAg/YMqnLOcS/4SAuMZXTbPurzkSQBsjb1hcbw= -github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho= github.com/go-authgate/sdk-go v0.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624/jw= github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= @@ -128,26 +120,20 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= 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.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= -github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= -github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= -github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= -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/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= -github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -163,15 +149,15 @@ 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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -184,10 +170,8 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= -github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= -github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= -github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= -github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -225,8 +209,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= -github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= -github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -234,31 +216,31 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= -github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= -github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= -github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -273,24 +255,21 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 9e0a6203ae43c30c506681af3e4cfbe4aebf0554 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 10 Apr 2026 17:29:15 +0000 Subject: [PATCH 67/83] feat(pulls): add ci status field to pull request list (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add `"ci"` as a new selectable field for `tea pr list --fields`, allowing users to see CI status across multiple PRs at a glance - Fetch CI status via `GetCombinedStatus` API **only when the `ci` field is explicitly requested** via `--fields`, avoiding unnecessary API calls in default usage - Improve CI status display in both detail and list views: - **Detail view** (`tea pr `): show each CI check with symbol, context name, description, and clickable link to CI run - **List view** (`tea pr list --fields ci`): show symbol + context name per CI check (e.g., `✓ lint, ⏳ build, ❌ test`) - **Machine-readable output**: return raw state string (e.g., `success`, `pending`) - Replace pending CI symbol from `⭮` to `⏳` for better readability - Extract `formatCIStatus` helper and reuse it in `PullDetails` to reduce code duplication - Add comprehensive tests for CI status formatting and PR list integration ## Detail View Example ``` - CI: - ✓ [**lint**](https://ci.example.com/lint): Lint passed - ⏳ [**build**](https://ci.example.com/build): Build is running - ❌ [**test**](https://ci.example.com/test): 3 tests failed ``` ## List View Example ``` INDEX TITLE STATE CI 123 Fix bug open ✓ lint, ⏳ build, ❌ test ``` ## Usage ```bash # Show CI status column in list tea pr list --fields index,title,state,ci # Default output is unchanged (no CI column, no extra API calls) tea pr list ``` ## Files Changed - `cmd/pulls/list.go` — conditionally fetch CI status per PR when `ci` field is selected - `modules/print/pull.go` — add `ci` field, `formatCIStatus` helper, improve detail/list CI display - `modules/print/pull_test.go` — comprehensive tests for CI status formatting ## Test plan - [x] `go build ./...` passes - [x] `go test ./...` passes (11 new tests) - [x] `tea pr list` — default output unchanged, no extra API calls - [x] `tea pr list --fields index,title,state,ci` — CI column with context names - [x] `tea pr ` — CI section shows each check with name, description, and link - [x] `tea pr list --fields ci -o csv` — machine-readable output shows raw state strings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.com/gitea/tea/pulls/956 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/pulls/list.go | 23 ++++- docs/CLI.md | 4 +- modules/print/pull.go | 52 +++++++--- modules/print/pull_test.go | 189 +++++++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 17 deletions(-) create mode 100644 modules/print/pull_test.go diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go index d87e3af..3eaf9d6 100644 --- a/cmd/pulls/list.go +++ b/cmd/pulls/list.go @@ -5,6 +5,8 @@ package pulls import ( stdctx "context" + "fmt" + "slices" "code.gitea.io/sdk/gitea" "code.gitea.io/tea/cmd/flags" @@ -43,7 +45,8 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ + client := ctx.Login.Client() + prs, _, err := client.ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ ListOptions: flags.GetListOptions(cmd), State: state, }) @@ -56,5 +59,21 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - return print.PullsList(prs, ctx.Output, fields) + var ciStatuses map[int64]*gitea.CombinedStatus + if slices.Contains(fields, "ci") { + ciStatuses = map[int64]*gitea.CombinedStatus{} + for _, pr := range prs { + if pr.Head == nil || pr.Head.Sha == "" { + continue + } + ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha) + if err != nil { + fmt.Printf("error fetching CI status for PR #%d: %v\n", pr.Index, err) + continue + } + ciStatuses[pr.Index] = ci + } + } + + return print.PullsList(prs, ctx.Output, fields, ciStatuses) } diff --git a/docs/CLI.md b/docs/CLI.md index e9e8621..05f43e5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -274,7 +274,7 @@ Manage and checkout pull requests **--comments**: Whether to display comments (will prompt if not provided & run interactively) **--fields, -f**="": Comma-separated list of fields to print. Available values: - index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments + index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci (default: "index,title,state,author,milestone,updated,labels") **--limit, --lm**="": specify limit of items per page (default: 30) @@ -296,7 +296,7 @@ Manage and checkout pull requests List pull requests of the repository **--fields, -f**="": Comma-separated list of fields to print. Available values: - index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments + index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci (default: "index,title,state,author,milestone,updated,labels") **--limit, --lm**="": specify limit of items per page (default: 30) diff --git a/modules/print/pull.go b/modules/print/pull.go index c4e537e..09d6cd6 100644 --- a/modules/print/pull.go +++ b/modules/print/pull.go @@ -12,7 +12,7 @@ import ( var ciStatusSymbols = map[gitea.StatusState]string{ gitea.StatusSuccess: "✓ ", - gitea.StatusPending: "⭮ ", + gitea.StatusPending: "⏳ ", gitea.StatusWarning: "⚠ ", gitea.StatusError: "✘ ", gitea.StatusFailure: "❌ ", @@ -42,16 +42,19 @@ func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *g out += formatReviews(pr, reviews) - if ciStatus != nil { - var summary, errors string + if ciStatus != nil && len(ciStatus.Statuses) != 0 { + out += "- CI:\n" for _, s := range ciStatus.Statuses { - summary += ciStatusSymbols[s.State] - if s.State != gitea.StatusSuccess { - errors += fmt.Sprintf(" - [**%s**:\t%s](%s)\n", s.Context, s.Description, s.TargetURL) + symbol := ciStatusSymbols[s.State] + if s.TargetURL != "" { + out += fmt.Sprintf(" - %s[**%s**](%s)", symbol, s.Context, s.TargetURL) + } else { + out += fmt.Sprintf(" - %s**%s**", symbol, s.Context) } - } - if len(ciStatus.Statuses) != 0 { - out += fmt.Sprintf("- CI: %s\n%s", summary, errors) + if s.Description != "" { + out += fmt.Sprintf(": %s", s.Description) + } + out += "\n" } } @@ -89,6 +92,20 @@ func formatPRState(pr *gitea.PullRequest) string { return string(pr.State) } +func formatCIStatus(ci *gitea.CombinedStatus, machineReadable bool) string { + if ci == nil || len(ci.Statuses) == 0 { + return "" + } + if machineReadable { + return string(ci.State) + } + items := make([]string, 0, len(ci.Statuses)) + for _, s := range ci.Statuses { + items = append(items, fmt.Sprintf("%s%s", ciStatusSymbols[s.State], s.Context)) + } + return strings.Join(items, ", ") +} + func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { result := "" if len(reviews) == 0 { @@ -138,8 +155,8 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { } // PullsList prints a listing of pulls -func PullsList(prs []*gitea.PullRequest, output string, fields []string) error { - return printPulls(prs, output, fields) +func PullsList(prs []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error { + return printPulls(prs, output, fields, ciStatuses) } // PullFields are all available fields to print with PullsList() @@ -168,9 +185,10 @@ var PullFields = []string{ "milestone", "labels", "comments", + "ci", } -func printPulls(pulls []*gitea.PullRequest, output string, fields []string) error { +func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error { labelMap := map[int64]string{} printables := make([]printable, len(pulls)) machineReadable := isMachineReadable(output) @@ -183,7 +201,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) erro } } // store items with printable interface - printables[i] = &printablePull{x, &labelMap} + printables[i] = &printablePull{x, &labelMap, &ciStatuses} } t := tableFromItems(fields, printables, machineReadable) @@ -193,6 +211,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) erro type printablePull struct { *gitea.PullRequest formattedLabels *map[int64]string + ciStatuses *map[int64]*gitea.CombinedStatus } func (x printablePull) FormatField(field string, machineReadable bool) string { @@ -252,6 +271,13 @@ func (x printablePull) FormatField(field string, machineReadable bool) string { return x.DiffURL case "patch": return x.PatchURL + case "ci": + if x.ciStatuses != nil { + if ci, ok := (*x.ciStatuses)[x.Index]; ok { + return formatCIStatus(ci, machineReadable) + } + } + return "" } return "" } diff --git a/modules/print/pull_test.go b/modules/print/pull_test.go new file mode 100644 index 0000000..7ce2147 --- /dev/null +++ b/modules/print/pull_test.go @@ -0,0 +1,189 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "bytes" + "encoding/json" + "slices" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestPR(index int64, title string) *gitea.PullRequest { + now := time.Now() + return &gitea.PullRequest{ + Index: index, + Title: title, + State: gitea.StateOpen, + Poster: &gitea.User{UserName: "testuser"}, + Head: &gitea.PRBranchInfo{Ref: "branch", Name: "branch"}, + Base: &gitea.PRBranchInfo{Ref: "main", Name: "main"}, + Created: &now, + Updated: &now, + } +} + +func TestFormatCIStatusNil(t *testing.T) { + assert.Equal(t, "", formatCIStatus(nil, false)) + assert.Equal(t, "", formatCIStatus(nil, true)) +} + +func TestFormatCIStatusEmpty(t *testing.T) { + ci := &gitea.CombinedStatus{Statuses: []*gitea.Status{}} + assert.Equal(t, "", formatCIStatus(ci, false)) + assert.Equal(t, "", formatCIStatus(ci, true)) +} + +func TestFormatCIStatusMachineReadable(t *testing.T) { + ci := &gitea.CombinedStatus{ + State: gitea.StatusSuccess, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "lint"}, + }, + } + assert.Equal(t, "success", formatCIStatus(ci, true)) + + ci.State = gitea.StatusPending + ci.Statuses = []*gitea.Status{ + {State: gitea.StatusPending, Context: "build"}, + } + assert.Equal(t, "pending", formatCIStatus(ci, true)) +} + +func TestFormatCIStatusSingle(t *testing.T) { + ci := &gitea.CombinedStatus{ + State: gitea.StatusSuccess, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "lint"}, + }, + } + assert.Equal(t, "✓ lint", formatCIStatus(ci, false)) +} + +func TestFormatCIStatusMultiple(t *testing.T) { + ci := &gitea.CombinedStatus{ + State: gitea.StatusFailure, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "lint"}, + {State: gitea.StatusPending, Context: "build"}, + {State: gitea.StatusFailure, Context: "test"}, + }, + } + assert.Equal(t, "✓ lint, ⏳ build, ❌ test", formatCIStatus(ci, false)) +} + +func TestFormatCIStatusAllStates(t *testing.T) { + tests := []struct { + state gitea.StatusState + context string + expected string + }{ + {gitea.StatusSuccess, "s", "✓ s"}, + {gitea.StatusPending, "p", "⏳ p"}, + {gitea.StatusWarning, "w", "⚠ w"}, + {gitea.StatusError, "e", "✘ e"}, + {gitea.StatusFailure, "f", "❌ f"}, + } + for _, tt := range tests { + ci := &gitea.CombinedStatus{ + State: tt.state, + Statuses: []*gitea.Status{{State: tt.state, Context: tt.context}}, + } + assert.Equal(t, tt.expected, formatCIStatus(ci, false), "state: %s", tt.state) + } +} + +func TestPullsListWithCIField(t *testing.T) { + prs := []*gitea.PullRequest{ + newTestPR(1, "feat: add feature"), + newTestPR(2, "fix: bug fix"), + } + + ciStatuses := map[int64]*gitea.CombinedStatus{ + 1: { + State: gitea.StatusSuccess, + Statuses: []*gitea.Status{ + {State: gitea.StatusSuccess, Context: "ci/build"}, + }, + }, + 2: { + State: gitea.StatusFailure, + Statuses: []*gitea.Status{ + {State: gitea.StatusFailure, Context: "ci/test"}, + }, + }, + } + + buf := &bytes.Buffer{} + tbl := tableFromItems( + []string{"index", "ci"}, + []printable{ + &printablePull{prs[0], &map[int64]string{}, &ciStatuses}, + &printablePull{prs[1], &map[int64]string{}, &ciStatuses}, + }, + true, + ) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 2) + assert.Equal(t, "1", result[0]["index"]) + assert.Equal(t, "success", result[0]["ci"]) + assert.Equal(t, "2", result[1]["index"]) + assert.Equal(t, "failure", result[1]["ci"]) +} + +func TestPullsListCIFieldEmpty(t *testing.T) { + prs := []*gitea.PullRequest{newTestPR(1, "no ci")} + ciStatuses := map[int64]*gitea.CombinedStatus{} + + buf := &bytes.Buffer{} + tbl := tableFromItems( + []string{"index", "ci"}, + []printable{ + &printablePull{prs[0], &map[int64]string{}, &ciStatuses}, + }, + true, + ) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + assert.Equal(t, "", result[0]["ci"]) +} + +func TestPullsListNilCIStatusesWithCIField(t *testing.T) { + prs := []*gitea.PullRequest{newTestPR(1, "nil ci")} + + buf := &bytes.Buffer{} + tbl := tableFromItems( + []string{"index", "ci"}, + []printable{ + &printablePull{prs[0], &map[int64]string{}, nil}, + }, + true, + ) + require.NoError(t, tbl.fprint(buf, "json")) + + var result []map[string]string + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + assert.Equal(t, "", result[0]["ci"]) +} + +func TestPullsListNoCIFieldNoPanic(t *testing.T) { + prs := []*gitea.PullRequest{newTestPR(1, "test")} + require.NoError(t, PullsList(prs, "", []string{"index", "title"}, nil)) +} + +func TestPullFieldsContainsCI(t *testing.T) { + assert.True(t, slices.Contains(PullFields, "ci"), "PullFields should contain 'ci'") +} From 63bc90ea521e54a8c9636a1216a2931ecad66547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Wed, 15 Apr 2026 17:27:47 +0000 Subject: [PATCH 68/83] feat(branches): add rename subcommand (#939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 'branches rename' command to rename a branch in a repository. This wraps the Gitea API endpoint PATCH /repos/{owner}/{repo}/branches/{branch}. Usage: tea branches rename Example: tea branches rename -r owner/repo main factory This resolves issue #938. Reviewed-on: https://gitea.com/gitea/tea/pulls/939 Reviewed-by: Lunny Xiao Co-authored-by: Matěj Cepl Co-committed-by: Matěj Cepl --- cmd/branches.go | 1 + cmd/branches/rename.go | 78 +++++++++++++++++++++++++++++++++++++ cmd/branches/rename_test.go | 46 ++++++++++++++++++++++ docs/CLI.md | 20 ++++++++++ 4 files changed, 145 insertions(+) create mode 100644 cmd/branches/rename.go create mode 100644 cmd/branches/rename_test.go diff --git a/cmd/branches.go b/cmd/branches.go index 557688c..625b412 100644 --- a/cmd/branches.go +++ b/cmd/branches.go @@ -24,6 +24,7 @@ var CmdBranches = cli.Command{ &branches.CmdBranchesList, &branches.CmdBranchesProtect, &branches.CmdBranchesUnprotect, + &branches.CmdBranchesRename, }, Flags: append([]cli.Flag{ &cli.BoolFlag{ diff --git a/cmd/branches/rename.go b/cmd/branches/rename.go new file mode 100644 index 0000000..bf99cec --- /dev/null +++ b/cmd/branches/rename.go @@ -0,0 +1,78 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package branches + +import ( + stdctx "context" + "fmt" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "code.gitea.io/sdk/gitea" + "github.com/urfave/cli/v3" +) + +// CmdBranchesRenameFlags Flags for command rename +var CmdBranchesRenameFlags = append([]cli.Flag{ + branchFieldsFlag, + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, +}, flags.AllDefaultFlags...) + +// CmdBranchesRename represents a sub command of branches to rename a branch +var CmdBranchesRename = cli.Command{ + Name: "rename", + Aliases: []string{"rn"}, + Usage: "Rename a branch", + Description: `Rename a branch in a repository`, + ArgsUsage: " ", + Action: RunBranchesRename, + Flags: CmdBranchesRenameFlags, +} + +// RunBranchesRename function to rename a branch +func RunBranchesRename(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + if err := ValidateRenameArgs(ctx.Args().Slice()); err != nil { + return err + } + + oldBranchName := ctx.Args().Get(0) + newBranchName := ctx.Args().Get(1) + + owner := ctx.Owner + if ctx.IsSet("owner") { + owner = ctx.String("owner") + } + + successful, _, err := ctx.Login.Client().RenameRepoBranch(owner, ctx.Repo, oldBranchName, gitea.RenameRepoBranchOption{ + Name: newBranchName, + }) + if err != nil { + return fmt.Errorf("failed to rename branch: %w", err) + } + if !successful { + return fmt.Errorf("failed to rename branch") + } + + fmt.Printf("Successfully renamed branch '%s' to '%s'\n", oldBranchName, newBranchName) + + return nil +} + +// ValidateRenameArgs validates arguments for the rename command +func ValidateRenameArgs(args []string) error { + if len(args) != 2 { + return fmt.Errorf("must specify exactly two arguments: ") + } + return nil +} diff --git a/cmd/branches/rename_test.go b/cmd/branches/rename_test.go new file mode 100644 index 0000000..cf3efd7 --- /dev/null +++ b/cmd/branches/rename_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package branches + +import ( + "testing" +) + +func TestBranchesRenameArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr bool + }{ + { + name: "valid args", + args: []string{"main", "develop"}, + wantErr: false, + }, + { + name: "missing both args", + args: []string{}, + wantErr: true, + }, + { + name: "missing new branch name", + args: []string{"main"}, + wantErr: true, + }, + { + name: "too many args", + args: []string{"main", "develop", "extra"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRenameArgs(tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateRenameArgs() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/docs/CLI.md b/docs/CLI.md index 05f43e5..5a79d0e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1381,6 +1381,26 @@ Unprotect branches **--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional +### rename, rn + +Rename a branch + +**--fields, -f**="": Comma-separated list of fields to print. Available values: + name,protected,user-can-merge,user-can-push,protection + (default: "name,protected,user-can-merge,user-can-push") + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +**--remote, -R**="": Discover Gitea login from remote. Optional + +**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional + ## actions, action Manage repository actions From 3c1c9b29048f66b44b3bb58bb0a88bd8023003e5 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 20 Apr 2026 01:11:09 +0000 Subject: [PATCH 69/83] chore(deps): update docker.gitea.com/gitea docker tag to v1.26.0 (#962) Reviewed-on: https://gitea.com/gitea/tea/pulls/962 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 75c46cb..5e7f436 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -39,7 +39,7 @@ jobs: make unit-test-coverage services: gitea: - image: docker.gitea.com/gitea:1.25.5 + image: docker.gitea.com/gitea:1.26.0 cmd: - bash - -c From 20914a1375e51ffb8dd7167b41e54cac37a89f73 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 20 Apr 2026 01:11:50 +0000 Subject: [PATCH 70/83] fix(deps): update module github.com/go-git/go-git/v5 to v5.18.0 (#961) Reviewed-on: https://gitea.com/gitea/tea/pulls/961 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0ca85f6..671fc65 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 github.com/go-authgate/sdk-go v0.6.1 - github.com/go-git/go-git/v5 v5.17.2 + github.com/go-git/go-git/v5 v5.18.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 diff --git a/go.sum b/go.sum index cdb75ea..c41e02f 100644 --- a/go.sum +++ b/go.sum @@ -122,6 +122,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj 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.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= From d0b7ea09e8656307db5b6d9b1706a52310af0f3c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 20 Apr 2026 19:34:25 +0000 Subject: [PATCH 71/83] fix(deps): update module charm.land/lipgloss/v2 to v2.0.3 (#959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) | `v2.0.2` → `v2.0.3` | ![age](https://developer.mend.io/api/mc/badges/age/go/charm.land%2flipgloss%2fv2/v2.0.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/charm.land%2flipgloss%2fv2/v2.0.2/v2.0.3?slim=true) | --- ### Release Notes
charmbracelet/lipgloss (charm.land/lipgloss/v2) ### [`v2.0.3`](https://github.com/charmbracelet/lipgloss/releases/tag/v2.0.3) [Compare Source](https://github.com/charmbracelet/lipgloss/compare/v2.0.2...v2.0.3) #### Changelog ##### Fixed - [`472d718`](https://github.com/charmbracelet/lipgloss/commit/472d718e2314596549bee2c0c8ccf8beea5f25ae): fix: Avoid background color query hang ([#​636](https://github.com/charmbracelet/lipgloss/issues/636)) ([@​jedevc](https://github.com/jedevc)) ##### Docs - [`9e39a0a`](https://github.com/charmbracelet/lipgloss/commit/9e39a0ad4f4fc779d620f17783cee3494da6ae29): docs: fix README typo ([#​629](https://github.com/charmbracelet/lipgloss/issues/629)) ([@​Rohan5commit](https://github.com/Rohan5commit)) - [`cd93a9f`](https://github.com/charmbracelet/lipgloss/commit/cd93a9f5d2e3cb151da83150db29751d92585d23): docs: fix tree comment typo ([#​634](https://github.com/charmbracelet/lipgloss/issues/634)) ([@​Rohan5commit](https://github.com/Rohan5commit)) *** The Charm logo Thoughts? Questions? We love hearing from you. Feel free to reach out on [X](https://x.com/charmcli), [Discord](https://charm.land/discord), [Slack](https://charm.land/slack), [The Fediverse](https://mastodon.social/@​charmcli), [Bluesky](https://bsky.app/profile/charm.land).
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate). --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/959 Reviewed-by: Lunny Xiao Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 4 ++-- go.sum | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 671fc65..5016a4e 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26 require ( charm.land/glamour/v2 v2.0.0 charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.3 code.gitea.io/gitea-vet v0.2.3 code.gitea.io/sdk/gitea v0.24.1 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c @@ -42,7 +42,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260406091427-a791e22d5143 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect diff --git a/go.sum b/go.sum index c41e02f..c2a9676 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= @@ -57,6 +59,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9 github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= From 783ac7684a38a7b5be63e2587f245cf63e7f372a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Mon, 20 Apr 2026 19:39:42 +0000 Subject: [PATCH 72/83] fix(context): skip local repo detection for repo slugs (#960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat explicit --repo slugs as remote targets so commands do not probe the current worktree. This avoids SHA256 repository failures when local git autodetection is unnecessary. --------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/960 Reviewed-by: Lunny Xiao Co-authored-by: Matěj Cepl Co-committed-by: Matěj Cepl --- modules/context/context.go | 29 ++++++++++--------- modules/context/context_test.go | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/modules/context/context.go b/modules/context/context.go index 0fe55b3..7255c68 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -83,6 +83,8 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) { } if repoFlagPathExists { repoPath = repoFlag + } else { + c.RepoSlug = repoFlag } } @@ -90,12 +92,6 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) { remoteFlag = config.GetPreferences().FlagDefaults.Remote } - if repoPath == "" { - if repoPath, err = os.Getwd(); err != nil { - return nil, err - } - } - // Create env login before repo context detection so it participates in remote URL matching var extraLogins []config.Login envLogin := GetLoginByEnvVar() @@ -108,17 +104,20 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) { // try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir, // otherwise attempt PWD. if no repo is found, continue with default login - if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil { - if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { - // we can deal with that, commands needing the optional values use ctx.Ensure() - } else { - return nil, err + if c.RepoSlug == "" { + if repoPath == "" { + if repoPath, err = os.Getwd(); err != nil { + return nil, err + } } - } - if len(repoFlag) != 0 && !repoFlagPathExists { - // if repoFlag is not a valid path, use it to override repoSlug - c.RepoSlug = repoFlag + if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil { + if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { + // we can deal with that, commands needing the optional values use ctx.Ensure() + } else { + return nil, err + } + } } // If env vars are set, always use the env login (but repo slug was already diff --git a/modules/context/context_test.go b/modules/context/context_test.go index 9b73f88..2c8ea43 100644 --- a/modules/context/context_test.go +++ b/modules/context/context_test.go @@ -4,9 +4,14 @@ package context import ( + "os" + "os/exec" "testing" "code.gitea.io/tea/modules/config" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" ) func Test_MatchLogins(t *testing.T) { @@ -65,3 +70,47 @@ func Test_MatchLogins(t *testing.T) { }) } } + +func TestInitCommand_WithRepoSlugSkipsLocalRepoDetection(t *testing.T) { + tmpDir := t.TempDir() + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test-login", + URL: "https://gitea.example.com", + Token: "token", + User: "login-user", + Default: true, + }}, + }) + + cmd := exec.Command("git", "init", "--object-format=sha256", tmpDir) + cmd.Env = os.Environ() + require.NoError(t, cmd.Run()) + + oldWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + cliCmd := cli.Command{ + Name: "branches", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "login"}, + &cli.StringFlag{Name: "repo"}, + &cli.StringFlag{Name: "remote"}, + &cli.StringFlag{Name: "output"}, + }, + } + require.NoError(t, cliCmd.Set("repo", "owner/repo")) + + ctx, err := InitCommand(&cliCmd) + require.NoError(t, err) + require.Equal(t, "owner", ctx.Owner) + require.Equal(t, "repo", ctx.Repo) + require.Equal(t, "owner/repo", ctx.RepoSlug) + require.Nil(t, ctx.LocalRepo) + require.NotNil(t, ctx.Login) + require.Equal(t, "test-login", ctx.Login.Name) +} From a58c35c3e2c0f3af012953061c0e60bad82b7f5d Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 20 Apr 2026 19:50:28 +0000 Subject: [PATCH 73/83] fix(cmd): Update CmdRepos description and usage in repos.go (#946) Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/946 Reviewed-by: Lunny Xiao Co-authored-by: Nicolas Co-committed-by: Nicolas --- .gitignore | 2 ++ cmd/repos.go | 6 +++--- docs/CLI.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ee80700..1672762 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ dist/ .direnv/ result result-* + +.DS_Store \ No newline at end of file diff --git a/cmd/repos.go b/cmd/repos.go index 20930a6..7c30690 100644 --- a/cmd/repos.go +++ b/cmd/repos.go @@ -15,13 +15,13 @@ import ( "github.com/urfave/cli/v3" ) -// CmdRepos represents to login a gitea server. +// CmdRepos represents the command to manage repositories. var CmdRepos = cli.Command{ Name: "repos", Aliases: []string{"repo"}, Category: catEntities, - Usage: "Show repository details", - Description: "Show repository details", + Usage: "Manage repositories", + Description: "Manage repositories", ArgsUsage: "[/]", Action: runRepos, Commands: []*cli.Command{ diff --git a/docs/CLI.md b/docs/CLI.md index 5a79d0e..84da431 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1065,7 +1065,7 @@ Delete users Organizations ## repos, repo -Show repository details +Manage repositories **--fields, -f**="": Comma-separated list of fields to print. Available values: description,forks,id,name,owner,stars,ssh,updated,url,permission,type From 51034962320d487a60d5cf075da8da3bccf1c415 Mon Sep 17 00:00:00 2001 From: Alain Thiffault Date: Thu, 23 Apr 2026 17:06:42 +0000 Subject: [PATCH 74/83] fix(pagination): replace Page:-1 with explicit pagination loops (#967) ## Summary \`Page: -1\` in the Gitea SDK calls \`setDefaults()\` which sets both \`Page=0\` and \`PageSize=0\`, resulting in \`?page=0&limit=0\` being sent to the server. The server interprets \`limit=0\` as "use server default" (typically 30 items via \`DEFAULT_PAGING_NUM\`), not "return everything". Any resource beyond the first page of results was silently invisible. This affected 8 call sites, with the most user-visible impact being \`tea issues edit --add-labels\` and \`tea pulls edit --add-labels\` silently failing to apply labels on repositories with more than ~30 labels. ## Affected call sites | File | API call | User-visible impact | |---|---|---| | \`modules/task/labels.go\` | \`ListRepoLabels\` | \`issues/pulls edit --add-labels\` fails silently | | \`modules/interact/issue_create.go\` | \`ListRepoLabels\` | interactive label picker missing labels | | \`modules/task/pull_review_comment.go\` | \`ListPullReviews\` | review comments truncated | | \`modules/task/login_ssh.go\` | \`ListMyPublicKeys\` | SSH key auto-detection fails | | \`modules/task/login_create.go\` | \`ListAccessTokens\` | token name deduplication misses existing tokens | | \`cmd/pulls.go\` | \`ListPullReviews\` | PR detail view missing reviews | | \`cmd/releases/utils.go\` | \`ListReleases\` | tag lookup fails on repos with many releases | | \`cmd/attachments/delete.go\` | \`ListReleaseAttachments\` | attachment deletion fails when many attachments exist | ## Fix Each call site is replaced with an explicit pagination loop that follows \`resp.NextPage\` until all pages are exhausted. Reviewed-on: https://gitea.com/gitea/tea/pulls/967 Reviewed-by: Lunny Xiao Co-authored-by: Alain Thiffault Co-committed-by: Alain Thiffault --- cmd/attachments/delete.go | 18 ++++++++++++----- cmd/pulls.go | 19 +++++++++++++----- cmd/releases/utils.go | 30 +++++++++++++++++------------ modules/interact/issue_create.go | 30 +++++++++++++++++------------ modules/task/labels.go | 25 +++++++++++++++--------- modules/task/login_create.go | 18 ++++++++++++----- modules/task/login_ssh.go | 21 +++++++++++++++----- modules/task/pull_review_comment.go | 18 ++++++++++++----- 8 files changed, 121 insertions(+), 58 deletions(-) diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go index b6ebd63..b0182f0 100644 --- a/cmd/attachments/delete.go +++ b/cmd/attachments/delete.go @@ -61,11 +61,19 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { return err } - existing, _, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return err + var existing []*gitea.Attachment + for page := 1; ; { + page_attachments, resp, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return err + } + existing = append(existing, page_attachments...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } for _, name := range ctx.Args().Slice()[1:] { diff --git a/cmd/pulls.go b/cmd/pulls.go index cb49bb3..5abbc71 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -109,11 +109,20 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { return err } - reviews, _, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - fmt.Printf("error while loading reviews: %v\n", err) + var reviews []*gitea.PullReview + for page := 1; ; { + page_reviews, resp, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + fmt.Printf("error while loading reviews: %v\n", err) + break + } + reviews = append(reviews, page_reviews...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } if ctx.IsSet("output") { diff --git a/cmd/releases/utils.go b/cmd/releases/utils.go index 1e0ab50..c27618c 100644 --- a/cmd/releases/utils.go +++ b/cmd/releases/utils.go @@ -11,19 +11,25 @@ import ( // GetReleaseByTag finds a release by its tag name. func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { - rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return nil, err - } - if len(rl) == 0 { - return nil, fmt.Errorf("repo does not have any release") - } - for _, r := range rl { - if r.TagName == tag { - return r, nil + for page := 1; ; { + rl, resp, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, err } + if page == 1 && len(rl) == 0 { + return nil, fmt.Errorf("repo does not have any release") + } + for _, r := range rl { + if r.TagName == tag { + return r, nil + } + } + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } return nil, fmt.Errorf("release tag does not exist") } diff --git a/modules/interact/issue_create.go b/modules/interact/issue_create.go index bfbafbd..3e478ee 100644 --- a/modules/interact/issue_create.go +++ b/modules/interact/issue_create.go @@ -180,19 +180,25 @@ func fetchIssueSelectables(login *config.Login, owner, repo string, done chan is r.MilestoneList[i] = m.Title } - labels, _, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - r.Err = err - done <- r - return - } r.LabelMap = make(map[string]int64) - r.LabelList = make([]string, len(labels)) - for i, l := range labels { - r.LabelMap[l.Name] = l.ID - r.LabelList[i] = l.Name + r.LabelList = make([]string, 0) + for page := 1; ; { + labels, resp, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + r.Err = err + done <- r + return + } + for _, l := range labels { + r.LabelMap[l.Name] = l.ID + r.LabelList = append(r.LabelList, l.Name) + } + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } done <- r diff --git a/modules/task/labels.go b/modules/task/labels.go index 8d3c12e..79fc672 100644 --- a/modules/task/labels.go +++ b/modules/task/labels.go @@ -13,16 +13,23 @@ import ( // ResolveLabelNames returns a list of label IDs for a given list of label names func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) { labelIDs := make([]int64, 0, len(labelNames)) - labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return nil, err - } - for _, l := range labels { - if utils.Contains(labelNames, l.Name) { - labelIDs = append(labelIDs, l.ID) + page := 1 + for { + labels, resp, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, err } + for _, l := range labels { + if utils.Contains(labelNames, l.Name) { + labelIDs = append(labelIDs, l.ID) + } + } + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } return labelIDs, nil } diff --git a/modules/task/login_create.go b/modules/task/login_create.go index ac45c46..2d54c48 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -166,11 +166,19 @@ func generateToken(login config.Login, user, pass, otp, scopes string) (string, } client := login.Client(opts...) - tl, _, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return "", err + var tl []*gitea.AccessToken + for page := 1; ; { + page_tokens, resp, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return "", err + } + tl = append(tl, page_tokens...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } host, _ := os.Hostname() tokenName := host + "-tea" diff --git a/modules/task/login_ssh.go b/modules/task/login_ssh.go index e36a293..eebcb28 100644 --- a/modules/task/login_ssh.go +++ b/modules/task/login_ssh.go @@ -19,11 +19,22 @@ import ( // a matching private key in ~/.ssh/. If no match is found, path is empty. func findSSHKey(client *gitea.Client) (string, error) { // get keys registered on gitea instance - keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil || len(keys) == 0 { - return "", err + var keys []*gitea.PublicKey + for page := 1; ; { + page_keys, resp, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return "", err + } + keys = append(keys, page_keys...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + if len(keys) == 0 { + return "", nil } // enumerate ~/.ssh/*.pub files diff --git a/modules/task/pull_review_comment.go b/modules/task/pull_review_comment.go index 68eaf78..1dea8fd 100644 --- a/modules/task/pull_review_comment.go +++ b/modules/task/pull_review_comment.go @@ -14,11 +14,19 @@ import ( func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) { c := ctx.Login.Client() - reviews, _, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ - ListOptions: gitea.ListOptions{Page: -1}, - }) - if err != nil { - return nil, err + var reviews []*gitea.PullReview + for page := 1; ; { + page_reviews, resp, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + return nil, err + } + reviews = append(reviews, page_reviews...) + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage } var allComments []*gitea.PullReviewComment From 892905d482df5095d35dd8c70e79573fb60b19e4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 25 Apr 2026 18:12:06 +0000 Subject: [PATCH 75/83] chore(deps): update docker.gitea.com/gitea docker tag to v1.26.1 (#968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [docker.gitea.com/gitea](https://github.com/go-gitea/gitea) | service | patch | `1.26.0` → `1.26.1` | --- ### Release Notes
go-gitea/gitea (docker.gitea.com/gitea) ### [`v1.26.1`](https://github.com/go-gitea/gitea/releases/tag/v1.26.1) [Compare Source](https://github.com/go-gitea/gitea/compare/v1.26.0...v1.26.1) - BUGFIXES \* Add event.schedule context for schedule actions task ([#​37320](https://github.com/go-gitea/gitea/issues/37320)) ([#​37348](https://github.com/go-gitea/gitea/issues/37348)) \* Fix an issue where changing an organization's visibility caused problems when users had forked its repositories. ([#​37324](https://github.com/go-gitea/gitea/issues/37324)) ([#​37344](https://github.com/go-gitea/gitea/issues/37344)) \* Use modern "git update-index --cacheinfo" syntax to support more file names ([#​37338](https://github.com/go-gitea/gitea/issues/37338)) ([#​37343](https://github.com/go-gitea/gitea/issues/37343)) \* Fix URL related escaping for oauth2 ([#​37334](https://github.com/go-gitea/gitea/issues/37334)) ([#​37340](https://github.com/go-gitea/gitea/issues/37340)) \* When the requested arch rpm is missing fall back to noarch ([#​37236](https://github.com/go-gitea/gitea/issues/37236)) ([#​37339](https://github.com/go-gitea/gitea/issues/37339)) \* Fix actions concurrency groups cross-branch leak ([#​37311](https://github.com/go-gitea/gitea/issues/37311)) ([#​37331](https://github.com/go-gitea/gitea/issues/37331)) \* Fix bug when accessing user badges ([#​37321](https://github.com/go-gitea/gitea/issues/37321)) ([#​37329](https://github.com/go-gitea/gitea/issues/37329)) \* Fix AppFullLink ([#​37325](https://github.com/go-gitea/gitea/issues/37325)) ([#​37328](https://github.com/go-gitea/gitea/issues/37328)) \* Fix container auth for public instance ([#​37290](https://github.com/go-gitea/gitea/issues/37290)) ([#​37294](https://github.com/go-gitea/gitea/issues/37294)) \* Enhance GetActionWorkflow to support fallback references ([#​37189](https://github.com/go-gitea/gitea/issues/37189)) ([#​37283](https://github.com/go-gitea/gitea/issues/37283)) \* Fix vite manifest update masking build errors ([#​37279](https://github.com/go-gitea/gitea/issues/37279)) ([#​37310](https://github.com/go-gitea/gitea/issues/37310)) \* Fix Mermaid diagrams failing when node labels contain line breaks ([#​37296](https://github.com/go-gitea/gitea/issues/37296)) ([#​37299](https://github.com/go-gitea/gitea/issues/37299)) \* Use TriggerEvent instead of Event in workflow runs API response for scheduled runs ([#​37288](https://github.com/go-gitea/gitea/issues/37288)) [#​37360](https://github.com/go-gitea/gitea/issues/37360) \* Add URL to Learn more about blocking a user. ([#​37355](https://github.com/go-gitea/gitea/issues/37355)) [#​37367](https://github.com/go-gitea/gitea/issues/37367) \* Fix button layout shift when collapsing file tree in editor ([#​37363](https://github.com/go-gitea/gitea/issues/37363)) [#​37375](https://github.com/go-gitea/gitea/issues/37375) \* Fix org team assignee/reviewer lookups for team member permissions ([#​37365](https://github.com/go-gitea/gitea/issues/37365)) [#​37391](https://github.com/go-gitea/gitea/issues/37391) \* Fix repo init README EOL ([#​37388](https://github.com/go-gitea/gitea/issues/37388)) [#​37399](https://github.com/go-gitea/gitea/issues/37399) \* Fix: dump with default zip type produces uncompressed zip ([#​37401](https://github.com/go-gitea/gitea/issues/37401))[#​37402](https://github.com/go-gitea/gitea/issues/37402)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate). Reviewed-on: https://gitea.com/gitea/tea/pulls/968 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- .gitea/workflows/test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 5e7f436..aecff5d 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -39,7 +39,7 @@ jobs: make unit-test-coverage services: gitea: - image: docker.gitea.com/gitea:1.26.0 + image: docker.gitea.com/gitea:1.26.1 cmd: - bash - -c From b100d4c93923bc57c7b618356d3def011485d02a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 29 Apr 2026 03:28:15 +0000 Subject: [PATCH 76/83] fix(deps): update module github.com/go-authgate/sdk-go to v0.7.0 (#970) Reviewed-on: https://gitea.com/gitea/tea/pulls/970 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5016a4e..4bc79f1 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 - github.com/go-authgate/sdk-go v0.6.1 + github.com/go-authgate/sdk-go v0.7.0 github.com/go-git/go-git/v5 v5.18.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 diff --git a/go.sum b/go.sum index c2a9676..adbf0ec 100644 --- a/go.sum +++ b/go.sum @@ -116,6 +116,8 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-authgate/sdk-go v0.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624/jw= github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk= +github.com/go-authgate/sdk-go v0.7.0 h1:hUqUMzsDkb+l5EiL+aX2LaFon/3mbjHmxm97qHHHL3k= +github.com/go-authgate/sdk-go v0.7.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU= 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= From dd81b33cec3d5d56a7d2c878959ebebfbe6914d0 Mon Sep 17 00:00:00 2001 From: Wesley Moore Date: Wed, 29 Apr 2026 15:04:55 +0000 Subject: [PATCH 77/83] Fix man page section (#969) Co-authored-by: Wesley Moore Co-committed-by: Wesley Moore --- cmd/man.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/man.go b/cmd/man.go index 8c3590c..5a72bcb 100644 --- a/cmd/man.go +++ b/cmd/man.go @@ -29,7 +29,9 @@ var CmdGenerateManPage = cli.Command{ Hidden: true, Flags: DocRenderFlags, Action: func(ctx context.Context, cmd *cli.Command) error { - return RenderDocs(cmd, cmd.Root(), docs.ToMan) + return RenderDocs(cmd, cmd.Root(), func(cmd *cli.Command) (string, error) { + return docs.ToManWithSection(cmd, 1) + }) }, } From 88421bb8885e2e1626f7f2695a2f763c0ea47508 Mon Sep 17 00:00:00 2001 From: Oleksii Zaremskyi Date: Fri, 1 May 2026 16:39:48 +0000 Subject: [PATCH 78/83] fix: read --assignee flag value instead of nonexistent --assigned-to (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this PR does `tea issues list --assignee USERNAME` currently returns every issue regardless of the assignee value — even nonexistent users return a full unfiltered list. Discovered against **tea v0.14.0** (with go-sdk v0.24.1) and reproduced on current `master` (commit `dd81b33`). This PR fixes that. ## Root cause Two distinct bugs on the same flag, both in `cmd/issues/list.go`: 1. **Per-repo path** (`tea issues list --repo OWNER/REPO --assignee USER`): the code reads `ctx.String("assigned-to")` for `AssignedBy`, but the flag is defined as `--assignee` in `cmd/flags/issue_pr.go:66`. The lookup always returns `""`, so the SDK omits the `assigned_by` query parameter and the API returns everything. 2. **Global path** (`tea issues list --assignee USER`, no `--repo`): this hits `/repos/issues/search`, which silently ignores `assigned_by`. Even after fix #1 the no-repo form would still return unfiltered results. Verified directly: - `GET /repos/issues/search?assigned_by=USER&owner=ORG&state=open` → all open issues - `GET /repos/issues/search?assigned=true&owner=ORG&state=open` → only the issues assigned to the authenticated user The endpoint only supports `assigned=true` (boolean self-filter), not arbitrary-user filtering, and `ListIssueOption` doesn't expose that field. Rather than misleading the caller, the no-repo path now returns a clear error. ## Changes Both changes are in `cmd/issues/list.go`: 1. Read `ctx.String("assignee")` instead of the non-existent flag name `"assigned-to"` (lines 80 and 97). 2. In the no-`--repo` branch, return `errors.New("--assignee requires --repo (...)")` when the flag is set. `cmd/pulls/list.go` does not expose an assignee filter, so it's unaffected. The `--author` mapping (`CreatedBy ← ctx.String("author")`) was already correct and is the model the fix follows. ## Manual verification Tested against a local Gitea instance with three open issues (only one assigned to the test user): | Command | Before | After | |---|---|---| | `tea issues list --repo X --assignee me` | all 3 | only the 1 assigned ✓ | | `tea issues list --repo X --assignee nonexistent` | all 3 | `Error: not found` ✓ | | `tea issues list --repo X --author me` | only the 1 (control) | unchanged ✓ | | `tea issues list --assignee me` (no `--repo`) | all 3 (silent) | clear error ✓ | | `tea issues list` (no flags) | all 3 | unchanged ✓ | --------- Co-authored-by: claude_1 Reviewed-on: https://gitea.com/gitea/tea/pulls/971 Reviewed-by: Lunny Xiao Co-authored-by: Oleksii Zaremskyi Co-committed-by: Oleksii Zaremskyi --- cmd/issues/list.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/issues/list.go b/cmd/issues/list.go index 9033f84..4b2652e 100644 --- a/cmd/issues/list.go +++ b/cmd/issues/list.go @@ -5,6 +5,7 @@ package issues import ( stdctx "context" + "errors" "time" "code.gitea.io/tea/cmd/flags" @@ -77,7 +78,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { Type: kind, KeyWord: ctx.String("keyword"), CreatedBy: ctx.String("author"), - AssignedBy: ctx.String("assigned-to"), + AssignedBy: ctx.String("assignee"), MentionedBy: ctx.String("mentions"), Labels: labels, Milestones: milestones, @@ -88,13 +89,15 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { return err } } else { + if ctx.IsSet("assignee") { + return errors.New("--assignee requires --repo (global issue search does not support assignee filter)") + } issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{ ListOptions: flags.GetListOptions(cmd), State: state, Type: kind, KeyWord: ctx.String("keyword"), CreatedBy: ctx.String("author"), - AssignedBy: ctx.String("assigned-to"), MentionedBy: ctx.String("mentions"), Labels: labels, Milestones: milestones, From 5d2d1a6e0c3f481b7d22eb4d65c362ef92e7999e Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 1 May 2026 16:45:52 +0000 Subject: [PATCH 79/83] fix(webhook): Fix when creating webhook, branch filter and auth header cannot be added (#964) Fix #963 Reviewed-on: https://gitea.com/gitea/tea/pulls/964 --- cmd/webhooks/create.go | 28 ++++----- cmd/webhooks/create_test.go | 72 +++++++-------------- cmd/webhooks/update.go | 23 ++++--- cmd/webhooks/update_test.go | 114 ++++++++++++++++++++++------------ modules/print/webhook.go | 12 +++- modules/print/webhook_test.go | 56 ++++++++--------- 6 files changed, 159 insertions(+), 146 deletions(-) diff --git a/cmd/webhooks/create.go b/cmd/webhooks/create.go index 57eb468..d669436 100644 --- a/cmd/webhooks/create.go +++ b/cmd/webhooks/create.go @@ -89,30 +89,26 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error { config["secret"] = secret } - if branchFilter != "" { - config["branch_filter"] = branchFilter - } - - if authHeader != "" { - config["authorization_header"] = authHeader - } - var hook *gitea.Hook if c.IsGlobal { return fmt.Errorf("global webhooks not yet supported in this version") } else if len(c.Org) > 0 { hook, _, err = client.CreateOrgHook(c.Org, gitea.CreateHookOption{ - Type: webhookType, - Config: config, - Events: events, - Active: active, + Type: webhookType, + Config: config, + Events: events, + Active: active, + BranchFilter: branchFilter, + AuthorizationHeader: authHeader, }) } else { hook, _, err = client.CreateRepoHook(c.Owner, c.Repo, gitea.CreateHookOption{ - Type: webhookType, - Config: config, - Events: events, - Active: active, + Type: webhookType, + Config: config, + Events: events, + Active: active, + BranchFilter: branchFilter, + AuthorizationHeader: authHeader, }) } if err != nil { diff --git a/cmd/webhooks/create_test.go b/cmd/webhooks/create_test.go index c2ee9d2..c5c9d91 100644 --- a/cmd/webhooks/create_test.go +++ b/cmd/webhooks/create_test.go @@ -79,8 +79,6 @@ func TestWebhookConfigConstruction(t *testing.T) { name string url string secret string - branchFilter string - authHeader string expectedKeys []string expectedValues map[string]string }{ @@ -106,44 +104,16 @@ func TestWebhookConfigConstruction(t *testing.T) { "secret": "my-secret", }, }, - { - name: "Config with branch filter", - url: "https://example.com/webhook", - branchFilter: "main,develop", - expectedKeys: []string{"url", "http_method", "content_type", "branch_filter"}, - expectedValues: map[string]string{ - "url": "https://example.com/webhook", - "http_method": "post", - "content_type": "json", - "branch_filter": "main,develop", - }, - }, - { - name: "Config with auth header", - url: "https://example.com/webhook", - authHeader: "Bearer token123", - expectedKeys: []string{"url", "http_method", "content_type", "authorization_header"}, - expectedValues: map[string]string{ - "url": "https://example.com/webhook", - "http_method": "post", - "content_type": "json", - "authorization_header": "Bearer token123", - }, - }, { name: "Complete config", url: "https://example.com/webhook", secret: "secret123", - branchFilter: "main", - authHeader: "X-Token: abc", - expectedKeys: []string{"url", "http_method", "content_type", "secret", "branch_filter", "authorization_header"}, + expectedKeys: []string{"url", "http_method", "content_type", "secret"}, expectedValues: map[string]string{ - "url": "https://example.com/webhook", - "http_method": "post", - "content_type": "json", - "secret": "secret123", - "branch_filter": "main", - "authorization_header": "X-Token: abc", + "url": "https://example.com/webhook", + "http_method": "post", + "content_type": "json", + "secret": "secret123", }, }, } @@ -159,12 +129,6 @@ func TestWebhookConfigConstruction(t *testing.T) { if tt.secret != "" { config["secret"] = tt.secret } - if tt.branchFilter != "" { - config["branch_filter"] = tt.branchFilter - } - if tt.authHeader != "" { - config["authorization_header"] = tt.authHeader - } // Check all expected keys exist for _, key := range tt.expectedKeys { @@ -184,11 +148,13 @@ func TestWebhookConfigConstruction(t *testing.T) { func TestWebhookCreateOptions(t *testing.T) { tests := []struct { - name string - webhookType string - events []string - active bool - config map[string]string + name string + webhookType string + events []string + active bool + config map[string]string + branchFilter string + authHeader string }{ { name: "Gitea webhook", @@ -200,6 +166,8 @@ func TestWebhookCreateOptions(t *testing.T) { "http_method": "post", "content_type": "json", }, + branchFilter: "main", + authHeader: "X-Token: abc", }, { name: "Slack webhook", @@ -228,16 +196,20 @@ func TestWebhookCreateOptions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { option := gitea.CreateHookOption{ - Type: gitea.HookType(tt.webhookType), - Config: tt.config, - Events: tt.events, - Active: tt.active, + Type: gitea.HookType(tt.webhookType), + Config: tt.config, + Events: tt.events, + Active: tt.active, + BranchFilter: tt.branchFilter, + AuthorizationHeader: tt.authHeader, } assert.Equal(t, gitea.HookType(tt.webhookType), option.Type) assert.Equal(t, tt.events, option.Events) assert.Equal(t, tt.active, option.Active) assert.Equal(t, tt.config, option.Config) + assert.Equal(t, tt.branchFilter, option.BranchFilter) + assert.Equal(t, tt.authHeader, option.AuthorizationHeader) }) } } diff --git a/cmd/webhooks/update.go b/cmd/webhooks/update.go index 256f2a9..e37fcb4 100644 --- a/cmd/webhooks/update.go +++ b/cmd/webhooks/update.go @@ -97,11 +97,14 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error { if cmd.IsSet("secret") { config["secret"] = cmd.String("secret") } + branchFilter := hook.BranchFilter if cmd.IsSet("branch-filter") { - config["branch_filter"] = cmd.String("branch-filter") + branchFilter = cmd.String("branch-filter") } + + authHeader := hook.AuthorizationHeader if cmd.IsSet("authorization-header") { - config["authorization_header"] = cmd.String("authorization-header") + authHeader = cmd.String("authorization-header") } // Update events if specified @@ -126,15 +129,19 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("global webhooks not yet supported in this version") } else if len(c.Org) > 0 { _, err = client.EditOrgHook(c.Org, int64(webhookID), gitea.EditHookOption{ - Config: config, - Events: events, - Active: &active, + Config: config, + Events: events, + Active: &active, + BranchFilter: branchFilter, + AuthorizationHeader: authHeader, }) } else { _, err = client.EditRepoHook(c.Owner, c.Repo, int64(webhookID), gitea.EditHookOption{ - Config: config, - Events: events, - Active: &active, + Config: config, + Events: events, + Active: &active, + BranchFilter: branchFilter, + AuthorizationHeader: authHeader, }) } if err != nil { diff --git a/cmd/webhooks/update_test.go b/cmd/webhooks/update_test.go index bc50574..70df1e0 100644 --- a/cmd/webhooks/update_test.go +++ b/cmd/webhooks/update_test.go @@ -128,12 +128,10 @@ func TestUpdateActiveInactiveFlags(t *testing.T) { func TestUpdateConfigPreservation(t *testing.T) { // Test that existing configuration is preserved when not updated originalConfig := map[string]string{ - "url": "https://old.example.com/webhook", - "secret": "old-secret", - "branch_filter": "main", - "authorization_header": "Bearer old-token", - "http_method": "post", - "content_type": "json", + "url": "https://old.example.com/webhook", + "secret": "old-secret", + "http_method": "post", + "content_type": "json", } tests := []struct { @@ -147,53 +145,32 @@ func TestUpdateConfigPreservation(t *testing.T) { "url": "https://new.example.com/webhook", }, expectedConfig: map[string]string{ - "url": "https://new.example.com/webhook", - "secret": "old-secret", - "branch_filter": "main", - "authorization_header": "Bearer old-token", - "http_method": "post", - "content_type": "json", + "url": "https://new.example.com/webhook", + "secret": "old-secret", + "http_method": "post", + "content_type": "json", }, }, { - name: "Update secret and auth header", + name: "Update secret", updates: map[string]string{ - "secret": "new-secret", - "authorization_header": "X-Token: new-token", + "secret": "new-secret", }, expectedConfig: map[string]string{ - "url": "https://old.example.com/webhook", - "secret": "new-secret", - "branch_filter": "main", - "authorization_header": "X-Token: new-token", - "http_method": "post", - "content_type": "json", - }, - }, - { - name: "Clear branch filter", - updates: map[string]string{ - "branch_filter": "", - }, - expectedConfig: map[string]string{ - "url": "https://old.example.com/webhook", - "secret": "old-secret", - "branch_filter": "", - "authorization_header": "Bearer old-token", - "http_method": "post", - "content_type": "json", + "url": "https://old.example.com/webhook", + "secret": "new-secret", + "http_method": "post", + "content_type": "json", }, }, { name: "No updates", updates: map[string]string{}, expectedConfig: map[string]string{ - "url": "https://old.example.com/webhook", - "secret": "old-secret", - "branch_filter": "main", - "authorization_header": "Bearer old-token", - "http_method": "post", - "content_type": "json", + "url": "https://old.example.com/webhook", + "secret": "old-secret", + "http_method": "post", + "content_type": "json", }, }, } @@ -217,6 +194,61 @@ func TestUpdateConfigPreservation(t *testing.T) { } } +func TestUpdateBranchFilterAndAuthHeaderHandling(t *testing.T) { + tests := []struct { + name string + originalBranchFilter string + originalAuthHeader string + setBranchFilter bool + newBranchFilter string + setAuthorizationHeader bool + newAuthHeader string + expectedBranchFilter string + expectedAuthHeader string + }{ + { + name: "Preserve values", + originalBranchFilter: "main", + originalAuthHeader: "Bearer old-token", + expectedBranchFilter: "main", + expectedAuthHeader: "Bearer old-token", + }, + { + name: "Update branch filter", + originalBranchFilter: "main", + setBranchFilter: true, + newBranchFilter: "develop", + expectedBranchFilter: "develop", + expectedAuthHeader: "", + }, + { + name: "Update authorization header", + originalAuthHeader: "Bearer old-token", + setAuthorizationHeader: true, + newAuthHeader: "X-Token: new-token", + expectedBranchFilter: "", + expectedAuthHeader: "X-Token: new-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + branchFilter := tt.originalBranchFilter + if tt.setBranchFilter { + branchFilter = tt.newBranchFilter + } + + authHeader := tt.originalAuthHeader + if tt.setAuthorizationHeader { + authHeader = tt.newAuthHeader + } + + assert.Equal(t, tt.expectedBranchFilter, branchFilter) + assert.Equal(t, tt.expectedAuthHeader, authHeader) + }) + } +} + func TestUpdateEventsHandling(t *testing.T) { tests := []struct { name string diff --git a/modules/print/webhook.go b/modules/print/webhook.go index 6193e21..577bddc 100644 --- a/modules/print/webhook.go +++ b/modules/print/webhook.go @@ -67,13 +67,21 @@ func WebhookDetails(hook *gitea.Hook) { if method, ok := hook.Config["http_method"]; ok { fmt.Printf("- **HTTP Method**: %s\n", method) } - if branchFilter, ok := hook.Config["branch_filter"]; ok && branchFilter != "" { + branchFilter := hook.BranchFilter + if branchFilter == "" { + branchFilter = hook.Config["branch_filter"] + } + if branchFilter != "" { fmt.Printf("- **Branch Filter**: %s\n", branchFilter) } if _, hasSecret := hook.Config["secret"]; hasSecret { fmt.Printf("- **Secret**: (configured)\n") } - if _, hasAuth := hook.Config["authorization_header"]; hasAuth { + hasAuth := hook.AuthorizationHeader != "" + if !hasAuth { + _, hasAuth = hook.Config["authorization_header"] + } + if hasAuth { fmt.Printf("- **Authorization Header**: (configured)\n") } } diff --git a/modules/print/webhook_test.go b/modules/print/webhook_test.go index 83963a1..017379c 100644 --- a/modules/print/webhook_test.go +++ b/modules/print/webhook_test.go @@ -81,17 +81,17 @@ func TestWebhookDetails(t *testing.T) { ID: 123, Type: "gitea", Config: map[string]string{ - "url": "https://example.com/webhook", - "content_type": "json", - "http_method": "post", - "branch_filter": "main,develop", - "secret": "secret-value", - "authorization_header": "Bearer token123", + "url": "https://example.com/webhook", + "content_type": "json", + "http_method": "post", + "secret": "secret-value", }, - Events: []string{"push", "pull_request", "issues"}, - Active: true, - Created: now.Add(-24 * time.Hour), - Updated: now, + BranchFilter: "main,develop", + AuthorizationHeader: "Bearer token123", + Events: []string{"push", "pull_request", "issues"}, + Active: true, + Created: now.Add(-24 * time.Hour), + Updated: now, }, }, { @@ -238,16 +238,14 @@ func TestWebhookConfigHandling(t *testing.T) { { name: "Config with all fields", config: map[string]string{ - "url": "https://example.com/webhook", - "secret": "my-secret", - "authorization_header": "Bearer token", - "content_type": "json", - "http_method": "post", - "branch_filter": "main", + "url": "https://example.com/webhook", + "secret": "my-secret", + "content_type": "json", + "http_method": "post", }, expectedURL: "https://example.com/webhook", hasSecret: true, - hasAuthHeader: true, + hasAuthHeader: false, }, { name: "Config with minimal fields", @@ -341,17 +339,17 @@ func TestWebhookDetailsFormatting(t *testing.T) { ID: 123, Type: "gitea", Config: map[string]string{ - "url": "https://example.com/webhook", - "content_type": "json", - "http_method": "post", - "branch_filter": "main,develop", - "secret": "secret-value", - "authorization_header": "Bearer token123", + "url": "https://example.com/webhook", + "content_type": "json", + "http_method": "post", + "secret": "secret-value", }, - Events: []string{"push", "pull_request", "issues"}, - Active: true, - Created: now.Add(-24 * time.Hour), - Updated: now, + BranchFilter: "main,develop", + AuthorizationHeader: "Bearer token123", + Events: []string{"push", "pull_request", "issues"}, + Active: true, + Created: now.Add(-24 * time.Hour), + Updated: now, } // Test that all expected fields are included in details @@ -379,8 +377,8 @@ func TestWebhookDetailsFormatting(t *testing.T) { assert.Equal(t, "https://example.com/webhook", hook.Config["url"]) assert.Equal(t, "json", hook.Config["content_type"]) assert.Equal(t, "post", hook.Config["http_method"]) - assert.Equal(t, "main,develop", hook.Config["branch_filter"]) + assert.Equal(t, "main,develop", hook.BranchFilter) assert.Contains(t, hook.Config, "secret") - assert.Contains(t, hook.Config, "authorization_header") + assert.Equal(t, "Bearer token123", hook.AuthorizationHeader) assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events) } From 27e6083e2393cb3489a45d5ca854cb8daa830065 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 1 May 2026 23:16:49 +0000 Subject: [PATCH 80/83] fix(deps): update module github.com/go-authgate/sdk-go to v0.8.0 (#972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [github.com/go-authgate/sdk-go](https://github.com/go-authgate/sdk-go) | `v0.7.0` → `v0.8.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-authgate%2fsdk-go/v0.8.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-authgate%2fsdk-go/v0.7.0/v0.8.0?slim=true) | --- ### Release Notes
go-authgate/sdk-go (github.com/go-authgate/sdk-go) ### [`v0.8.0`](https://github.com/go-authgate/sdk-go/releases/tag/v0.8.0) [Compare Source](https://github.com/go-authgate/sdk-go/compare/v0.7.0...v0.8.0) #### Changelog ##### Refactor - [`62ccff0`](https://github.com/go-authgate/sdk-go/commit/62ccff06c837abe9c9cd6d8411525e3d25344cf1): refactor(jwksauth): share OIDC discovery and drop tenant cache ([#​23](https://github.com/go-authgate/sdk-go/issues/23)) ([@​appleboy](https://github.com/appleboy)) - [`088ee3b`](https://github.com/go-authgate/sdk-go/commit/088ee3bd2d5f891c03d27212a6ed5283b1434282): refactor(sdk): harden HTTP reads and improve code quality ([#​18](https://github.com/go-authgate/sdk-go/issues/18)) ([@​appleboy](https://github.com/appleboy)) - [`aa17bc2`](https://github.com/go-authgate/sdk-go/commit/aa17bc2373b675b0f0882672706c66a0f523b05f): refactor: simplify oauth client and token source flows ([#​22](https://github.com/go-authgate/sdk-go/issues/22)) ([@​appleboy](https://github.com/appleboy))
--------- Co-authored-by: Lunny Xiao Reviewed-on: https://gitea.com/gitea/tea/pulls/972 Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 4bc79f1..1758087 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 - github.com/go-authgate/sdk-go v0.7.0 + github.com/go-authgate/sdk-go v0.8.0 github.com/go-git/go-git/v5 v5.18.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 diff --git a/go.sum b/go.sum index adbf0ec..030fae6 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/go-authgate/sdk-go v0.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624 github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk= github.com/go-authgate/sdk-go v0.7.0 h1:hUqUMzsDkb+l5EiL+aX2LaFon/3mbjHmxm97qHHHL3k= github.com/go-authgate/sdk-go v0.7.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU= +github.com/go-authgate/sdk-go v0.8.0 h1:uYJMOv//qwMEJeiFTUvXGXozEHGUOsS6zfOVXxEwat4= +github.com/go-authgate/sdk-go v0.8.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU= 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= From 1f6fd97fc1a5a751aa335dce11c23fe61e803a34 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 2 May 2026 02:04:23 +0000 Subject: [PATCH 81/83] fix(deps): update module github.com/go-authgate/sdk-go to v0.9.0 (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [github.com/go-authgate/sdk-go](https://github.com/go-authgate/sdk-go) | `v0.8.0` → `v0.9.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-authgate%2fsdk-go/v0.9.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-authgate%2fsdk-go/v0.8.0/v0.9.0?slim=true) | --- ### Release Notes
go-authgate/sdk-go (github.com/go-authgate/sdk-go) ### [`v0.9.0`](https://github.com/go-authgate/sdk-go/releases/tag/v0.9.0) [Compare Source](https://github.com/go-authgate/sdk-go/compare/v0.8.0...v0.9.0) #### Changelog ##### Documentation updates - [`86d33f3`](https://github.com/go-authgate/sdk-go/commit/86d33f315c3eddfe92f37e4d8b3ac30afbc0ef72): docs(jwksauth): tighten readme table column widths ([@​appleboy](https://github.com/appleboy)) ##### Others - [`545d96f`](https://github.com/go-authgate/sdk-go/commit/545d96fd43d8a6e6bc76a3c6b28683ffa3eace06): refactor(jwksauth)!: rename Tenant to Domain and add Tenant sub-claim ([#​25](https://github.com/go-authgate/sdk-go/issues/25)) ([@​appleboy](https://github.com/appleboy)) - [`1e73580`](https://github.com/go-authgate/sdk-go/commit/1e73580c87f2be874101c4ccc02f3dd1ceb17c53): feat(jwksauth)!: adopt slog-style Logger interface ([#​24](https://github.com/go-authgate/sdk-go/issues/24)) ([@​appleboy](https://github.com/appleboy)) - [`7af1bc4`](https://github.com/go-authgate/sdk-go/commit/7af1bc463714a2c4e6aea1741c87b20fdbba21ce): test(jwksauth): fix stale Tenant references in policy reject test ([#​26](https://github.com/go-authgate/sdk-go/issues/26)) ([@​appleboy](https://github.com/appleboy))
--- Reviewed-on: https://gitea.com/gitea/tea/pulls/974 Reviewed-by: Lunny Xiao Co-authored-by: Renovate Bot Co-committed-by: Renovate Bot --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 1758087..c3b3bbb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 - github.com/go-authgate/sdk-go v0.8.0 + github.com/go-authgate/sdk-go v0.9.0 github.com/go-git/go-git/v5 v5.18.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 diff --git a/go.sum b/go.sum index 030fae6..8478534 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/go-authgate/sdk-go v0.7.0 h1:hUqUMzsDkb+l5EiL+aX2LaFon/3mbjHmxm97qHHH github.com/go-authgate/sdk-go v0.7.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU= github.com/go-authgate/sdk-go v0.8.0 h1:uYJMOv//qwMEJeiFTUvXGXozEHGUOsS6zfOVXxEwat4= github.com/go-authgate/sdk-go v0.8.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU= +github.com/go-authgate/sdk-go v0.9.0 h1:VgQNjcKXtMONNiVf4coC/J69H78CkTt3CJ8maiQSf6Y= +github.com/go-authgate/sdk-go v0.9.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU= 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= From 83b718ac34ea22ce2d5283dcd242ecc7dec7423f Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 2 May 2026 04:18:45 +0000 Subject: [PATCH 82/83] Move integration tests to tests/ directory (#973) Reviewed-on: https://gitea.com/gitea/tea/pulls/973 --- .gitea/workflows/test-pr.yml | 30 +++++++--- Makefile | 27 ++++++--- modules/config/testtools.go | 13 ++++ modules/context/context_test.go | 49 --------------- tests/README.md | 10 ++++ .../integration/config_lock_unix_test.go | 22 ++++--- tests/integration/context_init_test.go | 59 ++++++++++++++++++ .../integration/git_repo_test.go | 15 +---- .../integration/repos_create_test.go | 60 +++++++++++++++---- 9 files changed, 184 insertions(+), 101 deletions(-) create mode 100644 modules/config/testtools.go create mode 100644 tests/README.md rename modules/config/lock_unix_test.go => tests/integration/config_lock_unix_test.go (75%) create mode 100644 tests/integration/context_init_test.go rename modules/git/repo_test.go => tests/integration/git_repo_test.go (80%) rename cmd/repos/create_test.go => tests/integration/repos_create_test.go (56%) diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index aecff5d..a9573c9 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -12,13 +12,9 @@ jobs: # uses: golang/govulncheck-action@v1 # with: # go-version-file: 'go.mod' - check-and-test: + check-and-unit: + name: Lint Build And Unit Coverage runs-on: ubuntu-latest - env: - HTTP_PROXY: "" - GITEA_TEA_TEST_URL: "http://gitea:3000" - GITEA_TEA_TEST_USERNAME: "test01" - GITEA_TEA_TEST_PASSWORD: "test01" steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 @@ -32,11 +28,27 @@ jobs: make fmt-check make docs-check make build - - run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance - - name: test and coverage + - name: unit test and coverage run: | - make test make unit-test-coverage + + integration-test: + name: Integration Test + runs-on: ubuntu-latest + env: + HTTP_PROXY: "" + GITEA_TEA_TEST_URL: "http://gitea:3000" + GITEA_TEA_TEST_USERNAME: "test01" + GITEA_TEA_TEST_PASSWORD: "test01" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance + - name: integration test + run: | + make integration-test services: gitea: image: docker.gitea.com/gitea:1.26.1 diff --git a/Makefile b/Makefile index 487461c..31ff016 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,10 @@ LDFLAGS := -X "code.gitea.io/tea/modules/version.Version=$(TEA_VERSION)" -X "cod # override to allow passing additional goflags via make CLI override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -PACKAGES ?= $(shell $(GO) list ./...) +PACKAGES ?= $(shell $(GO) list ./... | grep -v '^code.gitea.io/tea/tests') +UNIT_PACKAGES ?= $(PACKAGES) +INTEGRATION_PACKAGES ?= $(shell $(GO) list ./tests/... 2>/dev/null) +INTEGRATION_TEST_TAGS ?= testtools SOURCES ?= $(shell find . -name "*.go" -type f) # OS specific vars. @@ -64,11 +67,11 @@ vet: .PHONY: lint lint: - $(GO) run $(GOLANGCI_LINT_PACKAGE) run + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools .PHONY: lint-fix lint-fix: - $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix .PHONY: fmt-check fmt-check: @@ -93,13 +96,24 @@ docs-check: exit 1; \ fi; +.PHONY: unit-test +unit-test: + $(GO) test $(UNIT_PACKAGES) + +.PHONY: integration-test +integration-test: + @if [ -n "$(INTEGRATION_PACKAGES)" ]; then \ + $(GO) test -tags='$(INTEGRATION_TEST_TAGS)' $(INTEGRATION_PACKAGES); \ + else \ + echo "No integration test packages found"; \ + fi + .PHONY: test -test: - $(GO) test -tags='sqlite sqlite_unlock_notify' $(PACKAGES) +test: unit-test integration-test .PHONY: unit-test-coverage unit-test-coverage: - $(GO) test -tags='sqlite sqlite_unlock_notify' -cover -coverprofile coverage.out $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 + $(GO) test -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 .PHONY: tidy tidy: @@ -122,4 +136,3 @@ $(EXECUTABLE): $(SOURCES) .PHONY: build-image build-image: docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) . - diff --git a/modules/config/testtools.go b/modules/config/testtools.go new file mode 100644 index 0000000..384cfe2 --- /dev/null +++ b/modules/config/testtools.go @@ -0,0 +1,13 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build testtools + +package config + +import "time" + +// AcquireConfigLockForTesting exposes the internal lock helper to integration tests. +func AcquireConfigLockForTesting(lockPath string, timeout time.Duration) (func() error, error) { + return acquireConfigLock(lockPath, timeout) +} diff --git a/modules/context/context_test.go b/modules/context/context_test.go index 2c8ea43..9b73f88 100644 --- a/modules/context/context_test.go +++ b/modules/context/context_test.go @@ -4,14 +4,9 @@ package context import ( - "os" - "os/exec" "testing" "code.gitea.io/tea/modules/config" - - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" ) func Test_MatchLogins(t *testing.T) { @@ -70,47 +65,3 @@ func Test_MatchLogins(t *testing.T) { }) } } - -func TestInitCommand_WithRepoSlugSkipsLocalRepoDetection(t *testing.T) { - tmpDir := t.TempDir() - config.SetConfigForTesting(config.LocalConfig{ - Logins: []config.Login{{ - Name: "test-login", - URL: "https://gitea.example.com", - Token: "token", - User: "login-user", - Default: true, - }}, - }) - - cmd := exec.Command("git", "init", "--object-format=sha256", tmpDir) - cmd.Env = os.Environ() - require.NoError(t, cmd.Run()) - - oldWd, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(tmpDir)) - t.Cleanup(func() { - require.NoError(t, os.Chdir(oldWd)) - }) - - cliCmd := cli.Command{ - Name: "branches", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "login"}, - &cli.StringFlag{Name: "repo"}, - &cli.StringFlag{Name: "remote"}, - &cli.StringFlag{Name: "output"}, - }, - } - require.NoError(t, cliCmd.Set("repo", "owner/repo")) - - ctx, err := InitCommand(&cliCmd) - require.NoError(t, err) - require.Equal(t, "owner", ctx.Owner) - require.Equal(t, "repo", ctx.Repo) - require.Equal(t, "owner/repo", ctx.RepoSlug) - require.Nil(t, ctx.LocalRepo) - require.NotNil(t, ctx.Login) - require.Equal(t, "test-login", ctx.Login.Name) -} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0d03110 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,10 @@ +This directory contains integration tests that exercise tea against external services or external executables. + +- Unit tests stay next to the packages they cover. +- Integration tests live under `tests/` so they can be run separately. + +Common targets: + +- `make unit-test` +- `make integration-test` +- `make test` diff --git a/modules/config/lock_unix_test.go b/tests/integration/config_lock_unix_test.go similarity index 75% rename from modules/config/lock_unix_test.go rename to tests/integration/config_lock_unix_test.go index f8ab2f8..ff712c6 100644 --- a/modules/config/lock_unix_test.go +++ b/tests/integration/config_lock_unix_test.go @@ -3,7 +3,7 @@ //go:build unix -package config +package integration import ( "fmt" @@ -12,10 +12,11 @@ import ( "path/filepath" "testing" "time" + + "code.gitea.io/tea/modules/config" ) func TestConfigLock_CrossProcess(t *testing.T) { - // Create a temp directory for test tmpDir, err := os.MkdirTemp("", "tea-lock-test") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -24,15 +25,16 @@ func TestConfigLock_CrossProcess(t *testing.T) { lockPath := filepath.Join(tmpDir, "config.yml.lock") - // Acquire lock in main process - unlock, err := acquireConfigLock(lockPath, 5*time.Second) + unlock, err := config.AcquireConfigLockForTesting(lockPath, 5*time.Second) if err != nil { t.Fatalf("failed to acquire lock: %v", err) } - defer unlock() + defer func() { + if err := unlock(); err != nil { + t.Fatalf("failed to release lock: %v", err) + } + }() - // Spawn a subprocess that tries to acquire the same lock - // The subprocess should fail to acquire within timeout script := fmt.Sprintf(` package main @@ -48,19 +50,16 @@ func main() { } defer file.Close() - // Try non-blocking lock err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if err != nil { - // Lock is held - expected behavior os.Exit(0) } - // Lock was acquired - unexpected + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) os.Exit(1) } `, lockPath) - // Write and run the test script scriptPath := filepath.Join(tmpDir, "locktest.go") if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { t.Fatalf("failed to write test script: %v", err) @@ -78,5 +77,4 @@ func main() { t.Errorf("subprocess execution failed: %v", err) } } - // Exit code 0 means lock was properly held - success } diff --git a/tests/integration/context_init_test.go b/tests/integration/context_init_test.go new file mode 100644 index 0000000..a220af2 --- /dev/null +++ b/tests/integration/context_init_test.go @@ -0,0 +1,59 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "os" + "os/exec" + "testing" + + "code.gitea.io/tea/modules/config" + teacontext "code.gitea.io/tea/modules/context" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestInitCommand_WithRepoSlugSkipsLocalRepoDetection(t *testing.T) { + tmpDir := t.TempDir() + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "test-login", + URL: "https://gitea.example.com", + Token: "token", + User: "login-user", + Default: true, + }}, + }) + + cmd := exec.Command("git", "init", "--object-format=sha256", tmpDir) + cmd.Env = os.Environ() + require.NoError(t, cmd.Run()) + + oldWd, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { + require.NoError(t, os.Chdir(oldWd)) + }) + + cliCmd := cli.Command{ + Name: "branches", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "login"}, + &cli.StringFlag{Name: "repo"}, + &cli.StringFlag{Name: "remote"}, + &cli.StringFlag{Name: "output"}, + }, + } + require.NoError(t, cliCmd.Set("repo", "owner/repo")) + + ctx, err := teacontext.InitCommand(&cliCmd) + require.NoError(t, err) + require.Equal(t, "owner", ctx.Owner) + require.Equal(t, "repo", ctx.Repo) + require.Equal(t, "owner/repo", ctx.RepoSlug) + require.Nil(t, ctx.LocalRepo) + require.NotNil(t, ctx.Login) + require.Equal(t, "test-login", ctx.Login.Name) +} diff --git a/modules/git/repo_test.go b/tests/integration/git_repo_test.go similarity index 80% rename from modules/git/repo_test.go rename to tests/integration/git_repo_test.go index b9b339a..5dbf9f4 100644 --- a/modules/git/repo_test.go +++ b/tests/integration/git_repo_test.go @@ -1,7 +1,7 @@ // Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package git +package integration import ( "os" @@ -9,11 +9,11 @@ import ( "path/filepath" "testing" + teagit "code.gitea.io/tea/modules/git" "github.com/stretchr/testify/assert" ) func TestRepoFromPath_Worktree(t *testing.T) { - // Create a temporary directory for test tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*") assert.NoError(t, err) defer os.RemoveAll(tmpDir) @@ -21,21 +21,17 @@ func TestRepoFromPath_Worktree(t *testing.T) { mainRepoPath := filepath.Join(tmpDir, "main-repo") worktreePath := filepath.Join(tmpDir, "worktree") - // Initialize main repository cmd := exec.Command("git", "init", mainRepoPath) assert.NoError(t, cmd.Run()) - // Configure git for the test cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com") assert.NoError(t, cmd.Run()) cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User") assert.NoError(t, cmd.Run()) - // Add a remote to the main repository cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git") assert.NoError(t, cmd.Run()) - // Create an initial commit (required for worktree) readmePath := filepath.Join(mainRepoPath, "README.md") err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644) assert.NoError(t, err) @@ -44,19 +40,14 @@ func TestRepoFromPath_Worktree(t *testing.T) { cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit") assert.NoError(t, cmd.Run()) - // Create a worktree cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch") assert.NoError(t, cmd.Run()) - // Test: Open repository from worktree path - repo, err := RepoFromPath(worktreePath) + repo, err := teagit.RepoFromPath(worktreePath) assert.NoError(t, err, "Should be able to open worktree") - // Test: Read config from worktree (should read from main repo's config) config, err := repo.Config() assert.NoError(t, err, "Should be able to read config") - - // Verify that remotes are accessible from worktree assert.NotEmpty(t, config.Remotes, "Should be able to read remotes from worktree") assert.Contains(t, config.Remotes, "origin", "Should have origin remote") assert.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0], "Should have correct remote URL") diff --git a/cmd/repos/create_test.go b/tests/integration/repos_create_test.go similarity index 56% rename from cmd/repos/create_test.go rename to tests/integration/repos_create_test.go index acb42aa..07d79d7 100644 --- a/cmd/repos/create_test.go +++ b/tests/integration/repos_create_test.go @@ -1,28 +1,66 @@ // Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT -package repos +package integration import ( "context" "fmt" "os" + "path/filepath" "testing" "time" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/repos" + "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/task" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) +func useTempConfigPath(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.yml") + config.SetConfigPathForTesting(configPath) + t.Cleanup(func() { + config.SetConfigPathForTesting("") + }) + + return configPath +} + +func createIntegrationLogin(t *testing.T, giteaURL string) *config.Login { + t.Helper() + + _ = useTempConfigPath(t) + + username := os.Getenv("GITEA_TEA_TEST_USERNAME") + password := os.Getenv("GITEA_TEA_TEST_PASSWORD") + require.NotEmpty(t, username, "GITEA_TEA_TEST_USERNAME is required for integration tests") + require.NotEmpty(t, password, "GITEA_TEA_TEST_PASSWORD is required for integration tests") + + require.NoError(t, task.CreateLogin("integration", "", username, password, "", "", "", giteaURL, "", "", true, false, false, false)) + + login, err := config.GetLoginByName("integration") + require.NoError(t, err) + require.NotNil(t, login) + + return login +} + func TestCreateRepoObjectFormat(t *testing.T) { giteaURL := os.Getenv("GITEA_TEA_TEST_URL") if giteaURL == "" { t.Skip("GITEA_TEA_TEST_URL is not set, skipping test") } + login := createIntegrationLogin(t, giteaURL) + client := login.Client() timestamp := time.Now().Unix() + tests := []struct { name string args []string @@ -56,22 +94,15 @@ func TestCreateRepoObjectFormat(t *testing.T) { }, } - giteaUserName := os.Getenv("GITEA_TEA_TEST_USERNAME") - giteaUserPasword := os.Getenv("GITEA_TEA_TEST_PASSWORD") - - err := task.CreateLogin("test", "", giteaUserName, giteaUserPasword, "", "", "", giteaURL, "", "", true, false, false, false) - if err != nil && err.Error() != "login name 'test' has already been used" { - t.Fatal(err) - } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reposCmd := &cli.Command{ Name: "repos", - Commands: []*cli.Command{&CmdRepoCreate}, + Commands: []*cli.Command{&repos.CmdRepoCreate}, } - tt.args = append(tt.args, "--login", "test") + args := append([]string{"repos", "create"}, tt.args...) + args = append(args, "--login", login.Name) err := reposCmd.Run(context.Background(), args) if tt.wantErr { @@ -82,7 +113,12 @@ func TestCreateRepoObjectFormat(t *testing.T) { return } - assert.NoError(t, err) + require.NoError(t, err) + t.Cleanup(func() { + if _, delErr := client.DeleteRepo(login.User, tt.wantOpts.Name); delErr != nil { + t.Logf("failed to delete integration test repo %q: %v", tt.wantOpts.Name, delErr) + } + }) }) } } From d3e43d762384b677105f3cc4d388c02551c1b461 Mon Sep 17 00:00:00 2001 From: Hugo Nijhuis Date: Sat, 2 May 2026 16:00:01 +0200 Subject: [PATCH 83/83] feat(issues): add dependency management commands --- cmd/issues.go | 1 + cmd/issues/dependencies.go | 266 +++++++++++++++++++++++++++++++++++++ modules/api/types.go | 66 +++++++++ 3 files changed, 333 insertions(+) create mode 100644 cmd/issues/dependencies.go create mode 100644 modules/api/types.go diff --git a/cmd/issues.go b/cmd/issues.go index 16da65a..31d0a4b 100644 --- a/cmd/issues.go +++ b/cmd/issues.go @@ -62,6 +62,7 @@ var CmdIssues = cli.Command{ &issues.CmdIssuesEdit, &issues.CmdIssuesReopen, &issues.CmdIssuesClose, + &issues.CmdIssuesDependencies, }, Flags: append([]cli.Flag{ &cli.BoolFlag{ diff --git a/cmd/issues/dependencies.go b/cmd/issues/dependencies.go new file mode 100644 index 0000000..e55d3fd --- /dev/null +++ b/cmd/issues/dependencies.go @@ -0,0 +1,266 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + "bytes" + stdctx "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/api" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/utils" + + "github.com/urfave/cli/v3" +) + +// CmdIssuesDependencies represents the subcommand for issue dependencies +var CmdIssuesDependencies = cli.Command{ + Name: "dependencies", + Aliases: []string{"deps", "dep"}, + Usage: "Manage issue dependencies", + Description: "List, add, or remove dependencies for an issue", + ArgsUsage: "", + Action: runDependenciesList, + Commands: []*cli.Command{ + &cmdDependenciesList, + &cmdDependenciesAdd, + &cmdDependenciesRemove, + }, + Flags: flags.AllDefaultFlags, +} + +var cmdDependenciesList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List dependencies for an issue", + Description: "List issues that the specified issue depends on (blockers)", + ArgsUsage: "", + Action: runDependenciesList, + Flags: flags.AllDefaultFlags, +} + +var cmdDependenciesAdd = cli.Command{ + Name: "add", + Usage: "Add a dependency to an issue", + Description: "Make an issue depend on another issue (blocker)", + ArgsUsage: " ", + Action: runDependenciesAdd, + Flags: flags.AllDefaultFlags, +} + +var cmdDependenciesRemove = cli.Command{ + Name: "remove", + Aliases: []string{"rm"}, + Usage: "Remove a dependency from an issue", + Description: "Remove a dependency from an issue", + ArgsUsage: " ", + Action: runDependenciesRemove, + Flags: flags.AllDefaultFlags, +} + +// runDependenciesList lists dependencies for an issue +func runDependenciesList(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 1 { + return fmt.Errorf("issue index required") + } + + index, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return err + } + + client := api.NewClient(c.Login) + path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index) + + resp, err := client.Do("GET", path, nil, nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var deps []*gitea.Issue + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&deps); err != nil { + return err + } + + if len(deps) == 0 { + fmt.Printf("Issue #%d has no dependencies\n", index) + return nil + } + + print.IssuesPullsList(deps, c.Output, []string{"index", "state", "title", "owner", "repo"}) + return nil +} + +// runDependenciesAdd adds a dependency to an issue +func runDependenciesAdd(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 2 { + return fmt.Errorf("usage: tea issues dependencies add ") + } + + index, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid issue index: %w", err) + } + + depOwner, depRepo, depIndex, err := parseDependencyArg(cmd.Args().Get(1), c.Owner, c.Repo) + if err != nil { + return err + } + + client := api.NewClient(c.Login) + path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index) + + reqBody := api.IssueDependencyRequest{ + Owner: depOwner, + Repo: depRepo, + Index: depIndex, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := client.Do("POST", path, bytes.NewReader(body), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + if depOwner == c.Owner && depRepo == c.Repo { + fmt.Printf("Added dependency: issue #%d now depends on #%d\n", index, depIndex) + } else { + fmt.Printf("Added dependency: issue #%d now depends on %s/%s#%d\n", index, depOwner, depRepo, depIndex) + } + return nil +} + +// runDependenciesRemove removes a dependency from an issue +func runDependenciesRemove(ctx stdctx.Context, cmd *cli.Command) error { + c, err := context.InitCommand(cmd) + if err != nil { + return err + } + c.Ensure(context.CtxRequirement{RemoteRepo: true}) + + if cmd.Args().Len() < 2 { + return fmt.Errorf("usage: tea issues dependencies remove ") + } + + index, err := utils.ArgToIndex(cmd.Args().First()) + if err != nil { + return fmt.Errorf("invalid issue index: %w", err) + } + + depOwner, depRepo, depIndex, err := parseDependencyArg(cmd.Args().Get(1), c.Owner, c.Repo) + if err != nil { + return err + } + + client := api.NewClient(c.Login) + path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index) + + reqBody := api.IssueDependencyRequest{ + Owner: depOwner, + Repo: depRepo, + Index: depIndex, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := client.Do("DELETE", path, bytes.NewReader(body), nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + if depOwner == c.Owner && depRepo == c.Repo { + fmt.Printf("Removed dependency: issue #%d no longer depends on #%d\n", index, depIndex) + } else { + fmt.Printf("Removed dependency: issue #%d no longer depends on %s/%s#%d\n", index, depOwner, depRepo, depIndex) + } + return nil +} + +// parseDependencyArg parses a dependency argument which can be: +// - "123" or "#123" (same repo) +// - "owner/repo#123" (cross-repo) +func parseDependencyArg(arg, defaultOwner, defaultRepo string) (owner, repo string, index int64, err error) { + // Check for cross-repo format: owner/repo#123 + // Ensure "/" comes before "#" to distinguish from same-repo "#123" + slashIdx := strings.Index(arg, "/") + hashIdx := strings.Index(arg, "#") + if slashIdx != -1 && hashIdx != -1 && slashIdx < hashIdx { + parts := strings.SplitN(arg, "#", 2) + if len(parts) != 2 { + return "", "", 0, fmt.Errorf("invalid dependency format: %s (expected owner/repo#index)", arg) + } + + repoPath := parts[0] + indexStr := parts[1] + + repoParts := strings.SplitN(repoPath, "/", 2) + if len(repoParts) != 2 { + return "", "", 0, fmt.Errorf("invalid dependency format: %s (expected owner/repo#index)", arg) + } + + owner = repoParts[0] + repo = repoParts[1] + + index, err = strconv.ParseInt(indexStr, 10, 64) + if err != nil { + return "", "", 0, fmt.Errorf("invalid issue index: %s", indexStr) + } + + return owner, repo, index, nil + } + + // Same repo format: 123 or #123 + index, err = utils.ArgToIndex(arg) + if err != nil { + return "", "", 0, fmt.Errorf("invalid issue index: %w", err) + } + + return defaultOwner, defaultRepo, index, nil +} diff --git a/modules/api/types.go b/modules/api/types.go new file mode 100644 index 0000000..a2e03e4 --- /dev/null +++ b/modules/api/types.go @@ -0,0 +1,66 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package api + +import "time" + +// ActionRun represents a workflow run +type ActionRun struct { + ID int64 `json:"id"` + Title string `json:"display_title"` + Path string `json:"path"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + HeadSHA string `json:"head_sha"` + RunNumber int64 `json:"run_number"` + RunAttempt int64 `json:"run_attempt"` + HTMLURL string `json:"html_url"` + URL string `json:"url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ActionRunList is a list of workflow runs +type ActionRunList struct { + TotalCount int64 `json:"total_count"` + WorkflowRuns []*ActionRun `json:"workflow_runs"` +} + +// ActionJob represents a job within a workflow run +type ActionJob struct { + ID int64 `json:"id"` + RunID int64 `json:"run_id"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HTMLURL string `json:"html_url"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + Steps []*ActionJobStep `json:"steps"` +} + +// ActionJobStep represents a step within a job +type ActionJobStep struct { + Name string `json:"name"` + Number int64 `json:"number"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ActionJobList is a list of jobs +type ActionJobList struct { + TotalCount int64 `json:"total_count"` + Jobs []*ActionJob `json:"jobs"` +} + +// IssueDependencyRequest is the request body for adding/removing issue dependencies +type IssueDependencyRequest struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Index int64 `json:"index"` +}