diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a36b29..377be3e 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:2.1-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 diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 691d27d..49e3a28 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -8,26 +8,30 @@ 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 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 }} + - 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 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser-pro 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 }} @@ -45,24 +49,24 @@ 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 - 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 8254c56..49d9f2b 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -9,26 +9,30 @@ 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 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 }} + - 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 + uses: goreleaser/goreleaser-action@v7 with: distribution: goreleaser-pro 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 }} @@ -46,18 +50,18 @@ 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 - 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 }} @@ -67,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/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index 30a9527..a9573c9 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -4,24 +4,20 @@ on: - pull_request jobs: - govulncheck_job: + #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-unit: + name: Lint Build And Unit Coverage 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: - HTTP_PROXY: "" - GITEA_TEA_TEST_URL: "http://gitea:3000" - 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 @@ -30,17 +26,32 @@ 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 - - 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.24.5 + image: docker.gitea.com/gitea:1.26.1 cmd: - bash - -c 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/.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/.goreleaser.yaml b/.goreleaser.yaml index faafdb9..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 @@ -58,7 +56,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X code.gitea.io/tea/cmd.Version={{ .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/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 diff --git a/Makefile b/Makefile index dbb48c1..31ff016 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.9.2 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 ifneq ($(DRONE_TAG),) VERSION ?= $(subst v,,$(DRONE_TAG)) @@ -22,12 +25,15 @@ 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)' -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. @@ -49,7 +55,7 @@ clean: .PHONY: fmt fmt: - $(GOFMT) -w $(GOFILES) + $(GO) run $(GOFUMPT_PACKAGE) -w $(GOFILES) .PHONY: vet vet: @@ -60,21 +66,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 --build-tags testtools -.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 --build-tags testtools --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}"; \ @@ -94,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: @@ -123,11 +136,3 @@ $(EXECUTABLE): $(SOURCES) .PHONY: build-image 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/README.md b/README.md index 0c6acc9..6ca016d 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 @@ -170,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.go b/cmd/actions.go index 0b887c6..579eb9f 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -17,14 +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.CmdActionsJobs, - &actions.CmdActionsLogs, + &actions.CmdActionsWorkflows, }, Flags: []cli.Flag{ &cli.StringFlag{ diff --git a/cmd/actions/jobs.go b/cmd/actions/jobs.go deleted file mode 100644 index dd914b0..0000000 --- a/cmd/actions/jobs.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - stdctx "context" - "fmt" - - "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" -) - -// CmdActionsJobs represents the actions jobs command -var CmdActionsJobs = cli.Command{ - Name: "jobs", - Aliases: []string{"job"}, - Usage: "List jobs for a workflow run", - Description: "List jobs for a specific workflow run", - ArgsUsage: "", - Action: RunActionJobs, - Flags: flags.AllDefaultFlags, -} - -// RunActionJobs lists jobs for a workflow run -func RunActionJobs(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) - c.Ensure(context.CtxRequirement{RemoteRepo: true}) - - if cmd.Args().Len() < 1 { - return fmt.Errorf("run ID is required") - } - - runID, err := utils.ArgToIndex(cmd.Args().First()) - if err != nil { - return fmt.Errorf("invalid run ID: %w", err) - } - - client := api.NewClient(c.Login) - - path := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/jobs", - c.Owner, c.Repo, runID) - - var jobs api.ActionJobList - if _, err := client.Get(ctx, path, &jobs); err != nil { - return err - } - - print.ActionJobsList(jobs.Jobs, c.Output) - return nil -} diff --git a/cmd/actions/logs.go b/cmd/actions/logs.go deleted file mode 100644 index 34d0ab8..0000000 --- a/cmd/actions/logs.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - stdctx "context" - "fmt" - - "code.gitea.io/tea/cmd/flags" - "code.gitea.io/tea/modules/api" - "code.gitea.io/tea/modules/context" - "code.gitea.io/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdActionsLogs represents the actions logs command -var CmdActionsLogs = cli.Command{ - Name: "logs", - Aliases: []string{"log"}, - Usage: "Display logs for a job", - Description: "Display logs for a specific job", - ArgsUsage: "", - Action: RunActionLogs, - Flags: flags.AllDefaultFlags, -} - -// RunActionLogs displays logs for a job -func RunActionLogs(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) - c.Ensure(context.CtxRequirement{RemoteRepo: true}) - - if cmd.Args().Len() < 1 { - return fmt.Errorf("job ID is required") - } - - jobID, err := utils.ArgToIndex(cmd.Args().First()) - if err != nil { - return fmt.Errorf("invalid job ID: %w", err) - } - - client := api.NewClient(c.Login) - - path := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs", - c.Owner, c.Repo, jobID) - - logs, err := client.GetRaw(ctx, path) - if err != nil { - return err - } - - fmt.Print(string(logs)) - return nil -} diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go index 7daabae..10130cf 100644 --- a/cmd/actions/runs.go +++ b/cmd/actions/runs.go @@ -1,16 +1,12 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package actions import ( stdctx "context" - "fmt" - "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/cmd/actions/runs" "github.com/urfave/cli/v3" ) @@ -19,32 +15,17 @@ import ( var CmdActionsRuns = cli.Command{ Name: "runs", Aliases: []string{"run"}, - Usage: "List workflow runs", - Description: "List workflow runs for a repository", - Action: RunActionRuns, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), + 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, + }, } -// RunActionRuns lists workflow runs -func RunActionRuns(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) - c.Ensure(context.CtxRequirement{RemoteRepo: true}) - - client := api.NewClient(c.Login) - - path := fmt.Sprintf("/repos/%s/%s/actions/runs?page=%d&limit=%d", - c.Owner, c.Repo, - flags.GetListOptions().Page, - flags.GetListOptions().PageSize) - - var runs api.ActionRunList - if _, err := client.Get(ctx, path, &runs); err != nil { - return err - } - - print.ActionRunsList(runs.WorkflowRuns, c.Output) - return nil +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..6ca6954 --- /dev/null +++ b/cmd/actions/runs/delete.go @@ -0,0 +1,71 @@ +// 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, 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() + 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..73dba88 --- /dev/null +++ b/cmd/actions/runs/list.go @@ -0,0 +1,148 @@ +// 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, 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 + 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(cmd) + + 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 { + return print.ActionRunsList(nil, c.Output) + } + + // Filter by time if specified + filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until) + + return print.ActionRunsList(filteredRuns, c.Output) +} + +// 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..1486e54 --- /dev/null +++ b/cmd/actions/runs/list_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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) { + 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]) + } + } + }) + } +} + +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 new file mode 100644 index 0000000..bebcf63 --- /dev/null +++ b/cmd/actions/runs/logs.go @@ -0,0 +1,175 @@ +// 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, 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() + 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(cmd), + }) + 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..9bba204 --- /dev/null +++ b/cmd/actions/runs/view.go @@ -0,0 +1,83 @@ +// 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, 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() + 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(cmd), + }) + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + + if jobs != nil && len(jobs.Jobs) > 0 { + fmt.Printf("\nJobs:\n\n") + if err := print.ActionWorkflowJobsList(jobs.Jobs, c.Output); err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go index d573398..26e40e1 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 @@ -44,47 +40,29 @@ 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() - 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{ - Name: secretName, + _, err = client.CreateRepoActionSecret(c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{ Data: secretValue, }) if err != nil { diff --git a/cmd/actions/secrets/delete.go b/cmd/actions/secrets/delete.go index 255f24d..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() @@ -45,12 +51,12 @@ 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 } } - _, 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 cf73fb1..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() @@ -45,12 +51,12 @@ 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 } } - _, 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 03a4cac..9f2c568 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" ) @@ -42,43 +40,36 @@ 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() - 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/actions/workflows.go b/cmd/actions/workflows.go new file mode 100644 index 0000000..1dc593a --- /dev/null +++ b/cmd/actions/workflows.go @@ -0,0 +1,32 @@ +// 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, + &workflows.CmdWorkflowsView, + &workflows.CmdWorkflowsDispatch, + &workflows.CmdWorkflowsEnable, + &workflows.CmdWorkflowsDisable, + }, +} + +func runWorkflowsDefault(ctx stdctx.Context, cmd *cli.Command) error { + return workflows.RunWorkflowsList(ctx, cmd) +} 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 new file mode 100644 index 0000000..a661cbc --- /dev/null +++ b/cmd/actions/workflows/list.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" + + "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 workflows in the repository with their status", + Action: RunWorkflowsList, + Flags: flags.AllDefaultFlags, +} + +// 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 { + return err + } + if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + client := c.Login.Client() + + resp, _, err := client.ListRepoActionWorkflows(c.Owner, c.Repo) + if err != nil { + return fmt.Errorf("failed to list workflows: %w", err) + } + + var workflows []*gitea.ActionWorkflow + if resp != nil { + workflows = resp.Workflows + } + + 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/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 new file mode 100644 index 0000000..12c7feb --- /dev/null +++ b/cmd/api.go @@ -0,0 +1,408 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + 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" +) + +// 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"}, + 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.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"}, + 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)", + }, + } +} + +// 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...), +} + +type preparedAPIRequest struct { + Method string + Endpoint string + Headers map[string]string + Body []byte +} + +func runApi(_ 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 + } + + var body io.Reader + if request.Body != nil { + body = bytes.NewReader(request.Body) + } + + // Create API client and make request + client := api.NewClient(ctx.Login) + resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + 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") { + 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 func() { + if closeErr := file.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", closeErr) + } + }() + 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 +} + +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 +// - "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. + // 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 + 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 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 + } + + // 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 + } + + // 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 +} + +// 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/api_test.go b/cmd/api_test.go new file mode 100644 index 0000000..87b242e --- /dev/null +++ b/cmd/api_test.go @@ -0,0 +1,635 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + "encoding/json" + "io" + "os" + "path/filepath" + "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 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 capturedMethod string + var capturedBody []byte + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + 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: 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"}}, + &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) + + 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/cmd/attachments/create.go b/cmd/attachments/create.go index e970d8f..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" @@ -27,20 +28,25 @@ 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 { - 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 9abad42..b0182f0 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" @@ -32,17 +33,22 @@ 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 { - 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") { @@ -50,16 +56,24 @@ 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 } - 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:] { @@ -70,7 +84,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) @@ -81,21 +95,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/attachments/list.go b/cmd/attachments/list.go index be6c142..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" @@ -31,45 +32,31 @@ 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() 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 } 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 -} - -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") + return print.ReleaseAttachmentsList(attachments, ctx.Output) } 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/list.go b/cmd/branches/list.go index ce47330..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,19 +53,16 @@ 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 } @@ -70,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/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/cmd/clone.go b/cmd/clone.go index 7acb771..d65a5c2 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 { @@ -58,7 +61,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 ) @@ -73,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/cmd.go b/cmd/cmd.go index ac0a141..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, @@ -59,28 +47,13 @@ func App() *cli.Command { &CmdAdmin, + &CmdApi, &CmdGenerateManPage, }, EnableShellCompletion: true, } } -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'. @@ -90,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}} @@ -132,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/cmd/comment.go b/cmd/comment.go index a344dcd..cdccbe6 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,8 +19,7 @@ import ( "code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/utils" - "code.gitea.io/sdk/gitea" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/urfave/cli/v3" ) @@ -36,12 +36,17 @@ 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 { - 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/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/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 f942a70..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" @@ -39,16 +40,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 +80,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 +94,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 @@ -141,3 +157,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 "", fmt.Errorf("unknown state '%s'", 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 "", fmt.Errorf("unknown kind '%s'", kindStr) + } +} diff --git a/cmd/flags/generic_test.go b/cmd/flags/generic_test.go index 9f290b5..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" @@ -55,7 +56,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 +79,8 @@ func TestPaginationFlags(t *testing.T) { require.NoError(t, err) }) } - } + func TestPaginationFailures(t *testing.T) { cases := []struct { name string @@ -102,7 +103,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, @@ -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/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/issues.go b/cmd/issues.go index 41bd4c7..31d0a4b 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", @@ -81,14 +80,35 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error { } func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { - ctx := context.InitCommand(cmd) - 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 @@ -118,59 +138,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 42caaf6..ea6f1b8 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" @@ -32,10 +31,15 @@ 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 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..d88ff2c 100644 --- a/cmd/issues/create.go +++ b/cmd/issues/create.go @@ -26,10 +26,15 @@ 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.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/dependencies.go b/cmd/issues/dependencies.go index 199e7ff..e55d3fd 100644 --- a/cmd/issues/dependencies.go +++ b/cmd/issues/dependencies.go @@ -8,6 +8,7 @@ import ( stdctx "context" "encoding/json" "fmt" + "io" "strconv" "strings" @@ -68,7 +69,10 @@ var cmdDependenciesRemove = cli.Command{ // runDependenciesList lists dependencies for an issue func runDependenciesList(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 1 { @@ -83,8 +87,20 @@ func runDependenciesList(ctx stdctx.Context, cmd *cli.Command) error { 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 - if _, err := client.Get(ctx, path, &deps); err != nil { + decoder := json.NewDecoder(resp.Body) + if err := decoder.Decode(&deps); err != nil { return err } @@ -99,7 +115,10 @@ func runDependenciesList(ctx stdctx.Context, cmd *cli.Command) error { // runDependenciesAdd adds a dependency to an issue func runDependenciesAdd(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 2 { @@ -130,9 +149,16 @@ func runDependenciesAdd(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("failed to marshal request: %w", err) } - if _, err := client.Post(ctx, path, bytes.NewReader(body), nil); err != nil { + 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) @@ -144,7 +170,10 @@ func runDependenciesAdd(ctx stdctx.Context, cmd *cli.Command) error { // runDependenciesRemove removes a dependency from an issue func runDependenciesRemove(ctx stdctx.Context, cmd *cli.Command) error { - c := context.InitCommand(cmd) + c, err := context.InitCommand(cmd) + if err != nil { + return err + } c.Ensure(context.CtxRequirement{RemoteRepo: true}) if cmd.Args().Len() < 2 { @@ -175,9 +204,16 @@ func runDependenciesRemove(ctx stdctx.Context, cmd *cli.Command) error { return fmt.Errorf("failed to marshal request: %w", err) } - if _, err := client.Delete(ctx, path, bytes.NewReader(body)); err != nil { + 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) diff --git a/cmd/issues/edit.go b/cmd/issues/edit.go index 2e49437..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") @@ -49,7 +54,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..4b2652e 100644 --- a/cmd/issues/list.go +++ b/cmd/issues/list.go @@ -5,7 +5,7 @@ package issues import ( stdctx "context" - "fmt" + "errors" "time" "code.gitea.io/tea/cmd/flags" @@ -34,33 +34,21 @@ var CmdIssuesList = cli.Command{ // RunIssuesList list issues 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")) + ctx, err := context.InitCommand(cmd) + 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")) + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + + 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")) @@ -85,30 +73,31 @@ 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"), CreatedBy: ctx.String("author"), - AssignedBy: ctx.String("assigned-to"), + AssignedBy: ctx.String("assignee"), MentionedBy: ctx.String("mentions"), Labels: labels, Milestones: milestones, Since: from, Before: until, }) - if err != nil { 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(), + 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, @@ -116,7 +105,6 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { Before: until, Owner: owner, }) - if err != nil { return err } @@ -127,6 +115,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/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..6c8bf75 100644 --- a/cmd/issues_test.go +++ b/cmd/issues_test.go @@ -7,12 +7,11 @@ import ( "bytes" "encoding/json" "fmt" - "net/http" - "net/http/httptest" "testing" "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" @@ -25,8 +24,53 @@ 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 { - var issue = gitea.Issue{ + issue := gitea.Issue{ ID: 42, Index: 1, Title: "Test issue", @@ -55,11 +99,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 +114,6 @@ func createTestIssue(comments int, isClosed bool) gitea.Issue { } return issue - } func createTestIssueComments(comments int) []gitea.Comment { @@ -90,7 +133,6 @@ func createTestIssueComments(comments int) []gitea.Comment { } return result - } func TestRunIssueDetailAsJSON(t *testing.T) { @@ -99,9 +141,6 @@ func TestRunIssueDetailAsJSON(t *testing.T) { issue gitea.Issue comments []gitea.Comment flagComments bool - flagOutput string - flagOut string - closed bool } cmd := cli.Command{ @@ -163,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 @@ -190,22 +215,25 @@ 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() require.NotEmpty(t, out, "Unexpected empty output from runIssueDetailAsJSON") - //setting expectations + // setting expectations var expectedLabels []labelData expectedLabels = []labelData{} @@ -266,5 +294,65 @@ func TestRunIssueDetailAsJSON(t *testing.T) { assert.Equal(t, expected, actual, "Expected structs differ from expected one") }) } - +} + +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", + } + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "testLogin", + URL: "https://gitea.example.com", + 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("comments", "false")) + + 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.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/labels/create.go b/cmd/labels/create.go index ec72b32..5320521 100644 --- a/cmd/labels/create.go +++ b/cmd/labels/create.go @@ -46,44 +46,52 @@ 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") - 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/delete.go b/cmd/labels/delete.go index 0719fef..1199dcc 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,17 +22,37 @@ 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...), } 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 + } - _, 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/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 3f20862..9e2cb1d 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", }, @@ -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,13 +66,11 @@ 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, Description: pDescription, }) - if err != nil { return err } 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/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 14dd60d..174c85f 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -7,13 +7,10 @@ import ( "bufio" "context" "fmt" - "log" "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 +56,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) @@ -88,16 +92,35 @@ var CmdLoginHelper = cli.Command{ } if len(wants["host"]) == 0 { - log.Fatal("Require hostname") + return fmt.Errorf("hostname is required") } else if len(wants["protocol"]) == 0 { wants["protocol"] = "http" } - userConfig := config.GetLoginByHost(wants["host"]) - if userConfig == nil { - log.Fatal("host not exists") - } else if len(userConfig.Token) == 0 { - log.Fatal("User no set") + // Use --login flag if provided, otherwise fall back to host lookup + var userConfig *config.Login + if loginName := cmd.String("login"); loginName != "" { + var lookupErr error + userConfig, lookupErr = config.GetLoginByName(loginName) + if lookupErr != nil { + return lookupErr + } + if userConfig == nil { + return fmt.Errorf("login '%s' not found", loginName) + } + } else { + var lookupErr error + userConfig, lookupErr = config.GetLoginByHost(wants["host"]) + if lookupErr != nil { + return lookupErr + } + if userConfig == nil { + return fmt.Errorf("no login found for host '%s'", wants["host"]) + } + } + + if len(userConfig.GetAccessToken()) == 0 { + return fmt.Errorf("user not set") } host, err := url.Parse(userConfig.URL) @@ -105,21 +128,12 @@ 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) + _, 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/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 ec4dc27..af6012a 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, } @@ -38,22 +38,34 @@ 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) } // 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) } - // Refresh the token - err := auth.RefreshAccessToken(login) - if err != nil { - return fmt.Errorf("failed to refresh token: %s", err) + // Try to refresh the token + err = auth.RefreshAccessToken(login) + 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/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) + }) }, } 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 e6d1865..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{} @@ -67,7 +70,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/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 7d056ba..2f89bac 100644 --- a/cmd/milestones/issues.go +++ b/cmd/milestones/issues.go @@ -71,39 +71,38 @@ 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 := 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 } issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), Milestones: []string{milestone}, Type: kind, State: state, @@ -116,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") @@ -138,18 +141,26 @@ 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 { - 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") @@ -159,25 +170,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/milestones/list.go b/cmd/milestones/list.go index e229c56..2149a67 100644 --- a/cmd/milestones/list.go +++ b/cmd/milestones/list.go @@ -40,35 +40,35 @@ 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 { 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() 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 077ff64..595eb07 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" @@ -30,10 +29,15 @@ 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 errors.New(ctx.Command.ArgsUsage) + return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) } state := gitea.StateOpen @@ -42,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, @@ -53,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 a9d1525..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) } @@ -130,8 +144,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/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 523d03c..94be20c 100644 --- a/cmd/organizations/create.go +++ b/cmd/organizations/create.go @@ -53,10 +53,13 @@ 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("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..b88f75d 100644 --- a/cmd/organizations/delete.go +++ b/cmd/organizations/delete.go @@ -28,17 +28,20 @@ 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() 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()) 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 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 e4defa9..5abbc71 100644 --- a/cmd/pulls.go +++ b/cmd/pulls.go @@ -6,18 +6,50 @@ package cmd import ( stdctx "context" "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/tea/modules/workaround" - "code.gitea.io/sdk/gitea" "github.com/urfave/cli/v3" ) +type pullLabelData = detailLabelData + +type pullReviewData = detailReviewData + +type pullCommentData = detailCommentData + +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", @@ -40,10 +72,14 @@ var CmdPulls = cli.Command{ &pulls.CmdPullsCreate, &pulls.CmdPullsClose, &pulls.CmdPullsReopen, + &pulls.CmdPullsEdit, &pulls.CmdPullsReview, &pulls.CmdPullsApprove, &pulls.CmdPullsReject, &pulls.CmdPullsMerge, + &pulls.CmdPullsReviewComments, + &pulls.CmdPullsResolve, + &pulls.CmdPullsUnresolve, }, } @@ -55,8 +91,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 @@ -67,15 +108,28 @@ 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 + + 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 } - 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) + 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) @@ -94,3 +148,49 @@ 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(ctx.Command)} + + 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: username(pr.Poster), + Body: pr.Body, + Labels: buildDetailLabels(pr.Labels), + Assignees: buildDetailAssignees(pr.Assignees), + 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: buildDetailReviews(reviews), + 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 = buildDetailComments(comments) + } + + return writeIndentedJSON(ctx.Writer, pullSlice) +} diff --git a/cmd/pulls/approve.go b/cmd/pulls/approve.go index e691c4b..ba3b0ed 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" ) @@ -25,21 +20,11 @@ var CmdPullsApprove = cli.Command{ Description: "Approve a pull request", 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()) + ctx, err := context.InitCommand(cmd) 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..1219a5d 100644 --- a/cmd/pulls/checkout.go +++ b/cmd/pulls/checkout.go @@ -34,13 +34,18 @@ 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("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..05c1e85 100644 --- a/cmd/pulls/clean.go +++ b/cmd/pulls/clean.go @@ -32,10 +32,15 @@ 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("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..c0f74d7 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -37,18 +37,31 @@ 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...), } 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.NumFlags() == 0 { + if ctx.IsInteractiveMode() { if err := interact.CreatePull(ctx); err != nil && !interact.IsQuitting(err) { return err } @@ -61,6 +74,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/cmd/pulls/edit.go b/cmd/pulls/edit.go index 615dbef..b9090b0 100644 --- a/cmd/pulls/edit.go +++ b/cmd/pulls/edit.go @@ -6,21 +6,97 @@ 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 := 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("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..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" @@ -30,24 +32,24 @@ 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}) - - state := gitea.StateOpen - switch ctx.String("state") { - case "all": - state = gitea.StateAll - case "open": - state = gitea.StateOpen - case "closed": - state = gitea.StateClosed + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err } - prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ - ListOptions: flags.GetListOptions(), + state, err := flags.ParseState(ctx.String("state")) + if err != nil { + return err + } + + client := ctx.Login.Client() + prs, _, err := client.ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ + ListOptions: flags.GetListOptions(cmd), State: state, }) - if err != nil { return err } @@ -57,6 +59,21 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { return err } - print.PullsList(prs, ctx.Output, fields) - return nil + 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/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 277fcd3..67f2a70 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" ) @@ -24,21 +19,11 @@ 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.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()) + ctx, err := context.InitCommand(cmd) 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/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.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_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 new file mode 100644 index 0000000..ba844a6 --- /dev/null +++ b/cmd/pulls/review_helpers.go @@ -0,0 +1,60 @@ +// 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 { + if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { + return err + } + + 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) +} + +// 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/cmd/releases/create.go b/cmd/releases/create.go index ba1b345..bb525b5 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() { @@ -99,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 a3acc8f..9d6974d 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() { @@ -50,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 b1378ff..cc53078 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 @@ -76,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 431e43a..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" @@ -31,34 +30,20 @@ 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 -} - -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") + return print.ReleasesList(releases, ctx.Output) } diff --git a/cmd/releases/utils.go b/cmd/releases/utils.go new file mode 100644 index 0000000..c27618c --- /dev/null +++ b/cmd/releases/utils.go @@ -0,0 +1,35 @@ +// 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) { + 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/cmd/repos.go b/cmd/repos.go index 2ab1ab7..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{ @@ -32,6 +32,7 @@ var CmdRepos = cli.Command{ &repos.CmdRepoFork, &repos.CmdRepoMigrate, &repos.CmdRepoRm, + &repos.CmdRepoEdit, }, Flags: repos.CmdReposListFlags, } @@ -44,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 da44a33..abb9f89 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" ) @@ -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 new file mode 100644 index 0000000..22a3a55 --- /dev/null +++ b/cmd/repos/edit.go @@ -0,0 +1,111 @@ +// 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, 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{} + + 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/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 bea0243..56be1d9 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, @@ -50,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) @@ -59,25 +70,53 @@ 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(cmd), + }) + } else { + rps, _, err = client.ListOrgRepos(owner, gitea.ListOrgReposOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + } + if err != nil { + return err + } + } else if teaCmd.Bool("starred") { user, _, err := client.GetMyUserInfo() if err != nil { return err } rps, _, err = client.SearchRepos(gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), StarredByUserID: user.ID, }) + if err != nil { + return err + } } else if teaCmd.Bool("watched") { - 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(cmd)) } else { + var err error rps, _, err = client.ListMyRepos(gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(), + ListOptions: flags.GetListOptions(cmd), }) - } - - if err != nil { - return err + if err != nil { + return err + } } reposFiltered := rps @@ -90,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 { @@ -116,3 +154,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/cmd/repos/migrate.go b/cmd/repos/migrate.go index af73ecb..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 ) @@ -157,7 +159,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..d4bb0fd 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" @@ -57,17 +58,19 @@ 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 if teaCmd.IsSet("owner") { - // test if owner is a organisation - org, _, err := client.GetOrg(teaCmd.String("owner")) + // test if owner is an organization + 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 @@ -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..2877218 100644 --- a/cmd/times/add.go +++ b/cmd/times/add.go @@ -32,11 +32,16 @@ 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) + 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 d0b57af..cbb9284 100644 --- a/cmd/times/delete.go +++ b/cmd/times/delete.go @@ -26,12 +26,17 @@ 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 { - 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/list.go b/cmd/times/list.go index 0e8d0e9..9eec82c 100644 --- a/cmd/times/list.go +++ b/cmd/times/list.go @@ -65,17 +65,23 @@ 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...), } // 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 @@ -92,11 +98,15 @@ func RunTimesList(_ stdctx.Context, cmd *cli.Command) error { } } - opts := gitea.ListTrackedTimesOptions{Since: from, Before: until} + opts := gitea.ListTrackedTimesOptions{ + ListOptions: flags.GetListOptions(cmd), + 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 +114,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"} @@ -127,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..08f8f93 100644 --- a/cmd/times/reset.go +++ b/cmd/times/reset.go @@ -25,12 +25,17 @@ 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 { - 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/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..d669436 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")) @@ -86,31 +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 - var err error 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/delete.go b/cmd/webhooks/delete.go index 18226d7..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()) @@ -63,7 +66,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/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..e37fcb4 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()) @@ -94,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 @@ -123,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/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/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/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 diff --git a/go.mod b/go.mod index 8665cc0..c3b3bbb 100644 --- a/go.mod +++ b/go.mod @@ -1,97 +1,102 @@ module code.gitea.io/tea -go 1.24.4 +go 1.26 require ( + charm.land/glamour/v2 v2.0.0 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.3 code.gitea.io/gitea-vet v0.2.3 - code.gitea.io/sdk/gitea v0.22.1 + 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 - 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/enescakir/emoji v1.0.0 - github.com/go-git/go-git/v5 v5.16.4 + 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.1 + 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 - github.com/urfave/cli/v3 v3.6.1 - golang.org/x/crypto v0.45.0 - golang.org/x/oauth2 v0.33.0 - golang.org/x/term v0.37.0 + github.com/urfave/cli/v3 v3.8.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.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/42wim/httpsig v1.2.3 // 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.1.6 // indirect - github.com/alecthomas/chroma/v2 v2.14.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 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.2.3-0.20250311203215-f60798e515dc // 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.3.1 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.2.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/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.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 + github.com/charmbracelet/x/term v0.2.2 // 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 + 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.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/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.6.2 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // 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.7.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.2.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.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.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-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // 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/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.1.0 // indirect - github.com/olekukonko/ll v0.1.2 // indirect - github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/olekukonko/errors v1.2.0 // 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 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.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 + github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // 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.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 19f8636..8478534 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,40 @@ +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= +charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= +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.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= -code.gitea.io/sdk/gitea v0.22.1/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= +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= 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.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.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -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/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +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.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= @@ -33,65 +45,63 @@ 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-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -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/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/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/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +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-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/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= 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/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/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/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-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= +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.3.1 h1:k07iN9gD32177o1y4O1jQMzbLdCrsGJh+blirVYybsk= -github.com/clipperhouse/displaywidth v0.3.1/go.mod h1:tgLJKKyaDOCadywag3agw4snxS5kYEuYR6Y9+qWDDYM= -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/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= -github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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= +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/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= 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= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -100,36 +110,50 @@ 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/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.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-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= 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.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.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.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= +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/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.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= 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.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= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -137,48 +161,40 @@ 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.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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +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.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.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= 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.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/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/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.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= 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= 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= @@ -188,14 +204,16 @@ 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= 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= @@ -203,43 +221,44 @@ 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.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= 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-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= -github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +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.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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -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/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= 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.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= @@ -247,24 +266,22 @@ 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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.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.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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +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= diff --git a/main.go b/main.go index cc38c38..1ad0405 100644 --- a/main.go +++ b/main.go @@ -6,21 +6,40 @@ 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" ) 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 { + 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) 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 index 1f2649c..853a2a0 100644 --- a/modules/api/client.go +++ b/modules/api/client.go @@ -1,144 +1,106 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. +// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package api import ( - "context" "crypto/tls" - "encoding/json" "fmt" "io" + "log" "net/http" + "net/url" + "strings" "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/httputil" ) -// Client provides methods for making raw API calls to Gitea +// Client provides direct HTTP access to Gitea API type Client struct { - login *config.Login + baseURL string + token string httpClient *http.Client } -// NewClient creates a new API client from a login +// NewClient creates a new API client from a Login config func NewClient(login *config.Login) *Client { - httpClient := &http.Client{} - if login.Insecure { - httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - } + // 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: httputil.WrapTransport(&http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure}, + }), + } + return &Client{ - login: login, + baseURL: strings.TrimSuffix(login.URL, "/"), + token: login.GetAccessToken(), httpClient: httpClient, } } -// Get makes an authenticated GET request to the API and decodes the JSON response -func (c *Client) Get(ctx context.Context, path string, result interface{}) (*http.Response, error) { - url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) +// 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.NewRequestWithContext(ctx, "GET", url, nil) + req, err := http.NewRequest(method, reqURL, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Authorization", "token "+c.login.Token) - req.Header.Set("Accept", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return resp, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + // Set authentication header + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) } - if result != nil { - if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - return resp, fmt.Errorf("failed to decode response: %w", err) - } - } - - return resp, nil -} - -// GetRaw makes an authenticated GET request and returns the raw response body -func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) { - url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "token "+c.login.Token) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - return body, nil -} - -// Post makes an authenticated POST request to the API and decodes the JSON response -func (c *Client) Post(ctx context.Context, path string, body io.Reader, result interface{}) (*http.Response, error) { - return c.doRequest(ctx, "POST", path, body, result) -} - -// Delete makes an authenticated DELETE request to the API -func (c *Client) Delete(ctx context.Context, path string, body io.Reader) (*http.Response, error) { - return c.doRequest(ctx, "DELETE", path, body, nil) -} - -// doRequest performs an HTTP request with the given method -func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader, result interface{}) (*http.Response, error) { - url := fmt.Sprintf("%s/api/v1%s", c.login.URL, path) - - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "token "+c.login.Token) - req.Header.Set("Accept", "application/json") + // Set default content type for requests with body if body != nil { req.Header.Set("Content-Type", "application/json") } - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) - return resp, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(respBody)) + // Apply custom headers (can override defaults) + for key, value := range headers { + req.Header.Set(key, value) } - if result != nil { - if err := json.NewDecoder(resp.Body).Decode(result); err != nil { - return resp, fmt.Errorf("failed to decode response: %w", err) - } - } - - return resp, nil + 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..e1fd47c 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" @@ -27,9 +28,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 +63,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, } @@ -74,15 +72,27 @@ 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 = defaultClientID + opts.ClientID = config.DefaultClientID } // If the redirect URL is specified, parse it to extract port if needed @@ -110,7 +120,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) @@ -121,8 +131,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, @@ -144,7 +154,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 @@ -159,7 +169,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") @@ -168,35 +178,30 @@ 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 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 @@ -221,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) @@ -306,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) @@ -372,22 +375,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() @@ -395,6 +393,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 @@ -410,59 +411,50 @@ 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 } -// 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() +} + +// 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 + } + + 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 + } + if !token.Expiry.IsZero() { + login.TokenExpiry = token.Expiry.Unix() + } + return config.SaveLoginTokens(login) } diff --git a/modules/config/config.go b/modules/config/config.go index 30b59f3..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" @@ -40,12 +39,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 @@ -65,12 +73,20 @@ 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 } +// 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() @@ -83,22 +99,55 @@ 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 } } }) 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 +} + +// 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 { ymlPath := GetConfigPath() bs, err := yaml.Marshal(config) if err != nil { 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/lock.go b/modules/config/lock.go new file mode 100644 index 0000000..7a5a032 --- /dev/null +++ b/modules/config/lock.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "sync" + "time" + + "code.gitea.io/tea/modules/filelock" +) + +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 +) + +// 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 + remaining := max(time.Until(deadline), 0) + locker := filelock.New(lockPath, remaining) + + fileUnlock, err := locker.Acquire() + if err != nil { + configMutex.Unlock() + return nil, err + } + + // Return unlock function + return func() error { + unlockErr := fileUnlock() + configMutex.Unlock() + return unlockErr + }, 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..2dde519 --- /dev/null +++ b/modules/config/lock_test.go @@ -0,0 +1,196 @@ +// 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 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 + + 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) + } + + err := reloadConfigFromDisk() + if err != nil { + 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 + config = originalConfig +} + +func TestWithConfigLock(t *testing.T) { + useTempConfigPath(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) { + useTempConfigPath(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) { + useTempConfigPath(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/login.go b/modules/config/login.go index fb71b9e..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" @@ -18,17 +17,29 @@ 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" + + "charm.land/huh/v2" "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" + +// 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 @@ -43,10 +54,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 @@ -64,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 { @@ -77,192 +144,286 @@ 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) -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.ToLower(l.Name) == strings.ToLower(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 -func GetLoginByToken(token string) *Login { - err := loadConfig() - if err != nil { - log.Fatal(err) +func GetLoginByToken(token string) (*Login, error) { + if token == "" { + return nil, nil + } + 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 { - err := loadConfig() +// GetLoginByHost finds a login by its server URL +func GetLoginByHost(host string) (*Login, error) { + logins, err := GetLoginsByHost(host) if err != nil { - log.Fatal(err) + return nil, err + } + if len(logins) > 0 { + return logins[0], nil + } + return nil, nil +} + +// GetLoginsByHost returns all logins matching a host +func GetLoginsByHost(host string) ([]*Login, error) { + if err := loadConfig(); err != nil { + return nil, 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) + return nil, err } if loginURL.Host == host { - return &l + matches = append(matches, &config.Logins[i]) } } - return nil + return matches, nil } // 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:]...) + isOAuth := config.Logins[idx].IsOAuth() + config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...) - return saveConfig() + // Clean up credstore tokens for OAuth logins + if isOAuth { + _ = DeleteOAuthToken(name) + } + + return saveConfigUnsafe() + }) } // AddLogin save a login to config func AddLogin(login *Login) error { - if err := loadConfig(); err != nil { - return err - } + 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 +// 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()) } - - // 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 + 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) + }) +} + +// 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 + } + if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) { + 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. +// 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.GetRefreshToken() == "" { + return fmt.Errorf("no refresh token available") } - // Save updated config - return saveConfig() + 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 + 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 + 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 + } + } + + // Still need to refresh - proceed with OAuth call + newToken, err := doOAuthRefresh(l) + if err != nil { + return err + } + + 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 + } + if !newToken.Expiry.IsZero() { + l.TokenExpiry = newToken.Expiry.Unix() + } + 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) { + // 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: accessToken, + RefreshToken: refreshToken, + Expiry: expiry, + } + + ctx := context.Background() + + httpClient := &http.Client{ + Transport: httputil.WrapTransport(&http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure}, + }), + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + oauth2Config := &oauth2.Config{ + ClientID: DefaultClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), + }, + } + + newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token() + if err != nil { + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + + return newToken, nil } // 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 { + 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, @@ -277,18 +438,24 @@ 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.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) if debug.IsDebug() { options = append(options, gitea.SetDebugMode()) } 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)) } @@ -296,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/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.go b/modules/context/context.go index 1a2a61d..7255c68 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -6,25 +6,24 @@ package context import ( "errors" "fmt" - "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" - "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" ) -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") // TeaContext contains all context derived during command initialization and wraps cli.Context type TeaContext struct { @@ -41,51 +40,28 @@ 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 } -// 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) - } +// 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 } -// 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 +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 // 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") @@ -103,10 +79,12 @@ 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 + } else { + c.RepoSlug = repoFlag } } @@ -114,68 +92,74 @@ func InitCommand(cmd *cli.Command) *TeaContext { remoteFlag = config.GetPreferences().FlagDefaults.Remote } - if repoPath == "" { - if repoPath, err = os.Getwd(); err != nil { - log.Fatal(err.Error()) + // 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 { + return nil, err } + 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 err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { - // we can deal with that, commands needing the optional values use ctx.Ensure() - } else { - log.Fatal(err.Error()) + if c.RepoSlug == "" { + if repoPath == "" { + if repoPath, err = os.Getwd(); err != nil { + return nil, err + } + } + + 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 len(repoFlag) != 0 && !repoFlagPathExists { - // if repoFlag is not a valid path, use it to override repoSlug - 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, "", "") - if err != nil { - log.Fatal(err.Error()) - } c.Login = envLogin } // 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 - 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)). 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) } } @@ -185,154 +169,5 @@ and then run your command again.`) c.IsGlobal = globalFlag c.Command = cmd 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, - } + return &c, nil } diff --git a/modules/context/context_login.go b/modules/context/context_login.go new file mode 100644 index 0000000..76223d1 --- /dev/null +++ b/modules/context/context_login.go @@ -0,0 +1,51 @@ +// 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") + giteaInstanceSSHHost := os.Getenv("GITEA_INSTANCE_SSH_HOST") + 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, + SSHHost: giteaInstanceSSHHost, + Insecure: insecure, + SSHKey: "", + SSHCertPrincipal: "", + SSHKeyFingerprint: "", + SSHAgent: false, + Created: time.Now().Unix(), + VersionCheck: false, + } +} 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) + } + }) + } +} 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..ca44bfb --- /dev/null +++ b/modules/context/context_repo.go @@ -0,0 +1,83 @@ +// 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, extraLogins []config.Login) (*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 + } + // 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 + } + } + + return repo, nil, "", errNotAGiteaRepo +} diff --git a/modules/context/context_require.go b/modules/context/context_require.go new file mode 100644 index 0000000..93a94df --- /dev/null +++ b/modules/context/context_require.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "errors" +) + +// Ensure checks if requirements on the context are set. +func (ctx *TeaContext) Ensure(req CtxRequirement) error { + if req.LocalRepo && ctx.LocalRepo == nil { + return errors.New("local repository required: execute from a repo dir, or specify a path with --repo") + } + + if req.RemoteRepo && len(ctx.RepoSlug) == 0 { + return errors.New("remote repository required: specify id via --repo or execute from a local git repo") + } + + if req.Org && len(ctx.Org) == 0 { + return errors.New("organization required: specify organization via --org") + } + + if req.Global && !ctx.IsGlobal { + return errors.New("global scope required: specify --global") + } + + return nil +} + +// 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 +} 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/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) + } }) } } 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/filelock/filelock_unix.go b/modules/filelock/filelock_unix.go new file mode 100644 index 0000000..d5f165d --- /dev/null +++ b/modules/filelock/filelock_unix.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build unix + +package filelock + +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/filelock/filelock_windows.go b/modules/filelock/filelock_windows.go new file mode 100644 index 0000000..89ed1df --- /dev/null +++ b/modules/filelock/filelock_windows.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build windows + +package filelock + +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/git/branch.go b/modules/git/branch.go index d112d29..6c709ad 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" @@ -78,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 @@ -131,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 @@ -143,7 +145,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() @@ -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/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/interact/comments.go b/modules/interact/comments.go index 42c7dcc..844153d 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" ) @@ -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/issue_create.go b/modules/interact/issue_create.go index 4d23e9c..3e478ee 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 @@ -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/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 a8a3b75..a6aef27 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 @@ -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 @@ -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?"). @@ -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/interact/milestone_create.go b/modules/interact/milestone_create.go index 15166e8..4f2240f 100644 --- a/modules/interact/milestone_create.go +++ b/modules/interact/milestone_create.go @@ -7,12 +7,12 @@ 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" - "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..539be86 100644 --- a/modules/interact/prompts.go +++ b/modules/interact/prompts.go @@ -11,7 +11,8 @@ 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 6233405..3bd19d2 100644 --- a/modules/interact/pull_create.go +++ b/modules/interact/pull_create.go @@ -7,8 +7,9 @@ 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" + "charm.land/huh/v2" ) // CreatePull interactively creates a PR @@ -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/interact/pull_merge.go b/modules/interact/pull_merge.go index a47264e..f3d9973 100644 --- a/modules/interact/pull_merge.go +++ b/modules/interact/pull_merge.go @@ -7,19 +7,19 @@ 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" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) // 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() @@ -44,18 +44,21 @@ 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..." // 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") + return 0, fmt.Errorf("no open PRs found") } opts.ListOptions.Page++ prOptions := make([]string, 0) diff --git a/modules/interact/pull_review.go b/modules/interact/pull_review.go index dd48c4d..6975fa0 100644 --- a/modules/interact/pull_review.go +++ b/modules/interact/pull_review.go @@ -8,13 +8,13 @@ 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" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" ) var reviewStates = map[string]gitea.ReviewStateType{ diff --git a/modules/print/actions.go b/modules/print/actions.go index 49f95d4..d2bb232 100644 --- a/modules/print/actions.go +++ b/modules/print/actions.go @@ -7,11 +7,10 @@ import ( "fmt" "code.gitea.io/sdk/gitea" - "code.gitea.io/tea/modules/api" ) // 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", @@ -28,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 @@ -44,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", @@ -69,100 +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) -} - -// ActionRunsList prints a list of workflow runs -func ActionRunsList(runs []*api.ActionRun, output string) { - t := table{ - headers: []string{ - "ID", - "Title", - "Status", - "Conclusion", - "Event", - "Branch", - "Started", - }, - } - - for _, run := range runs { - conclusion := run.Conclusion - if conclusion == "" { - conclusion = "-" - } - - started := "" - if run.StartedAt != nil { - started = FormatTime(*run.StartedAt, output != "") - } - - t.addRow( - fmt.Sprintf("%d", run.ID), - run.Title, - run.Status, - conclusion, - run.Event, - run.HeadBranch, - started, - ) - } - - if len(runs) == 0 { - fmt.Printf("No workflow runs found\n") - return - } - - t.print(output) -} - -// ActionJobsList prints a list of jobs -func ActionJobsList(jobs []*api.ActionJob, output string) { - t := table{ - headers: []string{ - "ID", - "Name", - "Status", - "Conclusion", - "Started", - "Completed", - }, - } - - for _, job := range jobs { - conclusion := job.Conclusion - if conclusion == "" { - conclusion = "-" - } - - started := "" - if job.StartedAt != nil { - started = FormatTime(*job.StartedAt, output != "") - } - - completed := "" - if job.CompletedAt != nil { - completed = FormatTime(*job.CompletedAt, output != "") - } - - t.addRow( - fmt.Sprintf("%d", job.ID), - job.Name, - job.Status, - conclusion, - started, - completed, - ) - } - - if len(jobs) == 0 { - fmt.Printf("No jobs found\n") - return - } - - t.print(output) + return t.print(output) } diff --git a/modules/print/actions_runs.go b/modules/print/actions_runs.go new file mode 100644 index 0000000..aaf3799 --- /dev/null +++ b/modules/print/actions_runs.go @@ -0,0 +1,215 @@ +// 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) error { + 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 nil + } + + t.sort(0, true) + return 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) error { + 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 nil + } + + t.sort(0, true) + return t.print(output) +} + +// ActionWorkflowsList prints a list of workflows from the workflow API +func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error { + t := table{ + headers: []string{ + "ID", + "Name", + "Path", + "State", + }, + } + + for _, wf := range workflows { + t.addRow( + wf.ID, + wf.Name, + wf.Path, + wf.State, + ) + } + + if len(workflows) == 0 { + fmt.Printf("No workflows found\n") + return nil + } + + 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 new file mode 100644 index 0000000..5a3a263 --- /dev/null +++ b/modules/print/actions_runs_test.go @@ -0,0 +1,256 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "testing" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/stretchr/testify/require" +) + +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) + } + }() + + require.NoError(t, 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) + } + }() + + require.NoError(t, 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) + } + }() + + require.NoError(t, 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) + } + }() + + 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() + + 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) + } + }) + } +} 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/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/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 487a782..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) { - var printables = make([]printable, len(news)) +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 1c05b97..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 { @@ -111,7 +128,6 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string { reviewByUserOrTeam[fmt.Sprintf("team_%d", review.ReviewerTeam.ID)] = review } } - } } @@ -139,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) { - 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() @@ -169,11 +185,12 @@ var PullFields = []string{ "milestone", "labels", "comments", + "ci", } -func printPulls(pulls []*gitea.PullRequest, output string, fields []string) { +func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error { labelMap := map[int64]string{} - var printables = make([]printable, len(pulls)) + printables := make([]printable, len(pulls)) machineReadable := isMachineReadable(output) for i, x := range pulls { @@ -184,16 +201,17 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) { } } // store items with printable interface - printables[i] = &printablePull{x, &labelMap} + printables[i] = &printablePull{x, &labelMap, &ciStatuses} } t := tableFromItems(fields, printables, machineReadable) - t.print(output) + return t.print(output) } type printablePull struct { *gitea.PullRequest formattedLabels *map[int64]string + ciStatuses *map[int64]*gitea.CombinedStatus } func (x printablePull) FormatField(field string, machineReadable bool) string { @@ -227,13 +245,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) } @@ -253,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_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/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'") +} 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..41dd43f 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,58 @@ 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 { - intVal, _ := strconv.Atoi(val) - if strconv.Itoa(intVal) == val { - fmt.Fprintf(f, " %s: %s\n", headers[j], val) + row.Content = append(row.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: headers[j], + }) + + valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val} + if _, err := strconv.ParseInt(val, 10, 64); err == nil { + 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 +178,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 d5022cc..cb3b118 100644 --- a/modules/print/times.go +++ b/modules/print/times.go @@ -10,8 +10,8 @@ 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)) +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 { totalDuration += t.Time @@ -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 924428b..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) { - var printables = make([]printable, len(user)) +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..577bddc 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 @@ -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 b7746d3..017379c 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) { @@ -88,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, }, }, { @@ -245,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", @@ -348,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 @@ -386,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) } 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/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..79fc672 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" ) @@ -11,16 +13,88 @@ 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 } + +// 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/login_create.go b/modules/task/login_create.go index 44f3956..2d54c48 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 @@ -51,16 +52,24 @@ 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 - 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 - 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) { + 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) + } } serverURL, err := utils.ValidateAuthenticationMethod( @@ -68,6 +77,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 +104,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 } @@ -138,6 +150,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)} @@ -146,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" @@ -189,7 +217,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 } } 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) + } + }) + } +} 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/milestone_create.go b/modules/task/milestone_create.go index d08e778..d6ee093 100644 --- a/modules/task/milestone_create.go +++ b/modules/task/milestone_create.go @@ -15,10 +15,9 @@ 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") + return fmt.Errorf("title is required") } mile, _, err := login.Client().CreateMilestone(repoOwner, repoName, gitea.CreateMilestoneOption{ diff --git a/modules/task/pull_checkout.go b/modules/task/pull_checkout.go index aadbafe..d73a2ec 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() @@ -89,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 9c5f78f..5b3512b 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") @@ -43,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) @@ -66,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) @@ -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.GetAccessToken(), 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/pull_create.go b/modules/task/pull_create.go index b07a28a..58c56bb 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -153,3 +153,68 @@ 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.GetAccessToken(), ctx.Login.SSHKey, callback) + if err != nil { + return err + } + + return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth) +} 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 +} 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/task/pull_review_comment.go b/modules/task/pull_review_comment.go new file mode 100644 index 0000000..1dea8fd --- /dev/null +++ b/modules/task/pull_review_comment.go @@ -0,0 +1,68 @@ +// 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() + + 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 + 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 +} diff --git a/modules/task/repo_clone.go b/modules/task/repo_clone.go index 8461e81..b23af21 100644 --- a/modules/task/repo_clone.go +++ b/modules/task/repo_clone.go @@ -35,12 +35,12 @@ 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 } - // default path behaviour as native git + // default path behavior as native git if path == "" { path = repoName } diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 04221e8..6819835 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -4,20 +4,26 @@ 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() +// TeaTheme implements the huh.Theme interface with tea-cli styling. +type TeaTheme struct{} - title := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"} +// 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")} 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 +} + +// GetTheme returns the default theme for huh prompts. +func GetTheme() TeaTheme { + var t TeaTheme + return t } 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..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 } @@ -62,9 +64,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/utils/validate.go b/modules/utils/validate.go index bedf1d8..8f16931 100644 --- a/modules/utils/validate.go +++ b/modules/utils/validate.go @@ -14,6 +14,9 @@ func ValidateAuthenticationMethod( token string, user string, passwd string, + sshAgent bool, + sshKey string, + sshCertPrincipal string, ) (*url.URL, error) { // Normalize URL serverURL, err := NormalizeURL(giteaURL) @@ -21,14 +24,15 @@ func ValidateAuthenticationMethod( 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 } 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) +} 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 -} 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/tests/integration/config_lock_unix_test.go b/tests/integration/config_lock_unix_test.go new file mode 100644 index 0000000..ff712c6 --- /dev/null +++ b/tests/integration/config_lock_unix_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build unix + +package integration + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "code.gitea.io/tea/modules/config" +) + +func TestConfigLock_CrossProcess(t *testing.T) { + 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") + + unlock, err := config.AcquireConfigLockForTesting(lockPath, 5*time.Second) + if err != nil { + t.Fatalf("failed to acquire lock: %v", err) + } + defer func() { + if err := unlock(); err != nil { + t.Fatalf("failed to release lock: %v", err) + } + }() + + 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() + + err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + os.Exit(0) + } + + syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + os.Exit(1) +} +`, lockPath) + + 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) + } + } +} 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 77% rename from modules/git/repo_test.go rename to tests/integration/git_repo_test.go index 586caaa..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,42 +21,33 @@ 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"), 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()) 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) + } + }) }) } }