5 Commits

Author SHA1 Message Date
Hugo Nijhuis-Mekkelholt
6e88132305 chore: retrigger CI
Some checks failed
check-and-test / Run govulncheck (pull_request) Successful in 36s
check-and-test / check-and-test (pull_request) Failing after 1m21s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:07:01 +01:00
Hugo Nijhuis-Mekkelholt
38f93a4997 docs: generate documentation for actions commands
Some checks failed
check-and-test / Run govulncheck (pull_request) Successful in 2m18s
check-and-test / check-and-test (pull_request) Failing after 2m22s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:02:57 +01:00
Hugo Nijhuis-Mekkelholt
9fdbd4aafc fix: formatting and error message capitalization
Some checks failed
check-and-test / check-and-test (pull_request) Failing after 1m34s
check-and-test / Run govulncheck (pull_request) Successful in 1m54s
- Run go fmt on modules/api/types.go to fix struct alignment
- Use lowercase error messages to match codebase conventions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:57:49 +01:00
Hugo Nijhuis-Mekkelholt
7aa00e6f54 fix: address code review feedback
Some checks failed
check-and-test / check-and-test (pull_request) Failing after 1m38s
check-and-test / Run govulncheck (pull_request) Successful in 1m55s
- Fix GetRaw() error handling to check status before reading body
- Add context.Context support to all HTTP requests
- Use consistent capitalized error messages
- Update copyright year from 2024 to 2025
- Add unit tests for modules/api/client.go
- Add tests for runs, jobs, logs commands

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:51:52 +01:00
Hugo Nijhuis-Mekkelholt
cea501523e feat(actions): add runs, jobs, and logs commands
Some checks failed
check-and-test / Run govulncheck (pull_request) Successful in 1m43s
check-and-test / check-and-test (pull_request) Failing after 2m29s
Implement tea actions commands to view workflow runs and logs using
the Gitea 1.25 API endpoints directly. This adds:
- `tea actions runs` - list workflow runs for a repository
- `tea actions jobs <run-id>` - list jobs for a specific run
- `tea actions logs <job-id>` - display logs for a specific job

Also adds a new `modules/api` package for making raw authenticated
HTTP requests to Gitea API endpoints not yet supported by the go-sdk.

Closes #1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 19:40:19 +01:00
226 changed files with 2723 additions and 8939 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "Tea DevContainer", "name": "Tea DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:2.1-trixie", "image": "mcr.microsoft.com/devcontainers/go:1.24-bullseye",
"features": { "features": {
"ghcr.io/devcontainers/features/git-lfs:1.2.5": {} "ghcr.io/devcontainers/features/git-lfs:1.2.5": {}
}, },

View File

@@ -8,30 +8,26 @@ jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
- uses: actions/setup-go@v6 - uses: actions/setup-go@v5
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: import gpg - name: import gpg
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7 uses: crazy-max/ghaction-import-gpg@v6
with: with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }} gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: get SDK version
id: sdk_version
run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT"
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@v7 uses: goreleaser/goreleaser-action@v6
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
version: "~> v1" version: "~> v1"
args: release --nightly args: release --nightly
env: env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }} AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
@@ -49,24 +45,24 @@ jobs:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
env: env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119 ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
with: with:

View File

@@ -9,30 +9,26 @@ jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
- uses: actions/setup-go@v6 - uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: import gpg - name: import gpg
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@v7 uses: crazy-max/ghaction-import-gpg@v6
with: with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }} gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: get SDK version
id: sdk_version
run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT"
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@v7 uses: goreleaser/goreleaser-action@v6
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
version: "~> v1" version: "~> v1"
args: release args: release
env: env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }} AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
@@ -50,18 +46,18 @@ jobs:
DOCKER_LATEST: nightly DOCKER_LATEST: nightly
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # all history for all branches and tags fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker BuildX - name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
@@ -71,7 +67,7 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
env: env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119 ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
with: with:

View File

@@ -4,20 +4,24 @@ on:
- pull_request - pull_request
jobs: jobs:
#govulncheck_job: govulncheck_job:
# runs-on: ubuntu-latest
# name: Run govulncheck
# steps:
# - id: govulncheck
# uses: golang/govulncheck-action@v1
# with:
# go-version-file: 'go.mod'
check-and-unit:
name: Lint Build And Unit Coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Run govulncheck
steps: steps:
- uses: actions/checkout@v6 - id: govulncheck
- uses: actions/setup-go@v6 uses: golang/govulncheck-action@v1
with:
go-version-file: 'go.mod'
check-and-test:
runs-on: ubuntu-latest
env:
HTTP_PROXY: ""
GITEA_TEA_TEST_URL: "http://gitea:3000"
GITEA_TEA_TEST_USERNAME: "test01"
GITEA_TEA_TEST_PASSWORD: "test01"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: lint and build - name: lint and build
@@ -26,32 +30,17 @@ jobs:
make vet make vet
make lint make lint
make fmt-check make fmt-check
make misspell-check
make docs-check make docs-check
make build make build
- name: unit test and coverage
run: |
make unit-test-coverage
integration-test:
name: Integration Test
runs-on: ubuntu-latest
env:
HTTP_PROXY: ""
GITEA_TEA_TEST_URL: "http://gitea:3000"
GITEA_TEA_TEST_USERNAME: "test01"
GITEA_TEA_TEST_PASSWORD: "test01"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance - run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
- name: integration test - name: test and coverage
run: | run: |
make integration-test make test
make unit-test-coverage
services: services:
gitea: gitea:
image: docker.gitea.com/gitea:1.26.1 image: docker.gitea.com/gitea:1.24.5
cmd: cmd:
- bash - bash
- -c - -c

2
.gitignore vendored
View File

@@ -17,5 +17,3 @@ dist/
.direnv/ .direnv/
result result
result-* result-*
.DS_Store

View File

@@ -1,45 +0,0 @@
version: "2"
formatters:
enable:
- gofumpt
linters:
default: none
enable:
- govet
- revive
- misspell
- ineffassign
- unused
settings:
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
- name: if-return
- name: increment-decrement
- name: var-declaration
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
misspell:
locale: US
ignore-words:
- unknwon
- destory
issues:
max-issues-per-linter: 0
max-same-issues: 0

View File

@@ -38,6 +38,8 @@ builds:
- goos: windows - goos: windows
goarch: arm goarch: arm
goarm: "7" goarm: "7"
- goos: windows
goarch: arm64
- goos: freebsd - goos: freebsd
goarch: ppc64le goarch: ppc64le
- goos: freebsd - goos: freebsd
@@ -56,7 +58,7 @@ builds:
flags: flags:
- -trimpath - -trimpath
ldflags: ldflags:
- -s -w -X "code.gitea.io/tea/modules/version.Version={{ trimprefix .Summary "v" }}" -X "code.gitea.io/tea/modules/version.Tags=" -X "code.gitea.io/tea/modules/version.SDK={{ .Env.SDK_VERSION }}" - -s -w -X code.gitea.io/tea/cmd.Version={{ .Version }}
binary: >- binary: >-
{{ .ProjectName }}- {{ .ProjectName }}-
{{- .Version }}- {{- .Version }}-

View File

@@ -1,20 +1,5 @@
# Changelog # Changelog
## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05
* FEATURES
* Add `tea pr edit` subcommand for pull requests (#944)
* Add `tea repo edit` subcommand (#928)
* Support owner-based repository listing in `tea repo ls` (#931)
* Store OAuth tokens in OS keyring via credstore (#926)
* Support parsing multiple values in `tea api` subcommand (#911)
* ENHANCEMENTS
* Replace log.Fatal/os.Exit with proper error returns (#941)
* Update to charm libraries v2 (#923)
* MISC
* Bump Go version to 1.26
* Update dependencies: go-git/v5 v5.17.2, gitea SDK v0.24.1, urfave/cli/v3 v3.8.0, oauth2 v0.36.0, tablewriter v1.1.4, go-authgate/sdk-go v0.6.1
## [v0.9.1](https://gitea.com/gitea/tea/releases/tag/v0.9.1) - 2023-02-15 ## [v0.9.1](https://gitea.com/gitea/tea/releases/tag/v0.9.1) - 2023-02-15
* BUGFIXES * BUGFIXES

View File

@@ -5,10 +5,7 @@ SHASUM ?= shasum -a 256
export PATH := $($(GO) env GOPATH)/bin:$(PATH) export PATH := $($(GO) env GOPATH)/bin:$(PATH)
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go") GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
GOFMT ?= gofmt -s
# Tool packages with pinned versions
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
ifneq ($(DRONE_TAG),) ifneq ($(DRONE_TAG),)
VERSION ?= $(subst v,,$(DRONE_TAG)) VERSION ?= $(subst v,,$(DRONE_TAG))
@@ -25,15 +22,12 @@ TEA_VERSION_TAG ?= $(shell sed 's/+/_/' <<< $(TEA_VERSION))
TAGS ?= TAGS ?=
SDK ?= $(shell $(GO) list -f '{{.Version}}' -m code.gitea.io/sdk/gitea) SDK ?= $(shell $(GO) list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)
LDFLAGS := -X "code.gitea.io/tea/modules/version.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/modules/version.Tags=$(TAGS)" -X "code.gitea.io/tea/modules/version.SDK=$(SDK)" -s -w LDFLAGS := -X "code.gitea.io/tea/cmd.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/cmd.Tags=$(TAGS)" -X "code.gitea.io/tea/cmd.SDK=$(SDK)" -s -w
# override to allow passing additional goflags via make CLI # override to allow passing additional goflags via make CLI
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
PACKAGES ?= $(shell $(GO) list ./... | grep -v '^code.gitea.io/tea/tests') PACKAGES ?= $(shell $(GO) list ./...)
UNIT_PACKAGES ?= $(PACKAGES)
INTEGRATION_PACKAGES ?= $(shell $(GO) list ./tests/... 2>/dev/null)
INTEGRATION_TEST_TAGS ?= testtools
SOURCES ?= $(shell find . -name "*.go" -type f) SOURCES ?= $(shell find . -name "*.go" -type f)
# OS specific vars. # OS specific vars.
@@ -55,7 +49,7 @@ clean:
.PHONY: fmt .PHONY: fmt
fmt: fmt:
$(GO) run $(GOFUMPT_PACKAGE) -w $(GOFILES) $(GOFMT) -w $(GOFILES)
.PHONY: vet .PHONY: vet
vet: vet:
@@ -66,17 +60,21 @@ vet:
$(GO) vet -vettool=$(VET_TOOL) $(PACKAGES) $(GO) vet -vettool=$(VET_TOOL) $(PACKAGES)
.PHONY: lint .PHONY: lint
lint: lint: install-lint-tools
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools $(GO) run github.com/mgechev/revive@v1.3.2 -config .revive.toml ./... || exit 1
.PHONY: lint-fix .PHONY: misspell-check
lint-fix: misspell-check: install-lint-tools
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix $(GO) run github.com/client9/misspell/cmd/misspell@latest -error -i unknwon,destory $(GOFILES)
.PHONY: misspell
misspell: install-lint-tools
$(GO) run github.com/client9/misspell/cmd/misspell@latest -w -i unknwon $(GOFILES)
.PHONY: fmt-check .PHONY: fmt-check
fmt-check: fmt-check:
# get all go files and run gofumpt on them # get all go files and run go fmt on them
@diff=$$($(GO) run $(GOFUMPT_PACKAGE) -d $(GOFILES)); \ @diff=$$($(GOFMT) -d $(GOFILES)); \
if [ -n "$$diff" ]; then \ if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \ echo "Please run 'make fmt' and commit the result:"; \
echo "$${diff}"; \ echo "$${diff}"; \
@@ -96,24 +94,13 @@ docs-check:
exit 1; \ exit 1; \
fi; fi;
.PHONY: unit-test
unit-test:
$(GO) test $(UNIT_PACKAGES)
.PHONY: integration-test
integration-test:
@if [ -n "$(INTEGRATION_PACKAGES)" ]; then \
$(GO) test -tags='$(INTEGRATION_TEST_TAGS)' $(INTEGRATION_PACKAGES); \
else \
echo "No integration test packages found"; \
fi
.PHONY: test .PHONY: test
test: unit-test integration-test test:
$(GO) test -tags='sqlite sqlite_unlock_notify' $(PACKAGES)
.PHONY: unit-test-coverage .PHONY: unit-test-coverage
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 -tags='sqlite sqlite_unlock_notify' -cover -coverprofile coverage.out $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: tidy .PHONY: tidy
tidy: tidy:
@@ -136,3 +123,11 @@ $(EXECUTABLE): $(SOURCES)
.PHONY: build-image .PHONY: build-image
build-image: build-image:
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) . docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .
install-lint-tools:
@hash revive > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/mgechev/revive@v1.3.2; \
fi
@hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/client9/misspell/cmd/misspell@latest; \
fi

View File

@@ -97,6 +97,7 @@ ABOUT
More info about Gitea itself on https://about.gitea.com. More info about Gitea itself on https://about.gitea.com.
``` ```
- [Compare features with other git forge CLIs](./FEATURE-COMPARISON.md)
- tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API. - tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API.
## Installation ## Installation
@@ -169,7 +170,7 @@ tea man --out ./tea.man
## Compilation ## Compilation
Make sure you have a current Go version installed (1.26 or newer). Make sure you have a current go version installed (1.13 or newer).
- To compile the source yourself with the recommended flags & tags: - To compile the source yourself with the recommended flags & tags:
```sh ```sh

View File

@@ -17,13 +17,14 @@ var CmdActions = cli.Command{
Aliases: []string{"action"}, Aliases: []string{"action"},
Category: catEntities, Category: catEntities,
Usage: "Manage repository actions", Usage: "Manage repository actions",
Description: "Manage repository actions including secrets, variables, and workflow runs", Description: "Manage repository actions including secrets, variables, and workflows",
Action: runActionsDefault, Action: runActionsDefault,
Commands: []*cli.Command{ Commands: []*cli.Command{
&actions.CmdActionsSecrets, &actions.CmdActionsSecrets,
&actions.CmdActionsVariables, &actions.CmdActionsVariables,
&actions.CmdActionsRuns, &actions.CmdActionsRuns,
&actions.CmdActionsWorkflows, &actions.CmdActionsJobs,
&actions.CmdActionsLogs,
}, },
Flags: []cli.Flag{ Flags: []cli.Flag{
&cli.StringFlag{ &cli.StringFlag{
@@ -42,6 +43,7 @@ var CmdActions = cli.Command{
}, },
} }
func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error { func runActionsDefault(ctx stdctx.Context, cmd *cli.Command) error {
return cli.ShowSubcommandHelp(cmd) // Default to showing help
return cli.ShowCommandHelp(ctx, cmd, "actions")
} }

56
cmd/actions/jobs.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdActionsJobs represents the actions jobs command
var CmdActionsJobs = cli.Command{
Name: "jobs",
Aliases: []string{"job"},
Usage: "List jobs for a workflow run",
Description: "List jobs for a specific workflow run",
ArgsUsage: "<run-id>",
Action: RunActionJobs,
Flags: flags.AllDefaultFlags,
}
// RunActionJobs lists jobs for a workflow run
func RunActionJobs(ctx stdctx.Context, cmd *cli.Command) error {
c := context.InitCommand(cmd)
c.Ensure(context.CtxRequirement{RemoteRepo: true})
if cmd.Args().Len() < 1 {
return fmt.Errorf("run ID is required")
}
runID, err := utils.ArgToIndex(cmd.Args().First())
if err != nil {
return fmt.Errorf("invalid run ID: %w", err)
}
client := api.NewClient(c.Login)
path := fmt.Sprintf("/repos/%s/%s/actions/runs/%d/jobs",
c.Owner, c.Repo, runID)
var jobs api.ActionJobList
if _, err := client.Get(ctx, path, &jobs); err != nil {
return err
}
print.ActionJobsList(jobs.Jobs, c.Output)
return nil
}

62
cmd/actions/jobs_test.go Normal file
View File

@@ -0,0 +1,62 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
)
func TestJobsCommandFlags(t *testing.T) {
cmd := CmdActionsJobs
// Test that required flags exist
expectedFlags := []string{"output", "remote", "login", "repo"}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
for _, name := range flag.Names() {
if name == flagName {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("Expected flag %s not found in CmdActionsJobs", flagName)
}
}
}
func TestJobsCommandProperties(t *testing.T) {
cmd := CmdActionsJobs
if cmd.Name != "jobs" {
t.Errorf("Expected command name 'jobs', got %s", cmd.Name)
}
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "job" {
t.Errorf("Expected alias 'job' for jobs command")
}
if cmd.Usage == "" {
t.Error("Jobs command should have usage text")
}
if cmd.Description == "" {
t.Error("Jobs command should have description")
}
if cmd.ArgsUsage != "<run-id>" {
t.Errorf("Expected ArgsUsage '<run-id>', got %s", cmd.ArgsUsage)
}
if cmd.Action == nil {
t.Error("Jobs command should have an action")
}
}

55
cmd/actions/logs.go Normal file
View File

@@ -0,0 +1,55 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdActionsLogs represents the actions logs command
var CmdActionsLogs = cli.Command{
Name: "logs",
Aliases: []string{"log"},
Usage: "Display logs for a job",
Description: "Display logs for a specific job",
ArgsUsage: "<job-id>",
Action: RunActionLogs,
Flags: flags.AllDefaultFlags,
}
// RunActionLogs displays logs for a job
func RunActionLogs(ctx stdctx.Context, cmd *cli.Command) error {
c := context.InitCommand(cmd)
c.Ensure(context.CtxRequirement{RemoteRepo: true})
if cmd.Args().Len() < 1 {
return fmt.Errorf("job ID is required")
}
jobID, err := utils.ArgToIndex(cmd.Args().First())
if err != nil {
return fmt.Errorf("invalid job ID: %w", err)
}
client := api.NewClient(c.Login)
path := fmt.Sprintf("/repos/%s/%s/actions/jobs/%d/logs",
c.Owner, c.Repo, jobID)
logs, err := client.GetRaw(ctx, path)
if err != nil {
return err
}
fmt.Print(string(logs))
return nil
}

62
cmd/actions/logs_test.go Normal file
View File

@@ -0,0 +1,62 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
)
func TestLogsCommandFlags(t *testing.T) {
cmd := CmdActionsLogs
// Test that required flags exist
expectedFlags := []string{"output", "remote", "login", "repo"}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
for _, name := range flag.Names() {
if name == flagName {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("Expected flag %s not found in CmdActionsLogs", flagName)
}
}
}
func TestLogsCommandProperties(t *testing.T) {
cmd := CmdActionsLogs
if cmd.Name != "logs" {
t.Errorf("Expected command name 'logs', got %s", cmd.Name)
}
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "log" {
t.Errorf("Expected alias 'log' for logs command")
}
if cmd.Usage == "" {
t.Error("Logs command should have usage text")
}
if cmd.Description == "" {
t.Error("Logs command should have description")
}
if cmd.ArgsUsage != "<job-id>" {
t.Errorf("Expected ArgsUsage '<job-id>', got %s", cmd.ArgsUsage)
}
if cmd.Action == nil {
t.Error("Logs command should have an action")
}
}

View File

@@ -1,12 +1,16 @@
// Copyright 2026 The Gitea Authors. All rights reserved. // Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package actions package actions
import ( import (
stdctx "context" stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/actions/runs" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -15,17 +19,32 @@ import (
var CmdActionsRuns = cli.Command{ var CmdActionsRuns = cli.Command{
Name: "runs", Name: "runs",
Aliases: []string{"run"}, Aliases: []string{"run"},
Usage: "Manage workflow runs", Usage: "List workflow runs",
Description: "List, view, and manage workflow runs for repository actions", Description: "List workflow runs for a repository",
Action: runRunsDefault, Action: RunActionRuns,
Commands: []*cli.Command{ Flags: append([]cli.Flag{
&runs.CmdRunsList, &flags.PaginationPageFlag,
&runs.CmdRunsView, &flags.PaginationLimitFlag,
&runs.CmdRunsDelete, }, flags.AllDefaultFlags...),
&runs.CmdRunsLogs,
},
} }
func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error { // RunActionRuns lists workflow runs
return runs.RunRunsList(ctx, cmd) func RunActionRuns(ctx stdctx.Context, cmd *cli.Command) error {
c := context.InitCommand(cmd)
c.Ensure(context.CtxRequirement{RemoteRepo: true})
client := api.NewClient(c.Login)
path := fmt.Sprintf("/repos/%s/%s/actions/runs?page=%d&limit=%d",
c.Owner, c.Repo,
flags.GetListOptions().Page,
flags.GetListOptions().PageSize)
var runs api.ActionRunList
if _, err := client.Get(ctx, path, &runs); err != nil {
return err
}
print.ActionRunsList(runs.WorkflowRuns, c.Output)
return nil
} }

View File

@@ -1,71 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runs
import (
stdctx "context"
"fmt"
"strconv"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdRunsDelete represents a sub command to delete/cancel workflow runs
var CmdRunsDelete = cli.Command{
Name: "delete",
Aliases: []string{"remove", "rm", "cancel"},
Usage: "Delete or cancel a workflow run",
Description: "Delete (cancel) a workflow run from the repository",
ArgsUsage: "<run-id>",
Action: runRunsDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm deletion without prompting",
},
}, flags.AllDefaultFlags...),
}
func runRunsDelete(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("run ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
runIDStr := cmd.Args().First()
runID, err := strconv.ParseInt(runIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid run ID: %s", runIDStr)
}
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to delete run %d? [y/N] ", runID)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.")
return nil
}
}
_, err = client.DeleteRepoActionRun(c.Owner, c.Repo, runID)
if err != nil {
return fmt.Errorf("failed to delete run: %w", err)
}
fmt.Printf("Run %d deleted successfully\n", runID)
return nil
}

View File

@@ -1,148 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runs
import (
stdctx "context"
"fmt"
"time"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdRunsList represents a sub command to list workflow runs
var CmdRunsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List workflow runs",
Description: "List workflow runs for repository actions with optional filtering",
Action: RunRunsList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
&cli.StringFlag{
Name: "status",
Usage: "Filter by status (success, failure, pending, queued, in_progress, skipped, canceled)",
},
&cli.StringFlag{
Name: "branch",
Usage: "Filter by branch name",
},
&cli.StringFlag{
Name: "event",
Usage: "Filter by event type (push, pull_request, etc.)",
},
&cli.StringFlag{
Name: "actor",
Usage: "Filter by actor username (who triggered the run)",
},
&cli.StringFlag{
Name: "since",
Usage: "Show runs started after this time (e.g., '24h', '2024-01-01')",
},
&cli.StringFlag{
Name: "until",
Usage: "Show runs started before this time (e.g., '2024-01-01')",
},
}, flags.AllDefaultFlags...),
}
// parseTimeFlag parses time flags like "24h" or "2024-01-01"
func parseTimeFlag(value string) (time.Time, error) {
if value == "" {
return time.Time{}, nil
}
// Try parsing as duration (e.g., "24h", "168h")
if duration, err := time.ParseDuration(value); err == nil {
return time.Now().Add(-duration), nil
}
// Try parsing as date
formats := []string{
"2006-01-02",
"2006-01-02 15:04",
"2006-01-02T15:04:05",
time.RFC3339,
}
for _, format := range formats {
if t, err := time.Parse(format, value); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse time: %s", value)
}
// RunRunsList lists workflow runs
func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
// Parse time filters
since, err := parseTimeFlag(cmd.String("since"))
if err != nil {
return fmt.Errorf("invalid --since value: %w", err)
}
until, err := parseTimeFlag(cmd.String("until"))
if err != nil {
return fmt.Errorf("invalid --until value: %w", err)
}
// Build list options
listOpts := flags.GetListOptions(cmd)
runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{
ListOptions: listOpts,
Status: cmd.String("status"),
Branch: cmd.String("branch"),
Event: cmd.String("event"),
Actor: cmd.String("actor"),
})
if err != nil {
return err
}
if runs == nil {
return print.ActionRunsList(nil, c.Output)
}
// Filter by time if specified
filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until)
return print.ActionRunsList(filteredRuns, c.Output)
}
// filterRunsByTime filters runs based on time range
func filterRunsByTime(runs []*gitea.ActionWorkflowRun, since, until time.Time) []*gitea.ActionWorkflowRun {
if since.IsZero() && until.IsZero() {
return runs
}
var filtered []*gitea.ActionWorkflowRun
for _, run := range runs {
if !since.IsZero() && run.StartedAt.Before(since) {
continue
}
if !until.IsZero() && run.StartedAt.After(until) {
continue
}
filtered = append(filtered, run)
}
return filtered
}

