mirror of
https://gitea.com/gitea/tea.git
synced 2026-04-25 17:53:37 +02:00
Compare commits
91 Commits
v0.11.0
...
renovate/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05be589aef | ||
|
|
5103496232 | ||
|
|
a58c35c3e2 | ||
|
|
783ac7684a | ||
|
|
d0b7ea09e8 | ||
|
|
20914a1375 | ||
|
|
3c1c9b2904 | ||
|
|
63bc90ea52 | ||
|
|
9e0a6203ae | ||
|
|
84ecd16f9c | ||
|
|
53e53e1067 | ||
|
|
0489d8c275 | ||
|
|
f538c05282 | ||
|
|
662e339bf9 | ||
|
|
5bb73667d1 | ||
|
|
f329f6fab2 | ||
|
|
366069315f | ||
|
|
1e13681663 | ||
|
|
bfbec3fc00 | ||
|
|
e31a167e54 | ||
|
|
6a7c3e4efa | ||
|
|
b05e03416b | ||
|
|
21881525a8 | ||
|
|
9a462247bd | ||
|
|
5f74fb37df | ||
|
|
ec658cfc33 | ||
|
|
cb9824b451 | ||
|
|
a531faa626 | ||
|
|
302c946cb8 | ||
|
|
0346e1cbb5 | ||
|
|
cd4051ed38 | ||
|
|
c797624fcf | ||
|
|
3372c9ec59 | ||
|
|
1ac8492ac7 | ||
|
|
d019f0dd72 | ||
|
|
c031db2413 | ||
|
|
e3c550ff22 | ||
|
|
fab70f83c1 | ||
|
|
0b1147bfc0 | ||
|
|
93d4d3cc55 | ||
|
|
bdf15a57be | ||
|
|
87c8c3d6e0 | ||
|
|
dfd400f15b | ||
|
|
2152d99f2d | ||
|
|
ea795775af | ||
|
|
1093ef1524 | ||
|
|
873a44f897 | ||
|
|
47f74ea696 | ||
|
|
59656dfcd2 | ||
|
|
e644cc49d4 | ||
|
|
3595f8f89d | ||
|
|
49a9032d8a | ||
|
|
982adb4d02 | ||
|
|
29488a1f46 | ||
|
|
a47ac265d2 | ||
|
|
037d1aad23 | ||
|
|
e5342660fa | ||
|
|
233ffe4508 | ||
|
|
ae9eb4f2c0 | ||
|
|
0d5bf60632 | ||
|
|
82d8a14c73 | ||
|
|
6414a5e00e | ||
|
|
864face284 | ||
|
|
383c5fdc03 | ||
|
|
7801310a18 | ||
|
|
c2180048a0 | ||
|
|
629872d1e9 | ||
|
|
0be14de5c2 | ||
|
|
4f8cb7ef19 | ||
|
|
f638dba99b | ||
|
|
20da414145 | ||
|
|
ae740a66e8 | ||
|
|
c2e9265dae | ||
|
|
45260e1a1f | ||
|
|
7ab3366220 | ||
|
|
68b9620b8c | ||
|
|
e961a8f01d | ||
|
|
f59430a42a | ||
|
|
7e2e7ee809 | ||
|
|
1d1d9197ee | ||
|
|
f6d4b5fa4f | ||
|
|
016e068c60 | ||
|
|
587b31503d | ||
|
|
4877f181fb | ||
|
|
81481f8f9d | ||
|
|
3495ec5ed4 | ||
|
|
7a5c260268 | ||
|
|
90f8624ae7 | ||
|
|
61d4e571a7 | ||
|
|
4f33146b70 | ||
|
|
08b83986dd |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Tea DevContainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1.24-bullseye",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:2.1-trixie",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git-lfs:1.2.5": {}
|
||||
},
|
||||
|
||||
@@ -8,26 +8,30 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: import gpg
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v6
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
- name: get SDK version
|
||||
id: sdk_version
|
||||
run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT"
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v1"
|
||||
args: release --nightly
|
||||
env:
|
||||
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||
@@ -45,24 +49,24 @@ jobs:
|
||||
DOCKER_LATEST: nightly
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||
with:
|
||||
|
||||
@@ -9,26 +9,30 @@ jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: git fetch --force --tags
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: import gpg
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v6
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
- name: get SDK version
|
||||
id: sdk_version
|
||||
run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT"
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v1"
|
||||
args: release
|
||||
env:
|
||||
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
|
||||
@@ -46,18 +50,18 @@ jobs:
|
||||
DOCKER_LATEST: nightly
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # all history for all branches and tags
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -67,7 +71,7 @@ jobs:
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
env:
|
||||
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
|
||||
with:
|
||||
|
||||
@@ -4,14 +4,14 @@ on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
govulncheck_job:
|
||||
runs-on: ubuntu-latest
|
||||
name: Run govulncheck
|
||||
steps:
|
||||
- id: govulncheck
|
||||
uses: golang/govulncheck-action@v1
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
#govulncheck_job:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Run govulncheck
|
||||
# steps:
|
||||
# - id: govulncheck
|
||||
# uses: golang/govulncheck-action@v1
|
||||
# with:
|
||||
# go-version-file: 'go.mod'
|
||||
check-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
GITEA_TEA_TEST_USERNAME: "test01"
|
||||
GITEA_TEA_TEST_PASSWORD: "test01"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: lint and build
|
||||
@@ -30,7 +30,6 @@ jobs:
|
||||
make vet
|
||||
make lint
|
||||
make fmt-check
|
||||
make misspell-check
|
||||
make docs-check
|
||||
make build
|
||||
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
|
||||
@@ -40,7 +39,7 @@ jobs:
|
||||
make unit-test-coverage
|
||||
services:
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.24.5
|
||||
image: docker.gitea.com/gitea:1.26.1
|
||||
cmd:
|
||||
- bash
|
||||
- -c
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,3 +17,5 @@ dist/
|
||||
.direnv/
|
||||
result
|
||||
result-*
|
||||
|
||||
.DS_Store
|
||||
45
.golangci.yml
Normal file
45
.golangci.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
version: "2"
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- govet
|
||||
- revive
|
||||
- misspell
|
||||
- ineffassign
|
||||
- unused
|
||||
|
||||
settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: context-keys-type
|
||||
- name: dot-imports
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: error-naming
|
||||
- name: exported
|
||||
- name: if-return
|
||||
- name: increment-decrement
|
||||
- name: var-declaration
|
||||
- name: range
|
||||
- name: receiver-naming
|
||||
- name: time-naming
|
||||
- name: unexported-return
|
||||
- name: indent-error-flow
|
||||
- name: errorf
|
||||
|
||||
misspell:
|
||||
locale: US
|
||||
ignore-words:
|
||||
- unknwon
|
||||
- destory
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -38,8 +38,6 @@ builds:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
goarm: "7"
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: ppc64le
|
||||
- goos: freebsd
|
||||
@@ -58,7 +56,7 @@ builds:
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X code.gitea.io/tea/cmd.Version={{ .Version }}
|
||||
- -s -w -X "code.gitea.io/tea/modules/version.Version={{ trimprefix .Summary "v" }}" -X "code.gitea.io/tea/modules/version.Tags=" -X "code.gitea.io/tea/modules/version.SDK={{ .Env.SDK_VERSION }}"
|
||||
binary: >-
|
||||
{{ .ProjectName }}-
|
||||
{{- .Version }}-
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05
|
||||
|
||||
* FEATURES
|
||||
* Add `tea pr edit` subcommand for pull requests (#944)
|
||||
* Add `tea repo edit` subcommand (#928)
|
||||
* Support owner-based repository listing in `tea repo ls` (#931)
|
||||
* Store OAuth tokens in OS keyring via credstore (#926)
|
||||
* Support parsing multiple values in `tea api` subcommand (#911)
|
||||
* ENHANCEMENTS
|
||||
* Replace log.Fatal/os.Exit with proper error returns (#941)
|
||||
* Update to charm libraries v2 (#923)
|
||||
* MISC
|
||||
* Bump Go version to 1.26
|
||||
* Update dependencies: go-git/v5 v5.17.2, gitea SDK v0.24.1, urfave/cli/v3 v3.8.0, oauth2 v0.36.0, tablewriter v1.1.4, go-authgate/sdk-go v0.6.1
|
||||
|
||||
## [v0.9.1](https://gitea.com/gitea/tea/releases/tag/v0.9.1) - 2023-02-15
|
||||
|
||||
* BUGFIXES
|
||||
|
||||
34
Makefile
34
Makefile
@@ -5,7 +5,10 @@ SHASUM ?= shasum -a 256
|
||||
export PATH := $($(GO) env GOPATH)/bin:$(PATH)
|
||||
|
||||
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
|
||||
GOFMT ?= gofmt -s
|
||||
|
||||
# Tool packages with pinned versions
|
||||
GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4
|
||||
|
||||
ifneq ($(DRONE_TAG),)
|
||||
VERSION ?= $(subst v,,$(DRONE_TAG))
|
||||
@@ -22,7 +25,7 @@ TEA_VERSION_TAG ?= $(shell sed 's/+/_/' <<< $(TEA_VERSION))
|
||||
|
||||
TAGS ?=
|
||||
SDK ?= $(shell $(GO) list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)
|
||||
LDFLAGS := -X "code.gitea.io/tea/cmd.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/cmd.Tags=$(TAGS)" -X "code.gitea.io/tea/cmd.SDK=$(SDK)" -s -w
|
||||
LDFLAGS := -X "code.gitea.io/tea/modules/version.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/modules/version.Tags=$(TAGS)" -X "code.gitea.io/tea/modules/version.SDK=$(SDK)" -s -w
|
||||
|
||||
# override to allow passing additional goflags via make CLI
|
||||
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
|
||||
@@ -49,7 +52,7 @@ clean:
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
$(GOFMT) -w $(GOFILES)
|
||||
$(GO) run $(GOFUMPT_PACKAGE) -w $(GOFILES)
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
@@ -60,21 +63,17 @@ vet:
|
||||
$(GO) vet -vettool=$(VET_TOOL) $(PACKAGES)
|
||||
|
||||
.PHONY: lint
|
||||
lint: install-lint-tools
|
||||
$(GO) run github.com/mgechev/revive@v1.3.2 -config .revive.toml ./... || exit 1
|
||||
lint:
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
|
||||
|
||||
.PHONY: misspell-check
|
||||
misspell-check: install-lint-tools
|
||||
$(GO) run github.com/client9/misspell/cmd/misspell@latest -error -i unknwon,destory $(GOFILES)
|
||||
|
||||
.PHONY: misspell
|
||||
misspell: install-lint-tools
|
||||
$(GO) run github.com/client9/misspell/cmd/misspell@latest -w -i unknwon $(GOFILES)
|
||||
.PHONY: lint-fix
|
||||
lint-fix:
|
||||
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
|
||||
|
||||
.PHONY: fmt-check
|
||||
fmt-check:
|
||||
# get all go files and run go fmt on them
|
||||
@diff=$$($(GOFMT) -d $(GOFILES)); \
|
||||
# get all go files and run gofumpt on them
|
||||
@diff=$$($(GO) run $(GOFUMPT_PACKAGE) -d $(GOFILES)); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make fmt' and commit the result:"; \
|
||||
echo "$${diff}"; \
|
||||
@@ -124,10 +123,3 @@ $(EXECUTABLE): $(SOURCES)
|
||||
build-image:
|
||||
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .
|
||||
|
||||
install-lint-tools:
|
||||
@hash revive > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GO) install github.com/mgechev/revive@v1.3.2; \
|
||||
fi
|
||||
@hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GO) install github.com/client9/misspell/cmd/misspell@latest; \
|
||||
fi
|
||||
|
||||
18
README.md
18
README.md
@@ -42,7 +42,9 @@ COMMANDS:
|
||||
organizations, organization, org List, create, delete organizations
|
||||
repos, repo Show repository details
|
||||
branches, branch, b Consult branches
|
||||
actions Manage repository actions (secrets, variables)
|
||||
comment, c Add a comment to an issue / pr
|
||||
webhooks, webhook Manage repository webhooks
|
||||
|
||||
HELPERS:
|
||||
open, o Open something of the repository in web browser
|
||||
@@ -77,6 +79,15 @@ EXAMPLES
|
||||
tea open 189 # open web ui for issue 189
|
||||
tea open milestones # open web ui for milestones
|
||||
|
||||
tea actions secrets list # list all repository action secrets
|
||||
tea actions secrets create API_KEY # create a new secret (will prompt for value)
|
||||
tea actions variables list # list all repository action variables
|
||||
tea actions variables set API_URL https://api.example.com
|
||||
|
||||
tea webhooks list # list repository webhooks
|
||||
tea webhooks list --org myorg # list organization webhooks
|
||||
tea webhooks create https://example.com/hook --events push,pull_request
|
||||
|
||||
# send gitea desktop notifications every 5 minutes (bash + libnotify)
|
||||
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done
|
||||
|
||||
@@ -86,7 +97,6 @@ ABOUT
|
||||
More info about Gitea itself on https://about.gitea.com.
|
||||
```
|
||||
|
||||
- [Compare features with other git forge CLIs](./FEATURE-COMPARISON.md)
|
||||
- tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API.
|
||||
|
||||
## Installation
|
||||
@@ -106,9 +116,7 @@ There are different ways to get `tea`:
|
||||
|
||||
3. Install from source: [see *Compilation*](#compilation)
|
||||
|
||||
4. Docker (thirdparty): [tgerczei/tea](https://hub.docker.com/r/tgerczei/tea)
|
||||
|
||||
5. asdf (thirdparty): [mvaldes14/asdf-tea](https://github.com/mvaldes14/asdf-tea)
|
||||
4. Docker: [Tea at docker hub](https://hub.docker.com/r/gitea/tea)
|
||||
|
||||
### Log in to Gitea from tea
|
||||
|
||||
@@ -161,7 +169,7 @@ tea man --out ./tea.man
|
||||
|
||||
## Compilation
|
||||
|
||||
Make sure you have a current go version installed (1.13 or newer).
|
||||
Make sure you have a current Go version installed (1.26 or newer).
|
||||
|
||||
- To compile the source yourself with the recommended flags & tags:
|
||||
```sh
|
||||
|
||||
47
cmd/actions.go
Normal file
47
cmd/actions.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
"code.gitea.io/tea/cmd/actions"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdActions represents the actions command for managing Gitea Actions
|
||||
var CmdActions = cli.Command{
|
||||
Name: "actions",
|
||||
Aliases: []string{"action"},
|
||||
Category: catEntities,
|
||||
Usage: "Manage repository actions",
|
||||
Description: "Manage repository actions including secrets, variables, and workflow runs",
|
||||
Action: runActionsDefault,
|
||||
Commands: []*cli.Command{
|
||||
&actions.CmdActionsSecrets,
|
||||
&actions.CmdActionsVariables,
|
||||
&actions.CmdActionsRuns,
|
||||
&actions.CmdActionsWorkflows,
|
||||
},
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "repo",
|
||||
Usage: "repository to operate on",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "login",
|
||||
Usage: "gitea login instance to use",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "output format [table, csv, simple, tsv, yaml, json]",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return cli.ShowSubcommandHelp(cmd)
|
||||
}
|
||||
31
cmd/actions/runs.go
Normal file
31
cmd/actions/runs.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
"code.gitea.io/tea/cmd/actions/runs"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdActionsRuns represents the actions runs command
|
||||
var CmdActionsRuns = cli.Command{
|
||||
Name: "runs",
|
||||
Aliases: []string{"run"},
|
||||
Usage: "Manage workflow runs",
|
||||
Description: "List, view, and manage workflow runs for repository actions",
|
||||
Action: runRunsDefault,
|
||||
Commands: []*cli.Command{
|
||||
&runs.CmdRunsList,
|
||||
&runs.CmdRunsView,
|
||||
&runs.CmdRunsDelete,
|
||||
&runs.CmdRunsLogs,
|
||||
},
|
||||
}
|
||||
|
||||
func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
return runs.RunRunsList(ctx, cmd)
|
||||
}
|
||||
71
cmd/actions/runs/delete.go
Normal file
71
cmd/actions/runs/delete.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runs
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdRunsDelete represents a sub command to delete/cancel workflow runs
|
||||
var CmdRunsDelete = cli.Command{
|
||||
Name: "delete",
|
||||
Aliases: []string{"remove", "rm", "cancel"},
|
||||
Usage: "Delete or cancel a workflow run",
|
||||
Description: "Delete (cancel) a workflow run from the repository",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
148
cmd/actions/runs/list.go
Normal file
148
cmd/actions/runs/list.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runs
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdRunsList represents a sub command to list workflow runs
|
||||
var CmdRunsList = cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List workflow runs",
|
||||
Description: "List workflow runs for repository actions with optional filtering",
|
||||
Action: RunRunsList,
|
||||
Flags: append([]cli.Flag{
|
||||
&flags.PaginationPageFlag,
|
||||
&flags.PaginationLimitFlag,
|
||||
&cli.StringFlag{
|
||||
Name: "status",
|
||||
Usage: "Filter by status (success, failure, pending, queued, in_progress, skipped, canceled)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "branch",
|
||||
Usage: "Filter by branch name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "event",
|
||||
Usage: "Filter by event type (push, pull_request, etc.)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "actor",
|
||||
Usage: "Filter by actor username (who triggered the run)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "since",
|
||||
Usage: "Show runs started after this time (e.g., '24h', '2024-01-01')",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "until",
|
||||
Usage: "Show runs started before this time (e.g., '2024-01-01')",
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
// parseTimeFlag parses time flags like "24h" or "2024-01-01"
|
||||
func parseTimeFlag(value string) (time.Time, error) {
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
// Try parsing as duration (e.g., "24h", "168h")
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return time.Now().Add(-duration), nil
|
||||
}
|
||||
|
||||
// Try parsing as date
|
||||
formats := []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02T15:04:05",
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
for _, format := range formats {
|
||||
if t, err := time.Parse(format, value); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("unable to parse time: %s", value)
|
||||
}
|
||||
|
||||
// RunRunsList lists workflow runs
|
||||
func RunRunsList(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
// Parse time filters
|
||||
since, err := parseTimeFlag(cmd.String("since"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --since value: %w", err)
|
||||
}
|
||||
|
||||
until, err := parseTimeFlag(cmd.String("until"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --until value: %w", err)
|
||||
}
|
||||
|
||||
// Build list options
|
||||
listOpts := flags.GetListOptions(cmd)
|
||||
|
||||
runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{
|
||||
ListOptions: listOpts,
|
||||
Status: cmd.String("status"),
|
||||
Branch: cmd.String("branch"),
|
||||
Event: cmd.String("event"),
|
||||
Actor: cmd.String("actor"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if runs == nil {
|
||||
return print.ActionRunsList(nil, c.Output)
|
||||
}
|
||||
|
||||
// Filter by time if specified
|
||||
filteredRuns := filterRunsByTime(runs.WorkflowRuns, since, until)
|
||||
|
||||
return print.ActionRunsList(filteredRuns, c.Output)
|
||||
}
|
||||
|
||||
// filterRunsByTime filters runs based on time range
|
||||
func filterRunsByTime(runs []*gitea.ActionWorkflowRun, since, until time.Time) []*gitea.ActionWorkflowRun {
|
||||
if since.IsZero() && until.IsZero() {
|
||||
return runs
|
||||
}
|
||||
|
||||
var filtered []*gitea.ActionWorkflowRun
|
||||
for _, run := range runs {
|
||||
if !since.IsZero() && run.StartedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
if !until.IsZero() && run.StartedAt.After(until) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, run)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
111
cmd/actions/runs/list_test.go
Normal file
111
cmd/actions/runs/list_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runs
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func TestFilterRunsByTime(t *testing.T) {
|
||||
now := time.Now()
|
||||
runs := []*gitea.ActionWorkflowRun{
|
||||
{ID: 1, StartedAt: now.Add(-1 * time.Hour)},
|
||||
{ID: 2, StartedAt: now.Add(-2 * time.Hour)},
|
||||
{ID: 3, StartedAt: now.Add(-3 * time.Hour)},
|
||||
{ID: 4, StartedAt: now.Add(-4 * time.Hour)},
|
||||
{ID: 5, StartedAt: now.Add(-5 * time.Hour)},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
since time.Time
|
||||
until time.Time
|
||||
expected []int64
|
||||
}{
|
||||
{
|
||||
name: "no filter",
|
||||
since: time.Time{},
|
||||
until: time.Time{},
|
||||
expected: []int64{1, 2, 3, 4, 5},
|
||||
},
|
||||
{
|
||||
name: "since 2.5 hours ago",
|
||||
since: now.Add(-150 * time.Minute),
|
||||
until: time.Time{},
|
||||
expected: []int64{1, 2},
|
||||
},
|
||||
{
|
||||
name: "until 2.5 hours ago",
|
||||
since: time.Time{},
|
||||
until: now.Add(-150 * time.Minute),
|
||||
expected: []int64{3, 4, 5},
|
||||
},
|
||||
{
|
||||
name: "between 2 and 4 hours ago",
|
||||
since: now.Add(-4 * time.Hour),
|
||||
until: now.Add(-2 * time.Hour),
|
||||
expected: []int64{2, 3, 4},
|
||||
},
|
||||
{
|
||||
name: "filter excludes all",
|
||||
since: now.Add(-30 * time.Minute),
|
||||
until: time.Time{},
|
||||
expected: []int64{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := filterRunsByTime(runs, tt.since, tt.until)
|
||||
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("filterRunsByTime() returned %d runs, want %d", len(result), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
for i, run := range result {
|
||||
if run.ID != tt.expected[i] {
|
||||
t.Errorf("filterRunsByTime()[%d].ID = %d, want %d", i, run.ID, tt.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRunsListRequiresRepoContext(t *testing.T) {
|
||||
oldWd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.Chdir(t.TempDir()))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldWd))
|
||||
})
|
||||
|
||||
config.SetConfigForTesting(config.LocalConfig{
|
||||
Logins: []config.Login{{
|
||||
Name: "test",
|
||||
URL: "https://gitea.example.com",
|
||||
Token: "token",
|
||||
User: "tester",
|
||||
Default: true,
|
||||
}},
|
||||
})
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: CmdRunsList.Name,
|
||||
Flags: CmdRunsList.Flags,
|
||||
}
|
||||
require.NoError(t, cmd.Set("login", "test"))
|
||||
|
||||
err = RunRunsList(stdctx.Background(), cmd)
|
||||
require.ErrorContains(t, err, "remote repository required")
|
||||
}
|
||||
175
cmd/actions/runs/logs.go
Normal file
175
cmd/actions/runs/logs.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runs
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdRunsLogs represents a sub command to view workflow run logs
|
||||
var CmdRunsLogs = cli.Command{
|
||||
Name: "logs",
|
||||
Aliases: []string{"log"},
|
||||
Usage: "View workflow run logs",
|
||||
Description: "View logs for a workflow run or specific job",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
83
cmd/actions/runs/view.go
Normal file
83
cmd/actions/runs/view.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runs
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdRunsView represents a sub command to view workflow run details
|
||||
var CmdRunsView = cli.Command{
|
||||
Name: "view",
|
||||
Aliases: []string{"show", "get"},
|
||||
Usage: "View workflow run details",
|
||||
Description: "View details of a specific workflow run including jobs",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
30
cmd/actions/secrets.go
Normal file
30
cmd/actions/secrets.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
"code.gitea.io/tea/cmd/actions/secrets"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdActionsSecrets represents the actions secrets command
|
||||
var CmdActionsSecrets = cli.Command{
|
||||
Name: "secrets",
|
||||
Aliases: []string{"secret"},
|
||||
Usage: "Manage repository action secrets",
|
||||
Description: "Manage secrets used by repository actions and workflows",
|
||||
Action: runSecretsDefault,
|
||||
Commands: []*cli.Command{
|
||||
&secrets.CmdSecretsList,
|
||||
&secrets.CmdSecretsCreate,
|
||||
&secrets.CmdSecretsDelete,
|
||||
},
|
||||
}
|
||||
|
||||
func runSecretsDefault(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
return secrets.RunSecretsList(ctx, cmd)
|
||||
}
|
||||
74
cmd/actions/secrets/create.go
Normal file
74
cmd/actions/secrets/create.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdSecretsCreate represents a sub command to create action secrets
|
||||
var CmdSecretsCreate = cli.Command{
|
||||
Name: "create",
|
||||
Aliases: []string{"add", "set"},
|
||||
Usage: "Create an action secret",
|
||||
Description: "Create a secret for use in repository actions and workflows",
|
||||
ArgsUsage: "<secret-name> [secret-value]",
|
||||
Action: runSecretsCreate,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "file",
|
||||
Usage: "read secret value from file",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "stdin",
|
||||
Usage: "read secret value from stdin",
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("secret name is required")
|
||||
}
|
||||
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
secretName := cmd.Args().First()
|
||||
|
||||
// Read secret value using the utility
|
||||
secretValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{
|
||||
ResourceName: "secret",
|
||||
PromptMsg: fmt.Sprintf("Enter secret value for '%s'", secretName),
|
||||
Hidden: true,
|
||||
AllowEmpty: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.CreateRepoActionSecret(c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{
|
||||
Data: secretValue,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Secret '%s' created successfully\n", secretName)
|
||||
return nil
|
||||
}
|
||||
56
cmd/actions/secrets/create_test.go
Normal file
56
cmd/actions/secrets/create_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetSecretSourceArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid args",
|
||||
args: []string{"VALID_SECRET", "secret_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
args: []string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
args: []string{"SECRET_NAME", "value", "extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid secret name",
|
||||
args: []string{"invalid_secret", "value"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test argument validation only
|
||||
if len(tt.args) == 0 {
|
||||
if !tt.wantErr {
|
||||
t.Error("Expected error for empty args")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(tt.args) > 2 {
|
||||
if !tt.wantErr {
|
||||
t.Error("Expected error for too many args")
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
66
cmd/actions/secrets/delete.go
Normal file
66
cmd/actions/secrets/delete.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdSecretsDelete represents a sub command to delete action secrets
|
||||
var CmdSecretsDelete = cli.Command{
|
||||
Name: "delete",
|
||||
Aliases: []string{"remove", "rm"},
|
||||
Usage: "Delete an action secret",
|
||||
Description: "Delete a secret used by repository actions",
|
||||
ArgsUsage: "<secret-name>",
|
||||
Action: runSecretsDelete,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "confirm",
|
||||
Aliases: []string{"y"},
|
||||
Usage: "confirm deletion without prompting",
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("secret name is required")
|
||||
}
|
||||
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
secretName := cmd.Args().First()
|
||||
|
||||
if !cmd.Bool("confirm") {
|
||||
fmt.Printf("Are you sure you want to delete secret '%s'? [y/N] ", secretName)
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" && response != "Y" && response != "yes" {
|
||||
fmt.Println("Deletion canceled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Secret '%s' deleted successfully\n", secretName)
|
||||
return nil
|
||||
}
|
||||
93
cmd/actions/secrets/delete_test.go
Normal file
93
cmd/actions/secrets/delete_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecretsDeleteValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid secret name",
|
||||
args: []string{"VALID_SECRET"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no args",
|
||||
args: []string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
args: []string{"SECRET1", "SECRET2"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid secret name but client does not validate",
|
||||
args: []string{"invalid_secret"},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateDeleteArgs(tt.args)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateDeleteArgs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsDeleteFlags(t *testing.T) {
|
||||
cmd := CmdSecretsDelete
|
||||
|
||||
// Test command properties
|
||||
if cmd.Name != "delete" {
|
||||
t.Errorf("Expected command name 'delete', got %s", cmd.Name)
|
||||
}
|
||||
|
||||
// Check that rm is one of the aliases
|
||||
hasRmAlias := false
|
||||
for _, alias := range cmd.Aliases {
|
||||
if alias == "rm" {
|
||||
hasRmAlias = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRmAlias {
|
||||
t.Error("Expected 'rm' to be one of the aliases for delete command")
|
||||
}
|
||||
|
||||
if cmd.ArgsUsage != "<secret-name>" {
|
||||
t.Errorf("Expected ArgsUsage '<secret-name>', got %s", cmd.ArgsUsage)
|
||||
}
|
||||
|
||||
if cmd.Usage == "" {
|
||||
t.Error("Delete command should have usage text")
|
||||
}
|
||||
|
||||
if cmd.Description == "" {
|
||||
t.Error("Delete command should have description")
|
||||
}
|
||||
}
|
||||
|
||||
// validateDeleteArgs validates arguments for the delete command
|
||||
func validateDeleteArgs(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("secret name is required")
|
||||
}
|
||||
|
||||
if len(args) > 1 {
|
||||
return fmt.Errorf("only one secret name allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
49
cmd/actions/secrets/list.go
Normal file
49
cmd/actions/secrets/list.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// CmdSecretsList represents a sub command to list action secrets
|
||||
var CmdSecretsList = cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List action secrets",
|
||||
Description: "List secrets configured for repository actions",
|
||||
Action: RunSecretsList,
|
||||
Flags: append([]cli.Flag{
|
||||
&flags.PaginationPageFlag,
|
||||
&flags.PaginationLimitFlag,
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
// RunSecretsList list action secrets
|
||||
func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
secrets, _, err := client.ListRepoActionSecret(c.Owner, c.Repo, gitea.ListRepoActionSecretOption{
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return print.ActionSecretsList(secrets, c.Output)
|
||||
}
|
||||
98
cmd/actions/secrets/list_test.go
Normal file
98
cmd/actions/secrets/list_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package secrets
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func TestSecretsListFlags(t *testing.T) {
|
||||
cmd := CmdSecretsList
|
||||
|
||||
// Test that required flags exist
|
||||
expectedFlags := []string{"output", "remote", "login", "repo"}
|
||||
|
||||
for _, flagName := range expectedFlags {
|
||||
found := false
|
||||
for _, flag := range cmd.Flags {
|
||||
if flag.Names()[0] == flagName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected flag %s not found in CmdSecretsList", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
// Test command properties
|
||||
if cmd.Name != "list" {
|
||||
t.Errorf("Expected command name 'list', got %s", cmd.Name)
|
||||
}
|
||||
|
||||
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" {
|
||||
t.Errorf("Expected alias 'ls' for list command")
|
||||
}
|
||||
|
||||
if cmd.Usage == "" {
|
||||
t.Error("List command should have usage text")
|
||||
}
|
||||
|
||||
if cmd.Description == "" {
|
||||
t.Error("List command should have description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsListValidation(t *testing.T) {
|
||||
// Basic validation that the command accepts the expected arguments
|
||||
// More detailed testing would require mocking the Gitea client
|
||||
|
||||
// Test that list command doesn't require arguments
|
||||
args := []string{}
|
||||
if len(args) > 0 {
|
||||
t.Error("List command should not require arguments")
|
||||
}
|
||||
|
||||
// Test that extra arguments are ignored
|
||||
extraArgs := []string{"extra", "args"}
|
||||
if len(extraArgs) > 0 {
|
||||
// This is fine - list commands typically ignore extra args
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSecretsListRequiresRepoContext(t *testing.T) {
|
||||
oldWd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.Chdir(t.TempDir()))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldWd))
|
||||
})
|
||||
|
||||
config.SetConfigForTesting(config.LocalConfig{
|
||||
Logins: []config.Login{{
|
||||
Name: "test",
|
||||
URL: "https://gitea.example.com",
|
||||
Token: "token",
|
||||
User: "tester",
|
||||
Default: true,
|
||||
}},
|
||||
})
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: CmdSecretsList.Name,
|
||||
Flags: CmdSecretsList.Flags,
|
||||
}
|
||||
require.NoError(t, cmd.Set("login", "test"))
|
||||
|
||||
err = RunSecretsList(stdctx.Background(), cmd)
|
||||
require.ErrorContains(t, err, "remote repository required")
|
||||
}
|
||||
30
cmd/actions/variables.go
Normal file
30
cmd/actions/variables.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
"code.gitea.io/tea/cmd/actions/variables"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdActionsVariables represents the actions variables command
|
||||
var CmdActionsVariables = cli.Command{
|
||||
Name: "variables",
|
||||
Aliases: []string{"variable", "vars", "var"},
|
||||
Usage: "Manage repository action variables",
|
||||
Description: "Manage variables used by repository actions and workflows",
|
||||
Action: runVariablesDefault,
|
||||
Commands: []*cli.Command{
|
||||
&variables.CmdVariablesList,
|
||||
&variables.CmdVariablesSet,
|
||||
&variables.CmdVariablesDelete,
|
||||
},
|
||||
}
|
||||
|
||||
func runVariablesDefault(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
return variables.RunVariablesList(ctx, cmd)
|
||||
}
|
||||
66
cmd/actions/variables/delete.go
Normal file
66
cmd/actions/variables/delete.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package variables
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdVariablesDelete represents a sub command to delete action variables
|
||||
var CmdVariablesDelete = cli.Command{
|
||||
Name: "delete",
|
||||
Aliases: []string{"remove", "rm"},
|
||||
Usage: "Delete an action variable",
|
||||
Description: "Delete a variable used by repository actions",
|
||||
ArgsUsage: "<variable-name>",
|
||||
Action: runVariablesDelete,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "confirm",
|
||||
Aliases: []string{"y"},
|
||||
Usage: "confirm deletion without prompting",
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("variable name is required")
|
||||
}
|
||||
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
variableName := cmd.Args().First()
|
||||
|
||||
if !cmd.Bool("confirm") {
|
||||
fmt.Printf("Are you sure you want to delete variable '%s'? [y/N] ", variableName)
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" && response != "Y" && response != "yes" {
|
||||
fmt.Println("Deletion canceled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Variable '%s' deleted successfully\n", variableName)
|
||||
return nil
|
||||
}
|
||||
98
cmd/actions/variables/delete_test.go
Normal file
98
cmd/actions/variables/delete_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package variables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVariablesDeleteValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid variable name",
|
||||
args: []string{"VALID_VARIABLE"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase name",
|
||||
args: []string{"valid_variable"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no args",
|
||||
args: []string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
args: []string{"VARIABLE1", "VARIABLE2"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid variable name",
|
||||
args: []string{"invalid-variable"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateVariableDeleteArgs(tt.args)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateVariableDeleteArgs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariablesDeleteFlags(t *testing.T) {
|
||||
cmd := CmdVariablesDelete
|
||||
|
||||
// Test command properties
|
||||
if cmd.Name != "delete" {
|
||||
t.Errorf("Expected command name 'delete', got %s", cmd.Name)
|
||||
}
|
||||
|
||||
// Check that rm is one of the aliases
|
||||
hasRmAlias := false
|
||||
for _, alias := range cmd.Aliases {
|
||||
if alias == "rm" {
|
||||
hasRmAlias = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRmAlias {
|
||||
t.Error("Expected 'rm' to be one of the aliases for delete command")
|
||||
}
|
||||
|
||||
if cmd.ArgsUsage != "<variable-name>" {
|
||||
t.Errorf("Expected ArgsUsage '<variable-name>', got %s", cmd.ArgsUsage)
|
||||
}
|
||||
|
||||
if cmd.Usage == "" {
|
||||
t.Error("Delete command should have usage text")
|
||||
}
|
||||
|
||||
if cmd.Description == "" {
|
||||
t.Error("Delete command should have description")
|
||||
}
|
||||
}
|
||||
|
||||
// validateVariableDeleteArgs validates arguments for the delete command
|
||||
func validateVariableDeleteArgs(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("variable name is required")
|
||||
}
|
||||
|
||||
if len(args) > 1 {
|
||||
return fmt.Errorf("only one variable name allowed")
|
||||
}
|
||||
|
||||
return validateVariableName(args[0])
|
||||
}
|
||||
61
cmd/actions/variables/list.go
Normal file
61
cmd/actions/variables/list.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package variables
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// CmdVariablesList represents a sub command to list action variables
|
||||
var CmdVariablesList = cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List action variables",
|
||||
Description: "List variables configured for repository actions",
|
||||
Action: RunVariablesList,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Usage: "show specific variable by name",
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
// RunVariablesList list action variables
|
||||
func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
if name := cmd.String("name"); name != "" {
|
||||
// Get specific variable
|
||||
variable, _, err := client.GetRepoActionVariable(c.Owner, c.Repo, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.ActionVariableDetails(variable)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List all variables - Note: SDK doesn't have ListRepoActionVariables yet
|
||||
// This is a limitation of the current SDK
|
||||
fmt.Println("Note: Listing all variables is not yet supported by the Gitea SDK.")
|
||||
fmt.Println("Use 'tea actions variables list --name <variable-name>' to get a specific variable.")
|
||||
fmt.Println("You can also check your repository's Actions settings in the web interface.")
|
||||
|
||||
return nil
|
||||
}
|
||||
98
cmd/actions/variables/list_test.go
Normal file
98
cmd/actions/variables/list_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package variables
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func TestVariablesListFlags(t *testing.T) {
|
||||
cmd := CmdVariablesList
|
||||
|
||||
// Test that required flags exist
|
||||
expectedFlags := []string{"output", "remote", "login", "repo"}
|
||||
|
||||
for _, flagName := range expectedFlags {
|
||||
found := false
|
||||
for _, flag := range cmd.Flags {
|
||||
if flag.Names()[0] == flagName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Errorf("Expected flag %s not found in CmdVariablesList", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
// Test command properties
|
||||
if cmd.Name != "list" {
|
||||
t.Errorf("Expected command name 'list', got %s", cmd.Name)
|
||||
}
|
||||
|
||||
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" {
|
||||
t.Errorf("Expected alias 'ls' for list command")
|
||||
}
|
||||
|
||||
if cmd.Usage == "" {
|
||||
t.Error("List command should have usage text")
|
||||
}
|
||||
|
||||
if cmd.Description == "" {
|
||||
t.Error("List command should have description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariablesListValidation(t *testing.T) {
|
||||
// Basic validation that the command accepts the expected arguments
|
||||
// More detailed testing would require mocking the Gitea client
|
||||
|
||||
// Test that list command doesn't require arguments
|
||||
args := []string{}
|
||||
if len(args) > 0 {
|
||||
t.Error("List command should not require arguments")
|
||||
}
|
||||
|
||||
// Test that extra arguments are ignored
|
||||
extraArgs := []string{"extra", "args"}
|
||||
if len(extraArgs) > 0 {
|
||||
// This is fine - list commands typically ignore extra args
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVariablesListRequiresRepoContext(t *testing.T) {
|
||||
oldWd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, os.Chdir(t.TempDir()))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, os.Chdir(oldWd))
|
||||
})
|
||||
|
||||
config.SetConfigForTesting(config.LocalConfig{
|
||||
Logins: []config.Login{{
|
||||
Name: "test",
|
||||
URL: "https://gitea.example.com",
|
||||
Token: "token",
|
||||
User: "tester",
|
||||
Default: true,
|
||||
}},
|
||||
})
|
||||
|
||||
cmd := &cli.Command{
|
||||
Name: CmdVariablesList.Name,
|
||||
Flags: CmdVariablesList.Flags,
|
||||
}
|
||||
require.NoError(t, cmd.Set("login", "test"))
|
||||
|
||||
err = RunVariablesList(stdctx.Background(), cmd)
|
||||
require.ErrorContains(t, err, "remote repository required")
|
||||
}
|
||||
108
cmd/actions/variables/set.go
Normal file
108
cmd/actions/variables/set.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package variables
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdVariablesSet represents a sub command to set action variables
|
||||
var CmdVariablesSet = cli.Command{
|
||||
Name: "set",
|
||||
Aliases: []string{"create", "update"},
|
||||
Usage: "Set an action variable",
|
||||
Description: "Set a variable for use in repository actions and workflows",
|
||||
ArgsUsage: "<variable-name> [variable-value]",
|
||||
Action: runVariablesSet,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "file",
|
||||
Usage: "read variable value from file",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "stdin",
|
||||
Usage: "read variable value from stdin",
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
if cmd.Args().Len() == 0 {
|
||||
return fmt.Errorf("variable name is required")
|
||||
}
|
||||
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
variableName := cmd.Args().First()
|
||||
if err := validateVariableName(variableName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read variable value using the utility
|
||||
variableValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{
|
||||
ResourceName: "variable",
|
||||
PromptMsg: fmt.Sprintf("Enter variable value for '%s'", variableName),
|
||||
Hidden: false,
|
||||
AllowEmpty: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateVariableValue(variableValue); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.CreateRepoActionVariable(c.Owner, c.Repo, variableName, variableValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Variable '%s' set successfully\n", variableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateVariableName validates that a variable name follows the required format
|
||||
func validateVariableName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("variable name cannot be empty")
|
||||
}
|
||||
|
||||
// Variable names can contain letters (upper/lower), numbers, and underscores
|
||||
// Cannot start with a number
|
||||
// Cannot contain spaces or special characters (except underscore)
|
||||
validPattern := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
if !validPattern.MatchString(name) {
|
||||
return fmt.Errorf("variable name must contain only letters, numbers, and underscores, and cannot start with a number")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateVariableValue validates that a variable value is acceptable
|
||||
func validateVariableValue(value string) error {
|
||||
// Variables can be empty or contain whitespace, unlike secrets
|
||||
|
||||
// Check for maximum size (64KB limit)
|
||||
if len(value) > 65536 {
|
||||
return fmt.Errorf("variable value cannot exceed 64KB")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
213
cmd/actions/variables/set_test.go
Normal file
213
cmd/actions/variables/set_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package variables
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateVariableName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid name",
|
||||
input: "VALID_VARIABLE_NAME",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid name with numbers",
|
||||
input: "VARIABLE_123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase",
|
||||
input: "valid_variable",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid mixed case",
|
||||
input: "Mixed_Case_Variable",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid - spaces",
|
||||
input: "INVALID VARIABLE",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - special chars",
|
||||
input: "INVALID-VARIABLE!",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - starts with number",
|
||||
input: "1INVALID",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid - empty",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateVariableName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateVariableName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVariableSourceArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid args",
|
||||
args: []string{"VALID_VARIABLE", "variable_value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase",
|
||||
args: []string{"valid_variable", "value"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
args: []string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
args: []string{"VARIABLE_NAME", "value", "extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid variable name",
|
||||
args: []string{"invalid-variable", "value"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test argument validation only
|
||||
if len(tt.args) == 0 {
|
||||
if !tt.wantErr {
|
||||
t.Error("Expected error for empty args")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(tt.args) > 2 {
|
||||
if !tt.wantErr {
|
||||
t.Error("Expected error for too many args")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Test variable name validation
|
||||
err := validateVariableName(tt.args[0])
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateVariableName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariableNameValidation(t *testing.T) {
|
||||
// Test that variable names follow GitHub Actions/Gitea Actions conventions
|
||||
validNames := []string{
|
||||
"VALID_VARIABLE",
|
||||
"API_URL",
|
||||
"DATABASE_HOST",
|
||||
"VARIABLE_123",
|
||||
"mixed_Case_Variable",
|
||||
"lowercase_variable",
|
||||
"UPPERCASE_VARIABLE",
|
||||
}
|
||||
|
||||
invalidNames := []string{
|
||||
"Invalid-Dashes",
|
||||
"INVALID SPACES",
|
||||
"123_STARTS_WITH_NUMBER",
|
||||
"", // Empty
|
||||
"INVALID!@#", // Special chars
|
||||
}
|
||||
|
||||
for _, name := range validNames {
|
||||
t.Run("valid_"+name, func(t *testing.T) {
|
||||
err := validateVariableName(name)
|
||||
if err != nil {
|
||||
t.Errorf("validateVariableName(%q) should be valid, got error: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, name := range invalidNames {
|
||||
t.Run("invalid_"+name, func(t *testing.T) {
|
||||
err := validateVariableName(name)
|
||||
if err == nil {
|
||||
t.Errorf("validateVariableName(%q) should be invalid, got no error", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariableValueValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid value",
|
||||
value: "variable123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid complex value",
|
||||
value: "https://api.example.com/v1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid multiline value",
|
||||
value: "line1\nline2\nline3",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty value allowed",
|
||||
value: "",
|
||||
wantErr: false, // Variables can be empty unlike secrets
|
||||
},
|
||||
{
|
||||
name: "whitespace only allowed",
|
||||
value: " \t\n ",
|
||||
wantErr: false, // Variables can contain whitespace
|
||||
},
|
||||
{
|
||||
name: "very long value",
|
||||
value: strings.Repeat("a", 65537), // Over 64KB
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateVariableValue(tt.value)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateVariableValue() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
32
cmd/actions/workflows.go
Normal file
32
cmd/actions/workflows.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
|
||||
"code.gitea.io/tea/cmd/actions/workflows"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdActionsWorkflows represents the actions workflows command
|
||||
var CmdActionsWorkflows = cli.Command{
|
||||
Name: "workflows",
|
||||
Aliases: []string{"workflow"},
|
||||
Usage: "Manage repository workflows",
|
||||
Description: "List and manage repository action workflows",
|
||||
Action: runWorkflowsDefault,
|
||||
Commands: []*cli.Command{
|
||||
&workflows.CmdWorkflowsList,
|
||||
&workflows.CmdWorkflowsView,
|
||||
&workflows.CmdWorkflowsDispatch,
|
||||
&workflows.CmdWorkflowsEnable,
|
||||
&workflows.CmdWorkflowsDisable,
|
||||
},
|
||||
}
|
||||
|
||||
func runWorkflowsDefault(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
return workflows.RunWorkflowsList(ctx, cmd)
|
||||
}
|
||||
65
cmd/actions/workflows/disable.go
Normal file
65
cmd/actions/workflows/disable.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdWorkflowsDisable represents a sub command to disable a workflow
|
||||
var CmdWorkflowsDisable = cli.Command{
|
||||
Name: "disable",
|
||||
Usage: "Disable a workflow",
|
||||
Description: "Disable a workflow in the repository",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
174
cmd/actions/workflows/dispatch.go
Normal file
174
cmd/actions/workflows/dispatch.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdWorkflowsDispatch represents a sub command to dispatch a workflow
|
||||
var CmdWorkflowsDispatch = cli.Command{
|
||||
Name: "dispatch",
|
||||
Aliases: []string{"trigger", "run"},
|
||||
Usage: "Dispatch a workflow run",
|
||||
Description: "Trigger a workflow_dispatch event for a workflow",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
48
cmd/actions/workflows/enable.go
Normal file
48
cmd/actions/workflows/enable.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdWorkflowsEnable represents a sub command to enable a workflow
|
||||
var CmdWorkflowsEnable = cli.Command{
|
||||
Name: "enable",
|
||||
Usage: "Enable a workflow",
|
||||
Description: "Enable a disabled workflow in the repository",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
50
cmd/actions/workflows/list.go
Normal file
50
cmd/actions/workflows/list.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdWorkflowsList represents a sub command to list workflows
|
||||
var CmdWorkflowsList = cli.Command{
|
||||
Name: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Usage: "List repository workflows",
|
||||
Description: "List workflows in the repository with their status",
|
||||
Action: RunWorkflowsList,
|
||||
Flags: flags.AllDefaultFlags,
|
||||
}
|
||||
|
||||
// RunWorkflowsList lists workflows in the repository using the workflow API
|
||||
func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
c, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := c.Login.Client()
|
||||
|
||||
resp, _, err := client.ListRepoActionWorkflows(c.Owner, c.Repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list workflows: %w", err)
|
||||
}
|
||||
|
||||
var workflows []*gitea.ActionWorkflow
|
||||
if resp != nil {
|
||||
workflows = resp.Workflows
|
||||
}
|
||||
|
||||
return print.ActionWorkflowsList(workflows, c.Output)
|
||||
}
|
||||
50
cmd/actions/workflows/view.go
Normal file
50
cmd/actions/workflows/view.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdWorkflowsView represents a sub command to view workflow details
|
||||
var CmdWorkflowsView = cli.Command{
|
||||
Name: "view",
|
||||
Aliases: []string{"show", "get"},
|
||||
Usage: "View workflow details",
|
||||
Description: "View details of a specific workflow",
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
@@ -44,7 +44,10 @@ var cmdAdminUsers = cli.Command{
|
||||
}
|
||||
|
||||
func runAdminUserDetail(_ stdctx.Context, cmd *cli.Command, u string) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
user, _, err := client.GetUserInfo(u)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,7 +34,10 @@ var CmdUserList = cli.Command{
|
||||
|
||||
// RunUserList list users
|
||||
func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, err := userFieldsFlag.GetValues(cmd)
|
||||
if err != nil {
|
||||
@@ -43,13 +46,11 @@ func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
|
||||
client := ctx.Login.Client()
|
||||
users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.UserList(users, ctx.Output, fields)
|
||||
|
||||
return nil
|
||||
return print.UserList(users, ctx.Output, fields)
|
||||
}
|
||||
|
||||
408
cmd/api.go
Normal file
408
cmd/api.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdctx "context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/api"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// apiFlags returns a fresh set of flag instances for the api command.
|
||||
// This is a factory function so that each invocation gets independent flag
|
||||
// objects, avoiding shared hasBeenSet state across tests.
|
||||
func apiFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "method",
|
||||
Aliases: []string{"X"},
|
||||
Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
|
||||
Value: "GET",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "field",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Add a string field to the request body (key=value)",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "Field",
|
||||
Aliases: []string{"F"},
|
||||
Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "header",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Add a custom header (key:value)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "data",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Raw JSON request body (use @file to read from file, @- for stdin)",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "include",
|
||||
Aliases: []string{"i"},
|
||||
Usage: "Include HTTP status and response headers in output (written to stderr)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Write response body to file instead of stdout (use '-' for stdout)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CmdApi represents the api command
|
||||
var CmdApi = cli.Command{
|
||||
Name: "api",
|
||||
Category: catHelpers,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Usage: "Make an authenticated API request",
|
||||
Description: `Makes an authenticated HTTP request to the Gitea API and prints the response.
|
||||
|
||||
The endpoint argument is the path to the API endpoint, which will be prefixed
|
||||
with /api/v1/ if it doesn't start with /api/ or http(s)://.
|
||||
|
||||
Placeholders like {owner} and {repo} in the endpoint will be replaced with
|
||||
values from the current repository context.
|
||||
|
||||
Use -f for string fields and -F for typed fields (numbers, booleans, null).
|
||||
With -F, prefix value with @ to read from file (@- for stdin). Values starting
|
||||
with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force
|
||||
string type (e.g., -F key="null" for literal string "null").
|
||||
|
||||
Use -d/--data to send a raw JSON body. Use @file to read from a file, or @-
|
||||
to read from stdin. The -d flag cannot be combined with -f or -F.
|
||||
|
||||
When a request body is provided via -f, -F, or -d, the method defaults to POST
|
||||
unless explicitly set with -X/--method.
|
||||
|
||||
Note: if your endpoint contains ? or &, quote it to prevent shell expansion
|
||||
(e.g., '/repos/{owner}/{repo}/issues?state=open').`,
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
635
cmd/api_test.go
Normal file
635
cmd/api_test.go
Normal file
@@ -0,0 +1,635 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
tea_git "code.gitea.io/tea/modules/git"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func TestParseTypedValue(t *testing.T) {
|
||||
t.Run("null", func(t *testing.T) {
|
||||
v, err := parseTypedValue("null")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, v)
|
||||
})
|
||||
|
||||
t.Run("bool true", func(t *testing.T) {
|
||||
v, err := parseTypedValue("true")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, v)
|
||||
})
|
||||
|
||||
t.Run("bool false", func(t *testing.T) {
|
||||
v, err := parseTypedValue("false")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, v)
|
||||
})
|
||||
|
||||
t.Run("integer", func(t *testing.T) {
|
||||
v, err := parseTypedValue("42")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(42), v)
|
||||
})
|
||||
|
||||
t.Run("float", func(t *testing.T) {
|
||||
v, err := parseTypedValue("3.14")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3.14, v)
|
||||
})
|
||||
|
||||
t.Run("string", func(t *testing.T) {
|
||||
v, err := parseTypedValue("hello")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", v)
|
||||
})
|
||||
|
||||
t.Run("JSON array", func(t *testing.T) {
|
||||
v, err := parseTypedValue("[1,2,3]")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{float64(1), float64(2), float64(3)}, v)
|
||||
})
|
||||
|
||||
t.Run("JSON object", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`{"key":"val"}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"key": "val"}, v)
|
||||
})
|
||||
|
||||
t.Run("invalid JSON array falls back to string", func(t *testing.T) {
|
||||
v, err := parseTypedValue("[not json")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "[not json", v)
|
||||
})
|
||||
|
||||
t.Run("invalid JSON object falls back to string", func(t *testing.T) {
|
||||
v, err := parseTypedValue("{not json")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "{not json", v)
|
||||
})
|
||||
|
||||
t.Run("file reference", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "test.txt")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("file content\n"), 0o644))
|
||||
v, err := parseTypedValue("@" + tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "file content", v)
|
||||
})
|
||||
|
||||
t.Run("file reference without trailing newline", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "test.txt")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("no newline"), 0o644))
|
||||
v, err := parseTypedValue("@" + tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "no newline", v)
|
||||
})
|
||||
|
||||
t.Run("empty file reference", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "empty.txt")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(""), 0o644))
|
||||
v, err := parseTypedValue("@" + tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", v)
|
||||
})
|
||||
|
||||
t.Run("nonexistent file reference", func(t *testing.T) {
|
||||
_, err := parseTypedValue("@/nonexistent/file.txt")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to read")
|
||||
})
|
||||
|
||||
t.Run("negative integer", func(t *testing.T) {
|
||||
v, err := parseTypedValue("-42")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(-42), v)
|
||||
})
|
||||
|
||||
t.Run("negative float", func(t *testing.T) {
|
||||
v, err := parseTypedValue("-3.14")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, -3.14, v)
|
||||
})
|
||||
|
||||
t.Run("scientific notation", func(t *testing.T) {
|
||||
v, err := parseTypedValue("1.5e10")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1.5e10, v)
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
v, err := parseTypedValue("")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", v)
|
||||
})
|
||||
|
||||
t.Run("string starting with number", func(t *testing.T) {
|
||||
v, err := parseTypedValue("123abc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "123abc", v)
|
||||
})
|
||||
|
||||
t.Run("nested JSON object", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`{"user":{"name":"alice","id":1}}`)
|
||||
require.NoError(t, err)
|
||||
expected := map[string]any{
|
||||
"user": map[string]any{
|
||||
"name": "alice",
|
||||
"id": float64(1),
|
||||
},
|
||||
}
|
||||
assert.Equal(t, expected, v)
|
||||
})
|
||||
|
||||
t.Run("complex JSON array", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`[{"id":1},{"id":2}]`)
|
||||
require.NoError(t, err)
|
||||
expected := []any{
|
||||
map[string]any{"id": float64(1)},
|
||||
map[string]any{"id": float64(2)},
|
||||
}
|
||||
assert.Equal(t, expected, v)
|
||||
})
|
||||
|
||||
t.Run("quoted string prevents type parsing", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"null"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "null", v)
|
||||
})
|
||||
|
||||
t.Run("quoted true becomes string", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"true"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "true", v)
|
||||
})
|
||||
|
||||
t.Run("quoted false becomes string", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"false"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "false", v)
|
||||
})
|
||||
|
||||
t.Run("quoted number becomes string", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"123"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "123", v)
|
||||
})
|
||||
|
||||
t.Run("quoted empty string", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`""`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", v)
|
||||
})
|
||||
|
||||
t.Run("quoted string with spaces", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"hello world"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello world", v)
|
||||
})
|
||||
|
||||
t.Run("single quote not treated as quote", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`'hello'`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "'hello'", v)
|
||||
})
|
||||
|
||||
t.Run("unmatched quote at start only", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"hello`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"hello`, v)
|
||||
})
|
||||
|
||||
t.Run("unmatched quote at end only", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`hello"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `hello"`, v)
|
||||
})
|
||||
|
||||
t.Run("quoted string with escaped quote", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"hello \"world\""`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `hello "world"`, v)
|
||||
})
|
||||
|
||||
t.Run("quoted string with backslash-n", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"line1\nline2"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "line1\nline2", v)
|
||||
})
|
||||
|
||||
t.Run("quoted string with tab escape", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"col1\tcol2"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "col1\tcol2", v)
|
||||
})
|
||||
|
||||
t.Run("quoted string with backslash", func(t *testing.T) {
|
||||
v, err := parseTypedValue(`"path\\to\\file"`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `path\to\file`, v)
|
||||
})
|
||||
|
||||
t.Run("invalid escape sequence in quoted string", func(t *testing.T) {
|
||||
_, err := parseTypedValue(`"bad \z escape"`)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid quoted string")
|
||||
})
|
||||
}
|
||||
|
||||
// runApiWithArgs configures a test login, parses the command line, and captures
|
||||
// the prepared request without opening sockets or making HTTP requests.
|
||||
func runApiWithArgs(t *testing.T, args []string) (method string, body []byte, err error) {
|
||||
t.Helper()
|
||||
|
||||
var capturedMethod string
|
||||
var capturedBody []byte
|
||||
|
||||
config.SetConfigForTesting(config.LocalConfig{
|
||||
Logins: []config.Login{{
|
||||
Name: "testLogin",
|
||||
URL: "https://gitea.example.com",
|
||||
Token: "test-token",
|
||||
User: "testUser",
|
||||
Default: true,
|
||||
}},
|
||||
})
|
||||
|
||||
// Use the apiFlags factory to get fresh flag instances, avoiding shared
|
||||
// hasBeenSet state between tests. Append minimal login/repo flags needed
|
||||
// for the test harness.
|
||||
cmd := cli.Command{
|
||||
Name: "api",
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := prepareAPIRequest(cmd, ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
capturedMethod = request.Method
|
||||
capturedBody = append([]byte(nil), request.Body...)
|
||||
return nil
|
||||
},
|
||||
Flags: append(apiFlags(), []cli.Flag{
|
||||
&cli.StringFlag{Name: "login", Aliases: []string{"l"}},
|
||||
&cli.StringFlag{Name: "repo", Aliases: []string{"r"}},
|
||||
&cli.StringFlag{Name: "remote", Aliases: []string{"R"}},
|
||||
}...),
|
||||
Writer: io.Discard,
|
||||
ErrWriter: io.Discard,
|
||||
}
|
||||
|
||||
fullArgs := append([]string{"api", "--login", "testLogin"}, args...)
|
||||
runErr := cmd.Run(stdctx.Background(), fullArgs)
|
||||
|
||||
return capturedMethod, capturedBody, runErr
|
||||
}
|
||||
|
||||
func TestApiCommaInFieldValue(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{"-f", "body=hello, world", "-X", "POST", "/test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, "hello, world", parsed["body"])
|
||||
}
|
||||
|
||||
func TestApiRawDataFlag(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{"-d", `{"title":"test","body":"hello"}`, "/test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, "test", parsed["title"])
|
||||
assert.Equal(t, "hello", parsed["body"])
|
||||
}
|
||||
|
||||
func TestApiDataFieldMutualExclusion(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-f", "key=val", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "--data/-d cannot be combined with --field/-f or --Field/-F")
|
||||
}
|
||||
|
||||
func TestApiMethodAutoDefault(t *testing.T) {
|
||||
t.Run("POST when body provided without explicit method", func(t *testing.T) {
|
||||
method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "/test"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "POST", method)
|
||||
})
|
||||
|
||||
t.Run("explicit method overrides auto-POST", func(t *testing.T) {
|
||||
method, _, err := runApiWithArgs(t, []string{"-d", `{"title":"test"}`, "-X", "PATCH", "/test"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "PATCH", method)
|
||||
})
|
||||
|
||||
t.Run("GET when no body", func(t *testing.T) {
|
||||
method, _, err := runApiWithArgs(t, []string{"/test"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "GET", method)
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiMultipleFields(t *testing.T) {
|
||||
t.Run("multiple -f flags", func(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{
|
||||
"-f", "title=Test Issue",
|
||||
"-f", "body=Description here",
|
||||
"-X", "POST",
|
||||
"/test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, "Test Issue", parsed["title"])
|
||||
assert.Equal(t, "Description here", parsed["body"])
|
||||
})
|
||||
|
||||
t.Run("multiple -F flags with different types", func(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{
|
||||
"-F", "milestone=5",
|
||||
"-F", "closed=true",
|
||||
"-F", "title=Test",
|
||||
"-X", "POST",
|
||||
"/test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, float64(5), parsed["milestone"])
|
||||
assert.Equal(t, true, parsed["closed"])
|
||||
assert.Equal(t, "Test", parsed["title"])
|
||||
})
|
||||
|
||||
t.Run("combining -f and -F flags", func(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{
|
||||
"-f", "title=Test",
|
||||
"-F", "milestone=3",
|
||||
"-F", "closed=false",
|
||||
"-X", "POST",
|
||||
"/test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, "Test", parsed["title"])
|
||||
assert.Equal(t, float64(3), parsed["milestone"])
|
||||
assert.Equal(t, false, parsed["closed"])
|
||||
})
|
||||
|
||||
t.Run("-F with JSON array", func(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{
|
||||
"-F", `labels=["bug","enhancement"]`,
|
||||
"-X", "POST",
|
||||
"/test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, []any{"bug", "enhancement"}, parsed["labels"])
|
||||
})
|
||||
|
||||
t.Run("-F with JSON object", func(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{
|
||||
"-F", `assignee={"login":"alice","id":123}`,
|
||||
"-X", "POST",
|
||||
"/test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assignee, ok := parsed["assignee"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "alice", assignee["login"])
|
||||
assert.Equal(t, float64(123), assignee["id"])
|
||||
})
|
||||
|
||||
t.Run("-F with quoted string to prevent type parsing", func(t *testing.T) {
|
||||
_, body, err := runApiWithArgs(t, []string{
|
||||
"-F", `status="null"`,
|
||||
"-F", `enabled="true"`,
|
||||
"-F", `count="42"`,
|
||||
"-X", "POST",
|
||||
"/test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, "null", parsed["status"])
|
||||
assert.Equal(t, "true", parsed["enabled"])
|
||||
assert.Equal(t, "42", parsed["count"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiDataFromFile(t *testing.T) {
|
||||
t.Run("read JSON from file", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "data.json")
|
||||
jsonData := `{"title":"From File","body":"File content"}`
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte(jsonData), 0o644))
|
||||
|
||||
_, body, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &parsed))
|
||||
assert.Equal(t, "From File", parsed["title"])
|
||||
assert.Equal(t, "File content", parsed["body"])
|
||||
})
|
||||
|
||||
t.Run("invalid JSON in --data flag", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-d", `{invalid json}`, "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not valid JSON")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON from file includes filename", func(t *testing.T) {
|
||||
tmpFile := filepath.Join(t.TempDir(), "bad.json")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("not json"), 0o644))
|
||||
|
||||
_, _, err := runApiWithArgs(t, []string{"-d", "@" + tmpFile, "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not valid JSON")
|
||||
assert.Contains(t, err.Error(), "bad.json")
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiErrorHandling(t *testing.T) {
|
||||
t.Run("missing endpoint argument", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "endpoint argument required")
|
||||
})
|
||||
|
||||
t.Run("invalid field format", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-f", "invalidformat", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid field format")
|
||||
})
|
||||
|
||||
t.Run("invalid Field format", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-F", "noequalsign", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid field format")
|
||||
})
|
||||
|
||||
t.Run("empty field key with -f", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-f", "=value", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "field key cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("empty field key with -F", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-F", "=123", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "field key cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("duplicate field key in -f flags", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-f", "key=first", "-f", "key=second", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate field key")
|
||||
})
|
||||
|
||||
t.Run("duplicate field key in -F flags", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-F", "key=1", "-F", "key=2", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate field key")
|
||||
})
|
||||
|
||||
t.Run("duplicate field key across -f and -F flags", func(t *testing.T) {
|
||||
_, _, err := runApiWithArgs(t, []string{"-f", "key=string", "-F", "key=123", "-X", "POST", "/test"})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate field key")
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpandPlaceholders(t *testing.T) {
|
||||
t.Run("replaces owner and repo", func(t *testing.T) {
|
||||
ctx := &context.TeaContext{
|
||||
Owner: "myorg",
|
||||
Repo: "myrepo",
|
||||
}
|
||||
result := expandPlaceholders("/repos/{owner}/{repo}/issues", ctx)
|
||||
assert.Equal(t, "/repos/myorg/myrepo/issues", result)
|
||||
})
|
||||
|
||||
t.Run("replaces multiple occurrences", func(t *testing.T) {
|
||||
ctx := &context.TeaContext{
|
||||
Owner: "alice",
|
||||
Repo: "proj",
|
||||
}
|
||||
result := expandPlaceholders("/repos/{owner}/{repo}/branches?owner={owner}", ctx)
|
||||
assert.Equal(t, "/repos/alice/proj/branches?owner=alice", result)
|
||||
})
|
||||
|
||||
t.Run("no placeholders returns unchanged", func(t *testing.T) {
|
||||
ctx := &context.TeaContext{
|
||||
Owner: "alice",
|
||||
Repo: "proj",
|
||||
}
|
||||
result := expandPlaceholders("/api/v1/version", ctx)
|
||||
assert.Equal(t, "/api/v1/version", result)
|
||||
})
|
||||
|
||||
t.Run("empty owner and repo produce empty replacements", func(t *testing.T) {
|
||||
ctx := &context.TeaContext{}
|
||||
result := expandPlaceholders("/repos/{owner}/{repo}", ctx)
|
||||
assert.Equal(t, "/repos//", result)
|
||||
})
|
||||
|
||||
t.Run("branch left unreplaced when no local repo", func(t *testing.T) {
|
||||
ctx := &context.TeaContext{
|
||||
Owner: "alice",
|
||||
Repo: "proj",
|
||||
}
|
||||
result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx)
|
||||
assert.Equal(t, "/repos/alice/proj/branches/{branch}", result)
|
||||
})
|
||||
|
||||
t.Run("replaces branch from local repo HEAD", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(tmpDir, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create an initial commit so HEAD points to a branch.
|
||||
wt, err := repo.Worktree()
|
||||
require.NoError(t, err)
|
||||
tmpFile := filepath.Join(tmpDir, "init.txt")
|
||||
require.NoError(t, os.WriteFile(tmpFile, []byte("init"), 0o644))
|
||||
_, err = wt.Add("init.txt")
|
||||
require.NoError(t, err)
|
||||
_, err = wt.Commit("initial commit", &gogit.CommitOptions{
|
||||
Author: &object.Signature{Name: "test", Email: "test@test.com"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and checkout a feature branch.
|
||||
headRef, err := repo.Head()
|
||||
require.NoError(t, err)
|
||||
branchRef := plumbing.NewBranchReferenceName("feature/my-branch")
|
||||
ref := plumbing.NewHashReference(branchRef, headRef.Hash())
|
||||
require.NoError(t, repo.Storer.SetReference(ref))
|
||||
require.NoError(t, wt.Checkout(&gogit.CheckoutOptions{Branch: branchRef}))
|
||||
|
||||
ctx := &context.TeaContext{
|
||||
Owner: "alice",
|
||||
Repo: "proj",
|
||||
LocalRepo: &tea_git.TeaRepo{Repository: repo},
|
||||
}
|
||||
result := expandPlaceholders("/repos/{owner}/{repo}/branches/{branch}", ctx)
|
||||
assert.Equal(t, "/repos/alice/proj/branches/feature/my-branch", result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsTextContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
want bool
|
||||
}{
|
||||
{"empty string defaults to text", "", true},
|
||||
{"plain text", "text/plain", true},
|
||||
{"html", "text/html", true},
|
||||
{"json", "application/json", true},
|
||||
{"json with charset", "application/json; charset=utf-8", true},
|
||||
{"xml", "application/xml", true},
|
||||
{"javascript", "application/javascript", true},
|
||||
{"yaml", "application/yaml", true},
|
||||
{"toml", "application/toml", true},
|
||||
{"binary", "application/octet-stream", false},
|
||||
{"image", "image/png", false},
|
||||
{"pdf", "application/pdf", false},
|
||||
{"zip", "application/zip", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isTextContentType(tt.contentType)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/cmd/releases"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -27,20 +28,25 @@ var CmdReleaseAttachmentCreate = cli.Command{
|
||||
}
|
||||
|
||||
func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
if ctx.Args().Len() < 2 {
|
||||
return fmt.Errorf("No release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText)
|
||||
return fmt.Errorf("no release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText)
|
||||
}
|
||||
|
||||
tag := ctx.Args().First()
|
||||
if len(tag) == 0 {
|
||||
return fmt.Errorf("Release tag needed to create attachment")
|
||||
return fmt.Errorf("release tag needed to create attachment")
|
||||
}
|
||||
|
||||
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
|
||||
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/cmd/releases"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
@@ -32,17 +33,22 @@ var CmdReleaseAttachmentDelete = cli.Command{
|
||||
}
|
||||
|
||||
func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
if ctx.Args().Len() < 2 {
|
||||
return fmt.Errorf("No release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText)
|
||||
return fmt.Errorf("no release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText)
|
||||
}
|
||||
|
||||
tag := ctx.Args().First()
|
||||
if len(tag) == 0 {
|
||||
return fmt.Errorf("Release tag needed to delete attachment")
|
||||
return fmt.Errorf("release tag needed to delete attachment")
|
||||
}
|
||||
|
||||
if !ctx.Bool("confirm") {
|
||||
@@ -50,17 +56,25 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
|
||||
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, _, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: -1},
|
||||
var existing []*gitea.Attachment
|
||||
for page := 1; ; {
|
||||
page_attachments, resp, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing = append(existing, page_attachments...)
|
||||
if resp == nil || resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
for _, name := range ctx.Args().Slice()[1:] {
|
||||
var attachment *gitea.Attachment
|
||||
@@ -70,7 +84,7 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||
}
|
||||
}
|
||||
if attachment == nil {
|
||||
return fmt.Errorf("Release does not have attachment named '%s'", name)
|
||||
return fmt.Errorf("release does not have attachment named '%s'", name)
|
||||
}
|
||||
|
||||
_, err = client.DeleteReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, attachment.ID)
|
||||
@@ -81,21 +95,3 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getReleaseAttachmentByName(owner, repo string, release int64, name string, client *gitea.Client) (*gitea.Attachment, error) {
|
||||
al, _, err := client.ListReleaseAttachments(owner, repo, release, gitea.ListReleaseAttachmentsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: -1},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(al) == 0 {
|
||||
return nil, fmt.Errorf("Release does not have any attachments")
|
||||
}
|
||||
for _, a := range al {
|
||||
if a.Name == name {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Attachment does not exist")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/cmd/releases"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
|
||||
@@ -31,45 +32,31 @@ var CmdReleaseAttachmentList = cli.Command{
|
||||
|
||||
// RunReleaseAttachmentList list release attachments
|
||||
func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
tag := ctx.Args().First()
|
||||
if len(tag) == 0 {
|
||||
return fmt.Errorf("Release tag needed to list attachments")
|
||||
return fmt.Errorf("release tag needed to list attachments")
|
||||
}
|
||||
|
||||
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
|
||||
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attachments, _, err := ctx.Login.Client().ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.ReleaseAttachmentsList(attachments, ctx.Output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
|
||||
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
|
||||
ListOptions: gitea.ListOptions{Page: -1},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rl) == 0 {
|
||||
return nil, fmt.Errorf("Repo does not have any release")
|
||||
}
|
||||
for _, r := range rl {
|
||||
if r.TagName == tag {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Release tag does not exist")
|
||||
return print.ReleaseAttachmentsList(attachments, ctx.Output)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ var CmdBranches = cli.Command{
|
||||
&branches.CmdBranchesList,
|
||||
&branches.CmdBranchesProtect,
|
||||
&branches.CmdBranchesUnprotect,
|
||||
&branches.CmdBranchesRename,
|
||||
},
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
|
||||
@@ -38,8 +38,13 @@ var CmdBranchesList = cli.Command{
|
||||
|
||||
// RunBranchesList list branches
|
||||
func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
owner := ctx.Owner
|
||||
if ctx.IsSet("owner") {
|
||||
@@ -48,19 +53,16 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
|
||||
var branches []*gitea.Branch
|
||||
var protections []*gitea.BranchProtection
|
||||
var err error
|
||||
branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,6 +72,5 @@ func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
print.BranchesList(branches, protections, ctx.Output, fields)
|
||||
return nil
|
||||
return print.BranchesList(branches, protections, ctx.Output, fields)
|
||||
}
|
||||
|
||||
@@ -45,8 +45,13 @@ var CmdBranchesUnprotect = cli.Command{
|
||||
|
||||
// RunBranchesProtect function to protect/unprotect a list of branches
|
||||
func RunBranchesProtect(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cmd.Args().Present() {
|
||||
return fmt.Errorf("must specify at least one branch")
|
||||
|
||||
78
cmd/branches/rename.go
Normal file
78
cmd/branches/rename.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package branches
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdBranchesRenameFlags Flags for command rename
|
||||
var CmdBranchesRenameFlags = append([]cli.Flag{
|
||||
branchFieldsFlag,
|
||||
&flags.PaginationPageFlag,
|
||||
&flags.PaginationLimitFlag,
|
||||
}, flags.AllDefaultFlags...)
|
||||
|
||||
// CmdBranchesRename represents a sub command of branches to rename a branch
|
||||
var CmdBranchesRename = cli.Command{
|
||||
Name: "rename",
|
||||
Aliases: []string{"rn"},
|
||||
Usage: "Rename a branch",
|
||||
Description: `Rename a branch in a repository`,
|
||||
ArgsUsage: "<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
|
||||
}
|
||||
46
cmd/branches/rename_test.go
Normal file
46
cmd/branches/rename_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package branches
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBranchesRenameArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid args",
|
||||
args: []string{"main", "develop"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing both args",
|
||||
args: []string{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing new branch name",
|
||||
args: []string{"main"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
args: []string{"main", "develop", "extra"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateRenameArgs(tt.args)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateRenameArgs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
cmd/clone.go
15
cmd/clone.go
@@ -48,7 +48,10 @@ When a host is specified in the repo-slug, it will override the login specified
|
||||
}
|
||||
|
||||
func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
teaCmd := context.InitCommand(cmd)
|
||||
teaCmd, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := teaCmd.Args()
|
||||
if args.Len() < 1 {
|
||||
@@ -58,7 +61,7 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
|
||||
var (
|
||||
login *config.Login = teaCmd.Login
|
||||
owner string = teaCmd.Login.User
|
||||
owner string
|
||||
repo string
|
||||
)
|
||||
|
||||
@@ -73,9 +76,13 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
|
||||
owner, repo = utils.GetOwnerAndRepo(url.Path, login.User)
|
||||
if url.Host != "" {
|
||||
login = config.GetLoginByHost(url.Host)
|
||||
var lookupErr error
|
||||
login, lookupErr = config.GetLoginByHost(url.Host)
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
if login == nil {
|
||||
return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host)
|
||||
return fmt.Errorf("no login configured matching host '%s', run 'tea login add' first", url.Host)
|
||||
}
|
||||
debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host)
|
||||
}
|
||||
|
||||
41
cmd/cmd.go
41
cmd/cmd.go
@@ -6,23 +6,11 @@ package cmd // import "code.gitea.io/tea"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/modules/version"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// Version holds the current tea version
|
||||
// If the Version is moved to another package or name changed,
|
||||
// build flags in .goreleaser.yaml or Makefile need to be updated accordingly.
|
||||
var Version = "development"
|
||||
|
||||
// Tags holds the build tags used
|
||||
var Tags = ""
|
||||
|
||||
// SDK holds the sdk version from go.mod
|
||||
var SDK = ""
|
||||
|
||||
// App creates and returns a tea Command with all subcommands set
|
||||
// it was separated from main so docs can be generated for it
|
||||
func App() *cli.Command {
|
||||
@@ -34,7 +22,7 @@ func App() *cli.Command {
|
||||
Usage: "command line tool to interact with Gitea",
|
||||
Description: appDescription,
|
||||
CustomHelpTemplate: helpTemplate,
|
||||
Version: formatVersion(),
|
||||
Version: version.Format(),
|
||||
Commands: []*cli.Command{
|
||||
&CmdLogin,
|
||||
&CmdLogout,
|
||||
@@ -49,6 +37,8 @@ func App() *cli.Command {
|
||||
&CmdOrgs,
|
||||
&CmdRepos,
|
||||
&CmdBranches,
|
||||
&CmdActions,
|
||||
&CmdWebhooks,
|
||||
&CmdAddComment,
|
||||
|
||||
&CmdOpen,
|
||||
@@ -57,28 +47,13 @@ func App() *cli.Command {
|
||||
|
||||
&CmdAdmin,
|
||||
|
||||
&CmdApi,
|
||||
&CmdGenerateManPage,
|
||||
},
|
||||
EnableShellCompletion: true,
|
||||
}
|
||||
}
|
||||
|
||||
func formatVersion() string {
|
||||
version := fmt.Sprintf("Version: %s\tgolang: %s",
|
||||
bold(Version),
|
||||
strings.ReplaceAll(runtime.Version(), "go", ""))
|
||||
|
||||
if len(Tags) != 0 {
|
||||
version += fmt.Sprintf("\tbuilt with: %s", strings.Replace(Tags, " ", ", ", -1))
|
||||
}
|
||||
|
||||
if len(SDK) != 0 {
|
||||
version += fmt.Sprintf("\tgo-sdk: %s", SDK)
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on
|
||||
one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
|
||||
|
||||
@@ -88,7 +63,7 @@ upstream repo. tea assumes that local git state is published on the remote befor
|
||||
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
|
||||
`
|
||||
|
||||
var helpTemplate = bold(`
|
||||
var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", `
|
||||
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + `
|
||||
{{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}}
|
||||
|
||||
@@ -130,7 +105,3 @@ var helpTemplate = bold(`
|
||||
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea.
|
||||
More info about Gitea itself on https://about.gitea.com.
|
||||
`
|
||||
|
||||
func bold(t string) string {
|
||||
return fmt.Sprintf("\033[1m%s\033[0m", t)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
@@ -18,8 +19,7 @@ import (
|
||||
"code.gitea.io/tea/modules/theme"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"charm.land/huh/v2"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
@@ -36,12 +36,17 @@ var CmdAddComment = cli.Command{
|
||||
}
|
||||
|
||||
func runAddComment(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := ctx.Args()
|
||||
if args.Len() == 0 {
|
||||
return fmt.Errorf("Please specify issue / pr index")
|
||||
return fmt.Errorf("please specify issue / pr index")
|
||||
}
|
||||
|
||||
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||
|
||||
93
cmd/detail_json.go
Normal file
93
cmd/detail_json.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
type detailLabelData struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type detailCommentData struct {
|
||||
ID int64 `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Created time.Time `json:"created"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
type detailReviewData struct {
|
||||
ID int64 `json:"id"`
|
||||
Reviewer string `json:"reviewer"`
|
||||
State gitea.ReviewStateType `json:"state"`
|
||||
Body string `json:"body"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
func buildDetailLabels(labels []*gitea.Label) []detailLabelData {
|
||||
labelSlice := make([]detailLabelData, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
labelSlice = append(labelSlice, detailLabelData{
|
||||
Name: label.Name,
|
||||
Color: label.Color,
|
||||
Description: label.Description,
|
||||
})
|
||||
}
|
||||
return labelSlice
|
||||
}
|
||||
|
||||
func buildDetailAssignees(assignees []*gitea.User) []string {
|
||||
assigneeSlice := make([]string, 0, len(assignees))
|
||||
for _, assignee := range assignees {
|
||||
assigneeSlice = append(assigneeSlice, username(assignee))
|
||||
}
|
||||
return assigneeSlice
|
||||
}
|
||||
|
||||
func buildDetailComments(comments []*gitea.Comment) []detailCommentData {
|
||||
commentSlice := make([]detailCommentData, 0, len(comments))
|
||||
for _, comment := range comments {
|
||||
commentSlice = append(commentSlice, detailCommentData{
|
||||
ID: comment.ID,
|
||||
Author: username(comment.Poster),
|
||||
Body: comment.Body,
|
||||
Created: comment.Created,
|
||||
})
|
||||
}
|
||||
return commentSlice
|
||||
}
|
||||
|
||||
func buildDetailReviews(reviews []*gitea.PullReview) []detailReviewData {
|
||||
reviewSlice := make([]detailReviewData, 0, len(reviews))
|
||||
for _, review := range reviews {
|
||||
reviewSlice = append(reviewSlice, detailReviewData{
|
||||
ID: review.ID,
|
||||
Reviewer: username(review.Reviewer),
|
||||
State: review.State,
|
||||
Body: review.Body,
|
||||
Created: review.Submitted,
|
||||
})
|
||||
}
|
||||
return reviewSlice
|
||||
}
|
||||
|
||||
func username(user *gitea.User) string {
|
||||
if user == nil {
|
||||
return "ghost"
|
||||
}
|
||||
return user.UserName
|
||||
}
|
||||
|
||||
func writeIndentedJSON(w io.Writer, data any) error {
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", "\t")
|
||||
return encoder.Encode(data)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func (f CsvFlag) GetValues(cmd *cli.Command) ([]string, error) {
|
||||
if f.AvailableFields != nil && val != "" {
|
||||
for _, field := range selection {
|
||||
if !utils.Contains(f.AvailableFields, field) {
|
||||
return nil, fmt.Errorf("Invalid field '%s'", field)
|
||||
return nil, fmt.Errorf("invalid field '%s'", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package flags
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -39,16 +40,33 @@ var OutputFlag = cli.StringFlag{
|
||||
}
|
||||
|
||||
var (
|
||||
paging gitea.ListOptions
|
||||
// ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0).
|
||||
ErrPage = errors.New("page cannot be smaller than 1")
|
||||
// ErrLimit indicates that the provided limit value is invalid (negative).
|
||||
ErrLimit = errors.New("limit cannot be negative")
|
||||
)
|
||||
|
||||
// GetListOptions returns configured paging struct
|
||||
func GetListOptions() gitea.ListOptions {
|
||||
return paging
|
||||
const (
|
||||
defaultPageValue = 1
|
||||
defaultLimitValue = 30
|
||||
)
|
||||
|
||||
// GetListOptions returns list options derived from the active command.
|
||||
func GetListOptions(cmd *cli.Command) gitea.ListOptions {
|
||||
page := cmd.Int("page")
|
||||
if page == 0 {
|
||||
page = defaultPageValue
|
||||
}
|
||||
|
||||
pageSize := cmd.Int("limit")
|
||||
if pageSize == 0 {
|
||||
pageSize = defaultLimitValue
|
||||
}
|
||||
|
||||
return gitea.ListOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
// PaginationFlags provides all pagination related flags
|
||||
@@ -62,14 +80,13 @@ var PaginationPageFlag = cli.IntFlag{
|
||||
Name: "page",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "specify page",
|
||||
Value: 1,
|
||||
Value: defaultPageValue,
|
||||
Validator: func(i int) error {
|
||||
if i < 1 && i != -1 {
|
||||
return ErrPage
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Destination: &paging.Page,
|
||||
}
|
||||
|
||||
// PaginationLimitFlag provides flag for pagination options
|
||||
@@ -77,14 +94,13 @@ var PaginationLimitFlag = cli.IntFlag{
|
||||
Name: "limit",
|
||||
Aliases: []string{"lm"},
|
||||
Usage: "specify limit of items per page",
|
||||
Value: 30,
|
||||
Value: defaultLimitValue,
|
||||
Validator: func(i int) error {
|
||||
if i < 0 {
|
||||
return ErrLimit
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Destination: &paging.PageSize,
|
||||
}
|
||||
|
||||
// LoginOutputFlags defines login and output flags that should
|
||||
@@ -141,3 +157,34 @@ var NotificationStateFlag = NewCsvFlag(
|
||||
func FieldsFlag(availableFields, defaultFields []string) *CsvFlag {
|
||||
return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields)
|
||||
}
|
||||
|
||||
// ParseState parses a state string and returns the corresponding gitea.StateType
|
||||
func ParseState(stateStr string) (gitea.StateType, error) {
|
||||
switch stateStr {
|
||||
case "all":
|
||||
return gitea.StateAll, nil
|
||||
case "", "open":
|
||||
return gitea.StateOpen, nil
|
||||
case "closed":
|
||||
return gitea.StateClosed, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown state '%s'", stateStr)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseIssueKind parses a kind string and returns the corresponding gitea.IssueType.
|
||||
// If kindStr is empty, returns the provided defaultKind.
|
||||
func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueType, error) {
|
||||
switch kindStr {
|
||||
case "":
|
||||
return defaultKind, nil
|
||||
case "all":
|
||||
return gitea.IssueTypeAll, nil
|
||||
case "issue", "issues":
|
||||
return gitea.IssueTypeIssue, nil
|
||||
case "pull", "pulls", "pr":
|
||||
return gitea.IssueTypePull, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown kind '%s'", kindStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -78,8 +79,8 @@ func TestPaginationFlags(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestPaginationFailures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -123,3 +124,29 @@ func TestPaginationFailures(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetListOptionsDoesNotLeakBetweenCommands(t *testing.T) {
|
||||
var results []gitea.ListOptions
|
||||
|
||||
run := func(args []string) {
|
||||
t.Helper()
|
||||
|
||||
cmd := cli.Command{
|
||||
Name: "test-paging",
|
||||
Action: func(_ context.Context, cmd *cli.Command) error {
|
||||
results = append(results, GetListOptions(cmd))
|
||||
return nil
|
||||
},
|
||||
Flags: PaginationFlags,
|
||||
}
|
||||
|
||||
require.NoError(t, cmd.Run(context.Background(), args))
|
||||
}
|
||||
|
||||
run([]string{"test", "--page", "5", "--limit", "10"})
|
||||
run([]string{"test"})
|
||||
|
||||
require.Len(t, results, 2)
|
||||
assert.Equal(t, gitea.ListOptions{Page: 5, PageSize: 10}, results[0])
|
||||
assert.Equal(t, gitea.ListOptions{Page: defaultPageValue, PageSize: defaultLimitValue}, results[1])
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func GetIssuePRCreateFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, e
|
||||
}
|
||||
ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Milestone '%s' not found", milestoneName)
|
||||
return nil, fmt.Errorf("milestone '%s' not found", milestoneName)
|
||||
}
|
||||
opts.Milestone = ms.ID
|
||||
}
|
||||
|
||||
106
cmd/issues.go
106
cmd/issues.go
@@ -6,7 +6,10 @@ package cmd
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/cmd/issues"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/interact"
|
||||
@@ -16,6 +19,34 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type labelData = detailLabelData
|
||||
|
||||
type issueData struct {
|
||||
ID int64 `json:"id"`
|
||||
Index int64 `json:"index"`
|
||||
Title string `json:"title"`
|
||||
State gitea.StateType `json:"state"`
|
||||
Created time.Time `json:"created"`
|
||||
Labels []labelData `json:"labels"`
|
||||
User string `json:"user"`
|
||||
Body string `json:"body"`
|
||||
Assignees []string `json:"assignees"`
|
||||
URL string `json:"url"`
|
||||
ClosedAt *time.Time `json:"closedAt"`
|
||||
Comments []commentData `json:"comments"`
|
||||
}
|
||||
|
||||
type issueDetailClient interface {
|
||||
GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error)
|
||||
GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error)
|
||||
}
|
||||
|
||||
type issueCommentClient interface {
|
||||
ListIssueComments(owner, repo string, index int64, opt gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error)
|
||||
}
|
||||
|
||||
type commentData = detailCommentData
|
||||
|
||||
// CmdIssues represents to login a gitea server.
|
||||
var CmdIssues = cli.Command{
|
||||
Name: "issues",
|
||||
@@ -48,14 +79,35 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
|
||||
idx, err := utils.ArgToIndex(index)
|
||||
ctx, idx, err := resolveIssueDetailContext(cmd, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
return runIssueDetailWithClient(ctx, idx, ctx.Login.Client())
|
||||
}
|
||||
|
||||
func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) {
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if ctx.IsSet("owner") {
|
||||
ctx.Owner = ctx.String("owner")
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
idx, err := utils.ArgToIndex(index)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return ctx, idx, nil
|
||||
}
|
||||
|
||||
func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDetailClient) error {
|
||||
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -64,6 +116,14 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.IsSet("output") {
|
||||
switch ctx.String("output") {
|
||||
case "json":
|
||||
return runIssueDetailAsJSON(ctx, issue)
|
||||
}
|
||||
}
|
||||
|
||||
print.IssueDetails(issue, reactions)
|
||||
|
||||
if issue.Comments > 0 {
|
||||
@@ -75,3 +135,39 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error {
|
||||
return runIssueDetailAsJSONWithClient(ctx, issue, ctx.Login.Client())
|
||||
}
|
||||
|
||||
func runIssueDetailAsJSONWithClient(ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error {
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
|
||||
comments := []*gitea.Comment{}
|
||||
|
||||
if ctx.Bool("comments") {
|
||||
var err error
|
||||
comments, _, err = c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments))
|
||||
}
|
||||
|
||||
func buildIssueData(issue *gitea.Issue, comments []*gitea.Comment) issueData {
|
||||
return issueData{
|
||||
ID: issue.ID,
|
||||
Index: issue.Index,
|
||||
Title: issue.Title,
|
||||
State: issue.State,
|
||||
Created: issue.Created,
|
||||
User: username(issue.Poster),
|
||||
Body: issue.Body,
|
||||
Labels: buildDetailLabels(issue.Labels),
|
||||
Assignees: buildDetailAssignees(issue.Assignees),
|
||||
URL: issue.HTMLURL,
|
||||
ClosedAt: issue.Closed,
|
||||
Comments: buildDetailComments(comments),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ var CmdIssuesClose = cli.Command{
|
||||
Description: `Change state of one ore more issues to 'closed'`,
|
||||
ArgsUsage: "<issue index> [<issue index>...]",
|
||||
Action: func(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
var s = gitea.StateClosed
|
||||
s := gitea.StateClosed
|
||||
return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s})
|
||||
},
|
||||
Flags: flags.AllDefaultFlags,
|
||||
@@ -31,10 +31,15 @@ var CmdIssuesClose = cli.Command{
|
||||
|
||||
// editIssueState abstracts the arg parsing to edit the given issue
|
||||
func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Args().Len() == 0 {
|
||||
return fmt.Errorf(ctx.Command.ArgsUsage)
|
||||
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage)
|
||||
}
|
||||
|
||||
indices, err := utils.ArgsToIndices(ctx.Args().Slice())
|
||||
|
||||
@@ -26,10 +26,15 @@ var CmdIssuesCreate = cli.Command{
|
||||
}
|
||||
|
||||
func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.NumFlags() == 0 {
|
||||
if ctx.IsInteractiveMode() {
|
||||
err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo)
|
||||
if err != nil && !interact.IsQuitting(err) {
|
||||
return err
|
||||
|
||||
@@ -30,8 +30,13 @@ use an empty string (eg. --milestone "").`,
|
||||
}
|
||||
|
||||
func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !cmd.Args().Present() {
|
||||
return fmt.Errorf("must specify at least one issue index")
|
||||
@@ -49,7 +54,7 @@ func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error {
|
||||
|
||||
client := ctx.Login.Client()
|
||||
for _, opts.Index = range indices {
|
||||
if ctx.NumFlags() == 0 {
|
||||
if ctx.IsInteractiveMode() {
|
||||
var err error
|
||||
opts, err = interact.EditIssue(*ctx, opts.Index)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ package issues
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
@@ -34,33 +33,21 @@ var CmdIssuesList = cli.Command{
|
||||
|
||||
// RunIssuesList list issues
|
||||
func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
|
||||
state := gitea.StateOpen
|
||||
switch ctx.String("state") {
|
||||
case "all":
|
||||
state = gitea.StateAll
|
||||
case "", "open":
|
||||
state = gitea.StateOpen
|
||||
case "closed":
|
||||
state = gitea.StateClosed
|
||||
default:
|
||||
return fmt.Errorf("unknown state '%s'", ctx.String("state"))
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind := gitea.IssueTypeIssue
|
||||
switch ctx.String("kind") {
|
||||
case "", "issues", "issue":
|
||||
kind = gitea.IssueTypeIssue
|
||||
case "pulls", "pull", "pr":
|
||||
kind = gitea.IssueTypePull
|
||||
case "all":
|
||||
kind = gitea.IssueTypeAll
|
||||
default:
|
||||
return fmt.Errorf("unknown kind '%s'", ctx.String("kind"))
|
||||
state, err := flags.ParseState(ctx.String("state"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeIssue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
var from, until time.Time
|
||||
if ctx.IsSet("from") {
|
||||
from, err = dateparse.ParseLocal(ctx.String("from"))
|
||||
@@ -85,7 +72,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
var issues []*gitea.Issue
|
||||
if ctx.Repo != "" {
|
||||
issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
State: state,
|
||||
Type: kind,
|
||||
KeyWord: ctx.String("keyword"),
|
||||
@@ -97,13 +84,12 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
Since: from,
|
||||
Before: until,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
State: state,
|
||||
Type: kind,
|
||||
KeyWord: ctx.String("keyword"),
|
||||
@@ -116,7 +102,6 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
Before: until,
|
||||
Owner: owner,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,6 +112,5 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
print.IssuesPullsList(issues, ctx.Output, fields)
|
||||
return nil
|
||||
return print.IssuesPullsList(issues, ctx.Output, fields)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ var CmdIssuesReopen = cli.Command{
|
||||
Description: `Change state of one or more issues to 'open'`,
|
||||
ArgsUsage: "<issue index> [<issue index>...]",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
var s = gitea.StateOpen
|
||||
s := gitea.StateOpen
|
||||
return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s})
|
||||
},
|
||||
Flags: flags.AllDefaultFlags,
|
||||
|
||||
358
cmd/issues_test.go
Normal file
358
cmd/issues_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
testOwner = "testOwner"
|
||||
testRepo = "testRepo"
|
||||
)
|
||||
|
||||
type fakeIssueCommentClient struct {
|
||||
owner string
|
||||
repo string
|
||||
index int64
|
||||
comments []*gitea.Comment
|
||||
}
|
||||
|
||||
func (f *fakeIssueCommentClient) ListIssueComments(owner, repo string, index int64, _ gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) {
|
||||
f.owner = owner
|
||||
f.repo = repo
|
||||
f.index = index
|
||||
return f.comments, nil, nil
|
||||
}
|
||||
|
||||
type fakeIssueDetailClient struct {
|
||||
owner string
|
||||
repo string
|
||||
index int64
|
||||
issue *gitea.Issue
|
||||
reactions []*gitea.Reaction
|
||||
}
|
||||
|
||||
func (f *fakeIssueDetailClient) GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) {
|
||||
f.owner = owner
|
||||
f.repo = repo
|
||||
f.index = index
|
||||
return f.issue, nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeIssueDetailClient) GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) {
|
||||
f.owner = owner
|
||||
f.repo = repo
|
||||
f.index = index
|
||||
return f.reactions, nil, nil
|
||||
}
|
||||
|
||||
func toCommentPointers(comments []gitea.Comment) []*gitea.Comment {
|
||||
result := make([]*gitea.Comment, 0, len(comments))
|
||||
for i := range comments {
|
||||
comment := comments[i]
|
||||
result = append(result, &comment)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func createTestIssue(comments int, isClosed bool) gitea.Issue {
|
||||
issue := gitea.Issue{
|
||||
ID: 42,
|
||||
Index: 1,
|
||||
Title: "Test issue",
|
||||
State: gitea.StateOpen,
|
||||
Body: "This is a test",
|
||||
Created: time.Date(2025, 31, 10, 23, 59, 59, 999999999, time.UTC),
|
||||
Updated: time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC),
|
||||
Labels: []*gitea.Label{
|
||||
{
|
||||
Name: "example/Label1",
|
||||
Color: "very red",
|
||||
Description: "This is an example label",
|
||||
},
|
||||
{
|
||||
Name: "example/Label2",
|
||||
Color: "hardly red",
|
||||
Description: "This is another example label",
|
||||
},
|
||||
},
|
||||
Comments: comments,
|
||||
Poster: &gitea.User{
|
||||
UserName: "testUser",
|
||||
},
|
||||
Assignees: []*gitea.User{
|
||||
{UserName: "testUser"},
|
||||
{UserName: "testUser3"},
|
||||
},
|
||||
HTMLURL: "<space holder>",
|
||||
Closed: nil, // 2025-11-10T21:20:19Z
|
||||
}
|
||||
|
||||
if isClosed {
|
||||
closed := time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC)
|
||||
issue.Closed = &closed
|
||||
}
|
||||
|
||||
if isClosed {
|
||||
issue.State = gitea.StateClosed
|
||||
} else {
|
||||
issue.State = gitea.StateOpen
|
||||
}
|
||||
|
||||
return issue
|
||||
}
|
||||
|
||||
func createTestIssueComments(comments int) []gitea.Comment {
|
||||
baseID := 900
|
||||
var result []gitea.Comment
|
||||
|
||||
for commentID := 0; commentID < comments; commentID++ {
|
||||
result = append(result, gitea.Comment{
|
||||
ID: int64(baseID + commentID),
|
||||
Poster: &gitea.User{
|
||||
UserName: "Freddy",
|
||||
},
|
||||
Body: fmt.Sprintf("This is a test comment #%v", commentID),
|
||||
Created: time.Date(2025, 11, 3, 12, 0, 0, 0, time.UTC).
|
||||
Add(time.Duration(commentID) * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func TestRunIssueDetailAsJSON(t *testing.T) {
|
||||
type TestCase struct {
|
||||
name string
|
||||
issue gitea.Issue
|
||||
comments []gitea.Comment
|
||||
flagComments bool
|
||||
}
|
||||
|
||||
cmd := cli.Command{
|
||||
Name: "t",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "comments",
|
||||
Value: false,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Value: "json",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testContext := context.TeaContext{
|
||||
Owner: testOwner,
|
||||
Repo: testRepo,
|
||||
Login: &config.Login{
|
||||
Name: "testLogin",
|
||||
URL: "http://127.0.0.1:8081",
|
||||
},
|
||||
Command: &cmd,
|
||||
}
|
||||
|
||||
testCases := []TestCase{
|
||||
{
|
||||
name: "Simple issue with no comments, no comments requested",
|
||||
issue: createTestIssue(0, true),
|
||||
comments: []gitea.Comment{},
|
||||
flagComments: false,
|
||||
},
|
||||
{
|
||||
name: "Simple issue with no comments, comments requested",
|
||||
issue: createTestIssue(0, true),
|
||||
comments: []gitea.Comment{},
|
||||
flagComments: true,
|
||||
},
|
||||
{
|
||||
name: "Simple issue with comments, no comments requested",
|
||||
issue: createTestIssue(2, true),
|
||||
comments: createTestIssueComments(2),
|
||||
flagComments: false,
|
||||
},
|
||||
{
|
||||
name: "Simple issue with comments, comments requested",
|
||||
issue: createTestIssue(2, true),
|
||||
comments: createTestIssueComments(2),
|
||||
flagComments: true,
|
||||
},
|
||||
{
|
||||
name: "Simple issue with comments, comments requested, not closed",
|
||||
issue: createTestIssue(2, false),
|
||||
comments: createTestIssueComments(2),
|
||||
flagComments: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
client := &fakeIssueCommentClient{
|
||||
comments: toCommentPointers(testCase.comments),
|
||||
}
|
||||
|
||||
testContext.Login.URL = "https://gitea.example.com"
|
||||
testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index)
|
||||
|
||||
var outBuffer bytes.Buffer
|
||||
testContext.Writer = &outBuffer
|
||||
var errBuffer bytes.Buffer
|
||||
testContext.ErrWriter = &errBuffer
|
||||
|
||||
if testCase.flagComments {
|
||||
require.NoError(t, testContext.Set("comments", "true"))
|
||||
} else {
|
||||
require.NoError(t, testContext.Set("comments", "false"))
|
||||
}
|
||||
|
||||
err := runIssueDetailAsJSONWithClient(&testContext, &testCase.issue, client)
|
||||
|
||||
require.NoError(t, err, "Failed to run issue detail as JSON")
|
||||
if testCase.flagComments {
|
||||
assert.Equal(t, testOwner, client.owner)
|
||||
assert.Equal(t, testRepo, client.repo)
|
||||
assert.Equal(t, testCase.issue.Index, client.index)
|
||||
}
|
||||
|
||||
out := outBuffer.String()
|
||||
|
||||
require.NotEmpty(t, out, "Unexpected empty output from runIssueDetailAsJSON")
|
||||
|
||||
// setting expectations
|
||||
|
||||
var expectedLabels []labelData
|
||||
expectedLabels = []labelData{}
|
||||
for _, l := range testCase.issue.Labels {
|
||||
expectedLabels = append(expectedLabels, labelData{
|
||||
Name: l.Name,
|
||||
Color: l.Color,
|
||||
Description: l.Description,
|
||||
})
|
||||
}
|
||||
|
||||
var expectedAssignees []string
|
||||
expectedAssignees = []string{}
|
||||
for _, a := range testCase.issue.Assignees {
|
||||
expectedAssignees = append(expectedAssignees, a.UserName)
|
||||
}
|
||||
|
||||
var expectedClosedAt *time.Time
|
||||
if testCase.issue.Closed != nil {
|
||||
expectedClosedAt = testCase.issue.Closed
|
||||
}
|
||||
|
||||
var expectedComments []commentData
|
||||
expectedComments = []commentData{}
|
||||
if testCase.flagComments {
|
||||
for _, c := range testCase.comments {
|
||||
expectedComments = append(expectedComments, commentData{
|
||||
ID: c.ID,
|
||||
Author: c.Poster.UserName,
|
||||
Body: c.Body,
|
||||
Created: c.Created,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
expected := issueData{
|
||||
ID: testCase.issue.ID,
|
||||
Index: testCase.issue.Index,
|
||||
Title: testCase.issue.Title,
|
||||
State: testCase.issue.State,
|
||||
Created: testCase.issue.Created,
|
||||
User: testCase.issue.Poster.UserName,
|
||||
Body: testCase.issue.Body,
|
||||
URL: testCase.issue.HTMLURL,
|
||||
ClosedAt: expectedClosedAt,
|
||||
Labels: expectedLabels,
|
||||
Assignees: expectedAssignees,
|
||||
Comments: expectedComments,
|
||||
}
|
||||
|
||||
// validating reality
|
||||
var actual issueData
|
||||
dec := json.NewDecoder(bytes.NewReader(outBuffer.Bytes()))
|
||||
dec.DisallowUnknownFields()
|
||||
err = dec.Decode(&actual)
|
||||
require.NoError(t, err, "Failed to unmarshal output into struct")
|
||||
|
||||
assert.Equal(t, expected, actual, "Expected structs differ from expected one")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIssueDetailUsesOwnerFlag(t *testing.T) {
|
||||
issueIndex := int64(12)
|
||||
expectedOwner := "overrideOwner"
|
||||
expectedRepo := "overrideRepo"
|
||||
issue := &gitea.Issue{
|
||||
ID: 99,
|
||||
Index: issueIndex,
|
||||
Title: "Owner override test",
|
||||
State: gitea.StateOpen,
|
||||
Created: time.Date(2025, 11, 1, 10, 0, 0, 0, time.UTC),
|
||||
Poster: &gitea.User{
|
||||
UserName: "tester",
|
||||
},
|
||||
HTMLURL: "https://example.test/issues/12",
|
||||
}
|
||||
|
||||
config.SetConfigForTesting(config.LocalConfig{
|
||||
Logins: []config.Login{{
|
||||
Name: "testLogin",
|
||||
URL: "https://gitea.example.com",
|
||||
Token: "token",
|
||||
User: "loginUser",
|
||||
Default: true,
|
||||
}},
|
||||
})
|
||||
|
||||
cmd := cli.Command{
|
||||
Name: "issues",
|
||||
Flags: []cli.Flag{
|
||||
&flags.LoginFlag,
|
||||
&flags.RepoFlag,
|
||||
&flags.RemoteFlag,
|
||||
&flags.OutputFlag,
|
||||
&cli.StringFlag{Name: "owner"},
|
||||
&cli.BoolFlag{Name: "comments"},
|
||||
},
|
||||
}
|
||||
var outBuffer bytes.Buffer
|
||||
var errBuffer bytes.Buffer
|
||||
cmd.Writer = &outBuffer
|
||||
cmd.ErrWriter = &errBuffer
|
||||
require.NoError(t, cmd.Set("login", "testLogin"))
|
||||
require.NoError(t, cmd.Set("repo", expectedRepo))
|
||||
require.NoError(t, cmd.Set("owner", expectedOwner))
|
||||
require.NoError(t, cmd.Set("comments", "false"))
|
||||
|
||||
teaCtx, idx, err := resolveIssueDetailContext(&cmd, fmt.Sprintf("%d", issueIndex))
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &fakeIssueDetailClient{
|
||||
issue: issue,
|
||||
reactions: []*gitea.Reaction{},
|
||||
}
|
||||
|
||||
err = runIssueDetailWithClient(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)
|
||||
}
|
||||
@@ -37,5 +37,5 @@ func runLabels(ctx context.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
func runLabelsDetails(cmd *cli.Command) error {
|
||||
return fmt.Errorf("Not yet implemented")
|
||||
return fmt.Errorf("not yet implemented")
|
||||
}
|
||||
|
||||
@@ -46,18 +46,24 @@ var CmdLabelCreate = cli.Command{
|
||||
}
|
||||
|
||||
func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labelFile := ctx.String("file")
|
||||
var err error
|
||||
if len(labelFile) == 0 {
|
||||
_, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
|
||||
_, _, err := ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
|
||||
Name: ctx.String("name"),
|
||||
Color: ctx.String("color"),
|
||||
Description: ctx.String("description"),
|
||||
})
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(labelFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -65,7 +71,7 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
var i = 1
|
||||
i := 1
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
color, name, description := splitLabelLine(line)
|
||||
@@ -77,13 +83,15 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
Color: color,
|
||||
Description: description,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitLabelLine(line string) (string, string, string) {
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestParseLabelLine(t *testing.T) {
|
||||
`
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(labels))
|
||||
var i = 1
|
||||
i := 1
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
color, name, description := splitLabelLine(line)
|
||||
|
||||
@@ -5,6 +5,7 @@ package labels
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
@@ -21,17 +22,37 @@ var CmdLabelDelete = cli.Command{
|
||||
ArgsUsage: " ", // command does not accept arguments
|
||||
Action: runLabelDelete,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.IntFlag{
|
||||
&cli.Int64Flag{
|
||||
Name: "id",
|
||||
Usage: "label id",
|
||||
Required: true,
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
}
|
||||
|
||||
func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
|
||||
_, err := ctx.Login.Client().DeleteLabel(ctx.Owner, ctx.Repo, ctx.Int64("id"))
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
labelID := ctx.Int64("id")
|
||||
client := ctx.Login.Client()
|
||||
|
||||
// Verify the label exists first
|
||||
label, _, err := client.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
|
||||
}
|
||||
|
||||
@@ -36,12 +36,17 @@ var CmdLabelsList = cli.Command{
|
||||
|
||||
// RunLabelsList list labels.
|
||||
func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := ctx.Login.Client()
|
||||
labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -51,6 +56,5 @@ func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return task.LabelsExport(labels, ctx.String("save"))
|
||||
}
|
||||
|
||||
print.LabelsList(labels, ctx.Output)
|
||||
return nil
|
||||
return print.LabelsList(labels, ctx.Output)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ var CmdLabelUpdate = cli.Command{
|
||||
ArgsUsage: " ", // command does not accept arguments
|
||||
Action: runLabelUpdate,
|
||||
Flags: append([]cli.Flag{
|
||||
&cli.IntFlag{
|
||||
&cli.Int64Flag{
|
||||
Name: "id",
|
||||
Usage: "label id",
|
||||
},
|
||||
@@ -41,8 +41,13 @@ var CmdLabelUpdate = cli.Command{
|
||||
}
|
||||
|
||||
func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id := ctx.Int64("id")
|
||||
var pName, pColor, pDescription *string
|
||||
@@ -61,13 +66,11 @@ func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
pDescription = &description
|
||||
}
|
||||
|
||||
var err error
|
||||
_, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{
|
||||
Name: pName,
|
||||
Color: pColor,
|
||||
Description: pDescription,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ func runLogins(ctx context.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
func runLoginDetail(name string) error {
|
||||
l := config.GetLoginByName(name)
|
||||
l, err := config.GetLoginByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if l == nil {
|
||||
fmt.Printf("Login '%s' do not exist\n\n", name)
|
||||
return nil
|
||||
|
||||
@@ -5,8 +5,7 @@ package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/tea/modules/config"
|
||||
|
||||
@@ -27,7 +26,7 @@ var CmdLoginDelete = cli.Command{
|
||||
func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
|
||||
logins, err := config.GetLogins()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
var name string
|
||||
@@ -37,7 +36,7 @@ func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
|
||||
} else if len(logins) == 1 {
|
||||
name = logins[0].Name
|
||||
} else {
|
||||
return errors.New("Please specify a login name")
|
||||
return fmt.Errorf("please specify a login name")
|
||||
}
|
||||
|
||||
return config.DeleteLogin(name)
|
||||
|
||||
@@ -5,7 +5,6 @@ package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
@@ -34,7 +33,7 @@ func runLoginEdit(_ context.Context, _ *cli.Command) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
return open.Start(config.GetConfigPath())
|
||||
|
||||
@@ -7,13 +7,10 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/tea/modules/auth"
|
||||
"code.gitea.io/tea/modules/config"
|
||||
"code.gitea.io/tea/modules/task"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -59,6 +56,13 @@ var CmdLoginHelper = cli.Command{
|
||||
{
|
||||
Name: "get",
|
||||
Description: "Get token to auth",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "login",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Use a specific login",
|
||||
},
|
||||
},
|
||||
Action: func(_ context.Context, cmd *cli.Command) error {
|
||||
wants := map[string]string{}
|
||||
s := bufio.NewScanner(os.Stdin)
|
||||
@@ -88,16 +92,35 @@ var CmdLoginHelper = cli.Command{
|
||||
}
|
||||
|
||||
if len(wants["host"]) == 0 {
|
||||
log.Fatal("Require hostname")
|
||||
return fmt.Errorf("hostname is required")
|
||||
} else if len(wants["protocol"]) == 0 {
|
||||
wants["protocol"] = "http"
|
||||
}
|
||||
|
||||
userConfig := config.GetLoginByHost(wants["host"])
|
||||
// Use --login flag if provided, otherwise fall back to host lookup
|
||||
var userConfig *config.Login
|
||||
if loginName := cmd.String("login"); loginName != "" {
|
||||
var lookupErr error
|
||||
userConfig, lookupErr = config.GetLoginByName(loginName)
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
if userConfig == nil {
|
||||
log.Fatal("host not exists")
|
||||
} else if len(userConfig.Token) == 0 {
|
||||
log.Fatal("User no set")
|
||||
return fmt.Errorf("login '%s' not found", loginName)
|
||||
}
|
||||
} else {
|
||||
var lookupErr error
|
||||
userConfig, lookupErr = config.GetLoginByHost(wants["host"])
|
||||
if lookupErr != nil {
|
||||
return lookupErr
|
||||
}
|
||||
if userConfig == nil {
|
||||
return fmt.Errorf("no login found for host '%s'", wants["host"])
|
||||
}
|
||||
}
|
||||
|
||||
if len(userConfig.GetAccessToken()) == 0 {
|
||||
return fmt.Errorf("user not set")
|
||||
}
|
||||
|
||||
host, err := url.Parse(userConfig.URL)
|
||||
@@ -105,21 +128,12 @@ var CmdLoginHelper = cli.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
if userConfig.TokenExpiry > 0 && time.Now().Unix() > userConfig.TokenExpiry {
|
||||
// Token is expired, refresh it
|
||||
err = auth.RefreshAccessToken(userConfig)
|
||||
if err != nil {
|
||||
// Refresh token if expired or near expiry (updates userConfig in place)
|
||||
if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Once token is refreshed, get the latest from the updated config
|
||||
refreshedConfig := config.GetLoginByHost(wants["host"])
|
||||
if refreshedConfig != nil {
|
||||
userConfig = refreshedConfig
|
||||
}
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token)
|
||||
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.GetAccessToken())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,6 +30,5 @@ func RunLoginList(_ context.Context, cmd *cli.Command) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
print.LoginsList(logins, cmd.String("output"))
|
||||
return nil
|
||||
return print.LoginsList(logins, cmd.String("output"))
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
var CmdLoginOAuthRefresh = cli.Command{
|
||||
Name: "oauth-refresh",
|
||||
Usage: "Refresh an OAuth token",
|
||||
Description: "Manually refresh an expired OAuth token. Usually only used when troubleshooting authentication.",
|
||||
Description: "Manually refresh an expired OAuth token. If the refresh token is also expired, opens a browser for re-authentication.",
|
||||
ArgsUsage: "[<login name>]",
|
||||
Action: runLoginOAuthRefresh,
|
||||
}
|
||||
@@ -38,22 +38,34 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
// Get the login from config
|
||||
login := config.GetLoginByName(loginName)
|
||||
login, err := config.GetLoginByName(loginName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if login == nil {
|
||||
return fmt.Errorf("login '%s' not found", loginName)
|
||||
}
|
||||
|
||||
// Check if the login has a refresh token
|
||||
if login.RefreshToken == "" {
|
||||
if login.GetRefreshToken() == "" {
|
||||
return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName)
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
err := auth.RefreshAccessToken(login)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to refresh token: %s", err)
|
||||
}
|
||||
|
||||
// Try to refresh the token
|
||||
err = auth.RefreshAccessToken(login)
|
||||
if err == nil {
|
||||
fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -40,8 +40,13 @@ func runMilestones(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
func runMilestoneDetail(_ stdctx.Context, cmd *cli.Command, name string) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name)
|
||||
|
||||
@@ -50,7 +50,10 @@ var CmdMilestonesCreate = cli.Command{
|
||||
}
|
||||
|
||||
func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
date := ctx.String("deadline")
|
||||
deadline := &time.Time{}
|
||||
@@ -67,7 +70,7 @@ func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
state = gitea.StateClosed
|
||||
}
|
||||
|
||||
if ctx.NumFlags() == 0 {
|
||||
if ctx.IsInteractiveMode() {
|
||||
if err := interact.CreateMilestone(ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -24,10 +24,15 @@ var CmdMilestonesDelete = cli.Command{
|
||||
}
|
||||
|
||||
func deleteMilestone(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
client := ctx.Login.Client()
|
||||
|
||||
_, err := client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First())
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
_, err = client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -71,39 +71,38 @@ var CmdMilestoneRemoveIssue = cli.Command{
|
||||
}
|
||||
|
||||
func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
state := gitea.StateOpen
|
||||
switch ctx.String("state") {
|
||||
case "all":
|
||||
state = gitea.StateAll
|
||||
case "closed":
|
||||
state = gitea.StateClosed
|
||||
state, err := flags.ParseState(ctx.String("state"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kind := gitea.IssueTypeAll
|
||||
switch ctx.String("kind") {
|
||||
case "issue":
|
||||
kind = gitea.IssueTypeIssue
|
||||
case "pull":
|
||||
kind = gitea.IssueTypePull
|
||||
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Args().Len() != 1 {
|
||||
return fmt.Errorf("Must specify milestone name")
|
||||
return fmt.Errorf("milestone name is required")
|
||||
}
|
||||
|
||||
milestone := ctx.Args().First()
|
||||
// make sure milestone exist
|
||||
_, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone)
|
||||
_, _, err = client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
Milestones: []string{milestone},
|
||||
Type: kind,
|
||||
State: state,
|
||||
@@ -116,13 +115,17 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
print.IssuesPullsList(issues, ctx.Output, fields)
|
||||
return nil
|
||||
return print.IssuesPullsList(issues, ctx.Output, fields)
|
||||
}
|
||||
|
||||
func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
if ctx.Args().Len() != 2 {
|
||||
return fmt.Errorf("need two arguments")
|
||||
@@ -138,18 +141,26 @@ func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
|
||||
// make sure milestone exist
|
||||
mile, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, mileName)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to get milestone '%s': %w", mileName, err)
|
||||
}
|
||||
|
||||
_, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{
|
||||
Milestone: &mile.ID,
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add issue #%d to milestone '%s': %w", idx, mileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
if ctx.Args().Len() != 2 {
|
||||
return fmt.Errorf("need two arguments")
|
||||
@@ -159,25 +170,28 @@ func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error {
|
||||
issueIndex := ctx.Args().Get(1)
|
||||
idx, err := utils.ArgToIndex(issueIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err)
|
||||
}
|
||||
|
||||
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to get issue #%d: %w", idx, err)
|
||||
}
|
||||
|
||||
if issue.Milestone == nil {
|
||||
return fmt.Errorf("issue is not assigned to a milestone")
|
||||
return fmt.Errorf("issue #%d is not assigned to a milestone", idx)
|
||||
}
|
||||
|
||||
if issue.Milestone.Title != mileName {
|
||||
return fmt.Errorf("issue is not assigned to this milestone")
|
||||
return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName)
|
||||
}
|
||||
|
||||
zero := int64(0)
|
||||
_, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{
|
||||
Milestone: &zero,
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove issue #%d from milestone '%s': %w", idx, mileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,35 +40,35 @@ var CmdMilestonesList = cli.Command{
|
||||
|
||||
// RunMilestonesList list milestones
|
||||
func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fields, err := fieldsFlag.GetValues(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state := gitea.StateOpen
|
||||
switch ctx.String("state") {
|
||||
case "all":
|
||||
state = gitea.StateAll
|
||||
if !cmd.IsSet("fields") { // add to default fields
|
||||
fields = append(fields, "state")
|
||||
state, err := flags.ParseState(ctx.String("state"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "closed":
|
||||
state = gitea.StateClosed
|
||||
if state == gitea.StateAll && !cmd.IsSet("fields") {
|
||||
fields = append(fields, "state")
|
||||
}
|
||||
|
||||
client := ctx.Login.Client()
|
||||
milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
State: state,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.MilestonesList(milestones, ctx.Output, fields)
|
||||
return nil
|
||||
return print.MilestonesList(milestones, ctx.Output, fields)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,15 @@ var CmdMilestonesReopen = cli.Command{
|
||||
}
|
||||
|
||||
func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Args().Len() == 0 {
|
||||
return fmt.Errorf(ctx.Command.ArgsUsage)
|
||||
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage)
|
||||
}
|
||||
|
||||
state := gitea.StateOpen
|
||||
@@ -41,6 +46,13 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
|
||||
}
|
||||
|
||||
client := ctx.Login.Client()
|
||||
repoURL := ""
|
||||
if ctx.Args().Len() > 1 {
|
||||
repoURL, err = ctx.GetRemoteRepoHTMLURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, ms := range ctx.Args().Slice() {
|
||||
opts := gitea.EditMilestoneOption{
|
||||
State: &state,
|
||||
@@ -52,7 +64,7 @@ func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
|
||||
}
|
||||
|
||||
if ctx.Args().Len() > 1 {
|
||||
fmt.Printf("%s/milestone/%d\n", ctx.GetRemoteRepoHTMLURL(), milestone.ID)
|
||||
fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID)
|
||||
} else {
|
||||
print.MilestoneDetails(milestone)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package notifications
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"log"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
@@ -64,12 +63,15 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
|
||||
var news []*gitea.NotificationThread
|
||||
var err error
|
||||
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
all := ctx.Bool("mine")
|
||||
|
||||
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
|
||||
listOpts := flags.GetListOptions()
|
||||
listOpts := flags.GetListOptions(cmd)
|
||||
if listOpts.Page == 0 {
|
||||
listOpts.Page = 1
|
||||
}
|
||||
@@ -91,7 +93,9 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
|
||||
SubjectTypes: subjects,
|
||||
})
|
||||
} else {
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
news, _, err = client.ListRepoNotifications(ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{
|
||||
ListOptions: listOpts,
|
||||
Status: status,
|
||||
@@ -99,9 +103,8 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
print.NotificationsList(news, ctx.Output, fields)
|
||||
return nil
|
||||
return print.NotificationsList(news, ctx.Output, fields)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ var CmdNotificationsMarkRead = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter, err := flags.NotificationStateFlag.GetValues(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -44,7 +47,10 @@ var CmdNotificationsMarkUnread = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter, err := flags.NotificationStateFlag.GetValues(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -65,7 +71,10 @@ var CmdNotificationsMarkPinned = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter, err := flags.NotificationStateFlag.GetValues(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -85,7 +94,10 @@ var CmdNotificationsUnpin = cli.Command{
|
||||
ArgsUsage: "[all | <notification id>]",
|
||||
Flags: flags.NotificationFlags,
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter := []string{string(gitea.NotifyStatusPinned)}
|
||||
// NOTE: we implicitly mark it as read, to match web UI semantics. marking as unread might be more useful?
|
||||
return markNotificationAs(ctx, filter, gitea.NotifyStatusRead)
|
||||
@@ -109,7 +121,9 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt
|
||||
if allRepos {
|
||||
_, _, err = client.ReadNotifications(opts)
|
||||
} else {
|
||||
cmd.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts)
|
||||
}
|
||||
|
||||
@@ -130,8 +144,12 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// FIXME: this is an API URL, we want to display a web ui link..
|
||||
fmt.Println(n.Subject.URL)
|
||||
// Use LatestCommentHTMLURL if available, otherwise fall back to HTMLURL
|
||||
if n.Subject.LatestCommentHTMLURL != "" {
|
||||
fmt.Println(n.Subject.LatestCommentHTMLURL)
|
||||
} else {
|
||||
fmt.Println(n.Subject.HTMLURL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
16
cmd/open.go
16
cmd/open.go
@@ -28,8 +28,13 @@ var CmdOpen = cli.Command{
|
||||
}
|
||||
|
||||
func runOpen(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var suffix string
|
||||
number := ctx.Args().Get(0)
|
||||
@@ -74,5 +79,10 @@ func runOpen(_ stdctx.Context, cmd *cli.Command) error {
|
||||
suffix = number
|
||||
}
|
||||
|
||||
return open.Run(path.Join(ctx.GetRemoteRepoHTMLURL(), suffix))
|
||||
repoURL, err := ctx.GetRemoteRepoHTMLURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return open.Run(path.Join(repoURL, suffix))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ var CmdOrgs = cli.Command{
|
||||
}
|
||||
|
||||
func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
teaCtx := context.InitCommand(cmd)
|
||||
teaCtx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if teaCtx.Args().Len() == 1 {
|
||||
return runOrganizationDetail(teaCtx)
|
||||
}
|
||||
|
||||
@@ -53,10 +53,13 @@ var CmdOrganizationCreate = cli.Command{
|
||||
|
||||
// RunOrganizationCreate sets up a new organization
|
||||
func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Args().Len() < 1 {
|
||||
return fmt.Errorf("You have to specify the organization name you want to create")
|
||||
return fmt.Errorf("organization name is required")
|
||||
}
|
||||
|
||||
var visibility gitea.VisibleType
|
||||
|
||||
@@ -28,17 +28,20 @@ var CmdOrganizationDelete = cli.Command{
|
||||
|
||||
// RunOrganizationDelete delete user organization
|
||||
func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := ctx.Login.Client()
|
||||
|
||||
if ctx.Args().Len() < 1 {
|
||||
return fmt.Errorf("You have to specify the organization name you want to delete")
|
||||
return fmt.Errorf("organization name is required")
|
||||
}
|
||||
|
||||
response, err := client.DeleteOrg(ctx.Args().First())
|
||||
if response != nil && response.StatusCode == 404 {
|
||||
return fmt.Errorf("The given organization does not exist")
|
||||
return fmt.Errorf("organization not found: %s", ctx.Args().First())
|
||||
}
|
||||
|
||||
return err
|
||||
|
||||
@@ -29,17 +29,18 @@ var CmdOrganizationList = cli.Command{
|
||||
|
||||
// RunOrganizationList list user organizations
|
||||
func RunOrganizationList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := ctx.Login.Client()
|
||||
|
||||
userOrganizations, _, err := client.ListUserOrgs(ctx.Login.User, gitea.ListOrgsOptions{
|
||||
ListOptions: flags.GetListOptions(),
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
print.OrganizationsList(userOrganizations, ctx.Output)
|
||||
|
||||
return nil
|
||||
return print.OrganizationsList(userOrganizations, ctx.Output)
|
||||
}
|
||||
|
||||
118
cmd/pulls.go
118
cmd/pulls.go
@@ -6,18 +6,50 @@ package cmd
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/cmd/pulls"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/interact"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
"code.gitea.io/tea/modules/workaround"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type pullLabelData = detailLabelData
|
||||
|
||||
type pullReviewData = detailReviewData
|
||||
|
||||
type pullCommentData = detailCommentData
|
||||
|
||||
type pullData struct {
|
||||
ID int64 `json:"id"`
|
||||
Index int64 `json:"index"`
|
||||
Title string `json:"title"`
|
||||
State gitea.StateType `json:"state"`
|
||||
Created *time.Time `json:"created"`
|
||||
Updated *time.Time `json:"updated"`
|
||||
Labels []pullLabelData `json:"labels"`
|
||||
User string `json:"user"`
|
||||
Body string `json:"body"`
|
||||
Assignees []string `json:"assignees"`
|
||||
URL string `json:"url"`
|
||||
Base string `json:"base"`
|
||||
Head string `json:"head"`
|
||||
HeadSha string `json:"headSha"`
|
||||
DiffURL string `json:"diffUrl"`
|
||||
Mergeable bool `json:"mergeable"`
|
||||
HasMerged bool `json:"hasMerged"`
|
||||
MergedAt *time.Time `json:"mergedAt"`
|
||||
MergedBy string `json:"mergedBy,omitempty"`
|
||||
ClosedAt *time.Time `json:"closedAt"`
|
||||
Reviews []pullReviewData `json:"reviews"`
|
||||
Comments []pullCommentData `json:"comments"`
|
||||
}
|
||||
|
||||
// CmdPulls is the main command to operate on PRs
|
||||
var CmdPulls = cli.Command{
|
||||
Name: "pulls",
|
||||
@@ -40,10 +72,14 @@ var CmdPulls = cli.Command{
|
||||
&pulls.CmdPullsCreate,
|
||||
&pulls.CmdPullsClose,
|
||||
&pulls.CmdPullsReopen,
|
||||
&pulls.CmdPullsEdit,
|
||||
&pulls.CmdPullsReview,
|
||||
&pulls.CmdPullsApprove,
|
||||
&pulls.CmdPullsReject,
|
||||
&pulls.CmdPullsMerge,
|
||||
&pulls.CmdPullsReviewComments,
|
||||
&pulls.CmdPullsResolve,
|
||||
&pulls.CmdPullsUnresolve,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,8 +91,13 @@ func runPulls(ctx stdctx.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
idx, err := utils.ArgToIndex(index)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -67,15 +108,28 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := workaround.FixPullHeadSha(client, pr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviews, _, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: -1},
|
||||
var reviews []*gitea.PullReview
|
||||
for page := 1; ; {
|
||||
page_reviews, resp, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("error while loading reviews: %v\n", err)
|
||||
break
|
||||
}
|
||||
reviews = append(reviews, page_reviews...)
|
||||
if resp == nil || resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
if ctx.IsSet("output") {
|
||||
switch ctx.String("output") {
|
||||
case "json":
|
||||
return runPullDetailAsJSON(ctx, pr, reviews)
|
||||
}
|
||||
}
|
||||
|
||||
ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha)
|
||||
@@ -94,3 +148,49 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPullDetailAsJSON(ctx *context.TeaContext, pr *gitea.PullRequest, reviews []*gitea.PullReview) error {
|
||||
c := ctx.Login.Client()
|
||||
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
|
||||
|
||||
mergedBy := ""
|
||||
if pr.MergedBy != nil {
|
||||
mergedBy = pr.MergedBy.UserName
|
||||
}
|
||||
|
||||
pullSlice := pullData{
|
||||
ID: pr.ID,
|
||||
Index: pr.Index,
|
||||
Title: pr.Title,
|
||||
State: pr.State,
|
||||
Created: pr.Created,
|
||||
Updated: pr.Updated,
|
||||
User: username(pr.Poster),
|
||||
Body: pr.Body,
|
||||
Labels: buildDetailLabels(pr.Labels),
|
||||
Assignees: buildDetailAssignees(pr.Assignees),
|
||||
URL: pr.HTMLURL,
|
||||
Base: pr.Base.Ref,
|
||||
Head: pr.Head.Ref,
|
||||
HeadSha: pr.Head.Sha,
|
||||
DiffURL: pr.DiffURL,
|
||||
Mergeable: pr.Mergeable,
|
||||
HasMerged: pr.HasMerged,
|
||||
MergedAt: pr.Merged,
|
||||
MergedBy: mergedBy,
|
||||
ClosedAt: pr.Closed,
|
||||
Reviews: buildDetailReviews(reviews),
|
||||
Comments: make([]pullCommentData, 0),
|
||||
}
|
||||
|
||||
if ctx.Bool("comments") {
|
||||
comments, _, err := c.ListIssueComments(ctx.Owner, ctx.Repo, pr.Index, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pullSlice.Comments = buildDetailComments(comments)
|
||||
}
|
||||
|
||||
return writeIndentedJSON(ctx.Writer, pullSlice)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,11 @@
|
||||
package pulls
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
stdctx "context"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/task"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
@@ -25,21 +20,11 @@ var CmdPullsApprove = cli.Command{
|
||||
Description: "Approve a pull request",
|
||||
ArgsUsage: "<pull index> [<comment>]",
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
|
||||
if ctx.Args().Len() == 0 {
|
||||
return fmt.Errorf("Must specify a PR index")
|
||||
}
|
||||
|
||||
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
comment := strings.Join(ctx.Args().Tail(), " ")
|
||||
|
||||
return task.CreatePullReview(ctx, idx, gitea.ReviewStateApproved, comment, nil)
|
||||
return runPullReview(ctx, gitea.ReviewStateApproved, false)
|
||||
},
|
||||
Flags: flags.AllDefaultFlags,
|
||||
}
|
||||
|
||||
@@ -34,13 +34,18 @@ var CmdPullsCheckout = cli.Command{
|
||||
}
|
||||
|
||||
func runPullsCheckout(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{
|
||||
LocalRepo: true,
|
||||
RemoteRepo: true,
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Args().Len() != 1 {
|
||||
return fmt.Errorf("Must specify a PR index")
|
||||
return fmt.Errorf("pull request index is required")
|
||||
}
|
||||
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||
if err != nil {
|
||||
|
||||
@@ -32,10 +32,15 @@ var CmdPullsClean = cli.Command{
|
||||
}
|
||||
|
||||
func runPullsClean(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{LocalRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Args().Len() != 1 {
|
||||
return fmt.Errorf("Must specify a PR index")
|
||||
return fmt.Errorf("pull request index is required")
|
||||
}
|
||||
|
||||
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||
|
||||
@@ -19,7 +19,7 @@ var CmdPullsClose = cli.Command{
|
||||
Description: `Change state of one or more pull requests to 'closed'`,
|
||||
ArgsUsage: "<pull index> [<pull index>...]",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
var s = gitea.StateClosed
|
||||
s := gitea.StateClosed
|
||||
return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s})
|
||||
},
|
||||
Flags: flags.AllDefaultFlags,
|
||||
|
||||
@@ -37,14 +37,31 @@ var CmdPullsCreate = cli.Command{
|
||||
Usage: "Enable maintainers to push to the base branch of created pull",
|
||||
Value: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "agit",
|
||||
Usage: "Create an agit flow pull request",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "topic",
|
||||
Usage: "Topic name for agit flow pull request",
|
||||
},
|
||||
}, flags.IssuePRCreateFlags...),
|
||||
}
|
||||
|
||||
func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{
|
||||
LocalRepo: true,
|
||||
RemoteRepo: true,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// no args -> interactive mode
|
||||
if ctx.NumFlags() == 0 {
|
||||
if ctx.IsInteractiveMode() {
|
||||
if err := interact.CreatePull(ctx); err != nil && !interact.IsQuitting(err) {
|
||||
return err
|
||||
}
|
||||
@@ -57,6 +74,18 @@ func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Bool("agit") {
|
||||
return task.CreateAgitFlowPull(
|
||||
ctx,
|
||||
ctx.String("remote"),
|
||||
ctx.String("head"),
|
||||
ctx.String("base"),
|
||||
ctx.String("topic"),
|
||||
opts,
|
||||
interact.PromptPassword,
|
||||
)
|
||||
}
|
||||
|
||||
var allowMaintainerEdits *bool
|
||||
if ctx.IsSet("allow-maintainer-edits") {
|
||||
allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits"))
|
||||
|
||||
@@ -6,21 +6,97 @@ package pulls
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/print"
|
||||
"code.gitea.io/tea/modules/task"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
// CmdPullsEdit is the subcommand of pulls to edit pull requests
|
||||
var CmdPullsEdit = cli.Command{
|
||||
Name: "edit",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Edit one or more pull requests",
|
||||
Description: `Edit one or more pull requests. To unset a property again,
|
||||
use an empty string (eg. --milestone "").`,
|
||||
ArgsUsage: "<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
|
||||
func editPullState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditPullRequestOption) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Args().Len() == 0 {
|
||||
return fmt.Errorf("Please provide a Pull Request index")
|
||||
return fmt.Errorf("pull request index is required")
|
||||
}
|
||||
|
||||
indices, err := utils.ArgsToIndices(ctx.Args().Slice())
|
||||
|
||||
@@ -5,6 +5,8 @@ package pulls
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
@@ -30,23 +32,24 @@ var CmdPullsList = cli.Command{
|
||||
|
||||
// RunPullsList return list of pulls
|
||||
func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
|
||||
state := gitea.StateOpen
|
||||
switch ctx.String("state") {
|
||||
case "all":
|
||||
state = gitea.StateAll
|
||||
case "open":
|
||||
state = gitea.StateOpen
|
||||
case "closed":
|
||||
state = gitea.StateClosed
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{
|
||||
state, err := flags.ParseState(ctx.String("state"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := ctx.Login.Client()
|
||||
prs, _, err := client.ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{
|
||||
ListOptions: flags.GetListOptions(cmd),
|
||||
State: state,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,6 +59,21 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
print.PullsList(prs, ctx.Output, fields)
|
||||
return nil
|
||||
var ciStatuses map[int64]*gitea.CombinedStatus
|
||||
if slices.Contains(fields, "ci") {
|
||||
ciStatuses = map[int64]*gitea.CombinedStatus{}
|
||||
for _, pr := range prs {
|
||||
if pr.Head == nil || pr.Head.Sha == "" {
|
||||
continue
|
||||
}
|
||||
ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha)
|
||||
if err != nil {
|
||||
fmt.Printf("error fetching CI status for PR #%d: %v\n", pr.Index, err)
|
||||
continue
|
||||
}
|
||||
ciStatuses[pr.Index] = ci
|
||||
}
|
||||
}
|
||||
|
||||
return print.PullsList(prs, ctx.Output, fields, ciStatuses)
|
||||
}
|
||||
|
||||
@@ -41,8 +41,13 @@ var CmdPullsMerge = cli.Command{
|
||||
},
|
||||
}, flags.AllDefaultFlags...),
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ctx.Args().Len() != 1 {
|
||||
// If no PR index is provided, try interactive mode
|
||||
|
||||
@@ -5,15 +5,10 @@ package pulls
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"code.gitea.io/tea/modules/task"
|
||||
"code.gitea.io/tea/modules/utils"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"code.gitea.io/tea/cmd/flags"
|
||||
"code.gitea.io/tea/modules/context"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
@@ -24,21 +19,11 @@ var CmdPullsReject = cli.Command{
|
||||
Description: "Request changes to a pull request",
|
||||
ArgsUsage: "<pull index> <reason>",
|
||||
Action: func(_ stdctx.Context, cmd *cli.Command) error {
|
||||
ctx := context.InitCommand(cmd)
|
||||
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
|
||||
|
||||
if ctx.Args().Len() < 2 {
|
||||
return fmt.Errorf("Must specify a PR index and comment")
|
||||
}
|
||||
|
||||
idx, err := utils.ArgToIndex(ctx.Args().First())
|
||||
ctx, err := context.InitCommand(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
comment := strings.Join(ctx.Args().Tail(), " ")
|
||||
|
||||
return task.CreatePullReview(ctx, idx, gitea.ReviewStateRequestChanges, comment, nil)
|
||||
return runPullReview(ctx, gitea.ReviewStateRequestChanges, true)
|
||||
},
|
||||
Flags: flags.AllDefaultFlags,
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ var CmdPullsReopen = cli.Command{
|
||||
Description: `Change state of one or more pull requests to 'open'`,
|
||||
ArgsUsage: "<pull index> [<pull index>...]",
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
var s = gitea.StateOpen
|
||||
s := gitea.StateOpen
|
||||
return editPullState(ctx, cmd, gitea.EditPullRequestOption{State: &s})
|
||||
},
|
||||
Flags: flags.AllDefaultFlags,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user