2 Commits

Author SHA1 Message Date
6543
f28f199e85 Changelog for v0.9.1 (#535)
Reviewed-on: https://gitea.com/gitea/tea/pulls/535
Reviewed-by: John Olheiser <john+gitea@jolheiser.com>
2023-02-16 06:22:31 +08:00
6543
c00418e74c Print pull dont crash if it has TeamReviewRequests (#517)
partial backport of #515

Reviewed-on: https://gitea.com/gitea/tea/pulls/517
Reviewed-by: Norwin <noerw@noreply.gitea.io>
2022-09-29 20:35:57 +08:00
246 changed files with 2533 additions and 18037 deletions

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"
]
}
}
}

202
.drone.yml Normal file
View File

@@ -0,0 +1,202 @@
---
kind: pipeline
name: default
platform:
os: linux
arch: amd64
steps:
- name: vendor
pull: always
image: golang:1.18
environment:
GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not
commands:
- make vendor # use vendor folder as cache
- name: build
pull: always
image: golang:1.18
environment:
GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not
commands:
- make clean
- make vet
- make lint
- make fmt-check
- make misspell-check
- make build
when:
event:
- push
- tag
- pull_request
- name: unit-test
image: golang:1.18
commands:
- make unit-test-coverage
settings:
group: test
environment:
GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not
when:
branch:
- main
event:
- push
- pull_request
- name: release-test
image: golang:1.18
commands:
- make test
settings:
group: test
environment:
GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not
when:
branch:
- "release/*"
event:
- push
- pull_request
- name: tag-test
pull: always
image: golang:1.18
commands:
- make test
settings:
group: test
environment:
GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not
when:
event:
- tag
- name: static
image: golang:1.18
environment:
GOPROXY: https://goproxy.io # proxy.golang.org is blocked in China, this proxy is not
commands:
- 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: woodpeckerci/plugin-s3:latest
settings:
acl: public-read
bucket: gitea-artifacts
endpoint:
from_secret: aws_endpoint
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: woodpeckerci/plugin-s3:latest
settings:
acl: public-read
bucket: gitea-artifacts
endpoint:
from_secret: aws_endpoint
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: woodpeckerci/plugin-s3:latest
settings:
acl: public-read
bucket: gitea-artifacts
endpoint:
from_secret: aws_endpoint
source: "dist/release/*"
strip_prefix: dist/release/
target: /tea/main
environment:
AWS_ACCESS_KEY_ID:
from_secret: aws_access_key_id
AWS_SECRET_ACCESS_KEY:
from_secret: aws_secret_access_key
when:
branch:
- main
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