View File

@@ -1,111 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runs
import (
stdctx "context"
"os"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/config"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestFilterRunsByTime(t *testing.T) {
now := time.Now()
runs := []*gitea.ActionWorkflowRun{
{ID: 1, StartedAt: now.Add(-1 * time.Hour)},
{ID: 2, StartedAt: now.Add(-2 * time.Hour)},
{ID: 3, StartedAt: now.Add(-3 * time.Hour)},
{ID: 4, StartedAt: now.Add(-4 * time.Hour)},
{ID: 5, StartedAt: now.Add(-5 * time.Hour)},
}
tests := []struct {
name string
since time.Time
until time.Time
expected []int64
}{
{
name: "no filter",
since: time.Time{},
until: time.Time{},
expected: []int64{1, 2, 3, 4, 5},
},
{
name: "since 2.5 hours ago",
since: now.Add(-150 * time.Minute),
until: time.Time{},
expected: []int64{1, 2},
},
{
name: "until 2.5 hours ago",
since: time.Time{},
until: now.Add(-150 * time.Minute),
expected: []int64{3, 4, 5},
},
{
name: "between 2 and 4 hours ago",
since: now.Add(-4 * time.Hour),
until: now.Add(-2 * time.Hour),
expected: []int64{2, 3, 4},
},
{
name: "filter excludes all",
since: now.Add(-30 * time.Minute),
until: time.Time{},
expected: []int64{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := filterRunsByTime(runs, tt.since, tt.until)
if len(result) != len(tt.expected) {
t.Errorf("filterRunsByTime() returned %d runs, want %d", len(result), len(tt.expected))
return
}
for i, run := range result {
if run.ID != tt.expected[i] {
t.Errorf("filterRunsByTime()[%d].ID = %d, want %d", i, run.ID, tt.expected[i])
}
}
})
}
}
func TestRunRunsListRequiresRepoContext(t *testing.T) {
oldWd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(t.TempDir()))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWd))
})
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "test",
URL: "https://gitea.example.com",
Token: "token",
User: "tester",
Default: true,
}},
})
cmd := &cli.Command{
Name: CmdRunsList.Name,
Flags: CmdRunsList.Flags,
}
require.NoError(t, cmd.Set("login", "test"))
err = RunRunsList(stdctx.Background(), cmd)
require.ErrorContains(t, err, "remote repository required")
}

View File

@@ -1,175 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runs
import (
stdctx "context"
"fmt"
"strconv"
"time"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdRunsLogs represents a sub command to view workflow run logs
var CmdRunsLogs = cli.Command{
Name: "logs",
Aliases: []string{"log"},
Usage: "View workflow run logs",
Description: "View logs for a workflow run or specific job",
ArgsUsage: "<run-id>",
Action: runRunsLogs,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "job",
Usage: "specific job ID to view logs for (if omitted, shows all jobs)",
},
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "follow log output (like tail -f), requires job to be in progress",
},
}, flags.AllDefaultFlags...),
}
func runRunsLogs(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("run ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
runIDStr := cmd.Args().First()
runID, err := strconv.ParseInt(runIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid run ID: %s", runIDStr)
}
// Check if follow mode is enabled
follow := cmd.Bool("follow")
// If specific job ID provided, fetch only that job's logs
jobIDStr := cmd.String("job")
if jobIDStr != "" {
jobID, err := strconv.ParseInt(jobIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid job ID: %s", jobIDStr)
}
if follow {
return followJobLogs(client, c, jobID, "")
}
logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID)
if err != nil {
return fmt.Errorf("failed to get logs for job %d: %w", jobID, err)
}
fmt.Printf("Logs for job %d:\n", jobID)
fmt.Printf("---\n%s\n", string(logs))
return nil
}
// Otherwise, fetch all jobs and their logs
jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
if len(jobs.Jobs) == 0 {
fmt.Printf("No jobs found for run %d\n", runID)
return nil
}
// If following and multiple jobs, require --job flag
if follow && len(jobs.Jobs) > 1 {
return fmt.Errorf("--follow requires --job when run has multiple jobs (found %d jobs)", len(jobs.Jobs))
}
// If following with single job, follow it
if follow && len(jobs.Jobs) == 1 {
return followJobLogs(client, c, jobs.Jobs[0].ID, jobs.Jobs[0].Name)
}
// Fetch logs for each job
for i, job := range jobs.Jobs {
if i > 0 {
fmt.Println()
}
fmt.Printf("Job: %s (ID: %d)\n", job.Name, job.ID)
fmt.Printf("Status: %s\n", job.Status)
fmt.Println("---")
logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, job.ID)
if err != nil {
fmt.Printf("Error fetching logs: %v\n", err)
continue
}
fmt.Println(string(logs))
}
return nil
}
// followJobLogs continuously fetches and displays logs for a running job
func followJobLogs(client *gitea.Client, c *context.TeaContext, jobID int64, jobName string) error {
var lastLogLength int
if jobName != "" {
fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID)
} else {
fmt.Printf("Following logs for job %d (press Ctrl+C to stop)...\n", jobID)
}
fmt.Println("---")
for {
// Fetch job status
job, _, err := client.GetRepoActionJob(c.Owner, c.Repo, jobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
// Check if job is still running
isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending"
// Fetch logs
logs, _, err := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID)
if err != nil {
return fmt.Errorf("failed to get logs: %w", err)
}
// Display new content only
if len(logs) > lastLogLength {
newLogs := string(logs)[lastLogLength:]
fmt.Print(newLogs)
lastLogLength = len(logs)
}
// If job is complete, exit
if !isRunning {
fmt.Printf("\n---\nJob completed with status: %s\n", job.Status)
break
}
// Wait before next poll
time.Sleep(2 * time.Second)
}
return nil
}

View File

@@ -1,83 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package runs
import (
stdctx "context"
"fmt"
"strconv"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdRunsView represents a sub command to view workflow run details
var CmdRunsView = cli.Command{
Name: "view",
Aliases: []string{"show", "get"},
Usage: "View workflow run details",
Description: "View details of a specific workflow run including jobs",
ArgsUsage: "<run-id>",
Action: runRunsView,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "jobs",
Usage: "show jobs table",
Value: true,
},
}, flags.AllDefaultFlags...),
}
func runRunsView(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("run ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
runIDStr := cmd.Args().First()
runID, err := strconv.ParseInt(runIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid run ID: %s", runIDStr)
}
// Fetch run details
run, _, err := client.GetRepoActionRun(c.Owner, c.Repo, runID)
if err != nil {
return fmt.Errorf("failed to get run: %w", err)
}
// Print run details
print.ActionRunDetails(run)
// Fetch and print jobs if requested
if cmd.Bool("jobs") {
jobs, _, err := client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
if jobs != nil && len(jobs.Jobs) > 0 {
fmt.Printf("\nJobs:\n\n")
if err := print.ActionWorkflowJobsList(jobs.Jobs, c.Output); err != nil {
return err
}
}
}
return nil
}

58
cmd/actions/runs_test.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
"testing"
)
func TestRunsCommandFlags(t *testing.T) {
cmd := CmdActionsRuns
// Test that required flags exist
expectedFlags := []string{"page", "limit", "output", "remote", "login", "repo"}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
for _, name := range flag.Names() {
if name == flagName {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("Expected flag %s not found in CmdActionsRuns", flagName)
}
}
}
func TestRunsCommandProperties(t *testing.T) {
cmd := CmdActionsRuns
if cmd.Name != "runs" {
t.Errorf("Expected command name 'runs', got %s", cmd.Name)
}
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "run" {
t.Errorf("Expected alias 'run' for runs command")
}
if cmd.Usage == "" {
t.Error("Runs command should have usage text")
}
if cmd.Description == "" {
t.Error("Runs command should have description")
}
if cmd.Action == nil {
t.Error("Runs command should have an action")
}
}

View File

@@ -6,13 +6,17 @@ package secrets
import ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"io"
"os"
"strings"
"syscall"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"golang.org/x/term"
) )
// CmdSecretsCreate represents a sub command to create action secrets // CmdSecretsCreate represents a sub command to create action secrets
@@ -40,29 +44,47 @@ func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("secret name is required") return fmt.Errorf("secret name is required")
} }
c, err := context.InitCommand(cmd) c := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client() client := c.Login.Client()
secretName := cmd.Args().First() secretName := cmd.Args().First()
var secretValue string
// Read secret value using the utility // Determine how to get the secret value
secretValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{ if cmd.String("file") != "" {
ResourceName: "secret", // Read from file
PromptMsg: fmt.Sprintf("Enter secret value for '%s'", secretName), content, err := os.ReadFile(cmd.String("file"))
Hidden: true,
AllowEmpty: false,
})
if err != nil { if err != nil {
return err return fmt.Errorf("failed to read file: %w", err)
}
secretValue = strings.TrimSpace(string(content))
} else if cmd.Bool("stdin") {
// Read from stdin
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
secretValue = strings.TrimSpace(string(content))
} else if cmd.Args().Len() >= 2 {
// Use provided argument
secretValue = cmd.Args().Get(1)
} else {
// Interactive prompt (hidden input)
fmt.Printf("Enter secret value for '%s': ", secretName)
byteValue, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read secret value: %w", err)
}
fmt.Println() // Add newline after hidden input
secretValue = string(byteValue)
} }
_, err = client.CreateRepoActionSecret(c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{ if secretValue == "" {
return fmt.Errorf("secret value cannot be empty")
}
_, err := client.CreateRepoActionSecret(c.Owner, c.Repo, gitea.CreateSecretOption{
Name: secretName,
Data: secretValue, Data: secretValue,
}) })
if err != nil { if err != nil {

View File

@@ -35,13 +35,7 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("secret name is required") return fmt.Errorf("secret name is required")
} }
c, err := context.InitCommand(cmd) c := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client() client := c.Login.Client()
secretName := cmd.Args().First() secretName := cmd.Args().First()
@@ -51,12 +45,12 @@ func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error {
var response string var response string
fmt.Scanln(&response) fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" { if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.") fmt.Println("Deletion cancelled.")
return nil return nil
} }
} }
_, err = client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName) _, err := client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -29,21 +29,16 @@ var CmdSecretsList = cli.Command{
// RunSecretsList list action secrets // RunSecretsList list action secrets
func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error { func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd) c := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client() client := c.Login.Client()
secrets, _, err := client.ListRepoActionSecret(c.Owner, c.Repo, gitea.ListRepoActionSecretOption{ secrets, _, err := client.ListRepoActionSecret(c.Owner, c.Repo, gitea.ListRepoActionSecretOption{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
} }
return print.ActionSecretsList(secrets, c.Output) print.ActionSecretsList(secrets, c.Output)
return nil
} }

View File

@@ -4,13 +4,7 @@
package secrets package secrets
import ( import (
stdctx "context"
"os"
"testing" "testing"
"code.gitea.io/tea/modules/config"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
) )
func TestSecretsListFlags(t *testing.T) { func TestSecretsListFlags(t *testing.T) {
@@ -67,32 +61,3 @@ func TestSecretsListValidation(t *testing.T) {
// This is fine - list commands typically ignore extra args // This is fine - list commands typically ignore extra args
} }
} }
func TestRunSecretsListRequiresRepoContext(t *testing.T) {
oldWd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(t.TempDir()))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWd))
})
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "test",
URL: "https://gitea.example.com",
Token: "token",
User: "tester",
Default: true,
}},
})
cmd := &cli.Command{
Name: CmdSecretsList.Name,
Flags: CmdSecretsList.Flags,
}
require.NoError(t, cmd.Set("login", "test"))
err = RunSecretsList(stdctx.Background(), cmd)
require.ErrorContains(t, err, "remote repository required")
}

