1 Commits

Author SHA1 Message Date
6543
8372dad929 Changelog v0.4.1 (#176)
Changelog v0.4.1

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://gitea.com/gitea/tea/pulls/176
Reviewed-by: Andrew Thornton <art27@cantab.net>
Reviewed-by: Gary Kim <gary@garykim.dev>
2020-09-13 21:48:36 +00:00
1222 changed files with 300493 additions and 25187 deletions

View File

@@ -25,6 +25,8 @@ groups:
name: ENHANCEMENTS
labels:
- kind/enhancement
- kind/refactor
- kind/ui
-
name: SECURITY
labels:

View File

@@ -1,20 +0,0 @@
{
"name": "Tea DevContainer",
"image": "mcr.microsoft.com/devcontainers/go:2.1-trixie",
"features": {
"ghcr.io/devcontainers/features/git-lfs:1.2.5": {}
},
"customizations": {
"vscode": {
"settings": {},
"extensions": [
"editorconfig.editorconfig",
"golang.go",
"stylelint.vscode-stylelint",
"DavidAnson.vscode-markdownlint",
"ms-azuretools.vscode-docker",
"GitHub.vscode-pull-request-github"
]
}
}
}

View File

@@ -1,2 +0,0 @@
Dockerfile
tea

196
.drone.yml Normal file
View File

@@ -0,0 +1,196 @@
---
kind: pipeline
name: default
platform:
os: linux
arch: amd64
workspace:
base: /go
path: src/code.gitea.io/tea
steps:
- name: build
pull: always
image: golang:1.13
environment:
GOPROXY: https://goproxy.cn
commands:
- make clean
- make vet
- make lint
- make fmt-check
- make misspell-check
- make test-vendor
- make build
when:
event:
- push
- tag
- pull_request
- name: unit-test
pull: always
image: golang:1.13
commands:
- make unit-test-coverage
settings:
group: test
when:
branch:
- master
event:
- push
- pull_request
- name: release-test
pull: always
image: golang:1.13
commands:
- make test
settings:
group: test
when:
branch:
- "release/*"
event:
- push
- pull_request
- name: tag-test
pull: always
image: golang:1.13
commands:
- make test
settings:
group: test
when:
event:
- tag
- name: static
pull: always
image: techknowlogick/xgo:latest
environment:
GOPROXY: https://goproxy.cn
commands:
- export PATH=$PATH:$GOPATH/bin
- make release
when:
event:
- push
- tag
- name: gpg-sign
pull: always
image: plugins/gpgsign:1
settings:
detach_sign: true
excludes:
- "dist/release/*.sha256"
files:
- "dist/release/*"
environment:
GPGSIGN_KEY:
from_secret: gpgsign_key
GPGSIGN_PASSPHRASE:
from_secret: gpgsign_passphrase
when:
event:
- push
- tag
- name: tag-release
pull: always
image: plugins/s3:1
settings:
acl: public-read
bucket: releases
endpoint: https://storage.gitea.io
path_style: true
source: "dist/release/*"
strip_prefix: dist/release/
target: "/tea/${DRONE_TAG##v}"
environment:
AWS_ACCESS_KEY_ID:
from_secret: aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: aws_secret_access_key
when:
event:
- tag
- name: release-branch-release
pull: always
image: plugins/s3:1
settings:
acl: public-read
bucket: releases
endpoint: https://storage.gitea.io
path_style: true
source: "dist/release/*"
strip_prefix: dist/release/
target: "/tea/${DRONE_BRANCH##release/v}"
environment:
AWS_ACCESS_KEY_ID:
from_secret: aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: aws_secret_access_key
when:
branch:
- "release/*"
event:
- push
- name: release
pull: always
image: plugins/s3:1
settings:
acl: public-read
bucket: releases
endpoint: https://storage.gitea.io
path_style: true
source: "dist/release/*"
strip_prefix: dist/release/
target: /tea/master
environment:
AWS_ACCESS_KEY_ID:
from_secret: aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: aws_secret_access_key
when:
branch:
- master
event:
- push
- name: gitea
pull: always
image: plugins/gitea-release:1
settings:
files:
- "dist/release/*"
base_url: https://gitea.com
api_key:
from_secret: gitea_token
when:
event:
- tag
- name: discord
pull: always
image: appleboy/drone-discord:1.0.0
environment:
DISCORD_WEBHOOK_ID:
from_secret: discord_webhook_id
DISCORD_WEBHOOK_TOKEN:
from_secret: discord_webhook_token
when:
event:
- push
- tag
- pull_request
status:
- changed
- failure

1
.envrc
View File

@@ -1 +0,0 @@
use flake

View File

@@ -1,30 +0,0 @@
---
name: "Bug Report"
about: "Use this template when reporting a bug, so you don't forget important information we'd ask for later."
title: "Bug: "
labels:
- kind/bug
---
### describe your environment
- tea version used (`tea -v`):
- [ ] I also reproduced the issue [with the latest main build](https://dl.gitea.com/tea/main/)
- Gitea version used:
- [ ] the issue only occurred after updating gitea recently
- operating system:
- I make use of...
- [ ] non-standard default branch names (no `main`,`master`, or `trunk`)
- [ ] .ssh/config or .gitconfig host aliases in my git remotes
- [ ] ssh_agent or similar
- [ ] non-standard ports for gitea and/or ssh
- [ ] something else that's likely to interact badly with tea: ...
Please provide the output of `git remote -v` (if the issue is related to tea not finding resources on Gitea):
```
```
### describe the issue (observed vs expected behaviour)

View File

@@ -1,80 +0,0 @@
name: goreleaser
on:
push:
branches: [ main ]
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: import gpg
id: import_gpg
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@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 }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
GORELEASER_FORCE_TOKEN: 'gitea'
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: gitea
DOCKER_LATEST: nightly
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v7
env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
gitea/tea:latest

View File

@@ -1,85 +0,0 @@
name: goreleaser
on:
push:
tags:
- '*'
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: import gpg
id: import_gpg
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@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 }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
GORELEASER_FORCE_TOKEN: 'gitea'
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: gitea
DOCKER_LATEST: nightly
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get tag version without v
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@v7
env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
linux/arm64
push: true
tags: |
gitea/tea:${{ env.VERSION }}

View File

@@ -1,63 +0,0 @@
name: check-and-test
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'
check-and-test:
runs-on: ubuntu-latest
env:
HTTP_PROXY: ""
GITEA_TEA_TEST_URL: "http://gitea:3000"
GITEA_TEA_TEST_USERNAME: "test01"
GITEA_TEA_TEST_PASSWORD: "test01"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: lint and build
run: |
make clean
make vet
make lint
make fmt-check
make docs-check
make build
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
- name: test and coverage
run: |
make test
make unit-test-coverage
services:
gitea:
image: docker.gitea.com/gitea:1.25.5
cmd:
- bash
- -c
- >-
mkdir -p /tmp/conf/
&& mkdir -p /tmp/data/
&& echo "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT = true" > /tmp/conf/app.ini
&& echo "[security]" >> /tmp/conf/app.ini
&& echo "INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE1NTg4MzY4ODB9.LoKQyK5TN_0kMJFVHWUW0uDAyoGjDP6Mkup4ps2VJN4" >> /tmp/conf/app.ini
&& echo "INSTALL_LOCK = true" >> /tmp/conf/app.ini
&& echo "SECRET_KEY = 2crAW4UANgvLipDS6U5obRcFosjSJHQANll6MNfX7P0G3se3fKcCwwK3szPyGcbo" >> /tmp/conf/app.ini
&& echo "PASSWORD_COMPLEXITY = off" >> /tmp/conf/app.ini
&& echo "[database]" >> /tmp/conf/app.ini
&& echo "DB_TYPE = sqlite3" >> /tmp/conf/app.ini
&& echo "[repository]" >> /tmp/conf/app.ini
&& echo "ROOT = /tmp/data/" >> /tmp/conf/app.ini
&& echo "[server]" >> /tmp/conf/app.ini
&& echo "ROOT_URL = http://gitea:3000" >> /tmp/conf/app.ini
&& gitea migrate -c /tmp/conf/app.ini
&& gitea admin user create --username=test01 --password=test01 --email=test01@gitea.io --admin=true --must-change-password=false --access-token -c /tmp/conf/app.ini
&& gitea web -c /tmp/conf/app.ini

15
.gitignore vendored
View File

@@ -1,19 +1,6 @@
/tea
tea
/gitea-vet
/gitea-vet.exe
.idea/
.history/
dist/
.vscode/
vendor/
coverage.out
dist/
# Nix-specific
.direnv/
result
result-*

View File

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

View File

@@ -1,12 +0,0 @@
#!/bin/bash
set -e
if [ -z "$1" ]; then
echo "usage: $0 <path>"
exit 1
fi
SUM=$(shasum -a 256 "$1" | cut -d' ' -f1)
BASENAME=$(basename "$1")
echo -n "${SUM} ${BASENAME}" > "$1".sha256

View File

