diff --git a/.gitea/workflows/release-nightly.yml b/.gitea/workflows/release-nightly.yml index 54c3be66..a71bf4d6 100644 --- a/.gitea/workflows/release-nightly.yml +++ b/.gitea/workflows/release-nightly.yml @@ -33,9 +33,6 @@ jobs: with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} - - name: get SDK version - id: sdk_version - run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT" - name: goreleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: @@ -43,7 +40,6 @@ jobs: version: "~> v2" args: release --nightly env: - SDK_VERSION: ${{ steps.sdk_version.outputs.version }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_BUCKET: ${{ secrets.R2_BUCKET }} diff --git a/.gitea/workflows/release-tag.yml b/.gitea/workflows/release-tag.yml index 4d658c19..6faef0f9 100644 --- a/.gitea/workflows/release-tag.yml +++ b/.gitea/workflows/release-tag.yml @@ -34,9 +34,6 @@ jobs: with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} - - name: get SDK version - id: sdk_version - run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT" - name: goreleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: @@ -44,7 +41,6 @@ jobs: version: "~> v2" args: release env: - SDK_VERSION: ${{ steps.sdk_version.outputs.version }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_BUCKET: ${{ secrets.R2_BUCKET }} diff --git a/.gitea/workflows/test-pr.yml b/.gitea/workflows/test-pr.yml index b5c81466..bce2230b 100644 --- a/.gitea/workflows/test-pr.yml +++ b/.gitea/workflows/test-pr.yml @@ -27,7 +27,6 @@ jobs: make vet make lint make fmt-check - make docs-check make build - name: unit test and coverage run: | @@ -39,8 +38,9 @@ jobs: env: HTTP_PROXY: "" GITEA_TEA_TEST_URL: "http://gitea:3000" - GITEA_TEA_TEST_USERNAME: "test01" - GITEA_TEA_TEST_PASSWORD: "test01" + GITEA_TEA_TEST_TOKEN_FILE: /tmp/tea-ci-admin-token + GITEA_TEA_TEST_REVIEWER_TOKEN_FILE: /tmp/tea-ci-reviewer-token + GITEA_TEA_TEST_REPOSITORY_ROOT: /tmp/data steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 @@ -59,12 +59,14 @@ jobs: done echo "::error::gitea did not become ready within 60s" exit 1 + - name: create private token fixtures + run: python3 scripts/integration-tokens.py - name: integration test run: | make integration-test services: gitea: - image: docker.gitea.com/gitea:1.27.0 + image: docker.gitea.com/gitea:28.0.0 cmd: - bash - -c @@ -77,6 +79,7 @@ jobs: && echo "INSTALL_LOCK = true" >> /tmp/conf/app.ini && echo "SECRET_KEY = 2crAW4UANgvLipDS6U5obRcFosjSJHQANll6MNfX7P0G3se3fKcCwwK3szPyGcbo" >> /tmp/conf/app.ini && echo "PASSWORD_COMPLEXITY = off" >> /tmp/conf/app.ini + && echo "IMPORT_LOCAL_PATHS = true" >> /tmp/conf/app.ini && echo "[database]" >> /tmp/conf/app.ini && echo "DB_TYPE = sqlite3" >> /tmp/conf/app.ini && echo "[repository]" >> /tmp/conf/app.ini @@ -84,5 +87,6 @@ jobs: && echo "[server]" >> /tmp/conf/app.ini && echo "ROOT_URL = http://gitea:3000" >> /tmp/conf/app.ini && gitea migrate -c /tmp/conf/app.ini - && gitea admin user create --username=test01 --password=test01 --email=test01@gitea.io --admin=true --must-change-password=false --access-token -c /tmp/conf/app.ini + && gitea admin user create --username=integration --password=tea-local-integration-pass --email=integration@example.test --admin=true --must-change-password=false -c /tmp/conf/app.ini + && gitea admin user create --username=tea-reviewer --password=tea-local-reviewer-pass --email=tea-reviewer@example.test --must-change-password=false -c /tmp/conf/app.ini && gitea web -c /tmp/conf/app.ini diff --git a/.gitignore b/.gitignore index 1672762d..c63687bc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,13 @@ coverage.out dist/ +# Generated by `make docs`; shipped in the release docs archive, never committed +docs/generated/ + +# Release docs archive produced by `make docs-archive` / goreleaser before hooks +tea-*-docs.tar.xz +tea-*-docs.tar.xz.sha256 + # Nix-specific .direnv/ result diff --git a/.golangci.yml b/.golangci.yml index 85020787..43bde9d1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,6 +12,7 @@ linters: - misspell - ineffassign - unused + - copyloopvar settings: revive: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1fa2b71e..2eb8c1b0 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,6 +3,11 @@ version: 2 before: hooks: - go mod tidy + # Regenerate references/man/completions, then pack a versioned docs + # archive outside dist/ (goreleaser requires dist/ empty after hooks). + # tea-{{ .Version }}-docs.tar.xz is picked up by release/publisher/ + # checksum ./**.xz globs alongside binary .xz artifacts. + - make docs-archive DOCS_ARCHIVE=tea-{{ .Version }}-docs.tar.xz builds: - env: @@ -58,7 +63,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X "gitea.dev/tea/modules/version.Version={{ trimprefix .Summary "v" }}" -X "gitea.dev/tea/modules/version.Tags=" -X "gitea.dev/tea/modules/version.SDK={{ .Env.SDK_VERSION }}" + - -s -w -X "gitea.dev/tea/internal/version.Version={{ trimprefix .Summary "v" }}" -X "gitea.dev/tea/internal/version.Tags=" binary: >- {{ .ProjectName }}- {{- .Version }}- @@ -144,6 +149,9 @@ gitea_urls: download: https://gitea.com release: + # Includes per-platform binary .xz artifacts and the docs archive + # tea--docs.tar.xz (references, man pages, bash/zsh/fish/powershell + # completions) produced by the before.hooks docs-archive step. extra_files: - glob: ./**.xz - glob: ./**.xz.sha256 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a762dd2a..7e6bc51c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,9 +56,27 @@ high-level discussions. ## Testing redux -Before sending code out for review, run all the test by executing: `make test` +Before sending code out for review, run the unit tests with `make test`. Since TEA is an cli tool it should be obvious to test your feature locally first. +Tests live beside the packages they exercise. `make integration-test` runs the +`TestGitea*` tests against a disposable Gitea instance with an administrator +account named `integration` and a reviewer named `tea-reviewer`. Never point +these tests at a production instance: they create and delete resources. + +```sh +GITEA_TEA_TEST_URL=http://127.0.0.1:3000 \ +GITEA_TEA_TEST_TOKEN_FILE=/private/path/admin-token \ +GITEA_TEA_TEST_REVIEWER_TOKEN_FILE=/private/path/reviewer-token \ + make integration-test +``` + +Token files contain scoped PATs for their accounts and should be `0600`; +`scripts/integration-tokens.py` creates them for the CI accounts. Optional +coverage is enabled by `GITEA_TEA_TEST_REPOSITORY_ROOT` (the server's repository +root, with `[repository] IMPORT_LOCAL_PATHS = true`, for migration) and +`GITEA_TEA_TEST_SSH_ENDPOINT` (an SSH clone endpoint). + ## Code review Changes to TEA must be reviewed before they are accepted—no matter who @@ -81,52 +99,49 @@ Some of the key points: ## Styleguide ### Commands -- Subcommands should follow the following structure: - ``` - tea [] [] - ``` - for example: +Use explicit singular noun/verb commands, such as `tea issue list` and +`tea pr create`. Bare command groups show help. Commands validate input, collect +missing fields when the shared prompting policy permits it, invoke services, and +render results through injected streams. Destructive commands require confirmation +or `--yes`; `--no-input` disables implicit interaction. JSON results go to stdout; +diagnostics and prompts go to stderr. Lists register `--limit`/`--page` with +`pageFlags` and print through `writeList`, which renders one `terminal.Table` +(ID or number first, then title or name, then state) through the pager. - ``` - tea issues list - tea pulls create - tea teams add user --team x --user y - ``` -- Commands should accept nouns as singular & plural by making use of the `Aliases` field. -- The default action without a verb is `list`. -- There is a standard set of verbs: `list/ls`, `create`, `edit`, `delete` - - `ls` lists objects with filter options, and applies pagination where available. - - `delete` should show info what is deleted and ask user again, if force flag`-y` is not set - - Verbs that accept large numbers of flags provide an interactive mode when called without any arguments or flags. -- Try to reuse as many flag definitions as possible, see `cmd/flags/flags.go`. -- Always make sure that the help texts are properly set, and as concise as possible. +### Internal Package Structure -### Internal Module Structure -- `cmd`: only contains command/flag options for `urfave/cli` - - subcommands are in a subpackage named after its parent command -- `modules/task`: contain func for doing something with gitea - (e.g. create token by user/passwd) -- `modules/print`: contain all functions that print to stdout -- `modules/config`: config tea & login things -- `modules/interact`: contain functions to interact with user by prompts -- `modules/git`: do git related stuff (get info/push/pull/checkout/...) -- `modules/utils`: helper functions used by other functions +- `internal/app`: Cobra commands and application dependency construction. +- `internal/services`: typed workflows over `gitea.dev/sdk`, independent of CLI flags + and terminal I/O, including pagination policy. Services map SDK types to tea's + output types; requests the SDK cannot make yet go through the raw helpers in + `raw.go`, each marked `TODO(sdk)` with the missing SDK piece. +- `internal/settings`: split configuration, validation, persistence, target resolution, + and remote URL parsing. +- `internal/auth`: secure credentials and OAuth lifecycle. +- `internal/transport`: the tea HTTP client shared by the SDK and raw requests: TLS, + timeouts, User-Agent, `--debug`, retries, token, Sudo and SSH-signature + authentication, and sanitised API errors. Its raw requests serve `tea api`, signed + downloads and log streaming. +- `internal/git`: repository discovery and cancellable Git subprocesses. +- `internal/terminal`: streams, input, editor, browser, and rendering. +- `internal/clierr`: typed errors and executable exit codes. +- `internal/aliases`: user command alias expansion. +- `internal/filelock`: cross-process locks for configuration writes. +- `internal/version`: build version metadata. + +Unit and integration tests live beside their packages; see [Testing redux](#testing-redux). ### Code Style Use `make fmt`, check with `make lint`. -For imports you should use the following format (_without_ the comments) +Group imports into the standard library, then all other packages (local and +external) in one sorted block, as gofumpt keeps them: ```go import ( - // stdlib "encoding/json" "fmt" - // local packages - "code.gitea.io/gitea/models" - "gitea.dev/sdk" - - // external packages + "gitea.dev/tea/internal/services" "github.com/foo/bar" "gopkg.io/baz.v1" ) diff --git a/Makefile b/Makefile index b253d0ea..54e41cee 100644 --- a/Makefile +++ b/Makefile @@ -24,17 +24,13 @@ endif TEA_VERSION_TAG ?= $(shell sed 's/+/_/' <<< $(TEA_VERSION)) TAGS ?= -SDK ?= $(shell $(GO) list -f '{{.Version}}' -m gitea.dev/sdk) -LDFLAGS := -X "gitea.dev/tea/modules/version.Version=$(TEA_VERSION)" -X "gitea.dev/tea/modules/version.Tags=$(TAGS)" -X "gitea.dev/tea/modules/version.SDK=$(SDK)" -s -w +LDFLAGS := -X "gitea.dev/tea/internal/version.Version=$(TEA_VERSION)" -X "gitea.dev/tea/internal/version.Tags=$(TAGS)" -s -w # override to allow passing additional goflags via make CLI override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -PACKAGES ?= $(shell $(GO) list ./... | grep -v '^gitea.dev/tea/tests') +PACKAGES ?= $(shell $(GO) list ./...) UNIT_PACKAGES ?= $(PACKAGES) -INTEGRATION_PACKAGES ?= $(shell $(GO) list ./tests/... 2>/dev/null) -INTEGRATION_TEST_TAGS ?= testtools -INTEGRATION_TEST_GOFLAGS ?= -v SOURCES ?= $(shell find . -name "*.go" -type f) # OS specific vars. @@ -68,11 +64,11 @@ vet: .PHONY: lint lint: - $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools + $(GO) run $(GOLANGCI_LINT_PACKAGE) run .PHONY: lint-fix lint-fix: - $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix + $(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix .PHONY: fmt-check fmt-check: @@ -84,18 +80,39 @@ fmt-check: exit 1; \ fi; +# Man pages are dated by the last commit unless SOURCE_DATE_EPOCH is already set. .PHONY: docs docs: - $(GO) run docs/docs.go --out docs/CLI.md + SOURCE_DATE_EPOCH=$${SOURCE_DATE_EPOCH:-$$(git log -1 --format=%ct 2>/dev/null)} TZ=UTC $(GO) run ./tools/cli-docs --out docs/generated -.PHONY: docs-check -docs-check: - @DIFF=$$($(GO) run docs/docs.go | diff docs/CLI.md -); \ - if [ -n "$$DIFF" ]; then \ - echo "Please run 'make docs' and commit the result:"; \ - echo "$$DIFF"; \ - exit 1; \ - fi; +# Versioned docs archive for release uploads (references, man pages, completions). +# Keep the artifact outside dist/: goreleaser requires dist/ empty after before hooks. +# The .tar.xz suffix matches existing release/publisher/checksum ./**.xz globs. +DOCS_ARCHIVE ?= tea-$(TEA_VERSION_TAG)-docs.tar.xz + +.PHONY: docs-archive +docs-archive: docs + @rm -f "$(DOCS_ARCHIVE)" "$(DOCS_ARCHIVE).sha256" + tar -C docs/generated -c README.md reference man completion | xz -9 > "$(DOCS_ARCHIVE)" + @bash .goreleaser.checksum.sh "$(DOCS_ARCHIVE)" + +# Install generated man pages and shell completions for packagers / local installs. +# Binary install remains `make install` (GOPATH/bin). Override PREFIX/DESTDIR as needed. +PREFIX ?= /usr/local +DESTDIR ?= + +.PHONY: install-docs +install-docs: docs + install -d "$(DESTDIR)$(PREFIX)/share/man/man1" + install -m 644 docs/generated/man/*.1 "$(DESTDIR)$(PREFIX)/share/man/man1/" + install -d "$(DESTDIR)$(PREFIX)/share/bash-completion/completions" + install -m 644 docs/generated/completion/tea.bash "$(DESTDIR)$(PREFIX)/share/bash-completion/completions/tea" + install -d "$(DESTDIR)$(PREFIX)/share/zsh/site-functions" + install -m 644 docs/generated/completion/_tea "$(DESTDIR)$(PREFIX)/share/zsh/site-functions/_tea" + install -d "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d" + install -m 644 docs/generated/completion/tea.fish "$(DESTDIR)$(PREFIX)/share/fish/vendor_completions.d/tea.fish" + install -d "$(DESTDIR)$(PREFIX)/share/powershell/Modules/tea" + install -m 644 docs/generated/completion/tea.ps1 "$(DESTDIR)$(PREFIX)/share/powershell/Modules/tea/tea.ps1" .PHONY: unit-test unit-test: @@ -103,18 +120,17 @@ unit-test: .PHONY: integration-test integration-test: - @if [ -n "$(INTEGRATION_PACKAGES)" ]; then \ - $(GO) test $(INTEGRATION_TEST_GOFLAGS) -tags='$(INTEGRATION_TEST_TAGS)' $(INTEGRATION_PACKAGES); \ - else \ - echo "No integration test packages found"; \ - fi + @test -n "$$GITEA_TEA_TEST_URL" || { echo 'Set GITEA_TEA_TEST_URL to a disposable Gitea instance' >&2; exit 1; } + @test -r "$$GITEA_TEA_TEST_TOKEN_FILE" || { echo 'Set GITEA_TEA_TEST_TOKEN_FILE to a readable administrator token file' >&2; exit 1; } + @test -r "$$GITEA_TEA_TEST_REVIEWER_TOKEN_FILE" || { echo 'Set GITEA_TEA_TEST_REVIEWER_TOKEN_FILE to a readable reviewer token file' >&2; exit 1; } + $(GO) test -count=1 -race -run '^TestGitea' $(PACKAGES) .PHONY: test -test: unit-test integration-test +test: unit-test .PHONY: unit-test-coverage unit-test-coverage: - $(GO) test -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 + $(GO) test -race -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1 .PHONY: tidy tidy: diff --git a/README.md b/README.md index 2293b841..cbb0cff5 100644 --- a/README.md +++ b/README.md @@ -8,96 +8,29 @@ ## The official CLI for Gitea -![demo gif](./demo.gif) +Tea manages repositories, issues, pull requests, releases, and other Gitea resources +with explicit commands suitable for local development and CI. -``` -NAME: - tea - command line tool to interact with Gitea - -USAGE: - tea [global options] [command [command options]] - -VERSION: - Version: 0.10.1+15-g8876fe3 golang: 1.25.0 go-sdk: v0.21.0 - -DESCRIPTION: - 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'. - - tea tries to make use of context provided by the repository in $PWD if available. - tea works best in a upstream/fork workflow, when the local main branch tracks the - upstream repo. tea assumes that local git state is published on the remote before - doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. - -COMMANDS: - help, h Shows a list of commands or help for one command - - ENTITIES: - issues, issue, i List, create and update issues - pulls, pull, pr Manage and checkout pull requests - labels, label Manage issue labels - milestones, milestone, ms List and create milestones - releases, release, r Manage releases - times, time, t Operate on tracked times of a repository's issues & pulls - organizations, organization, org List, create, delete organizations - repos, repo Show repository details - branches, branch, b Consult branches - actions Manage repository actions (secrets, variables) - comment, c Add a comment to an issue / pr - webhooks, webhook Manage repository webhooks - - HELPERS: - open, o Open something of the repository in web browser - notifications, notification, n Show notifications - clone, C Clone a repository locally - - MISCELLANEOUS: - whoami Show current logged in user - admin, a Operations requiring admin access on the Gitea instance - - SETUP: - logins, login Log in to a Gitea server - logout Log out from a Gitea server - -GLOBAL OPTIONS: - --debug, --vvv Enable debug mode (default: false) - --help, -h show help - --version, -v print the version - -EXAMPLES - tea login add # add a login once to get started - - tea pulls # list open pulls for the repo in $PWD - tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo - tea pulls --remote upstream # list open pulls for the repo pointed at by - # your local "upstream" git remote - # list open pulls for any gitea repo at the given login instance - tea pulls --repo gitea/tea --login gitea.com - - tea milestone issues 0.7.0 # view open issues for milestone '0.7.0' - tea issue 189 # view contents of issue 189 - tea open 189 # open web ui for issue 189 - tea open milestones # open web ui for milestones - - tea actions secrets list # list all repository action secrets - tea actions secrets create API_KEY # create a new secret (will prompt for value) - tea actions variables list # list all repository action variables - tea actions variables set API_URL https://api.example.com - - tea webhooks list # list repository webhooks - tea webhooks list --org myorg # list organization webhooks - tea webhooks create https://example.com/hook --events push,pull_request - - # send gitea desktop notifications every 5 minutes (bash + libnotify) - while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done - -ABOUT - Written & maintained by The Gitea Authors. - 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. +```sh +tea auth login --host https://gitea.example.org --with-token < token-file +tea issue list --repo owner/repository +tea pr view 123 --repo owner/repository --json +tea alias set bugs 'issue list --label bug' +tea help ``` -- tea uses [gitea.dev/sdk](https://gitea.dev/sdk) and interacts with the Gitea API. +tea 1.0 is a breaking release: it requires Gitea 28 or newer, commands, flags and +output formats changed, it uses separate `config.yml` and `hosts.yml` files, and it +requires fresh authentication. +Existing combined configuration files are left untouched; archive the old file or +choose an isolated `TEA_CONFIG_DIR` before setup. See +[Migrating to tea 1.0](docs/migration-1.0.md) for the old-to-new mapping. +Run `tea --help` or `tea --help` for supported commands and flags; the +full reference, man pages, and shell completions ship in each release's docs archive. +Credentials use the operating-system keyring by default; explicitly +select `--credential-store file` when private file storage is needed. For CI, +supply `GITEA_TOKEN` and its instance through `TEA_HOST` or `GITEA_INSTANCE_URL`; +environment credentials are never persisted. ## Installation @@ -128,15 +61,15 @@ Gitea can use many different authentication schemes, and not every authenticatio 3. In your "user settings", generate an application token with at least **user read** permissions. If you want to do anything useful with the token add additional permissions/scopes. -4. Run `tea login add`, select **application token** authentication when asked for authentication type, and answer **yes** to the question if you have a token. Paste the generated token when asked for one. +4. Save the token in a private file and run `tea auth login --host https://your-instance --with-token < token-file`. Credentials use the operating-system keyring by default. You should now be logged in to your gitea instance from tea. -Since 0.10 Gitea supports the much simpler oauth workflow but oauth may not be available on all Gitea deployments, and gets much more complex when running tea on a remote system. +Alternatively, `tea auth login --host https://your-instance --oauth` authorizes tea in your browser. OAuth may not be available on all Gitea deployments, and needs a browser on the machine running tea. ### Shell completion -If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion ` command which is not visible in help. To generate the completions run one of the following commands depending on your shell. +If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion ` command. To generate the completions run one of the following commands depending on your shell. ```shell # .bashrc @@ -148,8 +81,8 @@ source <(tea completion zsh) # fish tea completion fish > ~/.config/fish/completions/tea.fish -# Powershell -Output the script to path/to/autocomplete/tea.ps1 an run it. +# PowerShell profile +tea completion powershell | Out-String | Invoke-Expression ``` ### Man Page @@ -164,16 +97,16 @@ man <(tea man) man (tea man | psub) # write man page to a file -tea man --out ./tea.man +tea man --output-file ./tea.man ``` ## Compilation -Make sure you have a current Go version installed (1.26 or newer). +Make sure you have a current Go version installed (1.27 or newer). - To compile the source yourself with the recommended flags & tags: ```sh - git clone https://gitea.com/gitea/tea.git # or: tea clone gitea.com/gitea/tea ;) + git clone https://gitea.com/gitea/tea.git # or: tea repo clone https://gitea.com/gitea/tea ;) cd tea make ``` @@ -193,8 +126,10 @@ Make sure you have a current Go version installed (1.26 or newer). Fork -> Patch -> Push -> Pull Request -- `make test` run testsuite +- `make test` run unit tests - `make vet` run checks (check the order of imports; preventing failure on CI pipeline beforehand) +- `make build` builds the production CLI +- `make docs` regenerates references, man pages, and shell completions in `docs/generated` (not committed; built on release) - ... (for other development tasks, check the `Makefile`) **Please** read the [CONTRIBUTING](CONTRIBUTING.md) documentation, it will tell you about internal structures and concepts. diff --git a/cmd/actions.go b/cmd/actions.go deleted file mode 100644 index 447949c5..00000000 --- a/cmd/actions.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/actions" - - "github.com/urfave/cli/v3" -) - -// CmdActions represents the actions command for managing Gitea Actions -var CmdActions = cli.Command{ - Name: "actions", - Aliases: []string{"action"}, - Category: catEntities, - Usage: "Manage repository actions", - Description: "Manage repository actions including secrets, variables, and workflow runs", - Action: runActionsDefault, - Commands: []*cli.Command{ - &actions.CmdActionsSecrets, - &actions.CmdActionsVariables, - &actions.CmdActionsRuns, - &actions.CmdActionsWorkflows, - }, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "repo", - Usage: "repository to operate on", - }, - &cli.StringFlag{ - Name: "login", - Usage: "gitea login instance to use", - }, - &cli.StringFlag{ - Name: "output", - Aliases: []string{"o"}, - Usage: "output format [table, csv, simple, tsv, yaml, json]", - }, - }, -} - -func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error { - return cli.ShowSubcommandHelp(cmd) -} diff --git a/cmd/actions/runs.go b/cmd/actions/runs.go deleted file mode 100644 index 0b34d690..00000000 --- a/cmd/actions/runs.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/actions/runs" - - "github.com/urfave/cli/v3" -) - -// CmdActionsRuns represents the actions runs command -var CmdActionsRuns = cli.Command{ - Name: "runs", - Aliases: []string{"run"}, - Usage: "Manage workflow runs", - Description: "List, view, and manage workflow runs for repository actions", - Action: runRunsDefault, - Commands: []*cli.Command{ - &runs.CmdRunsList, - &runs.CmdRunsView, - &runs.CmdRunsDelete, - &runs.CmdRunsLogs, - }, -} - -func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error { - return runs.RunRunsList(ctx, cmd) -} diff --git a/cmd/actions/runs/delete.go b/cmd/actions/runs/delete.go deleted file mode 100644 index 310afedd..00000000 --- a/cmd/actions/runs/delete.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package runs - -import ( - stdctx "context" - "fmt" - "strconv" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/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.Actions.DeleteRepoRun(ctx, 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 deleted file mode 100644 index 40f6fc1c..00000000 --- a/cmd/actions/runs/list.go +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package runs - -import ( - stdctx "context" - "fmt" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "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.Actions.ListRepoRuns(ctx, 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 deleted file mode 100644 index 82258996..00000000 --- a/cmd/actions/runs/list_test.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package runs - -import ( - "os" - "testing" - "time" - - gitea "gitea.dev/sdk" - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/modules/config" -) - -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(t.Context(), cmd) - require.ErrorContains(t, err, "remote repository required") -} diff --git a/cmd/actions/runs/logs.go b/cmd/actions/runs/logs.go deleted file mode 100644 index f6b4735b..00000000 --- a/cmd/actions/runs/logs.go +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package runs - -import ( - stdctx "context" - "fmt" - "strconv" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "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(ctx, client, c, jobID, "") - } - - logs, _, err := client.Actions.GetRepoRunJobLogs(ctx, 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.Actions.ListRepoJobsByRun(ctx, 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(ctx, 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.Actions.GetRepoRunJobLogs(ctx, 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(requestCtx stdctx.Context, 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.Actions.GetRepoRunJob(requestCtx, 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.Actions.GetRepoRunJobLogs(requestCtx, 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 deleted file mode 100644 index efd4dfa6..00000000 --- a/cmd/actions/runs/view.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package runs - -import ( - stdctx "context" - "fmt" - "strconv" - - gitea "gitea.dev/sdk" - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "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.Actions.GetRepoRun(ctx, 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.Actions.ListRepoJobsByRun(ctx, 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.go b/cmd/actions/secrets.go deleted file mode 100644 index 0380de39..00000000 --- a/cmd/actions/secrets.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/actions/secrets" - - "github.com/urfave/cli/v3" -) - -// CmdActionsSecrets represents the actions secrets command -var CmdActionsSecrets = cli.Command{ - Name: "secrets", - Aliases: []string{"secret"}, - Usage: "Manage repository action secrets", - Description: "Manage secrets used by repository actions and workflows", - Action: runSecretsDefault, - Commands: []*cli.Command{ - &secrets.CmdSecretsList, - &secrets.CmdSecretsCreate, - &secrets.CmdSecretsDelete, - }, -} - -func runSecretsDefault(ctx stdctx.Context, cmd *cli.Command) error { - return secrets.RunSecretsList(ctx, cmd) -} diff --git a/cmd/actions/secrets/create.go b/cmd/actions/secrets/create.go deleted file mode 100644 index 1f070844..00000000 --- a/cmd/actions/secrets/create.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package secrets - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/utils" - "github.com/urfave/cli/v3" -) - -// CmdSecretsCreate represents a sub command to create action secrets -var CmdSecretsCreate = cli.Command{ - Name: "create", - Aliases: []string{"add", "set"}, - Usage: "Create an action secret", - Description: "Create a secret for use in repository actions and workflows", - ArgsUsage: " [secret-value]", - Action: runSecretsCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "file", - Usage: "read secret value from file", - }, - &cli.BoolFlag{ - Name: "stdin", - Usage: "read secret value from stdin", - }, - }, flags.AllDefaultFlags...), -} - -func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 0 { - return fmt.Errorf("secret name 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() - - secretName := cmd.Args().First() - - // 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 - } - - _, err = client.Actions.CreateRepoSecret(ctx, c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{ - Data: secretValue, - }) - if err != nil { - return err - } - - fmt.Printf("Secret '%s' created successfully\n", secretName) - return nil -} diff --git a/cmd/actions/secrets/create_test.go b/cmd/actions/secrets/create_test.go deleted file mode 100644 index 5ac0869c..00000000 --- a/cmd/actions/secrets/create_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package secrets - -import ( - "testing" -) - -func TestGetSecretSourceArgs(t *testing.T) { - tests := []struct { - name string - args []string - wantErr bool - }{ - { - name: "valid args", - args: []string{"VALID_SECRET", "secret_value"}, - wantErr: false, - }, - { - name: "missing name", - args: []string{}, - wantErr: true, - }, - { - name: "too many args", - args: []string{"SECRET_NAME", "value", "extra"}, - wantErr: true, - }, - { - name: "invalid secret name", - args: []string{"invalid_secret", "value"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Test argument validation only - if len(tt.args) == 0 { - if !tt.wantErr { - t.Error("Expected error for empty args") - } - return - } - - if len(tt.args) > 2 { - if !tt.wantErr { - t.Error("Expected error for too many args") - } - return - } - }) - } -} diff --git a/cmd/actions/secrets/delete.go b/cmd/actions/secrets/delete.go deleted file mode 100644 index 2bd555a1..00000000 --- a/cmd/actions/secrets/delete.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package secrets - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdSecretsDelete represents a sub command to delete action secrets -var CmdSecretsDelete = cli.Command{ - Name: "delete", - Aliases: []string{"remove", "rm"}, - Usage: "Delete an action secret", - Description: "Delete a secret used by repository actions", - ArgsUsage: "", - Action: runSecretsDelete, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "confirm", - Aliases: []string{"y"}, - Usage: "confirm deletion without prompting", - }, - }, flags.AllDefaultFlags...), -} - -func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 0 { - return fmt.Errorf("secret name 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() - - secretName := cmd.Args().First() - - if !cmd.Bool("confirm") { - fmt.Printf("Are you sure you want to delete secret '%s'? [y/N] ", secretName) - var response string - fmt.Scanln(&response) - if response != "y" && response != "Y" && response != "yes" { - fmt.Println("Deletion canceled.") - return nil - } - } - - _, err = client.Actions.DeleteRepoSecret(ctx, c.Owner, c.Repo, secretName) - if err != nil { - return err - } - - fmt.Printf("Secret '%s' deleted successfully\n", secretName) - return nil -} diff --git a/cmd/actions/secrets/delete_test.go b/cmd/actions/secrets/delete_test.go deleted file mode 100644 index 798b8917..00000000 --- a/cmd/actions/secrets/delete_test.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package secrets - -import ( - "fmt" - "testing" -) - -func TestSecretsDeleteValidation(t *testing.T) { - tests := []struct { - name string - args []string - wantErr bool - }{ - { - name: "valid secret name", - args: []string{"VALID_SECRET"}, - wantErr: false, - }, - { - name: "no args", - args: []string{}, - wantErr: true, - }, - { - name: "too many args", - args: []string{"SECRET1", "SECRET2"}, - wantErr: true, - }, - { - name: "invalid secret name but client does not validate", - args: []string{"invalid_secret"}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateDeleteArgs(tt.args) - if (err != nil) != tt.wantErr { - t.Errorf("validateDeleteArgs() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestSecretsDeleteFlags(t *testing.T) { - cmd := CmdSecretsDelete - - // Test command properties - if cmd.Name != "delete" { - t.Errorf("Expected command name 'delete', got %s", cmd.Name) - } - - // Check that rm is one of the aliases - hasRmAlias := false - for _, alias := range cmd.Aliases { - if alias == "rm" { - hasRmAlias = true - break - } - } - if !hasRmAlias { - t.Error("Expected 'rm' to be one of the aliases for delete command") - } - - if cmd.ArgsUsage != "" { - t.Errorf("Expected ArgsUsage '', got %s", cmd.ArgsUsage) - } - - if cmd.Usage == "" { - t.Error("Delete command should have usage text") - } - - if cmd.Description == "" { - t.Error("Delete command should have description") - } -} - -// validateDeleteArgs validates arguments for the delete command -func validateDeleteArgs(args []string) error { - if len(args) == 0 { - return fmt.Errorf("secret name is required") - } - - if len(args) > 1 { - return fmt.Errorf("only one secret name allowed") - } - - return nil -} diff --git a/cmd/actions/secrets/list.go b/cmd/actions/secrets/list.go deleted file mode 100644 index 0a698ba7..00000000 --- a/cmd/actions/secrets/list.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package secrets - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdSecretsList represents a sub command to list action secrets -var CmdSecretsList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List action secrets", - Description: "List secrets configured for repository actions", - Action: RunSecretsList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunSecretsList list action secrets -func RunSecretsList(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() - - secrets, _, err := client.Actions.ListRepoSecrets(ctx, c.Owner, c.Repo, gitea.ListRepoActionSecretOption{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.ActionSecretsList(secrets, c.Output) -} diff --git a/cmd/actions/secrets/list_test.go b/cmd/actions/secrets/list_test.go deleted file mode 100644 index 063229b2..00000000 --- a/cmd/actions/secrets/list_test.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package secrets - -import ( - "os" - "testing" - - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/modules/config" -) - -func TestSecretsListFlags(t *testing.T) { - cmd := CmdSecretsList - - // Test that required flags exist - expectedFlags := []string{"output", "remote", "login", "repo"} - - for _, flagName := range expectedFlags { - found := false - for _, flag := range cmd.Flags { - if flag.Names()[0] == flagName { - found = true - break - } - } - - if !found { - t.Errorf("Expected flag %s not found in CmdSecretsList", flagName) - } - } - - // Test command properties - if cmd.Name != "list" { - t.Errorf("Expected command name 'list', got %s", cmd.Name) - } - - if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" { - t.Errorf("Expected alias 'ls' for list command") - } - - if cmd.Usage == "" { - t.Error("List command should have usage text") - } - - if cmd.Description == "" { - t.Error("List command should have description") - } -} - -func TestSecretsListValidation(t *testing.T) { - // Basic validation that the command accepts the expected arguments - // More detailed testing would require mocking the Gitea client - - // Test that list command doesn't require arguments - args := []string{} - if len(args) > 0 { - t.Error("List command should not require arguments") - } - - // Test that extra arguments are ignored - extraArgs := []string{"extra", "args"} - if len(extraArgs) > 0 { - // 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(t.Context(), cmd) - require.ErrorContains(t, err, "remote repository required") -} diff --git a/cmd/actions/variables.go b/cmd/actions/variables.go deleted file mode 100644 index 6e6c723f..00000000 --- a/cmd/actions/variables.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/actions/variables" - - "github.com/urfave/cli/v3" -) - -// CmdActionsVariables represents the actions variables command -var CmdActionsVariables = cli.Command{ - Name: "variables", - Aliases: []string{"variable", "vars", "var"}, - Usage: "Manage repository action variables", - Description: "Manage variables used by repository actions and workflows", - Action: runVariablesDefault, - Commands: []*cli.Command{ - &variables.CmdVariablesList, - &variables.CmdVariablesSet, - &variables.CmdVariablesDelete, - }, -} - -func runVariablesDefault(ctx stdctx.Context, cmd *cli.Command) error { - return variables.RunVariablesList(ctx, cmd) -} diff --git a/cmd/actions/variables/delete.go b/cmd/actions/variables/delete.go deleted file mode 100644 index 0db6e0c8..00000000 --- a/cmd/actions/variables/delete.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package variables - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdVariablesDelete represents a sub command to delete action variables -var CmdVariablesDelete = cli.Command{ - Name: "delete", - Aliases: []string{"remove", "rm"}, - Usage: "Delete an action variable", - Description: "Delete a variable used by repository actions", - ArgsUsage: "", - Action: runVariablesDelete, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "confirm", - Aliases: []string{"y"}, - Usage: "confirm deletion without prompting", - }, - }, flags.AllDefaultFlags...), -} - -func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 0 { - return fmt.Errorf("variable name 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() - - variableName := cmd.Args().First() - - if !cmd.Bool("confirm") { - fmt.Printf("Are you sure you want to delete variable '%s'? [y/N] ", variableName) - var response string - fmt.Scanln(&response) - if response != "y" && response != "Y" && response != "yes" { - fmt.Println("Deletion canceled.") - return nil - } - } - - _, err = client.Actions.DeleteRepoVariable(ctx, c.Owner, c.Repo, variableName) - if err != nil { - return err - } - - fmt.Printf("Variable '%s' deleted successfully\n", variableName) - return nil -} diff --git a/cmd/actions/variables/delete_test.go b/cmd/actions/variables/delete_test.go deleted file mode 100644 index 2a7cb6b1..00000000 --- a/cmd/actions/variables/delete_test.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package variables - -import ( - "fmt" - "testing" -) - -func TestVariablesDeleteValidation(t *testing.T) { - tests := []struct { - name string - args []string - wantErr bool - }{ - { - name: "valid variable name", - args: []string{"VALID_VARIABLE"}, - wantErr: false, - }, - { - name: "valid lowercase name", - args: []string{"valid_variable"}, - wantErr: false, - }, - { - name: "no args", - args: []string{}, - wantErr: true, - }, - { - name: "too many args", - args: []string{"VARIABLE1", "VARIABLE2"}, - wantErr: true, - }, - { - name: "invalid variable name", - args: []string{"invalid-variable"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateVariableDeleteArgs(tt.args) - if (err != nil) != tt.wantErr { - t.Errorf("validateVariableDeleteArgs() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestVariablesDeleteFlags(t *testing.T) { - cmd := CmdVariablesDelete - - // Test command properties - if cmd.Name != "delete" { - t.Errorf("Expected command name 'delete', got %s", cmd.Name) - } - - // Check that rm is one of the aliases - hasRmAlias := false - for _, alias := range cmd.Aliases { - if alias == "rm" { - hasRmAlias = true - break - } - } - if !hasRmAlias { - t.Error("Expected 'rm' to be one of the aliases for delete command") - } - - if cmd.ArgsUsage != "" { - t.Errorf("Expected ArgsUsage '', got %s", cmd.ArgsUsage) - } - - if cmd.Usage == "" { - t.Error("Delete command should have usage text") - } - - if cmd.Description == "" { - t.Error("Delete command should have description") - } -} - -// validateVariableDeleteArgs validates arguments for the delete command -func validateVariableDeleteArgs(args []string) error { - if len(args) == 0 { - return fmt.Errorf("variable name is required") - } - - if len(args) > 1 { - return fmt.Errorf("only one variable name allowed") - } - - return validateVariableName(args[0]) -} diff --git a/cmd/actions/variables/list.go b/cmd/actions/variables/list.go deleted file mode 100644 index d3b2c96c..00000000 --- a/cmd/actions/variables/list.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package variables - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - - "github.com/urfave/cli/v3" -) - -// CmdVariablesList represents a sub command to list action variables -var CmdVariablesList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List action variables", - Description: "List variables configured for repository actions", - Action: RunVariablesList, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "show specific variable by name", - }, - }, flags.AllDefaultFlags...), -} - -// RunVariablesList list action variables -func RunVariablesList(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() - - if name := cmd.String("name"); name != "" { - // Get specific variable - variable, _, err := client.Actions.GetRepoVariable(ctx, c.Owner, c.Repo, name) - if err != nil { - return err - } - - print.ActionVariableDetails(variable) - return nil - } - - // List all variables - Note: SDK doesn't have ListRepoActionVariables yet - // This is a limitation of the current SDK - fmt.Println("Note: Listing all variables is not yet supported by the Gitea SDK.") - fmt.Println("Use 'tea actions variables list --name ' to get a specific variable.") - fmt.Println("You can also check your repository's Actions settings in the web interface.") - - return nil -} diff --git a/cmd/actions/variables/list_test.go b/cmd/actions/variables/list_test.go deleted file mode 100644 index 6d018574..00000000 --- a/cmd/actions/variables/list_test.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package variables - -import ( - "os" - "testing" - - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/modules/config" -) - -func TestVariablesListFlags(t *testing.T) { - cmd := CmdVariablesList - - // Test that required flags exist - expectedFlags := []string{"output", "remote", "login", "repo"} - - for _, flagName := range expectedFlags { - found := false - for _, flag := range cmd.Flags { - if flag.Names()[0] == flagName { - found = true - break - } - } - - if !found { - t.Errorf("Expected flag %s not found in CmdVariablesList", flagName) - } - } - - // Test command properties - if cmd.Name != "list" { - t.Errorf("Expected command name 'list', got %s", cmd.Name) - } - - if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" { - t.Errorf("Expected alias 'ls' for list command") - } - - if cmd.Usage == "" { - t.Error("List command should have usage text") - } - - if cmd.Description == "" { - t.Error("List command should have description") - } -} - -func TestVariablesListValidation(t *testing.T) { - // Basic validation that the command accepts the expected arguments - // More detailed testing would require mocking the Gitea client - - // Test that list command doesn't require arguments - args := []string{} - if len(args) > 0 { - t.Error("List command should not require arguments") - } - - // Test that extra arguments are ignored - extraArgs := []string{"extra", "args"} - if len(extraArgs) > 0 { - // 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(t.Context(), cmd) - require.ErrorContains(t, err, "remote repository required") -} diff --git a/cmd/actions/variables/set.go b/cmd/actions/variables/set.go deleted file mode 100644 index d808771d..00000000 --- a/cmd/actions/variables/set.go +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package variables - -import ( - stdctx "context" - "fmt" - "regexp" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdVariablesSet represents a sub command to set action variables -var CmdVariablesSet = cli.Command{ - Name: "set", - Aliases: []string{"create", "update"}, - Usage: "Set an action variable", - Description: "Set a variable for use in repository actions and workflows", - ArgsUsage: " [variable-value]", - Action: runVariablesSet, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "file", - Usage: "read variable value from file", - }, - &cli.BoolFlag{ - Name: "stdin", - Usage: "read variable value from stdin", - }, - }, flags.AllDefaultFlags...), -} - -func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 0 { - return fmt.Errorf("variable name 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() - - variableName := cmd.Args().First() - if err := validateVariableName(variableName); err != nil { - return err - } - - // 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 - } - - if err := validateVariableValue(variableValue); err != nil { - return err - } - - _, err = client.Actions.CreateRepoVariable(ctx, c.Owner, c.Repo, variableName, variableValue) - if err != nil { - return err - } - - fmt.Printf("Variable '%s' set successfully\n", variableName) - return nil -} - -// validateVariableName validates that a variable name follows the required format -func validateVariableName(name string) error { - if name == "" { - return fmt.Errorf("variable name cannot be empty") - } - - // Variable names can contain letters (upper/lower), numbers, and underscores - // Cannot start with a number - // Cannot contain spaces or special characters (except underscore) - validPattern := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - if !validPattern.MatchString(name) { - return fmt.Errorf("variable name must contain only letters, numbers, and underscores, and cannot start with a number") - } - - return nil -} - -// validateVariableValue validates that a variable value is acceptable -func validateVariableValue(value string) error { - // Variables can be empty or contain whitespace, unlike secrets - - // Check for maximum size (64KB limit) - if len(value) > 65536 { - return fmt.Errorf("variable value cannot exceed 64KB") - } - - return nil -} diff --git a/cmd/actions/variables/set_test.go b/cmd/actions/variables/set_test.go deleted file mode 100644 index 99b89f21..00000000 --- a/cmd/actions/variables/set_test.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package variables - -import ( - "strings" - "testing" -) - -func TestValidateVariableName(t *testing.T) { - tests := []struct { - name string - input string - wantErr bool - }{ - { - name: "valid name", - input: "VALID_VARIABLE_NAME", - wantErr: false, - }, - { - name: "valid name with numbers", - input: "VARIABLE_123", - wantErr: false, - }, - { - name: "valid lowercase", - input: "valid_variable", - wantErr: false, - }, - { - name: "valid mixed case", - input: "Mixed_Case_Variable", - wantErr: false, - }, - { - name: "invalid - spaces", - input: "INVALID VARIABLE", - wantErr: true, - }, - { - name: "invalid - special chars", - input: "INVALID-VARIABLE!", - wantErr: true, - }, - { - name: "invalid - starts with number", - input: "1INVALID", - wantErr: true, - }, - { - name: "invalid - empty", - input: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateVariableName(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("validateVariableName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - } - }) - } -} - -func TestGetVariableSourceArgs(t *testing.T) { - tests := []struct { - name string - args []string - wantErr bool - }{ - { - name: "valid args", - args: []string{"VALID_VARIABLE", "variable_value"}, - wantErr: false, - }, - { - name: "valid lowercase", - args: []string{"valid_variable", "value"}, - wantErr: false, - }, - { - name: "missing name", - args: []string{}, - wantErr: true, - }, - { - name: "too many args", - args: []string{"VARIABLE_NAME", "value", "extra"}, - wantErr: true, - }, - { - name: "invalid variable name", - args: []string{"invalid-variable", "value"}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Test argument validation only - if len(tt.args) == 0 { - if !tt.wantErr { - t.Error("Expected error for empty args") - } - return - } - - if len(tt.args) > 2 { - if !tt.wantErr { - t.Error("Expected error for too many args") - } - return - } - - // Test variable name validation - err := validateVariableName(tt.args[0]) - if (err != nil) != tt.wantErr { - t.Errorf("validateVariableName() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestVariableNameValidation(t *testing.T) { - // Test that variable names follow GitHub Actions/Gitea Actions conventions - validNames := []string{ - "VALID_VARIABLE", - "API_URL", - "DATABASE_HOST", - "VARIABLE_123", - "mixed_Case_Variable", - "lowercase_variable", - "UPPERCASE_VARIABLE", - } - - invalidNames := []string{ - "Invalid-Dashes", - "INVALID SPACES", - "123_STARTS_WITH_NUMBER", - "", // Empty - "INVALID!@#", // Special chars - } - - for _, name := range validNames { - t.Run("valid_"+name, func(t *testing.T) { - err := validateVariableName(name) - if err != nil { - t.Errorf("validateVariableName(%q) should be valid, got error: %v", name, err) - } - }) - } - - for _, name := range invalidNames { - t.Run("invalid_"+name, func(t *testing.T) { - err := validateVariableName(name) - if err == nil { - t.Errorf("validateVariableName(%q) should be invalid, got no error", name) - } - }) - } -} - -func TestVariableValueValidation(t *testing.T) { - tests := []struct { - name string - value string - wantErr bool - }{ - { - name: "valid value", - value: "variable123", - wantErr: false, - }, - { - name: "valid complex value", - value: "https://api.example.com/v1", - wantErr: false, - }, - { - name: "valid multiline value", - value: "line1\nline2\nline3", - wantErr: false, - }, - { - name: "empty value allowed", - value: "", - wantErr: false, // Variables can be empty unlike secrets - }, - { - name: "whitespace only allowed", - value: " \t\n ", - wantErr: false, // Variables can contain whitespace - }, - { - name: "very long value", - value: strings.Repeat("a", 65537), // Over 64KB - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateVariableValue(tt.value) - if (err != nil) != tt.wantErr { - t.Errorf("validateVariableValue() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/cmd/actions/workflows.go b/cmd/actions/workflows.go deleted file mode 100644 index a7e692d7..00000000 --- a/cmd/actions/workflows.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package actions - -import ( - stdctx "context" - - "gitea.dev/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 deleted file mode 100644 index 8ed5b0d3..00000000 --- a/cmd/actions/workflows/disable.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflows - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/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.Actions.DisableRepoWorkflow(ctx, 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 deleted file mode 100644 index 7d28b235..00000000 --- a/cmd/actions/workflows/dispatch.go +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflows - -import ( - stdctx "context" - "fmt" - "strings" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "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.Actions.DispatchRepoWorkflow(ctx, 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(ctx, 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(ctx stdctx.Context, 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.Actions.ListRepoJobsByRun(ctx, 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.Actions.GetRepoRunJob(ctx, 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.Actions.GetRepoRunJobLogs(ctx, 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 deleted file mode 100644 index dbe2e1e4..00000000 --- a/cmd/actions/workflows/enable.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflows - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/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.Actions.EnableRepoWorkflow(ctx, 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 deleted file mode 100644 index 31a88646..00000000 --- a/cmd/actions/workflows/list.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflows - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "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.Actions.ListRepoWorkflows(ctx, 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 deleted file mode 100644 index 0d86de47..00000000 --- a/cmd/actions/workflows/view.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package workflows - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/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.Actions.GetRepoWorkflow(ctx, 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 deleted file mode 100644 index 306226be..00000000 --- a/cmd/admin.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/admin/users" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" -) - -// CmdAdmin represents the namespace of admin commands. -// The command itself has no functionality, but hosts subcommands. -var CmdAdmin = cli.Command{ - Name: "admin", - Usage: "Operations requiring admin access on the Gitea instance", - Aliases: []string{"a"}, - Category: catMisc, - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - return cli.ShowSubcommandHelp(cmd) - }, - Commands: []*cli.Command{ - &cmdAdminUsers, - }, -} - -var cmdAdminUsers = cli.Command{ - Name: "users", - Aliases: []string{"u"}, - Usage: "Manage registered users", - Action: func(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runAdminUserDetail(ctx, cmd, cmd.Args().First()) - } - return users.RunUserList(ctx, cmd) - }, - Commands: []*cli.Command{ - &users.CmdUserList, - &users.CmdUserCreate, - &users.CmdUserEdit, - &users.CmdUserDelete, - }, - Flags: users.CmdUserList.Flags, -} - -func runAdminUserDetail(requestCtx stdctx.Context, cmd *cli.Command, u string) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - user, _, err := client.Users.GetUserInfo(requestCtx, u) - if err != nil { - return err - } - - print.UserDetails(user) - return nil -} diff --git a/cmd/admin/users/create.go b/cmd/admin/users/create.go deleted file mode 100644 index f685639e..00000000 --- a/cmd/admin/users/create.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package users - -import ( - stdctx "context" - "fmt" - "io" - "os" - "strings" - "syscall" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" - "golang.org/x/term" -) - -// CmdUserCreate represents a sub command of users to create a user -var CmdUserCreate = cli.Command{ - Name: "create", - Aliases: []string{"add", "new"}, - Usage: "Create a new user", - Description: "Create a new user account", - ArgsUsage: " ", // command does not accept arguments - Action: RunUserCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "username", - Aliases: []string{"u"}, - Usage: "Username for the new user (required)", - Required: true, - }, - &cli.StringFlag{ - Name: "password", - Aliases: []string{"p"}, - Usage: "Password for the new user (will prompt if not provided)", - }, - &cli.StringFlag{ - Name: "password-file", - Usage: "Read password from file", - }, - &cli.BoolFlag{ - Name: "password-stdin", - Usage: "Read password from stdin", - }, - &cli.StringFlag{ - Name: "email", - Aliases: []string{"e"}, - Usage: "Email address for the new user (required)", - Required: true, - }, - &cli.StringFlag{ - Name: "full-name", - Usage: "Full name for the new user", - }, - &cli.BoolFlag{ - Name: "admin", - Usage: "Make the user an administrator", - }, - &cli.BoolFlag{ - Name: "restricted", - Usage: "Make the user restricted", - }, - &cli.BoolFlag{ - Name: "prohibit-login", - Usage: "Prohibit the user from logging in", - }, - &cli.BoolFlag{ - Name: "no-must-change-password", - Usage: "Don't require the user to change password on first login (default: password change required)", - }, - &cli.StringFlag{ - Name: "visibility", - Usage: "Visibility of the user profile (public, limited, private)", - Value: "public", - }, - }, flags.AllDefaultFlags...), -} - -// RunUserCreate creates a new user -func RunUserCreate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - username := ctx.String("username") - password := ctx.String("password") - email := ctx.String("email") - fullName := ctx.String("full-name") - isAdmin := ctx.Bool("admin") - restricted := ctx.Bool("restricted") - prohibitLogin := ctx.Bool("prohibit-login") - noMustChangePassword := ctx.Bool("no-must-change-password") - visibility := ctx.String("visibility") - - // Get password from various sources in priority order - if password == "" { - if ctx.String("password-file") != "" { - // Read from file - content, err := os.ReadFile(ctx.String("password-file")) - if err != nil { - return fmt.Errorf("failed to read password file: %w", err) - } - password = strings.TrimSpace(string(content)) - } else if ctx.Bool("password-stdin") { - // Read from stdin - content, err := io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("failed to read password from stdin: %w", err) - } - password = strings.TrimSpace(string(content)) - } else { - // Interactive prompt (hidden input) - fmt.Printf("Enter password for '%s': ", username) - bytePassword, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - fmt.Println() // Add newline after hidden input - password = string(bytePassword) - - if password == "" { - return fmt.Errorf("password cannot be empty") - } - - // Confirm password (only for interactive mode) - fmt.Printf("Confirm password for '%s': ", username) - bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password confirmation: %w", err) - } - fmt.Println() // Add newline after hidden input - passwordConfirm := string(bytePasswordConfirm) - - if password != passwordConfirm { - return fmt.Errorf("passwords do not match") - } - } - } - - if password == "" { - return fmt.Errorf("password cannot be empty") - } - - if email == "" { - return fmt.Errorf("email is required") - } - - client := ctx.Login.Client() - - // Build create options - createOpts := gitea.CreateUserOption{ - LoginName: username, - Username: username, - Password: password, - Email: email, - FullName: fullName, - SendNotify: false, - } - - // Set must change password flag (pointer to bool required) - // By default, require user to change password on first login - // Only set to false if --no-must-change-password flag is explicitly set - mustChangePassword := !noMustChangePassword - createOpts.MustChangePassword = &mustChangePassword - - vis, err := parseUserVisibility(visibility) - if err != nil { - return err - } - createOpts.Visibility = vis - - // Create the user - user, _, err := client.Admin.CreateUser(requestCtx, createOpts) - if err != nil { - return err - } - - // Admin, Restricted, and ProhibitLogin cannot be set during user creation - // We need to update them via AdminEditUser after creation if any of these flags are set - if isAdmin || restricted || prohibitLogin { - editOpts := gitea.EditUserOption{ - LoginName: username, // Required field - } - - if isAdmin { - editOpts.Admin = &isAdmin - } - - if restricted { - editOpts.Restricted = &restricted - } - - if prohibitLogin { - editOpts.ProhibitLogin = &prohibitLogin - } - - // Update user with admin/restricted/prohibit-login settings - _, err = client.Admin.EditUser(requestCtx, username, editOpts) - if err != nil { - return fmt.Errorf("user created but failed to update admin/restricted/prohibit-login status: %w", err) - } - - // Refresh user info to reflect the changes - user, _, err = client.Users.GetUserInfo(requestCtx, username) - if err != nil { - return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err) - } - } - - print.UserDetails(user) - - return nil -} diff --git a/cmd/admin/users/delete.go b/cmd/admin/users/delete.go deleted file mode 100644 index f0dcc90a..00000000 --- a/cmd/admin/users/delete.go +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package users - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdUserDelete represents a sub command of users to delete a user -var CmdUserDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm", "remove"}, - Usage: "Delete a user", - Description: "Delete a user account", - ArgsUsage: "", - Action: RunUserDelete, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "confirm", - Aliases: []string{"y"}, - Usage: "confirm deletion without prompting", - }, - }, flags.AllDefaultFlags...), -} - -// RunUserDelete deletes a user -func RunUserDelete(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - if ctx.Args().Len() == 0 { - return fmt.Errorf("username is required") - } - - client := ctx.Login.Client() - username := ctx.Args().First() - - // Get user details first to show what we're deleting - user, _, err := client.Users.GetUserInfo(requestCtx, username) - if err != nil { - return fmt.Errorf("failed to get user info: %w", err) - } - - if !ctx.Bool("confirm") { - userInfo := fmt.Sprintf("%s (ID: %d)", user.UserName, user.ID) - if user.Email != "" { - userInfo += fmt.Sprintf(" - %s", user.Email) - } - if user.IsAdmin { - userInfo += " [admin]" - } - fmt.Printf("Are you sure you want to delete user %s? [y/N] ", userInfo) - var response string - fmt.Scanln(&response) - if !isConfirmationAccepted(response) { - fmt.Println("Deletion canceled.") - return nil - } - } - - _, err = client.Admin.DeleteUser(requestCtx, username) - if err != nil { - return fmt.Errorf("failed to delete user: %w", err) - } - - fmt.Printf("User '%s' deleted successfully\n", username) - return nil -} diff --git a/cmd/admin/users/edit.go b/cmd/admin/users/edit.go deleted file mode 100644 index fa1475ee..00000000 --- a/cmd/admin/users/edit.go +++ /dev/null @@ -1,373 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package users - -import ( - stdctx "context" - "fmt" - "io" - "os" - "strings" - "syscall" - - gitea "gitea.dev/sdk" - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" - "golang.org/x/term" -) - -// CmdUserEdit represents a sub command of users to edit a user -var CmdUserEdit = cli.Command{ - Name: "edit", - Aliases: []string{"update", "e", "u"}, - Usage: "Edit a user", - Description: "Edit user account properties", - ArgsUsage: "", - Action: RunUserEdit, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "password", - Usage: "New password (use empty value --password=\"\" to trigger interactive prompt)", - Value: "", - }, - &cli.StringFlag{ - Name: "password-file", - Usage: "Read password from file", - }, - &cli.BoolFlag{ - Name: "password-stdin", - Usage: "Read password from stdin", - }, - &cli.StringFlag{ - Name: "email", - Aliases: []string{"e"}, - Usage: "Email address", - }, - &cli.StringFlag{ - Name: "full-name", - Usage: "Full name", - }, - &cli.StringFlag{ - Name: "description", - Usage: "User description", - }, - &cli.StringFlag{ - Name: "website", - Usage: "Website URL", - }, - &cli.StringFlag{ - Name: "location", - Usage: "Location", - }, - &cli.BoolFlag{ - Name: "admin", - Usage: "Make the user an administrator", - }, - &cli.BoolFlag{ - Name: "no-admin", - Usage: "Remove administrator status", - }, - &cli.BoolFlag{ - Name: "restricted", - Usage: "Make the user restricted", - }, - &cli.BoolFlag{ - Name: "no-restricted", - Usage: "Remove restricted status", - }, - &cli.BoolFlag{ - Name: "prohibit-login", - Usage: "Prohibit the user from logging in", - }, - &cli.BoolFlag{ - Name: "allow-login", - Usage: "Allow the user to log in", - }, - &cli.BoolFlag{ - Name: "active", - Usage: "Activate the user", - }, - &cli.BoolFlag{ - Name: "inactive", - Usage: "Deactivate the user", - }, - &cli.BoolFlag{ - Name: "no-must-change-password", - Usage: "Don't require the user to change password on next login (default: password change required)", - }, - &cli.StringFlag{ - Name: "visibility", - Usage: "Visibility of the user profile (public, limited, private)", - }, - &cli.IntFlag{ - Name: "max-repo-creation", - Usage: "Maximum number of repositories the user can create (-1 for unlimited)", - }, - &cli.BoolFlag{ - Name: "allow-git-hook", - Usage: "Allow the user to use git hooks", - }, - &cli.BoolFlag{ - Name: "no-allow-git-hook", - Usage: "Disallow the user from using git hooks", - }, - &cli.BoolFlag{ - Name: "allow-import-local", - Usage: "Allow the user to import local repositories", - }, - &cli.BoolFlag{ - Name: "no-allow-import-local", - Usage: "Disallow the user from importing local repositories", - }, - &cli.BoolFlag{ - Name: "allow-create-organization", - Usage: "Allow the user to create organizations", - }, - &cli.BoolFlag{ - Name: "no-allow-create-organization", - Usage: "Disallow the user from creating organizations", - }, - }, flags.AllDefaultFlags...), -} - -// RunUserEdit edits an existing user -func RunUserEdit(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - if ctx.Args().Len() == 0 { - return fmt.Errorf("username is required") - } - - client := ctx.Login.Client() - username := ctx.Args().First() - - // Verify the user exists before attempting an update. - _, _, err = client.Users.GetUserInfo(requestCtx, username) - if err != nil { - return fmt.Errorf("failed to get user info: %w", err) - } - - // Build edit options, starting with required LoginName - editOpts := gitea.EditUserOption{ - LoginName: username, - } - - // Update email if set - if ctx.IsSet("email") { - email := ctx.String("email") - editOpts.Email = &email - } - - // Update full name if set - if ctx.IsSet("full-name") { - fullName := ctx.String("full-name") - editOpts.FullName = &fullName - } - - // Update description if set - if ctx.IsSet("description") { - description := ctx.String("description") - editOpts.Description = &description - } - - // Update website if set - if ctx.IsSet("website") { - website := ctx.String("website") - editOpts.Website = &website - } - - // Update location if set - if ctx.IsSet("location") { - location := ctx.String("location") - editOpts.Location = &location - } - - // Handle admin status - if ctx.IsSet("admin") { - admin := ctx.Bool("admin") - editOpts.Admin = &admin - } else if ctx.IsSet("no-admin") { - admin := false - editOpts.Admin = &admin - } - - // Handle restricted status - if ctx.IsSet("restricted") { - restricted := ctx.Bool("restricted") - editOpts.Restricted = &restricted - } else if ctx.IsSet("no-restricted") { - restricted := false - editOpts.Restricted = &restricted - } - - // Handle prohibit login status - if ctx.IsSet("prohibit-login") { - prohibitLogin := ctx.Bool("prohibit-login") - editOpts.ProhibitLogin = &prohibitLogin - } else if ctx.IsSet("allow-login") { - prohibitLogin := false - editOpts.ProhibitLogin = &prohibitLogin - } - - // Handle active status - if ctx.IsSet("active") { - active := ctx.Bool("active") - editOpts.Active = &active - } else if ctx.IsSet("inactive") { - active := false - editOpts.Active = &active - } - - // Handle must change password - will be set when password is changed unless flag is set - - // Handle visibility - if ctx.IsSet("visibility") { - vis, err := parseUserVisibility(ctx.String("visibility")) - if err != nil { - return err - } - editOpts.Visibility = vis - } - - // Handle max repo creation - if ctx.IsSet("max-repo-creation") { - maxRepoCreation := ctx.Int("max-repo-creation") - editOpts.MaxRepoCreation = &maxRepoCreation - } - - // Handle allow git hook - if ctx.IsSet("allow-git-hook") { - allowGitHook := ctx.Bool("allow-git-hook") - editOpts.AllowGitHook = &allowGitHook - } else if ctx.IsSet("no-allow-git-hook") { - allowGitHook := false - editOpts.AllowGitHook = &allowGitHook - } - - // Handle allow import local - if ctx.IsSet("allow-import-local") { - allowImportLocal := ctx.Bool("allow-import-local") - editOpts.AllowImportLocal = &allowImportLocal - } else if ctx.IsSet("no-allow-import-local") { - allowImportLocal := false - editOpts.AllowImportLocal = &allowImportLocal - } - - // Handle allow create organization - if ctx.IsSet("allow-create-organization") { - allowCreateOrg := ctx.Bool("allow-create-organization") - editOpts.AllowCreateOrganization = &allowCreateOrg - } else if ctx.IsSet("no-allow-create-organization") { - allowCreateOrg := false - editOpts.AllowCreateOrganization = &allowCreateOrg - } - - // Handle password if any password flag is set or if password flag was provided (even without value) - shouldChangePassword := ctx.IsSet("password") || ctx.IsSet("password-file") || ctx.Bool("password-stdin") - if shouldChangePassword { - password := ctx.String("password") - - // Get password from various sources in priority order - if password == "" { - if ctx.IsSet("password-file") && ctx.String("password-file") != "" { - // Read from file - content, err := os.ReadFile(ctx.String("password-file")) - if err != nil { - return fmt.Errorf("failed to read password file: %w", err) - } - password = strings.TrimSpace(string(content)) - } else if ctx.Bool("password-stdin") { - // Read from stdin - content, err := io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("failed to read password from stdin: %w", err) - } - password = strings.TrimSpace(string(content)) - } else { - // Interactive prompt (hidden input) - triggered when --password is used without value - fmt.Printf("Enter new password for '%s': ", username) - bytePassword, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - fmt.Println() // Add newline after hidden input - password = string(bytePassword) - - if password == "" { - return fmt.Errorf("password cannot be empty") - } - - // Confirm password (only for interactive mode) - fmt.Printf("Confirm new password for '%s': ", username) - bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password confirmation: %w", err) - } - fmt.Println() // Add newline after hidden input - passwordConfirm := string(bytePasswordConfirm) - - if password != passwordConfirm { - return fmt.Errorf("passwords do not match") - } - } - } - - if password == "" { - return fmt.Errorf("password cannot be empty") - } - - editOpts.Password = password - - // When password is changed, require user to change password on next login by default - // Only set to false if --no-must-change-password flag is explicitly set - if !ctx.IsSet("no-must-change-password") { - mustChangePassword := true - editOpts.MustChangePassword = &mustChangePassword - } else { - mustChangePassword := false - editOpts.MustChangePassword = &mustChangePassword - } - } - - // Only proceed with update if at least one field is being modified - hasChanges := editOpts.Email != nil || - editOpts.FullName != nil || - editOpts.Description != nil || - editOpts.Website != nil || - editOpts.Location != nil || - editOpts.Admin != nil || - editOpts.Restricted != nil || - editOpts.ProhibitLogin != nil || - editOpts.Active != nil || - editOpts.Visibility != nil || - editOpts.MaxRepoCreation != nil || - editOpts.AllowGitHook != nil || - editOpts.AllowImportLocal != nil || - editOpts.AllowCreateOrganization != nil || - editOpts.Password != "" - - if !hasChanges { - return fmt.Errorf("no changes specified") - } - - // Update the user - _, err = client.Admin.EditUser(requestCtx, username, editOpts) - if err != nil { - return fmt.Errorf("failed to update user: %w", err) - } - - // Refresh user info to reflect the changes - updatedUser, _, err := client.Users.GetUserInfo(requestCtx, username) - if err != nil { - return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err) - } - - print.UserDetails(updatedUser) - return nil -} diff --git a/cmd/admin/users/list.go b/cmd/admin/users/list.go deleted file mode 100644 index 715d25af..00000000 --- a/cmd/admin/users/list.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package users - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -var userFieldsFlag = flags.FieldsFlag(print.UserFields, []string{ - "id", "login", "full_name", "email", "activated", -}) - -// CmdUserList represents a sub command of users to list users -var CmdUserList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List Users", - Description: "List users", - Action: RunUserList, - Flags: append([]cli.Flag{ - userFieldsFlag, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunUserList list users -func RunUserList(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - fields, err := userFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - client := ctx.Login.Client() - users, _, err := client.Admin.ListUsers(requestCtx, gitea.AdminListUsersOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.UserList(users, ctx.Output, fields) -} diff --git a/cmd/admin/users/shared.go b/cmd/admin/users/shared.go deleted file mode 100644 index 5abbaec5..00000000 --- a/cmd/admin/users/shared.go +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package users - -import ( - "fmt" - "strings" - - "gitea.dev/sdk" -) - -func parseUserVisibility(visibility string) (*gitea.VisibleType, error) { - switch visibility { - case "public": - vis := gitea.VisibleTypePublic - return &vis, nil - case "limited": - vis := gitea.VisibleTypeLimited - return &vis, nil - case "private": - vis := gitea.VisibleTypePrivate - return &vis, nil - default: - return nil, fmt.Errorf("invalid visibility: %s (must be public, limited, or private)", visibility) - } -} - -func isConfirmationAccepted(response string) bool { - trimmed := strings.TrimSpace(response) - return strings.EqualFold(trimmed, "y") || strings.EqualFold(trimmed, "yes") -} diff --git a/cmd/api.go b/cmd/api.go deleted file mode 100644 index 6c5c22a5..00000000 --- a/cmd/api.go +++ /dev/null @@ -1,408 +0,0 @@ -// 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" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/api" - "gitea.dev/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(requestCtx 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 deleted file mode 100644 index 8e92b5e0..00000000 --- a/cmd/api_test.go +++ /dev/null @@ -1,632 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - "encoding/json" - "io" - "os" - "os/exec" - "path/filepath" - "testing" - - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" - tea_git "gitea.dev/tea/modules/git" - - "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(t.Context(), 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() - runGit := func(args ...string) { - cmd := exec.Command("git", args...) - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - } - - runGit("init") - runGit("config", "user.email", "test@test.com") - runGit("config", "user.name", "test") - - // Create an initial commit so HEAD points to a branch. - tmpFile := filepath.Join(tmpDir, "init.txt") - require.NoError(t, os.WriteFile(tmpFile, []byte("init"), 0o644)) - runGit("add", "init.txt") - runGit("commit", "-m", "initial commit") - - // Create and checkout a feature branch. - runGit("checkout", "-b", "feature/my-branch") - - repo, err := tea_git.RepoFromPath(tmpDir) - require.NoError(t, err) - - ctx := &context.TeaContext{ - Owner: "alice", - Repo: "proj", - LocalRepo: 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.go b/cmd/attachments.go deleted file mode 100644 index 52f14020..00000000 --- a/cmd/attachments.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "gitea.dev/tea/cmd/attachments" - "gitea.dev/tea/cmd/flags" - - "github.com/urfave/cli/v3" -) - -// CmdReleaseAttachments represents a release attachment (file attachment) -var CmdReleaseAttachments = cli.Command{ - Name: "assets", - Aliases: []string{"asset", "a"}, - Category: catEntities, - Usage: "Manage release assets", - Description: "Manage release assets", - ArgsUsage: " ", // command does not accept arguments - Action: attachments.RunReleaseAttachmentList, - Commands: []*cli.Command{ - &attachments.CmdReleaseAttachmentList, - &attachments.CmdReleaseAttachmentCreate, - &attachments.CmdReleaseAttachmentDelete, - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/attachments/create.go b/cmd/attachments/create.go deleted file mode 100644 index d35c5646..00000000 --- a/cmd/attachments/create.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package attachments - -import ( - stdctx "context" - "fmt" - "os" - "path/filepath" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/cmd/releases" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdReleaseAttachmentCreate represents a sub command of Release Attachments to create a release attachment -var CmdReleaseAttachmentCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create one or more release attachments", - Description: `Create one or more release attachments`, - ArgsUsage: " [...]", - Action: runReleaseAttachmentCreate, - Flags: flags.AllDefaultFlags, -} - -func runReleaseAttachmentCreate(requestCtx 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() - - if ctx.Args().Len() < 2 { - 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") - } - - release, err := releases.GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) - if err != nil { - return err - } - - for _, asset := range ctx.Args().Slice()[1:] { - var file *os.File - if file, err = os.Open(asset); err != nil { - return err - } - - filePath := filepath.Base(asset) - - if _, _, err = ctx.Login.Client().Releases.CreateReleaseAttachment(requestCtx, ctx.Owner, ctx.Repo, release.ID, file, filePath); err != nil { - file.Close() - return err - } - - file.Close() - } - - return nil -} diff --git a/cmd/attachments/delete.go b/cmd/attachments/delete.go deleted file mode 100644 index 7a806a15..00000000 --- a/cmd/attachments/delete.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package attachments - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/cmd/releases" - "gitea.dev/tea/modules/context" - "github.com/urfave/cli/v3" -) - -// CmdReleaseAttachmentDelete represents a sub command of Release Attachments to delete a release attachment -var CmdReleaseAttachmentDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete one or more release attachments", - Description: `Delete one or more release attachments`, - ArgsUsage: " [...]", - Action: runReleaseAttachmentDelete, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "confirm", - Aliases: []string{"y"}, - Usage: "Confirm deletion (required)", - }, - }, flags.AllDefaultFlags...), -} - -func runReleaseAttachmentDelete(requestCtx 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() - - if ctx.Args().Len() < 2 { - 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") - } - - if !ctx.Bool("confirm") { - fmt.Println("Are you sure? Please confirm with -y or --confirm.") - return nil - } - - release, err := releases.GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) - if err != nil { - return err - } - - var existing []*gitea.Attachment - for page := 1; ; { - page_attachments, resp, err := client.Releases.ListReleaseAttachments(requestCtx, 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:] { - var attachment *gitea.Attachment - for _, a := range existing { - if a.Name == name { - attachment = a - } - } - if attachment == nil { - return fmt.Errorf("release does not have attachment named '%s'", name) - } - - _, err = client.Releases.DeleteReleaseAttachment(requestCtx, ctx.Owner, ctx.Repo, release.ID, attachment.ID) - if err != nil { - return err - } - } - - return nil -} diff --git a/cmd/attachments/list.go b/cmd/attachments/list.go deleted file mode 100644 index 4b512915..00000000 --- a/cmd/attachments/list.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package attachments - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/cmd/releases" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdReleaseAttachmentList represents a sub command of release attachment to list release attachments -var CmdReleaseAttachmentList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List Release Attachments", - Description: "List Release Attachments", - ArgsUsage: "", // command does not accept arguments - Action: RunReleaseAttachmentList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunReleaseAttachmentList list release attachments -func RunReleaseAttachmentList(requestCtx 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() - - tag := ctx.Args().First() - if len(tag) == 0 { - return fmt.Errorf("release tag needed to list attachments") - } - - release, err := releases.GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) - if err != nil { - return err - } - - attachments, _, err := ctx.Login.Client().Releases.ListReleaseAttachments(requestCtx, ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.ReleaseAttachmentsList(attachments, ctx.Output) -} diff --git a/cmd/branches.go b/cmd/branches.go deleted file mode 100644 index 6b646d6b..00000000 --- a/cmd/branches.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "context" - - "gitea.dev/tea/cmd/branches" - - "github.com/urfave/cli/v3" -) - -// CmdBranches represents to login a gitea server. -var CmdBranches = cli.Command{ - Name: "branches", - Aliases: []string{"branch", "b"}, - Category: catEntities, - Usage: "Consult branches", - Description: `Lists branches when called without argument. If a branch is provided, will show it in detail.`, - ArgsUsage: "[]", - Action: runBranches, - Commands: []*cli.Command{ - &branches.CmdBranchesList, - &branches.CmdBranchesProtect, - &branches.CmdBranchesUnprotect, - &branches.CmdBranchesRename, - }, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "comments", - Usage: "Whether to display comments (will prompt if not provided & run interactively)", - }, - }, branches.CmdBranchesList.Flags...), -} - -func runBranches(ctx context.Context, cmd *cli.Command) error { - return branches.RunBranchesList(ctx, cmd) -} diff --git a/cmd/branches/list.go b/cmd/branches/list.go deleted file mode 100644 index 6c559616..00000000 --- a/cmd/branches/list.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package branches - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -var branchFieldsFlag = flags.FieldsFlag(print.BranchFields, []string{ - "name", "protected", "user-can-merge", "user-can-push", -}) - -// CmdBranchesListFlags Flags for command list -var CmdBranchesListFlags = append([]cli.Flag{ - branchFieldsFlag, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, -}, flags.AllDefaultFlags...) - -// CmdBranchesList represents a sub command of branches to list branches -var CmdBranchesList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List branches of the repository", - Description: `List branches of the repository`, - ArgsUsage: " ", // command does not accept arguments - Action: RunBranchesList, - Flags: CmdBranchesListFlags, -} - -// RunBranchesList list branches -func RunBranchesList(requestCtx 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 - } - - owner := ctx.Owner - if ctx.IsSet("owner") { - owner = ctx.String("owner") - } - - var branches []*gitea.Branch - var protections []*gitea.BranchProtection - branches, _, err = ctx.Login.Client().Repositories.ListRepoBranches(requestCtx, owner, ctx.Repo, gitea.ListRepoBranchesOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - protections, _, err = ctx.Login.Client().Repositories.ListBranchProtections(requestCtx, owner, ctx.Repo, gitea.ListBranchProtectionsOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - fields, err := branchFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - return print.BranchesList(branches, protections, ctx.Output, fields) -} diff --git a/cmd/branches/protect.go b/cmd/branches/protect.go deleted file mode 100644 index 84417fe4..00000000 --- a/cmd/branches/protect.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package branches - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "github.com/urfave/cli/v3" -) - -// CmdBranchesProtectFlags Flags for command protect/unprotect -var CmdBranchesProtectFlags = append([]cli.Flag{ - branchFieldsFlag, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, -}, flags.AllDefaultFlags...) - -// CmdBranchesProtect represents a sub command of branches to protect a branch -var CmdBranchesProtect = cli.Command{ - Name: "protect", - Aliases: []string{"P"}, - Usage: "Protect branches", - Description: `Block actions push/merge on specified branches`, - ArgsUsage: "", - Action: RunBranchesProtect, - Flags: CmdBranchesProtectFlags, -} - -// CmdBranchesUnprotect represents a sub command of branches to protect a branch -var CmdBranchesUnprotect = cli.Command{ - Name: "unprotect", - Aliases: []string{"U"}, - Usage: "Unprotect branches", - Description: `Suppress existing protections on specified branches`, - ArgsUsage: "", - Action: RunBranchesProtect, - Flags: CmdBranchesProtectFlags, -} - -// RunBranchesProtect function to protect/unprotect a list of branches -func RunBranchesProtect(requestCtx 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 branch") - } - - owner := ctx.Owner - if ctx.IsSet("owner") { - owner = ctx.String("owner") - } - - for _, branch := range ctx.Args().Slice() { - - var err error - command := ctx.Command.Name - if command == "protect" { - _, _, err = ctx.Login.Client().Repositories.CreateBranchProtection(requestCtx, owner, ctx.Repo, gitea.CreateBranchProtectionOption{ - BranchName: branch, - RuleName: "", - EnablePush: false, - EnablePushWhitelist: false, - PushWhitelistUsernames: []string{}, - PushWhitelistTeams: []string{}, - PushWhitelistDeployKeys: false, - EnableMergeWhitelist: false, - MergeWhitelistUsernames: []string{}, - MergeWhitelistTeams: []string{}, - EnableStatusCheck: false, - StatusCheckContexts: []string{}, - RequiredApprovals: 1, - EnableApprovalsWhitelist: false, - ApprovalsWhitelistUsernames: []string{}, - ApprovalsWhitelistTeams: []string{}, - BlockOnRejectedReviews: false, - BlockOnOfficialReviewRequests: false, - BlockOnOutdatedBranch: false, - DismissStaleApprovals: false, - RequireSignedCommits: false, - ProtectedFilePatterns: "", - UnprotectedFilePatterns: "", - }) - } else if command == "unprotect" { - _, err = ctx.Login.Client().Repositories.DeleteBranchProtection(requestCtx, owner, ctx.Repo, branch) - } else { - return fmt.Errorf("command %s is not supported", command) - } - - if err != nil { - return err - } - } - - return nil -} diff --git a/cmd/branches/rename.go b/cmd/branches/rename.go deleted file mode 100644 index 85a0935c..00000000 --- a/cmd/branches/rename.go +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package branches - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "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(requestCtx 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().Repositories.RenameRepoBranch(requestCtx, 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 deleted file mode 100644 index cf3efd7a..00000000 --- a/cmd/branches/rename_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// 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/categories.go b/cmd/categories.go deleted file mode 100644 index b914532c..00000000 --- a/cmd/categories.go +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -var ( - catSetup = "SETUP" - catEntities = "ENTITIES" - catHelpers = "HELPERS" - catMisc = "MISCELLANEOUS" -) diff --git a/cmd/clone.go b/cmd/clone.go deleted file mode 100644 index c636a39f..00000000 --- a/cmd/clone.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/debug" - "gitea.dev/tea/modules/git" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdRepoClone represents a sub command of repos to create a local copy -var CmdRepoClone = cli.Command{ - Name: "clone", - Aliases: []string{"C"}, - Usage: "Clone a repository locally", - Description: `Clone a repository locally, without a local git installation required. -The repo slug can be specified in different formats: - gitea/tea - tea - gitea.com/gitea/tea - git@gitea.com:gitea/tea - https://gitea.com/gitea/tea - ssh://gitea.com:22/gitea/tea -When a host is specified in the repo-slug, it will override the login specified with --login. - `, - Category: catHelpers, - Action: runRepoClone, - ArgsUsage: " [target dir]", - Flags: []cli.Flag{ - &cli.IntFlag{ - Name: "depth", - Aliases: []string{"d"}, - Usage: "num commits to fetch, defaults to all", - }, - &flags.LoginFlag, - }, -} - -func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { - teaCmd, err := context.InitCommand(cmd) - if err != nil { - return err - } - - args := teaCmd.Args() - if args.Len() < 1 { - return cli.ShowCommandHelp(ctx, cmd, "clone") - } - dir := args.Get(1) - - var ( - login *config.Login = teaCmd.Login - owner string - repo string - ) - - // parse first arg as repo specifier - repoSlug := args.Get(0) - url, err := git.ParseURL(repoSlug) - if err != nil { - return err - } - - debug.Printf("Cloning repository %s into %s", url.String(), dir) - - owner, repo = utils.GetOwnerAndRepo(url.Path, login.User) - if 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) - } - debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host) - } - - _, err = task.RepoClone( - ctx, - dir, - login, - owner, - repo, - interact.PromptPassword, - teaCmd.Int("depth"), - ) - - return err -} diff --git a/cmd/cmd.go b/cmd/cmd.go deleted file mode 100644 index a2306d9c..00000000 --- a/cmd/cmd.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -// Tea is command line tool for Gitea. -package cmd // import "gitea.dev/tea" - -import ( - "fmt" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/modules/version" -) - -// 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 { - // make parsing tea --version easier, by printing /just/ the version string - cli.VersionPrinter = func(c *cli.Command) { fmt.Fprintln(c.Writer, c.Version) } - - return &cli.Command{ - Name: "tea", - Usage: "command line tool to interact with Gitea", - Description: appDescription, - CustomHelpTemplate: helpTemplate, - Version: version.Format(), - Commands: []*cli.Command{ - &CmdLogin, - &CmdLogout, - &CmdWhoami, - - &CmdIssues, - &CmdPulls, - &CmdLabels, - &CmdMilestones, - &CmdReleases, - &CmdTrackedTimes, - &CmdOrgs, - &CmdRepos, - &CmdBranches, - &CmdActions, - &CmdWiki, - &CmdWebhooks, - &CmdComments, - - &CmdOpen, - &CmdNotifications, - &CmdRepoClone, - - &CmdSSHKeys, - - &CmdAdmin, - - &CmdApi, - &CmdGenerateManPage, - }, - EnableShellCompletion: true, - } -} - -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'. - -tea tries to make use of context provided by the repository in $PWD if available. -tea works best in a upstream/fork workflow, when the local main branch tracks the -upstream repo. tea assumes that local git state is published on the remote before -doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. -` - -var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", ` - {{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + ` - {{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}} - - USAGE - {{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}}{{if .Commands}} command [subcommand] [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Description}} - - DESCRIPTION - {{.Description | nindent 3 | trim}}{{end}}{{if .VisibleCommands}} - - COMMANDS{{range .VisibleCategories}}{{if .Name}} - {{.Name}}:{{range .VisibleCommands}} - {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{else}}{{range .VisibleCommands}} - {{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}}{{end}}{{if .VisibleFlags}} - - OPTIONS - {{range $index, $option := .VisibleFlags}}{{if $index}} - {{end}}{{$option}}{{end}}{{end}} - - EXAMPLES - tea login add # add a login once to get started - - tea pulls # list open pulls for the repo in $PWD - tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo - tea pulls --remote upstream # list open pulls for the repo pointed at by - # your local "upstream" git remote - # list open pulls for any gitea repo at the given login instance - tea pulls --repo gitea/tea --login gitea.com - - tea milestone issues 0.7.0 # view open issues for milestone '0.7.0' - tea issue 189 # view contents of issue 189 - tea open 189 # open web ui for issue 189 - tea open milestones # open web ui for milestones - - # send gitea desktop notifications every 5 minutes (bash + libnotify) - while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done - - ABOUT - Written & maintained by The Gitea Authors. - 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. -` diff --git a/cmd/comment.go b/cmd/comment.go deleted file mode 100644 index 1434c70b..00000000 --- a/cmd/comment.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "gitea.dev/tea/cmd/comments" - - "github.com/urfave/cli/v3" -) - -// CmdComments is the top-level command for managing comments on issues and pull requests. -var CmdComments = cli.Command{ - Name: "comments", - Aliases: []string{"comment", "c"}, - Category: catEntities, - Usage: "Manage comments on issues and pull requests", - Description: `Manage comments on issues and pull requests. - -When invoked with an issue/PR index and an optional body, behaves like 'tea comments add' -(this preserves the historical 'tea comment ""' shorthand).`, - ArgsUsage: " []", - Action: comments.RunCommentsAdd, - Flags: comments.CmdCommentsAdd.Flags, - Commands: []*cli.Command{ - &comments.CmdCommentsAdd, - &comments.CmdCommentsList, - &comments.CmdCommentsEdit, - &comments.CmdCommentsDelete, - }, -} diff --git a/cmd/comments/add.go b/cmd/comments/add.go deleted file mode 100644 index 601006c7..00000000 --- a/cmd/comments/add.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package comments - -import ( - stdctx "context" - "errors" - "fmt" - "strings" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/theme" - "gitea.dev/tea/modules/utils" - - "charm.land/huh/v2" - "github.com/urfave/cli/v3" -) - -// CmdCommentsAdd adds a comment to an issue or pull request. -var CmdCommentsAdd = cli.Command{ - Name: "add", - Aliases: []string{"a"}, - Usage: "Add a comment to an issue or pull request", - Description: "Add a comment to an issue or pull request.", - ArgsUsage: " []", - Action: RunCommentsAdd, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "description", - Aliases: []string{"d"}, - Usage: "comment body (alternative to the positional argument)", - }, - }, flags.AllDefaultFlags...), -} - -// RunCommentsAdd creates a new comment. -func RunCommentsAdd(requestCtx 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() == 0 { - return fmt.Errorf("please specify issue / pr index") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - stdinPiped := interact.IsStdinPiped() - body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader) - if err != nil { - return err - } - if len(body) == 0 && !stdinPiped { - if err := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Comment(markdown):"). - ExternalEditor(config.GetPreferences().Editor). - EditorExtension("md"). - Value(&body), - ), - ).WithTheme(theme.GetTheme()). - Run(); err != nil { - return err - } - } - - if len(body) == 0 { - return errors.New("no comment content provided") - } - - client := ctx.Login.Client() - comment, _, err := client.Issues.CreateIssueComment(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.CreateIssueCommentOption{ - Body: body, - }) - if err != nil { - return err - } - - print.Comment(comment) - return nil -} diff --git a/cmd/comments/body.go b/cmd/comments/body.go deleted file mode 100644 index dcda8314..00000000 --- a/cmd/comments/body.go +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package comments - -import "io" - -// resolveBody picks the comment body from the non-interactive sources, in -// precedence order: -// -// 1. the positional argument (kept first for back-compat with the historical -// 'tea comment ""' shorthand), -// 2. the -d/--description flag (mirrors the body flag on 'issue create', -// 'issue edit' and 'pr create'), -// 3. piped stdin. -// -// stdin is only read when stdinPiped is true (a non-TTY stdin, e.g. CI, -// subshells or agent harnesses) and no body was supplied otherwise, so the -// command never blocks reading an interactive terminal when a body is already -// given. An empty result means the caller should fall back to the editor (when -// interactive) or error out. -func resolveBody(positional, description string, stdinPiped bool, stdin io.Reader) (string, error) { - if len(positional) != 0 { - return positional, nil - } - if len(description) != 0 { - return description, nil - } - if stdinPiped { - stdinBytes, err := io.ReadAll(stdin) - if err != nil { - return "", err - } - return string(stdinBytes), nil - } - return "", nil -} diff --git a/cmd/comments/body_test.go b/cmd/comments/body_test.go deleted file mode 100644 index 4c3ab1e3..00000000 --- a/cmd/comments/body_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package comments - -import ( - "io" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestResolveBody(t *testing.T) { - testCases := []struct { - name string - positional string - description string - stdinPiped bool - stdin string - expected string - }{ - { - name: "positional only", - positional: "from positional", - expected: "from positional", - }, - { - name: "description flag only", - description: "from -d", - expected: "from -d", - }, - { - name: "positional wins over description for back-compat", - positional: "from positional", - description: "from -d", - expected: "from positional", - }, - { - name: "description wins over piped stdin", - description: "from -d", - stdinPiped: true, - stdin: "from stdin", - expected: "from -d", - }, - { - name: "piped stdin used when nothing else given", - stdinPiped: true, - stdin: "from stdin", - expected: "from stdin", - }, - { - name: "stdin ignored when not piped (interactive terminal)", - stdinPiped: false, - stdin: "should never be read", - expected: "", - }, - { - name: "empty when no source provided", - stdinPiped: false, - expected: "", - }, - { - name: "piped but empty stdin yields empty body", - stdinPiped: true, - stdin: "", - expected: "", - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - body, err := resolveBody(tc.positional, tc.description, tc.stdinPiped, strings.NewReader(tc.stdin)) - require.NoError(t, err) - assert.Equal(t, tc.expected, body) - }) - } -} - -// TestResolveBodyDoesNotReadStdinWhenBodyGiven guards the original bug: when a -// body is supplied positionally (or via -d), stdin must not be consumed, so the -// command can never block on a non-TTY stdin under CI / agent harnesses. -func TestResolveBodyDoesNotReadStdinWhenBodyGiven(t *testing.T) { - reader := &trackingReader{} - - body, err := resolveBody("positional body", "", true, reader) - require.NoError(t, err) - assert.Equal(t, "positional body", body) - assert.False(t, reader.read, "stdin must not be read when a body is supplied") -} - -// trackingReader records whether Read was ever called. -type trackingReader struct { - read bool -} - -func (r *trackingReader) Read(p []byte) (int, error) { - r.read = true - return 0, io.EOF -} diff --git a/cmd/comments/delete.go b/cmd/comments/delete.go deleted file mode 100644 index b4d94a80..00000000 --- a/cmd/comments/delete.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package comments - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdCommentsDelete deletes one or more comments by ID. -var CmdCommentsDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete one or more comments by ID", - Description: "Delete one or more comments by their comment ID. Use 'tea comments list ' to find IDs.", - ArgsUsage: " [...]", - Action: RunCommentsDelete, - Flags: flags.AllDefaultFlags, -} - -// RunCommentsDelete removes one or more comments. -func RunCommentsDelete(requestCtx 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() == 0 { - return fmt.Errorf("please specify at least one comment id") - } - - ids, err := utils.ArgsToIndices(ctx.Args().Slice()) - if err != nil { - return fmt.Errorf("invalid comment id: %s", err) - } - - client := ctx.Login.Client() - for _, id := range ids { - if _, err := client.Issues.DeleteIssueComment(requestCtx, ctx.Owner, ctx.Repo, id); err != nil { - return fmt.Errorf("could not delete comment %d: %s", id, err) - } - fmt.Printf("Deleted comment %d\n", id) - } - return nil -} diff --git a/cmd/comments/edit.go b/cmd/comments/edit.go deleted file mode 100644 index 45da15d3..00000000 --- a/cmd/comments/edit.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package comments - -import ( - stdctx "context" - "errors" - "fmt" - "strings" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/theme" - "gitea.dev/tea/modules/utils" - - "charm.land/huh/v2" - "github.com/urfave/cli/v3" -) - -// CmdCommentsEdit edits an existing comment. -var CmdCommentsEdit = cli.Command{ - Name: "edit", - Aliases: []string{"e"}, - Usage: "Edit the body of an existing comment", - Description: `Edit the body of an existing comment by its comment ID. Use 'tea comments list ' to find IDs. - -The new body can be supplied as a positional argument, via -d/--description, piped on stdin, or (if none is given and stdin is a terminal) entered in your $EDITOR.`, - ArgsUsage: " []", - Action: RunCommentsEdit, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "description", - Aliases: []string{"d"}, - Usage: "new comment body (alternative to the positional argument)", - }, - }, flags.AllDefaultFlags...), -} - -// RunCommentsEdit updates the body of an existing comment. -func RunCommentsEdit(requestCtx 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() == 0 { - return fmt.Errorf("please specify comment id") - } - - id, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return fmt.Errorf("invalid comment id %q: %s", ctx.Args().First(), err) - } - - stdinPiped := interact.IsStdinPiped() - body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader) - if err != nil { - return err - } - if len(body) == 0 && !stdinPiped { - // Fetch current body to pre-populate the editor. - client := ctx.Login.Client() - current, _, fetchErr := client.Issues.GetIssueComment(requestCtx, ctx.Owner, ctx.Repo, id) - if fetchErr != nil { - return fmt.Errorf("could not fetch comment %d: %s", id, fetchErr) - } - body = current.Body - if err := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title("Comment(markdown):"). - ExternalEditor(config.GetPreferences().Editor). - EditorExtension("md"). - Value(&body), - ), - ).WithTheme(theme.GetTheme()). - Run(); err != nil { - return err - } - } - - if len(body) == 0 { - return errors.New("no comment content provided") - } - - client := ctx.Login.Client() - comment, _, err := client.Issues.EditIssueComment(requestCtx, ctx.Owner, ctx.Repo, id, gitea.EditIssueCommentOption{ - Body: body, - }) - if err != nil { - return err - } - - print.Comment(comment) - return nil -} diff --git a/cmd/comments/list.go b/cmd/comments/list.go deleted file mode 100644 index 4fe9d97d..00000000 --- a/cmd/comments/list.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package comments - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdCommentsList lists comments on an issue or pull request. -var CmdCommentsList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List comments on an issue or pull request", - Description: "List comments on an issue or pull request. Comment IDs returned here are the IDs accepted by 'tea comments edit' and 'tea comments delete'.", - ArgsUsage: "", - Action: RunCommentsList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunCommentsList lists comments on the given issue/PR. -func RunCommentsList(requestCtx 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() == 0 { - return fmt.Errorf("please specify issue / pr index") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - client := ctx.Login.Client() - comments, _, err := client.Issues.ListIssueComments(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListIssueCommentOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.CommentsList(comments, ctx.Output) -} diff --git a/cmd/detail_json.go b/cmd/detail_json.go deleted file mode 100644 index cde3c3fd..00000000 --- a/cmd/detail_json.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "encoding/json" - "io" - "time" - - "gitea.dev/sdk" -) - -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/body.go b/cmd/flags/body.go deleted file mode 100644 index 211e3d28..00000000 --- a/cmd/flags/body.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package flags - -import ( - "fmt" - "io" - "os" - - "golang.org/x/term" -) - -// stdinPiped reports whether stdin is not a terminal, e.g. when a description -// is piped from a file, command substitution, or a CI harness. -func stdinPiped() bool { - return !term.IsTerminal(int(os.Stdin.Fd())) -} - -// resolveCreateBody returns the issue/PR description for create commands. -// -// Precedence: -// 1. --description-file (read from the file, or stdin when the path is "-") -// 2. --description -// 3. piped stdin -func resolveCreateBody(description, descriptionFile string, descriptionFileSet, stdinPiped bool, stdin io.Reader) (string, error) { - if descriptionFileSet { - return readDescriptionSource(descriptionFile, stdin) - } - if description != "" { - return description, nil - } - if stdinPiped { - return readDescriptionStdin(stdin) - } - return "", nil -} - -// resolveEditBody returns the new issue/PR body when a description flag was -// provided, or nil when the caller should leave the body unchanged. -func resolveEditBody(description string, descriptionSet bool, descriptionFile string, descriptionFileSet bool, stdin io.Reader) (*string, error) { - if descriptionFileSet { - body, err := readDescriptionSource(descriptionFile, stdin) - if err != nil { - return nil, err - } - return &body, nil - } - if descriptionSet { - body := description - return &body, nil - } - return nil, nil -} - -func readDescriptionSource(source string, stdin io.Reader) (string, error) { - if source == "-" { - return readDescriptionStdin(stdin) - } - data, err := os.ReadFile(source) - if err != nil { - return "", fmt.Errorf("could not read description file %q: %w", source, err) - } - return string(data), nil -} - -func readDescriptionStdin(stdin io.Reader) (string, error) { - data, err := io.ReadAll(stdin) - if err != nil { - return "", fmt.Errorf("could not read description from stdin: %w", err) - } - return string(data), nil -} diff --git a/cmd/flags/body_test.go b/cmd/flags/body_test.go deleted file mode 100644 index dc3651bb..00000000 --- a/cmd/flags/body_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package flags - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestResolveCreateBody(t *testing.T) { - file := filepath.Join(t.TempDir(), "body.md") - require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600)) - - tests := []struct { - name string - description string - descriptionFile string - descriptionFileSet bool - stdinPiped bool - stdin string - want string - }{ - { - name: "description flag", - description: "from -d", - want: "from -d", - }, - { - name: "description file", - descriptionFile: file, - descriptionFileSet: true, - want: "from file", - }, - { - name: "description file wins over description", - description: "from -d", - descriptionFile: file, - descriptionFileSet: true, - want: "from file", - }, - { - name: "dash reads stdin", - descriptionFile: "-", - descriptionFileSet: true, - stdin: "from stdin", - want: "from stdin", - }, - { - name: "description wins over piped stdin", - description: "from -d", - stdinPiped: true, - stdin: "from stdin", - want: "from -d", - }, - { - name: "piped stdin", - stdinPiped: true, - stdin: "from stdin", - want: "from stdin", - }, - { - name: "empty description falls back to piped stdin", - description: "", - stdinPiped: true, - stdin: "from stdin", - want: "from stdin", - }, - { - name: "empty when no source provided", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := resolveCreateBody(tt.description, tt.descriptionFile, tt.descriptionFileSet, tt.stdinPiped, strings.NewReader(tt.stdin)) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestResolveEditBody(t *testing.T) { - file := filepath.Join(t.TempDir(), "body.md") - require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600)) - - tests := []struct { - name string - description string - descriptionSet bool - descriptionFile string - descriptionFileSet bool - stdin string - wantBody string - wantSet bool - }{ - { - name: "no description flag", - }, - { - name: "description flag", - description: "from -d", - descriptionSet: true, - wantBody: "from -d", - wantSet: true, - }, - { - name: "empty description clears body", - descriptionSet: true, - wantSet: true, - }, - { - name: "description file", - descriptionFile: file, - descriptionFileSet: true, - wantBody: "from file", - wantSet: true, - }, - { - name: "description file wins over description", - description: "from -d", - descriptionSet: true, - descriptionFile: file, - descriptionFileSet: true, - wantBody: "from file", - wantSet: true, - }, - { - name: "dash reads stdin", - descriptionFile: "-", - descriptionFileSet: true, - stdin: "from stdin", - wantBody: "from stdin", - wantSet: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := resolveEditBody(tt.description, tt.descriptionSet, tt.descriptionFile, tt.descriptionFileSet, strings.NewReader(tt.stdin)) - require.NoError(t, err) - if !tt.wantSet { - assert.Nil(t, got) - return - } - require.NotNil(t, got) - assert.Equal(t, tt.wantBody, *got) - }) - } -} - -func TestResolveDescriptionSourceError(t *testing.T) { - _, err := resolveCreateBody("", filepath.Join(t.TempDir(), "missing.md"), true, false, strings.NewReader("")) - require.ErrorContains(t, err, "could not read description file") -} diff --git a/cmd/flags/csvflag.go b/cmd/flags/csvflag.go deleted file mode 100644 index b3eb0972..00000000 --- a/cmd/flags/csvflag.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package flags - -import ( - "fmt" - "strings" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/modules/utils" -) - -// CsvFlag is a wrapper around cli.StringFlag, with an added GetValues() method -// to retrieve comma separated string values as a slice. -type CsvFlag struct { - cli.StringFlag - AvailableFields []string -} - -// NewCsvFlag creates a CsvFlag, while setting its usage string and default values -func NewCsvFlag(name, usage string, aliases, availableValues, defaults []string) *CsvFlag { - var availableDesc string - if len(availableValues) != 0 { - availableDesc = " Available values:" - } - return &CsvFlag{ - AvailableFields: availableValues, - StringFlag: cli.StringFlag{ - Name: name, - Aliases: aliases, - Value: strings.Join(defaults, ","), - Usage: fmt.Sprintf(`Comma-separated list of %s.%s - %s - `, usage, availableDesc, strings.Join(availableValues, ",")), - }, - } -} - -// GetValues returns the value of the flag, parsed as a commaseparated list -func (f CsvFlag) GetValues(cmd *cli.Command) ([]string, error) { - val := cmd.String(f.Name) - selection := strings.Split(val, ",") - if f.AvailableFields != nil && val != "" { - for _, field := range selection { - if !utils.Contains(f.AvailableFields, field) { - return nil, fmt.Errorf("invalid field '%s'", field) - } - } - } - return selection, nil -} diff --git a/cmd/flags/generic.go b/cmd/flags/generic.go deleted file mode 100644 index 56bb65d5..00000000 --- a/cmd/flags/generic.go +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package flags - -import ( - "errors" - "fmt" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" -) - -// LoginFlag provides flag to specify tea login profile -var LoginFlag = cli.StringFlag{ - Name: "login", - Aliases: []string{"l"}, - Usage: "Use a different Gitea Login. Optional", -} - -// RepoFlag provides flag to specify repository -var RepoFlag = cli.StringFlag{ - Name: "repo", - Aliases: []string{"r"}, - Usage: "Override local repository path or gitea repository slug to interact with. Optional", -} - -// RemoteFlag provides flag to specify remote repository -var RemoteFlag = cli.StringFlag{ - Name: "remote", - Aliases: []string{"R"}, - Usage: "Discover Gitea login from remote. Optional", -} - -// OutputFlag provides flag to specify output type -var OutputFlag = cli.StringFlag{ - Name: "output", - Aliases: []string{"o"}, - Usage: "Output format. (simple, table, csv, tsv, yaml, json)", -} - -var ( - // 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") -) - -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 -var PaginationFlags = []cli.Flag{ - &PaginationPageFlag, - &PaginationLimitFlag, -} - -// PaginationPageFlag provides flag for pagination options -var PaginationPageFlag = cli.IntFlag{ - Name: "page", - Aliases: []string{"p"}, - Usage: "specify page", - Value: defaultPageValue, - Validator: func(i int) error { - if i < 1 && i != -1 { - return ErrPage - } - return nil - }, -} - -// PaginationLimitFlag provides flag for pagination options -var PaginationLimitFlag = cli.IntFlag{ - Name: "limit", - Aliases: []string{"lm"}, - Usage: "specify limit of items per page", - Value: defaultLimitValue, - Validator: func(i int) error { - if i < 0 { - return ErrLimit - } - return nil - }, -} - -// LoginOutputFlags defines login and output flags that should -// added to all subcommands and appended to the flags of the -// subcommand to work around issue and provide --login and --output: -// https://github.com/urfave/cli/issues/585 -var LoginOutputFlags = []cli.Flag{ - &LoginFlag, - &OutputFlag, -} - -// LoginRepoFlags defines login and repo flags that should -// be used for all subcommands and appended to the flags of -// the subcommand to work around issue and provide --login and --repo: -// https://github.com/urfave/cli/issues/585 -var LoginRepoFlags = []cli.Flag{ - &LoginFlag, - &RepoFlag, - &RemoteFlag, -} - -// AllDefaultFlags defines flags that should be available -// for all subcommands working with dedicated repositories -// to work around issue and provide --login, --repo and --output: -// https://github.com/urfave/cli/issues/585 -var AllDefaultFlags = append([]cli.Flag{ - &RepoFlag, - &RemoteFlag, -}, LoginOutputFlags...) - -// NotificationFlags defines flags that should be available on notifications. -var NotificationFlags = append([]cli.Flag{ - NotificationStateFlag, - &cli.BoolFlag{ - Name: "mine", - Aliases: []string{"m"}, - Usage: "Show notifications across all your repositories instead of the current repository only", - }, - &PaginationPageFlag, - &PaginationLimitFlag, -}, AllDefaultFlags...) - -// NotificationStateFlag is a csv flag applied to all notification subcommands as filter -var NotificationStateFlag = NewCsvFlag( - "states", - "notification states to filter by", - []string{"s"}, - []string{"pinned", "unread", "read"}, - []string{"unread", "pinned"}, -) - -// FieldsFlag generates a flag selecting printable fields. -// To retrieve the value, use f.GetValues() -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 deleted file mode 100644 index f98cc8c8..00000000 --- a/cmd/flags/generic_test.go +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package flags - -import ( - "context" - "io" - "testing" - - gitea "gitea.dev/sdk" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" -) - -func TestPaginationFlags(t *testing.T) { - var ( - defaultPage = PaginationPageFlag.Value - defaultLimit = PaginationLimitFlag.Value - ) - - cases := []struct { - name string - args []string - expectedPage int - expectedLimit int - }{ - { - name: "no flags", - args: []string{"test"}, - expectedPage: defaultPage, - expectedLimit: defaultLimit, - }, - { - name: "only paging", - args: []string{"test", "--page", "5"}, - expectedPage: 5, - expectedLimit: defaultLimit, - }, - { - name: "only limit", - args: []string{"test", "--limit", "10"}, - expectedPage: defaultPage, - expectedLimit: 10, - }, - { - name: "only limit", - args: []string{"test", "--limit", "10"}, - expectedPage: defaultPage, - expectedLimit: 10, - }, - { - name: "both flags", - args: []string{"test", "--page", "2", "--limit", "20"}, - 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 - name: "no paging", - args: []string{"test", "--limit", "20", "--page", "-1"}, - expectedPage: -1, - expectedLimit: 20, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - cmd := cli.Command{ - Name: "test-paging", - Action: func(_ context.Context, cmd *cli.Command) error { - assert.Equal(t, tc.expectedPage, cmd.Int("page")) - assert.Equal(t, tc.expectedLimit, cmd.Int("limit")) - return nil - }, - Flags: PaginationFlags, - } - err := cmd.Run(t.Context(), tc.args) - require.NoError(t, err) - }) - } -} - -func TestPaginationFailures(t *testing.T) { - cases := []struct { - name string - args []string - expectedError error - }{ - { - name: "negative limit", - args: []string{"test", "--limit", "-10"}, - expectedError: ErrLimit, - }, - { - name: "negative paging", - args: []string{"test", "--page", "-2"}, - expectedError: ErrPage, - }, - { - name: "zero paging", - args: []string{"test", "--page", "0"}, - expectedError: ErrPage, - }, - { - // urfave does not validate all flags in one pass - name: "negative paging and paging", - args: []string{"test", "--page", "-2", "--limit", "-10"}, - expectedError: ErrPage, - }, - } - - for _, tc := range cases { - cmd := cli.Command{ - Name: "test-paging", - Flags: PaginationFlags, - Writer: io.Discard, - ErrWriter: io.Discard, - } - t.Run(tc.name, func(t *testing.T) { - err := cmd.Run(t.Context(), tc.args) - require.ErrorContains(t, err, tc.expectedError.Error()) - // require.ErrorIs(t, err, tc.expectedError) - }) - } -} - -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(t.Context(), 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 deleted file mode 100644 index 3259c885..00000000 --- a/cmd/flags/issue_pr.go +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package flags - -import ( - stdctx "context" - "fmt" - "strings" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/task" - - "github.com/araddon/dateparse" - "github.com/urfave/cli/v3" -) - -// StateFlag provides flag to specify issue/pr state, defaulting to "open" -var StateFlag = cli.StringFlag{ - Name: "state", - Usage: "Filter by state (all|open|closed)", - DefaultText: "open", -} - -// MilestoneFilterFlag is a CSV flag used to filter issues by milestones -var MilestoneFilterFlag = NewCsvFlag( - "milestones", - "milestones to match issues against", - []string{"m"}, nil, nil) - -// LabelFilterFlag is a CSV flag used to filter issues by labels -var LabelFilterFlag = NewCsvFlag( - "labels", - "labels to match issues against", - []string{"L"}, nil, nil) - -// PRListingFlags defines flags that should be available on pr listing flags. -var PRListingFlags = append([]cli.Flag{ - &StateFlag, - &PaginationPageFlag, - &PaginationLimitFlag, -}, AllDefaultFlags...) - -// IssueListingFlags defines flags that should be available on issue listing flags. -var IssueListingFlags = append([]cli.Flag{ - &StateFlag, - &cli.StringFlag{ - Name: "kind", - Aliases: []string{"K"}, - Usage: "Whether to return `issues`, `pulls`, or `all` (you can use this to apply advanced search filters to PRs)", - DefaultText: "issues", - }, - &cli.StringFlag{ - Name: "keyword", - Aliases: []string{"k"}, - Usage: "Filter by search string", - }, - LabelFilterFlag, - MilestoneFilterFlag, - &cli.StringFlag{ - Name: "author", - Aliases: []string{"A"}, - }, - &cli.StringFlag{ - Name: "assignee", - Aliases: []string{"a"}, - }, - &cli.StringFlag{ - Name: "mentions", - Aliases: []string{"M"}, - }, - &cli.StringFlag{ - Name: "owner", - Aliases: []string{"org"}, - }, - &cli.StringFlag{ - Name: "from", - Aliases: []string{"F"}, - Usage: "Filter by activity after this date", - }, - &cli.StringFlag{ - Name: "until", - Aliases: []string{"u"}, - Usage: "Filter by activity before this date", - }, - &PaginationPageFlag, - &PaginationLimitFlag, -}, AllDefaultFlags...) - -// issuePRFlags defines shared flags between flags IssuePRCreateFlags and IssuePREditFlags -var issuePRFlags = append([]cli.Flag{ - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - }, - &cli.StringFlag{ - Name: "description", - Aliases: []string{"d"}, - }, - &cli.StringFlag{ - Name: "description-file", - Usage: "Read description from file ('-' for stdin)", - }, - &cli.StringFlag{ - Name: "referenced-version", - Aliases: []string{"v"}, - Usage: "commit-hash or tag name to assign", - }, - &cli.StringFlag{ - Name: "milestone", - Aliases: []string{"m"}, - Usage: "Milestone to assign", - }, - &cli.StringFlag{ - Name: "deadline", - Aliases: []string{"D"}, - Usage: "Deadline timestamp to assign", - }, -}, LoginRepoFlags...) - -// IssuePRCreateFlags defines flags for creation of issues and PRs -var IssuePRCreateFlags = append([]cli.Flag{ - &cli.StringFlag{ - Name: "assignees", - Aliases: []string{"a"}, - Usage: "Comma-separated list of usernames to assign", - }, - &cli.StringFlag{ - Name: "labels", - Aliases: []string{"L"}, - Usage: "Comma-separated list of labels to assign", - }, -}, issuePRFlags...) - -// GetIssuePRCreateFlags parses all IssuePREditFlags -func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) { - body, err := resolveCreateBody( - ctx.String("description"), - ctx.String("description-file"), - ctx.IsSet("description-file"), - stdinPiped(), - ctx.Reader, - ) - if err != nil { - return nil, err - } - - opts := gitea.CreateIssueOption{ - Title: ctx.String("title"), - Body: body, - Assignees: strings.Split(ctx.String("assignees"), ","), - } - - date := ctx.String("deadline") - if date != "" { - t, err := dateparse.ParseAny(date) - if err != nil { - return nil, err - } - opts.Deadline = &t - } - - client := ctx.Login.Client() - - labelNames := strings.Split(ctx.String("labels"), ",") - if len(labelNames) != 0 { - if client == nil { - client = ctx.Login.Client() - } - if opts.Labels, err = task.ResolveLabelNames(requestCtx, client, ctx.Owner, ctx.Repo, labelNames); err != nil { - return nil, err - } - } - - if milestoneName := ctx.String("milestone"); len(milestoneName) != 0 { - if client == nil { - client = ctx.Login.Client() - } - ms, _, err := client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, milestoneName) - if err != nil { - return nil, fmt.Errorf("milestone '%s' not found", milestoneName) - } - opts.Milestone = ms.ID - } - - return &opts, nil -} - -// IssuePREditFlags defines flags for editing properties of issues and PRs -var IssuePREditFlags = append([]cli.Flag{ - &cli.StringFlag{ - Name: "set-assignees", - Usage: "Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees", - }, - &cli.StringFlag{ - Name: "add-assignees", - Aliases: []string{"a"}, - Usage: "Comma-separated list of usernames to assign. Takes precedence over --remove-assignees", - }, - &cli.StringFlag{ - Name: "remove-assignees", - Usage: "Comma-separated list of usernames to remove", - }, - &cli.StringFlag{ - Name: "add-labels", - Aliases: []string{"L"}, - Usage: "Comma-separated list of labels to assign. Takes precedence over --remove-labels", - }, - &cli.StringFlag{ - Name: "remove-labels", - Usage: "Comma-separated list of labels to remove", - }, -}, issuePRFlags...) - -// GetIssuePREditFlags parses all IssuePREditFlags -func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error) { - opts := task.EditIssueOption{} - if ctx.IsSet("title") { - val := ctx.String("title") - opts.Title = &val - } - body, err := resolveEditBody( - ctx.String("description"), - ctx.IsSet("description"), - ctx.String("description-file"), - ctx.IsSet("description-file"), - ctx.Reader, - ) - if err != nil { - return nil, err - } - if body != nil { - opts.Body = body - } - if ctx.IsSet("referenced-version") { - val := ctx.String("referenced-version") - opts.Ref = &val - } - if ctx.IsSet("milestone") { - val := ctx.String("milestone") - opts.Milestone = &val - } - if ctx.IsSet("deadline") { - date := ctx.String("deadline") - if date == "" { - opts.Deadline = &time.Time{} - } else { - t, err := dateparse.ParseAny(date) - if err != nil { - return nil, err - } - opts.Deadline = &t - } - } - if ctx.IsSet("set-assignees") { - val := ctx.String("set-assignees") - opts.SetAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") - } - if ctx.IsSet("add-assignees") { - val := ctx.String("add-assignees") - opts.AddAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") - } - if ctx.IsSet("remove-assignees") { - val := ctx.String("remove-assignees") - opts.RemoveAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",") - } - if ctx.IsSet("add-labels") { - val := ctx.String("add-labels") - opts.AddLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",") - } - if ctx.IsSet("remove-labels") { - val := ctx.String("remove-labels") - opts.RemoveLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",") - } - return &opts, nil -} diff --git a/cmd/issues.go b/cmd/issues.go deleted file mode 100644 index 7f3f4bea..00000000 --- a/cmd/issues.go +++ /dev/null @@ -1,174 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - "fmt" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/cmd/issues" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -type labelData = detailLabelData - -type issueData struct { - ID int64 `json:"id"` - Index int64 `json:"index"` - Title string `json:"title"` - State gitea.StateType `json:"state"` - Created time.Time `json:"created"` - Labels []labelData `json:"labels"` - User string `json:"user"` - Body string `json:"body"` - Assignees []string `json:"assignees"` - URL string `json:"url"` - ClosedAt *time.Time `json:"closedAt"` - Comments []commentData `json:"comments"` -} - -type issueDetailClient interface { - GetIssue(ctx stdctx.Context, owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) - ListIssueReactions(ctx stdctx.Context, owner, repo string, index int64, opt gitea.ListIssueReactionsOptions) ([]*gitea.Reaction, *gitea.Response, error) -} - -type issueCommentClient interface { - ListIssueComments(ctx stdctx.Context, 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", - Aliases: []string{"issue", "i"}, - Category: catEntities, - Usage: "List, create and update issues", - Description: `Lists issues when called without argument. If issue index is provided, will show it in detail.`, - ArgsUsage: "[]", - Action: runIssues, - Commands: []*cli.Command{ - &issues.CmdIssuesList, - &issues.CmdIssuesCreate, - &issues.CmdIssuesEdit, - &issues.CmdIssuesReopen, - &issues.CmdIssuesClose, - }, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "comments", - Usage: "Whether to display comments (will prompt if not provided & run interactively)", - }, - }, issues.CmdIssuesList.Flags...), -} - -func runIssues(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runIssueDetail(ctx, cmd, cmd.Args().First()) - } - return issues.RunIssuesList(ctx, cmd) -} - -func runIssueDetail(requestCtx stdctx.Context, cmd *cli.Command, index string) error { - ctx, idx, err := resolveIssueDetailContext(cmd, index) - if err != nil { - return err - } - - return runIssueDetailWithClient(requestCtx, ctx, idx, ctx.Login.Client().Issues) -} - -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(requestCtx stdctx.Context, ctx *context.TeaContext, idx int64, client issueDetailClient) error { - issue, _, err := client.GetIssue(requestCtx, ctx.Owner, ctx.Repo, idx) - if err != nil { - return err - } - reactions, _, err := client.ListIssueReactions(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListIssueReactionsOptions{}) - if err != nil { - return err - } - - if ctx.IsSet("output") { - switch ctx.String("output") { - case "json": - return runIssueDetailAsJSON(requestCtx, ctx, issue) - } - } - - print.IssueDetails(issue, reactions) - - if issue.Comments > 0 { - err = interact.ShowCommentsMaybeInteractive(requestCtx, ctx, idx, issue.Comments) - if err != nil { - return fmt.Errorf("error loading comments: %v", err) - } - } - - return nil -} - -func runIssueDetailAsJSON(requestCtx stdctx.Context, ctx *context.TeaContext, issue *gitea.Issue) error { - return runIssueDetailAsJSONWithClient(requestCtx, ctx, issue, ctx.Login.Client().Issues) -} - -func runIssueDetailAsJSONWithClient(requestCtx stdctx.Context, 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(requestCtx, ctx.Owner, ctx.Repo, issue.Index, opts) - if err != nil { - return err - } - } - - return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments)) -} - -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: username(issue.Poster), - Body: issue.Body, - Labels: buildDetailLabels(issue.Labels), - Assignees: buildDetailAssignees(issue.Assignees), - URL: issue.HTMLURL, - ClosedAt: issue.Closed, - Comments: buildDetailComments(comments), - } -} diff --git a/cmd/issues/close.go b/cmd/issues/close.go deleted file mode 100644 index 3b24d668..00000000 --- a/cmd/issues/close.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" - "github.com/urfave/cli/v3" -) - -// CmdIssuesClose represents a sub command of issues to close an issue -var CmdIssuesClose = cli.Command{ - Name: "close", - Usage: "Change state of one ore more issues to 'closed'", - Description: `Change state of one ore more issues to 'closed'`, - ArgsUsage: " [...]", - Action: func(ctx stdctx.Context, cmd *cli.Command) error { - s := gitea.StateClosed - return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s}) - }, - Flags: flags.AllDefaultFlags, -} - -// editIssueState abstracts the arg parsing to edit the given issue -func editIssueState(requestCtx stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) 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() == 0 { - return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) - } - - indices, err := utils.ArgsToIndices(ctx.Args().Slice()) - if err != nil { - return err - } - - client := ctx.Login.Client() - for _, index := range indices { - issue, _, err := client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, index, opts) - if err != nil { - return err - } - - if len(indices) > 1 { - fmt.Println(issue.HTMLURL) - } else { - print.IssueDetails(issue, nil) - } - } - return nil -} diff --git a/cmd/issues/create.go b/cmd/issues/create.go deleted file mode 100644 index 9cfd72bd..00000000 --- a/cmd/issues/create.go +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - stdctx "context" - "encoding/json" - "fmt" - "io" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" -) - -// CmdIssuesCreate represents a sub command of issues to create issue -var CmdIssuesCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create an issue on repository", - Description: `Create an issue on repository`, - ArgsUsage: " ", // command does not accept arguments - Action: runIssuesCreate, - Flags: flags.IssuePRCreateFlags, -} - -func runIssuesCreate(requestCtx 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.IsInteractiveMode() { - err := interact.CreateIssue(requestCtx, ctx.Login, ctx.Owner, ctx.Repo) - if err != nil && !interact.IsQuitting(err) { - return err - } - return nil - } - - opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx) - if err != nil { - return err - } - - issue, err := task.CreateIssue(requestCtx, ctx.Login, - ctx.Owner, - ctx.Repo, - *opts, - ) - if err != nil { - return err - } - - if ctx.IsSet("output") { - switch ctx.String("output") { - case "json": - return writeCreatedIssueAsJSON(ctx.Writer, issue) - } - } - - print.IssueDetails(issue, nil) - - fmt.Println(issue.HTMLURL) - - return nil -} - -// createdIssueJSON is the machine-readable representation of a freshly -// created issue, mirroring the create-PR equivalent in cmd/pulls/create.go -// (createdPullJSON). -type createdIssueJSON struct { - Index int64 `json:"index"` - Title string `json:"title"` - URL string `json:"url"` - State gitea.StateType `json:"state"` -} - -func writeCreatedIssueAsJSON(w io.Writer, issue *gitea.Issue) error { - return json.NewEncoder(w).Encode(createdIssueJSON{ - Index: issue.Index, - Title: issue.Title, - URL: issue.HTMLURL, - State: issue.State, - }) -} diff --git a/cmd/issues/create_test.go b/cmd/issues/create_test.go deleted file mode 100644 index 274b312a..00000000 --- a/cmd/issues/create_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - "bytes" - "encoding/json" - "testing" - - gitea "gitea.dev/sdk" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWriteCreatedIssueAsJSON(t *testing.T) { - issue := &gitea.Issue{ - Index: 42, - Title: "test title", - HTMLURL: "https://gitea.example.com/owner/repo/issues/42", - State: gitea.StateOpen, - } - - var buf bytes.Buffer - require.NoError(t, writeCreatedIssueAsJSON(&buf, issue)) - - var got map[string]any - require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) - - assert.Equal(t, float64(42), got["index"]) - assert.Equal(t, "test title", got["title"]) - assert.Equal(t, "https://gitea.example.com/owner/repo/issues/42", got["url"]) - assert.Equal(t, "open", got["state"]) - - // exactly the lean field set, nothing extra - assert.Len(t, got, 4) - - // machine-readable output must not contain terminal escape sequences - assert.NotContains(t, buf.String(), "\x1b") -} diff --git a/cmd/issues/edit.go b/cmd/issues/edit.go deleted file mode 100644 index 3635afa3..00000000 --- a/cmd/issues/edit.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - "fmt" - - stdctx "context" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" -) - -// CmdIssuesEdit is the subcommand of issues to edit issues -var CmdIssuesEdit = cli.Command{ - Name: "edit", - Aliases: []string{"e"}, - Usage: "Edit one or more issues", - Description: `Edit one or more issues. To unset a property again, -use an empty string (eg. --milestone "").`, - ArgsUsage: " [...]", - Action: runIssuesEdit, - Flags: flags.IssuePREditFlags, -} - -func runIssuesEdit(requestCtx 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 issue index") - } - - opts, err := flags.GetIssuePREditFlags(ctx) - if err != nil { - return err - } - - indices, err := utils.ArgsToIndices(ctx.Args().Slice()) - if err != nil { - return err - } - - client := ctx.Login.Client() - for _, opts.Index = range indices { - if ctx.IsInteractiveMode() { - var err error - opts, err = interact.EditIssue(requestCtx, *ctx, opts.Index) - if err != nil { - if interact.IsQuitting(err) { - return nil // user quit - } - return err - } - } - - issue, err := task.EditIssue(requestCtx, ctx, client, *opts) - if err != nil { - return err - } - if ctx.Args().Len() > 1 { - fmt.Println(issue.HTMLURL) - } else { - print.IssueDetails(issue, nil) - } - } - - return nil -} diff --git a/cmd/issues/list.go b/cmd/issues/list.go deleted file mode 100644 index 184c2fc6..00000000 --- a/cmd/issues/list.go +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - stdctx "context" - "errors" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/araddon/dateparse" - "github.com/urfave/cli/v3" -) - -var issueFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{ - "index", "title", "state", "author", "milestone", "labels", "owner", "repo", -}) - -// CmdIssuesList represents a sub command of issues to list issues -var CmdIssuesList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List issues of the repository", - Description: `List issues of the repository`, - ArgsUsage: " ", // command does not accept arguments - Action: RunIssuesList, - Flags: append([]cli.Flag{issueFieldsFlag}, flags.IssueListingFlags...), -} - -// RunIssuesList list issues -func RunIssuesList(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - 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 from, until time.Time - if ctx.IsSet("from") { - from, err = dateparse.ParseLocal(ctx.String("from")) - if err != nil { - return err - } - } - if ctx.IsSet("until") { - until, err = dateparse.ParseLocal(ctx.String("until")) - if err != nil { - return err - } - } - owner := ctx.Owner - if ctx.IsSet("owner") { - owner = ctx.String("owner") - } - - // ignore error, as we don't do any input validation on these flags - labels, _ := flags.LabelFilterFlag.GetValues(cmd) - milestones, _ := flags.MilestoneFilterFlag.GetValues(cmd) - var issues []*gitea.Issue - if ctx.Repo != "" { - issues, _, err = ctx.Login.Client().Issues.ListRepoIssues(requestCtx, owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(cmd), - State: state, - Type: kind, - KeyWord: ctx.String("keyword"), - CreatedBy: ctx.String("author"), - 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().Issues.ListIssues(requestCtx, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(cmd), - State: state, - Type: kind, - KeyWord: ctx.String("keyword"), - CreatedBy: ctx.String("author"), - MentionedBy: ctx.String("mentions"), - Labels: labels, - Milestones: milestones, - Since: from, - Before: until, - Owner: owner, - }) - if err != nil { - return err - } - } - - fields, err := issueFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - return print.IssuesPullsList(issues, ctx.Output, fields) -} diff --git a/cmd/issues/list_test.go b/cmd/issues/list_test.go deleted file mode 100644 index 2e265b39..00000000 --- a/cmd/issues/list_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - stdctx "context" - "crypto/ed25519" - "crypto/rand" - "crypto/x509" - "encoding/pem" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "gitea.dev/tea/modules/config" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" - "golang.org/x/crypto/ssh" -) - -func TestRunIssuesListWithSSHPubkeyLoginDoesNotDeadlock(t *testing.T) { - t.Parallel() - - sshKeyPath, fingerprint := writeTestSSHKey(t) - - var versionRequests atomic.Int32 - var issueRequests atomic.Int32 - var signedVersionRequests atomic.Int32 - var signedIssueRequests atomic.Int32 - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/api/v1/version": - versionRequests.Add(1) - if r.Header.Get("Signature") != "" { - signedVersionRequests.Add(1) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"version":"1.26.4"}`)) - case "/api/v1/repos/gitea/tea/issues": - issueRequests.Add(1) - if r.Header.Get("Signature") != "" { - signedIssueRequests.Add(1) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[]`)) - default: - t.Errorf("unexpected path %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - - config.SetConfigForTesting(config.LocalConfig{ - Logins: []config.Login{{ - Name: "ssh-login", - URL: server.URL, - SSHKey: sshKeyPath, - SSHKeyFingerprint: fingerprint, - VersionCheck: true, - Default: true, - }}, - }) - - cmd := cli.Command{ - Name: CmdIssuesList.Name, - Flags: CmdIssuesList.Flags, - } - require.NoError(t, cmd.Set("login", "ssh-login")) - require.NoError(t, cmd.Set("repo", "gitea/tea")) - require.NoError(t, cmd.Set("output", "json")) - - done := make(chan error, 1) - go func() { - done <- RunIssuesList(stdctx.Background(), &cmd) - }() - - select { - case err := <-done: - require.NoError(t, err) - case <-time.After(2 * time.Second): - t.Fatal("RunIssuesList deadlocked while bootstrapping the server version for HTTPSign authentication") - } - - assert.EqualValues(t, 1, versionRequests.Load()) - assert.EqualValues(t, 0, signedVersionRequests.Load()) - assert.EqualValues(t, 1, issueRequests.Load()) - assert.EqualValues(t, 1, signedIssueRequests.Load()) -} - -func writeTestSSHKey(t *testing.T) (string, string) { - t.Helper() - - _, privateKey, err := ed25519.GenerateKey(rand.Reader) - require.NoError(t, err) - - pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey) - require.NoError(t, err) - - pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) - sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") - require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600)) - - signer, err := ssh.NewSignerFromKey(privateKey) - require.NoError(t, err) - - return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey()) -} diff --git a/cmd/issues/reopen.go b/cmd/issues/reopen.go deleted file mode 100644 index 30ba85fd..00000000 --- a/cmd/issues/reopen.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issues - -import ( - "context" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "github.com/urfave/cli/v3" -) - -// CmdIssuesReopen represents a sub command of issues to open an issue -var CmdIssuesReopen = cli.Command{ - Name: "reopen", - Aliases: []string{"open"}, - Usage: "Change state of one or more issues to 'open'", - Description: `Change state of one or more issues to 'open'`, - ArgsUsage: " [...]", - Action: func(ctx context.Context, cmd *cli.Command) error { - 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 deleted file mode 100644 index 5a11acb8..00000000 --- a/cmd/issues_test.go +++ /dev/null @@ -1,360 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "bytes" - stdctx "context" - "encoding/json" - "fmt" - "testing" - "time" - - gitea "gitea.dev/sdk" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" -) - -const ( - testOwner = "testOwner" - testRepo = "testRepo" -) - -type fakeIssueCommentClient struct { - owner string - repo string - index int64 - comments []*gitea.Comment -} - -func (f *fakeIssueCommentClient) ListIssueComments(_ stdctx.Context, 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(_ stdctx.Context, 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) ListIssueReactions(_ stdctx.Context, owner, repo string, index int64, _ gitea.ListIssueReactionsOptions) ([]*gitea.Reaction, *gitea.Response, error) { - f.owner = owner - f.repo = repo - f.index = index - return f.reactions, nil, nil -} - -func toCommentPointers(comments []gitea.Comment) []*gitea.Comment { - result := make([]*gitea.Comment, 0, len(comments)) - for i := range comments { - comment := comments[i] - result = append(result, &comment) - } - return result -} - -func createTestIssue(comments int, isClosed bool) gitea.Issue { - issue := gitea.Issue{ - ID: 42, - Index: 1, - Title: "Test issue", - State: gitea.StateOpen, - Body: "This is a test", - Created: time.Date(2025, 31, 10, 23, 59, 59, 999999999, time.UTC), - Updated: time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC), - Labels: []*gitea.Label{ - { - Name: "example/Label1", - Color: "very red", - Description: "This is an example label", - }, - { - Name: "example/Label2", - Color: "hardly red", - Description: "This is another example label", - }, - }, - Comments: comments, - Poster: &gitea.User{ - UserName: "testUser", - }, - Assignees: []*gitea.User{ - {UserName: "testUser"}, - {UserName: "testUser3"}, - }, - HTMLURL: "", - Closed: nil, // 2025-11-10T21:20:19Z - } - - if isClosed { - closed := time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC) - issue.Closed = &closed - } - - if isClosed { - issue.State = gitea.StateClosed - } else { - issue.State = gitea.StateOpen - } - - return issue -} - -func createTestIssueComments(comments int) []gitea.Comment { - baseID := 900 - var result []gitea.Comment - - for commentID := 0; commentID < comments; commentID++ { - result = append(result, gitea.Comment{ - ID: int64(baseID + commentID), - Poster: &gitea.User{ - UserName: "Freddy", - }, - Body: fmt.Sprintf("This is a test comment #%v", commentID), - Created: time.Date(2025, 11, 3, 12, 0, 0, 0, time.UTC). - Add(time.Duration(commentID) * time.Hour), - }) - } - - return result -} - -func TestRunIssueDetailAsJSON(t *testing.T) { - type TestCase struct { - name string - issue gitea.Issue - comments []gitea.Comment - flagComments bool - } - - cmd := cli.Command{ - Name: "t", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "comments", - Value: false, - }, - &cli.StringFlag{ - Name: "output", - Value: "json", - }, - }, - } - - testContext := context.TeaContext{ - Owner: testOwner, - Repo: testRepo, - Login: &config.Login{ - Name: "testLogin", - URL: "http://127.0.0.1:8081", - }, - Command: &cmd, - } - - testCases := []TestCase{ - { - name: "Simple issue with no comments, no comments requested", - issue: createTestIssue(0, true), - comments: []gitea.Comment{}, - flagComments: false, - }, - { - name: "Simple issue with no comments, comments requested", - issue: createTestIssue(0, true), - comments: []gitea.Comment{}, - flagComments: true, - }, - { - name: "Simple issue with comments, no comments requested", - issue: createTestIssue(2, true), - comments: createTestIssueComments(2), - flagComments: false, - }, - { - name: "Simple issue with comments, comments requested", - issue: createTestIssue(2, true), - comments: createTestIssueComments(2), - flagComments: true, - }, - { - name: "Simple issue with comments, comments requested, not closed", - issue: createTestIssue(2, false), - comments: createTestIssueComments(2), - flagComments: true, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - client := &fakeIssueCommentClient{ - comments: toCommentPointers(testCase.comments), - } - - 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 - testContext.Writer = &outBuffer - var errBuffer bytes.Buffer - testContext.ErrWriter = &errBuffer - - if testCase.flagComments { - require.NoError(t, testContext.Set("comments", "true")) - } else { - require.NoError(t, testContext.Set("comments", "false")) - } - - err := runIssueDetailAsJSONWithClient(t.Context(), &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 - - var expectedLabels []labelData - expectedLabels = []labelData{} - for _, l := range testCase.issue.Labels { - expectedLabels = append(expectedLabels, labelData{ - Name: l.Name, - Color: l.Color, - Description: l.Description, - }) - } - - var expectedAssignees []string - expectedAssignees = []string{} - for _, a := range testCase.issue.Assignees { - expectedAssignees = append(expectedAssignees, a.UserName) - } - - var expectedClosedAt *time.Time - if testCase.issue.Closed != nil { - expectedClosedAt = testCase.issue.Closed - } - - var expectedComments []commentData - expectedComments = []commentData{} - if testCase.flagComments { - for _, c := range testCase.comments { - expectedComments = append(expectedComments, commentData{ - ID: c.ID, - Author: c.Poster.UserName, - Body: c.Body, - Created: c.Created, - }) - } - } - - expected := issueData{ - ID: testCase.issue.ID, - Index: testCase.issue.Index, - Title: testCase.issue.Title, - State: testCase.issue.State, - Created: testCase.issue.Created, - User: testCase.issue.Poster.UserName, - Body: testCase.issue.Body, - URL: testCase.issue.HTMLURL, - ClosedAt: expectedClosedAt, - Labels: expectedLabels, - Assignees: expectedAssignees, - Comments: expectedComments, - } - - // validating reality - var actual issueData - dec := json.NewDecoder(bytes.NewReader(outBuffer.Bytes())) - dec.DisallowUnknownFields() - err = dec.Decode(&actual) - require.NoError(t, err, "Failed to unmarshal output into struct") - - 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(t.Context(), 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 deleted file mode 100644 index b676de71..00000000 --- a/cmd/labels.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "context" - "fmt" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/labels" -) - -// CmdLabels represents to operate repositories' labels. -var CmdLabels = cli.Command{ - Name: "labels", - Aliases: []string{"label"}, - Category: catEntities, - Usage: "Manage issue labels", - Description: `Manage issue labels`, - ArgsUsage: " ", // command does not accept arguments - Action: runLabels, - Commands: []*cli.Command{ - &labels.CmdLabelsList, - &labels.CmdLabelCreate, - &labels.CmdLabelUpdate, - &labels.CmdLabelDelete, - }, - Flags: labels.CmdLabelsList.Flags, -} - -func runLabels(ctx context.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runLabelsDetails(cmd) - } - return labels.RunLabelsList(ctx, cmd) -} - -func runLabelsDetails(cmd *cli.Command) error { - return fmt.Errorf("not yet implemented") -} diff --git a/cmd/labels/create.go b/cmd/labels/create.go deleted file mode 100644 index aaea9a5c..00000000 --- a/cmd/labels/create.go +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package labels - -import ( - "bufio" - stdctx "context" - "log" - "os" - "strings" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "github.com/urfave/cli/v3" -) - -// CmdLabelCreate represents a sub command of labels to create label. -var CmdLabelCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create a label", - Description: `Create a label`, - ArgsUsage: " ", // command does not accept arguments - Action: runLabelCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "label name", - }, - &cli.StringFlag{ - Name: "color", - Usage: "label color value", - }, - &cli.StringFlag{ - Name: "description", - Usage: "label description", - }, - &cli.StringFlag{ - Name: "file", - Usage: "indicate a label file", - }, - }, flags.AllDefaultFlags...), -} - -func runLabelCreate(requestCtx 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 - } - - labelFile := ctx.String("file") - if len(labelFile) == 0 { - _, _, err := ctx.Login.Client().Repositories.CreateLabel(requestCtx, ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ - Name: ctx.String("name"), - Color: ctx.String("color"), - Description: ctx.String("description"), - }) - 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().Repositories.CreateLabel(requestCtx, 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) { - fields := strings.SplitN(line, ";", 2) - var color, name, description string - if len(fields) < 1 { - return "", "", "" - } else if len(fields) >= 2 { - description = strings.TrimSpace(fields[1]) - } - fields = strings.Fields(fields[0]) - if len(fields) <= 0 { - return "", "", "" - } - color = fields[0] - if len(fields) == 2 { - name = fields[1] - } else if len(fields) > 2 { - name = strings.Join(fields[1:], " ") - } - return color, name, description -} diff --git a/cmd/labels/create_test.go b/cmd/labels/create_test.go deleted file mode 100644 index f192cea7..00000000 --- a/cmd/labels/create_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package labels - -import ( - "bufio" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestParseLabelLine(t *testing.T) { - const labels = `#ededed in progress -#fbca04 kind/breaking ; breaking label -#fc2929 kind/bug -#c5def5 kind/deployment ; deployment label -#000000 in progress ; in progress label -` - - scanner := bufio.NewScanner(strings.NewReader(labels)) - i := 1 - for scanner.Scan() { - line := scanner.Text() - color, name, description := splitLabelLine(line) - - switch i { - case 1: - assert.EqualValues(t, "#ededed", color) - assert.EqualValues(t, "in progress", name) - case 2: - assert.EqualValues(t, "#fbca04", color) - assert.EqualValues(t, "kind/breaking", name) - assert.EqualValues(t, "breaking label", description) - case 3: - assert.EqualValues(t, "#fc2929", color) - assert.EqualValues(t, "kind/bug", name) - case 4: - assert.EqualValues(t, "#c5def5", color) - assert.EqualValues(t, "kind/deployment", name) - assert.EqualValues(t, "deployment label", description) - case 5: - assert.EqualValues(t, "#000000", color) - assert.EqualValues(t, "in progress", name) - assert.EqualValues(t, "in progress label", description) - } - - i++ - } -} diff --git a/cmd/labels/delete.go b/cmd/labels/delete.go deleted file mode 100644 index b7b2a7ad..00000000 --- a/cmd/labels/delete.go +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package labels - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdLabelDelete represents a sub command of labels to delete label. -var CmdLabelDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete a label", - Description: `Delete a label`, - ArgsUsage: " ", // command does not accept arguments - Action: runLabelDelete, - Flags: append([]cli.Flag{ - &cli.Int64Flag{ - Name: "id", - Usage: "label id", - Required: true, - }, - }, flags.AllDefaultFlags...), -} - -func runLabelDelete(requestCtx 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 - } - - labelID := ctx.Int64("id") - client := ctx.Login.Client() - - // Verify the label exists first - label, _, err := client.Repositories.GetRepoLabel(requestCtx, ctx.Owner, ctx.Repo, labelID) - if err != nil { - return fmt.Errorf("failed to get label %d: %w", labelID, err) - } - - _, err = client.Repositories.DeleteLabel(requestCtx, 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 deleted file mode 100644 index 793b3241..00000000 --- a/cmd/labels/list.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package labels - -import ( - stdctx "context" - "log" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" - "github.com/urfave/cli/v3" -) - -// CmdLabelsList represents a sub command of labels to list labels -var CmdLabelsList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List labels", - Description: "List labels", - ArgsUsage: " ", // command does not accept arguments - Action: RunLabelsList, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "save", - Aliases: []string{"s"}, - Usage: "Save all the labels as a file", - }, - &cli.StringFlag{ - Name: "org", - Usage: "List organization labels", - }, - &cli.BoolFlag{ - Name: "exclude-org", - Usage: "Exclude organization labels from the list", - }, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunLabelsList list labels. -func RunLabelsList(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - if cmd.String("org") == "" && cmd.String("repo") == "" { - if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { - return err - } - } - - if cmd.String("org") != "" && cmd.String("repo") != "" { - log.Printf("Warning: Both --org and --repo flags are set. Ignoring --org and using --repo value as owner.\n") - } - - client := ctx.Login.Client() - labels, err := collectLabels(requestCtx, client, ctx, cmd) - if err != nil { - return err - } - - if ctx.IsSet("save") { - return task.LabelsExport(labels, ctx.String("save")) - } - - return print.LabelsList(labels, ctx.Output) -} - -// CollectLabels collects labels based on the provided command flags and context. -func collectLabels(requestCtx stdctx.Context, client *gitea.Client, ctx *context.TeaContext, cmd *cli.Command) ([]*gitea.Label, error) { - var labels []*gitea.Label - var err error - owner := ctx.Owner - - // If --org is set but --repo is not, list organization labels only and skip repository labels. - if cmd.String("org") != "" && cmd.String("repo") == "" { - owner = ctx.Org - } else { - labels, _, err = client.Repositories.ListRepoLabels(requestCtx, owner, ctx.Repo, gitea.ListLabelsOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - log.Printf("Failed to list repository labels: %v", err) - } - } - - // If --exclude is not set and the owner is an organization, also list organization labels and append them to the list. - if !ctx.IsSet("exclude-org") { - if _, _, err = client.Organizations.GetOrg(requestCtx, owner); err == nil { - orgLabels, _, err := client.Organizations.ListOrgLabels(requestCtx, owner, gitea.ListOrgLabelsOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return nil, err - } - labels = append(labels, orgLabels...) - } - } - - return labels, nil -} diff --git a/cmd/labels/update.go b/cmd/labels/update.go deleted file mode 100644 index 640d1acd..00000000 --- a/cmd/labels/update.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package labels - -import ( - stdctx "context" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "github.com/urfave/cli/v3" -) - -// CmdLabelUpdate represents a sub command of labels to update label. -var CmdLabelUpdate = cli.Command{ - Name: "update", - Usage: "Update a label", - Description: `Update a label`, - ArgsUsage: " ", // command does not accept arguments - Action: runLabelUpdate, - Flags: append([]cli.Flag{ - &cli.Int64Flag{ - Name: "id", - Usage: "label id", - }, - &cli.StringFlag{ - Name: "name", - Usage: "label name", - }, - &cli.StringFlag{ - Name: "color", - Usage: "label color value", - }, - &cli.StringFlag{ - Name: "description", - Usage: "label description", - }, - }, flags.AllDefaultFlags...), -} - -func runLabelUpdate(requestCtx 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 - } - - id := ctx.Int64("id") - var pName, pColor, pDescription *string - name := ctx.String("name") - if name != "" { - pName = &name - } - - color := ctx.String("color") - if color != "" { - pColor = &color - } - - description := ctx.String("description") - if description != "" { - pDescription = &description - } - - _, _, err = ctx.Login.Client().Repositories.EditLabel(requestCtx, ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{ - Name: pName, - Color: pColor, - Description: pDescription, - }) - if err != nil { - return err - } - - return nil -} diff --git a/cmd/login.go b/cmd/login.go deleted file mode 100644 index 0a2de65d..00000000 --- a/cmd/login.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "context" - "fmt" - - "gitea.dev/tea/cmd/login" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/print" - - "github.com/urfave/cli/v3" -) - -// CmdLogin represents to login a gitea server. -var CmdLogin = cli.Command{ - Name: "logins", - Aliases: []string{"login"}, - Category: catSetup, - Usage: "Log in to a Gitea server", - Description: `Log in to a Gitea server`, - ArgsUsage: "[]", - Action: runLogins, - Commands: []*cli.Command{ - &login.CmdLoginList, - &login.CmdLoginAdd, - &login.CmdLoginEdit, - &login.CmdLoginDelete, - &login.CmdLoginSetDefault, - &login.CmdLoginHelper, - &login.CmdLoginOAuthRefresh, - &login.CmdLoginStatus, - }, -} - -func runLogins(ctx context.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runLoginDetail(cmd.Args().First()) - } - return login.RunLoginList(ctx, cmd) -} - -func runLoginDetail(name string) error { - 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 - } - - print.LoginDetails(l) - return nil -} diff --git a/cmd/login/add.go b/cmd/login/add.go deleted file mode 100644 index bcc71659..00000000 --- a/cmd/login/add.go +++ /dev/null @@ -1,171 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - "fmt" - - "gitea.dev/tea/modules/auth" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" - - "github.com/urfave/cli/v3" -) - -// CmdLoginAdd represents to login a gitea server. -var CmdLoginAdd = cli.Command{ - Name: "add", - Usage: "Add a Gitea login", - Description: `Add a Gitea login, without args it will create one interactively. - -By default tea only stores the token for its own API use. Pass --git-credentials -to also register tea as a git credential helper for the login's URL, so that -'git push' and 'git clone' over HTTPS authenticate silently using the stored -token. Equivalent to running 'tea login helper setup' afterwards.`, - ArgsUsage: " ", // command does not accept arguments - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "name", - Aliases: []string{"n"}, - Usage: "Login name", - }, - &cli.StringFlag{ - Name: "url", - Aliases: []string{"u"}, - Value: "https://gitea.com", - Sources: cli.EnvVars("GITEA_SERVER_URL"), - Usage: "Server URL", - }, - &cli.BoolFlag{ - Name: "no-version-check", - Aliases: []string{"nv"}, - Usage: "Do not check version of Gitea instance", - }, - &cli.StringFlag{ - Name: "token", - Aliases: []string{"t"}, - Value: "", - Sources: cli.EnvVars("GITEA_SERVER_TOKEN"), - Usage: "Access token. Can be obtained from Settings > Applications", - }, - &cli.StringFlag{ - Name: "user", - Value: "", - Sources: cli.EnvVars("GITEA_SERVER_USER"), - Usage: "User for basic auth (will create token)", - }, - &cli.StringFlag{ - Name: "password", - Aliases: []string{"pwd"}, - Value: "", - Sources: cli.EnvVars("GITEA_SERVER_PASSWORD"), - Usage: "Password for basic auth (will create token)", - }, - &cli.StringFlag{ - Name: "otp", - Sources: cli.EnvVars("GITEA_SERVER_OTP"), - Usage: "OTP token for auth, if necessary", - }, - &cli.StringFlag{ - Name: "scopes", - Sources: cli.EnvVars("GITEA_SCOPES"), - Usage: "Token scopes to add when creating a new token, separated by a comma", - }, - &cli.StringFlag{ - Name: "ssh-key", - Aliases: []string{"s"}, - Usage: "Path to a SSH key/certificate to use, overrides auto-discovery", - }, - &cli.BoolFlag{ - Name: "insecure", - Aliases: []string{"i"}, - Usage: "Disable TLS verification", - }, - &cli.StringFlag{ - Name: "ssh-agent-principal", - Aliases: []string{"c"}, - Usage: "Use SSH certificate with specified principal to login (needs a running ssh-agent with certificate loaded)", - }, - &cli.StringFlag{ - Name: "ssh-agent-key", - Aliases: []string{"a"}, - Usage: "Use SSH public key or SSH fingerprint to login (needs a running ssh-agent with ssh key loaded)", - }, - &cli.BoolFlag{ - Name: "git-credentials", - Aliases: []string{"helper", "j"}, - Usage: "Register tea as a git credential helper for this login's URL, so 'git push' and 'git clone' over HTTPS authenticate silently using the stored token", - }, - &cli.BoolFlag{ - Name: "oauth", - Aliases: []string{"o"}, - Usage: "Use interactive OAuth2 flow for authentication", - }, - &cli.StringFlag{ - Name: "client-id", - Usage: "OAuth client ID (for use with --oauth)", - }, - &cli.StringFlag{ - Name: "redirect-url", - Usage: "OAuth redirect URL (for use with --oauth)", - }, - }, - Action: runLoginAdd, -} - -func runLoginAdd(requestCtx context.Context, cmd *cli.Command) error { - // if no args create login interactive - if cmd.NumFlags() == 0 { - if err := interact.CreateLogin(requestCtx); err != nil && !interact.IsQuitting(err) { - return fmt.Errorf("error adding login: %w", err) - } - return nil - } - - // if OAuth flag is provided, use OAuth2 PKCE flow - if cmd.Bool("oauth") { - opts := auth.OAuthOptions{ - Name: cmd.String("name"), - URL: cmd.String("url"), - Insecure: cmd.Bool("insecure"), - } - - // Only set clientID if provided - if cmd.String("client-id") != "" { - opts.ClientID = cmd.String("client-id") - } - - // Only set redirect URL if provided - if cmd.String("redirect-url") != "" { - opts.RedirectURL = cmd.String("redirect-url") - } - - return auth.OAuthLoginWithFullOptions(requestCtx, opts) - } - - sshAgent := false - if cmd.String("ssh-agent-key") != "" || cmd.String("ssh-agent-principal") != "" { - sshAgent = true - } - - // else use args to add login - return task.CreateLogin( - requestCtx, - cmd.String("name"), - cmd.String("token"), - cmd.String("user"), - cmd.String("password"), - cmd.String("otp"), - cmd.String("scopes"), - cmd.String("ssh-key"), - cmd.String("url"), - cmd.String("ssh-agent-principal"), - cmd.String("ssh-agent-key"), - cmd.Bool("insecure"), - sshAgent, - !cmd.Bool("no-version-check"), - cmd.Bool("git-credentials"), - ) -} diff --git a/cmd/login/default.go b/cmd/login/default.go deleted file mode 100644 index 76c0c1e8..00000000 --- a/cmd/login/default.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - - "github.com/urfave/cli/v3" -) - -// CmdLoginSetDefault represents to login a gitea server. -var CmdLoginSetDefault = cli.Command{ - Name: "default", - Usage: "Get or Set Default Login", - Description: `Get or Set Default Login`, - ArgsUsage: "", - Action: runLoginSetDefault, - Flags: []cli.Flag{&flags.OutputFlag}, -} - -func runLoginSetDefault(requestCtx context.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 0 { - l, err := config.GetDefaultLogin() - if err != nil { - return err - } - fmt.Printf("Default Login: %s\n", l.Name) - return nil - } - - name := cmd.Args().First() - return config.SetDefaultLogin(name) -} diff --git a/cmd/login/delete.go b/cmd/login/delete.go deleted file mode 100644 index 835c7247..00000000 --- a/cmd/login/delete.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - "fmt" - - "gitea.dev/tea/modules/config" - - "github.com/urfave/cli/v3" -) - -// CmdLoginDelete is a command to delete a login -var CmdLoginDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Remove a Gitea login", - Description: `Remove a Gitea login`, - ArgsUsage: "", - Action: RunLoginDelete, -} - -// RunLoginDelete runs the action of a login delete command -func RunLoginDelete(requestCtx context.Context, cmd *cli.Command) error { - logins, err := config.GetLogins() - if err != nil { - return err - } - - var name string - - if len(cmd.Args().First()) != 0 { - name = cmd.Args().First() - } else if len(logins) == 1 { - name = logins[0].Name - } else { - return fmt.Errorf("please specify a login name") - } - - return config.DeleteLogin(name) -} diff --git a/cmd/login/edit.go b/cmd/login/edit.go deleted file mode 100644 index 03683255..00000000 --- a/cmd/login/edit.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - "fmt" - "os" - "os/exec" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/utils" - - "github.com/skratchdot/open-golang/open" - "github.com/urfave/cli/v3" -) - -// CmdLoginEdit represents to login a gitea server. -var CmdLoginEdit = cli.Command{ - Name: "edit", - Aliases: []string{"e"}, - Usage: "Edit Gitea logins", - Description: `Edit Gitea logins`, - ArgsUsage: " ", // command does not accept arguments - Action: runLoginEdit, - Flags: []cli.Flag{&flags.OutputFlag}, -} - -func runLoginEdit(requestCtx context.Context, _ *cli.Command) error { - ymlPath := config.GetConfigPath() - if e, ok := os.LookupEnv("EDITOR"); ok && e != "" { - cmd := exec.Command(e, ymlPath) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - } - if exist, _ := utils.FileExist(ymlPath); !exist { - fmt.Printf("Config file does not exist, please run login add first\n") - return nil - } - return open.Start(ymlPath) -} diff --git a/cmd/login/helper.go b/cmd/login/helper.go deleted file mode 100644 index f252a24a..00000000 --- a/cmd/login/helper.go +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "bufio" - "context" - "fmt" - "net/url" - "os" - "strings" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/task" -) - -// CmdLoginHelper represents to login a gitea helper. -var CmdLoginHelper = cli.Command{ - Name: "helper", - Aliases: []string{"git-credential"}, - Usage: "Act as a git credential helper for stored Gitea logins", - Description: `Speaks git's credential helper protocol so that HTTPS push and clone -operations against your configured Gitea instances authenticate silently -using the tokens tea already stores. - -Typical use is automatic: 'tea login add --git-credentials' (or 'tea login -helper setup' for existing logins) registers '!tea login helper' as a -credential helper in ~/.gitconfig. Git then invokes the 'get' subcommand -when it needs credentials for a configured host.`, - Commands: []*cli.Command{ - { - Name: "store", - Aliases: []string{"erase"}, - Usage: "No-op (git credential protocol store/erase)", - Description: "No-op invoked by git on credential store/erase; tea persists credentials only in its own config", - Action: func(requestCtx context.Context, _ *cli.Command) error { - return nil - }, - }, - { - Name: "setup", - Usage: "Register tea as a git credential helper for every configured login", - Description: "Register tea as a git credential helper in ~/.gitconfig for every configured login", - Action: func(requestCtx context.Context, _ *cli.Command) error { - logins, err := config.GetLogins() - if err != nil { - return err - } - for _, login := range logins { - added, err := task.SetupHelper(login) - if err != nil { - return err - } else if added { - fmt.Printf("Added \"%s\"\n", login.Name) - } else { - fmt.Printf("\"%s\" has already been added!\n", login.Name) - } - } - return nil - }, - }, - { - Name: "get", - Usage: "Return the stored token for a URL (git credential protocol)", - Description: "Return the stored token for a given URL in git's credential helper protocol (called by git, reads request from stdin)", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "login", - Aliases: []string{"l"}, - Usage: "Use a specific login", - }, - }, - Action: func(requestCtx context.Context, cmd *cli.Command) error { - wants := map[string]string{} - s := bufio.NewScanner(os.Stdin) - for s.Scan() { - line := s.Text() - if line == "" { - break - } - parts := strings.SplitN(line, "=", 2) - if len(parts) < 2 { - continue - } - key, value := parts[0], parts[1] - if key == "url" { - u, err := url.Parse(value) - if err != nil { - return err - } - wants["protocol"] = u.Scheme - wants["host"] = u.Host - wants["path"] = u.Path - wants["username"] = u.User.Username() - wants["password"], _ = u.User.Password() - } else { - wants[key] = value - } - } - - if len(wants["host"]) == 0 { - return fmt.Errorf("hostname is required") - } else if len(wants["protocol"]) == 0 { - wants["protocol"] = "http" - } - - // 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) - if err != nil { - return err - } - - // 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.GetAccessToken()) - if err != nil { - return err - } - - return nil - }, - }, - }, -} diff --git a/cmd/login/list.go b/cmd/login/list.go deleted file mode 100644 index d17ccc3b..00000000 --- a/cmd/login/list.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/print" - - "github.com/urfave/cli/v3" -) - -// CmdLoginList represents to login a gitea server. -var CmdLoginList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List Gitea logins", - Description: `List Gitea logins`, - ArgsUsage: " ", // command does not accept arguments - Action: RunLoginList, - Flags: []cli.Flag{&flags.OutputFlag}, -} - -// RunLoginList list all logins -func RunLoginList(requestCtx context.Context, cmd *cli.Command) error { - logins, err := config.GetLogins() - if err != nil { - return err - } - return print.LoginsList(logins, cmd.String("output")) -} diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go deleted file mode 100644 index 969e47f2..00000000 --- a/cmd/login/oauth_refresh.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - "fmt" - - "gitea.dev/tea/modules/auth" - "gitea.dev/tea/modules/config" - - "github.com/urfave/cli/v3" -) - -// CmdLoginOAuthRefresh represents a command to refresh an OAuth token -var CmdLoginOAuthRefresh = cli.Command{ - Name: "oauth-refresh", - Usage: "Refresh an OAuth token", - Description: "Manually refresh an expired OAuth token. If the refresh token is also expired, opens a browser for re-authentication.", - ArgsUsage: "[]", - Action: runLoginOAuthRefresh, -} - -func runLoginOAuthRefresh(requestCtx context.Context, cmd *cli.Command) error { - var loginName string - - // Get login name from args or use default - if cmd.Args().Len() > 0 { - loginName = cmd.Args().First() - } else { - // Get default login - login, err := config.GetDefaultLogin() - if err != nil { - return fmt.Errorf("no login specified and no default login found: %s", err) - } - loginName = login.Name - } - - // Get the login from config - 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.GetRefreshToken() == "" { - return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) - } - - // Try to refresh the token - err = auth.RefreshAccessToken(login) - if err == nil { - fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) - return nil - } - - // 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(requestCtx, 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/login/status.go b/cmd/login/status.go deleted file mode 100644 index 9cb852ba..00000000 --- a/cmd/login/status.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package login - -import ( - "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" - - "github.com/urfave/cli/v3" -) - -// CmdLoginStatus represents a command to show authentication status for logins. -var CmdLoginStatus = cli.Command{ - Name: "status", - Usage: "Show authentication status for Gitea logins", - Description: `Verify the stored token for one or all Gitea logins and report its validity.`, - ArgsUsage: "[]", - Action: RunLoginStatus, - Flags: []cli.Flag{&flags.OutputFlag}, -} - -// RunLoginStatus verifies one login, or every configured login when no name is -// provided, and prints a short authentication report. -func RunLoginStatus(requestCtx context.Context, cmd *cli.Command) error { - var logins []config.Login - - switch cmd.Args().Len() { - case 0: - var err error - logins, err = config.GetLogins() - if err != nil { - return err - } - case 1: - login, err := config.GetLoginByName(cmd.Args().First()) - if err != nil { - return err - } - if login == nil { - return fmt.Errorf("login '%s' not found", cmd.Args().First()) - } - logins = []config.Login{*login} - default: - return fmt.Errorf("too many arguments") - } - - statuses := make([]print.LoginStatus, 0, len(logins)) - for i := range logins { - statuses = append(statuses, task.CheckLoginStatus(requestCtx, &logins[i])) - } - - return print.LoginStatuses(statuses, cmd.String("output")) -} diff --git a/cmd/logout.go b/cmd/logout.go deleted file mode 100644 index c20f3b79..00000000 --- a/cmd/logout.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "gitea.dev/tea/cmd/login" - - "github.com/urfave/cli/v3" -) - -// CmdLogout represents to logout a gitea server. -var CmdLogout = cli.Command{ - Name: "logout", - Category: catSetup, - Usage: "Log out from a Gitea server", - Description: `Log out from a Gitea server`, - ArgsUsage: "", - Action: login.RunLoginDelete, -} diff --git a/cmd/man.go b/cmd/man.go deleted file mode 100644 index 5a72bcb3..00000000 --- a/cmd/man.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "context" - "fmt" - "os" - "path/filepath" - - docs "github.com/urfave/cli-docs/v3" - "github.com/urfave/cli/v3" -) - -// DocRenderFlags are the flags for documentation generation, used by `./docs/docs.go` and the `generate-man-page` sub command -var DocRenderFlags = []cli.Flag{ - &cli.StringFlag{ - Name: "out", - Usage: "Path to output docs to, otherwise prints to stdout", - Aliases: []string{"o"}, - }, -} - -// CmdGenerateManPage is the sub command to generate the `tea` man page -var CmdGenerateManPage = cli.Command{ - Name: "man", - Usage: "Generate man page", - Hidden: true, - Flags: DocRenderFlags, - Action: func(ctx context.Context, cmd *cli.Command) error { - return RenderDocs(cmd, cmd.Root(), func(cmd *cli.Command) (string, error) { - return docs.ToManWithSection(cmd, 1) - }) - }, -} - -// RenderDocs renders the documentation for `target` using the supplied `render` function -func RenderDocs(cmd, target *cli.Command, render func(*cli.Command) (string, error)) error { - out, err := render(target) - if err != nil { - return err - } - outPath := cmd.String("out") - if outPath == "" { - fmt.Print(out) - return nil - } - - if err = os.MkdirAll(filepath.Dir(outPath), os.ModePerm); err != nil { - return err - } - - fi, err := os.Create(outPath) - if err != nil { - return err - } - defer fi.Close() - if _, err = fi.WriteString(out); err != nil { - return err - } - - return nil -} diff --git a/cmd/milestones.go b/cmd/milestones.go deleted file mode 100644 index 4250c52e..00000000 --- a/cmd/milestones.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/milestones" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" -) - -// CmdMilestones represents to operate repositories milestones. -var CmdMilestones = cli.Command{ - Name: "milestones", - Aliases: []string{"milestone", "ms"}, - Category: catEntities, - Usage: "List and create milestones", - Description: `List and create milestones`, - ArgsUsage: "[]", - Action: runMilestones, - Commands: []*cli.Command{ - &milestones.CmdMilestonesList, - &milestones.CmdMilestonesCreate, - &milestones.CmdMilestonesClose, - &milestones.CmdMilestonesDelete, - &milestones.CmdMilestonesReopen, - &milestones.CmdMilestonesIssues, - }, - Flags: milestones.CmdMilestonesList.Flags, -} - -func runMilestones(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runMilestoneDetail(ctx, cmd, cmd.Args().First()) - } - return milestones.RunMilestonesList(ctx, cmd) -} - -func runMilestoneDetail(requestCtx stdctx.Context, cmd *cli.Command, name string) 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() - - milestone, _, err := client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, name) - if err != nil { - return err - } - - print.MilestoneDetails(milestone) - return nil -} diff --git a/cmd/milestones/close.go b/cmd/milestones/close.go deleted file mode 100644 index bea8af13..00000000 --- a/cmd/milestones/close.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package milestones - -import ( - "context" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" -) - -// CmdMilestonesClose represents a sub command of milestones to close an milestone -var CmdMilestonesClose = cli.Command{ - Name: "close", - Usage: "Change state of one or more milestones to 'closed'", - Description: `Change state of one or more milestones to 'closed'`, - ArgsUsage: " [...]", - Action: func(ctx context.Context, cmd *cli.Command) error { - if cmd.Bool("force") { - return deleteMilestone(ctx, cmd) - } - return editMilestoneStatus(ctx, cmd, true) - }, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Aliases: []string{"f"}, - Usage: "delete milestone", - }, - }, flags.AllDefaultFlags...), -} diff --git a/cmd/milestones/create.go b/cmd/milestones/create.go deleted file mode 100644 index 1c8c1dfc..00000000 --- a/cmd/milestones/create.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package milestones - -import ( - "time" - - stdctx "context" - - gitea "gitea.dev/sdk" - "github.com/araddon/dateparse" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" -) - -// CmdMilestonesCreate represents a sub command of milestones to create milestone -var CmdMilestonesCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create an milestone on repository", - Description: `Create an milestone on repository`, - ArgsUsage: " ", // command does not accept arguments - Action: runMilestonesCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "milestone title to create", - }, - &cli.StringFlag{ - Name: "description", - Aliases: []string{"d"}, - Usage: "milestone description to create", - }, - &cli.StringFlag{ - Name: "deadline", - Aliases: []string{"expires", "x"}, - Usage: "set milestone deadline (default is no due date)", - }, - &cli.StringFlag{ - Name: "state", - Usage: "set milestone state (default is open)", - DefaultText: "open", - }, - }, flags.AllDefaultFlags...), -} - -func runMilestonesCreate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - date := ctx.String("deadline") - deadline := &time.Time{} - if date != "" { - t, err := dateparse.ParseAny(date) - if err != nil { - return err - } - deadline = &t - } - - state := gitea.StateOpen - if ctx.String("state") == "closed" { - state = gitea.StateClosed - } - - if ctx.IsInteractiveMode() { - if err := interact.CreateMilestone(requestCtx, ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) { - return err - } - return nil - } - - return task.CreateMilestone(requestCtx, ctx.Login, - ctx.Owner, - ctx.Repo, - ctx.String("title"), - ctx.String("description"), - deadline, - state, - ) -} diff --git a/cmd/milestones/delete.go b/cmd/milestones/delete.go deleted file mode 100644 index d4ef1646..00000000 --- a/cmd/milestones/delete.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package milestones - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdMilestonesDelete represents a sub command of milestones to delete an milestone -var CmdMilestonesDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "delete a milestone", - Description: "delete a milestone", - ArgsUsage: "", - Action: deleteMilestone, - Flags: flags.AllDefaultFlags, -} - -func deleteMilestone(requestCtx 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() - - _, err = client.Repositories.DeleteMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, ctx.Args().First()) - return err -} diff --git a/cmd/milestones/issues.go b/cmd/milestones/issues.go deleted file mode 100644 index 2f7c18b7..00000000 --- a/cmd/milestones/issues.go +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package milestones - -import ( - "fmt" - - stdctx "context" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" -) - -var msIssuesFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{ - "index", "kind", "title", "state", "updated", "labels", -}) - -// CmdMilestonesIssues represents a sub command of milestones to manage issue/pull of an milestone -var CmdMilestonesIssues = cli.Command{ - Name: "issues", - Aliases: []string{"i"}, - Usage: "manage issue/pull of an milestone", - Description: "manage issue/pull of an milestone", - ArgsUsage: "", - Action: runMilestoneIssueList, - Commands: []*cli.Command{ - &CmdMilestoneAddIssue, - &CmdMilestoneRemoveIssue, - }, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "state", - Usage: "Filter by issue state (all|open|closed)", - DefaultText: "open", - }, - &cli.StringFlag{ - Name: "kind", - Usage: "Filter by kind (issue|pull)", - }, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - msIssuesFieldsFlag, - }, flags.AllDefaultFlags...), -} - -// CmdMilestoneAddIssue represents a sub command of milestone issues to add an issue/pull to an milestone -var CmdMilestoneAddIssue = cli.Command{ - Name: "add", - Aliases: []string{"a"}, - Usage: "Add an issue/pull to an milestone", - Description: "Add an issue/pull to an milestone", - ArgsUsage: " ", - Action: runMilestoneIssueAdd, - Flags: flags.AllDefaultFlags, -} - -// CmdMilestoneRemoveIssue represents a sub command of milestones to remove an issue/pull from an milestone -var CmdMilestoneRemoveIssue = cli.Command{ - Name: "remove", - Aliases: []string{"r"}, - Usage: "Remove an issue/pull to an milestone", - Description: "Remove an issue/pull to an milestone", - ArgsUsage: " ", - Action: runMilestoneIssueRemove, - Flags: flags.AllDefaultFlags, -} - -func runMilestoneIssueList(requestCtx 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() - - state, err := flags.ParseState(ctx.String("state")) - if err != nil { - return err - } - - kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll) - if err != nil { - return err - } - - if ctx.Args().Len() != 1 { - return fmt.Errorf("milestone name is required") - } - - milestone := ctx.Args().First() - // make sure milestone exist - _, _, err = client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, milestone) - if err != nil { - return err - } - - issues, _, err := client.Issues.ListRepoIssues(requestCtx, ctx.Owner, ctx.Repo, gitea.ListIssueOption{ - ListOptions: flags.GetListOptions(cmd), - Milestones: []string{milestone}, - Type: kind, - State: state, - }) - if err != nil { - return err - } - - fields, err := msIssuesFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - return print.IssuesPullsList(issues, ctx.Output, fields) -} - -func runMilestoneIssueAdd(requestCtx 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() - if ctx.Args().Len() != 2 { - return fmt.Errorf("need two arguments") - } - - mileName := ctx.Args().Get(0) - issueIndex := ctx.Args().Get(1) - idx, err := utils.ArgToIndex(issueIndex) - if err != nil { - return err - } - - // make sure milestone exist - mile, _, err := client.Repositories.GetMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, mileName) - if err != nil { - return fmt.Errorf("failed to get milestone '%s': %w", mileName, err) - } - - _, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ - Milestone: &mile.ID, - }) - if err != nil { - return fmt.Errorf("failed to add issue #%d to milestone '%s': %w", idx, mileName, err) - } - return nil -} - -func runMilestoneIssueRemove(requestCtx 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() - if ctx.Args().Len() != 2 { - return fmt.Errorf("need two arguments") - } - - mileName := ctx.Args().Get(0) - issueIndex := ctx.Args().Get(1) - idx, err := utils.ArgToIndex(issueIndex) - if err != nil { - return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err) - } - - issue, _, err := client.Issues.GetIssue(requestCtx, ctx.Owner, ctx.Repo, idx) - if err != nil { - return fmt.Errorf("failed to get issue #%d: %w", idx, err) - } - - if issue.Milestone == nil { - return fmt.Errorf("issue #%d is not assigned to a milestone", idx) - } - - if issue.Milestone.Title != mileName { - return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName) - } - - zero := int64(0) - _, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ - Milestone: &zero, - }) - 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 deleted file mode 100644 index 7db0998f..00000000 --- a/cmd/milestones/list.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package milestones - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -var fieldsFlag = flags.FieldsFlag(print.MilestoneFields, []string{ - "title", "items", "duedate", -}) - -// CmdMilestonesList represents a sub command of milestones to list milestones -var CmdMilestonesList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List milestones of the repository", - Description: `List milestones of the repository`, - ArgsUsage: " ", // command does not accept arguments - Action: RunMilestonesList, - Flags: append([]cli.Flag{ - fieldsFlag, - &cli.StringFlag{ - Name: "state", - Usage: "Filter by milestone state (all|open|closed)", - DefaultText: "open", - }, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunMilestonesList list milestones -func RunMilestonesList(requestCtx 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 - } - - fields, err := fieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - 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.Repositories.ListMilestones(requestCtx, ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{ - ListOptions: flags.GetListOptions(cmd), - State: state, - }) - if err != nil { - return err - } - - return print.MilestonesList(milestones, ctx.Output, fields) -} diff --git a/cmd/milestones/reopen.go b/cmd/milestones/reopen.go deleted file mode 100644 index f79ac966..00000000 --- a/cmd/milestones/reopen.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package milestones - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdMilestonesReopen represents a sub command of milestones to open an milestone -var CmdMilestonesReopen = cli.Command{ - Name: "reopen", - Aliases: []string{"open"}, - Usage: "Change state of one or more milestones to 'open'", - Description: `Change state of one or more milestones to 'open'`, - ArgsUsage: " [ ...]", - Action: func(ctx stdctx.Context, cmd *cli.Command) error { - return editMilestoneStatus(ctx, cmd, false) - }, - Flags: flags.AllDefaultFlags, -} - -func editMilestoneStatus(requestCtx stdctx.Context, cmd *cli.Command, close bool) 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() == 0 { - return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) - } - - state := gitea.StateOpen - if close { - state = gitea.StateClosed - } - - 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, - Title: ms, - } - milestone, _, err := client.Repositories.EditMilestoneByName(requestCtx, ctx.Owner, ctx.Repo, ms, opts) - if err != nil { - return err - } - - if ctx.Args().Len() > 1 { - fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID) - } else { - print.MilestoneDetails(milestone) - } - } - return nil -} diff --git a/cmd/notifications.go b/cmd/notifications.go deleted file mode 100644 index 1dd182c4..00000000 --- a/cmd/notifications.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "gitea.dev/tea/cmd/notifications" - - "github.com/urfave/cli/v3" -) - -// CmdNotifications is the main command to operate with notifications -var CmdNotifications = cli.Command{ - Name: "notifications", - Aliases: []string{"notification", "n"}, - Category: catHelpers, - Usage: "Show notifications", - Description: "Show notifications, by default based on the current repo if available", - Action: notifications.RunNotificationsList, - Commands: []*cli.Command{ - ¬ifications.CmdNotificationsList, - ¬ifications.CmdNotificationsMarkRead, - ¬ifications.CmdNotificationsMarkUnread, - ¬ifications.CmdNotificationsMarkPinned, - ¬ifications.CmdNotificationsUnpin, - }, - Flags: notifications.CmdNotificationsList.Flags, -} diff --git a/cmd/notifications/list.go b/cmd/notifications/list.go deleted file mode 100644 index 6ebea505..00000000 --- a/cmd/notifications/list.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package notifications - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -var notifyFieldsFlag = flags.FieldsFlag(print.NotificationFields, []string{ - "id", "status", "index", "type", "state", "title", -}) - -var notifyTypeFlag = flags.NewCsvFlag("types", "subject types to filter by", []string{"t"}, - []string{"issue", "pull", "repository", "commit"}, nil) - -// CmdNotificationsList represents a sub command of notifications to list notifications -var CmdNotificationsList = cli.Command{ - Name: "ls", - Aliases: []string{"list"}, - Usage: "List notifications", - Description: `List notifications`, - ArgsUsage: " ", // command does not accept arguments - Action: RunNotificationsList, - Flags: append([]cli.Flag{ - notifyFieldsFlag, - notifyTypeFlag, - }, flags.NotificationFlags...), -} - -// RunNotificationsList list notifications -func RunNotificationsList(ctx stdctx.Context, cmd *cli.Command) error { - var states []gitea.NotifyStatus - statesStr, err := flags.NotificationStateFlag.GetValues(cmd) - if err != nil { - return err - } - for _, s := range statesStr { - states = append(states, gitea.NotifyStatus(s)) - } - - var types []gitea.NotifySubjectType - typesStr, err := notifyTypeFlag.GetValues(cmd) - if err != nil { - return err - } - for _, t := range typesStr { - types = append(types, gitea.NotifySubjectType(t)) - } - - return listNotifications(ctx, cmd, states, types) -} - -// listNotifications will get the notifications based on status and subject type -func listNotifications(requestCtx stdctx.Context, cmd *cli.Command, status []gitea.NotifyStatus, subjects []gitea.NotifySubjectType) error { - var news []*gitea.NotificationThread - var err error - - all := cmd.Bool("mine") - ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: all}) - if err != nil { - return err - } - client := ctx.Login.Client() - - // This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733) - listOpts := flags.GetListOptions(cmd) - if listOpts.Page == 0 { - listOpts.Page = 1 - } - - fields, err := notifyFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - if all { - // add repository to the default fields - if !cmd.IsSet("fields") { - fields = append(fields, "repository") - } - - news, _, err = client.Notifications.List(requestCtx, gitea.ListNotificationOptions{ - ListOptions: listOpts, - Status: status, - SubjectTypes: subjects, - }) - } else { - if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { - return err - } - news, _, err = client.Notifications.ListByRepo(requestCtx, ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{ - ListOptions: listOpts, - Status: status, - SubjectTypes: subjects, - }) - } - if err != nil { - return err - } - - return print.NotificationsList(news, ctx.Output, fields) -} diff --git a/cmd/notifications/list_test.go b/cmd/notifications/list_test.go deleted file mode 100644 index 197e6fba..00000000 --- a/cmd/notifications/list_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package notifications - -import ( - stdctx "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "testing" - - "gitea.dev/tea/modules/config" - "github.com/stretchr/testify/require" - "github.com/urfave/cli/v3" -) - -func TestRunNotificationsListMineDoesNotProbeGitRepository(t *testing.T) { - gitPath := filepath.Join(t.TempDir(), "git") - gitScript := "#!/bin/sh\necho 'git should not be called' >&2\nexit 1\n" - if runtime.GOOS == "windows" { - gitPath += ".bat" - gitScript = "@echo git should not be called 1>&2\r\nexit /b 1\r\n" - } - require.NoError(t, os.WriteFile(gitPath, []byte(gitScript), 0o755)) - t.Setenv("PATH", filepath.Dir(gitPath)) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "/api/v1/notifications", r.URL.Path) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`[]`)) - })) - defer server.Close() - - config.SetConfigForTesting(config.LocalConfig{ - Logins: []config.Login{{ - Name: "default", - URL: server.URL, - Token: "token", - User: "user", - Default: true, - }}, - }) - - cmd := cli.Command{ - Name: CmdNotificationsList.Name, - Flags: CmdNotificationsList.Flags, - } - require.NoError(t, cmd.Set("mine", "true")) - require.NoError(t, cmd.Set("output", "json")) - - require.NoError(t, RunNotificationsList(stdctx.Background(), &cmd)) -} diff --git a/cmd/notifications/mark_as.go b/cmd/notifications/mark_as.go deleted file mode 100644 index 82cab66e..00000000 --- a/cmd/notifications/mark_as.go +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package notifications - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/utils" -) - -// CmdNotificationsMarkRead represents a sub command of notifications to list read notifications -var CmdNotificationsMarkRead = cli.Command{ - Name: "read", - Aliases: []string{"r"}, - Usage: "Mark all filtered or a specific notification as read", - Description: "Mark all filtered or a specific notification as read", - ArgsUsage: "[all | ]", - Flags: flags.NotificationFlags, - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) - if err != nil { - return err - } - filter, err := flags.NotificationStateFlag.GetValues(cmd) - if err != nil { - return err - } - if !ctx.IsSet(flags.NotificationStateFlag.Name) { - filter = []string{string(gitea.NotifyStatusUnread)} - } - return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusRead) - }, -} - -// CmdNotificationsMarkUnread will mark notifications as unread. -var CmdNotificationsMarkUnread = cli.Command{ - Name: "unread", - Aliases: []string{"u"}, - Usage: "Mark all filtered or a specific notification as unread", - Description: "Mark all filtered or a specific notification as unread", - ArgsUsage: "[all | ]", - Flags: flags.NotificationFlags, - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) - if err != nil { - return err - } - filter, err := flags.NotificationStateFlag.GetValues(cmd) - if err != nil { - return err - } - if !ctx.IsSet(flags.NotificationStateFlag.Name) { - filter = []string{string(gitea.NotifyStatusRead)} - } - return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusUnread) - }, -} - -// CmdNotificationsMarkPinned will mark notifications as unread. -var CmdNotificationsMarkPinned = cli.Command{ - Name: "pin", - Aliases: []string{"p"}, - Usage: "Mark all filtered or a specific notification as pinned", - Description: "Mark all filtered or a specific notification as pinned", - ArgsUsage: "[all | ]", - Flags: flags.NotificationFlags, - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) - if err != nil { - return err - } - filter, err := flags.NotificationStateFlag.GetValues(cmd) - if err != nil { - return err - } - if !ctx.IsSet(flags.NotificationStateFlag.Name) { - filter = []string{string(gitea.NotifyStatusUnread)} - } - return markNotificationAs(requestCtx, ctx, filter, gitea.NotifyStatusPinned) - }, -} - -// CmdNotificationsUnpin will mark pinned notifications as unread. -var CmdNotificationsUnpin = cli.Command{ - Name: "unpin", - Usage: "Unpin all pinned or a specific notification", - Description: "Marks all pinned or a specific notification as read", - ArgsUsage: "[all | ]", - Flags: flags.NotificationFlags, - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")}) - 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(requestCtx, ctx, filter, gitea.NotifyStatusRead) - }, -} - -func markNotificationAs(ctx stdctx.Context, cmd *context.TeaContext, filterStates []string, targetState gitea.NotifyStatus) (err error) { - client := cmd.Login.Client() - subject := cmd.Args().First() - allRepos := cmd.Bool("mine") - - states := []gitea.NotifyStatus{} - for _, s := range filterStates { - states = append(states, gitea.NotifyStatus(s)) - } - - switch subject { - case "", "all": - opts := gitea.MarkNotificationOptions{Status: states, ToStatus: targetState} - - if allRepos { - _, _, err = client.Notifications.MarkRead(ctx, opts) - } else { - if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { - return err - } - _, _, err = client.Notifications.MarkReadByRepo(ctx, cmd.Owner, cmd.Repo, opts) - } - - // TODO: print all affected notification subject URLs - // (not supported by API currently, https://github.com/go-gitea/gitea/issues/16797) - - default: - id, err := utils.ArgToIndex(subject) - if err != nil { - return err - } - _, _, err = client.Notifications.MarkReadByID(ctx, id, targetState) - if err != nil { - return err - } - - n, _, err := client.Notifications.GetByID(ctx, id) - if err != nil { - return err - } - // 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 - } - - return err -} diff --git a/cmd/open.go b/cmd/open.go deleted file mode 100644 index c69f94bf..00000000 --- a/cmd/open.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - "path" - "strings" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - local_git "gitea.dev/tea/modules/git" - - "github.com/skratchdot/open-golang/open" - "github.com/urfave/cli/v3" -) - -// CmdOpen represents a sub command of issues to open issue on the web browser -var CmdOpen = cli.Command{ - Name: "open", - Aliases: []string{"o"}, - Category: catHelpers, - Usage: "Open something of the repository in web browser", - Description: `Open something of the repository in web browser`, - Action: runOpen, - Flags: append([]cli.Flag{}, flags.LoginRepoFlags...), -} - -func runOpen(requestCtx 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 - } - - var suffix string - number := ctx.Args().Get(0) - switch { - case strings.EqualFold(number, "issues"): - suffix = "issues" - case strings.EqualFold(number, "pulls"): - suffix = "pulls" - case strings.EqualFold(number, "releases"): - suffix = "releases" - case strings.EqualFold(number, "commits"): - repo, err := local_git.RepoForWorkdir() - if err != nil { - return err - } - b, err := repo.Head() - if err != nil { - return err - } - name := b.Name() - switch { - case name.IsBranch(): - suffix = "commits/branch/" + name.Short() - case name.IsTag(): - suffix = "commits/tag/" + name.Short() - } - case strings.EqualFold(number, "branches"): - suffix = "branches" - case strings.EqualFold(number, "wiki"): - suffix = "wiki" - case strings.EqualFold(number, "activity"): - suffix = "activity" - case strings.EqualFold(number, "settings"): - suffix = "settings" - case strings.EqualFold(number, "labels"): - suffix = "labels" - case strings.EqualFold(number, "milestones"): - suffix = "milestones" - case number != "": - suffix = "issues/" + number - default: - suffix = number - } - - 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 deleted file mode 100644 index 86315528..00000000 --- a/cmd/organizations.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/organizations" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - - "github.com/urfave/cli/v3" -) - -// CmdOrgs represents handle organization -var CmdOrgs = cli.Command{ - Name: "organizations", - Aliases: []string{"organization", "org"}, - Category: catEntities, - Usage: "List, create, delete organizations", - Description: "Show organization details", - ArgsUsage: "[]", - Action: runOrganizations, - Commands: []*cli.Command{ - &organizations.CmdOrganizationList, - &organizations.CmdOrganizationCreate, - &organizations.CmdOrganizationDelete, - }, - Flags: organizations.CmdOrganizationList.Flags, -} - -func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error { - teaCtx, err := context.InitCommand(cmd) - if err != nil { - return err - } - if teaCtx.Args().Len() == 1 { - org, _, err := teaCtx.Login.Client().Organizations.GetOrg(ctx, teaCtx.Args().First()) - if err != nil { - return err - } - - print.OrganizationDetails(org) - return nil - } - return organizations.RunOrganizationList(ctx, cmd) -} diff --git a/cmd/organizations/create.go b/cmd/organizations/create.go deleted file mode 100644 index ff1cfce9..00000000 --- a/cmd/organizations/create.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package organizations - -import ( - stdctx "context" - "fmt" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - - "github.com/urfave/cli/v3" -) - -// CmdOrganizationCreate represents a sub command of organizations to delete a given user organization -var CmdOrganizationCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create an organization", - Description: "Create an organization", - Action: RunOrganizationCreate, - ArgsUsage: "", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "full-name", - Aliases: []string{"n"}, - }, - &cli.StringFlag{ - Name: "description", - Aliases: []string{"d"}, - }, - &cli.StringFlag{ - Name: "website", - Aliases: []string{"w"}, - }, - &cli.StringFlag{ - Name: "location", - Aliases: []string{"L"}, - }, - &cli.StringFlag{ - Name: "visibility", - Aliases: []string{"v"}, - }, - &cli.BoolFlag{ - Name: "repo-admins-can-change-team-access", - }, - &flags.LoginFlag, - }, -} - -// RunOrganizationCreate sets up a new organization -func RunOrganizationCreate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - if ctx.Args().Len() < 1 { - return fmt.Errorf("organization name is required") - } - - var visibility gitea.VisibleType - switch ctx.String("visibility") { - case "", "public": - visibility = gitea.VisibleTypePublic - case "private": - visibility = gitea.VisibleTypePrivate - case "limited": - visibility = gitea.VisibleTypeLimited - default: - return fmt.Errorf("unknown visibility '%s'", ctx.String("visibility")) - } - - org, _, err := ctx.Login.Client().Organizations.CreateOrg(requestCtx, gitea.CreateOrgOption{ - Name: ctx.Args().First(), - FullName: ctx.String("full-name"), - Description: ctx.String("description"), - Website: ctx.String("website"), - Location: ctx.String("location"), - RepoAdminChangeTeamAccess: ctx.Bool("repo-admins-can-change-team-access"), - Visibility: visibility, - }) - if err != nil { - return err - } - - print.OrganizationDetails(org) - - return err -} diff --git a/cmd/organizations/delete.go b/cmd/organizations/delete.go deleted file mode 100644 index c70740a6..00000000 --- a/cmd/organizations/delete.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package organizations - -import ( - stdctx "context" - "fmt" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" -) - -// CmdOrganizationDelete represents a sub command of organizations to delete a given user organization -var CmdOrganizationDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete users Organizations", - Description: "Delete users organizations", - ArgsUsage: "", - Action: RunOrganizationDelete, - Flags: []cli.Flag{ - &flags.LoginFlag, - &flags.RemoteFlag, - }, -} - -// RunOrganizationDelete delete user organization -func RunOrganizationDelete(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - client := ctx.Login.Client() - - if ctx.Args().Len() < 1 { - return fmt.Errorf("organization name is required") - } - - response, err := client.Organizations.DeleteOrg(requestCtx, ctx.Args().First()) - if response != nil && response.StatusCode == 404 { - return fmt.Errorf("organization not found: %s", ctx.Args().First()) - } - - return err -} diff --git a/cmd/organizations/list.go b/cmd/organizations/list.go deleted file mode 100644 index e1951aef..00000000 --- a/cmd/organizations/list.go +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package organizations - -import ( - stdctx "context" - - "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" -) - -// CmdOrganizationList represents a sub command of organizations to list users organizations -var CmdOrganizationList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List Organizations", - Description: "List users organizations", - ArgsUsage: " ", // command does not accept arguments - Action: RunOrganizationList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunOrganizationList list user organizations -func RunOrganizationList(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - - userOrganizations, _, err := client.Organizations.ListUserOrgs(requestCtx, ctx.Login.User, gitea.ListOrgsOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.OrganizationsList(userOrganizations, ctx.Output) -} diff --git a/cmd/pulls.go b/cmd/pulls.go deleted file mode 100644 index 4f732c05..00000000 --- a/cmd/pulls.go +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - "fmt" - "time" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/cmd/pulls" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" - - "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", - Aliases: []string{"pull", "pr"}, - Category: catEntities, - Usage: "Manage and checkout pull requests", - Description: `Lists PRs when called without argument. If PR index is provided, will show it in detail.`, - ArgsUsage: "[]", - Action: runPulls, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "comments", - Usage: "Whether to display comments (will prompt if not provided & run interactively)", - }, - }, pulls.CmdPullsList.Flags...), - Commands: []*cli.Command{ - &pulls.CmdPullsList, - &pulls.CmdPullsCheckout, - &pulls.CmdPullsClean, - &pulls.CmdPullsCreate, - &pulls.CmdPullsClose, - &pulls.CmdPullsReopen, - &pulls.CmdPullsEdit, - &pulls.CmdPullsReview, - &pulls.CmdPullsApprove, - &pulls.CmdPullsReject, - &pulls.CmdPullsMerge, - &pulls.CmdPullsReply, - &pulls.CmdPullsReviewComments, - &pulls.CmdPullsResolve, - &pulls.CmdPullsUnresolve, - }, -} - -func runPulls(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runPullDetail(ctx, cmd, cmd.Args().First()) - } - return pulls.RunPullsList(ctx, cmd) -} - -func runPullDetail(requestCtx stdctx.Context, cmd *cli.Command, index string) error { - 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 - } - - client := ctx.Login.Client() - pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) - if err != nil { - return err - } - - var reviews []*gitea.PullReview - for page := 1; ; { - page_reviews, resp, err := client.PullRequests.ListPullReviews(requestCtx, ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ - ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, - }) - if err != nil { - fmt.Printf("error while loading reviews: %v\n", err) - break - } - reviews = append(reviews, page_reviews...) - if resp == nil || resp.NextPage == 0 { - break - } - page = resp.NextPage - } - - if ctx.IsSet("output") { - switch ctx.String("output") { - case "json": - return runPullDetailAsJSON(requestCtx, ctx, pr, reviews) - } - } - - ci, _, err := client.Repositories.GetCombinedStatus(requestCtx, ctx.Owner, ctx.Repo, pr.Head.Sha) - if err != nil { - fmt.Printf("error while loading CI: %v\n", err) - } - - print.PullDetails(pr, reviews, ci) - - if pr.Comments > 0 { - err = interact.ShowCommentsMaybeInteractive(requestCtx, ctx, idx, pr.Comments) - if err != nil { - fmt.Printf("error loading comments: %v\n", err) - } - } - - return nil -} - -func runPullDetailAsJSON(requestCtx stdctx.Context, 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.Issues.ListIssueComments(requestCtx, 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 deleted file mode 100644 index 84d5b259..00000000 --- a/cmd/pulls/approve.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" -) - -// CmdPullsApprove approves a PR -var CmdPullsApprove = cli.Command{ - Name: "approve", - Aliases: []string{"lgtm", "a"}, - Usage: "Approve a pull request", - Description: "Approve a pull request", - ArgsUsage: " []", - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - return runPullReview(requestCtx, ctx, gitea.ReviewStateApproved, false) - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/pulls/checkout.go b/cmd/pulls/checkout.go deleted file mode 100644 index 90c74c3d..00000000 --- a/cmd/pulls/checkout.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdPullsCheckout is a command to locally checkout the given PR -var CmdPullsCheckout = cli.Command{ - Name: "checkout", - Aliases: []string{"co"}, - Usage: "Locally check out the given PR", - Description: `Locally check out the given PR`, - Action: runPullsCheckout, - ArgsUsage: "", - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "branch", - Aliases: []string{"b"}, - Usage: "Create a local branch if it doesn't exist yet", - }, - }, flags.AllDefaultFlags...), -} - -func runPullsCheckout(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - if err := ctx.Ensure(context.CtxRequirement{ - LocalRepo: true, - RemoteRepo: true, - }); err != nil { - return err - } - if ctx.Args().Len() != 1 { - return fmt.Errorf("pull request index is required") - } - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - if err := task.PullCheckout(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, ctx.Bool("branch"), idx, interact.PromptPassword); err != nil && !interact.IsQuitting(err) { - return err - } - return nil -} diff --git a/cmd/pulls/clean.go b/cmd/pulls/clean.go deleted file mode 100644 index 5bee1b66..00000000 --- a/cmd/pulls/clean.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - "fmt" - - stdctx "context" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" -) - -// CmdPullsClean removes the remote and local feature branches, if a PR is merged. -var CmdPullsClean = cli.Command{ - Name: "clean", - Usage: "Deletes local & remote feature-branches for a closed pull request", - Description: `Deletes local & remote feature-branches for a closed pull request`, - ArgsUsage: "", - Action: runPullsClean, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "ignore-sha", - Usage: "Find the local branch by name instead of commit hash (less precise)", - }, - }, flags.AllDefaultFlags...), -} - -func runPullsClean(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil { - return err - } - if ctx.Args().Len() != 1 { - return fmt.Errorf("pull request index is required") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - if err := task.PullClean(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, ctx.Bool("ignore-sha"), interact.PromptPassword); err != nil && !interact.IsQuitting(err) { - return err - } - return nil -} diff --git a/cmd/pulls/close.go b/cmd/pulls/close.go deleted file mode 100644 index 766f564c..00000000 --- a/cmd/pulls/close.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - "context" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "github.com/urfave/cli/v3" -) - -// CmdPullsClose closes a given open pull request -var CmdPullsClose = cli.Command{ - Name: "close", - Usage: "Change state of one or more pull requests to 'closed'", - Description: `Change state of one or more pull requests to 'closed'`, - ArgsUsage: " [...]", - Action: func(ctx context.Context, cmd *cli.Command) error { - 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 deleted file mode 100644 index 6740c974..00000000 --- a/cmd/pulls/create.go +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "encoding/json" - "fmt" - "io" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" -) - -// CmdPullsCreate creates a pull request -var CmdPullsCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create a pull-request", - Description: "Create a pull-request in the current repo", - Action: runPullsCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "head", - Usage: "Branch name of the PR source (default is current one). To specify a different head repo, use :", - }, - &cli.StringFlag{ - Name: "base", - Aliases: []string{"b"}, - Usage: "Branch name of the PR target (default is repos default branch)", - }, - &cli.BoolFlag{ - Name: "allow-maintainer-edits", - Aliases: []string{"edits"}, - 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", - }, - &cli.BoolFlag{ - Name: "draft", - Usage: "Create as a draft (prepends \"WIP: \" to the title; Gitea treats WIP-prefixed PRs as drafts)", - }, - }, flags.IssuePRCreateFlags...), -} - -func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - // Interactive mode and head-branch defaulting both need a local repo. - // When --head is given explicitly the user can target a cross-fork PR - // from outside a working tree (e.g. with --repo /); - // task.CreatePull only consults ctx.LocalRepo when head is empty. - needsLocalRepo := ctx.IsInteractiveMode() || len(ctx.String("head")) == 0 - requirement := context.CtxRequirement{RemoteRepo: true} - if needsLocalRepo { - requirement.LocalRepo = true - } - if err := ctx.Ensure(requirement); err != nil { - return err - } - - // no args -> interactive mode - if ctx.IsInteractiveMode() { - if err := interact.CreatePull(requestCtx, ctx); err != nil && !interact.IsQuitting(err) { - return err - } - return nil - } - - // agit flow creates the PR via git push and returns no PR object, so - // --output cannot be honored there; fail fast before any API calls - if ctx.Bool("agit") && ctx.IsSet("output") { - return fmt.Errorf("--output cannot be combined with --agit: the PR is created via git push, so no pull request object is available to print") - } - - // else use args to create PR - opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx) - if err != nil { - return err - } - - if ctx.Bool("draft") { - opts.Title = utils.AddDraftPrefix(opts.Title) - } - - if ctx.Bool("agit") { - return task.CreateAgitFlowPull( - requestCtx, - 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")) - } - - pr, err := task.CreatePull( - requestCtx, - ctx, - ctx.String("base"), - ctx.String("head"), - allowMaintainerEdits, - opts, - ) - if err != nil { - return err - } - - if ctx.IsSet("output") { - switch ctx.String("output") { - case "json": - return writeCreatedPullAsJSON(ctx.Writer, pr) - } - } - - print.PullDetails(pr, nil, nil) - - return nil -} - -// createdPullJSON is the machine-readable representation of a freshly -// created pull request. A new PR has no reviews, comments or CI yet, so -// this is intentionally leaner than the detail view's pullData (cmd/pulls.go). -type createdPullJSON struct { - Index int64 `json:"index"` - Title string `json:"title"` - URL string `json:"url"` - State gitea.StateType `json:"state"` - Base string `json:"base"` - Head string `json:"head"` -} - -func writeCreatedPullAsJSON(w io.Writer, pr *gitea.PullRequest) error { - return json.NewEncoder(w).Encode(createdPullJSON{ - Index: pr.Index, - Title: pr.Title, - URL: pr.HTMLURL, - State: pr.State, - Base: pr.Base.Ref, - Head: pr.Head.Ref, - }) -} diff --git a/cmd/pulls/create_app_test.go b/cmd/pulls/create_app_test.go deleted file mode 100644 index c22c1f51..00000000 --- a/cmd/pulls/create_app_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls_test - -import ( - "context" - "testing" - - "gitea.dev/tea/cmd" - "gitea.dev/tea/modules/config" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestPullsCreateAgitOutputRejected verifies that --output (parsed via the -// urfave/cli v3 ancestor-flag cascade, since create itself does not declare -// it) is rejected for the agit flow before any API call or git push happens. -func TestPullsCreateAgitOutputRejected(t *testing.T) { - config.SetConfigForTesting(config.LocalConfig{ - Logins: []config.Login{{ - Name: "testLogin", - URL: "https://gitea.example.com", - Token: "test-token", - User: "testUser", - Default: true, - }}, - }) - t.Cleanup(func() { - config.SetConfigForTesting(config.LocalConfig{}) - }) - - app := cmd.App() - args := []string{ - "tea", "pulls", "create", - "--agit", - "--output", "json", - "--head", "topic-branch", - "--title", "test", - "--login", "testLogin", - "--repo", "user/repo", - } - - err := app.Run(context.Background(), args) - require.Error(t, err) - assert.Contains(t, err.Error(), "--output cannot be combined with --agit") -} diff --git a/cmd/pulls/create_test.go b/cmd/pulls/create_test.go deleted file mode 100644 index 93f9774d..00000000 --- a/cmd/pulls/create_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - "bytes" - "encoding/json" - "testing" - - gitea "gitea.dev/sdk" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestWriteCreatedPullAsJSON(t *testing.T) { - pr := &gitea.PullRequest{ - Index: 33, - Title: "test title", - HTMLURL: "https://gitea.example.com/owner/repo/pulls/33", - State: gitea.StateOpen, - Base: &gitea.PRBranchInfo{Ref: "main"}, - Head: &gitea.PRBranchInfo{Ref: "feature"}, - } - - var buf bytes.Buffer - require.NoError(t, writeCreatedPullAsJSON(&buf, pr)) - - var got map[string]any - require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) - - assert.Equal(t, float64(33), got["index"]) - assert.Equal(t, "test title", got["title"]) - assert.Equal(t, "https://gitea.example.com/owner/repo/pulls/33", got["url"]) - assert.Equal(t, "open", got["state"]) - assert.Equal(t, "main", got["base"]) - assert.Equal(t, "feature", got["head"]) - - // exactly the lean field set, nothing extra - assert.Len(t, got, 6) - - // machine-readable output must not contain terminal escape sequences - assert.NotContains(t, buf.String(), "\x1b") -} diff --git a/cmd/pulls/edit.go b/cmd/pulls/edit.go deleted file mode 100644 index 64604e7c..00000000 --- a/cmd/pulls/edit.go +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "fmt" - "strings" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" - "github.com/urfave/cli/v3" -) - -// applyDraftFlag mutates opts.Title according to --draft / --ready. -// If a flag is set but --title isn't, it fetches the current title from the server. -// Returns an error if both --draft and --ready are set, or on a server error. -func applyDraftFlag(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client, idx int64, opts *task.EditIssueOption) error { - draft := ctx.Bool("draft") - ready := ctx.Bool("ready") - if !draft && !ready { - return nil - } - if draft && ready { - return fmt.Errorf("--draft and --ready are mutually exclusive") - } - - var current string - if opts.Title != nil { - current = *opts.Title - } else { - pr, _, err := client.PullRequests.GetPullRequest(requestCtx, ctx.Owner, ctx.Repo, idx) - if err != nil { - return fmt.Errorf("could not fetch pull request #%d: %s", idx, err) - } - current = pr.Title - } - - var next string - if draft { - next = utils.AddDraftPrefix(current) - } else { - next = utils.StripDraftPrefix(current) - } - if next != current || opts.Title != nil { - opts.Title = &next - } - return nil -} - -// 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", - }, - &cli.BoolFlag{ - Name: "draft", - Usage: "Mark as draft by prepending \"WIP: \" to the title (idempotent)", - }, - &cli.BoolFlag{ - Name: "ready", - Usage: "Mark as ready for review by stripping any leading \"WIP: \" or \"[WIP]\" prefix", - }, - ), -} - -func runPullsEdit(requestCtx 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 { - if err := applyDraftFlag(requestCtx, ctx, client, opts.Index, opts); err != nil { - return err - } - pr, err := task.EditPull(requestCtx, 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(requestCtx stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) 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() == 0 { - return fmt.Errorf("pull request index is required") - } - - indices, err := utils.ArgsToIndices(ctx.Args().Slice()) - if err != nil { - return err - } - - client := ctx.Login.Client() - for _, index := range indices { - pr, _, err := client.PullRequests.EditPullRequest(requestCtx, ctx.Owner, ctx.Repo, index, opts) - if err != nil { - return err - } - - if len(indices) > 1 { - fmt.Println(pr.HTMLURL) - } else { - print.PullDetails(pr, nil, nil) - } - } - return nil -} diff --git a/cmd/pulls/list.go b/cmd/pulls/list.go deleted file mode 100644 index f772e0f8..00000000 --- a/cmd/pulls/list.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "fmt" - "slices" - - "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" -) - -var pullFieldsFlag = flags.FieldsFlag(print.PullFields, []string{ - "index", "title", "state", "author", "milestone", "updated", "labels", -}) - -// CmdPullsList represents a sub command of issues to list pulls -var CmdPullsList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List pull requests of the repository", - Description: `List pull requests of the repository`, - ArgsUsage: " ", // command does not accept arguments - Action: RunPullsList, - Flags: append([]cli.Flag{pullFieldsFlag}, flags.PRListingFlags...), -} - -// RunPullsList return list of pulls -func RunPullsList(requestCtx 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 - } - - state, err := flags.ParseState(ctx.String("state")) - if err != nil { - return err - } - - client := ctx.Login.Client() - prs, _, err := client.PullRequests.ListRepoPullRequests(requestCtx, ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{ - ListOptions: flags.GetListOptions(cmd), - State: state, - }) - if err != nil { - return err - } - - fields, err := pullFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - 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.Repositories.GetCombinedStatus(requestCtx, 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 deleted file mode 100644 index a08d0606..00000000 --- a/cmd/pulls/merge.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/utils" -) - -// CmdPullsMerge merges a PR -var CmdPullsMerge = cli.Command{ - Name: "merge", - Aliases: []string{"m"}, - Usage: "Merge a pull request", - Description: "Merge a pull request", - ArgsUsage: "", - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "style", - Aliases: []string{"s"}, - Usage: "Kind of merge to perform: merge, rebase, squash, rebase-merge", - Value: "merge", - }, - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "Merge commit title", - }, - &cli.StringFlag{ - Name: "message", - Aliases: []string{"m"}, - Usage: "Merge commit message", - }, - }, flags.AllDefaultFlags...), - Action: func(requestCtx 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 { - // If no PR index is provided, try interactive mode - if err := interact.MergePull(requestCtx, ctx); err != nil && !interact.IsQuitting(err) { - return err - } - return nil - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - return task.PullMerge(requestCtx, ctx.Login, ctx.Owner, ctx.Repo, idx, gitea.MergePullRequestOption{ - Style: gitea.MergeStyle(ctx.String("style")), - Title: ctx.String("title"), - Message: ctx.String("message"), - }) - }, -} diff --git a/cmd/pulls/reject.go b/cmd/pulls/reject.go deleted file mode 100644 index 1b44ca81..00000000 --- a/cmd/pulls/reject.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" -) - -// CmdPullsReject requests changes to a PR -var CmdPullsReject = cli.Command{ - Name: "reject", - Usage: "Request changes to a pull request", - Description: "Request changes to a pull request", - ArgsUsage: " ", - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - return runPullReview(requestCtx, ctx, gitea.ReviewStateRequestChanges, true) - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/pulls/reopen.go b/cmd/pulls/reopen.go deleted file mode 100644 index 68790e29..00000000 --- a/cmd/pulls/reopen.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - "context" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "github.com/urfave/cli/v3" -) - -// CmdPullsReopen reopens a given closed pull request -var CmdPullsReopen = cli.Command{ - Name: "reopen", - Aliases: []string{"open"}, - Usage: "Change state of one or more pull requests to 'open'", - Description: `Change state of one or more pull requests to 'open'`, - ArgsUsage: " [...]", - Action: func(ctx context.Context, cmd *cli.Command) error { - s := gitea.StateOpen - return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s}) - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/pulls/reply.go b/cmd/pulls/reply.go deleted file mode 100644 index f66dcf86..00000000 --- a/cmd/pulls/reply.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdPullsReply replies to a review comment on a pull request. -var CmdPullsReply = cli.Command{ - Name: "reply", - Usage: "Reply to a pull request review comment", - Description: "Reply to a pull request review comment", - ArgsUsage: " []", - Action: func(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - return runPullReviewReply(requestCtx, ctx) - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/pulls/reply_test.go b/cmd/pulls/reply_test.go deleted file mode 100644 index 9dcf72b7..00000000 --- a/cmd/pulls/reply_test.go +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - "context" - "testing" - - "gitea.dev/tea/modules/config" - - "github.com/stretchr/testify/assert" -) - -func TestReply(t *testing.T) { - config.SetConfigForTesting(config.LocalConfig{ - Logins: []config.Login{{ - Name: "testLogin", - URL: "https://gitea.example.com", - Token: "test-token", - User: "testUser", - Default: true, - }}, - }) - t.Cleanup(func() { - config.SetConfigForTesting(config.LocalConfig{}) - }) - - tests := []struct { - name string - args []string - wantErr bool - errContains string - }{ - { - name: "no arguments", - args: []string{}, - wantErr: true, - errContains: "pull request index and comment ID are required", - }, - { - name: "missing comment id", - args: []string{"1"}, - wantErr: true, - errContains: "pull request index and comment ID are required", - }, - { - name: "pull index and comment id", - args: []string{"1", "2"}, - wantErr: true, - errContains: "no reply content provided", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := CmdPullsReply - args := append([]string{"reply"}, tt.args...) - args = append(args, "--login", "testLogin", "--repo", "user/repo") - err := cmd.Run(context.Background(), args) - if tt.wantErr { - assert.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - return - } - }) - } -} diff --git a/cmd/pulls/resolve.go b/cmd/pulls/resolve.go deleted file mode 100644 index a6e3bb73..00000000 --- a/cmd/pulls/resolve.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/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(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - return runResolveComment(requestCtx, ctx, task.ResolvePullReviewComment) - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/pulls/review.go b/cmd/pulls/review.go deleted file mode 100644 index c9b5b063..00000000 --- a/cmd/pulls/review.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "fmt" - "os" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdPullsReview starts an interactive review session -var CmdPullsReview = cli.Command{ - Name: "review", - Usage: "Interactively review a pull request", - Description: "Interactively review a pull request", - ArgsUsage: "", - Action: func(requestCtx 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().Present() { - return fmt.Errorf("must specify at least one PR index") - } - - // This command is intentionally interactive. Fail early in CI / non-TTY - // contexts rather than hanging on prompts. - if os.Getenv("CI") != "" || !print.IsInteractive() || interact.IsStdinPiped() { - return fmt.Errorf("pull review requires an interactive terminal") - } - - for _, arg := range ctx.Args().Slice() { - idx, err := utils.ArgToIndex(arg) - if err != nil { - return err - } - - if err := interact.ReviewPull(requestCtx, ctx, idx); err != nil && !interact.IsQuitting(err) { - return err - } - } - - return nil - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/pulls/review_comments.go b/cmd/pulls/review_comments.go deleted file mode 100644 index 7bc0c39f..00000000 --- a/cmd/pulls/review_comments.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/task" - "gitea.dev/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(requestCtx 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(requestCtx, 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 deleted file mode 100644 index 82db65e8..00000000 --- a/cmd/pulls/review_helpers.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - "errors" - "fmt" - "io" - "strings" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/modules/config" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/interact" - "gitea.dev/tea/modules/task" - "gitea.dev/tea/modules/theme" - "gitea.dev/tea/modules/utils" - - "charm.land/huh/v2" -) - -// runPullReview handles the common logic for approving/rejecting pull requests -func runPullReview(requestCtx stdctx.Context, 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(requestCtx, ctx, idx, state, comment, nil) -} - -// runResolveComment handles the common logic for resolving/unresolving review comments -func runResolveComment(requestCtx stdctx.Context, ctx *context.TeaContext, action func(stdctx.Context, *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(requestCtx, ctx, commentID) -} - -// runPullReviewReply handles replying to a specific review comment on a pull request. -func runPullReviewReply(requestCtx stdctx.Context, ctx *context.TeaContext) error { - if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { - return err - } - - if ctx.Args().Len() < 2 { - return fmt.Errorf("pull request index and comment ID are required") - } - - idx, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - commentID, err := utils.ArgToIndex(ctx.Args().Get(1)) - if err != nil { - return err - } - - body, err := getCommentBody(ctx, ctx.Args().Slice()[2:], "Reply(markdown):", "reply") - if err != nil { - return err - } - - return task.ReplyToPullReviewComment(requestCtx, ctx, idx, commentID, body) -} - -func getCommentBody(ctx *context.TeaContext, extraArgs []string, promptTitle, noun string) (string, error) { - body := strings.Join(extraArgs, " ") - if interact.IsStdinPiped() { - bodyStdin, err := io.ReadAll(ctx.Reader) - if err != nil { - return "", err - } - if len(bodyStdin) != 0 { - body = strings.Join([]string{body, string(bodyStdin)}, "\n\n") - } - } else if len(body) == 0 { - if err := huh.NewForm( - huh.NewGroup( - huh.NewText(). - Title(promptTitle). - ExternalEditor(config.GetPreferences().Editor). - EditorExtension("md"). - Value(&body), - ), - ).WithTheme(theme.GetTheme()).Run(); err != nil { - return "", err - } - } - - if len(strings.TrimSpace(body)) == 0 { - return "", errors.New("no " + noun + " content provided") - } - - return body, nil -} diff --git a/cmd/pulls/review_test.go b/cmd/pulls/review_test.go deleted file mode 100644 index 83d65eeb..00000000 --- a/cmd/pulls/review_test.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestReview(t *testing.T) { - if os.Getenv("GITEA_TEA_TEST_URL") == "" { - t.Skip("GITEA_TEA_TEST_URL is not set, skipping test") - } - - tests := []struct { - name string - args []string - wantErr bool - errContains string - }{ - { - name: "no arguments", - args: []string{}, - wantErr: true, - errContains: "must specify at least one PR index", - }, - { - name: "one argument", - args: []string{"1"}, - wantErr: false, - }, - { - name: "two arguments", - args: []string{"1", "2"}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cmd := CmdPullsReview - args := append(tt.args, "--repo", "user/repo") - err := cmd.Run(t.Context(), args) - if tt.wantErr { - assert.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - return - } - // Don't assert no error, because we expect an error about the missing - // remote. Just assert that the error is not the one we're looking for. - if err != nil { - assert.NotContains(t, err.Error(), "must specify at least one PR index") - } - }) - } -} diff --git a/cmd/pulls/unresolve.go b/cmd/pulls/unresolve.go deleted file mode 100644 index 55f72e1b..00000000 --- a/cmd/pulls/unresolve.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package pulls - -import ( - stdctx "context" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/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(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - return runResolveComment(requestCtx, ctx, task.UnresolvePullReviewComment) - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/releases.go b/cmd/releases.go deleted file mode 100644 index 6c2989f6..00000000 --- a/cmd/releases.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/cmd/releases" - - "github.com/urfave/cli/v3" -) - -// CmdReleases represents to login a gitea server. -// ToDo: ReleaseDetails -var CmdReleases = cli.Command{ - Name: "releases", - Aliases: []string{"release", "r"}, - Category: catEntities, - Usage: "Manage releases", - Description: "Manage releases", - ArgsUsage: " ", // command does not accept arguments - Action: releases.RunReleasesList, - Commands: []*cli.Command{ - &releases.CmdReleaseList, - &releases.CmdReleaseCreate, - &releases.CmdReleaseDelete, - &releases.CmdReleaseEdit, - &CmdReleaseAttachments, - }, - Flags: flags.AllDefaultFlags, -} diff --git a/cmd/releases/create.go b/cmd/releases/create.go deleted file mode 100644 index 74f7ebc3..00000000 --- a/cmd/releases/create.go +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package releases - -import ( - stdctx "context" - "fmt" - "net/http" - "os" - "path/filepath" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "github.com/urfave/cli/v3" -) - -// CmdReleaseCreate represents a sub command of Release to create release -var CmdReleaseCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create a release", - Description: `Create a release for a new or existing git tag`, - ArgsUsage: "[]", - Action: runReleaseCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "tag", - Usage: "Tag name. If the tag does not exist yet, it will be created by Gitea", - }, - &cli.StringFlag{ - Name: "target", - Usage: "Target branch name or commit hash. Defaults to the default branch of the repo", - }, - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "Release title", - }, - &cli.StringFlag{ - Name: "note", - Aliases: []string{"n"}, - Usage: "Release notes", - }, - &cli.StringFlag{ - Name: "note-file", - Aliases: []string{"f"}, - Usage: "Release notes file name. If set, --note is ignored.", - }, - &cli.BoolFlag{ - Name: "draft", - Aliases: []string{"d"}, - Usage: "Is a draft", - }, - &cli.BoolFlag{ - Name: "prerelease", - Aliases: []string{"p"}, - Usage: "Is a pre-release", - }, - &cli.StringSliceFlag{ - Name: "asset", - Aliases: []string{"a"}, - Usage: "Path to file attachment. Can be specified multiple times", - }, - }, flags.AllDefaultFlags...), -} - -func runReleaseCreate(requestCtx 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 - } - - tag := ctx.String("tag") - if cmd.Args().Present() { - if len(tag) != 0 { - return fmt.Errorf("ambiguous arguments: provide tagname via --tag or argument, but not both") - } - tag = cmd.Args().First() - } - - notestring := ctx.String("note") - notefile := ctx.String("note-file") - if notefile != "" { - notebytes, err := os.ReadFile(notefile) - if err != nil { - return fmt.Errorf("unable to read the note file") - } - notestring = string(notebytes) - } - - release, resp, err := ctx.Login.Client().Releases.CreateRelease(requestCtx, ctx.Owner, ctx.Repo, gitea.CreateReleaseOption{ - TagName: tag, - Target: ctx.String("target"), - Title: ctx.String("title"), - Note: notestring, - IsDraft: ctx.Bool("draft"), - IsPrerelease: ctx.Bool("prerelease"), - }) - if err != nil { - if resp != nil && resp.StatusCode == http.StatusConflict { - return fmt.Errorf("there is already a release for this tag") - } - return err - } - - for _, asset := range ctx.StringSlice("asset") { - var file *os.File - - if file, err = os.Open(asset); err != nil { - return err - } - - filePath := filepath.Base(asset) - - if _, _, err = ctx.Login.Client().Releases.CreateReleaseAttachment(requestCtx, ctx.Owner, ctx.Repo, release.ID, file, filePath); err != nil { - file.Close() - return err - } - - file.Close() - } - - return nil -} diff --git a/cmd/releases/delete.go b/cmd/releases/delete.go deleted file mode 100644 index 5deb41c5..00000000 --- a/cmd/releases/delete.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package releases - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdReleaseDelete represents a sub command of Release to delete a release -var CmdReleaseDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete one or more releases", - Description: `Delete one or more releases`, - ArgsUsage: " [...]", - Action: runReleaseDelete, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "confirm", - Aliases: []string{"y"}, - Usage: "Confirm deletion (required)", - }, - &cli.BoolFlag{ - Name: "delete-tag", - Usage: "Also delete the git tag for this release", - }, - }, flags.AllDefaultFlags...), -} - -func runReleaseDelete(requestCtx 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() - - if !ctx.Args().Present() { - fmt.Println("Release tag needed to edit") - return nil - } - - if !ctx.Bool("confirm") { - fmt.Println("Are you sure? Please confirm with -y or --confirm.") - return nil - } - - for _, tag := range ctx.Args().Slice() { - release, err := GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) - if err != nil { - return err - } - _, err = client.Releases.DeleteRelease(requestCtx, ctx.Owner, ctx.Repo, release.ID) - if err != nil { - return err - } - - if ctx.Bool("delete-tag") { - _, err = client.Repositories.DeleteTag(requestCtx, ctx.Owner, ctx.Repo, tag) - return err - } - } - - return nil -} diff --git a/cmd/releases/edit.go b/cmd/releases/edit.go deleted file mode 100644 index 76fb3b49..00000000 --- a/cmd/releases/edit.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package releases - -import ( - "fmt" - "strings" - - stdctx "context" - - gitea "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" -) - -// CmdReleaseEdit represents a sub command of Release to edit releases -var CmdReleaseEdit = cli.Command{ - Name: "edit", - Aliases: []string{"e"}, - Usage: "Edit one or more releases", - Description: `Edit one or more releases`, - ArgsUsage: " [...]", - Action: runReleaseEdit, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "tag", - Usage: "Change Tag", - }, - &cli.StringFlag{ - Name: "target", - Usage: "Change Target", - }, - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "Change Title", - }, - &cli.StringFlag{ - Name: "note", - Aliases: []string{"n"}, - Usage: "Change Notes", - }, - &cli.StringFlag{ - Name: "draft", - Aliases: []string{"d"}, - Usage: "Mark as Draft [True/false]", - DefaultText: "true", - }, - &cli.StringFlag{ - Name: "prerelease", - Aliases: []string{"p"}, - Usage: "Mark as Pre-Release [True/false]", - DefaultText: "true", - }, - }, flags.AllDefaultFlags...), -} - -func runReleaseEdit(requestCtx 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() - - var isDraft, isPre *bool - if ctx.IsSet("draft") { - isDraft = gitea.OptionalBool(strings.ToLower(ctx.String("draft"))[:1] == "t") - } - if ctx.IsSet("prerelease") { - isPre = gitea.OptionalBool(strings.ToLower(ctx.String("prerelease"))[:1] == "t") - } - - if !ctx.Args().Present() { - fmt.Println("Release tag needed to edit") - return nil - } - - for _, tag := range ctx.Args().Slice() { - release, err := GetReleaseByTag(requestCtx, ctx.Owner, ctx.Repo, tag, client) - if err != nil { - return err - } - - _, _, err = client.Releases.EditRelease(requestCtx, ctx.Owner, ctx.Repo, release.ID, gitea.EditReleaseOption{ - TagName: ctx.String("tag"), - Target: ctx.String("target"), - Title: ctx.String("title"), - Note: ctx.String("note"), - IsDraft: isDraft, - IsPrerelease: isPre, - }) - if err != nil { - return err - } - } - return nil -} diff --git a/cmd/releases/list.go b/cmd/releases/list.go deleted file mode 100644 index 4fa950d0..00000000 --- a/cmd/releases/list.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package releases - -import ( - stdctx "context" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdReleaseList represents a sub command of Release to list releases -var CmdReleaseList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List Releases", - Description: "List Releases", - ArgsUsage: " ", // command does not accept arguments - Action: RunReleasesList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.AllDefaultFlags...), -} - -// RunReleasesList list releases -func RunReleasesList(requestCtx 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 - } - - releases, _, err := ctx.Login.Client().Releases.ListReleases(requestCtx, ctx.Owner, ctx.Repo, gitea.ListReleasesOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.ReleasesList(releases, ctx.Output) -} diff --git a/cmd/releases/utils.go b/cmd/releases/utils.go deleted file mode 100644 index 6230ee46..00000000 --- a/cmd/releases/utils.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package releases - -import ( - stdctx "context" - "fmt" - - gitea "gitea.dev/sdk" -) - -// GetReleaseByTag finds a release by its tag name. -func GetReleaseByTag(ctx stdctx.Context, owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { - for page := 1; ; { - rl, resp, err := client.Releases.ListReleases(ctx, 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 deleted file mode 100644 index 47b54e94..00000000 --- a/cmd/repos.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - - gitea "gitea.dev/sdk" - - "gitea.dev/tea/cmd/repos" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" - "github.com/urfave/cli/v3" -) - -// CmdRepos represents the command to manage repositories. -var CmdRepos = cli.Command{ - Name: "repos", - Aliases: []string{"repo"}, - Category: catEntities, - Usage: "Manage repositories", - Description: "Manage repositories", - ArgsUsage: "[/]", - Action: runRepos, - Commands: []*cli.Command{ - &repos.CmdReposList, - &repos.CmdReposSearch, - &repos.CmdRepoCreate, - &repos.CmdRepoCreateFromTemplate, - &repos.CmdRepoFork, - &repos.CmdRepoMigrate, - &repos.CmdRepoRm, - &repos.CmdRepoEdit, - }, - Flags: repos.CmdReposListFlags, -} - -func runRepos(ctx stdctx.Context, cmd *cli.Command) error { - if cmd.Args().Len() == 1 { - return runRepoDetail(ctx, cmd, cmd.Args().First()) - } - return repos.RunReposList(ctx, cmd) -} - -func runRepoDetail(requestCtx stdctx.Context, cmd *cli.Command, path string) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - repoOwner, repoName := utils.GetOwnerAndRepo(path, ctx.Owner) - repo, _, err := client.Repositories.GetRepo(requestCtx, repoOwner, repoName) - if err != nil { - return err - } - topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repoOwner, repoName, gitea.ListRepoTopicsOptions{}) - if err != nil { - return err - } - - print.RepoDetails(repo, topics) - return nil -} diff --git a/cmd/repos/create.go b/cmd/repos/create.go deleted file mode 100644 index ef7b324d..00000000 --- a/cmd/repos/create.go +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - stdctx "context" - "fmt" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdRepoCreate represents a sub command of repos to create one -var CmdRepoCreate = cli.Command{ - Name: "create", - Aliases: []string{"c"}, - Usage: "Create a repository", - Description: "Create a repository", - ArgsUsage: " ", // command does not accept arguments - Action: runRepoCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "name", - Aliases: []string{""}, - Required: true, - Usage: "name of new repo", - }, - &cli.StringFlag{ - Name: "owner", - Aliases: []string{"O"}, - Required: false, - Usage: "name of repo owner", - }, - &cli.BoolFlag{ - Name: "private", - Required: false, - Value: false, - Usage: "make repo private", - }, - &cli.StringFlag{ - Name: "description", - Aliases: []string{"desc"}, - Required: false, - Usage: "add description to repo", - }, - &cli.BoolFlag{ - Name: "init", - Required: false, - Value: false, - Usage: "initialize repo", - }, - &cli.StringFlag{ - Name: "labels", - Required: false, - Usage: "name of label set to add", - }, - &cli.StringFlag{ - Name: "gitignores", - Aliases: []string{"git"}, - Required: false, - Usage: "list of gitignore templates (need --init)", - }, - &cli.StringFlag{ - Name: "license", - Required: false, - Usage: "add license (need --init)", - }, - &cli.StringFlag{ - Name: "readme", - Required: false, - Usage: "use readme template (need --init)", - }, - &cli.StringFlag{ - Name: "branch", - Required: false, - Usage: "use custom default branch (need --init)", - }, - &cli.BoolFlag{ - Name: "template", - Usage: "make repo a template repo", - }, - &cli.StringFlag{ - Name: "trustmodel", - Usage: "select trust model (committer,collaborator,collaborator+committer)", - }, - &cli.StringFlag{ - Name: "object-format", - Required: false, - Usage: "select git object format (sha1,sha256)", - Validator: func(v string) error { - if v != "sha1" && v != "sha256" { - return fmt.Errorf("invalid object format '%s', must be either 'sha1' or 'sha256'", v) - } - return nil - }, - }, - }, flags.LoginOutputFlags...), -} - -func runRepoCreate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - var ( - repo *gitea.Repository - trustmodel gitea.TrustModel - ) - - if ctx.IsSet("trustmodel") { - switch ctx.String("trustmodel") { - case "committer": - trustmodel = gitea.TrustModelCommitter - case "collaborator": - trustmodel = gitea.TrustModelCollaborator - case "collaborator+committer": - trustmodel = gitea.TrustModelCollaboratorCommitter - default: - return fmt.Errorf("unknown trustmodel type '%s'", ctx.String("trustmodel")) - } - } - - opts := gitea.CreateRepoOption{ - Name: ctx.String("name"), - Description: ctx.String("description"), - Private: ctx.Bool("private"), - AutoInit: ctx.Bool("init"), - IssueLabels: ctx.String("labels"), - Gitignores: ctx.String("gitignores"), - License: ctx.String("license"), - Readme: ctx.String("readme"), - DefaultBranch: ctx.String("branch"), - Template: ctx.Bool("template"), - TrustModel: trustmodel, - ObjectFormatName: ctx.String("object-format"), - } - if len(ctx.String("owner")) != 0 { - repo, _, err = client.Repositories.CreateOrgRepo(requestCtx, ctx.String("owner"), opts) - } else { - repo, _, err = client.Repositories.CreateRepo(requestCtx, opts) - } - if err != nil { - return err - } - - topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) - if err != nil { - return err - } - print.RepoDetails(repo, topics) - - fmt.Printf("%s\n", repo.HTMLURL) - return nil -} diff --git a/cmd/repos/create_from_template.go b/cmd/repos/create_from_template.go deleted file mode 100644 index 8bbc8912..00000000 --- a/cmd/repos/create_from_template.go +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - "fmt" - - stdctx "context" - - "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "gitea.dev/tea/modules/utils" -) - -// CmdRepoCreateFromTemplate represents a sub command of repos to generate one from a template repo -var CmdRepoCreateFromTemplate = cli.Command{ - Name: "create-from-template", - Aliases: []string{"ct"}, - Usage: "Create a repository based on an existing template", - Description: "Create a repository based on an existing template", - Action: runRepoCreateFromTemplate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "template", - Aliases: []string{"t"}, - Required: true, - Usage: "source template to copy from", - }, - &cli.StringFlag{ - Name: "name", - Aliases: []string{"n"}, - Required: true, - Usage: "name of new repo", - }, - &cli.StringFlag{ - Name: "owner", - Aliases: []string{"O"}, - Usage: "name of repo owner", - }, - &cli.BoolFlag{ - Name: "private", - Usage: "make new repo private", - }, - &cli.StringFlag{ - Name: "description", - Aliases: []string{"desc"}, - Usage: "add custom description to repo", - }, - &cli.BoolFlag{ - Name: "content", - Value: true, - Usage: "copy git content from template", - }, - &cli.BoolFlag{ - Name: "githooks", - Value: true, - Usage: "copy git hooks from template", - }, - &cli.BoolFlag{ - Name: "avatar", - Value: true, - Usage: "copy repo avatar from template", - }, - &cli.BoolFlag{ - Name: "labels", - Value: true, - Usage: "copy repo labels from template", - }, - &cli.BoolFlag{ - Name: "topics", - Value: true, - Usage: "copy topics from template", - }, - &cli.BoolFlag{ - Name: "webhooks", - Usage: "copy webhooks from template", - }, - }, flags.LoginOutputFlags...), -} - -func runRepoCreateFromTemplate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - - templateOwner, templateRepo := utils.GetOwnerAndRepo(ctx.String("template"), ctx.Login.User) - owner := ctx.Login.User - if ctx.IsSet("owner") { - owner = ctx.String("owner") - } - - opts := gitea.CreateRepoFromTemplateOption{ - Name: ctx.String("name"), - Owner: owner, - Description: ctx.String("description"), - Private: ctx.Bool("private"), - GitContent: ctx.Bool("content"), - GitHooks: ctx.Bool("githooks"), - Avatar: ctx.Bool("avatar"), - Labels: ctx.Bool("labels"), - Topics: ctx.Bool("topics"), - Webhooks: ctx.Bool("webhooks"), - } - - repo, _, err := client.Repositories.CreateRepoFromTemplate(requestCtx, templateOwner, templateRepo, opts) - if err != nil { - return err - } - - topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) - if err != nil { - return err - } - print.RepoDetails(repo, topics) - - fmt.Printf("%s\n", repo.HTMLURL) - return nil -} diff --git a/cmd/repos/delete.go b/cmd/repos/delete.go deleted file mode 100644 index 46e2871d..00000000 --- a/cmd/repos/delete.go +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - stdctx "context" - "fmt" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "charm.land/huh/v2" - "github.com/urfave/cli/v3" -) - -// CmdRepoRm represents a sub command of repos to delete an existing repo -var CmdRepoRm = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete an existing repository", - Description: "Removes a repository from your Gitea instance", - ArgsUsage: " ", // command does not accept arguments - Action: runRepoDelete, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "name", - Aliases: []string{""}, - Required: true, - Usage: "name of the repo", - }, - &cli.StringFlag{ - Name: "owner", - Aliases: []string{"O"}, - Required: false, - Usage: "owner of the repo", - }, - &cli.BoolFlag{ - Name: "force", - Aliases: []string{"f"}, - Required: false, - Value: false, - Usage: "Force the deletion and don't ask for confirmation", - }, - }, flags.LoginOutputFlags...), -} - -func runRepoDelete(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - client := ctx.Login.Client() - - var owner string - if ctx.IsSet("owner") { - owner = ctx.String("owner") - } else { - owner = ctx.Login.User - } - - repoName := ctx.String("name") - - repoSlug := fmt.Sprintf("%s/%s", owner, repoName) - - if !ctx.Bool("force") { - var enteredRepoSlug string - if err := huh.NewInput(). - Title(fmt.Sprintf("Confirm the deletion of the repository '%s' by typing its name: ", repoSlug)). - Validate(huh.ValidateNotEmpty()). - Value(&enteredRepoSlug). - Run(); err != nil { - return err - } - - if enteredRepoSlug != repoSlug { - return fmt.Errorf("entered wrong repository name '%s', expected '%s'", enteredRepoSlug, repoSlug) - } - } - - _, err = client.Repositories.DeleteRepo(requestCtx, owner, repoName) - if err != nil { - return err - } - - fmt.Printf("Successfully deleted %s/%s\n", owner, repoName) - return nil -} diff --git a/cmd/repos/edit.go b/cmd/repos/edit.go deleted file mode 100644 index 635ad2f2..00000000 --- a/cmd/repos/edit.go +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - stdctx "context" - "strings" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "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(requestCtx 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.Repositories.EditRepo(requestCtx, ctx.Owner, ctx.Repo, opts) - if err != nil { - return err - } - - topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) - if err != nil { - return err - } - print.RepoDetails(repo, topics) - return nil -} diff --git a/cmd/repos/flags.go b/cmd/repos/flags.go deleted file mode 100644 index 180a4c0b..00000000 --- a/cmd/repos/flags.go +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - "fmt" - - "gitea.dev/sdk" - "github.com/urfave/cli/v3" -) - -var typeFilterFlag = cli.StringFlag{ - Name: "type", - Aliases: []string{"T"}, - Required: false, - Usage: "Filter by type: fork, mirror, source", -} - -func getTypeFilter(cmd *cli.Command) (filter gitea.RepoType, err error) { - t := cmd.String("type") - filter = gitea.RepoTypeNone - switch t { - case "": - filter = gitea.RepoTypeNone - case "fork": - filter = gitea.RepoTypeFork - case "mirror": - filter = gitea.RepoTypeMirror - case "source": - filter = gitea.RepoTypeSource - default: - err = fmt.Errorf("invalid repo type '%s'. valid: fork, mirror, source", t) - } - return -} diff --git a/cmd/repos/fork.go b/cmd/repos/fork.go deleted file mode 100644 index 9cbc4a72..00000000 --- a/cmd/repos/fork.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - stdctx "context" - "fmt" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdRepoFork represents a sub command of repos to fork an existing repo -var CmdRepoFork = cli.Command{ - Name: "fork", - Aliases: []string{"f"}, - Usage: "Fork an existing repository", - Description: "Create a repository from an existing repo", - ArgsUsage: " ", // command does not accept arguments - Action: runRepoFork, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "owner", - Aliases: []string{"O"}, - Usage: "name of fork's owner, defaults to current user", - }, - }, flags.LoginRepoFlags...), -} - -func runRepoFork(requestCtx 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.CreateForkOption{} - if ctx.IsSet("owner") { - owner := ctx.String("owner") - opts.Organization = &owner - } - - repo, _, err := client.Repositories.CreateFork(requestCtx, ctx.Owner, ctx.Repo, opts) - if err != nil { - return err - } - - topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) - if err != nil { - return err - } - print.RepoDetails(repo, topics) - - fmt.Printf("%s\n", repo.HTMLURL) - return nil -} diff --git a/cmd/repos/list.go b/cmd/repos/list.go deleted file mode 100644 index 0f4a33c7..00000000 --- a/cmd/repos/list.go +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - stdctx "context" - "fmt" - "net/http" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -var repoFieldsFlag = flags.FieldsFlag(print.RepoFields, []string{ - "owner", "name", "type", "ssh", -}) - -// CmdReposListFlags contains all flags needed for repo listing -var CmdReposListFlags = append([]cli.Flag{ - &cli.BoolFlag{ - Name: "watched", - Aliases: []string{"w"}, - Required: false, - Usage: "List your watched repos instead", - }, - &cli.BoolFlag{ - Name: "starred", - Aliases: []string{"s"}, - 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, - &flags.PaginationLimitFlag, -}, flags.LoginOutputFlags...) - -// CmdReposList represents a sub command of repos to list them -var CmdReposList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List repositories you have access to", - Description: "List repositories you have access to", - Action: RunReposList, - Flags: CmdReposListFlags, -} - -// RunReposList list repositories -func RunReposList(requestCtx stdctx.Context, cmd *cli.Command) error { - teaCmd, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := teaCmd.Login.Client() - - typeFilter, err := getTypeFilter(cmd) - if err != nil { - return err - } - - var rps []*gitea.Repository - if owner := teaCmd.String("owner"); owner != "" { - var err error - _, resp, orgErr := client.Organizations.GetOrg(requestCtx, 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.Repositories.ListUserRepos(requestCtx, owner, gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - } else { - rps, _, err = client.Repositories.ListOrgRepos(requestCtx, owner, gitea.ListOrgReposOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - } - if err != nil { - return err - } - } else if teaCmd.Bool("starred") { - user, _, err := client.Users.GetMyUserInfo(requestCtx) - if err != nil { - return err - } - rps, _, err = client.Repositories.SearchRepos(requestCtx, gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(cmd), - StarredByUserID: user.ID, - }) - if err != nil { - return err - } - } else if teaCmd.Bool("watched") { - // GetMyWatchedRepos doesn't expose server-side pagination, - // so we implement client-side pagination as a workaround - allRepos, _, err := client.Repositories.GetMyWatchedRepos(requestCtx) - if err != nil { - return err - } - rps = paginateRepos(allRepos, flags.GetListOptions(cmd)) - } else { - var err error - rps, _, err = client.Repositories.ListMyRepos(requestCtx, gitea.ListReposOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - } - - reposFiltered := rps - if typeFilter != gitea.RepoTypeNone { - reposFiltered = filterReposByType(rps, typeFilter) - } - - fields, err := repoFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - - return print.ReposList(reposFiltered, teaCmd.Output, fields) -} - -func filterReposByType(repos []*gitea.Repository, t gitea.RepoType) []*gitea.Repository { - var filtered []*gitea.Repository - for _, r := range repos { - switch t { - case gitea.RepoTypeFork: - if !r.Fork { - continue - } - case gitea.RepoTypeMirror: - if !r.Mirror { - continue - } - case gitea.RepoTypeSource: - if r.Fork || r.Mirror { - continue - } - } - - filtered = append(filtered, r) - } - 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 deleted file mode 100644 index a241d0b8..00000000 --- a/cmd/repos/migrate.go +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - "fmt" - - stdctx "context" - - "gitea.dev/sdk" - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" -) - -// CmdRepoMigrate represents a sub command of repos to migrate one -var CmdRepoMigrate = cli.Command{ - Name: "migrate", - Aliases: []string{"m"}, - Usage: "Migrate a repository", - Description: "Migrate a repository and or mirror it.", - ArgsUsage: " ", // command does not accept arguments - Action: runRepoMigrate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "name", - Usage: "Name of the repository", - Required: true, - }, - &cli.StringFlag{ - Name: "owner", - Usage: "Owner of the repository", - Required: true, - }, - &cli.StringFlag{ - Name: "clone-url", - Usage: "Clone URL of the repository", - Required: true, - }, - &cli.StringFlag{ - Name: "service", - Usage: string("Service to migrate from. Supported services are: " + gitea.GitServicePlain + - ", " + gitea.GitServiceGitea + ", " + gitea.GitServiceGitlab + ", " + gitea.GitServiceGogs), - Required: true, - }, - &cli.BoolFlag{ - Name: "mirror", - Usage: "Mirror the repository", - }, - &cli.BoolFlag{ - Name: "private", - Usage: "Make the repository private", - }, - &cli.BoolFlag{ - Name: "template", - Usage: "Make the repository a template", - }, - &cli.BoolFlag{ - Name: "wiki", - Usage: "Copy the wiki", - }, - &cli.BoolFlag{ - Name: "issues", - Usage: "Copy the issues", - }, - &cli.BoolFlag{ - Name: "labels", - Usage: "Copy the lables", - }, - &cli.BoolFlag{ - Name: "pull-requests", - Usage: "Copy the pull requests", - }, - &cli.BoolFlag{ - Name: "releases", - Usage: "Copy the releases", - }, - &cli.BoolFlag{ - Name: "milestones", - Usage: "Copy the milestones", - }, - &cli.StringFlag{ - Name: "mirror-interval", - Usage: "Interval to mirror the repository.", - }, - &cli.BoolFlag{ - Name: "lfs", - Usage: "Copy the LFS objects", - }, - &cli.StringFlag{ - Name: "lfs-endpoint", - Usage: "LFS endpoint to use", - }, - &cli.StringFlag{ - Name: "auth-user", - Usage: "Username to use for authentication.", - }, - &cli.StringFlag{ - Name: "auth-password", - Usage: "Password to use for authentication.", - }, - &cli.StringFlag{ - Name: "auth-token", - Usage: "Token to use for authentication.", - }, - }, flags.LoginOutputFlags...), -} - -func runRepoMigrate(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - var ( - repo *gitea.Repository - service gitea.GitServiceType - ) - - if ctx.IsSet("service") { - switch ctx.String("service") { - case "git": - service = gitea.GitServicePlain - case "gitea": - service = gitea.GitServiceGitea - case "gitlab": - service = gitea.GitServiceGitlab - case "gogs": - service = gitea.GitServiceGogs - case "github": - service = gitea.GitServiceGithub - default: - return fmt.Errorf("unknown git service type '%s'", ctx.String("service")) - } - } - - opts := gitea.MigrateRepoOption{ - RepoName: ctx.String("name"), - RepoOwner: ctx.String("owner"), - CloneAddr: ctx.String("clone-url"), - Service: service, - AuthUsername: ctx.String("auth-user"), - AuthPassword: ctx.String("auth-password"), - AuthToken: ctx.String("auth-token"), - Mirror: ctx.Bool("mirror"), - Private: ctx.Bool("private"), - Description: ctx.String("description"), - Wiki: ctx.Bool("wiki"), - Milestones: ctx.Bool("milestones"), - Labels: ctx.Bool("labels"), - Issues: ctx.Bool("issues"), - PullRequests: ctx.Bool("pull-requests"), - Releases: ctx.Bool("releases"), - MirrorInterval: ctx.String("mirror-interval"), - LFS: ctx.Bool("lfs"), - LFSEndpoint: ctx.String("lfs-endpoint"), - } - - repo, _, err = client.Repositories.MigrateRepo(requestCtx, opts) - if err != nil { - return err - } - - topics, _, err := client.Repositories.ListRepoTopics(requestCtx, repo.Owner.UserName, repo.Name, gitea.ListRepoTopicsOptions{}) - if err != nil { - return err - } - print.RepoDetails(repo, topics) - - fmt.Printf("%s\n", repo.HTMLURL) - return nil -} diff --git a/cmd/repos/search.go b/cmd/repos/search.go deleted file mode 100644 index 4efde401..00000000 --- a/cmd/repos/search.go +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package repos - -import ( - stdctx "context" - "fmt" - "net/http" - "strings" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - "github.com/urfave/cli/v3" -) - -// CmdReposSearch represents a sub command of repos to find them -var CmdReposSearch = cli.Command{ - Name: "search", - Aliases: []string{"s"}, - Usage: "Find any repo on an Gitea instance", - Description: "Find any repo on an Gitea instance", - ArgsUsage: "[]", - Action: runReposSearch, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - // TODO: it might be nice to search for topics as an ADDITIONAL filter. - // for that, we'd probably need to make multiple queries and UNION the results. - Name: "topic", - Aliases: []string{"t"}, - Required: false, - Usage: "Search for term in repo topics instead of name", - }, - &typeFilterFlag, - &cli.StringFlag{ - Name: "owner", - Aliases: []string{"O"}, - Required: false, - Usage: "Filter by owner", - }, - &cli.StringFlag{ - Name: "private", - Required: false, - Usage: "Filter private repos (true|false)", - }, - &cli.StringFlag{ - Name: "archived", - Required: false, - Usage: "Filter archived repos (true|false)", - }, - repoFieldsFlag, - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.LoginOutputFlags...), -} - -func runReposSearch(requestCtx stdctx.Context, cmd *cli.Command) error { - 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 an organization - org, resp, err := client.Organizations.GetOrg(requestCtx, teaCmd.String("owner")) - if err != nil { - if resp == nil || resp.StatusCode != http.StatusNotFound { - return fmt.Errorf("could not find owner: %w", err) - } - - // if owner is no org, its a user - user, _, err := client.Users.GetUserInfo(requestCtx, teaCmd.String("owner")) - if err != nil { - return err - } - ownerID = user.ID - } else { - ownerID = org.ID - } - } - - var isArchived *bool - if teaCmd.IsSet("archived") { - archived := strings.ToLower(teaCmd.String("archived"))[:1] == "t" - isArchived = &archived - } - - var isPrivate *bool - if teaCmd.IsSet("private") { - private := strings.ToLower(teaCmd.String("private"))[:1] == "t" - isPrivate = &private - } - - mode, err := getTypeFilter(cmd) - if err != nil { - return err - } - - var keyword string - if teaCmd.Args().Present() { - keyword = strings.Join(teaCmd.Args().Slice(), " ") - } - - user, _, err := client.Users.GetMyUserInfo(requestCtx) - if err != nil { - return err - } - - rps, _, err := client.Repositories.SearchRepos(requestCtx, gitea.SearchRepoOptions{ - ListOptions: flags.GetListOptions(cmd), - OwnerID: ownerID, - IsPrivate: isPrivate, - IsArchived: isArchived, - Type: mode, - Keyword: keyword, - KeywordInDescription: true, - KeywordIsTopic: teaCmd.Bool("topic"), - PrioritizedByOwnerID: user.ID, - }) - if err != nil { - return err - } - - fields, err := repoFieldsFlag.GetValues(cmd) - if err != nil { - return err - } - return print.ReposList(rps, teaCmd.Output, fields) -} diff --git a/cmd/sshkeys.go b/cmd/sshkeys.go deleted file mode 100644 index f232c170..00000000 --- a/cmd/sshkeys.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - stdctx "context" - - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/sshkeys" -) - -// CmdSSHKeys represents the ssh-keys command group -var CmdSSHKeys = cli.Command{ - Name: "ssh-keys", - Aliases: []string{"ssh-key"}, - Category: catSetup, - Usage: "Manage SSH public keys", - Description: "List, add, or delete SSH public keys on the current user's account", - ArgsUsage: " ", - Action: runSSHKeys, - Commands: []*cli.Command{ - &sshkeys.CmdSSHKeyList, - &sshkeys.CmdSSHKeyAdd, - &sshkeys.CmdSSHKeyDelete, - }, - Flags: sshkeys.CmdSSHKeyList.Flags, -} - -func runSSHKeys(ctx stdctx.Context, cmd *cli.Command) error { - return sshkeys.RunSSHKeyList(ctx, cmd) -} diff --git a/cmd/sshkeys/add.go b/cmd/sshkeys/add.go deleted file mode 100644 index 1a930b5c..00000000 --- a/cmd/sshkeys/add.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package sshkeys - -import ( - stdctx "context" - "fmt" - "os" - "path/filepath" - "strings" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdSSHKeyAdd represents a sub command of ssh-keys to add an SSH public key -var CmdSSHKeyAdd = cli.Command{ - Name: "add", - Usage: "Add an SSH public key", - Description: "Add an SSH public key to the current user's profile", - ArgsUsage: "", - Action: RunSSHKeyAdd, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "Title for the key (defaults to the filename without extension)", - }, - }, flags.LoginOutputFlags...), -} - -// RunSSHKeyAdd reads a public key file and registers it with the Gitea instance -func RunSSHKeyAdd(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - if ctx.Args().Len() < 1 { - return fmt.Errorf("key file path is required") - } - - keyFile := ctx.Args().First() - keyBytes, err := os.ReadFile(keyFile) - if err != nil { - return fmt.Errorf("could not read key file '%s': %w", keyFile, err) - } - - keyContent := strings.TrimSpace(string(keyBytes)) - if keyContent == "" { - return fmt.Errorf("key file '%s' is empty", keyFile) - } - - title := ctx.String("title") - if title == "" { - base := filepath.Base(keyFile) - title = strings.TrimSuffix(base, filepath.Ext(base)) - } - - key, _, err := ctx.Login.Client().Users.CreatePublicKey(requestCtx, gitea.CreateKeyOption{ - Title: title, - Key: keyContent, - }) - if err != nil { - return err - } - - fmt.Printf("Key '%s' (id: %d) added successfully.\n", key.Title, key.ID) - return nil -} diff --git a/cmd/sshkeys/add_test.go b/cmd/sshkeys/add_test.go deleted file mode 100644 index 40c8147a..00000000 --- a/cmd/sshkeys/add_test.go +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package sshkeys - -import ( - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestKeyTitleFromFilename(t *testing.T) { - cases := []struct { - input string - expected string - }{ - {"id_ed25519.pub", "id_ed25519"}, - {"id_rsa.pub", "id_rsa"}, - {"/home/user/.ssh/id_ed25519.pub", "id_ed25519"}, - {"mykey", "mykey"}, // no extension - {"my.key.pub", "my.key"}, - } - - for _, tc := range cases { - t.Run(tc.input, func(t *testing.T) { - base := filepath.Base(tc.input) - title := strings.TrimSuffix(base, filepath.Ext(base)) - assert.Equal(t, tc.expected, title) - }) - } -} diff --git a/cmd/sshkeys/delete.go b/cmd/sshkeys/delete.go deleted file mode 100644 index d9df0d93..00000000 --- a/cmd/sshkeys/delete.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package sshkeys - -import ( - stdctx "context" - "fmt" - "strconv" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - - "github.com/urfave/cli/v3" -) - -// CmdSSHKeyDelete represents a sub command of ssh-keys to delete an SSH key by ID -var CmdSSHKeyDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete an SSH key", - Description: "Delete an SSH key from the current user's profile by its numeric ID", - ArgsUsage: "", - Action: RunSSHKeyDelete, - Flags: append([]cli.Flag{ - &cli.BoolFlag{ - Name: "confirm", - Aliases: []string{"y"}, - Usage: "Confirm deletion (required)", - }, - }, flags.LoginOutputFlags...), -} - -// RunSSHKeyDelete removes an SSH key by its numeric ID -func RunSSHKeyDelete(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - - if ctx.Args().Len() < 1 { - return fmt.Errorf("key ID is required") - } - - keyID, err := strconv.ParseInt(ctx.Args().First(), 10, 64) - if err != nil { - return fmt.Errorf("invalid key ID '%s': must be a number", ctx.Args().First()) - } - - client := ctx.Login.Client() - - key, resp, err := client.Users.GetPublicKey(requestCtx, keyID) - if err != nil { - if resp != nil && resp.StatusCode == 404 { - return fmt.Errorf("SSH key with ID %d not found", keyID) - } - return err - } - - if !ctx.Bool("confirm") { - fmt.Printf("Are you sure you want to delete SSH key '%s' (id: %d)? [y/N] ", key.Title, keyID) - var response string - fmt.Scanln(&response) - if response != "y" && response != "Y" && response != "yes" { - fmt.Println("Deletion canceled.") - return nil - } - } - - if _, err = client.Users.DeletePublicKey(requestCtx, keyID); err != nil { - return err - } - - fmt.Printf("SSH key '%s' deleted successfully\n", key.Title) - return nil -} diff --git a/cmd/sshkeys/list.go b/cmd/sshkeys/list.go deleted file mode 100644 index 269b876a..00000000 --- a/cmd/sshkeys/list.go +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package sshkeys - -import ( - stdctx "context" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/print" - - "github.com/urfave/cli/v3" -) - -// CmdSSHKeyList represents a sub command of ssh-keys to list the current user's SSH keys -var CmdSSHKeyList = cli.Command{ - Name: "list", - Aliases: []string{"ls"}, - Usage: "List SSH keys", - Description: "List the SSH keys registered for the current user", - ArgsUsage: " ", // command does not accept arguments - Action: RunSSHKeyList, - Flags: append([]cli.Flag{ - &flags.PaginationPageFlag, - &flags.PaginationLimitFlag, - }, flags.LoginOutputFlags...), -} - -// RunSSHKeyList lists SSH keys for the current user -func RunSSHKeyList(requestCtx stdctx.Context, cmd *cli.Command) error { - ctx, err := context.InitCommand(cmd) - if err != nil { - return err - } - client := ctx.Login.Client() - - keys, _, err := client.Users.ListMyPublicKeys(requestCtx, gitea.ListPublicKeysOptions{ - ListOptions: flags.GetListOptions(cmd), - }) - if err != nil { - return err - } - - return print.SSHKeysList(keys, ctx.Output) -} diff --git a/cmd/times.go b/cmd/times.go deleted file mode 100644 index fd1a761e..00000000 --- a/cmd/times.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "github.com/urfave/cli/v3" - - "gitea.dev/tea/cmd/times" -) - -// CmdTrackedTimes represents the command to operate repositories' times. -var CmdTrackedTimes = cli.Command{ - Name: "times", - Aliases: []string{"time", "t"}, - Category: catEntities, - Usage: "Operate on tracked times of a repository's issues & pulls", - Description: `Operate on tracked times of a repository's issues & pulls. - Depending on your permissions on the repository, only your own tracked - times might be listed.`, - ArgsUsage: "[username | #issue]", - Action: times.RunTimesList, - Commands: []*cli.Command{ - ×.CmdTrackedTimesAdd, - ×.CmdTrackedTimesDelete, - ×.CmdTrackedTimesReset, - ×.CmdTrackedTimesList, - }, - Flags: times.CmdTrackedTimesList.Flags, -} diff --git a/cmd/times/add.go b/cmd/times/add.go deleted file mode 100644 index 26f7781e..00000000 --- a/cmd/times/add.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package times - -import ( - stdctx "context" - "fmt" - "strings" - "time" - - "gitea.dev/sdk" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/utils" - "github.com/urfave/cli/v3" -) - -// CmdTrackedTimesAdd represents a sub command of times to add time to an issue -var CmdTrackedTimesAdd = cli.Command{ - Name: "add", - Aliases: []string{"a"}, - Usage: "Track spent time on an issue", - UsageText: "tea times add ", - Description: `Track spent time on an issue - Example: - tea times add 1 1h25m - `, - Action: runTrackedTimesAdd, - Flags: flags.LoginRepoFlags, -} - -func runTrackedTimesAdd(requestCtx 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() < 2 { - return fmt.Errorf("no issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText) - } - - issue, err := utils.ArgToIndex(ctx.Args().First()) - if err != nil { - return err - } - - duration, err := time.ParseDuration(strings.Join(ctx.Args().Tail(), "")) - if err != nil { - return err - } - - _, _, err = ctx.Login.Client().Issues.AddTime(requestCtx, ctx.Owner, ctx.Repo, issue, gitea.AddTimeOption{ - Time: int64(duration.Seconds()), - }) - return err -} diff --git a/cmd/times/delete.go b/cmd/times/delete.go deleted file mode 100644 index 58a2e2c0..00000000 --- a/cmd/times/delete.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package times - -import ( - stdctx "context" - "fmt" - "strconv" - - "gitea.dev/tea/cmd/flags" - "gitea.dev/tea/modules/context" - "gitea.dev/tea/modules/utils" - - "github.com/urfave/cli/v3" -) - -// CmdTrackedTimesDelete is a sub command of CmdTrackedTimes, and removes time from an issue -var CmdTrackedTimesDelete = cli.Command{ - Name: "delete", - Aliases: []string{"rm"}, - Usage: "Delete a single tracked time on an issue", - UsageText: "tea times delete