View File

@@ -35,13 +35,7 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("variable name is required") return fmt.Errorf("variable name is required")
} }
c, err := context.InitCommand(cmd) c := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client() client := c.Login.Client()
variableName := cmd.Args().First() variableName := cmd.Args().First()
@@ -51,12 +45,12 @@ func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error {
var response string var response string
fmt.Scanln(&response) fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" { if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.") fmt.Println("Deletion cancelled.")
return nil return nil
} }
} }
_, err = client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName) _, err := client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -31,13 +31,7 @@ var CmdVariablesList = cli.Command{
// RunVariablesList list action variables // RunVariablesList list action variables
func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error { func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd) c := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client() client := c.Login.Client()
if name := cmd.String("name"); name != "" { if name := cmd.String("name"); name != "" {

View File

@@ -4,13 +4,7 @@
package variables package variables
import ( import (
stdctx "context"
"os"
"testing" "testing"
"code.gitea.io/tea/modules/config"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
) )
func TestVariablesListFlags(t *testing.T) { func TestVariablesListFlags(t *testing.T) {
@@ -67,32 +61,3 @@ func TestVariablesListValidation(t *testing.T) {
// This is fine - list commands typically ignore extra args // This is fine - list commands typically ignore extra args
} }
} }
func TestRunVariablesListRequiresRepoContext(t *testing.T) {
oldWd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(t.TempDir()))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWd))
})
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "test",
URL: "https://gitea.example.com",
Token: "token",
User: "tester",
Default: true,
}},
})
cmd := &cli.Command{
Name: CmdVariablesList.Name,
Flags: CmdVariablesList.Flags,
}
require.NoError(t, cmd.Set("login", "test"))
err = RunVariablesList(stdctx.Background(), cmd)
require.ErrorContains(t, err, "remote repository required")
}

View File

@@ -6,11 +6,13 @@ package variables
import ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"io"
"os"
"regexp" "regexp"
"strings"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -40,36 +42,43 @@ func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("variable name is required") return fmt.Errorf("variable name is required")
} }
c, err := context.InitCommand(cmd) c := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client() client := c.Login.Client()
variableName := cmd.Args().First() variableName := cmd.Args().First()
if err := validateVariableName(variableName); err != nil { var variableValue string
return err
}
// Read variable value using the utility // Determine how to get the variable value
variableValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{ if cmd.String("file") != "" {
ResourceName: "variable", // Read from file
PromptMsg: fmt.Sprintf("Enter variable value for '%s'", variableName), content, err := os.ReadFile(cmd.String("file"))
Hidden: false,
AllowEmpty: false,
})
if err != nil { if err != nil {
return err return fmt.Errorf("failed to read file: %w", err)
}
variableValue = strings.TrimSpace(string(content))
} else if cmd.Bool("stdin") {
// Read from stdin
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read from stdin: %w", err)
}
variableValue = strings.TrimSpace(string(content))
} else if cmd.Args().Len() >= 2 {
// Use provided argument
variableValue = cmd.Args().Get(1)
} else {
// Interactive prompt
fmt.Printf("Enter variable value for '%s': ", variableName)
var input string
fmt.Scanln(&input)
variableValue = input
} }
if err := validateVariableValue(variableValue); err != nil { if variableValue == "" {
return err return fmt.Errorf("variable value cannot be empty")
} }
_, err = client.CreateRepoActionVariable(c.Owner, c.Repo, variableName, variableValue) _, err := client.CreateRepoActionVariable(c.Owner, c.Repo, variableName, variableValue)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -1,32 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"code.gitea.io/tea/cmd/actions/workflows"
"github.com/urfave/cli/v3"
)
// CmdActionsWorkflows represents the actions workflows command
var CmdActionsWorkflows = cli.Command{
Name: "workflows",
Aliases: []string{"workflow"},
Usage: "Manage repository workflows",
Description: "List and manage repository action workflows",
Action: runWorkflowsDefault,
Commands: []*cli.Command{
&workflows.CmdWorkflowsList,
&workflows.CmdWorkflowsView,
&workflows.CmdWorkflowsDispatch,
&workflows.CmdWorkflowsEnable,
&workflows.CmdWorkflowsDisable,
},
}
func runWorkflowsDefault(ctx stdctx.Context, cmd *cli.Command) error {
return workflows.RunWorkflowsList(ctx, cmd)
}

View File

@@ -1,65 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsDisable represents a sub command to disable a workflow
var CmdWorkflowsDisable = cli.Command{
Name: "disable",
Usage: "Disable a workflow",
Description: "Disable a workflow in the repository",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsDisable,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm disable without prompting",
},
}, flags.AllDefaultFlags...),
}
func runWorkflowsDisable(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to disable workflow %s? [y/N] ", workflowID)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Disable canceled.")
return nil
}
}
_, err = client.DisableRepoActionWorkflow(c.Owner, c.Repo, workflowID)
if err != nil {
return fmt.Errorf("failed to disable workflow: %w", err)
}
fmt.Printf("Workflow %s disabled successfully\n", workflowID)
return nil
}

View File

@@ -1,174 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"strings"
"time"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsDispatch represents a sub command to dispatch a workflow
var CmdWorkflowsDispatch = cli.Command{
Name: "dispatch",
Aliases: []string{"trigger", "run"},
Usage: "Dispatch a workflow run",
Description: "Trigger a workflow_dispatch event for a workflow",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsDispatch,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "ref",
Aliases: []string{"r"},
Usage: "branch or tag to dispatch on (default: current branch)",
},
&cli.StringSliceFlag{
Name: "input",
Aliases: []string{"i"},
Usage: "workflow input in key=value format (can be specified multiple times)",
},
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "follow log output after dispatching",
},
}, flags.AllDefaultFlags...),
}
func runWorkflowsDispatch(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
ref := cmd.String("ref")
if ref == "" {
if c.LocalRepo != nil {
branchName, _, localErr := c.LocalRepo.TeaGetCurrentBranchNameAndSHA()
if localErr == nil && branchName != "" {
ref = branchName
}
}
if ref == "" {
return fmt.Errorf("--ref is required (no local branch detected)")
}
}
inputs := make(map[string]string)
for _, input := range cmd.StringSlice("input") {
key, value, ok := strings.Cut(input, "=")
if !ok {
return fmt.Errorf("invalid input format %q, expected key=value", input)
}
inputs[key] = value
}
opt := gitea.CreateActionWorkflowDispatchOption{
Ref: ref,
Inputs: inputs,
}
details, _, err := client.DispatchRepoActionWorkflow(c.Owner, c.Repo, workflowID, opt, true)
if err != nil {
return fmt.Errorf("failed to dispatch workflow: %w", err)
}
print.ActionWorkflowDispatchResult(details)
if cmd.Bool("follow") && details != nil && details.WorkflowRunID > 0 {
return followDispatchedRun(client, c, details.WorkflowRunID)
}
return nil
}
const (
followPollInterval = 2 * time.Second
followMaxDuration = 30 * time.Minute
)
// followDispatchedRun waits for the dispatched run to start, then follows its logs
func followDispatchedRun(client *gitea.Client, c *context.TeaContext, runID int64) error {
fmt.Printf("\nWaiting for run %d to start...\n", runID)
var jobs *gitea.ActionWorkflowJobsResponse
for range 30 {
time.Sleep(followPollInterval)
var err error
jobs, _, err = client.ListRepoActionRunJobs(c.Owner, c.Repo, runID, gitea.ListRepoActionJobsOptions{})
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
if len(jobs.Jobs) > 0 {
break
}
}
if jobs == nil || len(jobs.Jobs) == 0 {
return fmt.Errorf("timed out waiting for jobs to appear")
}
jobID := jobs.Jobs[0].ID
jobName := jobs.Jobs[0].Name
fmt.Printf("Following logs for job '%s' (ID: %d) - press Ctrl+C to stop...\n", jobName, jobID)
fmt.Println("---")
deadline := time.Now().Add(followMaxDuration)
var lastLogLength int
for time.Now().Before(deadline) {
job, _, err := client.GetRepoActionJob(c.Owner, c.Repo, jobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
isRunning := job.Status == "in_progress" || job.Status == "queued" || job.Status == "pending"
logs, _, logErr := client.GetRepoActionJobLogs(c.Owner, c.Repo, jobID)
if logErr != nil && isRunning {
time.Sleep(followPollInterval)
continue
}
if logErr == nil && len(logs) > lastLogLength {
fmt.Print(string(logs[lastLogLength:]))
lastLogLength = len(logs)
}
if !isRunning {
if logErr != nil {
fmt.Printf("\n---\nJob completed with status: %s (failed to fetch final logs: %v)\n", job.Status, logErr)
} else {
fmt.Printf("\n---\nJob completed with status: %s\n", job.Status)
}
break
}
time.Sleep(followPollInterval)
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s following logs", followMaxDuration)
}
return nil
}

View File

@@ -1,48 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsEnable represents a sub command to enable a workflow
var CmdWorkflowsEnable = cli.Command{
Name: "enable",
Usage: "Enable a workflow",
Description: "Enable a disabled workflow in the repository",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsEnable,
Flags: flags.AllDefaultFlags,
}
func runWorkflowsEnable(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
_, err = client.EnableRepoActionWorkflow(c.Owner, c.Repo, workflowID)
if err != nil {
return fmt.Errorf("failed to enable workflow: %w", err)
}
fmt.Printf("Workflow %s enabled successfully\n", workflowID)
return nil
}

View File

@@ -1,50 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsList represents a sub command to list workflows
var CmdWorkflowsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List repository workflows",
Description: "List workflows in the repository with their status",
Action: RunWorkflowsList,
Flags: flags.AllDefaultFlags,
}
// RunWorkflowsList lists workflows in the repository using the workflow API
func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
resp, _, err := client.ListRepoActionWorkflows(c.Owner, c.Repo)
if err != nil {
return fmt.Errorf("failed to list workflows: %w", err)
}
var workflows []*gitea.ActionWorkflow
if resp != nil {
workflows = resp.Workflows
}
return print.ActionWorkflowsList(workflows, c.Output)
}

View File

@@ -1,50 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsView represents a sub command to view workflow details
var CmdWorkflowsView = cli.Command{
Name: "view",
Aliases: []string{"show", "get"},
Usage: "View workflow details",
Description: "View details of a specific workflow",
ArgsUsage: "<workflow-id>",
Action: runWorkflowsView,
Flags: flags.AllDefaultFlags,
}
func runWorkflowsView(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("workflow ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
workflowID := cmd.Args().First()
wf, _, err := client.GetRepoActionWorkflow(c.Owner, c.Repo, workflowID)
if err != nil {
return fmt.Errorf("failed to get workflow: %w", err)
}
print.ActionWorkflowDetails(wf)
return nil
}

View File

@@ -44,10 +44,7 @@ var cmdAdminUsers = cli.Command{
} }
func runAdminUserDetail(_ stdctx.Context, cmd *cli.Command, u string) error { func runAdminUserDetail(_ stdctx.Context, cmd *cli.Command, u string) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
user, _, err := client.GetUserInfo(u) user, _, err := client.GetUserInfo(u)
if err != nil { if err != nil {

View File

@@ -34,10 +34,7 @@ var CmdUserList = cli.Command{
// RunUserList list users // RunUserList list users
func RunUserList(_ stdctx.Context, cmd *cli.Command) error { func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
fields, err := userFieldsFlag.GetValues(cmd) fields, err := userFieldsFlag.GetValues(cmd)
if err != nil { if err != nil {
@@ -46,11 +43,13 @@ func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
client := ctx.Login.Client() client := ctx.Login.Client()
users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{ users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
} }
return print.UserList(users, ctx.Output, fields) print.UserList(users, ctx.Output, fields)
return nil
} }

View File

@@ -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"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
// apiFlags returns a fresh set of flag instances for the api command.
// This is a factory function so that each invocation gets independent flag
// objects, avoiding shared hasBeenSet state across tests.
func apiFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "method",
Aliases: []string{"X"},
Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
Value: "GET",
},
&cli.StringSliceFlag{
Name: "field",
Aliases: []string{"f"},
Usage: "Add a string field to the request body (key=value)",
},
&cli.StringSliceFlag{
Name: "Field",
Aliases: []string{"F"},
Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)",
},
&cli.StringSliceFlag{
Name: "header",
Aliases: []string{"H"},
Usage: "Add a custom header (key:value)",
},
&cli.StringFlag{
Name: "data",
Aliases: []string{"d"},
Usage: "Raw JSON request body (use @file to read from file, @- for stdin)",
},
&cli.BoolFlag{
Name: "include",
Aliases: []string{"i"},
Usage: "Include HTTP status and response headers in output (written to stderr)",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Write response body to file instead of stdout (use '-' for stdout)",
},
}
}
// CmdApi represents the api command
var CmdApi = cli.Command{
Name: "api",
Category: catHelpers,
DisableSliceFlagSeparator: true,
Usage: "Make an authenticated API request",
Description: `Makes an authenticated HTTP request to the Gitea API and prints the response.
The endpoint argument is the path to the API endpoint, which will be prefixed
with /api/v1/ if it doesn't start with /api/ or http(s)://.
Placeholders like {owner} and {repo} in the endpoint will be replaced with
values from the current repository context.
Use -f for string fields and -F for typed fields (numbers, booleans, null).
With -F, prefix value with @ to read from file (@- for stdin). Values starting
with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force
string type (e.g., -F key="null" for literal string "null").
Use -d/--data to send a raw JSON body. Use @file to read from a file, or @-
to read from stdin. The -d flag cannot be combined with -f or -F.
When a request body is provided via -f, -F, or -d, the method defaults to POST
unless explicitly set with -X/--method.
Note: if your endpoint contains ? or &, quote it to prevent shell expansion
(e.g., '/repos/{owner}/{repo}/issues?state=open').`,
ArgsUsage: "<endpoint>",
Action: runApi,
Flags: append(apiFlags(), flags.LoginRepoFlags...),
}
type preparedAPIRequest struct {
Method string
Endpoint string
Headers map[string]string
Body []byte
}
func runApi(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
request, err := prepareAPIRequest(cmd, ctx)
if err != nil {
return err
}
var body io.Reader
if request.Body != nil {
body = bytes.NewReader(request.Body)
}
// Create API client and make request
client := api.NewClient(ctx.Login)
resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr)
}
}()
// Print headers to stderr if requested (so redirects/pipes work correctly)
if cmd.Bool("include") {
fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status)
for key, values := range resp.Header {
for _, value := range values {
fmt.Fprintf(os.Stderr, "%s: %s\n", key, value)
}
}
fmt.Fprintln(os.Stderr)
}
// Determine output destination
outputPath := cmd.String("output")
forceStdout := outputPath == "-"
outputToStdout := outputPath == "" || forceStdout
// Check for binary output to terminal (skip warning if user explicitly forced stdout)
if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) {
fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o <file>' 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
}

View File