@@ -1,122 +0,0 @@
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
- freebsd
goarch:
- amd64
- arm
- arm64
goarm:
- "5"
- "6"
- "7"
ignore:
- goos: darwin
goarch: arm
- goos: darwin
goarch: ppc64le
- goos: darwin
goarch: s390x
- goos: windows
goarch: ppc64le
- goos: windows
goarch: s390x
- goos: windows
goarch: arm
goarm: "5"
- goos: windows
goarch: arm
goarm: "6"
- goos: windows
goarch: arm
goarm: "7"
- goos: freebsd
goarch: ppc64le
- goos: freebsd
goarch: s390x
- goos: freebsd
goarch: arm
goarm: "5"
- goos: freebsd
goarch: arm
goarm: "6"
- goos: freebsd
goarch: arm
goarm: "7"
- goos: freebsd
goarch: arm64
flags:
- -trimpath
ldflags:
- -s -w -X "code.gitea.io/tea/modules/version.Version={{ trimprefix .Summary "v" }}" -X "code.gitea.io/tea/modules/version.Tags=" -X "code.gitea.io/tea/modules/version.SDK={{ .Env.SDK_VERSION }}"
binary: >-
{{ .ProjectName }}-
{{- .Version }}-
{{- .Os }}-
{{- if eq .Arch "amd64" }}amd64
{{- else if eq .Arch "amd64_v1" }}amd64
{{- else if eq .Arch "386" }}386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}-{{ .Arm }}{{ end }}
no_unique_dist_dir: true
hooks:
post:
- cmd: xz -k -9 {{ .Path }}
dir: ./dist/
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
blobs:
-
provider: s3
bucket: "{{ .Env.S3_BUCKET }}"
region: "{{ .Env.S3_REGION }}"
folder: "tea/{{.Version}}"
extra_files:
- glob: ./**.xz
- glob: ./**.sha256
archives:
- format: binary
name_template: "{{ .Binary }}"
allow_different_binary_count: true
checksum:
name_template: 'checksums.txt'
extra_files:
- glob: ./**.xz
force_token: gitea
signs:
-
signature: "${artifact}.sig"
artifacts: checksum
stdin: '{{ .Env.GPGSIGN_PASSPHRASE }}'
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
snapshot:
name_template: "{{ .Branch }}-devel"
nightly:
name_template: "{{ .Branch }}"
gitea_urls:
api: https://gitea.com/api/v1
download: https://gitea.com
release:
extra_files:
- glob: ./**.xz
- glob: ./**.xz.sha256
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj

View File

@@ -16,6 +16,7 @@ warningCode = 1
[rule.increment-decrement]
[rule.var-naming]
[rule.var-declaration]
[rule.package-comments]
[rule.range]
[rule.receiver-naming]
[rule.time-naming]

View File

@@ -1,182 +1,5 @@
# 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
* Print pull dont crash if it has TeamReviewRequests (#517)
## [v0.9.0](https://gitea.com/gitea/tea/releases/tag/v0.9.0) - 2022-09-13
* BREAKING
* Rename master branch to main (#495)
* Return RFC3339 UTC timestamps for machine-readable output (#470)
* FEATURES
* Allow editing multiline prompts with external text editor (#429)
* Add `tea admin user list` (#427)
* Add `tea whoami` command (#426)
* Add `tea org create <name>` (#420)
* Add `tea clone` (#411)
* Add `tea repo fork` (#410)
* Add `tea repo create-from-template` (#408)
* BUGFIXES
* Fetch all items where needed. (#475)
* Fix running in repos without remote (#472)
* Add TSV to machine-readable formats (#467)
* Fix create milestone with deadline bug (#462)
* Fix resolving of URLs in markdown (#401)
* ENHANCEMENTS
* Don't emit ANSI sequences when not emitting to TTY for markdown (#491)
* Show more version info (#486)
* Add preference `flag_defaults.remote`, refactor (#466)
* Add `--fields` to notification & milestone listings (#422)
* PR listing: add --fields & expose additional fields (#415)
* Add more flags to `tea repo create` (#409)
* Implement more issue filters (#400)
* MISC
* Simplify build & update installation instructions (#437)
* Clarify command descriptions when no arguments are taken (#496)
* Improve Documentation (#433)
* Use golang v1.18 and drop vendor folder (#478)
* Correct spelling of "wether" to "whether" in usage output (#453)
## [v0.8.0](https://gitea.com/gitea/tea/releases/tag/v0.8.0) - 2021-09-22
* BREAKING
* `tea notifications --all` has moved to `tea notifications --mine` (#389)
* `tea notifications` now only works with the context of a remote repo. (#389)
To run this outside of a local git dir, run either tea n `--mine` or `tea n --repo <my/repo>`
* FEATURES
* Add `tea pr merge` (#348)
* BUGFIXES
* Don't skip reading the local repo when `--repo` specifies a repo slug (#398)
* Fix adding login without token on private instances (#392)
* Correctly match login by ssh host with port (#391)
* Fix printing issue deadline (#388)
* Return useful error on wrong sshkey path (#374)
* Fix parsing of `--description` for issue/pr create (#371)
* Add missing flags (#369)
* Check negative limit command parameter (#358) (#359)
* Add missing flags to org & labels subcommands (#357)
* ENHANCEMENTS
* Don't require a body for comment PR reviews (#399)
* Accept more main branch names for login detection (#396)
* Make local repo optional for `tea pr create`(#393)
* Notifications Add State Field (#384)
* Improve error messages (#370)
* Add tab completion for fish shell (#364)
* Text editor selection: follow unix defacto standards (#356)
* MISC
* Update Dependencies (#390)
## [v0.7.1](https://gitea.com/gitea/tea/releases/tag/v0.7.1) - 2021-08-27
* BUILD
* Enable release builds for darwin/arm64 (#360)
## [v0.7.0](https://gitea.com/gitea/tea/releases/tag/v0.7.0) - 2021-03-12
* BREAKING
* `tea issue create`: move `-b` flag to `-d` (#331)
* Drop `tea notif` shorthand in favor of `tea n` (#307)
* FEATURES
* Add commands for reviews (#315)
* Add `tea comment` and show comments of issues/pulls (#313)
* Add interactive mode for `tea milestone create` (#310)
* Add command to install shell completion (#309)
* Implement PR closing and reopening (#304)
* Add interactive mode for `tea issue create` (#302)
* BUGFIXES
* Introduce workaround for missing pull head sha (#340)
* Don't exit if we can't find a local repo with a remote matching to a login (#336)
* Don't push before creating a pull (#334)
* InitCommand() robustness (#327)
* `tea comment`: handle piped stdin (#322)
* ENHANCEMENTS
* Allow checking out PRs with deleted head branch (#341)
* Markdown renderer: detect terminal width, resolve relative URLs (#332)
* Add more issue / pr creation parameters (#331)
* Improve `tea time` (#319)
* `tea pr checkout`: dont create local branches (#314)
* Add `tea issues --fields`, allow printing labels (#312)
* Add more command shorthands (#307)
* Show PR CI status (#306)
* Make PR workflow helpers more robust (#300)
## [v0.6.0](https://gitea.com/gitea/tea/releases/tag/v0.6.0) - 2020-12-11
* BREAKING
* Add `tea repos search`, improve repo listing (#215)
* Add Detail View for Login (#212)
* FEATURES
* Add interactive mode for `tea pr create` (#279)
* Add organization delete command (#270)
* Add organization list command (#264)
* BUGFIXES
* Forces needed arguments to `tea ms issues` (#297)
* Subcommands work outside of git repos (#285)
* Fix repo flag ignores local repo for login detection (#285)
* Improve ssh handling (#277)
* Issue create return web url (#257)
* Support prerelease gitea instances (#252)
* Fix `tea pr create` within same repo (#248)
* Handle login name case-insensitive on all comands (#227)
* ENHANCEMENTS
* Add `tea login delete` (#296)
* Release delete: add --delete-tag & --confirm (#286)
* Sorted milestones list (#281)
* Pull clean & checkout use token for http(s) auth (#275)
* Show more infos in pull detail view (#271)
* Specify fields to print on `tea repos list` (#223)
* Print times in local timezone (#217)
* Issue create/edit print details (#214)
* Improve `tea logout` (#213)
* Added a shorthand for notifications (#209)
* Common subcommand naming scheme (#208)
* `tea pr checkout`: fetch via ssh if available (#192)
* Major refactor of codebase
* BUILD
* Use gox to cross-compile (#274)
* DOCS
* Update Docs to new code structure (#247)
## [v0.5.0](https://gitea.com/gitea/tea/releases/tag/v0.5.0) - 2020-09-27
* BREAKING
* Add Login Manage Functions (#182)
* FEATURES
* Add Release Subcomands (#195)
* Render Markdown and colorize labels table (#181)
* Add BasicAuth & Interactive for Login (#174)
* Add milestones subcomands (#149)
* BUGFIXES
* Fix Pulls Create (#202)
* Pulls create: detect head branch repo owner (#193)
* Fix Labels Delete (#180)
* ENHANCEMENTS
* Add Pagination Options for List Subcomands (#204)
* Issues/Pulls: Details show State (#196)
* Make issues & pulls subcommands consistent (#188)
* Update SDK to v0.13.0 (#179)
* More Options To Specify Repo (#178)
* Add Repo Create subcomand & enhancements (#173)
* Times: format duration as seconds for machine-readable outputs (#168)
* Add user message to login list view (#166)
## [v0.4.1](https://gitea.com/gitea/tea/releases/tag/v0.4.1) - 2020-09-13
* BUGFIXES

View File

@@ -7,6 +7,8 @@
- [Bug reports](#bug-reports)
- [Discuss your design](#discuss-your-design)
- [Testing redux](#testing-redux)
- [Vendoring](#vendoring)
- [Translation](#translation)
- [Code review](#code-review)
- [Styleguide](#styleguide)
- [Sign-off your work](#sign-off-your-work)
@@ -18,31 +20,36 @@
## Introduction
This document explains how to contribute changes to TEA.
This document explains how to contribute changes to the Gitea project.
It assumes you have followed the
[installation instructions](https://docs.gitea.io/en-us/).
Sensitive security-related issues should be reported to
[security@gitea.io](mailto:security@gitea.io).
For configuring IDE or code editor to develop Gitea see [IDE and code editor configuration](https://github.com/go-gitea/gitea/tree/master/contrib/ide)
For configuring IDE or code editor to develop Gitea see [IDE and code editor configuration](contrib/ide/)
## Bug reports
Please search the issues on the issue tracker with a variety of keywords
to ensure your bug is not already reported.
If unique, [open an issue](https://gitea.com/gitea/tea/issues/new).
If unique, [open an issue](https://github.com/go-gitea/gitea/issues/new)
and answer the questions so we can understand and reproduce the
problematic behavior.
Please write clear, concise instructions so we can reproduce the behavior—
To show us that the issue you are having is in Gitea itself, please
write clear, concise instructions so we can reproduce the behavior—
even if it seems obvious. The more detailed and specific you are,
the faster we can fix the issue. Check out [How to Report Bugs
Effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html).
Please be kind, remember that TEA comes at no cost to you, and you're
Please be kind, remember that Gitea comes at no cost to you, and you're
getting free help.
## Discuss your design
The project welcomes submissions. If you want to change or add something,
please let everyone know what you're working on—[file an issue](https://gitea.com/gitea/tea/issues/new)!
please let everyone know what you're working on—[file an issue](https://github.com/go-gitea/gitea/issues/new)!
Significant changes must go through the change proposal process
before they can be accepted. To create a proposal, file an issue with
your proposed changes documented, and make sure to note in the title
@@ -56,14 +63,55 @@ high-level discussions.
## Testing redux
Before sending code out for review, run all the test by executing: `make test`
Since TEA is an cli tool it should be obvious to test your feature locally first.
Before sending code out for review, run all the tests for the
whole tree to make sure the changes don't break other usage
and keep the compatibility on upgrade. To make sure you are
running the test suite exactly like we do, you should install
the CLI for [Drone CI](https://github.com/drone/drone), as
we are using the server for continous testing, following [these
instructions](http://docs.drone.io/cli-installation/). After that,
you can simply call `drone exec --local --build-event "pull_request"` within
your working directory and it will try to run the test suite locally.
## Vendoring
We keep a cached copy of dependencies within the `vendor/` directory,
managing updates via [dep](https://github.com/golang/dep).
Pull requests should only include `vendor/` updates if they are part of
the same change, be it a bugfix or a feature addition.
The `vendor/` update needs to be justified as part of the PR description,
and must be verified by the reviewers and/or merger to always reference
an existing upstream commit.
You can find more information on how to get started with it on the [dep project website](https://golang.github.io/dep/docs/introduction.html).
## Translation
We do all translation work inside [Crowdin](https://crowdin.com/project/gitea).
The only translation that is maintained in this git repository is
[`en_US.ini`](https://github.com/go-gitea/gitea/blob/master/options/locale/locale_en-US.ini)
and is synced regularily to Crowdin. Once a translation has reached
A SATISFACTORY PERCENTAGE it will be synced back into this repo and
included in the next released version.
## Building Gitea
Generally, the go build tools are installed as-needed in the `Makefile`.
An exception are the tools to build the CSS and images.
- To build CSS: Install [Node.js](https://nodejs.org/en/download/package-manager)
with `npm` and then run `npm install` and `make generate-stylesheets`.
- To build Images: ImageMagick, inkscape and zopflipng binaries must be
available in your `PATH` to run `make generate-images`.
## Code review
Changes to TEA must be reviewed before they are accepted—no matter who
makes the change, even if they are an owner or a maintainer. We use Gitea's
pull request & review workflow to do that. Gitea ensure every PR is reviewed by at least 2 maintainers.
Changes to Gitea must be reviewed before they are accepted—no matter who
makes the change, even if they are an owner or a maintainer. We use GitHub's
pull request workflow to do that. And, we also use [LGTM](http://lgtm.co)
to ensure every PR is reviewed by at least 2 maintainers.
Please try to make your pull request easy to review for us. And, please read
the *[How to get faster PR reviews](https://github.com/kubernetes/community/blob/261cb0fd089b64002c91e8eddceebf032462ccd6/contributors/guide/pull-requests.md#best-practices-for-faster-reviews)* guide;
@@ -80,41 +128,6 @@ Some of the key points:
## Styleguide
### Commands
- Subcommands should follow the following structure:
```
tea <noun> <verb> [<noun>] [<flags>]
```
for example:
```
tea issues list
tea pulls create
tea teams add user --team x --user y
```
- Commands should accept nouns as singular & plural by making use of the `Aliases` field.
- The default action without a verb is `list`.
- There is a standard set of verbs: `list/ls`, `create`, `edit`, `delete`
- `ls` lists objects with filter options, and applies pagination where available.
- `delete` should show info what is deleted and ask user again, if force flag`-y` is not set
- Verbs that accept large numbers of flags provide an interactive mode when called without any arguments or flags.
- Try to reuse as many flag definitions as possible, see `cmd/flags/flags.go`.
- Always make sure that the help texts are properly set, and as concise as possible.
### Internal Module Structure
- `cmd`: only contains command/flag options for `urfave/cli`
- subcommands are in a subpackage named after its parent command
- `modules/task`: contain func for doing something with gitea
(e.g. create token by user/passwd)
- `modules/print`: contain all functions that print to stdout
- `modules/config`: config tea & login things
- `modules/interact`: contain functions to interact with user by prompts
- `modules/git`: do git related stuff (get info/push/pull/checkout/...)
- `modules/utils`: helper functions used by other functions
### Code Style
Use `make fmt`, check with `make lint`.
For imports you should use the following format (_without_ the comments)
```go
import (
@@ -151,7 +164,25 @@ commit automatically with `git commit -s`.
## Release Cycle
Before we reach v1 there is no fixed release cycle.
We adopted a release schedule to streamline the process of working
on, finishing, and issuing releases. The overall goal is to make a
minor release every two months, which breaks down into one month of
general development followed by one month of testing and polishing
known as the release freeze. All the feature pull requests should be
merged in the first month of one release period. And, during the frozen
period, a corresponding release branch is open for fixes backported from
master. Release candidates are made during this period for user testing to
obtain a final version that is maintained in this branch. A release is
maintained by issuing patch releases to only correct critical problems
such as crashes or security issues.
Major release cycles are bimonthly. They always begin on the 25th and end on
the 24th (i.e., the 25th of December to February 24th).
During a development cycle, we may also publish any necessary minor releases
for the previous version. For example, if the latest, published release is
v1.2, then minor changes for the previous release—e.g., v1.1.0 -> v1.1.1—are
still possible.
## Maintainers
@@ -160,7 +191,7 @@ maintainers](MAINTAINERS). Every PR **MUST** be reviewed by at least
two maintainers (or owners) before it can get merged. A maintainer
should be a contributor of Gitea (or Gogs) and contributed at least
4 accepted PRs. A contributor should apply as a maintainer in the
[Discord](https://discord.gg/Gitea) #develop channel. The owners
[Discord](https://discord.gg/NsatcWJ) #develop channel. The owners
or the team maintainers may invite the contributor. A maintainer
should spend some time on code reviews. If a maintainer has no
time to do that, they should apply to leave the maintainers team
@@ -177,9 +208,6 @@ https://help.github.com/articles/signing-commits-with-gpg/
## Owners
This repo is part of the Gitea project and as such part of that project's
governance.
Since Gitea is a pure community organization without any company support,
to keep the development healthy we will elect three owners every year. All
contributors may vote to elect up to three candidates, one of which will
@@ -193,7 +221,7 @@ https://help.github.com/articles/securing-your-account-with-two-factor-authentic
After the election, the new owners should proactively agree
with our [CONTRIBUTING](CONTRIBUTING.md) requirements in the
[Discord](https://discord.gg/Gitea) #general channel. Below are the
[Discord](https://discord.gg/NsatcWJ) #general channel. Below are the
words to speak:
```
@@ -202,9 +230,22 @@ I'm honored to having been elected an owner of Gitea, I agree with
and lead the development of Gitea.
```
To honor the past owners, here's the history of the owners and the time
they served:
* 2016-11-04 ~ 2017-12-31
* [Lunny Xiao](https://github.com/lunny) <xiaolunwen@gmail.com>
* [Thomas Boerger](https://github.com/tboerger) <thomas@webhippie.de>
* [Kim Carlbäcker](https://github.com/bkcsoft) <kim.carlbacker@gmail.com>
* 2018-01-01 ~ 2018-12-31
* [Lunny Xiao](https://github.com/lunny) <xiaolunwen@gmail.com>
* [Lauris Bukšis-Haberkorns](https://github.com/lafriks) <lauris@nix.lv>
* [Kim Carlbäcker](https://github.com/bkcsoft) <kim.carlbacker@gmail.com>
## Versions
tea has the `master` branch as a tip branch and has version branches
Gitea has the `master` branch as a tip branch and has version branches
such as `release/v0.9`. `release/v0.9` is a release branch and we will
tag `v0.9.0` for binary download. If `v0.9.0` has bugs, we will accept
pull requests on the `release/v0.9` branch and publish a `v0.9.1` tag,
@@ -220,9 +261,9 @@ be reviewed by two maintainers and must pass the automatic tests.
Code that you contribute should use the standard copyright header:
```
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
```
Files in the repository contain copyright from the year they are added

View File

@@ -1,12 +0,0 @@
FROM docker.io/chainguard/go:latest AS build
COPY . /build/
WORKDIR /build
RUN make build && mkdir -p /app/.config/tea
FROM docker.io/chainguard/busybox:latest-glibc
COPY --from=build /build/tea /bin/tea
COPY --from=build --chown=65532:65532 /app /app
VOLUME [ "/app" ]
ENV HOME="/app"
ENTRYPOINT ["/bin/sh", "-c"]
CMD [ "tea" ]

View File

@@ -1,4 +1,5 @@
Copyright (c) 2016 The Gitea Authors
Copyright (c) 2015 The Gogs Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

171
Makefile
View File

@@ -1,14 +1,30 @@
DIST := dist
IMPORT := code.gitea.io/tea
export GO111MODULE=on
GO ?= go
SED_INPLACE := sed -i
SHASUM ?= shasum -a 256
export PATH := $($(GO) env GOPATH)/bin:$(PATH)
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
ifeq ($(OS), Windows_NT)
EXECUTABLE := tea.exe
else
EXECUTABLE := tea
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
SED_INPLACE := sed -i ''
endif
endif
# 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
GOFILES := $(shell find . -name "*.go" -type f ! -path "./vendor/*" ! -path "*/bindata.go")
GOFMT ?= gofmt -s
GOFLAGS := -i -v
EXTRA_GOFLAGS ?=
MAKE_VERSION := $(shell make -v | head -n 1)
ifneq ($(DRONE_TAG),)
VERSION ?= $(subst v,,$(DRONE_TAG))
@@ -17,109 +33,160 @@ else
ifneq ($(DRONE_BRANCH),)
VERSION ?= $(subst release/v,,$(DRONE_BRANCH))
else
VERSION ?= main
VERSION ?= master
endif
TEA_VERSION ?= $(shell git describe --tags --always | sed 's/-/+/' | sed 's/^v//')
endif
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/modules/version.Version=$(TEA_VERSION)" -X "code.gitea.io/tea/modules/version.Tags=$(TAGS)" -X "code.gitea.io/tea/modules/version.SDK=$(SDK)" -s -w
LDFLAGS := -X "main.Version=$(TEA_VERSION)" -X "main.Tags=$(TAGS)"
# override to allow passing additional goflags via make CLI
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
PACKAGES ?= $(shell $(GO) list ./...)
PACKAGES ?= $(shell $(GO) list ./... | grep -v /vendor/)
SOURCES ?= $(shell find . -name "*.go" -type f)
# OS specific vars.
TAGS ?=
ifeq ($(OS), Windows_NT)
EXECUTABLE := tea.exe
VET_TOOL := gitea-vet.exe
else
EXECUTABLE := tea
VET_TOOL := gitea-vet
endif
# $(call strip-suffix,filename)
strip-suffix = $(firstword $(subst ., ,$(1)))
.PHONY: all
all: build
.PHONY: clean
clean:
$(GO) clean -i ./...
$(GO) clean -mod=vendor -i ./...
rm -rf $(EXECUTABLE) $(DIST)
.PHONY: fmt
fmt:
$(GO) run $(GOFUMPT_PACKAGE) -w $(GOFILES)
$(GOFMT) -w $(GOFILES)
.PHONY: vet
vet:
# Default vet
$(GO) vet $(PACKAGES)
$(GO) vet -mod=vendor $(PACKAGES)
# Custom vet
$(GO) build code.gitea.io/gitea-vet
$(GO) vet -vettool=$(VET_TOOL) $(PACKAGES)
$(GO) build -mod=vendor gitea.com/jolheiser/gitea-vet
$(GO) vet -vettool=gitea-vet $(PACKAGES)
.PHONY: lint
lint:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
@hash revive > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
cd /tmp && $(GO) get -u github.com/mgechev/revive; \
fi
revive -config .revive.toml -exclude=./vendor/... ./... || exit 1
.PHONY: lint-fix
lint-fix:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
.PHONY: misspell-check
misspell-check:
@hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
cd /tmp && $(GO) get -u github.com/client9/misspell/cmd/misspell; \
fi
misspell -error -i unknwon,destory $(GOFILES)
.PHONY: misspell
misspell:
@hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
cd /tmp && $(GO) get -u github.com/client9/misspell/cmd/misspell; \
fi
misspell -w -i unknwon $(GOFILES)
.PHONY: fmt-check
fmt-check:
# get all go files and run gofumpt on them
@diff=$$($(GO) run $(GOFUMPT_PACKAGE) -d $(GOFILES)); \
# get all go files and run go fmt on them
@diff=$$($(GOFMT) -d $(GOFILES)); \
if [ -n "$$diff" ]; then \
echo "Please run 'make fmt' and commit the result:"; \
echo "$${diff}"; \
exit 1; \
fi;
.PHONY: docs
docs:
$(GO) run docs/docs.go --out docs/CLI.md
.PHONY: docs-check
docs-check:
@DIFF=$$($(GO) run docs/docs.go | diff docs/CLI.md -); \
if [ -n "$$DIFF" ]; then \
echo "Please run 'make docs' and commit the result:"; \
echo "$$DIFF"; \
exit 1; \
fi;
.PHONY: test
test:
$(GO) test -tags='sqlite sqlite_unlock_notify' $(PACKAGES)
$(GO) test -mod=vendor -tags='sqlite sqlite_unlock_notify' $(PACKAGES)
.PHONY: unit-test-coverage
unit-test-coverage:
$(GO) test -tags='sqlite sqlite_unlock_notify' -cover -coverprofile coverage.out $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
$(GO) test -mod=vendor -tags='sqlite sqlite_unlock_notify' -cover -coverprofile coverage.out $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: tidy
tidy:
$(GO) mod tidy
.PHONY: vendor
vendor:
$(GO) mod tidy && $(GO) mod vendor
.PHONY: test-vendor
test-vendor: vendor
@diff=$$(git diff vendor/); \
if [ -n "$$diff" ]; then \
echo "Please run 'make vendor' and commit the result:"; \
echo "$${diff}"; \
exit 1; \
fi;
.PHONY: check
check: test
.PHONY: install
install: $(SOURCES)
@echo "installing to $(shell $(GO) env GOPATH)/bin/$(EXECUTABLE)"
$(GO) install -v $(BUILDMODE) $(GOFLAGS)
install: $(wildcard *.go)
$(GO) install -v -tags '$(TAGS)' -ldflags '-s -w $(LDFLAGS)'
.PHONY: build
build: $(EXECUTABLE)
$(EXECUTABLE): $(SOURCES)
$(GO) build $(BUILDMODE) $(GOFLAGS) -o $@
$(GO) build -mod=vendor $(GOFLAGS) $(EXTRA_GOFLAGS) -tags '$(TAGS)' -ldflags '-s -w $(LDFLAGS)' -o $@
.PHONY: build-image
build-image:
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .
.PHONY: release
release: release-dirs release-windows release-linux release-darwin release-copy release-compress release-check
.PHONY: release-dirs
release-dirs:
mkdir -p $(DIST)/binaries $(DIST)/release
.PHONY: release-windows
release-windows:
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
cd /tmp && $(GO) get -u src.techknowlogick.com/xgo; \
fi
GO111MODULE=off xgo -dest $(DIST)/binaries -tags 'netgo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets 'windows/*' -out tea-$(VERSION) .
ifeq ($(CI),drone)
cp /build/* $(DIST)/binaries
endif
.PHONY: release-linux
release-linux:
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
cd /tmp && $(GO) get -u src.techknowlogick.com/xgo; \
fi
GO111MODULE=off xgo -dest $(DIST)/binaries -tags 'netgo $(TAGS)' -ldflags '-linkmode external -extldflags "-static" $(LDFLAGS)' -targets 'linux/amd64,linux/386,linux/arm-5,linux/arm-6,linux/arm64,linux/mips64le,linux/mips,linux/mipsle' -out tea-$(VERSION) .
ifeq ($(CI),drone)
cp /build/* $(DIST)/binaries
endif
.PHONY: release-darwin
release-darwin:
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
cd /tmp && $(GO) get -u src.techknowlogick.com/xgo; \
fi
GO111MODULE=off xgo -dest $(DIST)/binaries -tags 'netgo $(TAGS)' -ldflags '$(LDFLAGS)' -targets 'darwin/*' -out tea-$(VERSION) .
ifeq ($(CI),drone)
cp /build/* $(DIST)/binaries
endif
.PHONY: release-copy
release-copy:
cd $(DIST); for file in `find /build -type f -name "*"`; do cp $${file} ./release/; done;
.PHONY: release-compress
release-compress:
@hash gxz > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
GO111MODULE=off $(GO) get -u github.com/ulikunitz/xz/cmd/gxz; \
fi
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && gxz -k -9 $${file}; done;
.PHONY: release-check
release-check:
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done;

229
README.md
View File

@@ -1,203 +1,84 @@
# <img alt='tea logo' src='https://gitea.com/repo-avatars/550-80a3a8c2ab0e2c2d69f296b7f8582485' height="40"/> *T E A*
# <img alt='' src='https://gitea.com/repo-avatars/550-80a3a8c2ab0e2c2d69f296b7f8582485' height="40"/> *T E A*
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
[![Release](https://raster.shields.io/badge/dynamic/json.svg?label=release&url=https://gitea.com/api/v1/repos/gitea/tea/releases&query=$[0].tag_name)](https://gitea.com/gitea/tea/releases)
[![Join the chat at https://img.shields.io/discord/322538954119184384.svg](https://img.shields.io/discord/322538954119184384.svg)](https://discord.gg/Gitea)
[![Go Report Card](https://goreportcard.com/badge/code.gitea.io/tea)](https://goreportcard.com/report/code.gitea.io/tea) [![GoDoc](https://pkg.go.dev/badge/code.gitea.io/tea?status.svg)](https://godoc.org/code.gitea.io/tea)
![Tea Release Status](https://gitea.com/gitea/tea/actions/workflows/release-nightly.yml/badge.svg)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Release](https://raster.shields.io/badge/dynamic/json.svg?label=release&url=https://gitea.com/api/v1/repos/gitea/tea/releases&query=$[0].tag_name)](https://gitea.com/gitea/tea/releases) [![Build Status](https://drone.gitea.com/api/badges/gitea/tea/status.svg)](https://drone.gitea.com/gitea/tea) [![Join the chat at https://img.shields.io/discord/322538954119184384.svg](https://img.shields.io/discord/322538954119184384.svg)](https://discord.gg/Gitea) [![Go Report Card](https://goreportcard.com/badge/code.gitea.io/tea)](https://goreportcard.com/report/code.gitea.io/tea) [![GoDoc](https://godoc.org/code.gitea.io/tea?status.svg)](https://godoc.org/code.gitea.io/tea)
## The official CLI for Gitea
## The official CLI interface for gitea
![demo gif](./demo.gif)
Tea is a command line tool for interacting on one or more Gitea instances.
It uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API
```
NAME:
tea - command line tool to interact with Gitea
USAGE:
tea [global options] [command [command options]]
VERSION:
Version: 0.10.1+15-g8876fe3 golang: 1.25.0 go-sdk: v0.21.0
DESCRIPTION:
tea is a productivity helper for Gitea. It can be used to manage most entities on
one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
tea tries to make use of context provided by the repository in $PWD if available.
tea works best in a upstream/fork workflow, when the local main branch tracks the
upstream repo. tea assumes that local git state is published on the remote before
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
COMMANDS:
help, h Shows a list of commands or help for one command
ENTITIES:
issues, issue, i List, create and update issues
pulls, pull, pr Manage and checkout pull requests
labels, label Manage issue labels
milestones, milestone, ms List and create milestones
releases, release, r Manage releases
times, time, t Operate on tracked times of a repository's issues & pulls
organizations, organization, org List, create, delete organizations
repos, repo Show repository details
branches, branch, b Consult branches
actions Manage repository actions (secrets, variables)
comment, c Add a comment to an issue / pr
webhooks, webhook Manage repository webhooks
HELPERS:
open, o Open something of the repository in web browser
notifications, notification, n Show notifications
clone, C Clone a repository locally
MISCELLANEOUS:
whoami Show current logged in user
admin, a Operations requiring admin access on the Gitea instance
SETUP:
logins, login Log in to a Gitea server
logout Log out from a Gitea server
GLOBAL OPTIONS:
--debug, --vvv Enable debug mode (default: false)
--help, -h show help
--version, -v print the version
EXAMPLES
tea login add # add a login once to get started
tea pulls # list open pulls for the repo in $PWD
tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo
tea pulls --remote upstream # list open pulls for the repo pointed at by
# your local "upstream" git remote
# list open pulls for any gitea repo at the given login instance
tea pulls --repo gitea/tea --login gitea.com
tea milestone issues 0.7.0 # view open issues for milestone '0.7.0'
tea issue 189 # view contents of issue 189
tea open 189 # open web ui for issue 189
tea open milestones # open web ui for milestones
tea actions secrets list # list all repository action secrets
tea actions secrets create API_KEY # create a new secret (will prompt for value)
tea actions variables list # list all repository action variables
tea actions variables set API_URL https://api.example.com
tea webhooks list # list repository webhooks
tea webhooks list --org myorg # list organization webhooks
tea webhooks create https://example.com/hook --events push,pull_request
# send gitea desktop notifications every 5 minutes (bash + libnotify)
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done
ABOUT
Written & maintained by The Gitea Authors.
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea.
More info about Gitea itself on https://about.gitea.com.
```
- tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API.
![demo gif](https://dl.gitea.io/screenshots/tea_demo.gif)
## Installation
There are different ways to get `tea`:
You can use the prebuilt binaries from [dl.gitea.io](https://dl.gitea.io/tea/)
1. Install via your system package manager:
- macOS via `brew` (official):
```sh
brew install tea
```
- arch linux ([tea](https://archlinux.org/packages/extra/x86_64/tea/), thirdparty)
- alpine linux ([tea](https://pkgs.alpinelinux.org/packages?name=tea&branch=edge), thirdparty)
- Windows via `MSYS2` ([tea](https://packages.msys2.org/base/mingw-w64-tea), thirdparty)
2. Use the prebuilt binaries from [dl.gitea.com](https://dl.gitea.com/tea/)
3. Install from source: [see *Compilation*](#compilation)
4. Docker: [Tea at docker hub](https://hub.docker.com/r/gitea/tea)
### Log in to Gitea from tea
Gitea can use many different authentication schemes, and not every authentication method will work with every Gitea deployment. When you are a Gitea instance administrator you can tweak your settings to fit your use case. For the method that is most likely to work with any Gitea deployment use the following steps:
1. Open your Gitea instance in a web browser
2. Log in to Gitea in your web browser. Any MFA, IDP, or whatever else should be available this way.
3. In your "user settings", generate an application token with at least **user read** permissions. If you want to do anything useful with the token add additional permissions/scopes.
4. Run `tea login add`, select **application token** authentication when asked for authentication type, and answer **yes** to the question if you have a token. Paste the generated token when asked for one.
You should now be logged in to your gitea instance from tea.
Since 0.10 Gitea supports the much simpler oauth workflow but oauth may not be available on all Gitea deployments, and gets much more complex when running tea on a remote system.
### Shell completion
If you installed from source or the package does not provide the completions with it you can add them yourself with `tea completion <shell>` command which is not visible in help. To generate the completions run one of the following commands depending on your shell.
```shell
# .bashrc
source <(tea completion bash)
# .zshrc
source <(tea completion zsh)
# fish
tea completion fish > ~/.config/fish/completions/tea.fish
# Powershell
Output the script to path/to/autocomplete/tea.ps1 an run it.
To install from source, go 1.12 or newer is required:
```sh
go get code.gitea.io/tea
go install code.gitea.io/tea
```
### Man Page
The hidden command `tea man` can be used to generate the `tea` man page.
If you have `brew` installed, you can install `tea` via:
```shell
# for bash or zsh
man <(tea man)
# for fish
man (tea man | psub)
# write man page to a file
tea man --out ./tea.man
```sh
brew tap gitea/tap https://gitea.com/gitea/homebrew-gitea
brew install tea
```
Distribution packages exist for: **alpinelinux ([tea](https://pkgs.alpinelinux.org/packages?name=tea&branch=edge))** and **archlinux ([gitea-tea](https://aur.archlinux.org/packages/gitea-tea))**
## Usage
First of all, you have to create a token on your `personal settings -> application` page of your gitea instance.
Use this token to login with `tea`:
```sh
tea login add --name=try --url=https://try.gitea.io --token=xxxxxx
```
Now you can use the following `tea` subcommands.
Detailed usage information is available via `tea <command> --help`.
```sh
login Log in to a Gitea server
logout Log out from a Gitea server
issues List, create and update issues
pulls List, checkout and clean pull requests
releases Create releases
repos Operate with repositories
labels Manage issue labels
times Operate on tracked times of a repositorys issues and pulls
open Open something of the repository on web browser
```
To fetch issues from different repos, use the `--remote` flag (when inside a gitea repository directory) or `--login` & `--repo` flags.
## Compilation
Make sure you have a current Go version installed (1.26 or newer).
Make sure you have installed a current go version.
To compile the sources yourself run the following:
- To compile the source yourself with the recommended flags & tags:
```sh
git clone https://gitea.com/gitea/tea.git # or: tea clone gitea.com/gitea/tea ;)
cd tea
make
```
Note that GNU Make (gmake on OpenBSD) is required.
If you want to install the compiled program you have to execute the following command:
```sh
make install
```
This installs the binary into the "bin" folder inside of your GOPATH folder (`go env GOPATH`). It is possible that this folder isn't in your PATH Environment Variable.
- For a quick installation without `git` & `make`, set $version and exec:
```sh
go install code.gitea.io/tea@${version}
```
```sh
git clone https://gitea.com/gitea/tea.git
cd tea
make
```
## Contributing
Fork -> Patch -> Push -> Pull Request
- `make test` run testsuite
- `make vet` run checks (check the order of imports; preventing failure on CI pipeline beforehand)
- `make vendor` when adding new dependencies
- ... (for other development tasks, check the `Makefile`)
**Please** read the [CONTRIBUTING](CONTRIBUTING.md) documentation, it will tell you about internal structures and concepts.
## Authors
* [Maintainers](https://github.com/orgs/go-gitea/people)
* [Contributors](https://github.com/go-gitea/tea/graphs/contributors)
## License

View File

@@ -1,8 +1,7 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build vendor
// +build vendor
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
//+build vendor
package main
@@ -11,5 +10,5 @@ package main
import (
// for vet
_ "code.gitea.io/gitea-vet"
_ "gitea.com/jolheiser/gitea-vet"
)

View File

@@ -1,47 +0,0 @@
// 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)
}

View File

@@ -1,31 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"code.gitea.io/tea/cmd/actions/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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +0,0 @@
// 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)
}

View File

@@ -1,74 +0,0 @@
// 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
}

View File

@@ -1,56 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package secrets
import (
"testing"
)
func TestGetSecretSourceArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
}{
{
name: "valid args",
args: []string{"VALID_SECRET", "secret_value"},
wantErr: false,
},
{
name: "missing name",
args: []string{},
wantErr: true,
},
{
name: "too many args",
args: []string{"SECRET_NAME", "value", "extra"},
wantErr: true,
},
{
name: "invalid secret name",
args: []string{"invalid_secret", "value"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test argument validation only
if len(tt.args) == 0 {
if !tt.wantErr {
t.Error("Expected error for empty args")
}
return
}
if len(tt.args) > 2 {
if !tt.wantErr {
t.Error("Expected error for too many args")
}
return
}
})
}
}

View File

@@ -1,66 +0,0 @@
// 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
}

View File

@@ -1,93 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package secrets
import (
"fmt"
"testing"
)
func TestSecretsDeleteValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
}{
{
name: "valid secret name",
args: []string{"VALID_SECRET"},
wantErr: false,
},
{
name: "no args",
args: []string{},
wantErr: true,
},
{
name: "too many args",
args: []string{"SECRET1", "SECRET2"},
wantErr: true,
},
{
name: "invalid secret name but client does not validate",
args: []string{"invalid_secret"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDeleteArgs(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("validateDeleteArgs() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestSecretsDeleteFlags(t *testing.T) {
cmd := CmdSecretsDelete
// Test command properties
if cmd.Name != "delete" {
t.Errorf("Expected command name 'delete', got %s", cmd.Name)
}
// Check that rm is one of the aliases
hasRmAlias := false
for _, alias := range cmd.Aliases {
if alias == "rm" {
hasRmAlias = true
break
}
}
if !hasRmAlias {
t.Error("Expected 'rm' to be one of the aliases for delete command")
}
if cmd.ArgsUsage != "<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
}

View File

@@ -1,49 +0,0 @@
// 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)
}

View File

@@ -1,98 +0,0 @@
// 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")
}

View File

@@ -1,30 +0,0 @@
// 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)
}

View File

@@ -1,66 +0,0 @@
// 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
}

View File

@@ -1,98 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package variables
import (
"fmt"
"testing"
)
func TestVariablesDeleteValidation(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
}{
{
name: "valid variable name",
args: []string{"VALID_VARIABLE"},
wantErr: false,
},
{
name: "valid lowercase name",
args: []string{"valid_variable"},
wantErr: false,
},
{
name: "no args",
args: []string{},
wantErr: true,
},
{
name: "too many args",
args: []string{"VARIABLE1", "VARIABLE2"},
wantErr: true,
},
{
name: "invalid variable name",
args: []string{"invalid-variable"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateVariableDeleteArgs(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("validateVariableDeleteArgs() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestVariablesDeleteFlags(t *testing.T) {
cmd := CmdVariablesDelete
// Test command properties
if cmd.Name != "delete" {
t.Errorf("Expected command name 'delete', got %s", cmd.Name)
}
// Check that rm is one of the aliases
hasRmAlias := false
for _, alias := range cmd.Aliases {
if alias == "rm" {
hasRmAlias = true
break
}
}
if !hasRmAlias {
t.Error("Expected 'rm' to be one of the aliases for delete command")
}
if cmd.ArgsUsage != "<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])
}

View File

@@ -1,61 +0,0 @@
// 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
}

View File

@@ -1,98 +0,0 @@
// 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")
}

View File

@@ -1,108 +0,0 @@
// 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
}

View File

@@ -1,213 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package variables
import (
"strings"
"testing"
)
func TestValidateVariableName(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "valid name",
input: "VALID_VARIABLE_NAME",
wantErr: false,
},
{
name: "valid name with numbers",
input: "VARIABLE_123",
wantErr: false,
},
{
name: "valid lowercase",
input: "valid_variable",
wantErr: false,
},
{
name: "valid mixed case",
input: "Mixed_Case_Variable",
wantErr: false,
},
{
name: "invalid - spaces",
input: "INVALID VARIABLE",
wantErr: true,
},
{
name: "invalid - special chars",
input: "INVALID-VARIABLE!",
wantErr: true,
},
{
name: "invalid - starts with number",
input: "1INVALID",
wantErr: true,
},
{
name: "invalid - empty",
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateVariableName(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateVariableName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestGetVariableSourceArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
}{
{
name: "valid args",
args: []string{"VALID_VARIABLE", "variable_value"},
wantErr: false,
},
{
name: "valid lowercase",
args: []string{"valid_variable", "value"},
wantErr: false,
},
{
name: "missing name",
args: []string{},
wantErr: true,
},
{
name: "too many args",
args: []string{"VARIABLE_NAME", "value", "extra"},
wantErr: true,
},
{
name: "invalid variable name",
args: []string{"invalid-variable", "value"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test argument validation only
if len(tt.args) == 0 {
if !tt.wantErr {
t.Error("Expected error for empty args")
}
return
}
if len(tt.args) > 2 {
if !tt.wantErr {
t.Error("Expected error for too many args")
}
return
}
// Test variable name validation
err := validateVariableName(tt.args[0])
if (err != nil) != tt.wantErr {
t.Errorf("validateVariableName() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestVariableNameValidation(t *testing.T) {
// Test that variable names follow GitHub Actions/Gitea Actions conventions
validNames := []string{
"VALID_VARIABLE",
"API_URL",
"DATABASE_HOST",
"VARIABLE_123",
"mixed_Case_Variable",
"lowercase_variable",
"UPPERCASE_VARIABLE",
}
invalidNames := []string{
"Invalid-Dashes",
"INVALID SPACES",
"123_STARTS_WITH_NUMBER",
"", // Empty
"INVALID!@#", // Special chars
}
for _, name := range validNames {
t.Run("valid_"+name, func(t *testing.T) {
err := validateVariableName(name)
if err != nil {
t.Errorf("validateVariableName(%q) should be valid, got error: %v", name, err)
}
})
}
for _, name := range invalidNames {
t.Run("invalid_"+name, func(t *testing.T) {
err := validateVariableName(name)
if err == nil {
t.Errorf("validateVariableName(%q) should be invalid, got no error", name)
}
})
}
}
func TestVariableValueValidation(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{
name: "valid value",
value: "variable123",
wantErr: false,
},
{
name: "valid complex value",
value: "https://api.example.com/v1",
wantErr: false,
},
{
name: "valid multiline value",
value: "line1\nline2\nline3",
wantErr: false,
},
{
name: "empty value allowed",
value: "",
wantErr: false, // Variables can be empty unlike secrets
},
{
name: "whitespace only allowed",
value: " \t\n ",
wantErr: false, // Variables can contain whitespace
},
{
name: "very long value",
value: strings.Repeat("a", 65537), // Over 64KB
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateVariableValue(tt.value)
if (err != nil) != tt.wantErr {
t.Errorf("validateVariableValue() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

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

View File

@@ -1,91 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package workflows
import (
stdctx "context"
"fmt"
"path/filepath"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdWorkflowsList represents a sub command to list workflows
var CmdWorkflowsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List repository workflows",
Description: "List workflow files in the repository with active/inactive status",
Action: RunWorkflowsList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
}
// RunWorkflowsList lists workflow files in the repository
func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
// Try to list workflow files from .gitea/workflows directory
var workflows []*gitea.ContentsResponse
// Try .gitea/workflows first, then .github/workflows
workflowDir := ".gitea/workflows"
contents, _, err := client.ListContents(c.Owner, c.Repo, "", workflowDir)
if err != nil {
workflowDir = ".github/workflows"
contents, _, err = client.ListContents(c.Owner, c.Repo, "", workflowDir)
if err != nil {
fmt.Printf("No workflow files found\n")
return nil
}
}
// Filter for workflow files (.yml and .yaml)
for _, content := range contents {
if content.Type == "file" {
ext := strings.ToLower(filepath.Ext(content.Name))
if ext == ".yml" || ext == ".yaml" {
content.Path = workflowDir + "/" + content.Name
workflows = append(workflows, content)
}
}
}
if len(workflows) == 0 {
fmt.Printf("No workflow files found\n")
return nil
}
// Check which workflows have runs to determine active status
workflowStatus := make(map[string]bool)
// Get recent runs to check activity
runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err == nil && runs != nil {
for _, run := range runs.WorkflowRuns {
// Extract workflow file name from path
workflowFile := filepath.Base(run.Path)
workflowStatus[workflowFile] = true
}
}
return print.WorkflowsList(workflows, workflowStatus, c.Output)
}

View File

@@ -1,59 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"code.gitea.io/tea/cmd/admin/users"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdAdmin represents the namespace of admin commands.
// The command itself has no functionality, but hosts subcommands.
var CmdAdmin = cli.Command{
Name: "admin",
Usage: "Operations requiring admin access on the Gitea instance",
Aliases: []string{"a"},
Category: catMisc,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
return cli.ShowSubcommandHelp(cmd)
},
Commands: []*cli.Command{
&cmdAdminUsers,
},
}
var cmdAdminUsers = cli.Command{
Name: "users",
Aliases: []string{"u"},
Usage: "Manage registered users",
Action: func(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runAdminUserDetail(ctx, cmd, cmd.Args().First())
}
return users.RunUserList(ctx, cmd)
},
Commands: []*cli.Command{
&users.CmdUserList,
},
Flags: users.CmdUserList.Flags,
}
func runAdminUserDetail(_ stdctx.Context, cmd *cli.Command, u string) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client()
user, _, err := client.GetUserInfo(u)
if err != nil {
return err
}
print.UserDetails(user)
return nil
}

View File

@@ -1,56 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package users
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"
)
var userFieldsFlag = flags.FieldsFlag(print.UserFields, []string{
"id", "login", "full_name", "email", "activated",
})
// CmdUserList represents a sub command of users to list users
var CmdUserList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List Users",
Description: "List users",
Action: RunUserList,
Flags: append([]cli.Flag{
userFieldsFlag,
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
}
// RunUserList list users
func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
fields, err := userFieldsFlag.GetValues(cmd)
if err != nil {
return err
}
client := ctx.Login.Client()
users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
return print.UserList(users, ctx.Output, fields)
}

View File

@@ -1,408 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
stdctx "context"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/api"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
"golang.org/x/term"
)
// apiFlags returns a fresh set of flag instances for the api command.
// This is a factory function so that each invocation gets independent flag
// objects, avoiding shared hasBeenSet state across tests.
func apiFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{
Name: "method",
Aliases: []string{"X"},
Usage: "HTTP method (GET, POST, PUT, PATCH, DELETE)",
Value: "GET",
},
&cli.StringSliceFlag{
Name: "field",
Aliases: []string{"f"},
Usage: "Add a string field to the request body (key=value)",
},
&cli.StringSliceFlag{
Name: "Field",
Aliases: []string{"F"},
Usage: "Add a typed field to the request body (key=value, @file, or @- for stdin)",
},
&cli.StringSliceFlag{
Name: "header",
Aliases: []string{"H"},
Usage: "Add a custom header (key:value)",
},
&cli.StringFlag{
Name: "data",
Aliases: []string{"d"},
Usage: "Raw JSON request body (use @file to read from file, @- for stdin)",
},
&cli.BoolFlag{
Name: "include",
Aliases: []string{"i"},
Usage: "Include HTTP status and response headers in output (written to stderr)",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Write response body to file instead of stdout (use '-' for stdout)",
},
}
}
// CmdApi represents the api command
var CmdApi = cli.Command{
Name: "api",
Category: catHelpers,
DisableSliceFlagSeparator: true,
Usage: "Make an authenticated API request",
Description: `Makes an authenticated HTTP request to the Gitea API and prints the response.
The endpoint argument is the path to the API endpoint, which will be prefixed
with /api/v1/ if it doesn't start with /api/ or http(s)://.
Placeholders like {owner} and {repo} in the endpoint will be replaced with
values from the current repository context.
Use -f for string fields and -F for typed fields (numbers, booleans, null).
With -F, prefix value with @ to read from file (@- for stdin). Values starting
with [ or { are parsed as JSON arrays/objects. Wrap values in quotes to force
string type (e.g., -F key="null" for literal string "null").
Use -d/--data to send a raw JSON body. Use @file to read from a file, or @-
to read from stdin. The -d flag cannot be combined with -f or -F.
When a request body is provided via -f, -F, or -d, the method defaults to POST
unless explicitly set with -X/--method.
Note: if your endpoint contains ? or &, quote it to prevent shell expansion
(e.g., '/repos/{owner}/{repo}/issues?state=open').`,
ArgsUsage: "<endpoint>",
Action: runApi,
Flags: append(apiFlags(), flags.LoginRepoFlags...),
}
type preparedAPIRequest struct {
Method string
Endpoint string
Headers map[string]string
Body []byte
}
func runApi(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
request, err := prepareAPIRequest(cmd, ctx)
if err != nil {
return err
}
var body io.Reader
if request.Body != nil {
body = bytes.NewReader(request.Body)
}
// Create API client and make request
client := api.NewClient(ctx.Login)
resp, err := client.Do(request.Method, request.Endpoint, body, request.Headers)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "warning: failed to close response body: %v\n", closeErr)
}
}()
// Print headers to stderr if requested (so redirects/pipes work correctly)
if cmd.Bool("include") {
fmt.Fprintf(os.Stderr, "%s %s\n", resp.Proto, resp.Status)
for key, values := range resp.Header {
for _, value := range values {
fmt.Fprintf(os.Stderr, "%s: %s\n", key, value)
}
}
fmt.Fprintln(os.Stderr)
}
// Determine output destination
outputPath := cmd.String("output")
forceStdout := outputPath == "-"
outputToStdout := outputPath == "" || forceStdout
// Check for binary output to terminal (skip warning if user explicitly forced stdout)
if outputToStdout && !forceStdout && term.IsTerminal(int(os.Stdout.Fd())) && !isTextContentType(resp.Header.Get("Content-Type")) {
fmt.Fprintln(os.Stderr, "Warning: Binary output detected. Use '-o <file>' to save to a file,")
fmt.Fprintln(os.Stderr, "or '-o -' to force output to terminal.")
return nil
}
var output io.Writer = os.Stdout
if !outputToStdout {
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
fmt.Fprintf(os.Stderr, "warning: failed to close output file: %v\n", closeErr)
}
}()
output = file
}
// Copy response body to output
_, err = io.Copy(output, resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
// Add newline for better terminal display
if outputToStdout && term.IsTerminal(int(os.Stdout.Fd())) {
fmt.Println()
}
return nil
}
func prepareAPIRequest(cmd *cli.Command, ctx *context.TeaContext) (*preparedAPIRequest, error) {
var err error
// Get the endpoint argument
if cmd.NArg() < 1 {
return nil, fmt.Errorf("endpoint argument required")
}
endpoint := cmd.Args().First()
// Expand placeholders in endpoint
endpoint = expandPlaceholders(endpoint, ctx)
// Parse headers
headers := make(map[string]string)
for _, h := range cmd.StringSlice("header") {
parts := strings.SplitN(h, ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid header format: %q (expected key:value)", h)
}
headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
// Build request body from fields
var bodyBytes []byte
stringFields := cmd.StringSlice("field")
typedFields := cmd.StringSlice("Field")
dataRaw := cmd.String("data")
if dataRaw != "" && (len(stringFields) > 0 || len(typedFields) > 0) {
return nil, fmt.Errorf("--data/-d cannot be combined with --field/-f or --Field/-F")
}
if dataRaw != "" {
var dataBytes []byte
var dataSource string
if strings.HasPrefix(dataRaw, "@") {
filename := dataRaw[1:]
if filename == "-" {
dataBytes, err = io.ReadAll(os.Stdin)
dataSource = "stdin"
} else {
dataBytes, err = os.ReadFile(filename)
dataSource = filename
}
if err != nil {
return nil, fmt.Errorf("failed to read %q: %w", dataRaw, err)
}
} else {
dataBytes = []byte(dataRaw)
}
if !json.Valid(dataBytes) {
if dataSource != "" {
return nil, fmt.Errorf("--data/-d value from %s is not valid JSON", dataSource)
}
return nil, fmt.Errorf("--data/-d value is not valid JSON")
}
bodyBytes = dataBytes
} else if len(stringFields) > 0 || len(typedFields) > 0 {
bodyMap := make(map[string]any)
// Process string fields (-f)
for _, f := range stringFields {
parts := strings.SplitN(f, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f)
}
key := parts[0]
if key == "" {
return nil, fmt.Errorf("field key cannot be empty in %q", f)
}
if _, exists := bodyMap[key]; exists {
return nil, fmt.Errorf("duplicate field key %q", key)
}
bodyMap[key] = parts[1]
}
// Process typed fields (-F)
for _, f := range typedFields {
parts := strings.SplitN(f, "=", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid field format: %q (expected key=value)", f)
}
key := parts[0]
if key == "" {
return nil, fmt.Errorf("field key cannot be empty in %q", f)
}
if _, exists := bodyMap[key]; exists {
return nil, fmt.Errorf("duplicate field key %q", key)
}
value := parts[1]
parsedValue, err := parseTypedValue(value)
if err != nil {
return nil, fmt.Errorf("failed to parse field %q: %w", key, err)
}
bodyMap[key] = parsedValue
}
bodyBytes, err = json.Marshal(bodyMap)
if err != nil {
return nil, fmt.Errorf("failed to encode request body: %w", err)
}
}
method := strings.ToUpper(cmd.String("method"))
if !cmd.IsSet("method") {
if bodyBytes != nil {
method = "POST"
} else {
method = "GET"
}
}
return &preparedAPIRequest{
Method: method,
Endpoint: endpoint,
Headers: headers,
Body: bodyBytes,
}, nil
}
// parseTypedValue parses a value for -F flag, handling:
// - @filename: read content from file
// - @-: read content from stdin
// - "quoted": literal string (prevents type parsing)
// - true/false: boolean
// - null: nil
// - numbers: int or float
// - []/{}: JSON arrays/objects
// - otherwise: string
func parseTypedValue(value string) (any, error) {
// Handle file references.
// Note: if multiple fields use @- (stdin), only the first will get data;
// subsequent reads will return empty since stdin is consumed once.
if strings.HasPrefix(value, "@") {
filename := value[1:]
var content []byte
var err error
if filename == "-" {
content, err = io.ReadAll(os.Stdin)
} else {
content, err = os.ReadFile(filename)
}
if err != nil {
return nil, fmt.Errorf("failed to read %q: %w", value, err)
}
return strings.TrimSuffix(string(content), "\n"), nil
}
// Handle quoted strings (literal strings, no type parsing).
// Uses strconv.Unquote so escape sequences like \" are handled correctly.
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
unquoted, err := strconv.Unquote(value)
if err != nil {
return nil, fmt.Errorf("invalid quoted string %s: %w", value, err)
}
return unquoted, nil
}
// Handle null
if value == "null" {
return nil, nil
}
// Handle booleans
if value == "true" {
return true, nil
}
if value == "false" {
return false, nil
}
// Handle integers
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i, nil
}
// Handle floats
if f, err := strconv.ParseFloat(value, 64); err == nil {
return f, nil
}
// Handle JSON arrays and objects
if len(value) > 0 && (value[0] == '[' || value[0] == '{') {
var jsonVal any
if err := json.Unmarshal([]byte(value), &jsonVal); err == nil {
return jsonVal, nil
}
}
// Default to string
return value, nil
}
// isTextContentType returns true if the content type indicates text data
func isTextContentType(contentType string) bool {
if contentType == "" {
return true // assume text if unknown
}
contentType = strings.ToLower(strings.Split(contentType, ";")[0]) // strip charset
return strings.HasPrefix(contentType, "text/") ||
strings.Contains(contentType, "json") ||
strings.Contains(contentType, "xml") ||
strings.Contains(contentType, "javascript") ||
strings.Contains(contentType, "yaml") ||
strings.Contains(contentType, "toml")
}
// expandPlaceholders replaces {owner}, {repo}, and {branch} in the endpoint
func expandPlaceholders(endpoint string, ctx *context.TeaContext) string {
endpoint = strings.ReplaceAll(endpoint, "{owner}", ctx.Owner)
endpoint = strings.ReplaceAll(endpoint, "{repo}", ctx.Repo)
// Get current branch if available
if ctx.LocalRepo != nil {
if branch, err := ctx.LocalRepo.Head(); err == nil {
branchName := branch.Name().Short()
endpoint = strings.ReplaceAll(endpoint, "{branch}", branchName)
}
}
return endpoint
}

View File

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

View File

@@ -1,28 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"code.gitea.io/tea/cmd/attachments"
"code.gitea.io/tea/cmd/flags"
"github.com/urfave/cli/v3"
)
// CmdReleaseAttachments represents a release attachment (file attachment)
var CmdReleaseAttachments = cli.Command{
Name: "assets",
Aliases: []string{"asset", "a"},
Category: catEntities,
Usage: "Manage release assets",
Description: "Manage release assets",
ArgsUsage: " ", // command does not accept arguments
Action: attachments.RunReleaseAttachmentList,
Commands: []*cli.Command{
&attachments.CmdReleaseAttachmentList,
&attachments.CmdReleaseAttachmentCreate,
&attachments.CmdReleaseAttachmentDelete,
},
Flags: flags.AllDefaultFlags,
}

View File

@@ -1,70 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package attachments
import (
stdctx "context"
"fmt"
"os"
"path/filepath"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdReleaseAttachmentCreate represents a sub command of Release Attachments to create a release attachment
var CmdReleaseAttachmentCreate = cli.Command{
Name: "create",
Aliases: []string{"c"},
Usage: "Create one or more release attachments",
Description: `Create one or more release attachments`,
ArgsUsage: "<release-tag> <asset> [<asset>...]",
Action: runReleaseAttachmentCreate,
Flags: flags.AllDefaultFlags,
}
func runReleaseAttachmentCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
if ctx.Args().Len() < 2 {
return fmt.Errorf("No release tag or assets specified.\nUsage:\t%s", ctx.Command.UsageText)
}
tag := ctx.Args().First()
if len(tag) == 0 {
return fmt.Errorf("Release tag needed to create attachment")
}
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil {
return err
}
for _, asset := range ctx.Args().Slice()[1:] {
var file *os.File
if file, err = os.Open(asset); err != nil {
return err
}
filePath := filepath.Base(asset)
if _, _, err = ctx.Login.Client().CreateReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, file, filePath); err != nil {
file.Close()
return err
}
file.Close()
}
return nil
}

View File

@@ -1,88 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package attachments
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"
)
// CmdReleaseAttachmentDelete represents a sub command of Release Attachments to delete a release attachment
var CmdReleaseAttachmentDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "Delete one or more release attachments",
Description: `Delete one or more release attachments`,
ArgsUsage: "<release tag> <attachment name> [<attachment name>...]",
Action: runReleaseAttachmentDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "Confirm deletion (required)",
},
}, flags.AllDefaultFlags...),
}
func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
if ctx.Args().Len() < 2 {
return fmt.Errorf("No release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText)
}
tag := ctx.Args().First()
if len(tag) == 0 {
return fmt.Errorf("Release tag needed to delete attachment")
}
if !ctx.Bool("confirm") {
fmt.Println("Are you sure? Please confirm with -y or --confirm.")
return nil
}
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil {
return err
}
existing, _, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return err
}
for _, name := range ctx.Args().Slice()[1:] {
var attachment *gitea.Attachment
for _, a := range existing {
if a.Name == name {
attachment = a
}
}
if attachment == nil {
return fmt.Errorf("Release does not have attachment named '%s'", name)
}
_, err = client.DeleteReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, attachment.ID)
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,79 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package attachments
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"
)
// CmdReleaseAttachmentList represents a sub command of release attachment to list release attachments
var CmdReleaseAttachmentList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List Release Attachments",
Description: "List Release Attachments",
ArgsUsage: "<release-tag>", // command does not accept arguments
Action: RunReleaseAttachmentList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
}
// RunReleaseAttachmentList list release attachments
func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
tag := ctx.Args().First()
if len(tag) == 0 {
return fmt.Errorf("Release tag needed to list attachments")
}
release, err := 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(cmd),
})
if err != nil {
return err
}
return print.ReleaseAttachmentsList(attachments, ctx.Output)
}
func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
if len(rl) == 0 {
return nil, fmt.Errorf("Repo does not have any release")
}
for _, r := range rl {
if r.TagName == tag {
return r, nil
}
}
return nil, fmt.Errorf("Release tag does not exist")
}

View File

@@ -1,38 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"code.gitea.io/tea/cmd/branches"
"github.com/urfave/cli/v3"
)
// CmdBranches represents to login a gitea server.
var CmdBranches = cli.Command{
Name: "branches",
Aliases: []string{"branch", "b"},
Category: catEntities,
Usage: "Consult branches",
Description: `Lists branches when called without argument. If a branch is provided, will show it in detail.`,
ArgsUsage: "[<branch name>]",
Action: runBranches,
Commands: []*cli.Command{
&branches.CmdBranchesList,
&branches.CmdBranchesProtect,
&branches.CmdBranchesUnprotect,
},
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "comments",
Usage: "Whether to display comments (will prompt if not provided & run interactively)",
},
}, branches.CmdBranchesList.Flags...),
}
func runBranches(ctx context.Context, cmd *cli.Command) error {
return branches.RunBranchesList(ctx, cmd)
}

View File

@@ -1,76 +0,0 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package branches
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"
)
var branchFieldsFlag = flags.FieldsFlag(print.BranchFields, []string{
"name", "protected", "user-can-merge", "user-can-push",
})
// CmdBranchesListFlags Flags for command list
var CmdBranchesListFlags = append([]cli.Flag{
branchFieldsFlag,
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...)
// CmdBranchesList represents a sub command of branches to list branches
var CmdBranchesList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List branches of the repository",
Description: `List branches of the repository`,
ArgsUsage: " ", // command does not accept arguments
Action: RunBranchesList,
Flags: CmdBranchesListFlags,
}
// RunBranchesList list branches
func RunBranchesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
owner := ctx.Owner
if ctx.IsSet("owner") {
owner = ctx.String("owner")
}
var branches []*gitea.Branch
var protections []*gitea.BranchProtection
branches, _, err = ctx.Login.Client().ListRepoBranches(owner, ctx.Repo, gitea.ListRepoBranchesOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
protections, _, err = ctx.Login.Client().ListBranchProtections(owner, ctx.Repo, gitea.ListBranchProtectionsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
fields, err := branchFieldsFlag.GetValues(cmd)
if err != nil {
return err
}
return print.BranchesList(branches, protections, ctx.Output, fields)
}

View File

@@ -1,107 +0,0 @@
// Copyright 2024 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"
)
// CmdBranchesProtectFlags Flags for command protect/unprotect
var CmdBranchesProtectFlags = append([]cli.Flag{
branchFieldsFlag,
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...)
// CmdBranchesProtect represents a sub command of branches to protect a branch
var CmdBranchesProtect = cli.Command{
Name: "protect",
Aliases: []string{"P"},
Usage: "Protect branches",
Description: `Block actions push/merge on specified branches`,
ArgsUsage: "<branch>",
Action: RunBranchesProtect,
Flags: CmdBranchesProtectFlags,
}
// CmdBranchesUnprotect represents a sub command of branches to protect a branch
var CmdBranchesUnprotect = cli.Command{
Name: "unprotect",
Aliases: []string{"U"},
Usage: "Unprotect branches",
Description: `Suppress existing protections on specified branches`,
ArgsUsage: "<branch>",
Action: RunBranchesProtect,
Flags: CmdBranchesProtectFlags,
}
// RunBranchesProtect function to protect/unprotect a list of branches
func RunBranchesProtect(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if !cmd.Args().Present() {
return fmt.Errorf("must specify at least one branch")
}
owner := ctx.Owner
if ctx.IsSet("owner") {
owner = ctx.String("owner")
}
for _, branch := range ctx.Args().Slice() {
var err error
command := ctx.Command.Name
if command == "protect" {
_, _, err = ctx.Login.Client().CreateBranchProtection(owner, ctx.Repo, gitea.CreateBranchProtectionOption{
BranchName: branch,
RuleName: "",
EnablePush: false,
EnablePushWhitelist: false,
PushWhitelistUsernames: []string{},
PushWhitelistTeams: []string{},
PushWhitelistDeployKeys: false,
EnableMergeWhitelist: false,
MergeWhitelistUsernames: []string{},
MergeWhitelistTeams: []string{},
EnableStatusCheck: false,
StatusCheckContexts: []string{},
RequiredApprovals: 1,
EnableApprovalsWhitelist: false,
ApprovalsWhitelistUsernames: []string{},
ApprovalsWhitelistTeams: []string{},
BlockOnRejectedReviews: false,
BlockOnOfficialReviewRequests: false,
BlockOnOutdatedBranch: false,
DismissStaleApprovals: false,
RequireSignedCommits: false,
ProtectedFilePatterns: "",
UnprotectedFilePatterns: "",
})
} else if command == "unprotect" {
_, err = ctx.Login.Client().DeleteBranchProtection(owner, ctx.Repo, branch)
} else {
return fmt.Errorf("command %s is not supported", command)
}
if err != nil {
return err
}
}
return nil
}

View File

@@ -1,11 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
var (
catSetup = "SETUP"
catEntities = "ENTITIES"
catHelpers = "HELPERS"
catMisc = "MISCELLANEOUS"
)

View File

@@ -1,96 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdRepoClone represents a sub command of repos to create a local copy
var CmdRepoClone = cli.Command{
Name: "clone",
Aliases: []string{"C"},
Usage: "Clone a repository locally",
Description: `Clone a repository locally, without a local git installation required.
The repo slug can be specified in different formats:
gitea/tea
tea
gitea.com/gitea/tea
git@gitea.com:gitea/tea
https://gitea.com/gitea/tea
ssh://gitea.com:22/gitea/tea
When a host is specified in the repo-slug, it will override the login specified with --login.
`,
Category: catHelpers,
Action: runRepoClone,
ArgsUsage: "<repo-slug> [target dir]",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "depth",
Aliases: []string{"d"},
Usage: "num commits to fetch, defaults to all",
},
&flags.LoginFlag,
},
}
func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
teaCmd, err := context.InitCommand(cmd)
if err != nil {
return err
}
args := teaCmd.Args()
if args.Len() < 1 {
return cli.ShowCommandHelp(ctx, cmd, "clone")
}
dir := args.Get(1)
var (
login *config.Login = teaCmd.Login
owner string
repo string
)
// parse first arg as repo specifier
repoSlug := args.Get(0)
url, err := git.ParseURL(repoSlug)
if err != nil {
return err
}
debug.Printf("Cloning repository %s into %s", url.String(), dir)
owner, repo = utils.GetOwnerAndRepo(url.Path, login.User)
if url.Host != "" {
login = config.GetLoginByHost(url.Host)
if login == nil {
return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host)
}
debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host)
}
_, err = task.RepoClone(
dir,
login,
owner,
repo,
interact.PromptPassword,
teaCmd.Int("depth"),
)
return err
}

View File

@@ -1,107 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Tea is command line tool for Gitea.
package cmd // import "code.gitea.io/tea"
import (
"fmt"
"code.gitea.io/tea/modules/version"
"github.com/urfave/cli/v3"
)
// App creates and returns a tea Command with all subcommands set
// it was separated from main so docs can be generated for it
func App() *cli.Command {
// make parsing tea --version easier, by printing /just/ the version string
cli.VersionPrinter = func(c *cli.Command) { fmt.Fprintln(c.Writer, c.Version) }
return &cli.Command{
Name: "tea",
Usage: "command line tool to interact with Gitea",
Description: appDescription,
CustomHelpTemplate: helpTemplate,
Version: version.Format(),
Commands: []*cli.Command{
&CmdLogin,
&CmdLogout,
&CmdWhoami,
&CmdIssues,
&CmdPulls,
&CmdLabels,
&CmdMilestones,
&CmdReleases,
&CmdTrackedTimes,
&CmdOrgs,
&CmdRepos,
&CmdBranches,
&CmdActions,
&CmdWebhooks,
&CmdAddComment,
&CmdOpen,
&CmdNotifications,
&CmdRepoClone,
&CmdAdmin,
&CmdApi,
&CmdGenerateManPage,
},
EnableShellCompletion: true,
}
}
var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on
one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
tea tries to make use of context provided by the repository in $PWD if available.
tea works best in a upstream/fork workflow, when the local main branch tracks the
upstream repo. tea assumes that local git state is published on the remote before
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
`
var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", `
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + `
{{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}}
USAGE
{{if .UsageText}}{{.UsageText}}{{else}}{{.HelpName}}{{if .Commands}} command [subcommand] [command options]{{end}} {{if .ArgsUsage}}{{.ArgsUsage}}{{else}}[arguments...]{{end}}{{end}}{{if .Description}}
DESCRIPTION
{{.Description | nindent 3 | trim}}{{end}}{{if .VisibleCommands}}
COMMANDS{{range .VisibleCategories}}{{if .Name}}
{{.Name}}:{{range .VisibleCommands}}
{{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{else}}{{range .VisibleCommands}}
{{join .Names ", "}}{{"\t"}}{{.Usage}}{{end}}{{end}}{{end}}{{end}}{{if .VisibleFlags}}
OPTIONS
{{range $index, $option := .VisibleFlags}}{{if $index}}
{{end}}{{$option}}{{end}}{{end}}
EXAMPLES
tea login add # add a login once to get started
tea pulls # list open pulls for the repo in $PWD
tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo
tea pulls --remote upstream # list open pulls for the repo pointed at by
# your local "upstream" git remote
# list open pulls for any gitea repo at the given login instance
tea pulls --repo gitea/tea --login gitea.com
tea milestone issues 0.7.0 # view open issues for milestone '0.7.0'
tea issue 189 # view contents of issue 189
tea open 189 # open web ui for issue 189
tea open milestones # open web ui for milestones
# send gitea desktop notifications every 5 minutes (bash + libnotify)
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done
ABOUT
Written & maintained by The Gitea Authors.
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea.
More info about Gitea itself on https://about.gitea.com.
`

View File

@@ -1,95 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"errors"
"fmt"
"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"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils"
"charm.land/huh/v2"
"github.com/urfave/cli/v3"
)
// CmdAddComment is the main command to operate with notifications
var CmdAddComment = cli.Command{
Name: "comment",
Aliases: []string{"c"},
Category: catEntities,
Usage: "Add a comment to an issue / pr",
Description: "Add a comment to an issue / pr",
ArgsUsage: "<issue / pr index> [<comment body>]",
Action: runAddComment,
Flags: flags.AllDefaultFlags,
}
func runAddComment(_ 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
}
args := ctx.Args()
if args.Len() == 0 {
return fmt.Errorf("Please specify issue / pr index")
}
idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil {
return err
}
body := strings.Join(ctx.Args().Tail(), " ")
if interact.IsStdinPiped() {
// custom solution until https://github.com/AlecAivazis/survey/issues/328 is fixed
if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil {
return err
} else if len(bodyStdin) != 0 {
body = strings.Join([]string{body, string(bodyStdin)}, "\n\n")
}
} else if len(body) == 0 {
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Comment(markdown):").
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&body),
),
).WithTheme(theme.GetTheme()).
Run(); err != nil {
return err
}
}
if len(body) == 0 {
return errors.New("no comment content provided")
}
client := ctx.Login.Client()
comment, _, err := client.CreateIssueComment(ctx.Owner, ctx.Repo, idx, gitea.CreateIssueCommentOption{
Body: body,
})
if err != nil {
return err
}
print.Comment(comment)
return nil
}

255
cmd/config.go Normal file
View File

@@ -0,0 +1,255 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"crypto/tls"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/utils"
"gopkg.in/yaml.v2"
)
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Active bool `yaml:"active"`
SSHHost string `yaml:"ssh_host"`
// optional path to the private key
SSHKey string `yaml:"ssh_key"`
Insecure bool `yaml:"insecure"`
// optional gitea username
User string `yaml:"user"`
}
// Client returns a client to operate Gitea API
func (l *Login) Client() *gitea.Client {
client := gitea.NewClient(l.URL, l.Token)
if l.Insecure {
cookieJar, _ := cookiejar.New(nil)
client.SetHTTPClient(&http.Client{
Jar: cookieJar,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
})
}
return client
}
// GetSSHHost returns SSH host name
func (l *Login) GetSSHHost() string {
if l.SSHHost != "" {
return l.SSHHost
}
u, err := url.Parse(l.URL)
if err != nil {
return ""
}
return u.Hostname()
}
// Config reprensents local configurations
type Config struct {
Logins []Login `yaml:"logins"`
}
var (
config Config
yamlConfigPath string
)
func init() {
homeDir, err := utils.Home()
if err != nil {
log.Fatal("Retrieve home dir failed")
}
dir := filepath.Join(homeDir, ".tea")
err = os.MkdirAll(dir, os.ModePerm)
if err != nil {
log.Fatal("Init tea config dir " + dir + " failed")
}
yamlConfigPath = filepath.Join(dir, "tea.yml")
}
func splitRepo(repoPath string) (string, string) {
p := strings.Split(repoPath, "/")
if len(p) >= 2 {
return p[0], p[1]
}
return repoPath, ""
}
func getActiveLogin() (*Login, error) {
if len(config.Logins) == 0 {
return nil, errors.New("No available login")
}
for _, l := range config.Logins {
if l.Active {
return &l, nil
}
}
return &config.Logins[0], nil
}
func getLoginByName(name string) *Login {
for _, l := range config.Logins {
if l.Name == name {
return &l
}
}
return nil
}
func addLogin(login Login) error {
for _, l := range config.Logins {
if l.Name == login.Name {
if l.URL == login.URL && l.Token == login.Token {
return nil
}
return errors.New("Login name has already been used")
}
if l.URL == login.URL && l.Token == login.Token {
return errors.New("URL has been added")
}
}
u, err := url.Parse(login.URL)
if err != nil {
return err
}
if login.SSHHost == "" {
login.SSHHost = u.Hostname()
}
config.Logins = append(config.Logins, login)
return nil
}
func isFileExist(fileName string) (bool, error) {
f, err := os.Stat(fileName)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if f.IsDir() {
return false, errors.New("A directory with the same name exists")
}
return true, nil
}
func loadConfig(ymlPath string) error {
exist, _ := isFileExist(ymlPath)
if exist {
Println("Found config file", ymlPath)
bs, err := ioutil.ReadFile(ymlPath)
if err != nil {
return err
}
err = yaml.Unmarshal(bs, &config)
if err != nil {
return err
}
}
return nil
}
func saveConfig(ymlPath string) error {
bs, err := yaml.Marshal(&config)
if err != nil {
return err
}
return ioutil.WriteFile(ymlPath, bs, 0660)
}
func curGitRepoPath(path string) (*Login, string, error) {
var err error
var repo *git.TeaRepo
if len(path) == 0 {
repo, err = git.RepoForWorkdir()
} else {
repo, err = git.RepoFromPath(path)
}
if err != nil {
return nil, "", err
}
gitConfig, err := repo.Config()
if err != nil {
return nil, "", err
}
// if no remote
if len(gitConfig.Remotes) == 0 {
return nil, "", errors.New("No remote(s) found in this Git repository")
}
// if only one remote exists
if len(gitConfig.Remotes) >= 1 && len(remoteValue) == 0 {
for remote := range gitConfig.Remotes {
remoteValue = remote
}
if len(gitConfig.Remotes) > 1 {
// if master branch is present, use it as the default remote
masterBranch, ok := gitConfig.Branches["master"]
if ok {
if len(masterBranch.Remote) > 0 {
remoteValue = masterBranch.Remote
}
}
}
}
remoteConfig, ok := gitConfig.Remotes[remoteValue]
if !ok || remoteConfig == nil {
return nil, "", errors.New("Remote " + remoteValue + " not found in this Git repository")
}
for _, l := range config.Logins {
for _, u := range remoteConfig.URLs {
p, err := git.ParseURL(strings.TrimSpace(u))
if err != nil {
return nil, "", fmt.Errorf("Git remote URL parse failed: %s", err.Error())
}
if strings.EqualFold(p.Scheme, "http") || strings.EqualFold(p.Scheme, "https") {
if strings.HasPrefix(u, l.URL) {
ps := strings.Split(p.Path, "/")
path := strings.Join(ps[len(ps)-2:], "/")
return &l, strings.TrimSuffix(path, ".git"), nil
}
} else if strings.EqualFold(p.Scheme, "ssh") {
if l.GetSSHHost() == strings.Split(p.Host, ":")[0] {
return &l, strings.TrimLeft(strings.TrimSuffix(p.Path, ".git"), "/"), nil
}
}
}
}
return nil, "", errors.New("No Gitea login found. You might want to specify --repo (and --login) to work outside of a repository")
}

View File

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

126
cmd/flags.go Normal file
View File

@@ -0,0 +1,126 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"log"
"github.com/urfave/cli/v2"
)
// create global variables for global Flags to simplify
// access to the options without requiring cli.Context
var (
loginValue string
repoValue string
outputValue string
remoteValue string
)
// LoginFlag provides flag to specify tea login profile
var LoginFlag = cli.StringFlag{
Name: "login",
Aliases: []string{"l"},
Usage: "Use a different Gitea login. Optional",
Destination: &loginValue,
}
// RepoFlag provides flag to specify repository
var RepoFlag = cli.StringFlag{
Name: "repo",
Aliases: []string{"r"},
Usage: "Repository to interact with. Optional",
Destination: &repoValue,
}
// RemoteFlag provides flag to specify remote repository
var RemoteFlag = cli.StringFlag{
Name: "remote",
Aliases: []string{"R"},
Usage: "Discover Gitea login from remote. Optional",
Destination: &remoteValue,
}
// OutputFlag provides flag to specify output type
var OutputFlag = cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Output format. (csv, simple, table, tsv, yaml)",
Destination: &outputValue,
}
// LoginOutputFlags defines login and output flags that should
// added to all subcommands and appended to the flags of the
// subcommand to work around issue and provide --login and --output:
// https://github.com/urfave/cli/issues/585
var LoginOutputFlags = []cli.Flag{
&LoginFlag,
&OutputFlag,
}
// LoginRepoFlags defines login and repo flags that should
// be used for all subcommands and appended to the flags of
// the subcommand to work around issue and provide --login and --repo:
// https://github.com/urfave/cli/issues/585
var LoginRepoFlags = []cli.Flag{
&LoginFlag,
&RepoFlag,
&RemoteFlag,
}
// AllDefaultFlags defines flags that should be available
// for all subcommands working with dedicated repositories
// to work around issue and provide --login, --repo and --output:
// https://github.com/urfave/cli/issues/585
var AllDefaultFlags = append([]cli.Flag{
&RepoFlag,
&RemoteFlag,
}, LoginOutputFlags...)
// initCommand returns repository and *Login based on flags
func initCommand() (*Login, string, string) {
err := loadConfig(yamlConfigPath)
if err != nil {
log.Fatal("load config file failed ", yamlConfigPath)
}
login, repoPath, err := curGitRepoPath(repoValue)
if err != nil {
log.Fatal(err.Error())
}
if loginValue != "" {
login = getLoginByName(loginValue)
if login == nil {
log.Fatal("Login name " + loginValue + " does not exist")
}
}
owner, repo := splitRepo(repoPath)
return login, owner, repo
}
// initCommandLoginOnly return *Login based on flags
func initCommandLoginOnly() *Login {
err := loadConfig(yamlConfigPath)
if err != nil {
log.Fatal("load config file failed ", yamlConfigPath)
}
var login *Login
if loginValue == "" {
login, err = getActiveLogin()
if err != nil {
log.Fatal(err)
}
} else {
login = getLoginByName(loginValue)
if login == nil {
log.Fatal("Login name " + loginValue + " does not exist")
}
}
return login
}

View File

@@ -1,52 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"fmt"
"strings"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CsvFlag is a wrapper around cli.StringFlag, with an added GetValues() method
// to retrieve comma separated string values as a slice.
type CsvFlag struct {
cli.StringFlag
AvailableFields []string
}
// NewCsvFlag creates a CsvFlag, while setting its usage string and default values
func NewCsvFlag(name, usage string, aliases, availableValues, defaults []string) *CsvFlag {
var availableDesc string
if len(availableValues) != 0 {
availableDesc = " Available values:"
}
return &CsvFlag{
AvailableFields: availableValues,
StringFlag: cli.StringFlag{
Name: name,
Aliases: aliases,
Value: strings.Join(defaults, ","),
Usage: fmt.Sprintf(`Comma-separated list of %s.%s
%s
`, usage, availableDesc, strings.Join(availableValues, ",")),
},
}
}
// GetValues returns the value of the flag, parsed as a commaseparated list
func (f CsvFlag) GetValues(cmd *cli.Command) ([]string, error) {
val := cmd.String(f.Name)
selection := strings.Split(val, ",")
if f.AvailableFields != nil && val != "" {
for _, field := range selection {
if !utils.Contains(f.AvailableFields, field) {
return nil, fmt.Errorf("Invalid field '%s'", field)
}
}
}
return selection, nil
}

View File

@@ -1,189 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"errors"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// LoginFlag provides flag to specify tea login profile
var LoginFlag = cli.StringFlag{
Name: "login",
Aliases: []string{"l"},
Usage: "Use a different Gitea Login. Optional",
}
// RepoFlag provides flag to specify repository
var RepoFlag = cli.StringFlag{
Name: "repo",
Aliases: []string{"r"},
Usage: "Override local repository path or gitea repository slug to interact with. Optional",
}
// RemoteFlag provides flag to specify remote repository
var RemoteFlag = cli.StringFlag{
Name: "remote",
Aliases: []string{"R"},
Usage: "Discover Gitea login from remote. Optional",
}
// OutputFlag provides flag to specify output type
var OutputFlag = cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Output format. (simple, table, csv, tsv, yaml, json)",
}
var (
// ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0).
ErrPage = errors.New("page cannot be smaller than 1")
// ErrLimit indicates that the provided limit value is invalid (negative).
ErrLimit = errors.New("limit cannot be negative")
)
const (
defaultPageValue = 1
defaultLimitValue = 30
)
// GetListOptions returns list options derived from the active command.
func GetListOptions(cmd *cli.Command) gitea.ListOptions {
page := cmd.Int("page")
if page == 0 {
page = defaultPageValue
}
pageSize := cmd.Int("limit")
if pageSize == 0 {
pageSize = defaultLimitValue
}
return gitea.ListOptions{
Page: page,
PageSize: pageSize,
}
}
// PaginationFlags provides all pagination related flags
var PaginationFlags = []cli.Flag{
&PaginationPageFlag,
&PaginationLimitFlag,
}
// PaginationPageFlag provides flag for pagination options
var PaginationPageFlag = cli.IntFlag{
Name: "page",
Aliases: []string{"p"},
Usage: "specify page",
Value: defaultPageValue,
Validator: func(i int) error {
if i < 1 && i != -1 {
return ErrPage
}
return nil
},
}
// PaginationLimitFlag provides flag for pagination options
var PaginationLimitFlag = cli.IntFlag{
Name: "limit",
Aliases: []string{"lm"},
Usage: "specify limit of items per page",
Value: defaultLimitValue,
Validator: func(i int) error {
if i < 0 {
return ErrLimit
}
return nil
},
}
// LoginOutputFlags defines login and output flags that should
// added to all subcommands and appended to the flags of the
// subcommand to work around issue and provide --login and --output:
// https://github.com/urfave/cli/issues/585
var LoginOutputFlags = []cli.Flag{
&LoginFlag,
&OutputFlag,
}
// LoginRepoFlags defines login and repo flags that should
// be used for all subcommands and appended to the flags of
// the subcommand to work around issue and provide --login and --repo:
// https://github.com/urfave/cli/issues/585
var LoginRepoFlags = []cli.Flag{
&LoginFlag,
&RepoFlag,
&RemoteFlag,
}
// AllDefaultFlags defines flags that should be available
// for all subcommands working with dedicated repositories
// to work around issue and provide --login, --repo and --output:
// https://github.com/urfave/cli/issues/585
var AllDefaultFlags = append([]cli.Flag{
&RepoFlag,
&RemoteFlag,
}, LoginOutputFlags...)
// NotificationFlags defines flags that should be available on notifications.
var NotificationFlags = append([]cli.Flag{
NotificationStateFlag,
&cli.BoolFlag{
Name: "mine",
Aliases: []string{"m"},
Usage: "Show notifications across all your repositories instead of the current repository only",
},
&PaginationPageFlag,
&PaginationLimitFlag,
}, AllDefaultFlags...)
// NotificationStateFlag is a csv flag applied to all notification subcommands as filter
var NotificationStateFlag = NewCsvFlag(
"states",
"notification states to filter by",
[]string{"s"},
[]string{"pinned", "unread", "read"},
[]string{"unread", "pinned"},
)
// FieldsFlag generates a flag selecting printable fields.
// To retrieve the value, use f.GetValues()
func FieldsFlag(availableFields, defaultFields []string) *CsvFlag {
return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields)
}
// ParseState parses a state string and returns the corresponding gitea.StateType
func ParseState(stateStr string) (gitea.StateType, error) {
switch stateStr {
case "all":
return gitea.StateAll, nil
case "", "open":
return gitea.StateOpen, nil
case "closed":
return gitea.StateClosed, nil
default:
return "", errors.New("unknown state '" + stateStr + "'")
}
}
// ParseIssueKind parses a kind string and returns the corresponding gitea.IssueType.
// If kindStr is empty, returns the provided defaultKind.
func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueType, error) {
switch kindStr {
case "":
return defaultKind, nil
case "all":
return gitea.IssueTypeAll, nil
case "issue", "issues":
return gitea.IssueTypeIssue, nil
case "pull", "pulls", "pr":
return gitea.IssueTypePull, nil
default:
return "", errors.New("unknown kind '" + kindStr + "'")
}
}

View File

@@ -1,152 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"context"
"io"
"testing"
"code.gitea.io/sdk/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestPaginationFlags(t *testing.T) {
var (
defaultPage = PaginationPageFlag.Value
defaultLimit = PaginationLimitFlag.Value
)
cases := []struct {
name string
args []string
expectedPage int
expectedLimit int
}{
{
name: "no flags",
args: []string{"test"},
expectedPage: defaultPage,
expectedLimit: defaultLimit,
},
{
name: "only paging",
args: []string{"test", "--page", "5"},
expectedPage: 5,
expectedLimit: defaultLimit,
},
{
name: "only limit",
args: []string{"test", "--limit", "10"},
expectedPage: defaultPage,
expectedLimit: 10,
},
{
name: "only limit",
args: []string{"test", "--limit", "10"},
expectedPage: defaultPage,
expectedLimit: 10,
},
{
name: "both flags",
args: []string{"test", "--page", "2", "--limit", "20"},
expectedPage: 2,
expectedLimit: 20,
},
{ // TODO: Should no paging be applied as -1 or a separate flag? It's not obvious that page=-1 turns off paging and limit is ignored
name: "no paging",
args: []string{"test", "--limit", "20", "--page", "-1"},
expectedPage: -1,
expectedLimit: 20,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := cli.Command{
Name: "test-paging",
Action: func(_ context.Context, cmd *cli.Command) error {
assert.Equal(t, tc.expectedPage, cmd.Int("page"))
assert.Equal(t, tc.expectedLimit, cmd.Int("limit"))
return nil
},
Flags: PaginationFlags,
}
err := cmd.Run(context.Background(), tc.args)
require.NoError(t, err)
})
}
}
func TestPaginationFailures(t *testing.T) {
cases := []struct {
name string
args []string
expectedError error
}{
{
name: "negative limit",
args: []string{"test", "--limit", "-10"},
expectedError: ErrLimit,
},
{
name: "negative paging",
args: []string{"test", "--page", "-2"},
expectedError: ErrPage,
},
{
name: "zero paging",
args: []string{"test", "--page", "0"},
expectedError: ErrPage,
},
{
// urfave does not validate all flags in one pass
name: "negative paging and paging",
args: []string{"test", "--page", "-2", "--limit", "-10"},
expectedError: ErrPage,
},
}
for _, tc := range cases {
cmd := cli.Command{
Name: "test-paging",
Flags: PaginationFlags,
Writer: io.Discard,
ErrWriter: io.Discard,
}
t.Run(tc.name, func(t *testing.T) {
err := cmd.Run(context.Background(), tc.args)
require.ErrorContains(t, err, tc.expectedError.Error())
// require.ErrorIs(t, err, tc.expectedError)
})
}
}
func TestGetListOptionsDoesNotLeakBetweenCommands(t *testing.T) {
var results []gitea.ListOptions
run := func(args []string) {
t.Helper()
cmd := cli.Command{
Name: "test-paging",
Action: func(_ context.Context, cmd *cli.Command) error {
results = append(results, GetListOptions(cmd))
return nil
},
Flags: PaginationFlags,
}
require.NoError(t, cmd.Run(context.Background(), args))
}
run([]string{"test", "--page", "5", "--limit", "10"})
run([]string{"test"})
require.Len(t, results, 2)
assert.Equal(t, gitea.ListOptions{Page: 5, PageSize: 10}, results[0])
assert.Equal(t, gitea.ListOptions{Page: defaultPageValue, PageSize: defaultLimitValue}, results[1])
}

View File

@@ -1,238 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"fmt"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"github.com/araddon/dateparse"
"github.com/urfave/cli/v3"
)
// StateFlag provides flag to specify issue/pr state, defaulting to "open"
var StateFlag = cli.StringFlag{
Name: "state",
Usage: "Filter by state (all|open|closed)",
DefaultText: "open",
}
// MilestoneFilterFlag is a CSV flag used to filter issues by milestones
var MilestoneFilterFlag = NewCsvFlag(
"milestones",
"milestones to match issues against",
[]string{"m"}, nil, nil)
// LabelFilterFlag is a CSV flag used to filter issues by labels
var LabelFilterFlag = NewCsvFlag(
"labels",
"labels to match issues against",
[]string{"L"}, nil, nil)
// PRListingFlags defines flags that should be available on pr listing flags.
var PRListingFlags = append([]cli.Flag{
&StateFlag,
&PaginationPageFlag,
&PaginationLimitFlag,
}, AllDefaultFlags...)
// IssueListingFlags defines flags that should be available on issue listing flags.
var IssueListingFlags = append([]cli.Flag{
&StateFlag,
&cli.StringFlag{
Name: "kind",
Aliases: []string{"K"},
Usage: "Whether to return `issues`, `pulls`, or `all` (you can use this to apply advanced search filters to PRs)",
DefaultText: "issues",
},
&cli.StringFlag{
Name: "keyword",
Aliases: []string{"k"},
Usage: "Filter by search string",
},
LabelFilterFlag,
MilestoneFilterFlag,
&cli.StringFlag{
Name: "author",
Aliases: []string{"A"},
},
&cli.StringFlag{
Name: "assignee",
Aliases: []string{"a"},
},
&cli.StringFlag{
Name: "mentions",
Aliases: []string{"M"},
},
&cli.StringFlag{
Name: "owner",
Aliases: []string{"org"},
},
&cli.StringFlag{
Name: "from",
Aliases: []string{"F"},
Usage: "Filter by activity after this date",
},
&cli.StringFlag{
Name: "until",
Aliases: []string{"u"},
Usage: "Filter by activity before this date",
},
&PaginationPageFlag,
&PaginationLimitFlag,
}, AllDefaultFlags...)
// issuePRFlags defines shared flags between flags IssuePRCreateFlags and IssuePREditFlags
var issuePRFlags = append([]cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"d"},
},
&cli.StringFlag{
Name: "referenced-version",
Aliases: []string{"v"},
Usage: "commit-hash or tag name to assign",
},
&cli.StringFlag{
Name: "milestone",
Aliases: []string{"m"},
Usage: "Milestone to assign",
},
&cli.StringFlag{
Name: "deadline",
Aliases: []string{"D"},
Usage: "Deadline timestamp to assign",
},
}, LoginRepoFlags...)
// IssuePRCreateFlags defines flags for creation of issues and PRs
var IssuePRCreateFlags = append([]cli.Flag{
&cli.StringFlag{
Name: "assignees",
Aliases: []string{"a"},
Usage: "Comma-separated list of usernames to assign",
},
&cli.StringFlag{
Name: "labels",
Aliases: []string{"L"},
Usage: "Comma-separated list of labels to assign",
},
}, issuePRFlags...)
// GetIssuePRCreateFlags parses all IssuePREditFlags
func GetIssuePRCreateFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, error) {
opts := gitea.CreateIssueOption{
Title: ctx.String("title"),
Body: ctx.String("description"),
Assignees: strings.Split(ctx.String("assignees"), ","),
}
var err error
date := ctx.String("deadline")
if date != "" {
t, err := dateparse.ParseAny(date)
if err != nil {
return nil, err
}
opts.Deadline = &t
}
client := ctx.Login.Client()
labelNames := strings.Split(ctx.String("labels"), ",")
if len(labelNames) != 0 {
if client == nil {
client = ctx.Login.Client()
}
if opts.Labels, err = task.ResolveLabelNames(client, ctx.Owner, ctx.Repo, labelNames); err != nil {
return nil, err
}
}
if milestoneName := ctx.String("milestone"); len(milestoneName) != 0 {
if client == nil {
client = ctx.Login.Client()
}
ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName)
if err != nil {
return nil, fmt.Errorf("Milestone '%s' not found", milestoneName)
}
opts.Milestone = ms.ID
}
return &opts, nil
}
// IssuePREditFlags defines flags for editing properties of issues and PRs
var IssuePREditFlags = append([]cli.Flag{
&cli.StringFlag{
Name: "add-assignees",
Aliases: []string{"a"},
Usage: "Comma-separated list of usernames to assign",
},
&cli.StringFlag{
Name: "add-labels",
Aliases: []string{"L"},
Usage: "Comma-separated list of labels to assign. Takes precedence over --remove-labels",
},
&cli.StringFlag{
Name: "remove-labels",
Usage: "Comma-separated list of labels to remove",
},
}, issuePRFlags...)
// GetIssuePREditFlags parses all IssuePREditFlags
func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error) {
opts := task.EditIssueOption{}
if ctx.IsSet("title") {
val := ctx.String("title")
opts.Title = &val
}
if ctx.IsSet("description") {
val := ctx.String("description")
opts.Body = &val
}
if ctx.IsSet("referenced-version") {
val := ctx.String("referenced-version")
opts.Ref = &val
}
if ctx.IsSet("milestone") {
val := ctx.String("milestone")
opts.Milestone = &val
}
if ctx.IsSet("deadline") {
date := ctx.String("deadline")
if date == "" {
opts.Deadline = &time.Time{}
} else {
t, err := dateparse.ParseAny(date)
if err != nil {
return nil, err
}
opts.Deadline = &t
}
}
if ctx.IsSet("add-assignees") {
val := ctx.String("add-assignees")
opts.AddAssignees = strings.Split(val, ",")
}
if ctx.IsSet("add-labels") {
val := ctx.String("add-labels")
opts.AddLabels = strings.Split(val, ",")
}
if ctx.IsSet("remove-labels") {
val := ctx.String("remove-labels")
opts.RemoveLabels = strings.Split(val, ",")
}
return &opts, nil
}

View File

@@ -1,173 +1,217 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
stdctx "context"
"fmt"
"time"
"log"
"strconv"
"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"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
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",
Aliases: []string{"issue", "i"},
Category: catEntities,
Usage: "List, create and update issues",
Description: `Lists issues when called without argument. If issue index is provided, will show it in detail.`,
Usage: "List and create issues",
Description: `List and create issues`,
ArgsUsage: "[<issue index>]",
Action: runIssues,
Commands: []*cli.Command{
&issues.CmdIssuesList,
&issues.CmdIssuesCreate,
&issues.CmdIssuesEdit,
&issues.CmdIssuesReopen,
&issues.CmdIssuesClose,
Subcommands: []*cli.Command{
&CmdIssuesList,
&CmdIssuesCreate,
&CmdIssuesReopen,
&CmdIssuesClose,
},
Flags: AllDefaultFlags,
}
// CmdIssuesList represents a sub command of issues to list issues
var CmdIssuesList = cli.Command{
Name: "ls",
Usage: "List issues of the repository",
Description: `List issues of the repository`,
Action: runIssuesList,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "comments",
Usage: "Whether to display comments (will prompt if not provided & run interactively)",
&cli.StringFlag{
Name: "state",
Usage: "Filter by issue state (all|open|closed)",
DefaultText: "open",
},
}, issues.CmdIssuesList.Flags...),
}, AllDefaultFlags...),
}
func runIssues(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runIssueDetail(ctx, cmd, cmd.Args().First())
func runIssues(ctx *cli.Context) error {
if ctx.Args().Len() == 1 {
return runIssueDetail(ctx, ctx.Args().First())
}
return issues.RunIssuesList(ctx, cmd)
return runIssuesList(ctx)
}
func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
ctx, idx, err := resolveIssueDetailContext(cmd, index)
func runIssueDetail(ctx *cli.Context, index string) error {
login, owner, repo := initCommand()
idx, err := argToIndex(index)
if err != nil {
return err
}
issue, err := login.Client().GetIssue(owner, repo, idx)
if err != nil {
return err
}
return runIssueDetailWithClient(ctx, idx, ctx.Login.Client())
fmt.Printf("#%d %s\n%s created %s\n\n%s\n", issue.Index,
issue.Title,
issue.Poster.UserName,
issue.Created.Format("2006-01-02 15:04:05"),
issue.Body,
)
return nil
}
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
func runIssuesList(ctx *cli.Context) error {
login, owner, repo := initCommand()
state := gitea.StateOpen
switch ctx.String("state") {
case "all":
state = gitea.StateAll
case "open":
state = gitea.StateOpen
case "closed":
state = gitea.StateClosed
}
idx, err := utils.ArgToIndex(index)
issues, err := login.Client().ListRepoIssues(owner, repo, gitea.ListIssueOption{
State: state,
Type: gitea.IssueTypeIssue,
})
if err != nil {
return nil, 0, err
log.Fatal(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
}
reactions, _, err := client.GetIssueReactions(ctx.Owner, ctx.Repo, idx)
if err != nil {
return err
headers := []string{
"Index",
"State",
"Author",
"Updated",
"Title",
}
if ctx.IsSet("output") {
switch ctx.String("output") {
case "json":
return runIssueDetailAsJSON(ctx, issue)
var values [][]string
if len(issues) == 0 {
Output(outputValue, headers, values)
return nil
}
for _, issue := range issues {
name := issue.Poster.FullName
if len(name) == 0 {
name = issue.Poster.UserName
}
values = append(
values,
[]string{
strconv.FormatInt(issue.Index, 10),
string(issue.State),
name,
issue.Updated.Format("2006-01-02 15:04:05"),
issue.Title,
},
)
}
Output(outputValue, headers, values)
print.IssueDetails(issue, reactions)
return nil
}
if issue.Comments > 0 {
err = interact.ShowCommentsMaybeInteractive(ctx, idx, issue.Comments)
if err != nil {
return fmt.Errorf("error loading comments: %v", err)
}
// CmdIssuesCreate represents a sub command of issues to create issue
var CmdIssuesCreate = cli.Command{
Name: "create",
Usage: "Create an issue on repository",
Description: `Create an issue on repository`,
Action: runIssuesCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
Usage: "issue title to create",
},
&cli.StringFlag{
Name: "body",
Aliases: []string{"b"},
Usage: "issue body to create",
},
}, LoginRepoFlags...),
}
func runIssuesCreate(ctx *cli.Context) error {
login, owner, repo := initCommand()
_, err := login.Client().CreateIssue(owner, repo, gitea.CreateIssueOption{
Title: ctx.String("title"),
Body: ctx.String("body"),
// TODO:
//Assignee string `json:"assignee"`
//Assignees []string `json:"assignees"`
//Deadline *time.Time `json:"due_date"`
//Milestone int64 `json:"milestone"`
//Labels []int64 `json:"labels"`
//Closed bool `json:"closed"`
})
if err != nil {
log.Fatal(err)
}
return nil
}
func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error {
return runIssueDetailAsJSONWithClient(ctx, issue, ctx.Login.Client())
// CmdIssuesReopen represents a sub command of issues to open an issue
var CmdIssuesReopen = cli.Command{
Name: "reopen",
Aliases: []string{"open"},
Usage: "Change state of an issue to 'open'",
Description: `Change state of an issue to 'open'`,
ArgsUsage: "<issue index>",
Action: func(ctx *cli.Context) error {
var s = gitea.StateOpen
return editIssueState(ctx, gitea.EditIssueOption{State: &s})
},
Flags: AllDefaultFlags,
}
func runIssueDetailAsJSONWithClient(ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error {
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
comments := []*gitea.Comment{}
// CmdIssuesClose represents a sub command of issues to close an issue
var CmdIssuesClose = cli.Command{
Name: "close",
Usage: "Change state of an issue to 'closed'",
Description: `Change state of an issue to 'closed'`,
ArgsUsage: "<issue index>",
Action: func(ctx *cli.Context) error {
var s = gitea.StateClosed
return editIssueState(ctx, gitea.EditIssueOption{State: &s})
},
Flags: AllDefaultFlags,
}
if ctx.Bool("comments") {
var err error
comments, _, err = c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts)
if err != nil {
return err
}
// editIssueState abstracts the arg parsing to edit the given issue
func editIssueState(ctx *cli.Context, opts gitea.EditIssueOption) error {
login, owner, repo := initCommand()
if ctx.Args().Len() == 0 {
log.Fatal(ctx.Command.ArgsUsage)
}
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),
index, err := argToIndex(ctx.Args().First())
if err != nil {
return err
}
_, err = login.Client().EditIssue(owner, repo, index, opts)
return err
}

View File

@@ -1,64 +0,0 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdIssuesClose represents a sub command of issues to close an issue
var CmdIssuesClose = cli.Command{
Name: "close",
Usage: "Change state of one ore more issues to 'closed'",
Description: `Change state of one ore more issues to 'closed'`,
ArgsUsage: "<issue index> [<issue index>...]",
Action: func(ctx stdctx.Context, cmd *cli.Command) error {
s := gitea.StateClosed
return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s})
},
Flags: flags.AllDefaultFlags,
}
// editIssueState abstracts the arg parsing to edit the given issue
func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() == 0 {
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage)
}
indices, err := utils.ArgsToIndices(ctx.Args().Slice())
if err != nil {
return err
}
client := ctx.Login.Client()
for _, index := range indices {
issue, _, err := client.EditIssue(ctx.Owner, ctx.Repo, index, opts)
if err != nil {
return err
}
if len(indices) > 1 {
fmt.Println(issue.HTMLURL)
} else {
print.IssueDetails(issue, nil)
}
}
return nil
}

View File

@@ -1,56 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdIssuesCreate represents a sub command of issues to create issue
var CmdIssuesCreate = cli.Command{
Name: "create",
Aliases: []string{"c"},
Usage: "Create an issue on repository",
Description: `Create an issue on repository`,
ArgsUsage: " ", // command does not accept arguments
Action: runIssuesCreate,
Flags: flags.IssuePRCreateFlags,
}
func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.IsInteractiveMode() {
err := interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo)
if err != nil && !interact.IsQuitting(err) {
return err
}
return nil
}
opts, err := flags.GetIssuePRCreateFlags(ctx)
if err != nil {
return err
}
return task.CreateIssue(
ctx.Login,
ctx.Owner,
ctx.Repo,
*opts,
)
}

View File

@@ -1,80 +0,0 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"fmt"
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdIssuesEdit is the subcommand of issues to edit issues
var CmdIssuesEdit = cli.Command{
Name: "edit",
Aliases: []string{"e"},
Usage: "Edit one or more issues",
Description: `Edit one or more issues. To unset a property again,
use an empty string (eg. --milestone "").`,
ArgsUsage: "<idx> [<idx>...]",
Action: runIssuesEdit,
Flags: flags.IssuePREditFlags,
}
func runIssuesEdit(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if !cmd.Args().Present() {
return fmt.Errorf("must specify at least one issue index")
}
opts, err := flags.GetIssuePREditFlags(ctx)
if err != nil {
return err
}
indices, err := utils.ArgsToIndices(ctx.Args().Slice())
if err != nil {
return err
}
client := ctx.Login.Client()
for _, opts.Index = range indices {
if ctx.IsInteractiveMode() {
var err error
opts, err = interact.EditIssue(*ctx, opts.Index)
if err != nil {
if interact.IsQuitting(err) {
return nil // user quit
}
return err
}
}
issue, err := task.EditIssue(ctx, client, *opts)
if err != nil {
return err
}
if ctx.Args().Len() > 1 {
fmt.Println(issue.HTMLURL)
} else {
print.IssueDetails(issue, nil)
}
}
return nil
}

View File

@@ -1,116 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
stdctx "context"
"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/araddon/dateparse"
"github.com/urfave/cli/v3"
)
var issueFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{
"index", "title", "state", "author", "milestone", "labels", "owner", "repo",
})
// CmdIssuesList represents a sub command of issues to list issues
var CmdIssuesList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List issues of the repository",
Description: `List issues of the repository`,
ArgsUsage: " ", // command does not accept arguments
Action: RunIssuesList,
Flags: append([]cli.Flag{issueFieldsFlag}, flags.IssueListingFlags...),
}
// RunIssuesList list issues
func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
state, err := flags.ParseState(ctx.String("state"))
if err != nil {
return err
}
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeIssue)
if err != nil {
return err
}
var from, until time.Time
if ctx.IsSet("from") {
from, err = dateparse.ParseLocal(ctx.String("from"))
if err != nil {
return err
}
}
if ctx.IsSet("until") {
until, err = dateparse.ParseLocal(ctx.String("until"))
if err != nil {
return err
}
}
owner := ctx.Owner
if ctx.IsSet("owner") {
owner = ctx.String("owner")
}
// ignore error, as we don't do any input validation on these flags
labels, _ := flags.LabelFilterFlag.GetValues(cmd)
milestones, _ := flags.MilestoneFilterFlag.GetValues(cmd)
var issues []*gitea.Issue
if ctx.Repo != "" {
issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd),
State: state,
Type: kind,
KeyWord: ctx.String("keyword"),
CreatedBy: ctx.String("author"),
AssignedBy: ctx.String("assigned-to"),
MentionedBy: ctx.String("mentions"),
Labels: labels,
Milestones: milestones,
Since: from,
Before: until,
})
if err != nil {
return err
}
} else {
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd),
State: state,
Type: kind,
KeyWord: ctx.String("keyword"),
CreatedBy: ctx.String("author"),
AssignedBy: ctx.String("assigned-to"),
MentionedBy: ctx.String("mentions"),
Labels: labels,
Milestones: milestones,
Since: from,
Before: until,
Owner: owner,
})
if err != nil {
return err
}
}
fields, err := issueFieldsFlag.GetValues(cmd)
if err != nil {
return err
}
return print.IssuesPullsList(issues, ctx.Output, fields)
}

View File

@@ -1,27 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdIssuesReopen represents a sub command of issues to open an issue
var CmdIssuesReopen = cli.Command{
Name: "reopen",
Aliases: []string{"open"},
Usage: "Change state of one or more issues to 'open'",
Description: `Change state of one or more issues to 'open'`,
ArgsUsage: "<issue index> [<issue index>...]",
Action: func(ctx context.Context, cmd *cli.Command) error {
s := gitea.StateOpen
return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s})
},
Flags: flags.AllDefaultFlags,
}

View File

@@ -1,358 +0,0 @@
// 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)
}

View File

@@ -1,41 +1,266 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"bufio"
"fmt"
"log"
"os"
"strconv"
"strings"
"code.gitea.io/tea/cmd/labels"
"github.com/urfave/cli/v3"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v2"
)
// CmdLabels represents to operate repositories' labels.
var CmdLabels = cli.Command{
Name: "labels",
Aliases: []string{"label"},
Category: catEntities,
Usage: "Manage issue labels",
Description: `Manage issue labels`,
ArgsUsage: " ", // command does not accept arguments
Action: runLabels,
Commands: []*cli.Command{
&labels.CmdLabelsList,
&labels.CmdLabelCreate,
&labels.CmdLabelUpdate,
&labels.CmdLabelDelete,
Subcommands: []*cli.Command{
&CmdLabelCreate,
&CmdLabelUpdate,
&CmdLabelDelete,
},
Flags: labels.CmdLabelsList.Flags,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "save",
Aliases: []string{"s"},
Usage: "Save all the labels as a file",
},
}, AllDefaultFlags...),
}
func runLabels(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runLabelsDetails(cmd)
func runLabels(ctx *cli.Context) error {
login, owner, repo := initCommand()
headers := []string{
"Index",
"Color",
"Name",
"Description",
}
return labels.RunLabelsList(ctx, cmd)
var values [][]string
labels, err := login.Client().ListRepoLabels(owner, repo, gitea.ListLabelsOptions{})
if err != nil {
log.Fatal(err)
}
if len(labels) == 0 {
Output(outputValue, headers, values)
return nil
}
fPath := ctx.String("save")
if len(fPath) > 0 {
f, err := os.Create(fPath)
if err != nil {
return err
}
defer f.Close()
for _, label := range labels {
fmt.Fprintf(f, "#%s %s\n", label.Color, label.Name)
}
} else {
for _, label := range labels {
values = append(
values,
[]string{
strconv.FormatInt(label.ID, 10),
label.Color,
label.Name,
label.Description,
},
)
}
Output(outputValue, headers, values)
}
return nil
}
func runLabelsDetails(cmd *cli.Command) error {
return fmt.Errorf("Not yet implemented")
// CmdLabelCreate represents a sub command of labels to create label.
var CmdLabelCreate = cli.Command{
Name: "create",
Usage: "Create a label",
Description: `Create a label`,
Action: runLabelCreate,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Usage: "label name",
},
&cli.StringFlag{
Name: "color",
Usage: "label color value",
},
&cli.StringFlag{
Name: "description",
Usage: "label description",
},
&cli.StringFlag{
Name: "file",
Usage: "indicate a label file",
},
},
}
func splitLabelLine(line string) (string, string, string) {
fields := strings.SplitN(line, ";", 2)
var color, name, description string
if len(fields) < 1 {
return "", "", ""
} else if len(fields) >= 2 {
description = strings.TrimSpace(fields[1])
}
fields = strings.Fields(fields[0])
if len(fields) <= 0 {
return "", "", ""
}
color = fields[0]
if len(fields) == 2 {
name = fields[1]
} else if len(fields) > 2 {
name = strings.Join(fields[1:], " ")
}
return color, name, description
}
func runLabelCreate(ctx *cli.Context) error {
login, owner, repo := initCommand()
labelFile := ctx.String("file")
var err error
if len(labelFile) == 0 {
_, err = login.Client().CreateLabel(owner, repo, gitea.CreateLabelOption{
Name: ctx.String("name"),
Color: ctx.String("color"),
Description: ctx.String("description"),
})
} else {
f, err := os.Open(labelFile)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var i = 1
// FIXME: if Gitea's API support create multiple labels once, we should move to that API.
for scanner.Scan() {
line := scanner.Text()
color, name, description := splitLabelLine(line)
if color == "" || name == "" {
log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line)
} else {
_, err = login.Client().CreateLabel(owner, repo, gitea.CreateLabelOption{
Name: name,
Color: color,
Description: description,
})
}
i++
}
}
if err != nil {
log.Fatal(err)
}
return nil
}
// CmdLabelUpdate represents a sub command of labels to update label.
var CmdLabelUpdate = cli.Command{
Name: "update",
Usage: "Update a label",
Description: `Update a label`,
Action: runLabelUpdate,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "id",
Usage: "label id",
},
&cli.StringFlag{
Name: "name",
Usage: "label name",
},
&cli.StringFlag{
Name: "color",
Usage: "label color value",
},
&cli.StringFlag{
Name: "description",
Usage: "label description",
},
},
}
func runLabelUpdate(ctx *cli.Context) error {
login, owner, repo := initCommand()
id := ctx.Int64("id")
var pName, pColor, pDescription *string
name := ctx.String("name")
if name != "" {
pName = &name
}
color := ctx.String("color")
if color != "" {
pColor = &color
}
description := ctx.String("description")
if description != "" {
pDescription = &description
}
var err error
_, err = login.Client().EditLabel(owner, repo, id, gitea.EditLabelOption{
Name: pName,
Color: pColor,
Description: pDescription,
})
if err != nil {
log.Fatal(err)
}
return nil
}
// CmdLabelDelete represents a sub command of labels to delete label.
var CmdLabelDelete = cli.Command{
Name: "delete",
Usage: "Delete a label",
Description: `Delete a label`,
Action: runLabelCreate,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "id",
Usage: "label id",
},
},
}
func runLabelDelete(ctx *cli.Context) error {
login, owner, repo := initCommand()
err := login.Client().DeleteLabel(owner, repo, ctx.Int64("id"))
if err != nil {
log.Fatal(err)
}
return nil
}

View File

@@ -1,116 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package labels
import (
"bufio"
stdctx "context"
"log"
"os"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdLabelCreate represents a sub command of labels to create label.
var CmdLabelCreate = cli.Command{
Name: "create",
Aliases: []string{"c"},
Usage: "Create a label",
Description: `Create a label`,
ArgsUsage: " ", // command does not accept arguments
Action: runLabelCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "name",
Usage: "label name",
},
&cli.StringFlag{
Name: "color",
Usage: "label color value",
},
&cli.StringFlag{
Name: "description",
Usage: "label description",
},
&cli.StringFlag{
Name: "file",
Usage: "indicate a label file",
},
}, flags.AllDefaultFlags...),
}
func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
labelFile := ctx.String("file")
if len(labelFile) == 0 {
_, _, err := ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: ctx.String("name"),
Color: ctx.String("color"),
Description: ctx.String("description"),
})
return err
}
f, err := os.Open(labelFile)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
i := 1
for scanner.Scan() {
line := scanner.Text()
color, name, description := splitLabelLine(line)
if color == "" || name == "" {
log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line)
} else {
_, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: name,
Color: color,
Description: description,
})
if err != nil {
return err
}
}
i++
}
return nil
}
func splitLabelLine(line string) (string, string, string) {
fields := strings.SplitN(line, ";", 2)
var color, name, description string
if len(fields) < 1 {
return "", "", ""
} else if len(fields) >= 2 {
description = strings.TrimSpace(fields[1])
}
fields = strings.Fields(fields[0])
if len(fields) <= 0 {
return "", "", ""
}
color = fields[0]
if len(fields) == 2 {
name = fields[1]
} else if len(fields) > 2 {
name = strings.Join(fields[1:], " ")
}
return color, name, description
}

View File

@@ -1,58 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package labels
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdLabelDelete represents a sub command of labels to delete label.
var CmdLabelDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "Delete a label",
Description: `Delete a label`,
ArgsUsage: " ", // command does not accept arguments
Action: runLabelDelete,
Flags: append([]cli.Flag{
&cli.Int64Flag{
Name: "id",
Usage: "label id",
Required: true,
},
}, flags.AllDefaultFlags...),
}
func runLabelDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
labelID := ctx.Int64("id")
client := ctx.Login.Client()
// Verify the label exists first
label, _, err := client.GetRepoLabel(ctx.Owner, ctx.Repo, labelID)
if err != nil {
return fmt.Errorf("failed to get label %d: %w", labelID, err)
}
_, err = client.DeleteLabel(ctx.Owner, ctx.Repo, labelID)
if err != nil {
return fmt.Errorf("failed to delete label '%s' (id: %d): %w", label.Name, labelID, err)
}
fmt.Printf("Label '%s' (id: %d) deleted successfully\n", label.Name, labelID)
return nil
}

View File

@@ -1,60 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package labels
import (
stdctx "context"
"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/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdLabelsList represents a sub command of labels to list labels
var CmdLabelsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List labels",
Description: "List labels",
ArgsUsage: " ", // command does not accept arguments
Action: RunLabelsList,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "save",
Aliases: []string{"s"},
Usage: "Save all the labels as a file",
},
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
}
// RunLabelsList list labels.
func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
if ctx.IsSet("save") {
return task.LabelsExport(labels, ctx.String("save"))
}
return print.LabelsList(labels, ctx.Output)
}

View File

@@ -1,79 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package labels
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdLabelUpdate represents a sub command of labels to update label.
var CmdLabelUpdate = cli.Command{
Name: "update",
Usage: "Update a label",
Description: `Update a label`,
ArgsUsage: " ", // command does not accept arguments
Action: runLabelUpdate,
Flags: append([]cli.Flag{
&cli.Int64Flag{
Name: "id",
Usage: "label id",
},
&cli.StringFlag{
Name: "name",
Usage: "label name",
},
&cli.StringFlag{
Name: "color",
Usage: "label color value",
},
&cli.StringFlag{
Name: "description",
Usage: "label description",
},
}, flags.AllDefaultFlags...),
}
func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
id := ctx.Int64("id")
var pName, pColor, pDescription *string
name := ctx.String("name")
if name != "" {
pName = &name
}
color := ctx.String("color")
if color != "" {
pColor = &color
}
description := ctx.String("description")
if description != "" {
pDescription = &description
}
_, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{
Name: pName,
Color: pColor,
Description: pDescription,
})
if err != nil {
return err
}
return nil
}

View File

@@ -1,7 +1,8 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package labels
package cmd
import (
"bufio"
@@ -20,7 +21,7 @@ func TestParseLabelLine(t *testing.T) {
`
scanner := bufio.NewScanner(strings.NewReader(labels))
i := 1
var i = 1
for scanner.Scan() {
line := scanner.Text()
color, name, description := splitLabelLine(line)

111
cmd/log.go Normal file
View File

@@ -0,0 +1,111 @@
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/olekukonko/tablewriter"
)
var (
showLog bool
)
// Println println content according the flag
func Println(a ...interface{}) {
if showLog {
fmt.Println(a...)
}
}
// Printf printf content according the flag
func Printf(format string, a ...interface{}) {
if showLog {
fmt.Printf(format, a...)
}
}
// Error println content as an error information
func Error(a ...interface{}) {
fmt.Println(a...)
}
// Errorf printf content as an error information
func Errorf(format string, a ...interface{}) {
fmt.Printf(format, a...)
}
// outputtable prints structured data as table
func outputtable(headers []string, values [][]string) {
table := tablewriter.NewWriter(os.Stdout)
if len(headers) > 0 {
table.SetHeader(headers)
}
for _, value := range values {
table.Append(value)
}
table.Render()
}
// outputsimple prints structured data as space delimited value
func outputsimple(headers []string, values [][]string) {
for _, value := range values {
fmt.Printf(strings.Join(value, " "))
fmt.Printf("\n")
}
}
// outputdsv prints structured data as delimiter separated value format
func outputdsv(headers []string, values [][]string, delimiterOpt ...string) {
delimiter := ","
if len(delimiterOpt) > 0 {
delimiter = delimiterOpt[0]
}
fmt.Println("\"" + strings.Join(headers, "\""+delimiter+"\"") + "\"")
for _, value := range values {
fmt.Printf("\"")
fmt.Printf(strings.Join(value, "\""+delimiter+"\""))
fmt.Printf("\"")
fmt.Printf("\n")
}
}
// outputyaml prints structured data as yaml
func outputyaml(headers []string, values [][]string) {
for _, value := range values {
fmt.Println("-")
for j, val := range value {
intVal, _ := strconv.Atoi(val)
if strconv.Itoa(intVal) == val {
fmt.Printf(" %s: %s\n", headers[j], val)
} else {
fmt.Printf(" %s: '%s'\n", headers[j], val)
}
}
}
}
// Output provides general function to convert given information
// into several outputs
func Output(output string, headers []string, values [][]string) {
switch {
case output == "" || output == "table":
outputtable(headers, values)
case output == "csv":
outputdsv(headers, values, ",")
case output == "simple":
outputsimple(headers, values)
case output == "tsv":
outputdsv(headers, values, "\t")
case output == "yaml":
outputyaml(headers, values)
default:
Errorf("unknown output type '" + output + "', available types are:\n- csv: comma-separated values\n- simple: space-separated values\n- table: auto-aligned table format (default)\n- tsv: tab-separated values\n- yaml: YAML format\n")
}
}

View File

@@ -1,56 +1,161 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"crypto/tls"
"fmt"
"log"
"net/http"
"net/http/cookiejar"
"code.gitea.io/tea/cmd/login"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLogin represents to login a gitea server.
var CmdLogin = cli.Command{
Name: "logins",
Aliases: []string{"login"},
Category: catSetup,
Name: "login",
Usage: "Log in to a Gitea server",
Description: `Log in to a Gitea server`,
ArgsUsage: "[<login name>]",
Action: runLogins,
Commands: []*cli.Command{
&login.CmdLoginList,
&login.CmdLoginAdd,
&login.CmdLoginEdit,
&login.CmdLoginDelete,
&login.CmdLoginSetDefault,
&login.CmdLoginHelper,
&login.CmdLoginOAuthRefresh,
Action: runLoginList,
Subcommands: []*cli.Command{
&cmdLoginList,
&cmdLoginAdd,
},
}
func runLogins(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runLoginDetail(cmd.Args().First())
}
return login.RunLoginList(ctx, cmd)
// CmdLogin represents to login a gitea server.
var cmdLoginAdd = cli.Command{
Name: "add",
Usage: "Add a Gitea login",
Description: `Add a Gitea login`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Login name",
Required: true,
},
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Value: "https://try.gitea.io",
EnvVars: []string{"GITEA_SERVER_URL"},
Usage: "Server URL",
Required: true,
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Value: "",
EnvVars: []string{"GITEA_SERVER_TOKEN"},
Usage: "Access token. Can be obtained from Settings > Applications",
Required: true,
},
&cli.StringFlag{
Name: "ssh-key",
Aliases: []string{"s"},
Usage: "Path to a SSH key to use for pull/push operations",
},
&cli.BoolFlag{
Name: "insecure",
Aliases: []string{"i"},
Usage: "Disable TLS verification",
},
},
Action: runLoginAdd,
}
func runLoginDetail(name string) error {
l, err := config.GetLoginByName(name)
if err != nil {
return err
func runLoginAdd(ctx *cli.Context) error {
if !ctx.IsSet("url") {
log.Fatal("You have to input Gitea server URL")
}
if l == nil {
fmt.Printf("Login '%s' do not exist\n\n", name)
return nil
if !ctx.IsSet("token") {
log.Fatal("No token found")
}
if !ctx.IsSet("name") {
log.Fatal("You have to set a name for the login")
}
err := loadConfig(yamlConfigPath)
if err != nil {
log.Fatal("Unable to load config file " + yamlConfigPath)
}
client := gitea.NewClient(ctx.String("url"), ctx.String("token"))
if ctx.Bool("insecure") {
cookieJar, _ := cookiejar.New(nil)
client.SetHTTPClient(&http.Client{
Jar: cookieJar,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
})
}
u, err := client.GetMyUserInfo()
if err != nil {
log.Fatal(err)
}
fmt.Println("Login successful! Login name " + u.UserName)
err = addLogin(Login{
Name: ctx.String("name"),
URL: ctx.String("url"),
Token: ctx.String("token"),
Insecure: ctx.Bool("insecure"),
SSHKey: ctx.String("ssh-key"),
User: u.UserName,
})
if err != nil {
log.Fatal(err)
}
err = saveConfig(yamlConfigPath)
if err != nil {
log.Fatal(err)
}
print.LoginDetails(l)
return nil
}
// CmdLogin represents to login a gitea server.
var cmdLoginList = cli.Command{
Name: "ls",
Usage: "List Gitea logins",
Description: `List Gitea logins`,
Action: runLoginList,
Flags: []cli.Flag{&OutputFlag},
}
func runLoginList(ctx *cli.Context) error {
err := loadConfig(yamlConfigPath)
if err != nil {
log.Fatal("Unable to load config file " + yamlConfigPath)
}
headers := []string{
"Name",
"URL",
"SSHHost",
}
var values [][]string
for _, l := range config.Logins {
values = append(values, []string{
l.Name,
l.URL,
l.GetSSHHost(),
})
}
Output(outputValue, headers, values)
return nil
}

View File

@@ -1,165 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"fmt"
"code.gitea.io/tea/modules/auth"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdLoginAdd represents to login a gitea server.
var CmdLoginAdd = cli.Command{
Name: "add",
Usage: "Add a Gitea login",
Description: `Add a Gitea login, without args it will create one interactively`,
ArgsUsage: " ", // command does not accept arguments
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Login name",
},
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Value: "https://gitea.com",
Sources: cli.EnvVars("GITEA_SERVER_URL"),
Usage: "Server URL",
},
&cli.BoolFlag{
Name: "no-version-check",
Aliases: []string{"nv"},
Usage: "Do not check version of Gitea instance",
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Value: "",
Sources: cli.EnvVars("GITEA_SERVER_TOKEN"),
Usage: "Access token. Can be obtained from Settings > Applications",
},
&cli.StringFlag{
Name: "user",
Value: "",
Sources: cli.EnvVars("GITEA_SERVER_USER"),
Usage: "User for basic auth (will create token)",
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"pwd"},
Value: "",
Sources: cli.EnvVars("GITEA_SERVER_PASSWORD"),
Usage: "Password for basic auth (will create token)",
},
&cli.StringFlag{
Name: "otp",
Sources: cli.EnvVars("GITEA_SERVER_OTP"),
Usage: "OTP token for auth, if necessary",
},
&cli.StringFlag{
Name: "scopes",
Sources: cli.EnvVars("GITEA_SCOPES"),
Usage: "Token scopes to add when creating a new token, separated by a comma",
},
&cli.StringFlag{
Name: "ssh-key",
Aliases: []string{"s"},
Usage: "Path to a SSH key/certificate to use, overrides auto-discovery",
},
&cli.BoolFlag{
Name: "insecure",
Aliases: []string{"i"},
Usage: "Disable TLS verification",
},
&cli.StringFlag{
Name: "ssh-agent-principal",
Aliases: []string{"c"},
Usage: "Use SSH certificate with specified principal to login (needs a running ssh-agent with certificate loaded)",
},
&cli.StringFlag{
Name: "ssh-agent-key",
Aliases: []string{"a"},
Usage: "Use SSH public key or SSH fingerprint to login (needs a running ssh-agent with ssh key loaded)",
},
&cli.BoolFlag{
Name: "helper",
Aliases: []string{"j"},
Usage: "Add helper",
},
&cli.BoolFlag{
Name: "oauth",
Aliases: []string{"o"},
Usage: "Use interactive OAuth2 flow for authentication",
},
&cli.StringFlag{
Name: "client-id",
Usage: "OAuth client ID (for use with --oauth)",
},
&cli.StringFlag{
Name: "redirect-url",
Usage: "OAuth redirect URL (for use with --oauth)",
},
},
Action: runLoginAdd,
}
func runLoginAdd(_ context.Context, cmd *cli.Command) error {
// if no args create login interactive
if cmd.NumFlags() == 0 {
if err := interact.CreateLogin(); err != nil && !interact.IsQuitting(err) {
return fmt.Errorf("error adding login: %w", err)
}
return nil
}
// if OAuth flag is provided, use OAuth2 PKCE flow
if cmd.Bool("oauth") {
opts := auth.OAuthOptions{
Name: cmd.String("name"),
URL: cmd.String("url"),
Insecure: cmd.Bool("insecure"),
}
// Only set clientID if provided
if cmd.String("client-id") != "" {
opts.ClientID = cmd.String("client-id")
}
// Only set redirect URL if provided
if cmd.String("redirect-url") != "" {
opts.RedirectURL = cmd.String("redirect-url")
}
return auth.OAuthLoginWithFullOptions(opts)
}
sshAgent := false
if cmd.String("ssh-agent-key") != "" || cmd.String("ssh-agent-principal") != "" {
sshAgent = true
}
// else use args to add login
return task.CreateLogin(
cmd.String("name"),
cmd.String("token"),
cmd.String("user"),
cmd.String("password"),
cmd.String("otp"),
cmd.String("scopes"),
cmd.String("ssh-key"),
cmd.String("url"),
cmd.String("ssh-agent-principal"),
cmd.String("ssh-agent-key"),
cmd.Bool("insecure"),
sshAgent,
!cmd.Bool("no-version-check"),
cmd.Bool("helper"),
)
}

View File

@@ -1,38 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"github.com/urfave/cli/v3"
)
// CmdLoginSetDefault represents to login a gitea server.
var CmdLoginSetDefault = cli.Command{
Name: "default",
Usage: "Get or Set Default Login",
Description: `Get or Set Default Login`,
ArgsUsage: "<Login>",
Action: runLoginSetDefault,
Flags: []cli.Flag{&flags.OutputFlag},
}
func runLoginSetDefault(_ context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
l, err := config.GetDefaultLogin()
if err != nil {
return err
}
fmt.Printf("Default Login: %s\n", l.Name)
return nil
}
name := cmd.Args().First()
return config.SetDefaultLogin(name)
}

View File

@@ -1,44 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"errors"
"log"
"code.gitea.io/tea/modules/config"
"github.com/urfave/cli/v3"
)
// CmdLoginDelete is a command to delete a login
var CmdLoginDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "Remove a Gitea login",
Description: `Remove a Gitea login`,
ArgsUsage: "<login name>",
Action: RunLoginDelete,
}
// RunLoginDelete runs the action of a login delete command
func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
logins, err := config.GetLogins()
if err != nil {
log.Fatal(err)
}
var name string
if len(cmd.Args().First()) != 0 {
name = cmd.Args().First()
} else if len(logins) == 1 {
name = logins[0].Name
} else {
return errors.New("Please specify a login name")
}
return config.DeleteLogin(name)
}

View File

@@ -1,41 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"log"
"os"
"os/exec"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"github.com/skratchdot/open-golang/open"
"github.com/urfave/cli/v3"
)
// CmdLoginEdit represents to login a gitea server.
var CmdLoginEdit = cli.Command{
Name: "edit",
Aliases: []string{"e"},
Usage: "Edit Gitea logins",
Description: `Edit Gitea logins`,
ArgsUsage: " ", // command does not accept arguments
Action: runLoginEdit,
Flags: []cli.Flag{&flags.OutputFlag},
}
func runLoginEdit(_ context.Context, _ *cli.Command) error {
if e, ok := os.LookupEnv("EDITOR"); ok && e != "" {
cmd := exec.Command(e, config.GetConfigPath())
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err.Error())
}
}
return open.Start(config.GetConfigPath())
}

View File

@@ -1,142 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"bufio"
"context"
"fmt"
"log"
"net/url"
"os"
"strings"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdLoginHelper represents to login a gitea helper.
var CmdLoginHelper = cli.Command{
Name: "helper",
Aliases: []string{"git-credential"},
Usage: "Git helper",
Description: `Git helper`,
Hidden: true,
Commands: []*cli.Command{
{
Name: "store",
Description: "Command drops",
Aliases: []string{"erase"},
Action: func(_ context.Context, _ *cli.Command) error {
return nil
},
},
{
Name: "setup",
Description: "Setup helper to tea authenticate",
Action: func(_ context.Context, _ *cli.Command) error {
logins, err := config.GetLogins()
if err != nil {
return err
}
for _, login := range logins {
added, err := task.SetupHelper(login)
if err != nil {
return err
} else if added {
fmt.Printf("Added \"%s\"\n", login.Name)
} else {
fmt.Printf("\"%s\" has already been added!\n", login.Name)
}
}
return nil
},
},
{
Name: "get",
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)
for s.Scan() {
line := s.Text()
if line == "" {
break
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
key, value := parts[0], parts[1]
if key == "url" {
u, err := url.Parse(value)
if err != nil {
return err
}
wants["protocol"] = u.Scheme
wants["host"] = u.Host
wants["path"] = u.Path
wants["username"] = u.User.Username()
wants["password"], _ = u.User.Password()
} else {
wants[key] = value
}
}
if len(wants["host"]) == 0 {
log.Fatal("Hostname is required")
} else if len(wants["protocol"]) == 0 {
wants["protocol"] = "http"
}
// Use --login flag if provided, otherwise fall back to host lookup
var userConfig *config.Login
if loginName := cmd.String("login"); loginName != "" {
var lookupErr error
userConfig, lookupErr = config.GetLoginByName(loginName)
if lookupErr != nil {
log.Fatal(lookupErr)
}
if userConfig == nil {
log.Fatalf("Login '%s' not found", loginName)
}
} else {
userConfig = config.GetLoginByHost(wants["host"])
if userConfig == nil {
log.Fatalf("No login found for host '%s'", wants["host"])
}
}
if len(userConfig.GetAccessToken()) == 0 {
log.Fatal("User not set")
}
host, err := url.Parse(userConfig.URL)
if err != nil {
return err
}
// Refresh token if expired or near expiry (updates userConfig in place)
if err = userConfig.RefreshOAuthTokenIfNeeded(); err != nil {
return err
}
_, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.GetAccessToken())
if err != nil {
return err
}
return nil
},
},
},
}

View File

@@ -1,34 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdLoginList represents to login a gitea server.
var CmdLoginList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List Gitea logins",
Description: `List Gitea logins`,
ArgsUsage: " ", // command does not accept arguments
Action: RunLoginList,
Flags: []cli.Flag{&flags.OutputFlag},
}
// RunLoginList list all logins
func RunLoginList(_ context.Context, cmd *cli.Command) error {
logins, err := config.GetLogins()
if err != nil {
return err
}
return print.LoginsList(logins, cmd.String("output"))
}

View File

@@ -1,71 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"fmt"
"code.gitea.io/tea/modules/auth"
"code.gitea.io/tea/modules/config"
"github.com/urfave/cli/v3"
)
// CmdLoginOAuthRefresh represents a command to refresh an OAuth token
var CmdLoginOAuthRefresh = cli.Command{
Name: "oauth-refresh",
Usage: "Refresh an OAuth token",
Description: "Manually refresh an expired OAuth token. If the refresh token is also expired, opens a browser for re-authentication.",
ArgsUsage: "[<login name>]",
Action: runLoginOAuthRefresh,
}
func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error {
var loginName string
// Get login name from args or use default
if cmd.Args().Len() > 0 {
loginName = cmd.Args().First()
} else {
// Get default login
login, err := config.GetDefaultLogin()
if err != nil {
return fmt.Errorf("no login specified and no default login found: %s", err)
}
loginName = login.Name
}
// Get the login from config
login, err := config.GetLoginByName(loginName)
if err != nil {
return err
}
if login == nil {
return fmt.Errorf("login '%s' not found", loginName)
}
// Check if the login has a refresh token
if login.GetRefreshToken() == "" {
return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName)
}
// Try to refresh the token
err = auth.RefreshAccessToken(login)
if err == nil {
fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName)
return nil
}
// Refresh failed - fall back to browser-based re-authentication
fmt.Printf("Token refresh failed: %s\n", err)
fmt.Println("Opening browser for re-authentication...")
if err := auth.ReauthenticateLogin(login); err != nil {
return fmt.Errorf("re-authentication failed: %s", err)
}
fmt.Printf("Successfully re-authenticated %s\n", loginName)
return nil
}

View File

@@ -1,20 +1,61 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Copyright 2018 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"code.gitea.io/tea/cmd/login"
"errors"
"log"
"os"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLogout represents to logout a gitea server.
var CmdLogout = cli.Command{
Name: "logout",
Category: catSetup,
Usage: "Log out from a Gitea server",
Description: `Log out from a Gitea server`,
ArgsUsage: "<login name>",
Action: login.RunLoginDelete,
Action: runLogout,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Login name to remove",
},
},
}
func runLogout(ctx *cli.Context) error {
var name string
if len(os.Args) == 3 {
name = os.Args[2]
} else if ctx.IsSet("name") {
name = ctx.String("name")
} else {
return errors.New("Please specify a login name")
}
err := loadConfig(yamlConfigPath)
if err != nil {
log.Fatal("Unable to load config file " + yamlConfigPath)
}
var idx = -1
for i, l := range config.Logins {
if l.Name == name {
idx = i
break
}
}
if idx > -1 {
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
err = saveConfig(yamlConfigPath)
if err != nil {
log.Fatal("Unable to save config file " + yamlConfigPath)
}
}
return nil
}

View File

@@ -1,62 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
docs "github.com/urfave/cli-docs/v3"
"github.com/urfave/cli/v3"
)
// DocRenderFlags are the flags for documentation generation, used by `./docs/docs.go` and the `generate-man-page` sub command
var DocRenderFlags = []cli.Flag{
&cli.StringFlag{
Name: "out",
Usage: "Path to output docs to, otherwise prints to stdout",
Aliases: []string{"o"},
},
}
// CmdGenerateManPage is the sub command to generate the `tea` man page
var CmdGenerateManPage = cli.Command{
Name: "man",
Usage: "Generate man page",
Hidden: true,
Flags: DocRenderFlags,
Action: func(ctx context.Context, cmd *cli.Command) error {
return RenderDocs(cmd, cmd.Root(), docs.ToMan)
},
}
// RenderDocs renders the documentation for `target` using the supplied `render` function
func RenderDocs(cmd, target *cli.Command, render func(*cli.Command) (string, error)) error {
out, err := render(target)
if err != nil {
return err
}
outPath := cmd.String("out")
if outPath == "" {
fmt.Print(out)
return nil
}
if err = os.MkdirAll(filepath.Dir(outPath), os.ModePerm); err != nil {
return err
}
fi, err := os.Create(outPath)
if err != nil {
return err
}
defer fi.Close()
if _, err = fi.WriteString(out); err != nil {
return err
}
return nil
}

View File

@@ -1,59 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"code.gitea.io/tea/cmd/milestones"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdMilestones represents to operate repositories milestones.
var CmdMilestones = cli.Command{
Name: "milestones",
Aliases: []string{"milestone", "ms"},
Category: catEntities,
Usage: "List and create milestones",
Description: `List and create milestones`,
ArgsUsage: "[<milestone name>]",
Action: runMilestones,
Commands: []*cli.Command{
&milestones.CmdMilestonesList,
&milestones.CmdMilestonesCreate,
&milestones.CmdMilestonesClose,
&milestones.CmdMilestonesDelete,
&milestones.CmdMilestonesReopen,
&milestones.CmdMilestonesIssues,
},
Flags: milestones.CmdMilestonesList.Flags,
}
func runMilestones(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runMilestoneDetail(ctx, cmd, cmd.Args().First())
}
return milestones.RunMilestonesList(ctx, cmd)
}
func runMilestoneDetail(_ stdctx.Context, cmd *cli.Command, name string) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name)
if err != nil {
return err
}
print.MilestoneDetails(milestone)
return nil
}

View File

@@ -1,32 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestones
import (
"context"
"code.gitea.io/tea/cmd/flags"
"github.com/urfave/cli/v3"
)
// CmdMilestonesClose represents a sub command of milestones to close an milestone
var CmdMilestonesClose = cli.Command{
Name: "close",
Usage: "Change state of one or more milestones to 'closed'",
Description: `Change state of one or more milestones to 'closed'`,
ArgsUsage: "<milestone name> [<milestone name>...]",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Bool("force") {
return deleteMilestone(ctx, cmd)
}
return editMilestoneStatus(ctx, cmd, true)
},
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "force",
Aliases: []string{"f"},
Usage: "delete milestone",
},
}, flags.AllDefaultFlags...),
}

View File

@@ -1,89 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestones
import (
"time"
stdctx "context"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task"
"github.com/araddon/dateparse"
"github.com/urfave/cli/v3"
)
// CmdMilestonesCreate represents a sub command of milestones to create milestone
var CmdMilestonesCreate = cli.Command{
Name: "create",
Aliases: []string{"c"},
Usage: "Create an milestone on repository",
Description: `Create an milestone on repository`,
ArgsUsage: " ", // command does not accept arguments
Action: runMilestonesCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
Usage: "milestone title to create",
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"d"},
Usage: "milestone description to create",
},
&cli.StringFlag{
Name: "deadline",
Aliases: []string{"expires", "x"},
Usage: "set milestone deadline (default is no due date)",
},
&cli.StringFlag{
Name: "state",
Usage: "set milestone state (default is open)",
DefaultText: "open",
},
}, flags.AllDefaultFlags...),
}
func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
date := ctx.String("deadline")
deadline := &time.Time{}
if date != "" {
t, err := dateparse.ParseAny(date)
if err != nil {
return err
}
deadline = &t
}
state := gitea.StateOpen
if ctx.String("state") == "closed" {
state = gitea.StateClosed
}
if ctx.IsInteractiveMode() {
if err := interact.CreateMilestone(ctx.Login, ctx.Owner, ctx.Repo); err != nil && !interact.IsQuitting(err) {
return err
}
return nil
}
return task.CreateMilestone(
ctx.Login,
ctx.Owner,
ctx.Repo,
ctx.String("title"),
ctx.String("description"),
deadline,
state,
)
}

View File

@@ -1,38 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestones
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdMilestonesDelete represents a sub command of milestones to delete an milestone
var CmdMilestonesDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "delete a milestone",
Description: "delete a milestone",
ArgsUsage: "<milestone name>",
Action: deleteMilestone,
Flags: flags.AllDefaultFlags,
}
func deleteMilestone(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
_, err = client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First())
return err
}

View File

@@ -1,197 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestones
import (
"fmt"
stdctx "context"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
var msIssuesFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{
"index", "kind", "title", "state", "updated", "labels",
})
// CmdMilestonesIssues represents a sub command of milestones to manage issue/pull of an milestone
var CmdMilestonesIssues = cli.Command{
Name: "issues",
Aliases: []string{"i"},
Usage: "manage issue/pull of an milestone",
Description: "manage issue/pull of an milestone",
ArgsUsage: "<milestone name>",
Action: runMilestoneIssueList,
Commands: []*cli.Command{
&CmdMilestoneAddIssue,
&CmdMilestoneRemoveIssue,
},
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "state",
Usage: "Filter by issue state (all|open|closed)",
DefaultText: "open",
},
&cli.StringFlag{
Name: "kind",
Usage: "Filter by kind (issue|pull)",
},
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
msIssuesFieldsFlag,
}, flags.AllDefaultFlags...),
}
// CmdMilestoneAddIssue represents a sub command of milestone issues to add an issue/pull to an milestone
var CmdMilestoneAddIssue = cli.Command{
Name: "add",
Aliases: []string{"a"},
Usage: "Add an issue/pull to an milestone",
Description: "Add an issue/pull to an milestone",
ArgsUsage: "<milestone name> <issue/pull index>",
Action: runMilestoneIssueAdd,
Flags: flags.AllDefaultFlags,
}
// CmdMilestoneRemoveIssue represents a sub command of milestones to remove an issue/pull from an milestone
var CmdMilestoneRemoveIssue = cli.Command{
Name: "remove",
Aliases: []string{"r"},
Usage: "Remove an issue/pull to an milestone",
Description: "Remove an issue/pull to an milestone",
ArgsUsage: "<milestone name> <issue/pull index>",
Action: runMilestoneIssueRemove,
Flags: flags.AllDefaultFlags,
}
func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
state, err := flags.ParseState(ctx.String("state"))
if err != nil {
return err
}
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll)
if err != nil {
return err
}
if ctx.Args().Len() != 1 {
return fmt.Errorf("milestone name is required")
}
milestone := ctx.Args().First()
// make sure milestone exist
_, _, err = client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone)
if err != nil {
return err
}
issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd),
Milestones: []string{milestone},
Type: kind,
State: state,
})
if err != nil {
return err
}
fields, err := msIssuesFieldsFlag.GetValues(cmd)
if err != nil {
return err
}
return print.IssuesPullsList(issues, ctx.Output, fields)
}
func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
if ctx.Args().Len() != 2 {
return fmt.Errorf("need two arguments")
}
mileName := ctx.Args().Get(0)
issueIndex := ctx.Args().Get(1)
idx, err := utils.ArgToIndex(issueIndex)
if err != nil {
return err
}
// make sure milestone exist
mile, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, mileName)
if err != nil {
return fmt.Errorf("failed to get milestone '%s': %w", mileName, err)
}
_, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{
Milestone: &mile.ID,
})
if err != nil {
return fmt.Errorf("failed to add issue #%d to milestone '%s': %w", idx, mileName, err)
}
return nil
}
func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := ctx.Login.Client()
if ctx.Args().Len() != 2 {
return fmt.Errorf("need two arguments")
}
mileName := ctx.Args().Get(0)
issueIndex := ctx.Args().Get(1)
idx, err := utils.ArgToIndex(issueIndex)
if err != nil {
return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err)
}
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
if err != nil {
return fmt.Errorf("failed to get issue #%d: %w", idx, err)
}
if issue.Milestone == nil {
return fmt.Errorf("issue #%d is not assigned to a milestone", idx)
}
if issue.Milestone.Title != mileName {
return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName)
}
zero := int64(0)
_, _, err = client.EditIssue(ctx.Owner, ctx.Repo, idx, gitea.EditIssueOption{
Milestone: &zero,
})
if err != nil {
return fmt.Errorf("failed to remove issue #%d from milestone '%s': %w", idx, mileName, err)
}
return nil
}

View File

@@ -1,74 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestones
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"
)
var fieldsFlag = flags.FieldsFlag(print.MilestoneFields, []string{
"title", "items", "duedate",
})
// CmdMilestonesList represents a sub command of milestones to list milestones
var CmdMilestonesList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List milestones of the repository",
Description: `List milestones of the repository`,
ArgsUsage: " ", // command does not accept arguments
Action: RunMilestonesList,
Flags: append([]cli.Flag{
fieldsFlag,
&cli.StringFlag{
Name: "state",
Usage: "Filter by milestone state (all|open|closed)",
DefaultText: "open",
},
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
}
// RunMilestonesList list milestones
func RunMilestonesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
fields, err := fieldsFlag.GetValues(cmd)
if err != nil {
return err
}
state, err := flags.ParseState(ctx.String("state"))
if err != nil {
return err
}
if state == gitea.StateAll && !cmd.IsSet("fields") {
fields = append(fields, "state")
}
client := ctx.Login.Client()
milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{
ListOptions: flags.GetListOptions(cmd),
State: state,
})
if err != nil {
return err
}
return print.MilestonesList(milestones, ctx.Output, fields)
}

View File

@@ -1,73 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package milestones
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"
)
// CmdMilestonesReopen represents a sub command of milestones to open an milestone
var CmdMilestonesReopen = cli.Command{
Name: "reopen",
Aliases: []string{"open"},
Usage: "Change state of one or more milestones to 'open'",
Description: `Change state of one or more milestones to 'open'`,
ArgsUsage: "<milestone name> [<milestone name> ...]",
Action: func(ctx stdctx.Context, cmd *cli.Command) error {
return editMilestoneStatus(ctx, cmd, false)
},
Flags: flags.AllDefaultFlags,
}
func editMilestoneStatus(_ stdctx.Context, cmd *cli.Command, close bool) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() == 0 {
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage)
}
state := gitea.StateOpen
if close {
state = gitea.StateClosed
}
client := ctx.Login.Client()
repoURL := ""
if ctx.Args().Len() > 1 {
repoURL, err = ctx.GetRemoteRepoHTMLURL()
if err != nil {
return err
}
}
for _, ms := range ctx.Args().Slice() {
opts := gitea.EditMilestoneOption{
State: &state,
Title: ms,
}
milestone, _, err := client.EditMilestoneByName(ctx.Owner, ctx.Repo, ms, opts)
if err != nil {
return err
}
if ctx.Args().Len() > 1 {
fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID)
} else {
print.MilestoneDetails(milestone)
}
}
return nil
}

View File

@@ -1,28 +1,109 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"code.gitea.io/tea/cmd/notifications"
"log"
"strings"
"github.com/urfave/cli/v3"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v2"
)
// CmdNotifications is the main command to operate with notifications
var CmdNotifications = cli.Command{
Name: "notifications",
Aliases: []string{"notification", "n"},
Category: catHelpers,
Usage: "Show notifications",
Description: "Show notifications, by default based on the current repo if available",
Action: notifications.RunNotificationsList,
Commands: []*cli.Command{
&notifications.CmdNotificationsList,
&notifications.CmdNotificationsMarkRead,
&notifications.CmdNotificationsMarkUnread,
&notifications.CmdNotificationsMarkPinned,
&notifications.CmdNotificationsUnpin,
},
Flags: notifications.CmdNotificationsList.Flags,
Usage: "show notifications",
Description: "show notifications, by default based of the current repo and unread one",
Action: runNotifications,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "all",
Aliases: []string{"a"},
Usage: "show all notifications of related gitea instance",
},
/* // not supported jet
&cli.BoolFlag{
Name: "read",
Aliases: []string{"rd"},
Usage: "show read notifications instead unread",
},
*/
&cli.IntFlag{
Name: "page",
Aliases: []string{"p"},
Usage: "specify page, default is 1",
Value: 1,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"lm"},
Usage: "specify limit of items per page",
},
}, AllDefaultFlags...),
}
func runNotifications(ctx *cli.Context) error {
var news []*gitea.NotificationThread
var err error
listOpts := gitea.ListOptions{
Page: ctx.Int("page"),
PageSize: ctx.Int("limit"),
}
if ctx.Bool("all") {
login := initCommandLoginOnly()
news, err = login.Client().ListNotifications(gitea.ListNotificationOptions{
ListOptions: listOpts,
})
} else {
login, owner, repo := initCommand()
news, err = login.Client().ListRepoNotifications(owner, repo, gitea.ListNotificationOptions{
ListOptions: listOpts,
})
}
if err != nil {
log.Fatal(err)
}
headers := []string{
"Type",
"Index",
"Title",
}
if ctx.Bool("all") {
headers = append(headers, "Repository")
}
var values [][]string
for _, n := range news {
if n.Subject == nil {
continue
}
// if pull or Issue get Index
var index string
if n.Subject.Type == "Issue" || n.Subject.Type == "Pull" {
index = n.Subject.URL
urlParts := strings.Split(n.Subject.URL, "/")
if len(urlParts) != 0 {
index = urlParts[len(urlParts)-1]
}
index = "#" + index
}
item := []string{n.Subject.Type, index, n.Subject.Title}
if ctx.Bool("all") {
item = append(item, n.Repository.FullName)
}
values = append(values, item)
}
if len(values) != 0 {
Output(outputValue, headers, values)
}
return nil
}