@@ -8,7 +8,7 @@ labels:
### describe your environment
- tea version used (`tea -v`):
- [ ] I also reproduced the issue [with the latest main build](https://dl.gitea.com/tea/main/)
- [ ] I also reproduced the issue [with the latest master build](https://dl.gitea.io/tea/master)
- Gitea version used:
- [ ] the issue only occurred after updating gitea recently
- operating system:

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

12
.gitignore vendored
View File

@@ -1,6 +1,5 @@
/tea
tea
/gitea-vet
/gitea-vet.exe
.idea/
.history/
@@ -8,12 +7,3 @@ 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

@@ -1,20 +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

View File

@@ -7,6 +7,7 @@
- [Bug reports](#bug-reports)
- [Discuss your design](#discuss-your-design)
- [Testing redux](#testing-redux)
- [Vendoring](#vendoring)
- [Code review](#code-review)
- [Styleguide](#styleguide)
- [Sign-off your work](#sign-off-your-work)
@@ -59,6 +60,20 @@ high-level discussions.
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.
## 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).
## Code review
Changes to TEA must be reviewed before they are accepted—no matter who
@@ -160,7 +175,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
@@ -193,7 +208,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:
```
@@ -221,8 +236,8 @@ Code that you contribute should use the standard copyright header:
```
// 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.
```
Files in the repository contain copyright from the year they are added

View File

@@ -1,12 +1,25 @@
FROM docker.io/chainguard/go:latest AS build
COPY . /build/
WORKDIR /build
RUN make build && mkdir -p /app/.config/tea
ARG GOVERSION="1.16.2"
FROM docker.io/chainguard/busybox:latest-glibc
COPY --from=build /build/tea /bin/tea
COPY --from=build --chown=65532:65532 /app /app
VOLUME [ "/app" ]
FROM golang:${GOVERSION}-alpine AS buildenv
ARG GOOS="linux"
COPY . $GOPATH/src/
WORKDIR $GOPATH/src
RUN apk add --quiet --no-cache \
build-base \
make \
git && \
make clean build STATIC=true
FROM scratch
ARG VERSION="0.7.0"
LABEL org.opencontainers.image.title="tea - CLI for Gitea - git with a cup of tea"
LABEL org.opencontainers.image.description="A command line tool to interact with Gitea servers"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.authors="Tamás Gérczei <tamas@gerczei.eu>"
LABEL org.opencontainers.image.vendor="The Gitea Authors"
COPY --from=buildenv /go/src/tea /
ENV HOME="/app"
ENTRYPOINT ["/bin/sh", "-c"]
CMD [ "tea" ]
ENTRYPOINT ["/tea"]

63
FEATURE-COMPARISON.md Normal file
View File

@@ -0,0 +1,63 @@
# comparing git forge commandline interfaces
[tea]: https://gitea.com/gitea/tea
[sip]: https://gitea.com/jolheiser/sip
[gitlab]: https://github.com/makkes/gitlab-cli
[glab]: https://github.com/profclems/glab
[gh]: https://cli.github.com
last update: 2020-12-11
## general
/ | [tea][tea] | [sip][sip] | [gitlab][gitlab] | [gh][gh]
-----------------------|:-----:|:-----:|:-----:|:-----:
forge|gitea|gitea|gitlab|github
official forge support|✓|✘|✘|✓
dev status|adding features|maintenance||
platform|any|any|any|any
## philosophy
/ | [tea][tea] | [sip][sip] | [gitlab][gitlab] | [gh][gh]
-----------------------|:-----:|:-----:|:-----:|:-----:
aims to replace git cli|✘|||✓
works with decentralization in mind|✓|✓|✓|✘
per-repo setup needed|✘||✓|✘
workflow helpers|✓|||
interactive mode |[(✓)](https://gitea.com/gitea/tea/issues?type=all&state=open&labels=&milestone=0&assignee=0&q=interactive)|✘| |✓
programmatic mode|✓|||✓
machine readable output|✓|||
follows XDG spec|✓|||
## features
/ | [tea][tea] | [sip][sip] | [gitlab][gitlab] | [gh][gh]
-----------------------|:-----:|:-----:|:-----:|:-----:
open web UI|✓|||
search repos|✓|||
search issues|✘|✓||
textual item search filter syntax|✘|✓||
CRUD repos|[(✓)](https://gitea.com/gitea/tea/issues/239)|||
CRUD issues|[(✓)](https://gitea.com/gitea/tea/issues/229)|||
CRUD milestones|[(✓)](https://gitea.com/gitea/tea/issues/246)|||
CRUD releases|✓|||
CRUD labels|✓|||
CRUD PRs|✓|||
CRUD time tracking|✓|||x
CRUD orgs|[(✓)](https://gitea.com/gitea/tea/issues/287)|||
create PRs from local repo|✓|||
create PRs from remote repo|✓|||
code review|[u](https://gitea.com/gitea/tea/issues/131)|||
merge PRs||||
read comments|[u](https://gitea.com/gitea/tea/issues/172)|||
post comments||||
manage CI|✘|✘|✓|
manage notifications|[(✓)]()|||
administration|[u](https://gitea.com/gitea/tea/issues/161)|✘||✘
markdown rendering|✓|||✓
issue import/export|[u](https://gitea.com/gitea/tea/issues/132)|||
checkout PRs|✓|||
- ✓: supported
- (✓): partial support
- u: upcoming
- ✘: not supported
- ?: unknown

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

111
Makefile
View File

@@ -1,14 +1,14 @@
DIST := dist
export GO111MODULE=on
export CGO_ENABLED=0
GO ?= go
SHASUM ?= shasum -a 256
export PATH := $($(GO) env GOPATH)/bin:$(PATH)
GOFILES := $(shell find . -name "*.go" -type f ! -path "*/bindata.go")
# 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
ifneq ($(DRONE_TAG),)
VERSION ?= $(subst v,,$(DRONE_TAG))
@@ -17,7 +17,7 @@ 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
@@ -25,21 +25,22 @@ 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)" -X "main.SDK=$(SDK)" -s -w
# override to allow passing additional goflags via make CLI
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
override GOFLAGS := $(GOFLAGS) -mod=vendor -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.
ifeq ($(OS), Windows_NT)
EXECUTABLE := tea.exe
VET_TOOL := gitea-vet.exe
else
EXECUTABLE := tea
VET_TOOL := gitea-vet
ifneq ($(shell uname -s), OpenBSD)
override BUILDMODE := -buildmode=pie
endif
endif
.PHONY: all
@@ -47,70 +48,61 @@ 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 code.gitea.io/gitea-vet
$(GO) vet -vettool=gitea-vet $(PACKAGES)
.PHONY: lint
lint:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
lint: install-lint-tools
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: install-lint-tools
misspell -error -i unknwon,destory $(GOFILES)
.PHONY: misspell
misspell: install-lint-tools
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: check
check: test
.PHONY: install
install: $(SOURCES)
@echo "installing to $(shell $(GO) env GOPATH)/bin/$(EXECUTABLE)"
@echo "installing to $(GOPATH)/bin/$(EXECUTABLE)"
$(GO) install -v $(BUILDMODE) $(GOFLAGS)
.PHONY: build
@@ -123,3 +115,38 @@ $(EXECUTABLE): $(SOURCES)
build-image:
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .
.PHONY: release
release: release-dirs install-release-tools release-os release-compress release-check
.PHONY: release-dirs
release-dirs:
mkdir -p $(DIST)/binaries $(DIST)/release
.PHONY: release-os
release-os:
CGO_ENABLED=0 gox -verbose -cgo=false $(GOFLAGS) -osarch='!darwin/386 !darwin/arm' -os="windows linux darwin" -arch="386 amd64 arm arm64" -output="$(DIST)/release/tea-$(VERSION)-{{.OS}}-{{.Arch}}"
.PHONY: release-compress
release-compress: install-release-tools
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && gxz -k -9 $${file}; done;
.PHONY: release-check
release-check: install-release-tools
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done;
### tools
install-release-tools:
@hash gox > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/mitchellh/gox@latest; \
fi
@hash gxz > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/ulikunitz/xz/cmd/gxz@latest; \
fi
install-lint-tools:
@hash revive > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/mgechev/revive@latest; \
fi
@hash misspell > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GO) install github.com/client9/misspell/cmd/misspell@latest; \
fi

129
README.md
View File

@@ -1,26 +1,19 @@
# <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 for Gitea
![demo gif](./demo.gif)
```
NAME:
tea - command line tool to interact with Gitea
version 0.8.0-preview
USAGE:
tea [global options] [command [command options]]
USAGE
tea command [subcommand] [command options] [arguments...]
VERSION:
Version: 0.10.1+15-g8876fe3 golang: 1.25.0 go-sdk: v0.21.0
DESCRIPTION:
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'.
@@ -29,9 +22,8 @@ DESCRIPTION:
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:
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
@@ -41,30 +33,22 @@ COMMANDS:
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
shellcompletion, autocomplete Install shell completion for tea
whoami Show current logged in user
GLOBAL OPTIONS:
--debug, --vvv Enable debug mode (default: false)
--help, -h show help
--version, -v print the version
OPTIONS
--help, -h show help (default: false)
--version, -v print the version (default: false)
EXAMPLES
EXAMPLES
tea login add # add a login once to get started
tea pulls # list open pulls for the repo in $PWD
@@ -79,24 +63,16 @@ EXAMPLES
tea open 189 # open web ui for issue 189
tea open milestones # open web ui for milestones
tea actions secrets list # list all repository action secrets
tea actions secrets create API_KEY # create a new secret (will prompt for value)
tea actions variables list # list all repository action variables
tea actions variables set API_URL https://api.example.com
tea webhooks list # list repository webhooks
tea webhooks list --org myorg # list organization webhooks
tea webhooks create https://example.com/hook --events push,pull_request
# send gitea desktop notifications every 5 minutes (bash + libnotify)
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done
ABOUT
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.
More info about Gitea itself on https://gitea.io.
```
- [Compare features with other git forge CLIs](./FEATURE-COMPARISON.md)
- tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API.
## Installation
@@ -104,72 +80,23 @@ ABOUT
There are different ways to get `tea`:
1. Install via your system package manager:
- macOS via `brew` (official):
- macOS via `brew` (gitea-maintained):
```sh
brew tap gitea/tap https://gitea.com/gitea/homebrew-gitea
brew install tea
```
- arch linux ([tea](https://archlinux.org/packages/extra/x86_64/tea/), thirdparty)
- arch linux ([gitea-tea-git](https://aur.archlinux.org/packages/gitea-tea-git), 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/)
2. Use the prebuilt binaries from [dl.gitea.io](https://dl.gitea.io/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.
```
### Man Page
The hidden command `tea man` can be used to generate the `tea` man page.
```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
```
4. Docker (thirdparty): [tgerczei/tea](https://hub.docker.com/r/tgerczei/tea)
## Compilation
Make sure you have a current Go version installed (1.26 or newer).
Make sure you have a current go version installed (1.13 or newer).
- To compile the source yourself with the recommended flags & tags:
```sh
@@ -178,15 +105,10 @@ Make sure you have a current Go version installed (1.26 or newer).
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:
- For a quick installation without `git` & `make`:
```sh
go install code.gitea.io/tea@${version}
go install code.gitea.io/tea@latest
```
## Contributing
@@ -195,6 +117,7 @@ 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.

View File

@@ -1,6 +1,6 @@
// 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.
//go:build vendor
// +build vendor

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,15 +1,14 @@
// Copyright 2021 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"
"code.gitea.io/tea/cmd/admin/users"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdAdmin represents the namespace of admin commands.
@@ -19,10 +18,10 @@ var CmdAdmin = cli.Command{
Usage: "Operations requiring admin access on the Gitea instance",
Aliases: []string{"a"},
Category: catMisc,
Action: func(_ stdctx.Context, cmd *cli.Command) error {
Action: func(cmd *cli.Context) error {
return cli.ShowSubcommandHelp(cmd)
},
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&cmdAdminUsers,
},
}
@@ -31,23 +30,20 @@ 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())
Action: func(ctx *cli.Context) error {
if ctx.Args().Len() == 1 {
return runAdminUserDetail(ctx, ctx.Args().First())
}
return users.RunUserList(ctx, cmd)
return users.RunUserList(ctx)
},
Commands: []*cli.Command{
Subcommands: []*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
}
func runAdminUserDetail(cmd *cli.Context, u string) error {
ctx := context.InitCommand(cmd)
client := ctx.Login.Client()
user, _, err := client.GetUserInfo(u)
if err != nil {

View File

@@ -1,17 +1,16 @@
// Copyright 2021 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 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"
"github.com/urfave/cli/v2"
)
var userFieldsFlag = flags.FieldsFlag(print.UserFields, []string{
@@ -33,11 +32,8 @@ var CmdUserList = cli.Command{
}
// RunUserList list users
func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
func RunUserList(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
fields, err := userFieldsFlag.GetValues(cmd)
if err != nil {
@@ -46,11 +42,13 @@ func RunUserList(_ stdctx.Context, cmd *cli.Command) error {
client := ctx.Login.Client()
users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{
ListOptions: flags.GetListOptions(cmd),
ListOptions: ctx.GetListOptions(),
})
if err != nil {
return err
}
return print.UserList(users, ctx.Output, fields)
print.UserList(users, ctx.Output, fields)
return nil
}

View File

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

View File

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

View File

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

138
cmd/autocomplete.go Normal file
View File

@@ -0,0 +1,138 @@
// Copyright 2020 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"
"io"
"net/http"
"os"
"os/exec"
"github.com/adrg/xdg"
"github.com/urfave/cli/v2"
)
// CmdAutocomplete manages autocompletion
var CmdAutocomplete = cli.Command{
Name: "shellcompletion",
Aliases: []string{"autocomplete"},
Category: catSetup,
Usage: "Install shell completion for tea",
Description: "Install shell completion for tea",
ArgsUsage: "<shell type> (bash, zsh, powershell, fish)",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "install",
Usage: "Persist in shell config instead of printing commands",
},
},
Action: runAutocompleteAdd,
}
func runAutocompleteAdd(ctx *cli.Context) error {
var remoteFile, localFile, cmds string
shell := ctx.Args().First()
switch shell {
case "zsh":
remoteFile = "contrib/autocomplete.zsh"
localFile = "autocomplete.zsh"
cmds = "echo 'PROG=tea _CLI_ZSH_AUTOCOMPLETE_HACK=1 source \"%s\"' >> ~/.zshrc && source ~/.zshrc"
case "bash":
remoteFile = "contrib/autocomplete.sh"
localFile = "autocomplete.sh"
cmds = "echo 'PROG=tea source \"%s\"' >> ~/.bashrc && source ~/.bashrc"
case "powershell":
remoteFile = "contrib/autocomplete.ps1"
localFile = "tea.ps1"
cmds = "\"& %s\" >> $profile"
case "fish":
// fish is different, in that urfave/cli provides a generator for the shell script needed.
// this also means that the fish completion can become out of sync with the tea binary!
// writing to this directory suffices, as fish reads files there on startup, no cmds needed.
return writeFishAutoCompleteFile(ctx)
default:
return fmt.Errorf("Must specify valid %s", ctx.Command.ArgsUsage)
}
localPath, err := xdg.ConfigFile("tea/" + localFile)
if err != nil {
return err
}
cmds = fmt.Sprintf(cmds, localPath)
if err = writeRemoteAutoCompleteFile(remoteFile, localPath); err != nil {
return err
}
if ctx.Bool("install") {
fmt.Println("Installing in your shellrc")
installer := exec.Command(shell, "-c", cmds)
if shell == "powershell" {
installer = exec.Command("powershell.exe", "-Command", cmds)
}
out, err := installer.CombinedOutput()
if err != nil {
return fmt.Errorf("Couldn't run the commands: %s %s", err, out)
}
} else {
fmt.Println("\n# Run the following commands to install autocompletion (or use --install)")
fmt.Println(cmds)
}
return nil
}
func writeRemoteAutoCompleteFile(file, destPath string) error {
url := fmt.Sprintf("https://gitea.com/gitea/tea/raw/branch/master/%s", file)
fmt.Println("Fetching " + url)
res, err := http.Get(url)
if err != nil {
return err
}
defer res.Body.Close()
writer, err := os.Create(destPath)
if err != nil {
return err
}
defer writer.Close()
_, err = io.Copy(writer, res.Body)
return err
}
func writeFishAutoCompleteFile(ctx *cli.Context) error {
// NOTE: to make sure this file is in sync with tea commands, we'd need to
// - check if the file exists
// - if it does, check if the tea version that wrote it is the currently running version
// - if not, rewrite the file
// on each application run
// NOTE: this generates a completion that also suggests file names, which looks kinda messy..
script, err := ctx.App.ToFishCompletion()
if err != nil {
return err
}
localPath, err := xdg.ConfigFile("fish/conf.d/tea_completion.fish")
if err != nil {
return err
}
writer, err := os.Create(localPath)
if err != nil {
return err
}
if _, err = io.WriteString(writer, script); err != nil {
return err
}
fmt.Printf("Installed tab completion to %s\n", localPath)
return nil
}

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,5 +1,6 @@
// 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

View File

@@ -1,22 +1,21 @@
// Copyright 2021 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"
"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"
"github.com/urfave/cli/v2"
)
// CmdRepoClone represents a sub command of repos to create a local copy
@@ -47,21 +46,18 @@ When a host is specified in the repo-slug, it will override the login specified
},
}
func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
teaCmd, err := context.InitCommand(cmd)
if err != nil {
return err
}
func runRepoClone(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
args := teaCmd.Args()
args := ctx.Args()
if args.Len() < 1 {
return cli.ShowCommandHelp(ctx, cmd, "clone")
return cli.ShowCommandHelp(cmd, "clone")
}
dir := args.Get(1)
var (
login *config.Login = teaCmd.Login
owner string
login *config.Login = ctx.Login
owner string = ctx.Login.User
repo string
)
@@ -72,15 +68,12 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
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(
@@ -89,7 +82,7 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
owner,
repo,
interact.PromptPassword,
teaCmd.Int("depth"),
ctx.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,26 +1,24 @@
// 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 (
stdctx "context"
"errors"
"fmt"
"io"
"io/ioutil"
"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"
"code.gitea.io/sdk/gitea"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v2"
)
// CmdAddComment is the main command to operate with notifications
@@ -35,14 +33,9 @@ var CmdAddComment = cli.Command{
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
}
func runAddComment(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
args := ctx.Args()
if args.Len() == 0 {
@@ -57,28 +50,23 @@ func runAddComment(_ stdctx.Context, cmd *cli.Command) error {
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 {
if bodyStdin, err := ioutil.ReadAll(ctx.App.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 {
if err = survey.AskOne(interact.NewMultiline(interact.Multiline{
Message: "Comment:",
Syntax: "md",
UseEditor: config.GetPreferences().Editor,
}), &body); err != nil {
return err
}
}
if len(body) == 0 {
return errors.New("no comment content provided")
return fmt.Errorf("No comment body provided")
}
client := ctx.Login.Client()

View File

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

View File

@@ -1,5 +1,6 @@
// Copyright 2021 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 flags
@@ -8,7 +9,7 @@ import (
"strings"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CsvFlag is a wrapper around cli.StringFlag, with an added GetValues() method
@@ -38,8 +39,8 @@ func NewCsvFlag(name, usage string, aliases, availableValues, defaults []string)
}
// 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)
func (f CsvFlag) GetValues(ctx *cli.Context) ([]string, error) {
val := ctx.String(f.Name)
selection := strings.Split(val, ",")
if f.AvailableFields != nil && val != "" {
for _, field := range selection {

View File

@@ -1,13 +1,11 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// 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 flags
import (
"errors"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// LoginFlag provides flag to specify tea login profile
@@ -35,71 +33,21 @@ var RemoteFlag = cli.StringFlag{
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,
Usage: "Output format. (csv, simple, table, tsv, yaml)",
}
// PaginationPageFlag provides flag for pagination options
var PaginationPageFlag = cli.IntFlag{
var PaginationPageFlag = cli.StringFlag{
Name: "page",
Aliases: []string{"p"},
Usage: "specify page",
Value: defaultPageValue,
Validator: func(i int) error {
if i < 1 && i != -1 {
return ErrPage
}
return nil
},
Usage: "specify page, default is 1",
}
// PaginationLimitFlag provides flag for pagination options
var PaginationLimitFlag = cli.IntFlag{
var PaginationLimitFlag = cli.StringFlag{
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
@@ -156,34 +104,3 @@ var NotificationStateFlag = NewCsvFlag(
func FieldsFlag(availableFields, defaultFields []string) *CsvFlag {
return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields)
}
// ParseState parses a state string and returns the corresponding gitea.StateType
func ParseState(stateStr string) (gitea.StateType, error) {
switch stateStr {
case "all":
return gitea.StateAll, nil
case "", "open":
return gitea.StateOpen, nil
case "closed":
return gitea.StateClosed, nil
default:
return "", 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,19 +1,19 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// StateFlag provides flag to specify issue/pr state, defaulting to "open"
@@ -70,10 +70,6 @@ var IssueListingFlags = append([]cli.Flag{
Name: "mentions",
Aliases: []string{"M"},
},
&cli.StringFlag{
Name: "owner",
Aliases: []string{"org"},
},
&cli.StringFlag{
Name: "from",
Aliases: []string{"F"},
@@ -88,8 +84,8 @@ var IssueListingFlags = append([]cli.Flag{
&PaginationLimitFlag,
}, AllDefaultFlags...)
// issuePRFlags defines shared flags between flags IssuePRCreateFlags and IssuePREditFlags
var issuePRFlags = append([]cli.Flag{
// IssuePREditFlags defines flags for properties of issues and PRs
var IssuePREditFlags = append([]cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
@@ -98,25 +94,6 @@ var issuePRFlags = append([]cli.Flag{
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"},
@@ -127,10 +104,20 @@ var IssuePRCreateFlags = append([]cli.Flag{
Aliases: []string{"L"},
Usage: "Comma-separated list of labels to assign",
},
}, issuePRFlags...)
&cli.StringFlag{
Name: "deadline",
Aliases: []string{"D"},
Usage: "Deadline timestamp to assign",
},
&cli.StringFlag{
Name: "milestone",
Aliases: []string{"m"},
Usage: "Milestone to assign",
},
}, LoginRepoFlags...)
// GetIssuePRCreateFlags parses all IssuePREditFlags
func GetIssuePRCreateFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, error) {
// GetIssuePREditFlags parses all IssuePREditFlags
func GetIssuePREditFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, error) {
opts := gitea.CreateIssueOption{
Title: ctx.String("title"),
Body: ctx.String("description"),
@@ -172,67 +159,3 @@ func GetIssuePRCreateFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, e
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,52 +1,21 @@
// 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"
"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",
@@ -56,10 +25,9 @@ var CmdIssues = cli.Command{
Description: `Lists issues when called without argument. If issue index is provided, will show it in detail.`,
ArgsUsage: "[<issue index>]",
Action: runIssues,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&issues.CmdIssuesList,
&issues.CmdIssuesCreate,
&issues.CmdIssuesEdit,
&issues.CmdIssuesReopen,
&issues.CmdIssuesClose,
},
@@ -71,43 +39,22 @@ var CmdIssues = cli.Command{
}, issues.CmdIssuesList.Flags...),
}
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 issues.RunIssuesList(ctx)
}
func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
ctx, idx, err := resolveIssueDetailContext(cmd, index)
if err != nil {
return err
}
return runIssueDetailWithClient(ctx, idx, ctx.Login.Client())
}
func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) {
ctx, err := context.InitCommand(cmd)
if err != nil {
return nil, 0, err
}
if ctx.IsSet("owner") {
ctx.Owner = ctx.String("owner")
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return nil, 0, err
}
func runIssueDetail(cmd *cli.Context, index string) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
idx, err := utils.ArgToIndex(index)
if err != nil {
return nil, 0, err
return err
}
return ctx, idx, nil
}
func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDetailClient) error {
client := ctx.Login.Client()
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
if err != nil {
return err
@@ -116,14 +63,6 @@ func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDe
if err != nil {
return err
}
if ctx.IsSet("output") {
switch ctx.String("output") {
case "json":
return runIssueDetailAsJSON(ctx, issue)
}
}
print.IssueDetails(issue, reactions)
if issue.Comments > 0 {
@@ -135,39 +74,3 @@ func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDe
return nil
}
func runIssueDetailAsJSON(ctx *context.TeaContext, issue *gitea.Issue) error {
return runIssueDetailAsJSONWithClient(ctx, issue, ctx.Login.Client())
}
func runIssueDetailAsJSONWithClient(ctx *context.TeaContext, issue *gitea.Issue, c issueCommentClient) error {
opts := gitea.ListIssueCommentOptions{ListOptions: flags.GetListOptions(ctx.Command)}
comments := []*gitea.Comment{}
if ctx.Bool("comments") {
var err error
comments, _, err = c.ListIssueComments(ctx.Owner, ctx.Repo, issue.Index, opts)
if err != nil {
return err
}
}
return writeIndentedJSON(ctx.Writer, buildIssueData(issue, comments))
}
func buildIssueData(issue *gitea.Issue, comments []*gitea.Comment) issueData {
return issueData{
ID: issue.ID,
Index: issue.Index,
Title: issue.Title,
State: issue.State,
Created: issue.Created,
User: username(issue.Poster),
Body: issue.Body,
Labels: buildDetailLabels(issue.Labels),
Assignees: buildDetailAssignees(issue.Assignees),
URL: issue.HTMLURL,
ClosedAt: issue.Closed,
Comments: buildDetailComments(comments),
}
}

View File

@@ -1,10 +1,10 @@
// 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 issues
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
@@ -13,52 +13,40 @@ import (
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// 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})
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: 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
}
func editIssueState(cmd *cli.Context, opts gitea.EditIssueOption) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
if ctx.Args().Len() == 0 {
return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage)
return fmt.Errorf(ctx.Command.ArgsUsage)
}
indices, err := utils.ArgsToIndices(ctx.Args().Slice())
index, err := utils.ArgToIndex(ctx.Args().First())
if err != nil {
return err
}
client := ctx.Login.Client()
for _, index := range indices {
issue, _, err := client.EditIssue(ctx.Owner, ctx.Repo, index, opts)
issue, _, err := ctx.Login.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,17 +1,16 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// CmdIssuesCreate represents a sub command of issues to create issue
@@ -22,27 +21,18 @@ var CmdIssuesCreate = cli.Command{
Description: `Create an issue on repository`,
ArgsUsage: " ", // command does not accept arguments
Action: runIssuesCreate,
Flags: flags.IssuePRCreateFlags,
Flags: flags.IssuePREditFlags,
}
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
func runIssuesCreate(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
if ctx.NumFlags() == 0 {
return interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo)
}
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)
opts, err := flags.GetIssuePREditFlags(ctx)
if err != nil {
return err
}

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,10 +1,11 @@
// 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 issues
import (
stdctx "context"
"fmt"
"time"
"code.gitea.io/tea/cmd/flags"
@@ -13,11 +14,11 @@ import (
"code.gitea.io/sdk/gitea"
"github.com/araddon/dateparse"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
var issueFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{
"index", "title", "state", "author", "milestone", "labels", "owner", "repo",
"index", "title", "state", "author", "milestone", "labels",
})
// CmdIssuesList represents a sub command of issues to list issues
@@ -32,22 +33,35 @@ var CmdIssuesList = cli.Command{
}
// RunIssuesList list issues
func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
func RunIssuesList(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
state := gitea.StateOpen
switch ctx.String("state") {
case "all":
state = gitea.StateAll
case "", "open":
state = gitea.StateOpen
case "closed":
state = gitea.StateClosed
default:
return fmt.Errorf("unknown state '%s'", ctx.String("state"))
}
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
kind := gitea.IssueTypeIssue
switch ctx.String("kind") {
case "", "issues", "issue":
kind = gitea.IssueTypeIssue
case "pulls", "pull", "pr":
kind = gitea.IssueTypePull
case "all":
kind = gitea.IssueTypeAll
default:
return fmt.Errorf("unknown kind '%s'", ctx.String("kind"))
}
var err error
var from, until time.Time
if ctx.IsSet("from") {
from, err = dateparse.ParseLocal(ctx.String("from"))
@@ -61,18 +75,13 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
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),
issues, _, err := ctx.Login.Client().ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: ctx.GetListOptions(),
State: state,
Type: kind,
KeyWord: ctx.String("keyword"),
@@ -84,33 +93,16 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
Since: from,
Before: until,
})
if err != nil {
return err
}
} else {
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
ListOptions: flags.GetListOptions(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)
print.IssuesPullsList(issues, ctx.Output, fields)
return nil
}

View File

@@ -1,27 +1,26 @@
// 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 issues
import (
"context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// 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})
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: 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,14 +1,14 @@
// 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"
"fmt"
"code.gitea.io/tea/cmd/labels"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLabels represents to operate repositories' labels.
@@ -20,7 +20,7 @@ var CmdLabels = cli.Command{
Description: `Manage issue labels`,
ArgsUsage: " ", // command does not accept arguments
Action: runLabels,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&labels.CmdLabelsList,
&labels.CmdLabelCreate,
&labels.CmdLabelUpdate,
@@ -29,13 +29,13 @@ var CmdLabels = cli.Command{
Flags: labels.CmdLabelsList.Flags,
}
func runLabels(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runLabelsDetails(cmd)
func runLabels(ctx *cli.Context) error {
if ctx.Args().Len() == 1 {
return runLabelsDetails(ctx)
}
return labels.RunLabelsList(ctx, cmd)
return labels.RunLabelsList(ctx)
}
func runLabelsDetails(cmd *cli.Command) error {
func runLabelsDetails(ctx *cli.Context) error {
return fmt.Errorf("Not yet implemented")
}

View File

@@ -1,11 +1,11 @@
// 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 labels
import (
"bufio"
stdctx "context"
"log"
"os"
"strings"
@@ -14,7 +14,7 @@ import (
"code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLabelCreate represents a sub command of labels to create label.
@@ -45,25 +45,19 @@ var CmdLabelCreate = cli.Command{
}, 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
}
func runLabelCreate(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
labelFile := ctx.String("file")
var err error
if len(labelFile) == 0 {
_, _, err := ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
_, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: ctx.String("name"),
Color: ctx.String("color"),
Description: ctx.String("description"),
})
return err
}
} else {
f, err := os.Open(labelFile)
if err != nil {
return err
@@ -71,7 +65,7 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
defer f.Close()
scanner := bufio.NewScanner(f)
i := 1
var i = 1
for scanner.Scan() {
line := scanner.Text()
color, name, description := splitLabelLine(line)
@@ -83,15 +77,13 @@ func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
Color: color,
Description: description,
})
if err != nil {
return err
}
}
i++
}
}
return nil
return err
}
func splitLabelLine(line string) (string, string, string) {

View File

@@ -1,5 +1,6 @@
// 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
@@ -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)

View File

@@ -1,16 +1,14 @@
// 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 labels
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLabelDelete represents a sub command of labels to delete label.
@@ -22,37 +20,17 @@ var CmdLabelDelete = cli.Command{
ArgsUsage: " ", // command does not accept arguments
Action: runLabelDelete,
Flags: append([]cli.Flag{
&cli.Int64Flag{
&cli.IntFlag{
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 {
func runLabelDelete(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
_, err := ctx.Login.Client().DeleteLabel(ctx.Owner, ctx.Repo, ctx.Int64("id"))
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,18 +1,17 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// CmdLabelsList represents a sub command of labels to list labels
@@ -35,18 +34,13 @@ var CmdLabelsList = cli.Command{
}
// 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
}
func RunLabelsList(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
labels, _, err := client.ListRepoLabels(ctx.Owner, ctx.Repo, gitea.ListLabelsOptions{
ListOptions: flags.GetListOptions(cmd),
ListOptions: ctx.GetListOptions(),
})
if err != nil {
return err
@@ -56,5 +50,6 @@ func RunLabelsList(_ stdctx.Context, cmd *cli.Command) error {
return task.LabelsExport(labels, ctx.String("save"))
}
return print.LabelsList(labels, ctx.Output)
print.LabelsList(labels, ctx.Output)
return nil
}

View File

@@ -1,16 +1,15 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// CmdLabelUpdate represents a sub command of labels to update label.
@@ -21,7 +20,7 @@ var CmdLabelUpdate = cli.Command{
ArgsUsage: " ", // command does not accept arguments
Action: runLabelUpdate,
Flags: append([]cli.Flag{
&cli.Int64Flag{
&cli.IntFlag{
Name: "id",
Usage: "label id",
},
@@ -40,14 +39,9 @@ var CmdLabelUpdate = cli.Command{
}, 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
}
func runLabelUpdate(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
id := ctx.Int64("id")
var pName, pColor, pDescription *string
@@ -66,11 +60,13 @@ func runLabelUpdate(_ stdctx.Context, cmd *cli.Command) error {
pDescription = &description
}
var err error
_, _, err = ctx.Login.Client().EditLabel(ctx.Owner, ctx.Repo, id, gitea.EditLabelOption{
Name: pName,
Color: pColor,
Description: pDescription,
})
if err != nil {
return err
}

View File

@@ -1,17 +1,17 @@
// 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 (
"context"
"fmt"
"code.gitea.io/tea/cmd/login"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLogin represents to login a gitea server.
@@ -23,29 +23,24 @@ var CmdLogin = cli.Command{
Description: `Log in to a Gitea server`,
ArgsUsage: "[<login name>]",
Action: runLogins,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&login.CmdLoginList,
&login.CmdLoginAdd,
&login.CmdLoginEdit,
&login.CmdLoginDelete,
&login.CmdLoginSetDefault,
&login.CmdLoginHelper,
&login.CmdLoginOAuthRefresh,
},
}
func runLogins(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runLoginDetail(cmd.Args().First())
func runLogins(ctx *cli.Context) error {
if ctx.Args().Len() == 1 {
return runLoginDetail(ctx.Args().First())
}
return login.RunLoginList(ctx, cmd)
return login.RunLoginList(ctx)
}
func runLoginDetail(name string) error {
l, err := config.GetLoginByName(name)
if err != nil {
return err
}
l := config.GetLoginByName(name)
if l == nil {
fmt.Printf("Login '%s' do not exist\n\n", name)
return nil

View File

@@ -1,17 +1,14 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// CmdLoginAdd represents to login a gitea server.
@@ -30,136 +27,56 @@ var CmdLoginAdd = cli.Command{
Name: "url",
Aliases: []string{"u"},
Value: "https://gitea.com",
Sources: cli.EnvVars("GITEA_SERVER_URL"),
EnvVars: []string{"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"),
EnvVars: []string{"GITEA_SERVER_TOKEN"},
Usage: "Access token. Can be obtained from Settings > Applications",
},
&cli.StringFlag{
Name: "user",
Value: "",
Sources: cli.EnvVars("GITEA_SERVER_USER"),
EnvVars: []string{"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"),
EnvVars: []string{"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",
Usage: "Path to a SSH key 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 {
func runLoginAdd(ctx *cli.Context) 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
if ctx.NumFlags() == 0 {
return interact.CreateLogin()
}
// 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"),
)
ctx.String("name"),
ctx.String("token"),
ctx.String("user"),
ctx.String("password"),
ctx.String("ssh-key"),
ctx.String("url"),
ctx.Bool("insecure"))
}

View File

@@ -1,16 +1,16 @@
// 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 login
import (
"context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLoginSetDefault represents to login a gitea server.
@@ -23,8 +23,8 @@ var CmdLoginSetDefault = cli.Command{
Flags: []cli.Flag{&flags.OutputFlag},
}
func runLoginSetDefault(_ context.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
func runLoginSetDefault(ctx *cli.Context) error {
if ctx.Args().Len() == 0 {
l, err := config.GetDefaultLogin()
if err != nil {
return err
@@ -33,6 +33,6 @@ func runLoginSetDefault(_ context.Context, cmd *cli.Command) error {
return nil
}
name := cmd.Args().First()
name := ctx.Args().First()
return config.SetDefaultLogin(name)
}

View File

@@ -1,16 +1,16 @@
// 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 login
import (
"context"
"errors"
"log"
"code.gitea.io/tea/modules/config"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLoginDelete is a command to delete a login
@@ -24,7 +24,7 @@ var CmdLoginDelete = cli.Command{
}
// RunLoginDelete runs the action of a login delete command
func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
func RunLoginDelete(ctx *cli.Context) error {
logins, err := config.GetLogins()
if err != nil {
log.Fatal(err)
@@ -32,8 +32,8 @@ func RunLoginDelete(_ context.Context, cmd *cli.Command) error {
var name string
if len(cmd.Args().First()) != 0 {
name = cmd.Args().First()
if len(ctx.Args().First()) != 0 {
name = ctx.Args().First()
} else if len(logins) == 1 {
name = logins[0].Name
} else {

View File

@@ -1,19 +1,15 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// CmdLoginEdit represents to login a gitea server.
@@ -27,15 +23,6 @@ var CmdLoginEdit = cli.Command{
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())
}
}
func runLoginEdit(_ *cli.Context) 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,16 +1,15 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// CmdLoginList represents to login a gitea server.
@@ -25,10 +24,11 @@ var CmdLoginList = cli.Command{
}
// RunLoginList list all logins
func RunLoginList(_ context.Context, cmd *cli.Command) error {
func RunLoginList(cmd *cli.Context) error {
logins, err := config.GetLogins()
if err != nil {
return err
}
return print.LoginsList(logins, cmd.String("output"))
print.LoginsList(logins, cmd.String("output"))
return nil
}

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,12 +1,13 @@
// 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/login"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdLogout represents to logout a gitea server.

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,15 +1,15 @@
// 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 (
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"
"github.com/urfave/cli/v2"
)
// CmdMilestones represents to operate repositories milestones.
@@ -21,7 +21,7 @@ var CmdMilestones = cli.Command{
Description: `List and create milestones`,
ArgsUsage: "[<milestone name>]",
Action: runMilestones,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&milestones.CmdMilestonesList,
&milestones.CmdMilestonesCreate,
&milestones.CmdMilestonesClose,
@@ -32,21 +32,16 @@ var CmdMilestones = cli.Command{
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())
func runMilestones(ctx *cli.Context) error {
if ctx.Args().Len() == 1 {
return runMilestoneDetail(ctx, ctx.Args().First())
}
return milestones.RunMilestonesList(ctx, cmd)
return milestones.RunMilestonesList(ctx)
}
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
}
func runMilestoneDetail(cmd *cli.Context, name string) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
milestone, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, name)

View File

@@ -1,26 +1,26 @@
// 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 milestones
import (
"context"
"code.gitea.io/tea/cmd/flags"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// 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)
Usage: "Change state of an milestone to 'closed'",
Description: `Change state of an milestone to 'closed'`,
ArgsUsage: "<milestone name>",
Action: func(ctx *cli.Context) error {
if ctx.Bool("force") {
return deleteMilestone(ctx)
}
return editMilestoneStatus(ctx, cmd, true)
return editMilestoneStatus(ctx, true)
},
Flags: append([]cli.Flag{
&cli.BoolFlag{

View File

@@ -1,20 +1,20 @@
// 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 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"
"code.gitea.io/sdk/gitea"
"github.com/araddon/dateparse"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdMilestonesCreate represents a sub command of milestones to create milestone
@@ -49,11 +49,8 @@ var CmdMilestonesCreate = cli.Command{
}, flags.AllDefaultFlags...),
}
func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
func runMilestonesCreate(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
date := ctx.String("deadline")
deadline := &time.Time{}
@@ -70,11 +67,8 @@ func runMilestonesCreate(_ stdctx.Context, cmd *cli.Command) error {
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
if ctx.NumFlags() == 0 {
return interact.CreateMilestone(ctx.Login, ctx.Owner, ctx.Repo)
}
return task.CreateMilestone(

View File

@@ -1,15 +1,14 @@
// 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 milestones
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdMilestonesDelete represents a sub command of milestones to delete an milestone
@@ -23,16 +22,11 @@ var CmdMilestonesDelete = cli.Command{
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
}
func deleteMilestone(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
_, err = client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First())
_, err := client.DeleteMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First())
return err
}

View File

@@ -1,19 +1,19 @@
// 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 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"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v2"
)
var msIssuesFieldsFlag = flags.FieldsFlag(print.IssueFields, []string{
@@ -28,7 +28,7 @@ var CmdMilestonesIssues = cli.Command{
Description: "manage issue/pull of an milestone",
ArgsUsage: "<milestone name>",
Action: runMilestoneIssueList,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&CmdMilestoneAddIssue,
&CmdMilestoneRemoveIssue,
},
@@ -70,39 +70,40 @@ var CmdMilestoneRemoveIssue = cli.Command{
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
}
func runMilestoneIssueList(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
state, err := flags.ParseState(ctx.String("state"))
if err != nil {
return err
state := gitea.StateOpen
switch ctx.String("state") {
case "all":
state = gitea.StateAll
case "closed":
state = gitea.StateClosed
}
kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeAll)
if err != nil {
return err
kind := gitea.IssueTypeAll
switch ctx.String("kind") {
case "issue":
kind = gitea.IssueTypeIssue
case "pull":
kind = gitea.IssueTypePull
}
if ctx.Args().Len() != 1 {
return fmt.Errorf("milestone name is required")
return fmt.Errorf("Must specify milestone name")
}
milestone := ctx.Args().First()
// make sure milestone exist
_, _, err = client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone)
_, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestone)
if err != nil {
return err
}
issues, _, err := client.ListRepoIssues(ctx.Owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd),
ListOptions: ctx.GetListOptions(),
Milestones: []string{milestone},
Type: kind,
State: state,
@@ -115,17 +116,13 @@ func runMilestoneIssueList(_ stdctx.Context, cmd *cli.Command) error {
if err != nil {
return err
}
return print.IssuesPullsList(issues, ctx.Output, fields)
print.IssuesPullsList(issues, ctx.Output, fields)
return nil
}
func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
func runMilestoneIssueAdd(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
if ctx.Args().Len() != 2 {
return fmt.Errorf("need two arguments")
@@ -141,26 +138,18 @@ func runMilestoneIssueAdd(_ stdctx.Context, cmd *cli.Command) error {
// make sure milestone exist
mile, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, mileName)
if err != nil {
return fmt.Errorf("failed to get milestone '%s': %w", mileName, err)
return 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
return err
}
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
}
func runMilestoneIssueRemove(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
if ctx.Args().Len() != 2 {
return fmt.Errorf("need two arguments")
@@ -170,28 +159,25 @@ func runMilestoneIssueRemove(_ stdctx.Context, cmd *cli.Command) error {
issueIndex := ctx.Args().Get(1)
idx, err := utils.ArgToIndex(issueIndex)
if err != nil {
return fmt.Errorf("invalid issue index '%s': %w", issueIndex, err)
return err
}
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
if err != nil {
return fmt.Errorf("failed to get issue #%d: %w", idx, err)
return err
}
if issue.Milestone == nil {
return fmt.Errorf("issue #%d is not assigned to a milestone", idx)
return fmt.Errorf("issue is not assigned to a milestone")
}
if issue.Milestone.Title != mileName {
return fmt.Errorf("issue #%d is assigned to milestone '%s', not '%s'", idx, issue.Milestone.Title, mileName)
return fmt.Errorf("issue is not assigned to this milestone")
}
zero := int64(0)
_, _, 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
return err
}

View File

@@ -1,17 +1,16 @@
// 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 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"
"github.com/urfave/cli/v2"
)
var fieldsFlag = flags.FieldsFlag(print.MilestoneFields, []string{
@@ -39,36 +38,36 @@ var CmdMilestonesList = cli.Command{
}
// 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
}
func RunMilestonesList(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
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") {
state := gitea.StateOpen
switch ctx.String("state") {
case "all":
state = gitea.StateAll
if !cmd.IsSet("fields") { // add to default fields
fields = append(fields, "state")
}
case "closed":
state = gitea.StateClosed
}
client := ctx.Login.Client()
milestones, _, err := client.ListRepoMilestones(ctx.Owner, ctx.Repo, gitea.ListMilestoneOption{
ListOptions: flags.GetListOptions(cmd),
ListOptions: ctx.GetListOptions(),
State: state,
})
if err != nil {
return err
}
return print.MilestonesList(milestones, ctx.Output, fields)
print.MilestonesList(milestones, ctx.Output, fields)
return nil
}

View File

@@ -1,73 +1,43 @@
// 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 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"
"github.com/urfave/cli/v2"
)
// 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)
Usage: "Change state of an milestone to 'open'",
Description: `Change state of an milestone to 'open'`,
ArgsUsage: "<milestone name>",
Action: func(ctx *cli.Context) error {
return editMilestoneStatus(ctx, 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)
}
func editMilestoneStatus(cmd *cli.Context, close bool) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
client := ctx.Login.Client()
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{
_, _, err := client.EditMilestoneByName(ctx.Owner, ctx.Repo, ctx.Args().First(), gitea.EditMilestoneOption{
State: &state,
Title: ms,
}
milestone, _, err := client.EditMilestoneByName(ctx.Owner, ctx.Repo, ms, opts)
if err != nil {
return err
}
Title: ctx.Args().First(),
})
if ctx.Args().Len() > 1 {
fmt.Printf("%s/milestone/%d\n", repoURL, milestone.ID)
} else {
print.MilestoneDetails(milestone)
}
}
return nil
return err
}

View File

@@ -1,12 +1,13 @@
// 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"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdNotifications is the main command to operate with notifications
@@ -17,7 +18,7 @@ var CmdNotifications = cli.Command{
Usage: "Show notifications",
Description: "Show notifications, by default based on the current repo if available",
Action: notifications.RunNotificationsList,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&notifications.CmdNotificationsList,
&notifications.CmdNotificationsMarkRead,
&notifications.CmdNotificationsMarkUnread,

View File

@@ -1,17 +1,18 @@
// Copyright 2021 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 notifications
import (
stdctx "context"
"log"
"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"
"github.com/urfave/cli/v2"
)
var notifyFieldsFlag = flags.FieldsFlag(print.NotificationFields, []string{
@@ -36,9 +37,9 @@ var CmdNotificationsList = cli.Command{
}
// RunNotificationsList list notifications
func RunNotificationsList(ctx stdctx.Context, cmd *cli.Command) error {
func RunNotificationsList(ctx *cli.Context) error {
var states []gitea.NotifyStatus
statesStr, err := flags.NotificationStateFlag.GetValues(cmd)
statesStr, err := flags.NotificationStateFlag.GetValues(ctx)
if err != nil {
return err
}
@@ -47,7 +48,7 @@ func RunNotificationsList(ctx stdctx.Context, cmd *cli.Command) error {
}
var types []gitea.NotifySubjectType
typesStr, err := notifyTypeFlag.GetValues(cmd)
typesStr, err := notifyTypeFlag.GetValues(ctx)
if err != nil {
return err
}
@@ -55,23 +56,20 @@ func RunNotificationsList(ctx stdctx.Context, cmd *cli.Command) error {
types = append(types, gitea.NotifySubjectType(t))
}
return listNotifications(ctx, cmd, states, types)
return listNotifications(ctx, 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 {
func listNotifications(cmd *cli.Context, status []gitea.NotifyStatus, subjects []gitea.NotifySubjectType) error {
var news []*gitea.NotificationThread
var err error
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
ctx := context.InitCommand(cmd)
client := ctx.Login.Client()
all := ctx.Bool("mine")
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
listOpts := flags.GetListOptions(cmd)
listOpts := ctx.GetListOptions()
if listOpts.Page == 0 {
listOpts.Page = 1
}
@@ -93,9 +91,7 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
SubjectTypes: subjects,
})
} else {
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
news, _, err = client.ListRepoNotifications(ctx.Owner, ctx.Repo, gitea.ListNotificationOptions{
ListOptions: listOpts,
Status: status,
@@ -103,8 +99,9 @@ func listNotifications(_ stdctx.Context, cmd *cli.Command, status []gitea.Notify
})
}
if err != nil {
return err
log.Fatal(err)
}
return print.NotificationsList(news, ctx.Output, fields)
print.NotificationsList(news, ctx.Output, fields)
return nil
}

View File

@@ -1,17 +1,17 @@
// Copyright 2021 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 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"
"github.com/urfave/cli/v2"
)
// CmdNotificationsMarkRead represents a sub command of notifications to list read notifications
@@ -22,19 +22,16 @@ var CmdNotificationsMarkRead = cli.Command{
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)
Action: func(ctx *cli.Context) error {
cmd := context.InitCommand(ctx)
filter, err := flags.NotificationStateFlag.GetValues(ctx)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
if !ctx.IsSet(flags.NotificationStateFlag.Name) {
if !cmd.IsSet(flags.NotificationStateFlag.Name) {
filter = []string{string(gitea.NotifyStatusUnread)}
}
return markNotificationAs(ctx, filter, gitea.NotifyStatusRead)
return markNotificationAs(cmd, filter, gitea.NotifyStatusRead)
},
}
@@ -46,19 +43,16 @@ var CmdNotificationsMarkUnread = cli.Command{
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)
Action: func(ctx *cli.Context) error {
cmd := context.InitCommand(ctx)
filter, err := flags.NotificationStateFlag.GetValues(ctx)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
if !ctx.IsSet(flags.NotificationStateFlag.Name) {
if !cmd.IsSet(flags.NotificationStateFlag.Name) {
filter = []string{string(gitea.NotifyStatusRead)}
}
return markNotificationAs(ctx, filter, gitea.NotifyStatusUnread)
return markNotificationAs(cmd, filter, gitea.NotifyStatusUnread)
},
}
@@ -70,19 +64,16 @@ var CmdNotificationsMarkPinned = cli.Command{
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)
Action: func(ctx *cli.Context) error {
cmd := context.InitCommand(ctx)
filter, err := flags.NotificationStateFlag.GetValues(ctx)
if err != nil {
return err
}
filter, err := flags.NotificationStateFlag.GetValues(cmd)
if err != nil {
return err
}
if !ctx.IsSet(flags.NotificationStateFlag.Name) {
if !cmd.IsSet(flags.NotificationStateFlag.Name) {
filter = []string{string(gitea.NotifyStatusUnread)}
}
return markNotificationAs(ctx, filter, gitea.NotifyStatusPinned)
return markNotificationAs(cmd, filter, gitea.NotifyStatusPinned)
},
}
@@ -93,14 +84,11 @@ var CmdNotificationsUnpin = cli.Command{
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
}
Action: func(ctx *cli.Context) error {
cmd := context.InitCommand(ctx)
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)
return markNotificationAs(cmd, filter, gitea.NotifyStatusRead)
},
}
@@ -121,9 +109,7 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt
if allRepos {
_, _, err = client.ReadNotifications(opts)
} else {
if err := cmd.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
cmd.Ensure(context.CtxRequirement{RemoteRepo: true})
_, _, err = client.ReadRepoNotifications(cmd.Owner, cmd.Repo, opts)
}
@@ -144,12 +130,8 @@ func markNotificationAs(cmd *context.TeaContext, filterStates []string, targetSt
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)
}
// FIXME: this is an API URL, we want to display a web ui link..
fmt.Println(n.Subject.URL)
return nil
}

View File

@@ -1,10 +1,10 @@
// 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 (
stdctx "context"
"path"
"strings"
@@ -13,7 +13,7 @@ import (
local_git "code.gitea.io/tea/modules/git"
"github.com/skratchdot/open-golang/open"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdOpen represents a sub command of issues to open issue on the web browser
@@ -27,14 +27,9 @@ var CmdOpen = cli.Command{
Flags: append([]cli.Flag{}, flags.LoginRepoFlags...),
}
func runOpen(_ 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
}
func runOpen(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
var suffix string
number := ctx.Args().Get(0)
@@ -79,10 +74,6 @@ func runOpen(_ stdctx.Context, cmd *cli.Command) error {
suffix = number
}
repoURL, err := ctx.GetRemoteRepoHTMLURL()
if err != nil {
return err
}
return open.Run(path.Join(repoURL, suffix))
u := path.Join(ctx.Login.URL, ctx.Owner, ctx.Repo, suffix)
return open.Run(u)
}

View File

@@ -1,16 +1,15 @@
// 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 (
stdctx "context"
"code.gitea.io/tea/cmd/organizations"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdOrgs represents handle organization
@@ -22,7 +21,7 @@ var CmdOrgs = cli.Command{
Description: "Show organization details",
ArgsUsage: "[<organization>]",
Action: runOrganizations,
Commands: []*cli.Command{
Subcommands: []*cli.Command{
&organizations.CmdOrganizationList,
&organizations.CmdOrganizationCreate,
&organizations.CmdOrganizationDelete,
@@ -30,15 +29,12 @@ var CmdOrgs = cli.Command{
Flags: organizations.CmdOrganizationList.Flags,
}
func runOrganizations(ctx stdctx.Context, cmd *cli.Command) error {
teaCtx, err := context.InitCommand(cmd)
if err != nil {
return err
func runOrganizations(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
if ctx.Args().Len() == 1 {
return runOrganizationDetail(ctx)
}
if teaCtx.Args().Len() == 1 {
return runOrganizationDetail(teaCtx)
}
return organizations.RunOrganizationList(ctx, cmd)
return organizations.RunOrganizationList(cmd)
}
func runOrganizationDetail(ctx *context.TeaContext) error {

View File

@@ -1,18 +1,18 @@
// Copyright 2021 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 organizations
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"
"github.com/urfave/cli/v3"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v2"
)
// CmdOrganizationCreate represents a sub command of organizations to delete a given user organization
@@ -52,14 +52,11 @@ var CmdOrganizationCreate = cli.Command{
}
// RunOrganizationCreate sets up a new organization
func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
func RunOrganizationCreate(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
if ctx.Args().Len() < 1 {
return fmt.Errorf("organization name is required")
return fmt.Errorf("You have to specify the organization name you want to create")
}
var visibility gitea.VisibleType

View File

@@ -1,15 +1,15 @@
// 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 organizations
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
"github.com/urfave/cli/v2"
)
// CmdOrganizationDelete represents a sub command of organizations to delete a given user organization
@@ -27,21 +27,18 @@ var CmdOrganizationDelete = cli.Command{
}
// RunOrganizationDelete delete user organization
func RunOrganizationDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
func RunOrganizationDelete(cmd *cli.Context) error {
ctx := context.InitCommand(cmd)
client := ctx.Login.Client()
if ctx.Args().Len() < 1 {
return fmt.Errorf("organization name is required")
return fmt.Errorf("You have to specify the organization name you want to delete")
}
response, err := client.DeleteOrg(ctx.Args().First())
if response != nil && response.StatusCode == 404 {
return fmt.Errorf("organization not found: %s", ctx.Args().First())
return fmt.Errorf("The given organization does not exist")
}
return err

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