@@ -1,635 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context"
tea_git "code.gitea.io/tea/modules/git"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestParseTypedValue(t *testing.T) {
t.Run("null", func(t *testing.T) {
v, err := parseTypedValue("null")
require.NoError(t, err)
assert.Nil(t, v)
})
t.Run("bool true", func(t *testing.T) {
v, err := parseTypedValue("true")
require.NoError(t, err)
assert.Equal(t, true, v)
})
t.Run("bool false", func(t *testing.T) {
v, err := parseTypedValue("false")
require.NoError(t, err)
assert.Equal(t, false, v)
})
t.Run("integer", func(t *testing.T) {
v, err := parseTypedValue("42")
require.NoError(t, err)
assert.Equal(t, int64(42), v)
})
t.Run("float", func(t *testing.T) {
v, err := parseTypedValue("3.14")
require.NoError(t, err)
assert.Equal(t, 3.14, v)
})
t.Run("string", func(t *testing.T) {
v, err := parseTypedValue("hello")
require.NoError(t, err)
assert.Equal(t, "hello", v)
})
t.Run("JSON array", func(t *testing.T) {
v, err := parseTypedValue("[1,2,3]")
require.NoError(t, err)
assert.Equal(t, []any{float64(1), float64(2), float64(3)}, v)
})
t.Run("JSON object", func(t *testing.T) {
v, err := parseTypedValue(`{"key":"val"}`)
require.NoError(t, err)
assert.Equal(t, map[string]any{"key": "val"}, v)
})
t.Run("invalid JSON array falls back to string", func(t *testing.T) {
v, err := parseTypedValue("[not json")
require.NoError(t, err)
assert.Equal(t, "[not json", v)
})
t.Run("invalid JSON object falls back to string", func(t *testing.T) {
v, err := parseTypedValue("{not json")
require.NoError(t, err)
assert.Equal(t, "{not json", v)
})
t.Run("file reference", func(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "test.txt")
require.NoError(t, os.WriteFile(tmpFile, []byte("file content\n"), 0o644))
v, err := parseTypedValue("@" + tmpFile)
require.NoError(t, err)
assert.Equal(t, "file content", v)
})
t.Run("file reference without trailing newline", func(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "test.txt")
require.NoError(t, os.WriteFile(tmpFile, []byte("no newline"), 0o644))
v, err := parseTypedValue("@" + tmpFile)
require.NoError(t, err)
assert.Equal(t, "no newline", v)
})
t.Run("empty file reference", func(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "empty.txt")
require.NoError(t, os.WriteFile(tmpFile, []byte(""), 0o644))
v, err := parseTypedValue("@" + tmpFile)
require.NoError(t, err)
assert.Equal(t, "", v)
})
t.Run("nonexistent file reference", func(t *testing.T) {
_, err := parseTypedValue("@/nonexistent/file.txt")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read")
})
t.Run("negative integer", func(t *testing.T) {
v, err := parseTypedValue("-42")
require.NoError(t, err)
assert.Equal(t, int64(-42), v)
})
t.Run("negative float", func(t *testing.T) {
v, err := parseTypedValue("-3.14")
require.NoError(t, err)
assert.Equal(t, -3.14, v)
})
t.Run("scientific notation", func(t *testing.T) {
v, err := parseTypedValue("1.5e10")
require.NoError(t, err)
assert.Equal(t, 1.5e10, v)
})
t.Run("empty string", func(t *testing.T) {
v, err := parseTypedValue("")
require.NoError(t, err)
assert.Equal(t, "", v)
})
t.Run("string starting with number", func(t *testing.T) {
v, err := parseTypedValue("123abc")
require.NoError(t, err)
assert.Equal(t, "123abc", v)
})
t.Run("nested JSON object", func(t *testing.T) {
v, err := parseTypedValue(`{"user":{"name":"alice","id":1}}`)
require.NoError(t, err)
expected := map[string]any{
"user": map[string]any{
"name": "alice",
"id": float64(1),
},
}
assert.Equal(t, expected, v)
})
t.Run("complex JSON array", func(t *testing.T) {
v, err := parseTypedValue(`[{"id":1},{"id":2}]`)
require.NoError(t, err)
expected := []any{
map[string]any{"id": float64(1)},
map[string]any{"id": float64(2)},
}
assert.Equal(t, expected, v)
})
t.Run("quoted string prevents type parsing", func(t *testing.T) {
v, err := parseTypedValue(`"null"`)
require.NoError(t, err)
assert.Equal(t, "null", v)
})
t.Run("quoted true becomes string", func(t *testing.T) {
v, err := parseTypedValue(`"true"`)
require.NoError(t, err)
assert.Equal(t, "true", v)
})
t.Run("quoted false becomes string", func(t *testing.T) {
v, err := parseTypedValue(`"false"`)
require.NoError(t, err)
assert.Equal(t, "false", v)
})
t.Run("quoted number becomes string", func(t *testing.T) {
v, err := parseTypedValue(`"123"`)
require.NoError(t, err)
assert.Equal(t, "123", v)
})
t.Run("quoted empty string", func(t *testing.T) {
v, err := parseTypedValue(`""`)
require.NoError(t, err)
assert.Equal(t, "", v)
})
t.Run("quoted string with spaces", func(t *testing.T) {
v, err := parseTypedValue(`"hello world"`)
require.NoError(t, err)
assert.Equal(t, "hello world", v)
})
t.Run("single quote not treated as quote", func(t *testing.T) {
v, err := parseTypedValue(`'hello'`)
require.NoError(t, err)
assert.Equal(t, "'hello'", v)
})
t.Run("unmatched quote at start only", func(t *testing.T) {
v, err := parseTypedValue(`"hello`)
require.NoError(t, err)
assert.Equal(t, `"hello`, v)
})
t.Run("unmatched quote at end only", func(t *testing.T) {
v, err := parseTypedValue(`hello"`)
require.NoError(t, err)
assert.Equal(t, `hello"`, v)
})
t.Run("quoted string with escaped quote", func(t *testing.T) {
v, err := parseTypedValue(`"hello \"world\""`)
require.NoError(t, err)
assert.Equal(t, `hello "world"`, v)
})
t.Run("quoted string with backslash-n", func(t *testing.T) {
v, err := parseTypedValue(`"line1\nline2"`)
require.NoError(t, err)
assert.Equal(t, "line1\nline2", v)
})
t.Run("quoted string with tab escape", func(t *testing.T) {
v, err := parseTypedValue(`"col1\tcol2"`)
require.NoError(t, err)
assert.Equal(t, "col1\tcol2", v)
})
t.Run("quoted string with backslash", func(t *testing.T) {
v, err := parseTypedValue(`"path\\to\\file"`)
require.NoError(t, err)
assert.Equal(t, `path\to\file`, v)
})
t.Run("invalid escape sequence in quoted string", func(t *testing.T) {
_, err := parseTypedValue(`"bad \z escape"`)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid quoted string")
})
}
// runApiWithArgs configures a test login, parses the command line, and captures
// the prepared request without opening sockets or making HTTP requests.
func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) {
t.Helper()
var capturedMethod string
var capturedBody []byte
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "testLogin",
URL: "https://gitea.example.com",
Token: "test-token",
User: "testUser",
Default: true,
}},
})
// Use the apiFlags factory to get fresh flag instances, avoiding shared
// hasBeenSet state between tests. Append minimal login/repo flags needed
// for the test harness.
cmd := cli.Command{
Name: "api",
DisableSliceFlagSeparator: true,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
request, err := prepareAPIRequest(cmd, ctx)
if err != nil {
return err
}
capturedMethod = request.Method
capturedBody = append([]byte(nil), request.Body...)
return nil
},
Flags: append(apiFlags(), []cli.Flag{
&cli.StringFlag{Name: "login", Aliases: []string{"l"}},
&cli.StringFlag{Name: "repo", Aliases: []string{"r"}},
&cli.StringFlag{Name: "remote", Aliases: []string{"R"}},
}...),
Writer: io.Discard,
ErrWriter: io.Discard,
}
fullArgs := append([]string{"api", "--login", "testLogin"}, args...)
runErr := cmd.Run(stdctx.Background(), fullArgs)
return capturedMethod, capturedBody, runErr
}
func TestApiCommaInFieldValue(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{"-f", "body=hello, world", "-X", "POST", "/test"})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, "hello, world", parsed["body"])
}
func TestApiRawDataFlag(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{"-d", `{"title":"test","body":"hello"}`, "/test"})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, "test", parsed["title"])
assert.Equal(t, "hello", parsed["body"])
}
func TestApiDataFieldMutualExclusion(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-f", "key=val", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "--data/-d cannot be combined with --field/-f or --Field/-F")
}
func TestApiMethodAutoDefault(t *testing.T) {
t.Run("POST when body provided without explicit method", func(t *testing.T) {
method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "/test"})
require.NoError(t, err)
assert.Equal(t, "POST", method)
})
t.Run("explicit method overrides auto-POST", func(t *testing.T) {
method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-X", "PATCH", "/test"})
require.NoError(t, err)
assert.Equal(t, "PATCH", method)
})
t.Run("GET when no body", func(t *testing.T) {
method, _, err := runApiWithArgs(t, []string{"/test"})
require.NoError(t, err)
assert.Equal(t, "GET", method)
})
}
func TestApiMultipleFields(t *testing.T) {
t.Run("multiple -f flags", func(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{
"-f", "title=Test Issue",
"-f", "body=Description here",
"-X", "POST",
"/test",
})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, "Test Issue", parsed["title"])
assert.Equal(t, "Description here", parsed["body"])
})
t.Run("multiple -F flags with different types", func(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{
"-F", "milestone=5",
"-F", "closed=true",
"-F", "title=Test",
"-X", "POST",
"/test",
})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, float64(5), parsed["milestone"])
assert.Equal(t, true, parsed["closed"])
assert.Equal(t, "Test", parsed["title"])
})
t.Run("combining -f and -F flags", func(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{
"-f", "title=Test",
"-F", "milestone=3",
"-F", "closed=false",
"-X", "POST",
"/test",
})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, "Test", parsed["title"])
assert.Equal(t, float64(3), parsed["milestone"])
assert.Equal(t, false, parsed["closed"])
})
t.Run("-F with JSON array", func(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{
"-F", `labels=["bug","enhancement"]`,
"-X", "POST",
"/test",
})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, []any{"bug", "enhancement"}, parsed["labels"])
})
t.Run("-F with JSON object", func(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{
"-F", `assignee={"login":"alice","id":123}`,
"-X", "POST",
"/test",
})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assignee, ok := parsed["assignee"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "alice", assignee["login"])
assert.Equal(t, float64(123), assignee["id"])
})
t.Run("-F with quoted string to prevent type parsing", func(t *testing.T) {
_, body, err := runApiWithArgs(t, []string{
"-F", `status="null"`,
"-F", `enabled="true"`,
"-F", `count="42"`,
"-X", "POST",
"/test",
})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, "null", parsed["status"])
assert.Equal(t, "true", parsed["enabled"])
assert.Equal(t, "42", parsed["count"])
})
}
func TestApiDataFromFile(t *testing.T) {
t.Run("read JSON from file", func(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "data.json")
jsonData := `{"title":"From File","body":"File content"}`
require.NoError(t, os.WriteFile(tmpFile, []byte(jsonData), 0o644))
_, body, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"})
require.NoError(t, err)
var parsed map[string]any
require.NoError(t, json.Unmarshal(body, &parsed))
assert.Equal(t, "From File", parsed["title"])
assert.Equal(t, "File content", parsed["body"])
})
t.Run("invalid JSON in --data flag", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-d", `{invalid json}`, "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "not valid JSON")
})
t.Run("invalid JSON from file includes filename", func(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "bad.json")
require.NoError(t, os.WriteFile(tmpFile, []byte("not json"), 0o644))
_, _, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "not valid JSON")
assert.Contains(t, err.Error(), "bad.json")
})
}
func TestApiErrorHandling(t *testing.T) {
t.Run("missing endpoint argument", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "endpoint argument required")
})
t.Run("invalid field format", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-f", "invalidformat", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid field format")
})
t.Run("invalid Field format", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-F", "noequalsign", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid field format")
})
t.Run("empty field key with -f", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-f", "=value", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "field key cannot be empty")
})
t.Run("empty field key with -F", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-F", "=123", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "field key cannot be empty")
})
t.Run("duplicate field key in -f flags", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-f", "key=first", "-f", "key=second", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate field key")
})
t.Run("duplicate field key in -F flags", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-F", "key=1", "-F", "key=2", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate field key")
})
t.Run("duplicate field key across -f and -F flags", func(t *testing.T) {
_, _, err := runApiWithArgs(t, []string{"-f", "key=string", "-F", "key=123", "-X", "POST", "/test"})
require.Error(t, err)
assert.Contains(t, err.Error(), "duplicate field key")
})
}
func TestExpandPlaceholders(t *testing.T) {
t.Run("replaces owner and repo", func(t *testing.T) {
ctx := &context.TeaContext{
Owner: "myorg",
Repo: "myrepo",
}
result := expandPlaceholders("/repos/{owner}/{repo}/issues", ctx)
assert.Equal(t, "/repos/myorg/myrepo/issues", result)
})
t.Run("replaces multiple occurrences", func(t *testing.T) {
ctx := &context.TeaContext{
Owner: "alice",
Repo: "proj",
}
result := expandPlaceholders("/repos/{owner}/{repo}/branches?owner={owner}", ctx)
assert.Equal(t, "/repos/alice/proj/branches?owner=alice", result)
})
t.Run("no placeholders returns unchanged", func(t *testing.T) {
ctx := &context.TeaContext{
Owner: "alice",
Repo: "proj",
}
result := expandPlaceholders("/api/v1/version", ctx)
assert.Equal(t, "/api/v1/version", result)
})
t.Run("empty owner and repo produce empty replacements", func(t *testing.T) {
ctx := &context.TeaContext{}
result := expandPlaceholders("/repos/{owner}/{repo}", ctx)
assert.Equal(t, "/repos//", result)
})
t.Run("branch left unreplaced when no local repo", func(t *testing.T) {
ctx := &context.TeaContext{
Owner: "alice",
Repo: "proj",
}
result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx)
assert.Equal(t, "/repos/alice/proj/branches/{branch}", result)
})
t.Run("replaces branch from local repo HEAD", func(t *testing.T) {
tmpDir := t.TempDir()
repo, err := gogit.PlainInit(tmpDir, false)
require.NoError(t, err)
// Create an initial commit so HEAD points to a branch.
wt, err := repo.Worktree()
require.NoError(t, err)
tmpFile := filepath.Join(tmpDir, "init.txt")
require.NoError(t, os.WriteFile(tmpFile, []byte("init"), 0o644))
_, err = wt.Add("init.txt")
require.NoError(t, err)
_, err = wt.Commit("initial commit", &gogit.CommitOptions{
Author: &object.Signature{Name: "test", Email: "test@test.com"},
})
require.NoError(t, err)
// Create and checkout a feature branch.
headRef, err := repo.Head()
require.NoError(t, err)
branchRef := plumbing.NewBranchReferenceName("feature/my-branch")
ref := plumbing.NewHashReference(branchRef, headRef.Hash())
require.NoError(t, repo.Storer.SetReference(ref))
require.NoError(t, wt.Checkout(&gogit.CheckoutOptions{Branch: branchRef}))
ctx := &context.TeaContext{
Owner: "alice",
Repo: "proj",
LocalRepo: &tea_git.TeaRepo{Repository: repo},
}
result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx)
assert.Equal(t, "/repos/alice/proj/branches/feature/my-branch", result)
})
}
func TestIsTextContentType(t *testing.T) {
tests := []struct {
name string
contentType string
want bool
}{
{"empty string defaults to text", "", true},
{"plain text", "text/plain", true},
{"html", "text/html", true},
{"json", "application/json", true},
{"json with charset", "application/json; charset=utf-8", true},
{"xml", "application/xml", true},
{"javascript", "application/javascript", true},
{"yaml", "application/yaml", true},
{"toml", "application/toml", true},
{"binary", "application/octet-stream", false},
{"image", "image/png", false},
{"pdf", "application/pdf", false},
{"zip", "application/zip", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isTextContentType(tt.contentType)
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -10,7 +10,6 @@ import (
"path/filepath" "path/filepath"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/releases"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -28,25 +27,20 @@ var CmdReleaseAttachmentCreate = cli.Command{
} }
func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error { func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
if ctx.Args().Len() < 2 { if ctx.Args().Len() < 2 {
return fmt.Errorf("no release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText) return fmt.Errorf("No release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText)
} }
tag := ctx.Args().First() tag := ctx.Args().First()
if len(tag) == 0 { if len(tag) == 0 {
return fmt.Errorf("release tag needed to create attachment") return fmt.Errorf("Release tag needed to create attachment")
} }
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/releases"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
@@ -33,22 +32,17 @@ var CmdReleaseAttachmentDelete = cli.Command{
} }
func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error { func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
if ctx.Args().Len() < 2 { if ctx.Args().Len() < 2 {
return fmt.Errorf("no release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText) return fmt.Errorf("No release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText)
} }
tag := ctx.Args().First() tag := ctx.Args().First()
if len(tag) == 0 { if len(tag) == 0 {
return fmt.Errorf("release tag needed to delete attachment") return fmt.Errorf("Release tag needed to delete attachment")
} }
if !ctx.Bool("confirm") { if !ctx.Bool("confirm") {
@@ -56,25 +50,17 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
return nil return nil
} }
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil { if err != nil {
return err return err
} }
var existing []*gitea.Attachment existing, _, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
for page := 1; ; { ListOptions: gitea.ListOptions{Page: -1},
page_attachments, resp, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
return err return err
} }
existing = append(existing, page_attachments...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
for _, name := range ctx.Args().Slice()[1:] { for _, name := range ctx.Args().Slice()[1:] {
var attachment *gitea.Attachment var attachment *gitea.Attachment
@@ -84,7 +70,7 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
} }
} }
if attachment == nil { if attachment == nil {
return fmt.Errorf("release does not have attachment named '%s'", name) return fmt.Errorf("Release does not have attachment named '%s'", name)
} }
_, err = client.DeleteReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, attachment.ID) _, err = client.DeleteReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, attachment.ID)
@@ -95,3 +81,21 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
return nil return nil
} }
func getReleaseAttachmentByName(owner, repo string, release int64, name string, client *gitea.Client) (*gitea.Attachment, error) {
al, _, err := client.ListReleaseAttachments(owner, repo, release, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
if len(al) == 0 {
return nil, fmt.Errorf("Release does not have any attachments")
}
for _, a := range al {
if a.Name == name {
return a, nil
}
}
return nil, fmt.Errorf("Attachment does not exist")
}

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/releases"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/print"
@@ -32,31 +31,45 @@ var CmdReleaseAttachmentList = cli.Command{
// RunReleaseAttachmentList list release attachments // RunReleaseAttachmentList list release attachments
func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error { func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
tag := ctx.Args().First() tag := ctx.Args().First()
if len(tag) == 0 { if len(tag) == 0 {
return fmt.Errorf("release tag needed to list attachments") return fmt.Errorf("Release tag needed to list attachments")
} }
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client) release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil { if err != nil {
return err return err
} }
attachments, _, err := ctx.Login.Client().ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{ attachments, _, err := ctx.Login.Client().ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
} }
return print.ReleaseAttachmentsList(attachments, ctx.Output) print.ReleaseAttachmentsList(attachments, ctx.Output)
return nil
}
func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
if len(rl) == 0 {
return nil, fmt.Errorf("Repo does not have any release")
}
for _, r := range rl {
if r.TagName == tag {
return r, nil
}
}
return nil, fmt.Errorf("Release tag does not exist")
} }

View File