View File

@@ -1,110 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package notifications
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"
)
var notifyFieldsFlag = flags.FieldsFlag(print.NotificationFields, []string{
"id", "status", "index", "type", "state", "title",
})
var notifyTypeFlag = flags.NewCsvFlag("types", "subject types to filter by", []string{"t"},
[]string{"issue", "pull", "repository", "commit"}, nil)
// CmdNotificationsList represents a sub command of notifications to list notifications
var CmdNotificationsList = cli.Command{
Name: "ls",
Aliases: []string{"list"},
Usage: "List notifications",
Description: `List notifications`,
ArgsUsage: " ", // command does not accept arguments
Action: RunNotificationsList,
Flags: append([]cli.Flag{
notifyFieldsFlag,
notifyTypeFlag,
}, flags.NotificationFlags...),
}
// RunNotificationsList list notifications
func RunNotificationsList(ctx stdctx.Context, cmd *cli.Command) error {
var states []gitea.NotifyStatus
statesStr, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
for _, s := range statesStr {
states = append(states, gitea.NotifyStatus(s))
}
var types []gitea.NotifySubjectType
typesStr, err := notifyTypeFlag.GetValues(cmd)
if err != nil {
return err
}
for _, t := range typesStr {
types = append(types, gitea.NotifySubjectType(t))
}
return listNotifications(ctx, cmd, states, types)
}
// listNotifications will get the notifications based on status and subject type
func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.NotifyStatus, subjects []gitea.NotifySubjectType) error {
var news []*gitea.NotificationThread
var err error
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(cmd)
if listOpts.Page == 0 {
listOpts.Page = 1
}
fields, err := notifyFieldsFlag.GetValues(cmd)
if err != nil {
return err
}
if all {
// add repository to the default fields
if !cmd.IsSet("fields") {
fields = append(fields, "repository")
}
news, _, err = client.ListNotifications(gitea.ListNotificationOptions{
ListOptions: listOpts,
Status: status,
SubjectTypes: subjects,
})
} else {
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,
SubjectTypes: subjects,
})
}
if err != nil {
return err
}
return print.NotificationsList(news, ctx.Output, fields)
}