@@ -24,7 +24,6 @@ var CmdBranches = cli.Command{
&branches.CmdBranchesList, &branches.CmdBranchesList,
&branches.CmdBranchesProtect, &branches.CmdBranchesProtect,
&branches.CmdBranchesUnprotect, &branches.CmdBranchesUnprotect,
&branches.CmdBranchesRename,
}, },
Flags: append([]cli.Flag{ Flags: append([]cli.Flag{
&cli.BoolFlag{ &cli.BoolFlag{

View File

@@ -38,13 +38,8 @@ var CmdBranchesList = cli.Command{
// RunBranchesList list branches // RunBranchesList list branches
func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error { func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
owner := ctx.Owner owner := ctx.Owner
if ctx.IsSet("owner") { if ctx.IsSet("owner") {
@@ -53,16 +48,19 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
var branches []*gitea.Branch var branches []*gitea.Branch
var protections []*gitea.BranchProtection var protections []*gitea.BranchProtection
var err error
branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{ branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
} }
protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{ protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
} }
@@ -72,5 +70,6 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
return err return err
} }
return print.BranchesList(branches, protections, ctx.Output, fields) print.BranchesList(branches, protections, ctx.Output, fields)
return nil
} }

View File

@@ -45,13 +45,8 @@ var CmdBranchesUnprotect = cli.Command{
// RunBranchesProtect function to protect/unprotect a list of branches // RunBranchesProtect function to protect/unprotect a list of branches
func RunBranchesProtect(_ stdctx.Context, cmd *cli.Command) error { func RunBranchesProtect(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if !cmd.Args().Present() { if !cmd.Args().Present() {
return fmt.Errorf("must specify at least one branch") return fmt.Errorf("must specify at least one branch")

View File

@@ -1,78 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package branches
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdBranchesRenameFlags Flags for command rename
var CmdBranchesRenameFlags = append([]cli.Flag{
branchFieldsFlag,
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...)
// CmdBranchesRename represents a sub command of branches to rename a branch
var CmdBranchesRename = cli.Command{
Name: "rename",
Aliases: []string{"rn"},
Usage: "Rename a branch",
Description: `Rename a branch in a repository`,
ArgsUsage: "<old_branch_name> <new_branch_name>",
Action: RunBranchesRename,
Flags: CmdBranchesRenameFlags,
}
// RunBranchesRename function to rename a branch
func RunBranchesRename(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if err := ValidateRenameArgs(ctx.Args().Slice()); err != nil {
return err
}
oldBranchName := ctx.Args().Get(0)
newBranchName := ctx.Args().Get(1)
owner := ctx.Owner
if ctx.IsSet("owner") {
owner = ctx.String("owner")
}
successful, _, err := ctx.Login.Client().RenameRepoBranch(owner, ctx.Repo, oldBranchName, gitea.RenameRepoBranchOption{
Name: newBranchName,
})
if err != nil {
return fmt.Errorf("failed to rename branch: %w", err)
}
if !successful {
return fmt.Errorf("failed to rename branch")
}
fmt.Printf("Successfully renamed branch '%s' to '%s'\n", oldBranchName, newBranchName)
return nil
}
// ValidateRenameArgs validates arguments for the rename command
func ValidateRenameArgs(args []string) error {
if len(args) != 2 {
return fmt.Errorf("must specify exactly two arguments: <old_branch_name> <new_branch_name>")
}
return nil
}

View File

@@ -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)
}
})
}
}

View File

@@ -48,10 +48,7 @@ When a host is specified in the repo-slug, it will override the login specified
} }
func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
teaCmd, err := context.InitCommand(cmd) teaCmd := context.InitCommand(cmd)
if err != nil {
return err
}
args := teaCmd.Args() args := teaCmd.Args()
if args.Len() < 1 { if args.Len() < 1 {
@@ -61,7 +58,7 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
var ( var (
login *config.Login = teaCmd.Login login *config.Login = teaCmd.Login
owner string owner string = teaCmd.Login.User
repo string repo string
) )
@@ -76,13 +73,9 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
owner, repo = utils.GetOwnerAndRepo(url.Path, login.User) owner, repo = utils.GetOwnerAndRepo(url.Path, login.User)
if url.Host != "" { if url.Host != "" {
var lookupErr error login = config.GetLoginByHost(url.Host)
login, lookupErr = config.GetLoginByHost(url.Host)
if lookupErr != nil {
return lookupErr
}
if login == nil { if login == nil {
return fmt.Errorf("no login configured matching host '%s', run 'tea login add' first", url.Host) return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host)
} }
debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host) debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host)
} }

View File

@@ -6,11 +6,23 @@ package cmd // import "code.gitea.io/tea"
import ( import (
"fmt" "fmt"
"runtime"
"strings"
"code.gitea.io/tea/modules/version"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
// Version holds the current tea version
// If the Version is moved to another package or name changed,
// build flags in .goreleaser.yaml or Makefile need to be updated accordingly.
var Version = "development"
// Tags holds the build tags used
var Tags = ""
// SDK holds the sdk version from go.mod
var SDK = ""
// App creates and returns a tea Command with all subcommands set // App creates and returns a tea Command with all subcommands set
// it was separated from main so docs can be generated for it // it was separated from main so docs can be generated for it
func App() *cli.Command { func App() *cli.Command {
@@ -22,7 +34,7 @@ func App() *cli.Command {
Usage: "command line tool to interact with Gitea", Usage: "command line tool to interact with Gitea",
Description: appDescription, Description: appDescription,
CustomHelpTemplate: helpTemplate, CustomHelpTemplate: helpTemplate,
Version: version.Format(), Version: formatVersion(),
Commands: []*cli.Command{ Commands: []*cli.Command{
&CmdLogin, &CmdLogin,
&CmdLogout, &CmdLogout,
@@ -47,13 +59,28 @@ func App() *cli.Command {
&CmdAdmin, &CmdAdmin,
&CmdApi,
&CmdGenerateManPage, &CmdGenerateManPage,
}, },
EnableShellCompletion: true, EnableShellCompletion: true,
} }
} }
func formatVersion() string {
version := fmt.Sprintf("Version: %s\tgolang: %s",
bold(Version),
strings.ReplaceAll(runtime.Version(), "go", ""))
if len(Tags) != 0 {
version += fmt.Sprintf("\tbuilt with: %s", strings.Replace(Tags, " ", ", ", -1))
}
if len(SDK) != 0 {
version += fmt.Sprintf("\tgo-sdk: %s", SDK)
}
return version
}
var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on 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'. one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
@@ -63,7 +90,7 @@ upstream repo. tea assumes that local git state is published on the remote befor
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
` `
var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", ` var helpTemplate = bold(`
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + ` {{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + `
{{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}} {{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}}
@@ -105,3 +132,7 @@ var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", `
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea. 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. More info about Gitea itself on https://about.gitea.com.
` `
func bold(t string) string {
return fmt.Sprintf("\033[1m%s\033[0m", t)
}

View File

@@ -10,7 +10,6 @@ import (
"io" "io"
"strings" "strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
@@ -19,7 +18,8 @@ import (
"code.gitea.io/tea/modules/theme" "code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils" "code.gitea.io/tea/modules/utils"
"charm.land/huh/v2" "code.gitea.io/sdk/gitea"
"github.com/charmbracelet/huh"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -36,17 +36,12 @@ var CmdAddComment = cli.Command{
} }
func runAddComment(_ stdctx.Context, cmd *cli.Command) error { func runAddComment(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
args := ctx.Args() args := ctx.Args()
if args.Len() == 0 { if args.Len() == 0 {
return fmt.Errorf("please specify issue / pr index") return fmt.Errorf("Please specify issue / pr index")
} }
idx, err := utils.ArgToIndex(ctx.Args().First()) idx, err := utils.ArgToIndex(ctx.Args().First())

View File

@@ -1,93 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"encoding/json"
"io"
"time"
"code.gitea.io/sdk/gitea"
)
type detailLabelData struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}
type detailCommentData struct {
ID int64 `json:"id"`
Author string `json:"author"`
Created time.Time `json:"created"`
Body string `json:"body"`
}
type detailReviewData struct {
ID int64 `json:"id"`
Reviewer string `json:"reviewer"`
State gitea.ReviewStateType `json:"state"`
Body string `json:"body"`
Created time.Time `json:"created"`
}
func buildDetailLabels(labels []*gitea.Label) []detailLabelData {
labelSlice := make([]detailLabelData, 0, len(labels))
for _, label := range labels {
labelSlice = append(labelSlice, detailLabelData{
Name: label.Name,
Color: label.Color,
Description: label.Description,
})
}
return labelSlice
}
func buildDetailAssignees(assignees []*gitea.User) []string {
assigneeSlice := make([]string, 0, len(assignees))
for _, assignee := range assignees {
assigneeSlice = append(assigneeSlice, username(assignee))
}
return assigneeSlice
}
func buildDetailComments(comments []*gitea.Comment) []detailCommentData {
commentSlice := make([]detailCommentData, 0, len(comments))
for _, comment := range comments {
commentSlice = append(commentSlice, detailCommentData{
ID: comment.ID,
Author: username(comment.Poster),
Body: comment.Body,
Created: comment.Created,
})
}
return commentSlice
}
func buildDetailReviews(reviews []*gitea.PullReview) []detailReviewData {
reviewSlice := make([]detailReviewData, 0, len(reviews))
for _, review := range reviews {
reviewSlice = append(reviewSlice, detailReviewData{
ID: review.ID,
Reviewer: username(review.Reviewer),
State: review.State,
Body: review.Body,
Created: review.Submitted,
})
}
return reviewSlice
}
func username(user *gitea.User) string {
if user == nil {
return "ghost"
}
return user.UserName
}
func writeIndentedJSON(w io.Writer, data any) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", "\t")
return encoder.Encode(data)
}

View File

@@ -44,7 +44,7 @@ func (f CsvFlag) GetValues(cmd *cli.Command) ([]string, error) {
if f.AvailableFields != nil && val != "" { if f.AvailableFields != nil && val != "" {
for _, field := range selection { for _, field := range selection {
if !utils.Contains(f.AvailableFields, field) { if !utils.Contains(f.AvailableFields, field) {
return nil, fmt.Errorf("invalid field '%s'", field) return nil, fmt.Errorf("Invalid field '%s'", field)
} }
} }
} }

View File

@@ -5,7 +5,6 @@ package flags
import ( import (
"errors" "errors"
"fmt"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -40,33 +39,16 @@ var OutputFlag = cli.StringFlag{
} }
var ( var (
paging gitea.ListOptions
// ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0). // 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") ErrPage = errors.New("page cannot be smaller than 1")
// ErrLimit indicates that the provided limit value is invalid (negative). // ErrLimit indicates that the provided limit value is invalid (negative).
ErrLimit = errors.New("limit cannot be negative") ErrLimit = errors.New("limit cannot be negative")
) )
const ( // GetListOptions returns configured paging struct
defaultPageValue = 1 func GetListOptions() gitea.ListOptions {
defaultLimitValue = 30 return paging
)
// 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 // PaginationFlags provides all pagination related flags
@@ -80,13 +62,14 @@ var PaginationPageFlag = cli.IntFlag{
Name: "page", Name: "page",
Aliases: []string{"p"}, Aliases: []string{"p"},
Usage: "specify page", Usage: "specify page",
Value: defaultPageValue, Value: 1,
Validator: func(i int) error { Validator: func(i int) error {
if i < 1 && i != -1 { if i < 1 && i != -1 {
return ErrPage return ErrPage
} }
return nil return nil
}, },
Destination: &paging.Page,
} }
// PaginationLimitFlag provides flag for pagination options // PaginationLimitFlag provides flag for pagination options
@@ -94,13 +77,14 @@ var PaginationLimitFlag = cli.IntFlag{
Name: "limit", Name: "limit",
Aliases: []string{"lm"}, Aliases: []string{"lm"},
Usage: "specify limit of items per page", Usage: "specify limit of items per page",
Value: defaultLimitValue, Value: 30,
Validator: func(i int) error { Validator: func(i int) error {
if i < 0 { if i < 0 {
return ErrLimit return ErrLimit
} }
return nil return nil
}, },
Destination: &paging.PageSize,
} }
// LoginOutputFlags defines login and output flags that should // LoginOutputFlags defines login and output flags that should
@@ -157,34 +141,3 @@ var NotificationStateFlag = NewCsvFlag(
func FieldsFlag(availableFields, defaultFields []string) *CsvFlag { func FieldsFlag(availableFields, defaultFields []string) *CsvFlag {
return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields) 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)
}
}

View File

@@ -8,7 +8,6 @@ import (
"io" "io"
"testing" "testing"
"code.gitea.io/sdk/gitea"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -79,8 +78,8 @@ func TestPaginationFlags(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
}) })
} }
}
}
func TestPaginationFailures(t *testing.T) { func TestPaginationFailures(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
@@ -124,29 +123,3 @@ func TestPaginationFailures(t *testing.T) {
}) })
} }
} }
func TestGetListOptionsDoesNotLeakBetweenCommands(t *testing.T) {
var results []gitea.ListOptions
run := func(args []string) {
t.Helper()
cmd := cli.Command{
Name: "test-paging",
Action: func(_ context.Context, cmd *cli.Command) error {
results = append(results, GetListOptions(cmd))
return nil
},
Flags: PaginationFlags,
}
require.NoError(t, cmd.Run(context.Background(), args))
}
run([]string{"test", "--page", "5", "--limit", "10"})
run([]string{"test"})
require.Len(t, results, 2)
assert.Equal(t, gitea.ListOptions{Page: 5, PageSize: 10}, results[0])
assert.Equal(t, gitea.ListOptions{Page: defaultPageValue, PageSize: defaultLimitValue}, results[1])
}

View File

@@ -165,7 +165,7 @@ func GetIssuePRCreateFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, e
} }
ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName) ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName)
if err != nil { if err != nil {
return nil, fmt.Errorf("milestone '%s' not found", milestoneName) return nil, fmt.Errorf("Milestone '%s' not found", milestoneName)
} }
opts.Milestone = ms.ID opts.Milestone = ms.ID
} }

View File

@@ -5,6 +5,7 @@ package cmd
import ( import (
stdctx "context" stdctx "context"
"encoding/json"
"fmt" "fmt"
"time" "time"
@@ -19,7 +20,11 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
type labelData = detailLabelData type labelData struct {
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}
type issueData struct { type issueData struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -36,17 +41,13 @@ type issueData struct {
Comments []commentData `json:"comments"` Comments []commentData `json:"comments"`
} }
type issueDetailClient interface { type commentData struct {
GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) ID int64 `json:"id"`
GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) Author string `json:"author"`
Created time.Time `json:"created"`
Body string `json:"body"`
} }
type issueCommentClient interface {
ListIssueComments(owner, repo string, index int64, opt gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error)
}
type commentData = detailCommentData
// CmdIssues represents to login a gitea server. // CmdIssues represents to login a gitea server.
var CmdIssues = cli.Command{ var CmdIssues = cli.Command{
Name: "issues", Name: "issues",
@@ -62,7 +63,6 @@ var CmdIssues = cli.Command{
&issues.CmdIssuesEdit, &issues.CmdIssuesEdit,
&issues.CmdIssuesReopen, &issues.CmdIssuesReopen,
&issues.CmdIssuesClose, &issues.CmdIssuesClose,
&issues.CmdIssuesDependencies,
}, },
Flags: append([]cli.Flag{ Flags: append([]cli.Flag{
&cli.BoolFlag{ &cli.BoolFlag{
@@ -80,35 +80,14 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error {
} }
func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
ctx, idx, err := resolveIssueDetailContext(cmd, index) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
return runIssueDetailWithClient(ctx, idx, ctx.Login.Client())
}
func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) {
ctx, err := context.InitCommand(cmd)
if err != nil {
return nil, 0, err
}
if ctx.IsSet("owner") {
ctx.Owner = ctx.String("owner")
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return nil, 0, err
}
idx, err := utils.ArgToIndex(index) idx, err := utils.ArgToIndex(index)
if err != nil { if err != nil {
return nil, 0, err return err
} }
client := ctx.Login.Client()
return ctx, idx, nil
}
func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDetailClient) error {
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx) issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
if err != nil { if err != nil {
return err return err
@@ -138,37 +117,59 @@ func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDe
} }
func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error { func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error {
return runIssueDetailAsJSONWithClient(ctx, issue, ctx.Login.Client()) c := ctx.Login.Client()
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions()}
labelSlice := make([]labelData, 0, len(issue.Labels))
for _, label := range issue.Labels {
labelSlice = append(labelSlice, labelData{label.Name, label.Color, label.Description})
} }
func runIssueDetailAsJSONWithClient(ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error { assigneesSlice := make([]string, 0, len(issue.Assignees))
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)} for _, assignee := range issue.Assignees {
comments := []*gitea.Comment{} assigneesSlice = append(assigneesSlice, assignee.UserName)
if ctx.Bool("comments") {
var err error
comments, _, err = c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts)
if err != nil {
return err
}
} }
return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments)) issueSlice := issueData{
}
func buildIssueData(issue *gitea.Issue, comments []*gitea.Comment) issueData {
return issueData{
ID: issue.ID, ID: issue.ID,
Index: issue.Index, Index: issue.Index,
Title: issue.Title, Title: issue.Title,
State: issue.State, State: issue.State,
Created: issue.Created, Created: issue.Created,
User: username(issue.Poster), User: issue.Poster.UserName,
Body: issue.Body, Body: issue.Body,
Labels: buildDetailLabels(issue.Labels), Labels: labelSlice,
Assignees: buildDetailAssignees(issue.Assignees), Assignees: assigneesSlice,
URL: issue.HTMLURL, URL: issue.HTMLURL,
ClosedAt: issue.Closed, ClosedAt: issue.Closed,
Comments: buildDetailComments(comments), Comments: make([]commentData, 0),
} }
if ctx.Bool("comments") {
comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts)
issueSlice.Comments = make([]commentData, 0, len(comments))
if err != nil {
return err
}
for _, comment := range comments {
issueSlice.Comments = append(issueSlice.Comments, commentData{
ID: comment.ID,
Author: comment.Poster.UserName,
Body: comment.Body, // Selected Field
Created: comment.Created,
})
}
}
jsonData, err := json.MarshalIndent(issueSlice, "", "\t")
if err != nil {
return err
}
_, err = fmt.Fprintf(ctx.Writer, "%s\n", jsonData)
return err
} }

View File

@@ -5,6 +5,7 @@ package issues
import ( import (
stdctx "context" stdctx "context"
"errors"
"fmt" "fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
@@ -31,15 +32,10 @@ var CmdIssuesClose = cli.Command{
// editIssueState abstracts the arg parsing to edit the given issue // editIssueState abstracts the arg parsing to edit the given issue
func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error { func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() == 0 { if ctx.Args().Len() == 0 {
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) return errors.New(ctx.Command.ArgsUsage)
} }
indices, err := utils.ArgsToIndices(ctx.Args().Slice()) indices, err := utils.ArgsToIndices(ctx.Args().Slice())

View File

@@ -26,15 +26,10 @@ var CmdIssuesCreate = cli.Command{
} }
func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error { func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.IsInteractiveMode() { if ctx.NumFlags() == 0 {
err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo) err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo)
if err != nil && !interact.IsQuitting(err) { if err != nil && !interact.IsQuitting(err) {
return err return err

View File

@@ -1,266 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"bytes"
stdctx "context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdIssuesDependencies represents the subcommand for issue dependencies
var CmdIssuesDependencies = cli.Command{
Name: "dependencies",
Aliases: []string{"deps", "dep"},
Usage: "Manage issue dependencies",
Description: "List, add, or remove dependencies for an issue",
ArgsUsage: "<issue index>",
Action: runDependenciesList,
Commands: []*cli.Command{
&cmdDependenciesList,
&cmdDependenciesAdd,
&cmdDependenciesRemove,
},
Flags: flags.AllDefaultFlags,
}
var cmdDependenciesList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List dependencies for an issue",
Description: "List issues that the specified issue depends on (blockers)",
ArgsUsage: "<issue index>",
Action: runDependenciesList,
Flags: flags.AllDefaultFlags,
}
var cmdDependenciesAdd = cli.Command{
Name: "add",
Usage: "Add a dependency to an issue",
Description: "Make an issue depend on another issue (blocker)",
ArgsUsage: "<issue index> <dependency index or owner/repo#index>",
Action: runDependenciesAdd,
Flags: flags.AllDefaultFlags,
}
var cmdDependenciesRemove = cli.Command{
Name: "remove",
Aliases: []string{"rm"},
Usage: "Remove a dependency from an issue",
Description: "Remove a dependency from an issue",
ArgsUsage: "<issue index> <dependency index or owner/repo#index>",
Action: runDependenciesRemove,
Flags: flags.AllDefaultFlags,
}
// runDependenciesList lists dependencies for an issue
func runDependenciesList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
c.Ensure(context.CtxRequirement{RemoteRepo: true})
if cmd.Args().Len() < 1 {
return fmt.Errorf("issue index required")
}
index, err := utils.ArgToIndex(cmd.Args().First())
if err != nil {
return err
}
client := api.NewClient(c.Login)
path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index)
resp, err := client.Do("GET", path, nil, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
var deps []*gitea.Issue
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&deps); err != nil {
return err
}
if len(deps) == 0 {
fmt.Printf("Issue #%d has no dependencies\n", index)
return nil
}
print.IssuesPullsList(deps, c.Output, []string{"index", "state", "title", "owner", "repo"})
return nil
}
// runDependenciesAdd adds a dependency to an issue
func runDependenciesAdd(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
c.Ensure(context.CtxRequirement{RemoteRepo: true})
if cmd.Args().Len() < 2 {
return fmt.Errorf("usage: tea issues dependencies add <issue index> <dependency index or owner/repo#index>")
}
index, err := utils.ArgToIndex(cmd.Args().First())
if err != nil {
return fmt.Errorf("invalid issue index: %w", err)
}
depOwner, depRepo, depIndex, err := parseDependencyArg(cmd.Args().Get(1), c.Owner, c.Repo)
if err != nil {
return err
}
client := api.NewClient(c.Login)
path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index)
reqBody := api.IssueDependencyRequest{
Owner: depOwner,
Repo: depRepo,
Index: depIndex,
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := client.Do("POST", path, bytes.NewReader(body), nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
if depOwner == c.Owner && depRepo == c.Repo {
fmt.Printf("Added dependency: issue #%d now depends on #%d\n", index, depIndex)
} else {
fmt.Printf("Added dependency: issue #%d now depends on %s/%s#%d\n", index, depOwner, depRepo, depIndex)
}
return nil
}
// runDependenciesRemove removes a dependency from an issue
func runDependenciesRemove(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
c.Ensure(context.CtxRequirement{RemoteRepo: true})
if cmd.Args().Len() < 2 {
return fmt.Errorf("usage: tea issues dependencies remove <issue index> <dependency index or owner/repo#index>")
}
index, err := utils.ArgToIndex(cmd.Args().First())
if err != nil {
return fmt.Errorf("invalid issue index: %w", err)
}
depOwner, depRepo, depIndex, err := parseDependencyArg(cmd.Args().Get(1), c.Owner, c.Repo)
if err != nil {
return err
}
client := api.NewClient(c.Login)
path := fmt.Sprintf("/repos/%s/%s/issues/%d/dependencies", c.Owner, c.Repo, index)
reqBody := api.IssueDependencyRequest{
Owner: depOwner,
Repo: depRepo,
Index: depIndex,
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := client.Do("DELETE", path, bytes.NewReader(body), nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
if depOwner == c.Owner && depRepo == c.Repo {
fmt.Printf("Removed dependency: issue #%d no longer depends on #%d\n", index, depIndex)
} else {
fmt.Printf("Removed dependency: issue #%d no longer depends on %s/%s#%d\n", index, depOwner, depRepo, depIndex)
}
return nil
}
// parseDependencyArg parses a dependency argument which can be:
// - "123" or "#123" (same repo)
// - "owner/repo#123" (cross-repo)
func parseDependencyArg(arg, defaultOwner, defaultRepo string) (owner, repo string, index int64, err error) {
// Check for cross-repo format: owner/repo#123
// Ensure "/" comes before "#" to distinguish from same-repo "#123"
slashIdx := strings.Index(arg, "/")
hashIdx := strings.Index(arg, "#")
if slashIdx != -1 && hashIdx != -1 && slashIdx < hashIdx {
parts := strings.SplitN(arg, "#", 2)
if len(parts) != 2 {
return "", "", 0, fmt.Errorf("invalid dependency format: %s (expected owner/repo#index)", arg)
}
repoPath := parts[0]
indexStr := parts[1]
repoParts := strings.SplitN(repoPath, "/", 2)
if len(repoParts) != 2 {
return "", "", 0, fmt.Errorf("invalid dependency format: %s (expected owner/repo#index)", arg)
}
owner = repoParts[0]
repo = repoParts[1]
index, err = strconv.ParseInt(indexStr, 10, 64)
if err != nil {
return "", "", 0, fmt.Errorf("invalid issue index: %s", indexStr)
}
return owner, repo, index, nil
}
// Same repo format: 123 or #123
index, err = utils.ArgToIndex(arg)
if err != nil {
return "", "", 0, fmt.Errorf("invalid issue index: %w", err)
}
return defaultOwner, defaultRepo, index, nil
}

View File

@@ -30,13 +30,8 @@ use an empty string (eg. --milestone "").`,
} }
func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error { func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if !cmd.Args().Present() { if !cmd.Args().Present() {
return fmt.Errorf("must specify at least one issue index") return fmt.Errorf("must specify at least one issue index")
@@ -54,7 +49,7 @@ func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error {
client := ctx.Login.Client() client := ctx.Login.Client()
for _, opts.Index = range indices { for _, opts.Index = range indices {
if ctx.IsInteractiveMode() { if ctx.NumFlags() == 0 {
var err error var err error
opts, err = interact.EditIssue(*ctx, opts.Index) opts, err = interact.EditIssue(*ctx, opts.Index)
if err != nil { if err != nil {

View File

@@ -5,7 +5,7 @@ package issues
import ( import (
stdctx "context" stdctx "context"
"errors" "fmt"
"time" "time"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
@@ -34,21 +34,33 @@ var CmdIssuesList = cli.Command{
// RunIssuesList list issues // RunIssuesList list issues
func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err state := gitea.StateOpen
switch ctx.String("state") {
case "all":
state = gitea.StateAll
case "", "open":
state = gitea.StateOpen
case "closed":
state = gitea.StateClosed
default:
return fmt.Errorf("unknown state '%s'", ctx.String("state"))
} }
state, err := flags.ParseState(ctx.String("state")) kind := gitea.IssueTypeIssue
if err != nil { switch ctx.String("kind") {
return err case "", "issues", "issue":
} kind = gitea.IssueTypeIssue
case "pulls", "pull", "pr":
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeIssue) kind = gitea.IssueTypePull
if err != nil { case "all":
return err kind = gitea.IssueTypeAll
default:
return fmt.Errorf("unknown kind '%s'", ctx.String("kind"))
} }
var err error
var from, until time.Time var from, until time.Time
if ctx.IsSet("from") { if ctx.IsSet("from") {
from, err = dateparse.ParseLocal(ctx.String("from")) from, err = dateparse.ParseLocal(ctx.String("from"))
@@ -73,31 +85,30 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
var issues []*gitea.Issue var issues []*gitea.Issue
if ctx.Repo != "" { if ctx.Repo != "" {
issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{ issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
State: state, State: state,
Type: kind, Type: kind,
KeyWord: ctx.String("keyword"), KeyWord: ctx.String("keyword"),
CreatedBy: ctx.String("author"), CreatedBy: ctx.String("author"),
AssignedBy: ctx.String("assignee"), AssignedBy: ctx.String("assigned-to"),
MentionedBy: ctx.String("mentions"), MentionedBy: ctx.String("mentions"),
Labels: labels, Labels: labels,
Milestones: milestones, Milestones: milestones,
Since: from, Since: from,
Before: until, Before: until,
}) })
if err != nil { if err != nil {
return err return err
} }
} else { } else {
if ctx.IsSet("assignee") {
return errors.New("--assignee requires --repo (global issue search does not support assignee filter)")
}
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{ issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
State: state, State: state,
Type: kind, Type: kind,
KeyWord: ctx.String("keyword"), KeyWord: ctx.String("keyword"),
CreatedBy: ctx.String("author"), CreatedBy: ctx.String("author"),
AssignedBy: ctx.String("assigned-to"),
MentionedBy: ctx.String("mentions"), MentionedBy: ctx.String("mentions"),
Labels: labels, Labels: labels,
Milestones: milestones, Milestones: milestones,
@@ -105,6 +116,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
Before: until, Before: until,
Owner: owner, Owner: owner,
}) })
if err != nil { if err != nil {
return err return err
} }
@@ -115,5 +127,6 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
return err return err
} }
return print.IssuesPullsList(issues, ctx.Output, fields) print.IssuesPullsList(issues, ctx.Output, fields)
return nil
} }

View File

@@ -20,7 +20,7 @@ var CmdIssuesReopen = cli.Command{
Description: `Change state of one or more issues to 'open'`, Description: `Change state of one or more issues to 'open'`,
ArgsUsage: "<issue index> [<issue index>...]", ArgsUsage: "<issue index> [<issue index>...]",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
s := gitea.StateOpen var s = gitea.StateOpen
return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s}) return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s})
}, },
Flags: flags.AllDefaultFlags, Flags: flags.AllDefaultFlags,

View File

@@ -7,11 +7,12 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"net/http/httptest"
"testing" "testing"
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -24,53 +25,8 @@ const (
testRepo = "testRepo" testRepo = "testRepo"
) )
type fakeIssueCommentClient struct {
owner string
repo string
index int64
comments []*gitea.Comment
}
func (f *fakeIssueCommentClient) ListIssueComments(owner, repo string, index int64, _ gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.index = index
return f.comments, nil, nil
}
type fakeIssueDetailClient struct {
owner string
repo string
index int64
issue *gitea.Issue
reactions []*gitea.Reaction
}
func (f *fakeIssueDetailClient) GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.index = index
return f.issue, nil, nil
}
func (f *fakeIssueDetailClient) GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.index = index
return f.reactions, nil, nil
}
func toCommentPointers(comments []gitea.Comment) []*gitea.Comment {
result := make([]*gitea.Comment, 0, len(comments))
for i := range comments {
comment := comments[i]
result = append(result, &comment)
}
return result
}
func createTestIssue(comments int, isClosed bool) gitea.Issue { func createTestIssue(comments int, isClosed bool) gitea.Issue {
issue := gitea.Issue{ var issue = gitea.Issue{
ID: 42, ID: 42,
Index: 1, Index: 1,
Title: "Test issue", Title: "Test issue",
@@ -103,7 +59,7 @@ func createTestIssue(comments int, isClosed bool) gitea.Issue {
} }
if isClosed { if isClosed {
closed := time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC) var closed = time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC)
issue.Closed = &closed issue.Closed = &closed
} }
@@ -114,6 +70,7 @@ func createTestIssue(comments int, isClosed bool) gitea.Issue {
} }
return issue return issue
} }
func createTestIssueComments(comments int) []gitea.Comment { func createTestIssueComments(comments int) []gitea.Comment {
@@ -133,6 +90,7 @@ func createTestIssueComments(comments int) []gitea.Comment {
} }
return result return result
} }
func TestRunIssueDetailAsJSON(t *testing.T) { func TestRunIssueDetailAsJSON(t *testing.T) {
@@ -141,6 +99,9 @@ func TestRunIssueDetailAsJSON(t *testing.T) {
issue gitea.Issue issue gitea.Issue
comments []gitea.Comment comments []gitea.Comment
flagComments bool flagComments bool
flagOutput string
flagOut string
closed bool
} }
cmd := cli.Command{ cmd := cli.Command{
@@ -202,11 +163,25 @@ func TestRunIssueDetailAsJSON(t *testing.T) {
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
client := &fakeIssueCommentClient{ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
comments: toCommentPointers(testCase.comments), path := r.URL.Path
if path == fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", testOwner, testRepo, testCase.issue.Index) {
jsonComments, err := json.Marshal(testCase.comments)
if err != nil {
require.NoError(t, err, "Testing setup failed: failed to marshal comments")
} }
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write(jsonComments)
require.NoError(t, err, "Testing setup failed: failed to write out comments")
} else {
http.NotFound(w, r)
}
})
testContext.Login.URL = "https://gitea.example.com" server := httptest.NewServer(handler)
testContext.Login.URL = server.URL
testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index) testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index)
var outBuffer bytes.Buffer var outBuffer bytes.Buffer
@@ -215,19 +190,16 @@ func TestRunIssueDetailAsJSON(t *testing.T) {
testContext.ErrWriter = &errBuffer testContext.ErrWriter = &errBuffer
if testCase.flagComments { if testCase.flagComments {
require.NoError(t, testContext.Set("comments", "true")) _ = testContext.Command.Set("comments", "true")
} else { } else {
require.NoError(t, testContext.Set("comments", "false")) _ = testContext.Command.Set("comments", "false")
} }
err := runIssueDetailAsJSONWithClient(&testContext, &testCase.issue, client) err := runIssueDetailAsJSON(&testContext, &testCase.issue)
server.Close()
require.NoError(t, err, "Failed to run issue detail as JSON") 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() out := outBuffer.String()
@@ -294,65 +266,5 @@ func TestRunIssueDetailAsJSON(t *testing.T) {
assert.Equal(t, expected, actual, "Expected structs differ from expected one") assert.Equal(t, expected, actual, "Expected structs differ from expected one")
}) })
} }
}
func TestRunIssueDetailUsesOwnerFlag(t *testing.T) {
issueIndex := int64(12)
expectedOwner := "overrideOwner"
expectedRepo := "overrideRepo"
issue := &gitea.Issue{
ID: 99,
Index: issueIndex,
Title: "Owner override test",
State: gitea.StateOpen,
Created: time.Date(2025, 11, 1, 10, 0, 0, 0, time.UTC),
Poster: &gitea.User{
UserName: "tester",
},
HTMLURL: "https://example.test/issues/12",
}
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "testLogin",
URL: "https://gitea.example.com",
Token: "token",
User: "loginUser",
Default: true,
}},
})
cmd := cli.Command{
Name: "issues",
Flags: []cli.Flag{
&flags.LoginFlag,
&flags.RepoFlag,
&flags.RemoteFlag,
&flags.OutputFlag,
&cli.StringFlag{Name: "owner"},
&cli.BoolFlag{Name: "comments"},
},
}
var outBuffer bytes.Buffer
var errBuffer bytes.Buffer
cmd.Writer = &outBuffer
cmd.ErrWriter = &errBuffer
require.NoError(t, cmd.Set("login", "testLogin"))
require.NoError(t, cmd.Set("repo", expectedRepo))
require.NoError(t, cmd.Set("owner", expectedOwner))
require.NoError(t, cmd.Set("comments", "false"))
teaCtx, idx, err := resolveIssueDetailContext(&cmd, fmt.Sprintf("%d", issueIndex))
require.NoError(t, err)
client := &fakeIssueDetailClient{
issue: issue,
reactions: []*gitea.Reaction{},
}
err = runIssueDetailWithClient(teaCtx, idx, client)
require.NoError(t, err, "Expected runIssueDetail to succeed")
assert.Equal(t, expectedOwner, client.owner)
assert.Equal(t, expectedRepo, client.repo)
assert.Equal(t, issueIndex, client.index)
} }

View File

@@ -37,5 +37,5 @@ func runLabels(ctx context.Context, cmd *cli.Command) error {
} }
func runLabelsDetails(cmd *cli.Command) error { func runLabelsDetails(cmd *cli.Command) error {
return fmt.Errorf("not yet implemented") return fmt.Errorf("Not yet implemented")
} }

View File

@@ -46,24 +46,18 @@ var CmdLabelCreate = cli.Command{
} }
func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error { func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
labelFile := ctx.String("file") labelFile := ctx.String("file")
var err error
if len(labelFile) == 0 { if len(labelFile) == 0 {
_, _, err := ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ _, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: ctx.String("name"), Name: ctx.String("name"),
Color: ctx.String("color"), Color: ctx.String("color"),
Description: ctx.String("description"), Description: ctx.String("description"),
}) })
return err } else {
}
f, err := os.Open(labelFile) f, err := os.Open(labelFile)
if err != nil { if err != nil {
return err return err
@@ -71,7 +65,7 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
defer f.Close() defer f.Close()
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
i := 1 var i = 1
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
color, name, description := splitLabelLine(line) color, name, description := splitLabelLine(line)
@@ -83,15 +77,13 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
Color: color, Color: color,
Description: description, Description: description,
}) })
if err != nil {
return err
}
} }
i++ i++
} }
}
return nil return err
} }
func splitLabelLine(line string) (string, string, string) { func splitLabelLine(line string) (string, string, string) {

View File

@@ -20,7 +20,7 @@ func TestParseLabelLine(t *testing.T) {
` `
scanner := bufio.NewScanner(strings.NewReader(labels)) scanner := bufio.NewScanner(strings.NewReader(labels))
i := 1 var i = 1
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
color, name, description := splitLabelLine(line) color, name, description := splitLabelLine(line)

View File

@@ -5,7 +5,6 @@ package labels
import ( import (
stdctx "context" stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
@@ -22,37 +21,17 @@ var CmdLabelDelete = cli.Command{
ArgsUsage: " ", // command does not accept arguments ArgsUsage: " ", // command does not accept arguments
Action: runLabelDelete, Action: runLabelDelete,
Flags: append([]cli.Flag{ Flags: append([]cli.Flag{
&cli.Int64Flag{ &cli.IntFlag{
Name: "id", Name: "id",
Usage: "label id", Usage: "label id",
Required: true,
}, },
}, flags.AllDefaultFlags...), }, flags.AllDefaultFlags...),
} }
func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error { func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
_, err := ctx.Login.Client().DeleteLabel(ctx.Owner, ctx.Repo, ctx.Int64("id"))
return err 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.GetRepoLabel(ctx.Owner, ctx.Repo, labelID)
if err != nil {
return fmt.Errorf("failed to get label %d: %w", labelID, err)
}
_, err = client.DeleteLabel(ctx.Owner, ctx.Repo, labelID)
if err != nil {
return fmt.Errorf("failed to delete label '%s' (id: %d): %w", label.Name, labelID, err)
}
fmt.Printf("Label '%s' (id: %d) deleted successfully\n", label.Name, labelID)
return nil
}

View File

@@ -36,17 +36,12 @@ var CmdLabelsList = cli.Command{
// RunLabelsList list labels. // RunLabelsList list labels.
func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error { func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{ labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
@@ -56,5 +51,6 @@ func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error {
return task.LabelsExport(labels, ctx.String("save")) return task.LabelsExport(labels, ctx.String("save"))
} }
return print.LabelsList(labels, ctx.Output) print.LabelsList(labels, ctx.Output)
return nil
} }

View File

@@ -21,7 +21,7 @@ var CmdLabelUpdate = cli.Command{
ArgsUsage: " ", // command does not accept arguments ArgsUsage: " ", // command does not accept arguments
Action: runLabelUpdate, Action: runLabelUpdate,
Flags: append([]cli.Flag{ Flags: append([]cli.Flag{
&cli.Int64Flag{ &cli.IntFlag{
Name: "id", Name: "id",
Usage: "label id", Usage: "label id",
}, },
@@ -41,13 +41,8 @@ var CmdLabelUpdate = cli.Command{
} }
func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error { func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
id := ctx.Int64("id") id := ctx.Int64("id")
var pName, pColor, pDescription *string var pName, pColor, pDescription *string
@@ -66,11 +61,13 @@ func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error {
pDescription = &description pDescription = &description
} }
var err error
_, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{ _, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{
Name: pName, Name: pName,
Color: pColor, Color: pColor,
Description: pDescription, Description: pDescription,
}) })
if err != nil { if err != nil {
return err return err
} }

View File

@@ -42,10 +42,7 @@ func runLogins(ctx context.Context, cmd *cli.Command) error {
} }
func runLoginDetail(name string) error { func runLoginDetail(name string) error {
l, err := config.GetLoginByName(name) l := config.GetLoginByName(name)
if err != nil {
return err
}
if l == nil { if l == nil {
fmt.Printf("Login '%s' do not exist\n\n", name) fmt.Printf("Login '%s' do not exist\n\n", name)
return nil return nil

View File

@@ -5,7 +5,8 @@ package login
import ( import (
"context" "context"
"fmt" "errors"
"log"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
@@ -26,7 +27,7 @@ var CmdLoginDelete = cli.Command{
func RunLoginDelete(_ context.Context, cmd *cli.Command) error { func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
logins, err := config.GetLogins() logins, err := config.GetLogins()
if err != nil { if err != nil {
return err log.Fatal(err)
} }
var name string var name string
@@ -36,7 +37,7 @@ func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
} else if len(logins) == 1 { } else if len(logins) == 1 {
name = logins[0].Name name = logins[0].Name
} else { } else {
return fmt.Errorf("please specify a login name") return errors.New("Please specify a login name")
} }
return config.DeleteLogin(name) return config.DeleteLogin(name)

View File

@@ -5,6 +5,7 @@ package login
import ( import (
"context" "context"
"log"
"os" "os"
"os/exec" "os/exec"
@@ -33,7 +34,7 @@ func runLoginEdit(_ context.Context, _ *cli.Command) error {
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return err log.Fatal(err.Error())
} }
} }
return open.Start(config.GetConfigPath()) return open.Start(config.GetConfigPath())

View File

@@ -7,10 +7,13 @@ import (
"bufio" "bufio"
"context" "context"
"fmt" "fmt"
"log"
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time"
"code.gitea.io/tea/modules/auth"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -56,13 +59,6 @@ var CmdLoginHelper = cli.Command{
{ {
Name: "get", Name: "get",
Description: "Get token to auth", Description: "Get token to auth",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "login",
Aliases: []string{"l"},
Usage: "Use a specific login",
},
},
Action: func(_ context.Context, cmd *cli.Command) error { Action: func(_ context.Context, cmd *cli.Command) error {
wants := map[string]string{} wants := map[string]string{}
s := bufio.NewScanner(os.Stdin) s := bufio.NewScanner(os.Stdin)
@@ -92,35 +88,16 @@ var CmdLoginHelper = cli.Command{
} }
if len(wants["host"]) == 0 { if len(wants["host"]) == 0 {
return fmt.Errorf("hostname is required") log.Fatal("Require hostname")
} else if len(wants["protocol"]) == 0 { } else if len(wants["protocol"]) == 0 {
wants["protocol"] = "http" wants["protocol"] = "http"
} }
// Use --login flag if provided, otherwise fall back to host lookup userConfig := config.GetLoginByHost(wants["host"])
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 { if userConfig == nil {
return fmt.Errorf("login '%s' not found", loginName) log.Fatal("host not exists")
} } else if len(userConfig.Token) == 0 {
} else { log.Fatal("User no set")
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) host, err := url.Parse(userConfig.URL)
@@ -128,12 +105,21 @@ var CmdLoginHelper = cli.Command{
return err return err
} }
// Refresh token if expired or near expiry (updates userConfig in place) if userConfig.TokenExpiry > 0 && time.Now().Unix() > userConfig.TokenExpiry {
if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil { // Token is expired, refresh it
err = auth.RefreshAccessToken(userConfig)
if err != nil {
return err return err
} }
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.GetAccessToken()) // Once token is refreshed, get the latest from the updated config
refreshedConfig := config.GetLoginByHost(wants["host"])
if refreshedConfig != nil {
userConfig = refreshedConfig
}
}
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -30,5 +30,6 @@ func RunLoginList(_ context.Context, cmd *cli.Command) error {
if err != nil { if err != nil {
return err return err
} }
return print.LoginsList(logins, cmd.String("output")) print.LoginsList(logins, cmd.String("output"))
return nil
} }

View File

@@ -17,7 +17,7 @@ import (
var CmdLoginOAuthRefresh = cli.Command{ var CmdLoginOAuthRefresh = cli.Command{
Name: "oauth-refresh", Name: "oauth-refresh",
Usage: "Refresh an OAuth token", 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.", Description: "Manually refresh an expired OAuth token. Usually only used when troubleshooting authentication.",
ArgsUsage: "[<login name>]", ArgsUsage: "[<login name>]",
Action: runLoginOAuthRefresh, Action: runLoginOAuthRefresh,
} }
@@ -38,34 +38,22 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error {
} }
// Get the login from config // Get the login from config
login, err := config.GetLoginByName(loginName) login := config.GetLoginByName(loginName)
if err != nil {
return err
}
if login == nil { if login == nil {
return fmt.Errorf("login '%s' not found", loginName) return fmt.Errorf("login '%s' not found", loginName)
} }
// Check if the login has a refresh token // Check if the login has a refresh token
if login.GetRefreshToken() == "" { if login.RefreshToken == "" {
return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) 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 // Refresh the token
err = auth.RefreshAccessToken(login) err := auth.RefreshAccessToken(login)
if err == nil { if err != nil {
return fmt.Errorf("failed to refresh token: %s", err)
}
fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName)
return nil 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(login); err != nil {
return fmt.Errorf("re-authentication failed: %s", err)
}
fmt.Printf("Successfully re-authenticated %s\n", loginName)
return nil
}

View File

@@ -29,9 +29,7 @@ var CmdGenerateManPage = cli.Command{
Hidden: true, Hidden: true,
Flags: DocRenderFlags, Flags: DocRenderFlags,
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
return RenderDocs(cmd, cmd.Root(), func(cmd *cli.Command) (string, error) { return RenderDocs(cmd, cmd.Root(), docs.ToMan)
return docs.ToManWithSection(cmd, 1)
})
}, },
} }

View File

@@ -40,13 +40,8 @@ func runMilestones(ctx stdctx.Context, cmd *cli.Command) error {
} }
func runMilestoneDetail(_ stdctx.Context, cmd *cli.Command, name string) error { func runMilestoneDetail(_ stdctx.Context, cmd *cli.Command, name string) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name) milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name)

View File

@@ -50,10 +50,7 @@ var CmdMilestonesCreate = cli.Command{
} }
func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error { func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
date := ctx.String("deadline") date := ctx.String("deadline")
deadline := &time.Time{} deadline := &time.Time{}
@@ -70,7 +67,7 @@ func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
state = gitea.StateClosed state = gitea.StateClosed
} }
if ctx.IsInteractiveMode() { if ctx.NumFlags() == 0 {
if err := interact.CreateMilestone(ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) { if err := interact.CreateMilestone(ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) {
return err return err
} }

View File

@@ -24,15 +24,10 @@ var CmdMilestonesDelete = cli.Command{
} }
func deleteMilestone(_ stdctx.Context, cmd *cli.Command) error { func deleteMilestone(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
_, err = client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First()) _, err := client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First())
return err return err
} }

View File

@@ -71,38 +71,39 @@ var CmdMilestoneRemoveIssue = cli.Command{
} }
func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error { func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
state, err := flags.ParseState(ctx.String("state")) state := gitea.StateOpen
if err != nil { switch ctx.String("state") {
return err case "all":
state = gitea.StateAll
case "closed":
state = gitea.StateClosed
} }
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll) kind := gitea.IssueTypeAll
if err != nil { switch ctx.String("kind") {
return err case "issue":
kind = gitea.IssueTypeIssue
case "pull":
kind = gitea.IssueTypePull
} }
if ctx.Args().Len() != 1 { if ctx.Args().Len() != 1 {
return fmt.Errorf("milestone name is required") return fmt.Errorf("Must specify milestone name")
} }
milestone := ctx.Args().First() milestone := ctx.Args().First()
// make sure milestone exist // make sure milestone exist
_, _, err = client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone) _, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone)
if err != nil { if err != nil {
return err return err
} }
issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{ issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
Milestones: []string{milestone}, Milestones: []string{milestone},
Type: kind, Type: kind,
State: state, State: state,
@@ -115,17 +116,13 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error {
if err != nil { if err != nil {
return err return err
} }
return print.IssuesPullsList(issues, ctx.Output, fields) print.IssuesPullsList(issues, ctx.Output, fields)
return nil
} }
func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error { func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
if ctx.Args().Len() != 2 { if ctx.Args().Len() != 2 {
return fmt.Errorf("need two arguments") return fmt.Errorf("need two arguments")
@@ -141,26 +138,18 @@ func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
// make sure milestone exist // make sure milestone exist
mile, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, mileName) mile, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, mileName)
if err != nil { if err != nil {
return fmt.Errorf("failed to get milestone '%s': %w", mileName, err) return err
} }
_, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ _, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{
Milestone: &mile.ID, Milestone: &mile.ID,
}) })
if err != nil { return err
return fmt.Errorf("failed to add issue #%d to milestone '%s': %w", idx, mileName, err)
}
return nil
} }
func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error { func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
if ctx.Args().Len() != 2 { if ctx.Args().Len() != 2 {
return fmt.Errorf("need two arguments") return fmt.Errorf("need two arguments")
@@ -170,28 +159,25 @@ func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error {
issueIndex := ctx.Args().Get(1) issueIndex := ctx.Args().Get(1)
idx, err := utils.ArgToIndex(issueIndex) idx, err := utils.ArgToIndex(issueIndex)
if err != nil { if err != nil {
return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err) return err
} }
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx) issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
if err != nil { if err != nil {
return fmt.Errorf("failed to get issue #%d: %w", idx, err) return err
} }
if issue.Milestone == nil { if issue.Milestone == nil {
return fmt.Errorf("issue #%d is not assigned to a milestone", idx) return fmt.Errorf("issue is not assigned to a milestone")
} }
if issue.Milestone.Title != mileName { if issue.Milestone.Title != mileName {
return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName) return fmt.Errorf("issue is not assigned to this milestone")
} }
zero := int64(0) zero := int64(0)
_, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{ _, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{
Milestone: &zero, Milestone: &zero,
}) })
if err != nil { return err
return fmt.Errorf("failed to remove issue #%d from milestone '%s': %w", idx, mileName, err)
}
return nil
} }

View File

@@ -40,35 +40,35 @@ var CmdMilestonesList = cli.Command{
// RunMilestonesList list milestones // RunMilestonesList list milestones
func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error { func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
fields, err := fieldsFlag.GetValues(cmd) fields, err := fieldsFlag.GetValues(cmd)
if err != nil { if err != nil {
return err return err
} }
state, err := flags.ParseState(ctx.String("state")) state := gitea.StateOpen
if err != nil { switch ctx.String("state") {
return err case "all":
} state = gitea.StateAll
if state == gitea.StateAll && !cmd.IsSet("fields") { if !cmd.IsSet("fields") { // add to default fields
fields = append(fields, "state") fields = append(fields, "state")
} }
case "closed":
state = gitea.StateClosed
}
client := ctx.Login.Client() client := ctx.Login.Client()
milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{ milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
State: state, State: state,
}) })
if err != nil { if err != nil {
return err return err
} }
return print.MilestonesList(milestones, ctx.Output, fields) print.MilestonesList(milestones, ctx.Output, fields)
return nil
} }

View File

@@ -5,6 +5,7 @@ package milestones
import ( import (
stdctx "context" stdctx "context"
"errors"
"fmt" "fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
@@ -29,15 +30,10 @@ var CmdMilestonesReopen = cli.Command{
} }
func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error { func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() == 0 { if ctx.Args().Len() == 0 {
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage) return errors.New(ctx.Command.ArgsUsage)
} }
state := gitea.StateOpen state := gitea.StateOpen
@@ -46,13 +42,6 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
} }
client := ctx.Login.Client() 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() { for _, ms := range ctx.Args().Slice() {
opts := gitea.EditMilestoneOption{ opts := gitea.EditMilestoneOption{
State: &state, State: &state,
@@ -64,7 +53,7 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
} }
if ctx.Args().Len() > 1 { if ctx.Args().Len() > 1 {
fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID) fmt.Printf("%s/milestone/%d\n", ctx.GetRemoteRepoHTMLURL(), milestone.ID)
} else { } else {
print.MilestoneDetails(milestone) print.MilestoneDetails(milestone)
} }

View File

@@ -5,6 +5,7 @@ package notifications
import ( import (
stdctx "context" stdctx "context"
"log"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
@@ -63,15 +64,12 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
var news []*gitea.NotificationThread var news []*gitea.NotificationThread
var err error var err error
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
all := ctx.Bool("mine") all := ctx.Bool("mine")
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733) // This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
listOpts := flags.GetListOptions(cmd) listOpts := flags.GetListOptions()
if listOpts.Page == 0 { if listOpts.Page == 0 {
listOpts.Page = 1 listOpts.Page = 1
} }
@@ -93,9 +91,7 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
SubjectTypes: subjects, SubjectTypes: subjects,
}) })
} else { } else {
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
news, _, err = client.ListRepoNotifications(ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{ news, _, err = client.ListRepoNotifications(ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{
ListOptions: listOpts, ListOptions: listOpts,
Status: status, Status: status,
@@ -103,8 +99,9 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
}) })
} }
if err != nil { if err != nil {
return err log.Fatal(err)
} }
return print.NotificationsList(news, ctx.Output, fields) print.NotificationsList(news, ctx.Output, fields)
return nil
} }

View File

@@ -23,10 +23,7 @@ var CmdNotificationsMarkRead = cli.Command{
ArgsUsage: "[all | <notification id>]", ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags, Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd) filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil { if err != nil {
return err return err
@@ -47,10 +44,7 @@ var CmdNotificationsMarkUnread = cli.Command{
ArgsUsage: "[all | <notification id>]", ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags, Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd) filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil { if err != nil {
return err return err
@@ -71,10 +65,7 @@ var CmdNotificationsMarkPinned = cli.Command{
ArgsUsage: "[all | <notification id>]", ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags, Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd) filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil { if err != nil {
return err return err
@@ -94,10 +85,7 @@ var CmdNotificationsUnpin = cli.Command{
ArgsUsage: "[all | <notification id>]", ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags, Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
filter := []string{string(gitea.NotifyStatusPinned)} filter := []string{string(gitea.NotifyStatusPinned)}
// NOTE: we implicitly mark it as read, to match web UI semantics. marking as unread might be more useful? // NOTE: we implicitly mark it as read, to match web UI semantics. marking as unread might be more useful?
return markNotificationAs(ctx, filter, gitea.NotifyStatusRead) return markNotificationAs(ctx, filter, gitea.NotifyStatusRead)
@@ -121,9 +109,7 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt
if allRepos { if allRepos {
_, _, err = client.ReadNotifications(opts) _, _, err = client.ReadNotifications(opts)
} else { } else {
if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { cmd.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
_, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts) _, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts)
} }
@@ -144,12 +130,8 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt
if err != nil { if err != nil {
return err return err
} }
// Use LatestCommentHTMLURL if available, otherwise fall back to HTMLURL // FIXME: this is an API URL, we want to display a web ui link..
if n.Subject.LatestCommentHTMLURL != "" { fmt.Println(n.Subject.URL)
fmt.Println(n.Subject.LatestCommentHTMLURL)
} else {
fmt.Println(n.Subject.HTMLURL)
}
return nil return nil
} }

View File

@@ -28,13 +28,8 @@ var CmdOpen = cli.Command{
} }
func runOpen(_ stdctx.Context, cmd *cli.Command) error { func runOpen(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
var suffix string var suffix string
number := ctx.Args().Get(0) number := ctx.Args().Get(0)
@@ -79,10 +74,5 @@ func runOpen(_ stdctx.Context, cmd *cli.Command) error {
suffix = number suffix = number
} }
repoURL, err := ctx.GetRemoteRepoHTMLURL() return open.Run(path.Join(ctx.GetRemoteRepoHTMLURL(), suffix))
if err != nil {
return err
}
return open.Run(path.Join(repoURL, suffix))
} }

View File

@@ -31,10 +31,7 @@ var CmdOrgs = cli.Command{
} }
func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error { func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error {
teaCtx, err := context.InitCommand(cmd) teaCtx := context.InitCommand(cmd)
if err != nil {
return err
}
if teaCtx.Args().Len() == 1 { if teaCtx.Args().Len() == 1 {
return runOrganizationDetail(teaCtx) return runOrganizationDetail(teaCtx)
} }

View File

@@ -53,13 +53,10 @@ var CmdOrganizationCreate = cli.Command{
// RunOrganizationCreate sets up a new organization // RunOrganizationCreate sets up a new organization
func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error { func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
if ctx.Args().Len() < 1 { if ctx.Args().Len() < 1 {
return fmt.Errorf("organization name is required") return fmt.Errorf("You have to specify the organization name you want to create")
} }
var visibility gitea.VisibleType var visibility gitea.VisibleType

View File

@@ -28,20 +28,17 @@ var CmdOrganizationDelete = cli.Command{
// RunOrganizationDelete delete user organization // RunOrganizationDelete delete user organization
func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error { func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
if ctx.Args().Len() < 1 { if ctx.Args().Len() < 1 {
return fmt.Errorf("organization name is required") return fmt.Errorf("You have to specify the organization name you want to delete")
} }
response, err := client.DeleteOrg(ctx.Args().First()) response, err := client.DeleteOrg(ctx.Args().First())
if response != nil && response.StatusCode == 404 { if response != nil && response.StatusCode == 404 {
return fmt.Errorf("organization not found: %s", ctx.Args().First()) return fmt.Errorf("The given organization does not exist")
} }
return err return err

View File

@@ -29,18 +29,17 @@ var CmdOrganizationList = cli.Command{
// RunOrganizationList list user organizations // RunOrganizationList list user organizations
func RunOrganizationList(_ stdctx.Context, cmd *cli.Command) error { func RunOrganizationList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client() client := ctx.Login.Client()
userOrganizations, _, err := client.ListUserOrgs(ctx.Login.User, gitea.ListOrgsOptions{ userOrganizations, _, err := client.ListUserOrgs(ctx.Login.User, gitea.ListOrgsOptions{
ListOptions: flags.GetListOptions(cmd), ListOptions: flags.GetListOptions(),
}) })
if err != nil { if err != nil {
return err return err
} }
return print.OrganizationsList(userOrganizations, ctx.Output) print.OrganizationsList(userOrganizations, ctx.Output)
return nil
} }

View File

@@ -6,50 +6,18 @@ package cmd
import ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/pulls" "code.gitea.io/tea/cmd/pulls"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils" "code.gitea.io/tea/modules/utils"
"code.gitea.io/tea/modules/workaround"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "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 // CmdPulls is the main command to operate on PRs
var CmdPulls = cli.Command{ var CmdPulls = cli.Command{
Name: "pulls", Name: "pulls",
@@ -72,14 +40,10 @@ var CmdPulls = cli.Command{
&pulls.CmdPullsCreate, &pulls.CmdPullsCreate,
&pulls.CmdPullsClose, &pulls.CmdPullsClose,
&pulls.CmdPullsReopen, &pulls.CmdPullsReopen,
&pulls.CmdPullsEdit,
&pulls.CmdPullsReview, &pulls.CmdPullsReview,
&pulls.CmdPullsApprove, &pulls.CmdPullsApprove,
&pulls.CmdPullsReject, &pulls.CmdPullsReject,
&pulls.CmdPullsMerge, &pulls.CmdPullsMerge,
&pulls.CmdPullsReviewComments,
&pulls.CmdPullsResolve,
&pulls.CmdPullsUnresolve,
}, },
} }
@@ -91,13 +55,8 @@ func runPulls(ctx stdctx.Context, cmd *cli.Command) error {
} }
func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error { func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
idx, err := utils.ArgToIndex(index) idx, err := utils.ArgToIndex(index)
if err != nil { if err != nil {
return err return err
@@ -108,28 +67,15 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
if err != nil { if err != nil {
return err return err
} }
if err := workaround.FixPullHeadSha(client, pr); err != nil {
return err
}
var reviews []*gitea.PullReview reviews, _, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
for page := 1; ; { ListOptions: gitea.ListOptions{Page: -1},
page_reviews, resp, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
fmt.Printf("error while loading reviews: %v\n", err) 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(ctx, pr, reviews)
}
} }
ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha) ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha)
@@ -148,49 +94,3 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
return nil return nil
} }
func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews []*gitea.PullReview) error {
c := ctx.Login.Client()
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
mergedBy := ""
if pr.MergedBy != nil {
mergedBy = pr.MergedBy.UserName
}
pullSlice := pullData{
ID: pr.ID,
Index: pr.Index,
Title: pr.Title,
State: pr.State,
Created: pr.Created,
Updated: pr.Updated,
User: username(pr.Poster),
Body: pr.Body,
Labels: buildDetailLabels(pr.Labels),
Assignees: buildDetailAssignees(pr.Assignees),
URL: pr.HTMLURL,
Base: pr.Base.Ref,
Head: pr.Head.Ref,
HeadSha: pr.Head.Sha,
DiffURL: pr.DiffURL,
Mergeable: pr.Mergeable,
HasMerged: pr.HasMerged,
MergedAt: pr.Merged,
MergedBy: mergedBy,
ClosedAt: pr.Closed,
Reviews: buildDetailReviews(reviews),
Comments: make([]pullCommentData, 0),
}
if ctx.Bool("comments") {
comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, pr.Index, opts)
if err != nil {
return err
}
pullSlice.Comments = buildDetailComments(comments)
}
return writeIndentedJSON(ctx.Writer, pullSlice)
}

View File

@@ -4,11 +4,16 @@
package pulls package pulls
import ( import (
"fmt"
"strings"
stdctx "context" stdctx "context"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -20,11 +25,21 @@ var CmdPullsApprove = cli.Command{
Description: "Approve a pull request", Description: "Approve a pull request",
ArgsUsage: "<pull index> [<comment>]", ArgsUsage: "<pull index> [<comment>]",
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
if ctx.Args().Len() == 0 {
return fmt.Errorf("Must specify a PR index")
}
idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil { if err != nil {
return err return err
} }
return runPullReview(ctx, gitea.ReviewStateApproved, false)
comment := strings.Join(ctx.Args().Tail(), " ")
return task.CreatePullReview(ctx, idx, gitea.ReviewStateApproved, comment, nil)
}, },
Flags: flags.AllDefaultFlags, Flags: flags.AllDefaultFlags,
} }

View File

@@ -34,18 +34,13 @@ var CmdPullsCheckout = cli.Command{
} }
func runPullsCheckout(_ stdctx.Context, cmd *cli.Command) error { func runPullsCheckout(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{
return err
}
if err := ctx.Ensure(context.CtxRequirement{
LocalRepo: true, LocalRepo: true,
RemoteRepo: true, RemoteRepo: true,
}); err != nil { })
return err
}
if ctx.Args().Len() != 1 { if ctx.Args().Len() != 1 {
return fmt.Errorf("pull request index is required") return fmt.Errorf("Must specify a PR index")
} }
idx, err := utils.ArgToIndex(ctx.Args().First()) idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil { if err != nil {

View File

@@ -32,15 +32,10 @@ var CmdPullsClean = cli.Command{
} }
func runPullsClean(_ stdctx.Context, cmd *cli.Command) error { func runPullsClean(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{LocalRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil {
return err
}
if ctx.Args().Len() != 1 { if ctx.Args().Len() != 1 {
return fmt.Errorf("pull request index is required") return fmt.Errorf("Must specify a PR index")
} }
idx, err := utils.ArgToIndex(ctx.Args().First()) idx, err := utils.ArgToIndex(ctx.Args().First())

View File

@@ -19,7 +19,7 @@ var CmdPullsClose = cli.Command{
Description: `Change state of one or more pull requests to 'closed'`, Description: `Change state of one or more pull requests to 'closed'`,
ArgsUsage: "<pull index> [<pull index>...]", ArgsUsage: "<pull index> [<pull index>...]",
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
s := gitea.StateClosed var s = gitea.StateClosed
return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s}) return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s})
}, },
Flags: flags.AllDefaultFlags, Flags: flags.AllDefaultFlags,

View File

@@ -37,31 +37,18 @@ var CmdPullsCreate = cli.Command{
Usage: "Enable maintainers to push to the base branch of created pull", Usage: "Enable maintainers to push to the base branch of created pull",
Value: true, Value: true,
}, },
&cli.BoolFlag{
Name: "agit",
Usage: "Create an agit flow pull request",
},
&cli.StringFlag{
Name: "topic",
Usage: "Topic name for agit flow pull request",
},
}, flags.IssuePRCreateFlags...), }, flags.IssuePRCreateFlags...),
} }
func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error { func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{
return err
}
if err := ctx.Ensure(context.CtxRequirement{
LocalRepo: true, LocalRepo: true,
RemoteRepo: true, RemoteRepo: true,
}); err != nil { })
return err
}
// no args -> interactive mode // no args -> interactive mode
if ctx.IsInteractiveMode() { if ctx.NumFlags() == 0 {
if err := interact.CreatePull(ctx); err != nil && !interact.IsQuitting(err) { if err := interact.CreatePull(ctx); err != nil && !interact.IsQuitting(err) {
return err return err
} }
@@ -74,18 +61,6 @@ func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error {
return err return err
} }
if ctx.Bool("agit") {
return task.CreateAgitFlowPull(
ctx,
ctx.String("remote"),
ctx.String("head"),
ctx.String("base"),
ctx.String("topic"),
opts,
interact.PromptPassword,
)
}
var allowMaintainerEdits *bool var allowMaintainerEdits *bool
if ctx.IsSet("allow-maintainer-edits") { if ctx.IsSet("allow-maintainer-edits") {
allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits")) allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits"))

View File

@@ -6,97 +6,21 @@ package pulls
import ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils" "code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
// CmdPullsEdit is the subcommand of pulls to edit pull requests
var CmdPullsEdit = cli.Command{
Name: "edit",
Aliases: []string{"e"},
Usage: "Edit one or more pull requests",
Description: `Edit one or more pull requests. To unset a property again,
use an empty string (eg. --milestone "").`,
ArgsUsage: "<idx> [<idx>...]",
Action: runPullsEdit,
Flags: append(flags.IssuePREditFlags,
&cli.StringFlag{
Name: "add-reviewers",
Aliases: []string{"r"},
Usage: "Comma-separated list of usernames to request review from",
},
&cli.StringFlag{
Name: "remove-reviewers",
Usage: "Comma-separated list of usernames to remove from reviewers",
},
),
}
func runPullsEdit(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if !cmd.Args().Present() {
return fmt.Errorf("must specify at least one pull request index")
}
opts, err := flags.GetIssuePREditFlags(ctx)
if err != nil {
return err
}
if cmd.IsSet("add-reviewers") {
opts.AddReviewers = strings.Split(cmd.String("add-reviewers"), ",")
}
if cmd.IsSet("remove-reviewers") {
opts.RemoveReviewers = strings.Split(cmd.String("remove-reviewers"), ",")
}
indices, err := utils.ArgsToIndices(ctx.Args().Slice())
if err != nil {
return err
}
client := ctx.Login.Client()
for _, opts.Index = range indices {
pr, err := task.EditPull(ctx, client, *opts)
if err != nil {
return err
}
if ctx.Args().Len() > 1 {
fmt.Println(pr.HTMLURL)
} else {
print.PullDetails(pr, nil, nil)
}
}
return nil
}
// editPullState abstracts the arg parsing to edit the given pull request // editPullState abstracts the arg parsing to edit the given pull request
func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error { func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() == 0 { if ctx.Args().Len() == 0 {
return fmt.Errorf("pull request index is required") return fmt.Errorf("Please provide a Pull Request index")
} }
indices, err := utils.ArgsToIndices(ctx.Args().Slice()) indices, err := utils.ArgsToIndices(ctx.Args().Slice())

View File

@@ -5,8 +5,6 @@ package pulls
import ( import (
stdctx "context" stdctx "context"
"fmt"
"slices"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
@@ -32,24 +30,24 @@ var CmdPullsList = cli.Command{
// RunPullsList return list of pulls // RunPullsList return list of pulls
func RunPullsList(_ stdctx.Context, cmd *cli.Command) error { func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
} state := gitea.StateOpen
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil { switch ctx.String("state") {
return err case "all":
state = gitea.StateAll
case "open":
state = gitea.StateOpen
case "closed":
state = gitea.StateClosed
} }
state, err := flags.ParseState(ctx.String("state")) prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{
if err != nil { ListOptions: flags.GetListOptions(),
return err
}
client := ctx.Login.Client()
prs, _, err := client.ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{
ListOptions: flags.GetListOptions(cmd),
State: state, State: state,
}) })
if err != nil { if err != nil {
return err return err
} }
@@ -59,21 +57,6 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
return err return err
} }
var ciStatuses map[int64]*gitea.CombinedStatus print.PullsList(prs, ctx.Output, fields)
if slices.Contains(fields, "ci") { return nil
ciStatuses = map[int64]*gitea.CombinedStatus{}
for _, pr := range prs {
if pr.Head == nil || pr.Head.Sha == "" {
continue
}
ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha)
if err != nil {
fmt.Printf("error fetching CI status for PR #%d: %v\n", pr.Index, err)
continue
}
ciStatuses[pr.Index] = ci
}
}
return print.PullsList(prs, ctx.Output, fields, ciStatuses)
} }

View File

@@ -41,13 +41,8 @@ var CmdPullsMerge = cli.Command{
}, },
}, flags.AllDefaultFlags...), }, flags.AllDefaultFlags...),
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
if err != nil { ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() != 1 { if ctx.Args().Len() != 1 {
// If no PR index is provided, try interactive mode // If no PR index is provided, try interactive mode

View File

@@ -5,10 +5,15 @@ package pulls
import ( import (
stdctx "context" stdctx "context"
"fmt"
"strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -19,11 +24,21 @@ var CmdPullsReject = cli.Command{
Description: "Request changes to a pull request", Description: "Request changes to a pull request",
ArgsUsage: "<pull index> <reason>", ArgsUsage: "<pull index> <reason>",
Action: func(_ stdctx.Context, cmd *cli.Command) error { Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd) ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
if ctx.Args().Len() < 2 {
return fmt.Errorf("Must specify a PR index and comment")
}
idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil { if err != nil {
return err return err
} }
return runPullReview(ctx, gitea.ReviewStateRequestChanges, true)
comment := strings.Join(ctx.Args().Tail(), " ")
return task.CreatePullReview(ctx, idx, gitea.ReviewStateRequestChanges, comment, nil)
}, },
Flags: flags.AllDefaultFlags, Flags: flags.AllDefaultFlags,
} }

Some files were not shown because too many files have changed in this diff Show More