View File

@@ -1,157 +0,0 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package notifications
import (
stdctx "context"
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdNotificationsMarkRead represents a sub command of notifications to list read notifications
var CmdNotificationsMarkRead = cli.Command{
Name: "read",
Aliases: []string{"r"},
Usage: "Mark all filtered or a specific notification as read",
Description: "Mark all filtered or a specific notification as read",
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
if !ctx.IsSet(flags.NotificationStateFlag.Name) {
filter = []string{string(gitea.NotifyStatusUnread)}
}
return markNotificationAs(ctx, filter, gitea.NotifyStatusRead)
},
}
// CmdNotificationsMarkUnread will mark notifications as unread.
var CmdNotificationsMarkUnread = cli.Command{
Name: "unread",
Aliases: []string{"u"},
Usage: "Mark all filtered or a specific notification as unread",
Description: "Mark all filtered or a specific notification as unread",
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
if !ctx.IsSet(flags.NotificationStateFlag.Name) {
filter = []string{string(gitea.NotifyStatusRead)}
}
return markNotificationAs(ctx, filter, gitea.NotifyStatusUnread)
},
}
// CmdNotificationsMarkPinned will mark notifications as unread.
var CmdNotificationsMarkPinned = cli.Command{
Name: "pin",
Aliases: []string{"p"},
Usage: "Mark all filtered or a specific notification as pinned",
Description: "Mark all filtered or a specific notification as pinned",
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
if !ctx.IsSet(flags.NotificationStateFlag.Name) {
filter = []string{string(gitea.NotifyStatusUnread)}
}
return markNotificationAs(ctx, filter, gitea.NotifyStatusPinned)
},
}
// CmdNotificationsUnpin will mark pinned notifications as unread.
var CmdNotificationsUnpin = cli.Command{
Name: "unpin",
Usage: "Unpin all pinned or a specific notification",
Description: "Marks all pinned or a specific notification as read",
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
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)
},
}
func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetState gitea.NotifyStatus) (err error) {
client := cmd.Login.Client()
subject := cmd.Args().First()
allRepos := cmd.Bool("mine")
states := []gitea.NotifyStatus{}
for _, s := range filterStates {
states = append(states, gitea.NotifyStatus(s))
}
switch subject {
case "", "all":
opts := gitea.MarkNotificationOptions{Status: states, ToStatus: targetState}
if allRepos {
_, _, err = client.ReadNotifications(opts)
} else {
if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
_, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts)
}
// TODO: print all affected notification subject URLs
// (not supported by API currently, https://github.com/go-gitea/gitea/issues/16797)
default:
id, err := utils.ArgToIndex(subject)
if err != nil {
return err
}
_, _, err = client.ReadNotification(id, targetState)
if err != nil {
return err
}
n, _, err := client.GetNotification(id)
if err != nil {
return err
}
// Use LatestCommentHTMLURL if available, otherwise fall back to HTMLURL
if n.Subject.LatestCommentHTMLURL != "" {
fmt.Println(n.Subject.LatestCommentHTMLURL)
} else {
fmt.Println(n.Subject.HTMLURL)
}
return nil
}
return err
}

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