118 Commits

Author SHA1 Message Date
Renovate Bot
05be589aef chore(deps): update docker.gitea.com/gitea docker tag to v1.26.1 2026-04-25 00:15:05 +00:00
Alain Thiffault
5103496232 fix(pagination): replace Page:-1 with explicit pagination loops (#967)
## Summary

\`Page: -1\` in the Gitea SDK calls \`setDefaults()\` which sets both \`Page=0\` and \`PageSize=0\`, resulting in \`?page=0&limit=0\` being sent to the server. The server interprets \`limit=0\` as "use server default" (typically 30 items via \`DEFAULT_PAGING_NUM\`), not "return everything". Any resource beyond the first page of results was silently invisible.

This affected 8 call sites, with the most user-visible impact being \`tea issues edit --add-labels\` and \`tea pulls edit --add-labels\` silently failing to apply labels on repositories with more than ~30 labels.

## Affected call sites

| File | API call | User-visible impact |
|---|---|---|
| \`modules/task/labels.go\` | \`ListRepoLabels\` | \`issues/pulls edit --add-labels\` fails silently |
| \`modules/interact/issue_create.go\` | \`ListRepoLabels\` | interactive label picker missing labels |
| \`modules/task/pull_review_comment.go\` | \`ListPullReviews\` | review comments truncated |
| \`modules/task/login_ssh.go\` | \`ListMyPublicKeys\` | SSH key auto-detection fails |
| \`modules/task/login_create.go\` | \`ListAccessTokens\` | token name deduplication misses existing tokens |
| \`cmd/pulls.go\` | \`ListPullReviews\` | PR detail view missing reviews |
| \`cmd/releases/utils.go\` | \`ListReleases\` | tag lookup fails on repos with many releases |
| \`cmd/attachments/delete.go\` | \`ListReleaseAttachments\` | attachment deletion fails when many attachments exist |

## Fix

Each call site is replaced with an explicit pagination loop that follows \`resp.NextPage\` until all pages are exhausted.

Reviewed-on: https://gitea.com/gitea/tea/pulls/967
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Alain Thiffault <athiffau@effectivemomentum.com>
Co-committed-by: Alain Thiffault <athiffau@effectivemomentum.com>
2026-04-23 17:06:42 +00:00
Nicolas
a58c35c3e2 fix(cmd): Update CmdRepos description and usage in repos.go (#946)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/946
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-04-20 19:50:28 +00:00
Matěj Cepl
783ac7684a fix(context): skip local repo detection for repo slugs (#960)
Treat explicit --repo slugs as remote targets so commands do not probe
the current worktree. This avoids SHA256 repository failures when local
git autodetection is unnecessary.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/960
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Matěj Cepl <mcepl@cepl.eu>
Co-committed-by: Matěj Cepl <mcepl@cepl.eu>
2026-04-20 19:39:42 +00:00
Renovate Bot
d0b7ea09e8 fix(deps): update module charm.land/lipgloss/v2 to v2.0.3 (#959)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) | `v2.0.2` → `v2.0.3` | ![age](https://developer.mend.io/api/mc/badges/age/go/charm.land%2flipgloss%2fv2/v2.0.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/charm.land%2flipgloss%2fv2/v2.0.2/v2.0.3?slim=true) |

---

### Release Notes

<details>
<summary>charmbracelet/lipgloss (charm.land/lipgloss/v2)</summary>

### [`v2.0.3`](https://github.com/charmbracelet/lipgloss/releases/tag/v2.0.3)

[Compare Source](https://github.com/charmbracelet/lipgloss/compare/v2.0.2...v2.0.3)

#### Changelog

##### Fixed

- [`472d718`](472d718e23): fix: Avoid background color query hang ([#&#8203;636](https://github.com/charmbracelet/lipgloss/issues/636)) ([@&#8203;jedevc](https://github.com/jedevc))

##### Docs

- [`9e39a0a`](9e39a0ad4f): docs: fix README typo ([#&#8203;629](https://github.com/charmbracelet/lipgloss/issues/629)) ([@&#8203;Rohan5commit](https://github.com/Rohan5commit))
- [`cd93a9f`](cd93a9f5d2): docs: fix tree comment typo ([#&#8203;634](https://github.com/charmbracelet/lipgloss/issues/634)) ([@&#8203;Rohan5commit](https://github.com/Rohan5commit))

***

<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>

Thoughts? Questions? We love hearing from you. Feel free to reach out on [X](https://x.com/charmcli), [Discord](https://charm.land/discord), [Slack](https://charm.land/slack), [The Fediverse](https://mastodon.social/@&#8203;charmcli), [Bluesky](https://bsky.app/profile/charm.land).

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMTEuMCIsInVwZGF0ZWRJblZlciI6IjQzLjExMS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/959
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-20 19:34:25 +00:00
Renovate Bot
20914a1375 fix(deps): update module github.com/go-git/go-git/v5 to v5.18.0 (#961)
Reviewed-on: https://gitea.com/gitea/tea/pulls/961
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-20 01:11:50 +00:00
Renovate Bot
3c1c9b2904 chore(deps): update docker.gitea.com/gitea docker tag to v1.26.0 (#962)
Reviewed-on: https://gitea.com/gitea/tea/pulls/962
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-20 01:11:09 +00:00
Matěj Cepl
63bc90ea52 feat(branches): add rename subcommand (#939)
Implements the 'branches rename' command to rename a branch in a repository.
This wraps the Gitea API endpoint PATCH /repos/{owner}/{repo}/branches/{branch}.

Usage: tea branches rename <old_branch_name> <new_branch_name>

Example: tea branches rename -r owner/repo main factory

This resolves issue #938.

Reviewed-on: https://gitea.com/gitea/tea/pulls/939
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Matěj Cepl <mcepl@cepl.eu>
Co-committed-by: Matěj Cepl <mcepl@cepl.eu>
2026-04-15 17:27:47 +00:00
Bo-Yi Wu
9e0a6203ae feat(pulls): add ci status field to pull request list (#956)
## Summary

- Add `"ci"` as a new selectable field for `tea pr list --fields`, allowing users to see CI status across multiple PRs at a glance
- Fetch CI status via `GetCombinedStatus` API **only when the `ci` field is explicitly requested** via `--fields`, avoiding unnecessary API calls in default usage
- Improve CI status display in both detail and list views:
  - **Detail view** (`tea pr <index>`): show each CI check with symbol, context name, description, and clickable link to CI run
  - **List view** (`tea pr list --fields ci`): show symbol + context name per CI check (e.g., `✓ lint,  build,  test`)
  - **Machine-readable output**: return raw state string (e.g., `success`, `pending`)
- Replace pending CI symbol from `⭮` to `` for better readability
- Extract `formatCIStatus` helper and reuse it in `PullDetails` to reduce code duplication
- Add comprehensive tests for CI status formatting and PR list integration

## Detail View Example

```
- CI:
  - ✓ [**lint**](https://ci.example.com/lint): Lint passed
  -  [**build**](https://ci.example.com/build): Build is running
  -  [**test**](https://ci.example.com/test): 3 tests failed
```

## List View Example

```
INDEX  TITLE       STATE  CI
123    Fix bug     open   ✓ lint,  build,  test
```

## Usage

```bash
# Show CI status column in list
tea pr list --fields index,title,state,ci

# Default output is unchanged (no CI column, no extra API calls)
tea pr list
```

## Files Changed

- `cmd/pulls/list.go` — conditionally fetch CI status per PR when `ci` field is selected
- `modules/print/pull.go` — add `ci` field, `formatCIStatus` helper, improve detail/list CI display
- `modules/print/pull_test.go` — comprehensive tests for CI status formatting

## Test plan

- [x] `go build ./...` passes
- [x] `go test ./...` passes (11 new tests)
- [x] `tea pr list` — default output unchanged, no extra API calls
- [x] `tea pr list --fields index,title,state,ci` — CI column with context names
- [x] `tea pr <index>` — CI section shows each check with name, description, and link
- [x] `tea pr list --fields ci -o csv` — machine-readable output shows raw state strings

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

Reviewed-on: https://gitea.com/gitea/tea/pulls/956
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-10 17:29:15 +00:00
Bo-Yi Wu
84ecd16f9c fix(deps): update Go dependencies to latest versions (#955)
## Summary
- Upgrade all Go module dependencies to their latest versions
- Includes updates to charm.land, golang.org/x, goldmark, go-crypto, and other indirect dependencies
- Project builds cleanly with all updates

## Test plan
- [x] `go build ./...` passes
- [x] CI pipeline passes

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

Reviewed-on: https://gitea.com/gitea/tea/pulls/955
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-10 01:40:40 +00:00
Bo-Yi Wu
53e53e1067 feat(workflows): add dispatch, view, enable and disable subcommands (#952)
## Summary

- Add `tea actions workflows dispatch` to trigger `workflow_dispatch` events with `--ref`, `--input key=value`, and `--follow` for log tailing
- Add `tea actions workflows view` to show workflow details
- Add `tea actions workflows enable` and `disable` to toggle workflow state
- Rewrite `workflows list` to use the Workflow API instead of file listing
- Remove dead `WorkflowsList` print function that used `ContentsResponse`
- Update `CLI.md` and `example-workflows.md` with usage documentation and examples

## Motivation

Enable re-triggering specific workflows from the CLI, which is essential for AI-driven PR flows where a specific workflow needs to be re-run after pushing changes.

Leverages the 5 workflow API endpoints already supported by the Go SDK (v0.24.1) from go-gitea/gitea#33545:
- `ListRepoActionWorkflows`
- `GetRepoActionWorkflow`
- `DispatchRepoActionWorkflow` (with `returnRunDetails` support)
- `EnableRepoActionWorkflow`
- `DisableRepoActionWorkflow`

## New commands

\`\`\`
tea actions workflows
├── list          (rewritten to use Workflow API)
├── view <id>     (new)
├── dispatch <id> (new)
├── enable <id>   (new)
└── disable <id>  (new)
\`\`\`

### Usage examples

\`\`\`bash
# Dispatch workflow on current branch
tea actions workflows dispatch deploy.yml

# Dispatch with specific ref and inputs
tea actions workflows dispatch deploy.yml --ref main --input env=staging --input version=1.2.3

# Dispatch and follow logs
tea actions workflows dispatch ci.yml --ref feature/my-pr --follow

# View workflow details
tea actions workflows view deploy.yml

# Enable/disable workflows
tea actions workflows enable deploy.yml
tea actions workflows disable deploy.yml --confirm
\`\`\`

## Test plan

- [x] `go build ./...` passes
- [x] `go test ./...` passes
- [x] `go vet ./...` passes
- [x] `make lint` — 0 issues
- [x] `make docs-check` — CLI.md is up to date
- [x] Manual test: `tea actions workflows list` shows workflows from API
- [x] Manual test: `tea actions workflows dispatch <workflow> --ref main` triggers a run
- [x] Manual test: `tea actions workflows view <workflow>` shows details

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/952
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-09 20:03:33 +00:00
Renovate Bot
0489d8c275 fix(deps): update module golang.org/x/sys to v0.43.0 (#951)
Reviewed-on: https://gitea.com/gitea/tea/pulls/951
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-09 14:16:37 +00:00
Nicolas
f538c05282 refactor: code cleanup across codebase (#947)
## Summary

- Extract duplicate \`getReleaseByTag\` into shared \`cmd/releases/utils.go\`
- Replace \`log.Fatal\` calls with proper error returns in config and login commands; \`GetLoginByToken\`/\`GetLoginsByHost\`/\`GetLoginByHost\` now return errors
- Remove dead \`portChan\` channel in \`modules/auth/oauth.go\`
- Fix YAML integer detection to use \`strconv.ParseInt\` (correctly handles negatives and large ints)
- Fix \`path.go\` error handling to use \`errors.As\` + \`syscall.ENOTDIR\` instead of string comparison
- Extract repeated credential helper key into local variable in \`SetupHelper\`
- Use existing \`isRemoteDeleted()\` in \`pull_clean.go\` instead of duplicating the logic
- Fix ~30 error message casing violations to follow Go conventions
- Use \`fmt.Errorf\` consistently instead of string concatenation in \`generic.go\`

Reviewed-on: https://gitea.com/gitea/tea/pulls/947
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Bo-Yi Wu (吳柏毅) <appleboy.tw@gmail.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-04-08 03:38:49 +00:00
Bo-Yi Wu
662e339bf9 feat(pulls): add resolve, unresolve and review-comments subcommands (#948)
## Summary

- Add `tea pulls review-comments <pull-index>` subcommand to list PR review comments with configurable fields (supports table/json/csv/yaml output)
- Add `tea pulls resolve <comment-id>` subcommand to mark a review comment as resolved
- Add `tea pulls unresolve <comment-id>` subcommand to unmark a review comment as resolved
- Follow existing approve/reject pattern with shared `runResolveComment` helper in `review_helpers.go`

## Usage

```bash
# List review comments for PR #42
tea pulls review-comments 42

# Resolve comment #789
tea pulls resolve 789

# Unresolve comment #789
tea pulls unresolve 789

# Custom output fields
tea pulls review-comments 42 --fields id,path,body,resolver --output json
```

## New Files

| File | Description |
|------|-------------|
| `cmd/pulls/review_comments.go` | `review-comments` subcommand |
| `cmd/pulls/resolve.go` | `resolve` subcommand |
| `cmd/pulls/unresolve.go` | `unresolve` subcommand |
| `modules/task/pull_review_comment.go` | Task layer: list, resolve, unresolve via SDK |
| `modules/print/pull_review_comment.go` | Print formatting with `printable` interface |

## Modified Files

| File | Description |
|------|-------------|
| `cmd/pulls.go` | Register 3 new commands |
| `cmd/pulls/review_helpers.go` | Add shared `runResolveComment` helper |

## Test Plan

- [x] `go build ./...` passes
- [x] `go vet ./...` passes
- [x] `tea pulls review-comments <PR-index>` lists comments with IDs
- [x] `tea pulls resolve <comment-id>` resolves successfully
- [x] `tea pulls unresolve <comment-id>` unresolves successfully
- [x] `--output json` produces valid JSON output

Reviewed-on: https://gitea.com/gitea/tea/pulls/948
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-04-08 03:36:09 +00:00
appleboy
5bb73667d1 docs: add v0.13.0 release notes to CHANGELOG (#945)
Add v0.13.0 release notes to CHANGELOG.md covering 21 commits since v0.12.0: 5 new features, 2 enhancements, and dependency updates.

Reviewed-on: https://gitea.com/gitea/tea/pulls/945
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: appleboy <appleboy.tw@gmail.com>
Co-committed-by: appleboy <appleboy.tw@gmail.com>
2026-04-05 16:42:27 +00:00
appleboy
f329f6fab2 feat(pulls): add edit subcommand for pull requests (#944)
## Summary

- Add `tea pr edit` subcommand to support editing pull request properties (description, title, milestone, deadline, assignees, labels, reviewers)
- Add `--add-reviewers` / `--remove-reviewers` flags for managing PR reviewers via `CreateReviewRequests` / `DeleteReviewRequests` API
- Extract shared helpers (`ResolveLabelOpts`, `ApplyLabelChanges`, `ApplyReviewerChanges`, `ResolveMilestoneID`) into `modules/task/labels.go` to reduce duplication between issue and PR editing
- Refactor existing `EditIssue` to use the same shared helpers
- Wrap original error in `ResolveMilestoneID` to preserve underlying error context

## Usage

```bash
# Edit PR description
tea pr edit 1 --description "new description"

# Edit PR title
tea pr edit 1 --title "new title"

# Edit multiple fields
tea pr edit 1 --title "new title" --description "new desc" --add-labels "bug"

# Edit multiple PRs
tea pr edit 1 2 3 --add-assignees "user1"

# Add reviewers
tea pr edit 1 --add-reviewers "user1,user2"

# Remove reviewers
tea pr edit 1 --remove-reviewers "user1"
```

## Test plan

- [x] `go build .` succeeds
- [x] `go test ./...` passes
- [x] `make clean && make vet && make lint && make fmt-check && make docs-check && make build` all pass
- [x] `tea pr edit <idx> --description "test"` updates PR description on a Gitea instance
- [x] `tea pr edit <idx> --title "test"` updates PR title
- [x] `tea pr edit <idx> --add-labels "bug"` adds label
- [x] `tea pr edit <idx> --add-reviewers "user"` requests review
- [x] `tea pr edit <idx> --remove-reviewers "user"` removes reviewer
- [x] Existing `tea issues edit` still works correctly after refactor

Reviewed-on: https://gitea.com/gitea/tea/pulls/944
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: appleboy <appleboy.tw@gmail.com>
Co-committed-by: appleboy <appleboy.tw@gmail.com>
2026-04-05 05:35:15 +00:00
Renovate Bot
366069315f fix(deps): update module github.com/go-git/go-git/v5 to v5.17.2 (#943)
Reviewed-on: https://gitea.com/gitea/tea/pulls/943
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-01 18:14:14 +00:00
Renovate Bot
1e13681663 fix(deps): update module github.com/go-git/go-git/v5 to v5.17.1 (#942)
Reviewed-on: https://gitea.com/gitea/tea/pulls/942
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-30 07:00:31 +00:00
Renovate Bot
bfbec3fc00 fix(deps): update module code.gitea.io/sdk/gitea to v0.24.1 (#936)
Reviewed-on: https://gitea.com/gitea/tea/pulls/936
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-27 06:04:29 +00:00
Renovate Bot
e31a167e54 fix(deps): update module github.com/go-authgate/sdk-go to v0.6.1 (#935)
Reviewed-on: https://gitea.com/gitea/tea/pulls/935
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-27 03:47:06 +00:00
Renovate Bot
6a7c3e4efa fix(deps): update module github.com/urfave/cli/v3 to v3.8.0 (#937)
Reviewed-on: https://gitea.com/gitea/tea/pulls/937
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-27 03:46:50 +00:00
techknowlogick
b05e03416b replace log.Fatal/os.Exit with error returns (#941)
* Use stdlib encoders
* Reduce some duplication
* Remove global pagination state
* Dedupe JSON detail types
* Bump golangci-lint

Reviewed-on: https://gitea.com/gitea/tea/pulls/941
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-03-27 03:36:44 +00:00
Renovate Bot
21881525a8 chore(deps): update docker.gitea.com/gitea docker tag to v1.25.5 (#934)
Reviewed-on: https://gitea.com/gitea/tea/pulls/934
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-15 23:04:40 +00:00
Renovate Bot
9a462247bd fix(deps): update module github.com/olekukonko/tablewriter to v1.1.4 (#933)
Reviewed-on: https://gitea.com/gitea/tea/pulls/933
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-13 00:18:26 +00:00
Renovate Bot
5f74fb37df chore(deps): update mcr.microsoft.com/devcontainers/go docker tag to v2.1 (#930)
Reviewed-on: https://gitea.com/gitea/tea/pulls/930
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-12 17:13:25 +00:00
Bo-Yi Wu
ec658cfc33 chore(deps): update Go dependencies and CI workflow action versions (#932)
## Summary

- Run `go get -u ./...` and `go mod tidy` to update all Go dependencies
- Update CI workflow action versions:
  - `crazy-max/ghaction-import-gpg`: v6 → v7
  - `goreleaser/goreleaser-action`: v6 → v7
  - `docker/setup-qemu-action`: v3 → v4
  - `docker/setup-buildx-action`: v3 → v4
  - `docker/login-action`: v3 → v4
  - `docker/build-push-action`: v6 → v7

## Notable Go dependency updates

- `github.com/urfave/cli/v3`: v3.6.2 → v3.7.0
- `github.com/ProtonMail/go-crypto`: v1.3.0 → v1.4.0
- `charm.land/huh/v2`: v2.0.1 → v2.0.3
- `golang.org/x/crypto`: v0.48.0 → v0.49.0
- `golang.org/x/net`: v0.49.0 → v0.52.0

Reviewed-on: https://gitea.com/gitea/tea/pulls/932
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-03-12 05:28:47 +00:00
Bo-Yi Wu
cb9824b451 feat(repos): support owner-based repository listing with robust lookup (#931)
- Add an owner flag to the repos list command to list repositories for a specific user or organization
- Implement owner-based repository listing by detecting whether the owner is an organization or a user and calling the appropriate API
- Improve error handling for owner lookup by checking HTTP status codes instead of relying on error string matching
- Align repository search logic with the updated owner lookup behavior using HTTP response validation

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

Reviewed-on: https://gitea.com/gitea/tea/pulls/931
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-03-12 04:22:28 +00:00
Bo-Yi Wu
a531faa626 feat(repos): add repo edit subcommand (#928)
## Summary
- Add `tea repo edit` subcommand to update repository properties via the Gitea API
- Support flags: `--name`, `--description`/`--desc`, `--website`, `--private`, `--template`, `--archived`, `--default-branch`
- Boolean-like flags use string type to distinguish "not set" from "false", following the pattern in `releases/edit.go`

## Test plan
- [x] `go build ./...` passes
- [x] `go vet ./...` passes
- [x] `tea repo edit --help` shows all flags correctly
- [x] Manual test: `tea repo edit --private true` on a test repo
- [x] Manual test: `tea repo edit --name new-name --description "new desc"` on a test repo

Reviewed-on: https://gitea.com/gitea/tea/pulls/928
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-03-12 03:06:44 +00:00
Bo-Yi Wu
302c946cb8 feat: store OAuth tokens in OS keyring via credstore (#926)
## Summary

- Introduce `github.com/go-authgate/sdk-go/credstore` to store OAuth tokens securely in the OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Manager), with automatic fallback to an encrypted JSON file
- Add `AuthMethod` field to `Login` struct; new OAuth logins are marked `auth_method: oauth` and no longer write `token`/`refresh_token`/`token_expiry` to `config.yml`
- Add `GetAccessToken()` / `GetRefreshToken()` / `GetTokenExpiry()` accessors that transparently read from credstore for OAuth logins, with fallback to YAML fields for legacy logins
- Update all token reference sites across the codebase to use the new accessors
- Non-OAuth logins (token, SSH) are completely unaffected; no migration of existing tokens

## Key files

| File | Role |
|------|------|
| `modules/config/credstore.go` | **New** — credstore wrapper (Load/Save/Delete) |
| `modules/config/login.go` | Login struct, token accessors, refresh logic |
| `modules/auth/oauth.go` | OAuth flow, token creation / re-authentication |
| `modules/api/client.go`, `cmd/login/helper.go`, `cmd/login/oauth_refresh.go` | Token reference updates |
| `modules/task/pull_*.go`, `modules/task/repo_clone.go` | Git operation token reference updates |

## Test plan

- [x] `go build ./...` compiles successfully
- [x] `go test ./...` all tests pass
- [x] `tea login add --oauth` completes OAuth flow; verify config.yml has `auth_method: oauth` but no token/refresh_token/token_expiry
- [x] `tea repos ls` API calls work (token read from credstore)
- [x] `tea login delete <name>` credstore token is also removed
- [x] Existing non-OAuth logins continue to work unchanged

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

Reviewed-on: https://gitea.com/gitea/tea/pulls/926
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-03-12 02:49:14 +00:00
techknowlogick
0346e1cbb5 add function comment 2026-03-10 10:00:39 -04:00
techknowlogick
cd4051ed38 make vet&fmt pass 2026-03-10 09:55:10 -04:00
Michal Suchanek
c797624fcf Update to charm libraries v2 (#923)
Reviewed-on: https://gitea.com/gitea/tea/pulls/923
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: Michal Suchanek <msuchanek@suse.de>
Co-committed-by: Michal Suchanek <msuchanek@suse.de>
2026-03-09 16:36:00 +00:00
Renovate Bot
3372c9ec59 fix(deps): update module golang.org/x/oauth2 to v0.36.0 (#919)
Reviewed-on: https://gitea.com/gitea/tea/pulls/919
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-03-09 16:19:28 +00:00
techknowlogick
1ac8492ac7 go 1.26 2026-03-09 15:57:54 +00:00
Renovate Bot
d019f0dd72 fix(deps): update module github.com/go-git/go-git/v5 to v5.17.0 (#910)
Reviewed-on: https://gitea.com/gitea/tea/pulls/910
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-26 17:59:28 +00:00
techknowlogick
c031db2413 Parse multiple values in api subcommand (#911)
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-26 17:43:46 +00:00
Nikolaos Karaolidis
e3c550ff22 fix: authentication via env variables repo argument (#809)
---------

Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/809
Co-authored-by: Nikolaos Karaolidis <nick@karaolidis.com>
Co-committed-by: Nikolaos Karaolidis <nick@karaolidis.com>
2026-02-19 19:23:44 +00:00
Lunny Xiao
fab70f83c1 Fix issue detail view ignoring --owner flag (#899)
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/899
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-02-19 18:57:23 +00:00
techknowlogick
0b1147bfc0 build for windows aarch64 too 2026-02-19 18:41:21 +00:00
Lunny Xiao
93d4d3cc55 Skip token uniqueness check when using SSH authentication (#898)
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-authored-by: silverwind <silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/898
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-02-19 15:19:45 +00:00
Alain Thiffault
bdf15a57be feat(pulls): add JSON output support for single PR view (#864)
Reviewed-on: https://gitea.com/gitea/tea/pulls/864
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Alain Thiffault <athiffau@effectivemomentum.com>
Co-committed-by: Alain Thiffault <athiffau@effectivemomentum.com>
2026-02-19 15:16:21 +00:00
Lunny Xiao
87c8c3d6e0 Fix new tty prompt (#897)
Fix #827

---------

Co-authored-by: silverwind <silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/897
Reviewed-by: silverwind <silverwind@noreply.gitea.com>
2026-02-16 03:37:44 +00:00
Michal Suchanek
dfd400f15b Fix termenv OSC RGBA handling (#907)
Fixes: #889
Reviewed-on: https://gitea.com/gitea/tea/pulls/907
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Michal Suchanek <msuchanek@suse.de>
Co-committed-by: Michal Suchanek <msuchanek@suse.de>
2026-02-12 16:16:53 +00:00
yousfi saad
2152d99f2d Add tea actions runs and workflows commands (#880)
Implements comprehensive workflow execution tracking for Gitea Actions using tea CLI

## Features

### tea actions runs list
- List workflow runs with filtering (status, branch, event, actor, time)
- Time filters: relative (24h, 7d) and absolute dates
- Status symbols: ✓ success, ✘ failure, ⭮ pending, ⊘ skipped/cancelled, ⚠ blocked
- Multiple output formats: table, json, yaml, csv, tsv

### tea actions runs view
- View run details with metadata (ID, status, workflow, branch, event, trigger info)
- Shows jobs table with status, runner, duration
- Optional --jobs flag to toggle jobs display

### tea actions runs delete
- Delete/cancel workflow runs with confirmation prompt
- Supports --confirm/-y to skip prompt

### tea actions runs logs
- View job logs for all jobs or specific job (--job <id>)
- **New: --follow/-f flag for real-time log following** (like tail -f)
- Polls API every 2 seconds, only shows new content
- Auto-detects completion and exits

### tea actions workflows list
- List workflow files (.yml and .yaml) in repository
- Searches in .gitea/workflows and .github/workflows
- Shows active (✓) or inactive (✗) status based on recent runs
- Displays workflow name, path, and file size

## Commands

`tea actions runs list --status success --since 24h`
`tea actions runs view 123`
`tea actions runs delete 123 --confirm`
`tea actions runs logs 123 --job 456 --follow`
`tea actions workflows list`

## Tests
- 19 unit tests across all commands
- Full test suite passing
- Manual testing successful

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/880
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: yousfi saad <yousfi.saad@gmail.com>
Co-committed-by: yousfi saad <yousfi.saad@gmail.com>
2026-02-11 00:40:06 +00:00
Renovate Bot
ea795775af fix(deps): update module golang.org/x/crypto to v0.48.0 (#905)
Reviewed-on: https://gitea.com/gitea/tea/pulls/905
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-10 00:43:00 +00:00
Renovate Bot
1093ef1524 fix(deps): update module github.com/go-git/go-git/v5 to v5.16.5 (#904)
Reviewed-on: https://gitea.com/gitea/tea/pulls/904
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-10 00:42:45 +00:00
Renovate Bot
873a44f897 fix(deps): update module golang.org/x/sys to v0.41.0 (#901)
Reviewed-on: https://gitea.com/gitea/tea/pulls/901
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-09 05:22:44 +00:00
Renovate Bot
47f74ea696 fix(deps): update module golang.org/x/oauth2 to v0.35.0 (#900)
Reviewed-on: https://gitea.com/gitea/tea/pulls/900
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-09 04:56:34 +00:00
Michal Suchanek
59656dfcd2 Require non-empty token in GetLoginByToken (#895)
Fixes: #893
Reviewed-on: https://gitea.com/gitea/tea/pulls/895
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Michal Suchanek <msuchanek@suse.de>
Co-committed-by: Michal Suchanek <msuchanek@suse.de>
2026-02-08 18:11:54 +00:00
Michal Suchanek
e644cc49d4 Revert "Login requires a http/https login URL and revmoe SSH as a login method. SSH will be optional (#826)" (#891)
This reverts commit 90f8624ae7.

Fixes: #890

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/891
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Michal Suchanek <msuchanek@suse.de>
Co-committed-by: Michal Suchanek <msuchanek@suse.de>
2026-02-08 00:21:47 +00:00
boozedog
3595f8f89d fixed minor typo and grammar issue (#892)
Reviewed-on: https://gitea.com/gitea/tea/pulls/892
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: boozedog <boozedog@noreply.gitea.com>
Co-committed-by: boozedog <boozedog@noreply.gitea.com>
2026-02-07 16:02:20 +00:00
techknowlogick
49a9032d8a Move versions/filelocker into dedicated subpackages, and consistent headers in http requests (#888)
- move filelocker logic into dedicated subpackage
- consistent useragent in requests

Reviewed-on: https://gitea.com/gitea/tea/pulls/888
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-05 18:05:43 +00:00
techknowlogick
982adb4d02 Update README.md 2026-02-04 19:37:39 +00:00
techknowlogick
29488a1f46 build w/ go1.25 (#886)
Reviewed-on: https://gitea.com/gitea/tea/pulls/886
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-04 19:27:25 +00:00
Renovate Bot
a47ac265d2 chore(deps): update mcr.microsoft.com/devcontainers/go docker tag to v2 (#884)
Reviewed-on: https://gitea.com/gitea/tea/pulls/884
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-04 00:56:27 +00:00
Renovate Bot
037d1aad23 fix(deps): update module github.com/charmbracelet/lipgloss to v2 (#885)
Reviewed-on: https://gitea.com/gitea/tea/pulls/885
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-04 00:56:04 +00:00
Renovate Bot
e5342660fa chore(deps): update actions/checkout action to v6 (#882)
Reviewed-on: https://gitea.com/gitea/tea/pulls/882
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-04 00:16:06 +00:00
Renovate Bot
233ffe4508 chore(deps): update actions/setup-go action to v6 (#883)
Reviewed-on: https://gitea.com/gitea/tea/pulls/883
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-04 00:15:53 +00:00
techknowlogick
ae9eb4f2c0 Add locking to ensure safe concurrent access to config file (#881)
Reviewed-on: https://gitea.com/gitea/tea/pulls/881
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-03 23:48:18 +00:00
a1012112796
0d5bf60632 support create agit flow pull request (#867)
while looks the alibaba has not maintain
[`git-repo-go`](https://github.com/alibaba/git-repo-go/)
tool, to make agit flow pull requst can be create quickly.
add creating agit flow pull request feature
in tea tool

example:

```SHELL
tea pulls create --agit --remote=origin --topic=test-topic
--title="hello world" --description="test1
test 2
test 3"
```

Signed-off-by: a1012112796 <1012112796@qq.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/867
Co-authored-by: a1012112796 <1012112796@qq.com>
Co-committed-by: a1012112796 <1012112796@qq.com>
2026-02-03 20:36:04 +00:00
techknowlogick
82d8a14c73 Add api subcommand for arbitrary api calls not covered by existing subcommands (#879)
Reviewed-on: https://gitea.com/gitea/tea/pulls/879
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-03 20:24:21 +00:00
Renovate Bot
6414a5e00e chore(deps): update docker.gitea.com/gitea docker tag to v1.25.4 (#877)
Reviewed-on: https://gitea.com/gitea/tea/pulls/877
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-03 01:06:13 +00:00
Renovate Bot
864face284 fix(deps): update module golang.org/x/oauth2 to v0.34.0 (#878)
Reviewed-on: https://gitea.com/gitea/tea/pulls/878
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-03 01:06:03 +00:00
Renovate Bot
383c5fdc03 fix(deps): update module github.com/urfave/cli/v3 to v3.6.2 (#876)
Reviewed-on: https://gitea.com/gitea/tea/pulls/876
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-03 01:00:21 +00:00
Renovate Bot
7801310a18 fix(deps): update module github.com/olekukonko/tablewriter to v1.1.3 (#875)
Reviewed-on: https://gitea.com/gitea/tea/pulls/875
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-02-03 01:00:03 +00:00
techknowlogick
c2180048a0 Split up Context (#873)
Reviewed-on: https://gitea.com/gitea/tea/pulls/873
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-02 23:16:39 +00:00
techknowlogick
629872d1e9 nix flake update (#872)
Reviewed-on: https://gitea.com/gitea/tea/pulls/872
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-02 23:05:45 +00:00
techknowlogick
0be14de5c2 bump devcontainer 2026-02-02 23:02:00 +00:00
techknowlogick
4f8cb7ef19 helpful error messages (#871)
Reviewed-on: https://gitea.com/gitea/tea/pulls/871
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-02 22:59:22 +00:00
techknowlogick
f638dba99b More improvements (#870)
- no duplicate logins
- link to html page rather than api in output
- client side pagination of watched repos

Reviewed-on: https://gitea.com/gitea/tea/pulls/870
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-02 22:58:25 +00:00
techknowlogick
20da414145 Code Cleanup (#869)
- switch to golangci-lint for linting
- switch to gofmpt for formatting
- fix lint and fmt issues that came up from switch to new tools
- upgrade go-sdk to 0.23.2
- support pagination for listing tracked times
- remove `FixPullHeadSha` workaround (upstream fix has been merged for 5+ years at this point)
- standardize on US spelling (previously a mix of US&UK spelling)
- remove some unused code
- reduce some duplication in parsing state and issue type
- reduce some duplication in reading input for secrets and variables
- reduce some duplication with PR Review code
- report error for when yaml parsing fails
- various other misc cleanup

Reviewed-on: https://gitea.com/gitea/tea/pulls/869
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-02 22:39:26 +00:00
techknowlogick
ae740a66e8 update sdk version (#868)
Reviewed-on: https://gitea.com/gitea/tea/pulls/868
Co-authored-by: techknowlogick <techknowlogick@gitea.com>
Co-committed-by: techknowlogick <techknowlogick@gitea.com>
2026-02-02 19:54:44 +00:00
techknowlogick
c2e9265dae bump more CI actions 2026-02-02 19:53:20 +00:00
techknowlogick
45260e1a1f bump action versions in CI for PRs
disable govulncheck temporarily
2026-02-02 19:50:25 +00:00
Alain Thiffault
7ab3366220 fix(labels): improve delete command and fix --id flag type (#865)
## Summary

Fix the `tea labels delete` and `tea labels update` commands which were silently ignoring the `--id` flag.

## Problem

Both commands used `IntFlag` for the `--id` parameter but called `ctx.Int64("id")` to retrieve the value. This type mismatch caused the ID to always be read as `0`, making the commands useless.

**Before (bug):**
```bash
$ tea labels delete --id 36 --debug
DELETE: .../labels/0   # Wrong! ID ignored
```

**After (fix):**
```bash
$ tea labels delete --id 36 --debug
GET: .../labels/36     # Verify exists
DELETE: .../labels/36  # Correct ID
Label 'my-label' (id: 36) deleted successfully
```

## Changes

### labels/delete.go
- Change `IntFlag` to `Int64Flag` to match `ctx.Int64()` usage
- Make `--id` flag required
- Verify label exists before attempting deletion
- Provide clear error messages with label name and ID context
- Print success message after deletion

### labels/update.go
- Change `IntFlag` to `Int64Flag` to fix the same bug

## Test plan

- [x] `go test ./...` passes
- [x] `go vet ./...` passes
- [x] `gofmt` check passes
- [x] Manual testing confirms ID is now correctly passed to API
- [ ] CI passes

Reviewed-on: https://gitea.com/gitea/tea/pulls/865
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Alain Thiffault <athiffau@effectivemomentum.com>
Co-committed-by: Alain Thiffault <athiffau@effectivemomentum.com>
2026-01-25 23:36:42 +00:00
Alain Thiffault
68b9620b8c fix: expose pagination flags for secrets list command (#853)
The command uses flags.GetListOptions() internally but didn't expose --page and --limit flags to users, making pagination inaccessible.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/853
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Alain Thiffault <athiffau@effectivemomentum.com>
Co-committed-by: Alain Thiffault <athiffau@effectivemomentum.com>
2025-12-05 06:14:41 +00:00
Alain Thiffault
e961a8f01d fix: expose pagination flags for webhooks list command (#852)
The command uses flags.GetListOptions() internally but didn't expose --page and --limit flags to users, making pagination inaccessible.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/852
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Alain Thiffault <athiffau@effectivemomentum.com>
Co-committed-by: Alain Thiffault <athiffau@effectivemomentum.com>
2025-12-05 06:14:34 +00:00
Alain Thiffault
f59430a42a fix: pass pagination options to ListRepoPullRequests (#851)
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/851
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Alain Thiffault <athiffau@effectivemomentum.com>
Co-committed-by: Alain Thiffault <athiffau@effectivemomentum.com>
2025-12-05 06:14:01 +00:00
Lunny Xiao
7e2e7ee809 Fix delete repo description (#858)
Fix #857

Reviewed-on: https://gitea.com/gitea/tea/pulls/858
2025-12-05 06:11:38 +00:00
Riccardo Förster
1d1d9197ee feat(issue): Add JSON output and file redirection (#841)
This change enhances the 'issue' command functionality by enabling structured JSON
output for single issue views and introducing a method for output redirection.

**Changes Implemented:**

1. Enables the existing `--output json` flag for single issue commands (e.g., 'tea issue 17'). This flag was previously ignored in this context.
2. Introduces the new `--out <filename>` flag, which redirects the marshaled JSON output from stdout to the specified file.

Feeback more then welcome.

Co-authored-by: Jonas Toth <development@jonas-toth.eu>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/841
Reviewed-by: TheFox0x7 <thefox0x7@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Riccardo Förster <riccardo.foerster@sarad.de>
Co-committed-by: Riccardo Förster <riccardo.foerster@sarad.de>
2025-11-29 05:05:30 +00:00
TheFox0x7
f6d4b5fa4f remove group readwrite permission (#856)
closes: https://gitea.com/gitea/tea/issues/855
Reviewed-on: https://gitea.com/gitea/tea/pulls/856
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
2025-11-27 22:45:25 +00:00
Brandon Martin
016e068c60 Fix: Enable git worktree support and improve pr create error handling (#850)
## Problem

Tea commands fail when run from git worktrees with the error:
Remote repository required: Specify ID via --repo or execute from a
local git repo.

Even though the worktree is in a valid git repository with remotes
configured.

Additionally, `tea pr create` was missing context validation, showing
cryptic errors like `"path segment [0]
is empty"` instead of helpful messages.

## Root Cause

1. **Worktree issue**: go-git's `PlainOpenWithOptions` was not
configured to read the `commondir` file that
git worktrees use. This file points to the main repository's `.git`
directory where remotes are actually
stored (worktrees don't have their own remotes).

2. **PR create issue**: Missing `ctx.Ensure()` validation meant errors
weren't caught early with clear
messages.

## Solution

### 1. Enable worktree support (`modules/git/repo.go`)
```go
EnableDotGitCommonDir: true, // Enable commondir support for worktrees

This tells go-git to:
- Read the commondir file in .git/worktrees/<name>/commondir
- Follow the reference (typically ../..) to the main repository
- Load remotes from the main repo's config

2. Add context validation (cmd/pulls/create.go)

ctx.Ensure(context.CtxRequirement{
LocalRepo:  true,
RemoteRepo: true,
})

Provides clear error messages and matches the pattern used in pr
checkout (fixed in commit 0970b945 from
2020).

3. Add test coverage (modules/git/repo_test.go)

- Creates a real git repository with a worktree
- Verifies that RepoFromPath() can open the worktree
- Confirms that Config() correctly reads remotes from main repo

Test Results

Without fix:
 FAIL: Should NOT be empty, but was map[]

With fix:
 PASS: TestRepoFromPath_Worktree (0.12s)

Manual test in worktree:
cd /path/to/worktree
tea pr create --title "test"
# Now works! 

Checklist

- Tested manually in a git worktree
- Added test case that fails without the fix
- All existing tests pass

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/850
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Brandon Martin <brandon@codedmart.com>
Co-committed-by: Brandon Martin <brandon@codedmart.com>
2025-11-24 22:21:19 +00:00
Lunny Xiao
587b31503d Upgrade dependencies (#849)
Reviewed-on: https://gitea.com/gitea/tea/pulls/849
2025-11-24 19:21:55 +00:00
qwerty287
4877f181fb Only prompt for SSH passphrase if necessary (#844)
Since one of the last updates (I cannot tell you exactly which one, but likely 0.10 or 0.11), tea always asks me for my ssh passphrase without actually needing it. I do not have anything configured regarding SSH keys.

The passphrase is not even verified, you can enter anything there. But as this is quite annoying, I fixed this by moving the prompt to only be used when a ssh key/cert is configured.

Would be nice to get this in. Thanks!

Reviewed-on: https://gitea.com/gitea/tea/pulls/844
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-committed-by: qwerty287 <qwerty287@posteo.de>
2025-11-20 01:32:28 +00:00
Ross Golder
81481f8f9d Fix: Only prompt for login confirmation when no default login is set (#839)
When running tea commands outside of a repository context, tea falls back to using the default login but always prompted for confirmation, even when a default was set. This fix only prompts when no default is configured.

Reviewed-on: https://gitea.com/gitea/tea/pulls/839
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Ross Golder <ross@golder.org>
Co-committed-by: Ross Golder <ross@golder.org>
2025-10-27 17:52:04 +00:00
Ross Golder
3495ec5ed4 feat: add repository webhook management (#798)
## Summary

This PR adds support for organization-level and global webhooks in the tea CLI tool.

## Changes Made

### Organization Webhooks
- Added `--org` flag to webhook commands to operate on organization-level webhooks
- Implemented full CRUD operations for org webhooks (create, list, update, delete)
- Extended TeaContext to support organization scope

### Global Webhooks
- Added `--global` flag with placeholder implementation
- Ready for when Gitea SDK adds global webhook API methods

### Technical Details
- Updated context handling to support org/global scopes
- Modified all webhook subcommands (create, list, update, delete)
- Maintained backward compatibility for repository webhooks
- Updated tests and documentation

## Usage Examples

```bash
# Repository webhooks (existing)
tea webhooks list
tea webhooks create https://example.com/hook --events push

# Organization webhooks (new)
tea webhooks list --org myorg
tea webhooks create https://example.com/hook --org myorg --events push,pull_request

# Global webhooks (future)
tea webhooks list --global
```

## Testing
- All existing tests pass
- Updated test expectations for new descriptions
- Manual testing of org webhook operations completed

Closes: webhook management feature request
Reviewed-on: https://gitea.com/gitea/tea/pulls/798
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Ross Golder <ross@golder.org>
Co-committed-by: Ross Golder <ross@golder.org>
2025-10-19 03:40:23 +00:00
Ross Golder
7a5c260268 feat: add actions management commands (#796)
## Summary

This PR adds comprehensive Actions secrets and variables management functionality to the tea CLI, enabling users to manage their repository's CI/CD configuration directly from the command line.

## Features Added

### Actions Secrets Management
- **List secrets**: `tea actions secrets list` - Display all repository action secrets
- **Create secrets**: `tea actions secrets create <name>` - Create new secrets with interactive prompts
- **Delete secrets**: `tea actions secrets delete <name>` - Remove existing secrets

### Actions Variables Management
- **List variables**: `tea actions variables list` - Display all repository action variables
- **Set variables**: `tea actions variables set <name> <value>` - Create or update variables
- **Delete variables**: `tea actions variables delete <name>` - Remove existing variables

## Implementation Details

- **Interactive prompts**: Secure input handling for sensitive secret values
- **Input validation**: Proper validation for secret/variable names and values
- **Table formatting**: Consistent output formatting with existing tea commands
- **Error handling**: Comprehensive error handling and user feedback
- **Test coverage**: Full test suite for all functionality

## Usage Examples

```bash
# Secrets management
tea actions secrets list
tea actions secrets create API_KEY    # Will prompt securely for value
tea actions secrets delete OLD_SECRET

# Variables management
tea actions variables list
tea actions variables set API_URL https://api.example.com
tea actions variables delete UNUSED_VAR
```

## Related Issue

Resolves #797

## Testing

- All new functionality includes comprehensive unit tests
- Integration with existing tea CLI patterns and conventions
- Validated against Gitea Actions API

Reviewed-on: https://gitea.com/gitea/tea/pulls/796
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Ross Golder <ross@golder.org>
Co-committed-by: Ross Golder <ross@golder.org>
2025-10-19 02:53:17 +00:00
Lunny Xiao
90f8624ae7 Login requires a http/https login URL and revmoe SSH as a login method. SSH will be optional (#826)
Fix #825

Reviewed-on: https://gitea.com/gitea/tea/pulls/826
2025-10-18 23:09:27 +00:00
Lunny Xiao
61d4e571a7 Fix Pr Create crash (#823)
Fix #822

Reviewed-on: https://gitea.com/gitea/tea/pulls/823
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-10-08 14:43:38 +00:00
Lunny Xiao
4f33146b70 add test for matching logins (#820)
Reviewed-on: https://gitea.com/gitea/tea/pulls/820
2025-10-03 18:05:51 +00:00
Lunny Xiao
08b83986dd Update README.md (#819)
Use official docker images on README

Reviewed-on: https://gitea.com/gitea/tea/pulls/819
Reviewed-by: TheFox0x7 <thefox0x7@noreply.gitea.com>
2025-09-25 07:08:21 +00:00
Lunny Xiao
6acb29efd7 Fix yaml output single quote (#814)
Fix #659

Reviewed-on: https://gitea.com/gitea/tea/pulls/814
2025-09-14 00:23:12 +00:00
Valentin Brandl
4f513ca3e3 generate man page (#811)
[CLI.md](src/branch/main/docs/CLI.md) already gets generated using `urfave/cli-docs`. `cli-docs` can also generate man pages.

This change extends the doc generator to also generate a man page for `tea`.

* Add a subcommand to the doc generator to print the generated man page to stdout

Closes #777.

Co-authored-by: Valentin Brandl <mail@vbrandl.net>
Reviewed-on: https://gitea.com/gitea/tea/pulls/811
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: TheFox0x7 <thefox0x7@noreply.gitea.com>
Co-authored-by: Valentin Brandl <vbrandl@noreply.gitea.com>
Co-committed-by: Valentin Brandl <vbrandl@noreply.gitea.com>
2025-09-14 00:17:28 +00:00
Lunny Xiao
cc20b52ab3 feat: add validation for object-format flag in repo create command (#741)
This PR adds validation for the `--object-format` flag in the `repo create` command. The flag now accepts only `sha1` or `sha256` as valid values, and returns an error for any other value.

Changes:
- Added validation in `runRepoCreate` to check for valid object format values
- Added unit tests to verify the validation logic
- Fixed the field name from `ObjectFormat` to `ObjectFormatName` to match the SDK

The changes ensure that users get clear error messages when using invalid object format values, improving the user experience.

Fix #727
Fix #660
Fix #767

Co-authored-by: techknowlogick <techknowlogick@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/741
Reviewed-by: TheFox0x7 <thefox0x7@noreply.gitea.com>
2025-09-12 16:51:43 +00:00
Lunny Xiao
2ca114e309 Fix release version (#815)
Reviewed-on: https://gitea.com/gitea/tea/pulls/815
Reviewed-by: TheFox0x7 <thefox0x7@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-09-11 19:18:19 +00:00
TheFox0x7
45771265c4 update gitea sdk to v0.22 (#813)
needed because of: 25b5fb0ff7
closes: https://gitea.com/gitea/tea/issues/812

Reviewed-on: https://gitea.com/gitea/tea/pulls/813
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
2025-09-10 23:18:05 +00:00
Lunny Xiao
8faa1d33f4 don't fallback login directly (#806)
Fix #499

Reviewed-on: https://gitea.com/gitea/tea/pulls/806
2025-09-10 21:30:15 +00:00
Lunny Xiao
ddf5c0a5bb Check duplicated login name in interact mode when creating new login (#803)
Reviewed-on: https://gitea.com/gitea/tea/pulls/803
2025-09-10 20:42:49 +00:00
Lunny Xiao
d3c73cd5dc Fix bug when output json with special chars (#801)
Fix #800

Reviewed-on: https://gitea.com/gitea/tea/pulls/801
2025-09-10 20:36:27 +00:00
Lunny Xiao
6c958eec99 add debug mode and update readme (#805)
Fix #456
Fix #207

Reviewed-on: https://gitea.com/gitea/tea/pulls/805
2025-09-10 19:10:02 +00:00
Lunny Xiao
d531c6fdb0 update go.mod to retract the wrong tag v1.3.3 (#802)
Fix #674

Reviewed-on: https://gitea.com/gitea/tea/pulls/802
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-08-27 15:38:58 +00:00
TheFox0x7
cd58296995 revert completion scripts removal (#808)
partial revert of: https://gitea.com/gitea/tea/pulls/782
closes: https://gitea.com/gitea/tea/issues/784

Old versions of tea have hardcoded completion fetched from main branch

Those should not be used from v0.10 onward.

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/808
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
2025-08-26 23:15:36 +00:00
TheFox0x7
b74405530a Remove pagination from context (#807)
Pagination related flags now write directly to ListOption struct and enforce non negative numbers.
Flag tests were added to cover the validation

Reviewed-on: https://gitea.com/gitea/tea/pulls/807
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
2025-08-26 23:13:27 +00:00
Chen Linxuan
8876fe3cb8 Continue auth when failed to open browser (#794)
When users login gitea on a headless server via ssh, xdg-open might not be installed on that machine. So tea may fail to open URL itself. In this case, users can use the other machine to open the URL for authentication.

Github CLI act like this, too.

Signed-off-by: Chen Linxuan <me@black-desk.cn>

Reviewed-on: https://gitea.com/gitea/tea/pulls/794
Reviewed-by: blumia <blumia@noreply.gitea.com>
Co-authored-by: Chen Linxuan <me@black-desk.cn>
Co-committed-by: Chen Linxuan <me@black-desk.cn>
2025-08-18 03:12:25 +00:00
Lunny Xiao
07ca1ba106 Fix bug (#793)
Partially fix #791

Reviewed-on: https://gitea.com/gitea/tea/pulls/793
Reviewed-by: hiifong <i@hiif.ong>
2025-08-15 02:38:45 +00:00
Lunny Xiao
d643e94a69 Fix tea login add with ssh public key bug (#789)
Fix #705

Reviewed-on: https://gitea.com/gitea/tea/pulls/789
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
2025-08-11 19:23:28 +00:00
Tim Riedl
d2ccead88b Add temporary authentication via environment variables (#639)
#633

Co-authored-by: Tim Riedl <mail@tim-riedl.de>
Co-authored-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Lunny Xiao <lunny@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/639
Co-authored-by: Tim Riedl <uvulpos@noreply.gitea.com>
Co-committed-by: Tim Riedl <uvulpos@noreply.gitea.com>
2025-08-11 18:53:09 +00:00
Lunny Xiao
449b2e3117 Fix attachment size (#787)
Fix `tea releases assets <tag_name>` displayed wrong attachment size.

Reviewed-on: https://gitea.com/gitea/tea/pulls/787
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-08-11 18:45:12 +00:00
Lunny Xiao
9e8c71e13e deploy image when tagging (#792)
Reviewed-on: https://gitea.com/gitea/tea/pulls/792
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-08-11 18:44:34 +00:00
Lunny Xiao
2ddb3bd4a1 Add Zip URL for release list (#788)
Fix #780

Reviewed-on: https://gitea.com/gitea/tea/pulls/788
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-committed-by: Lunny Xiao <xiaolunwen@gmail.com>
2025-08-11 18:43:34 +00:00
Lunny Xiao
4c00b8b571 Use bubbletea instead of survey for interacting with TUI (#786)
Fix #772

Reviewed-on: https://gitea.com/gitea/tea/pulls/786
Reviewed-by: Bo-Yi Wu (吳柏毅) <appleboy.tw@gmail.com>
2025-08-11 18:23:52 +00:00
techknowlogick
c0eb30af03 capitalize a few items 2025-08-11 15:29:19 +00:00
techknowlogick
e462acfcd6 rm out of date comparison file 2025-08-11 15:27:04 +00:00
Michal Suchanek
ee111d7c12 README: Document logging in to gitea (#790)
References: #569 #675 #697 #767 #775
Reviewed-on: https://gitea.com/gitea/tea/pulls/790
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: Michal Suchanek <msuchanek@suse.de>
Co-committed-by: Michal Suchanek <msuchanek@suse.de>
2025-08-11 15:25:42 +00:00
TheFox0x7
5f35cebcf1 remove autocomplete command (#782)
Add a note in readme about adding shell completions

Closes: https://gitea.com/gitea/tea/issues/781
Co-authored-by: techknowlogick <techknowlogick@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/782
Reviewed-by: techknowlogick <techknowlogick@noreply.gitea.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
2025-07-21 21:24:28 +00:00
Renovate Bot
a010c9bc7f chore(deps): update ghcr.io/devcontainers/features/git-lfs docker tag to v1.2.5 (#773)
Reviewed-on: https://gitea.com/gitea/tea/pulls/773
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2025-07-21 21:22:04 +00:00
TheFox0x7
ab4ad92d40 replace arch package url (#783)
Direct users to main repository instead of AUR package

Reviewed-on: https://gitea.com/gitea/tea/pulls/783
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
Co-committed-by: TheFox0x7 <thefox0x7@gmail.com>
2025-07-21 21:21:25 +00:00
Kirill Müller
15052b4dcc fix: Reenable -p and --limit switches (#778)
Reduced version of #776, without the new tests.

Fixes #771.

Reviewed-on: https://gitea.com/gitea/tea/pulls/778
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Kirill Müller <kirill@cynkra.com>
Co-committed-by: Kirill Müller <kirill@cynkra.com>
2025-07-14 14:28:35 +00:00
228 changed files with 14236 additions and 2146 deletions

View File

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

View File

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

View File

@@ -9,26 +9,30 @@ jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: import gpg - name: import gpg
id: import_gpg id: import_gpg
uses: crazy-max/ghaction-import-gpg@v6 uses: crazy-max/ghaction-import-gpg@v7
with: with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }} gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
- name: get SDK version
id: sdk_version
run: echo "version=$(go list -f '{{.Version}}' -m code.gitea.io/sdk/gitea)" >> "$GITHUB_OUTPUT"
- name: goreleaser - name: goreleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
distribution: goreleaser-pro distribution: goreleaser-pro
version: "~> v1" version: "~> v1"
args: release args: release
env: env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }} AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
@@ -39,3 +43,43 @@ jobs:
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }} GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} 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

@@ -4,11 +4,24 @@ on:
- pull_request - pull_request
jobs: 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: check-and-test:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: lint and build - name: lint and build
@@ -17,10 +30,34 @@ jobs:
make vet make vet
make lint make lint
make fmt-check make fmt-check
make misspell-check
make docs-check make docs-check
make build make build
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
- name: test and coverage - name: test and coverage
run: | run: |
make test make test
make unit-test-coverage make unit-test-coverage
services:
gitea:
image: docker.gitea.com/gitea:1.26.1
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

2
.gitignore vendored
View File

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

45
.golangci.yml Normal file
View File

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

View File

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

View File

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

View File

@@ -1,63 +0,0 @@
# 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

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

186
README.md
View File

@@ -11,73 +11,92 @@
![demo gif](./demo.gif) ![demo gif](./demo.gif)
``` ```
tea - command line tool to interact with Gitea NAME:
version 0.8.0-preview tea - command line tool to interact with Gitea
USAGE USAGE:
tea command [subcommand] [command options] [arguments...] tea [global options] [command [command options]]
DESCRIPTION VERSION:
tea is a productivity helper for Gitea. It can be used to manage most entities on Version: 0.10.1+15-g8876fe3 golang: 1.25.0 go-sdk: v0.21.0
one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
tea tries to make use of context provided by the repository in $PWD if available.
tea works best in a upstream/fork workflow, when the local main branch tracks the
upstream repo. tea assumes that local git state is published on the remote before
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
COMMANDS DESCRIPTION:
help, h Shows a list of commands or help for one command tea is a productivity helper for Gitea. It can be used to manage most entities on
ENTITIES: one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
issues, issue, i List, create and update issues
pulls, pull, pr Manage and checkout pull requests
labels, label Manage issue labels
milestones, milestone, ms List and create milestones
releases, release, r Manage releases
release assets, release asset, r a Manage release attachments
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
comment, c Add a comment to an issue / pr
HELPERS:
open, o Open something of the repository in web browser
notifications, notification, n Show notifications
clone, C Clone a repository locally
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
OPTIONS tea tries to make use of context provided by the repository in $PWD if available.
--help, -h show help (default: false) tea works best in a upstream/fork workflow, when the local main branch tracks the
--version, -v print the version (default: false) 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.
EXAMPLES COMMANDS:
tea login add # add a login once to get started help, h Shows a list of commands or help for one command
tea pulls # list open pulls for the repo in $PWD ENTITIES:
tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo issues, issue, i List, create and update issues
tea pulls --remote upstream # list open pulls for the repo pointed at by pulls, pull, pr Manage and checkout pull requests
# your local "upstream" git remote labels, label Manage issue labels
# list open pulls for any gitea repo at the given login instance milestones, milestone, ms List and create milestones
tea pulls --repo gitea/tea --login gitea.com releases, release, r Manage releases
times, time, t Operate on tracked times of a repository's issues & pulls
organizations, organization, org List, create, delete organizations
repos, repo Show repository details
branches, branch, b Consult branches
actions Manage repository actions (secrets, variables)
comment, c Add a comment to an issue / pr
webhooks, webhook Manage repository webhooks
tea milestone issues 0.7.0 # view open issues for milestone '0.7.0' HELPERS:
tea issue 189 # view contents of issue 189 open, o Open something of the repository in web browser
tea open 189 # open web ui for issue 189 notifications, notification, n Show notifications
tea open milestones # open web ui for milestones clone, C Clone a repository locally
# send gitea desktop notifications every 5 minutes (bash + libnotify) MISCELLANEOUS:
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done whoami Show current logged in user
admin, a Operations requiring admin access on the Gitea instance
ABOUT SETUP:
Written & maintained by The Gitea Authors. logins, login Log in to a Gitea server
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea. logout Log out from a Gitea server
More info about Gitea itself on https://about.gitea.com.
GLOBAL OPTIONS:
--debug, --vvv Enable debug mode (default: false)
--help, -h show help
--version, -v print the version
EXAMPLES
tea login add # add a login once to get started
tea pulls # list open pulls for the repo in $PWD
tea pulls --repo $HOME/foo # list open pulls for the repo in $HOME/foo
tea pulls --remote upstream # list open pulls for the repo pointed at by
# your local "upstream" git remote
# list open pulls for any gitea repo at the given login instance
tea pulls --repo gitea/tea --login gitea.com
tea milestone issues 0.7.0 # view open issues for milestone '0.7.0'
tea issue 189 # view contents of issue 189
tea open 189 # open web ui for issue 189
tea open milestones # open web ui for milestones
tea actions secrets list # list all repository action secrets
tea actions secrets create API_KEY # create a new secret (will prompt for value)
tea actions variables list # list all repository action variables
tea actions variables set API_URL https://api.example.com
tea webhooks list # list repository webhooks
tea webhooks list --org myorg # list organization webhooks
tea webhooks create https://example.com/hook --events push,pull_request
# send gitea desktop notifications every 5 minutes (bash + libnotify)
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done
ABOUT
Written & maintained by The Gitea Authors.
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea.
More info about Gitea itself on https://about.gitea.com.
``` ```
- [Compare features with other git forge CLIs](./FEATURE-COMPARISON.md)
- tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API. - tea uses [code.gitea.io/sdk](https://code.gitea.io/sdk) and interacts with the Gitea API.
## Installation ## Installation
@@ -89,7 +108,7 @@ There are different ways to get `tea`:
```sh ```sh
brew install tea brew install tea
``` ```
- arch linux ([gitea-tea-git](https://aur.archlinux.org/packages/gitea-tea-git), thirdparty) - arch linux ([tea](https://archlinux.org/packages/extra/x86_64/tea/), thirdparty)
- alpine linux ([tea](https://pkgs.alpinelinux.org/packages?name=tea&branch=edge), thirdparty) - 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) - Windows via `MSYS2` ([tea](https://packages.msys2.org/base/mingw-w64-tea), thirdparty)
@@ -97,13 +116,60 @@ There are different ways to get `tea`:
3. Install from source: [see *Compilation*](#compilation) 3. Install from source: [see *Compilation*](#compilation)
4. Docker (thirdparty): [tgerczei/tea](https://hub.docker.com/r/tgerczei/tea) 4. Docker: [Tea at docker hub](https://hub.docker.com/r/gitea/tea)
5. asdf (thirdparty): [mvaldes14/asdf-tea](https://github.com/mvaldes14/asdf-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
```
## Compilation ## Compilation
Make sure you have a current go version installed (1.13 or newer). Make sure you have a current Go version installed (1.26 or newer).
- To compile the source yourself with the recommended flags & tags: - To compile the source yourself with the recommended flags & tags:
```sh ```sh

47
cmd/actions.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"code.gitea.io/tea/cmd/actions"
"github.com/urfave/cli/v3"
)
// CmdActions represents the actions command for managing Gitea Actions
var CmdActions = cli.Command{
Name: "actions",
Aliases: []string{"action"},
Category: catEntities,
Usage: "Manage repository actions",
Description: "Manage repository actions including secrets, variables, and workflow runs",
Action: runActionsDefault,
Commands: []*cli.Command{
&actions.CmdActionsSecrets,
&actions.CmdActionsVariables,
&actions.CmdActionsRuns,
&actions.CmdActionsWorkflows,
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "repo",
Usage: "repository to operate on",
},
&cli.StringFlag{
Name: "login",
Usage: "gitea login instance to use",
},
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "output format [table, csv, simple, tsv, yaml, json]",
},
},
}
func runActionsDefault(_ stdctx.Context, cmd *cli.Command) error {
return cli.ShowSubcommandHelp(cmd)
}

31
cmd/actions/runs.go Normal file
View File

@@ -0,0 +1,31 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"code.gitea.io/tea/cmd/actions/runs"
"github.com/urfave/cli/v3"
)
// CmdActionsRuns represents the actions runs command
var CmdActionsRuns = cli.Command{
Name: "runs",
Aliases: []string{"run"},
Usage: "Manage workflow runs",
Description: "List, view, and manage workflow runs for repository actions",
Action: runRunsDefault,
Commands: []*cli.Command{
&runs.CmdRunsList,
&runs.CmdRunsView,
&runs.CmdRunsDelete,
&runs.CmdRunsLogs,
},
}
func runRunsDefault(ctx stdctx.Context, cmd *cli.Command) error {
return runs.RunRunsList(ctx, cmd)
}

View File

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

148
cmd/actions/runs/list.go Normal file
View File

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

View File

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

175
cmd/actions/runs/logs.go Normal file
View File

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

83
cmd/actions/runs/view.go Normal file
View File

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

30
cmd/actions/secrets.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"code.gitea.io/tea/cmd/actions/secrets"
"github.com/urfave/cli/v3"
)
// CmdActionsSecrets represents the actions secrets command
var CmdActionsSecrets = cli.Command{
Name: "secrets",
Aliases: []string{"secret"},
Usage: "Manage repository action secrets",
Description: "Manage secrets used by repository actions and workflows",
Action: runSecretsDefault,
Commands: []*cli.Command{
&secrets.CmdSecretsList,
&secrets.CmdSecretsCreate,
&secrets.CmdSecretsDelete,
},
}
func runSecretsDefault(ctx stdctx.Context, cmd *cli.Command) error {
return secrets.RunSecretsList(ctx, cmd)
}

View File

@@ -0,0 +1,74 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package secrets
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdSecretsCreate represents a sub command to create action secrets
var CmdSecretsCreate = cli.Command{
Name: "create",
Aliases: []string{"add", "set"},
Usage: "Create an action secret",
Description: "Create a secret for use in repository actions and workflows",
ArgsUsage: "<secret-name> [secret-value]",
Action: runSecretsCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "file",
Usage: "read secret value from file",
},
&cli.BoolFlag{
Name: "stdin",
Usage: "read secret value from stdin",
},
}, flags.AllDefaultFlags...),
}
func runSecretsCreate(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("secret name is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
secretName := cmd.Args().First()
// Read secret value using the utility
secretValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{
ResourceName: "secret",
PromptMsg: fmt.Sprintf("Enter secret value for '%s'", secretName),
Hidden: true,
AllowEmpty: false,
})
if err != nil {
return err
}
_, err = client.CreateRepoActionSecret(c.Owner, c.Repo, secretName, gitea.CreateOrUpdateSecretOption{
Data: secretValue,
})
if err != nil {
return err
}
fmt.Printf("Secret '%s' created successfully\n", secretName)
return nil
}

View File

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

View File

@@ -0,0 +1,66 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package secrets
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdSecretsDelete represents a sub command to delete action secrets
var CmdSecretsDelete = cli.Command{
Name: "delete",
Aliases: []string{"remove", "rm"},
Usage: "Delete an action secret",
Description: "Delete a secret used by repository actions",
ArgsUsage: "<secret-name>",
Action: runSecretsDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm deletion without prompting",
},
}, flags.AllDefaultFlags...),
}
func runSecretsDelete(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("secret name is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
secretName := cmd.Args().First()
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to delete secret '%s'? [y/N] ", secretName)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.")
return nil
}
}
_, err = client.DeleteRepoActionSecret(c.Owner, c.Repo, secretName)
if err != nil {
return err
}
fmt.Printf("Secret '%s' deleted successfully\n", secretName)
return nil
}

View File

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

View File

@@ -0,0 +1,49 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package secrets
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
)
// CmdSecretsList represents a sub command to list action secrets
var CmdSecretsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List action secrets",
Description: "List secrets configured for repository actions",
Action: RunSecretsList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
}
// RunSecretsList list action secrets
func RunSecretsList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
secrets, _, err := client.ListRepoActionSecret(c.Owner, c.Repo, gitea.ListRepoActionSecretOption{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
return print.ActionSecretsList(secrets, c.Output)
}

View File

@@ -0,0 +1,98 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package secrets
import (
stdctx "context"
"os"
"testing"
"code.gitea.io/tea/modules/config"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestSecretsListFlags(t *testing.T) {
cmd := CmdSecretsList
// Test that required flags exist
expectedFlags := []string{"output", "remote", "login", "repo"}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
if flag.Names()[0] == flagName {
found = true
break
}
}
if !found {
t.Errorf("Expected flag %s not found in CmdSecretsList", flagName)
}
}
// Test command properties
if cmd.Name != "list" {
t.Errorf("Expected command name 'list', got %s", cmd.Name)
}
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" {
t.Errorf("Expected alias 'ls' for list command")
}
if cmd.Usage == "" {
t.Error("List command should have usage text")
}
if cmd.Description == "" {
t.Error("List command should have description")
}
}
func TestSecretsListValidation(t *testing.T) {
// Basic validation that the command accepts the expected arguments
// More detailed testing would require mocking the Gitea client
// Test that list command doesn't require arguments
args := []string{}
if len(args) > 0 {
t.Error("List command should not require arguments")
}
// Test that extra arguments are ignored
extraArgs := []string{"extra", "args"}
if len(extraArgs) > 0 {
// This is fine - list commands typically ignore extra args
}
}
func TestRunSecretsListRequiresRepoContext(t *testing.T) {
oldWd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(t.TempDir()))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWd))
})
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "test",
URL: "https://gitea.example.com",
Token: "token",
User: "tester",
Default: true,
}},
})
cmd := &cli.Command{
Name: CmdSecretsList.Name,
Flags: CmdSecretsList.Flags,
}
require.NoError(t, cmd.Set("login", "test"))
err = RunSecretsList(stdctx.Background(), cmd)
require.ErrorContains(t, err, "remote repository required")
}

30
cmd/actions/variables.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package actions
import (
stdctx "context"
"code.gitea.io/tea/cmd/actions/variables"
"github.com/urfave/cli/v3"
)
// CmdActionsVariables represents the actions variables command
var CmdActionsVariables = cli.Command{
Name: "variables",
Aliases: []string{"variable", "vars", "var"},
Usage: "Manage repository action variables",
Description: "Manage variables used by repository actions and workflows",
Action: runVariablesDefault,
Commands: []*cli.Command{
&variables.CmdVariablesList,
&variables.CmdVariablesSet,
&variables.CmdVariablesDelete,
},
}
func runVariablesDefault(ctx stdctx.Context, cmd *cli.Command) error {
return variables.RunVariablesList(ctx, cmd)
}

View File

@@ -0,0 +1,66 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package variables
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdVariablesDelete represents a sub command to delete action variables
var CmdVariablesDelete = cli.Command{
Name: "delete",
Aliases: []string{"remove", "rm"},
Usage: "Delete an action variable",
Description: "Delete a variable used by repository actions",
ArgsUsage: "<variable-name>",
Action: runVariablesDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm deletion without prompting",
},
}, flags.AllDefaultFlags...),
}
func runVariablesDelete(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("variable name is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
variableName := cmd.Args().First()
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to delete variable '%s'? [y/N] ", variableName)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.")
return nil
}
}
_, err = client.DeleteRepoActionVariable(c.Owner, c.Repo, variableName)
if err != nil {
return err
}
fmt.Printf("Variable '%s' deleted successfully\n", variableName)
return nil
}

View File

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

View File

@@ -0,0 +1,61 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package variables
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdVariablesList represents a sub command to list action variables
var CmdVariablesList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List action variables",
Description: "List variables configured for repository actions",
Action: RunVariablesList,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "name",
Usage: "show specific variable by name",
},
}, flags.AllDefaultFlags...),
}
// RunVariablesList list action variables
func RunVariablesList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
if name := cmd.String("name"); name != "" {
// Get specific variable
variable, _, err := client.GetRepoActionVariable(c.Owner, c.Repo, name)
if err != nil {
return err
}
print.ActionVariableDetails(variable)
return nil
}
// List all variables - Note: SDK doesn't have ListRepoActionVariables yet
// This is a limitation of the current SDK
fmt.Println("Note: Listing all variables is not yet supported by the Gitea SDK.")
fmt.Println("Use 'tea actions variables list --name <variable-name>' to get a specific variable.")
fmt.Println("You can also check your repository's Actions settings in the web interface.")
return nil
}

View File

@@ -0,0 +1,98 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package variables
import (
stdctx "context"
"os"
"testing"
"code.gitea.io/tea/modules/config"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestVariablesListFlags(t *testing.T) {
cmd := CmdVariablesList
// Test that required flags exist
expectedFlags := []string{"output", "remote", "login", "repo"}
for _, flagName := range expectedFlags {
found := false
for _, flag := range cmd.Flags {
if flag.Names()[0] == flagName {
found = true
break
}
}
if !found {
t.Errorf("Expected flag %s not found in CmdVariablesList", flagName)
}
}
// Test command properties
if cmd.Name != "list" {
t.Errorf("Expected command name 'list', got %s", cmd.Name)
}
if len(cmd.Aliases) == 0 || cmd.Aliases[0] != "ls" {
t.Errorf("Expected alias 'ls' for list command")
}
if cmd.Usage == "" {
t.Error("List command should have usage text")
}
if cmd.Description == "" {
t.Error("List command should have description")
}
}
func TestVariablesListValidation(t *testing.T) {
// Basic validation that the command accepts the expected arguments
// More detailed testing would require mocking the Gitea client
// Test that list command doesn't require arguments
args := []string{}
if len(args) > 0 {
t.Error("List command should not require arguments")
}
// Test that extra arguments are ignored
extraArgs := []string{"extra", "args"}
if len(extraArgs) > 0 {
// This is fine - list commands typically ignore extra args
}
}
func TestRunVariablesListRequiresRepoContext(t *testing.T) {
oldWd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(t.TempDir()))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWd))
})
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "test",
URL: "https://gitea.example.com",
Token: "token",
User: "tester",
Default: true,
}},
})
cmd := &cli.Command{
Name: CmdVariablesList.Name,
Flags: CmdVariablesList.Flags,
}
require.NoError(t, cmd.Set("login", "test"))
err = RunVariablesList(stdctx.Background(), cmd)
require.ErrorContains(t, err, "remote repository required")
}

View File

@@ -0,0 +1,108 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package variables
import (
stdctx "context"
"fmt"
"regexp"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdVariablesSet represents a sub command to set action variables
var CmdVariablesSet = cli.Command{
Name: "set",
Aliases: []string{"create", "update"},
Usage: "Set an action variable",
Description: "Set a variable for use in repository actions and workflows",
ArgsUsage: "<variable-name> [variable-value]",
Action: runVariablesSet,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "file",
Usage: "read variable value from file",
},
&cli.BoolFlag{
Name: "stdin",
Usage: "read variable value from stdin",
},
}, flags.AllDefaultFlags...),
}
func runVariablesSet(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return fmt.Errorf("variable name is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
variableName := cmd.Args().First()
if err := validateVariableName(variableName); err != nil {
return err
}
// Read variable value using the utility
variableValue, err := utils.ReadValue(cmd, utils.ReadValueOptions{
ResourceName: "variable",
PromptMsg: fmt.Sprintf("Enter variable value for '%s'", variableName),
Hidden: false,
AllowEmpty: false,
})
if err != nil {
return err
}
if err := validateVariableValue(variableValue); err != nil {
return err
}
_, err = client.CreateRepoActionVariable(c.Owner, c.Repo, variableName, variableValue)
if err != nil {
return err
}
fmt.Printf("Variable '%s' set successfully\n", variableName)
return nil
}
// validateVariableName validates that a variable name follows the required format
func validateVariableName(name string) error {
if name == "" {
return fmt.Errorf("variable name cannot be empty")
}
// Variable names can contain letters (upper/lower), numbers, and underscores
// Cannot start with a number
// Cannot contain spaces or special characters (except underscore)
validPattern := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
if !validPattern.MatchString(name) {
return fmt.Errorf("variable name must contain only letters, numbers, and underscores, and cannot start with a number")
}
return nil
}
// validateVariableValue validates that a variable value is acceptable
func validateVariableValue(value string) error {
// Variables can be empty or contain whitespace, unlike secrets
// Check for maximum size (64KB limit)
if len(value) > 65536 {
return fmt.Errorf("variable value cannot exceed 64KB")
}
return nil
}

View File

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

32
cmd/actions/workflows.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

408
cmd/api.go Normal file
View File

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

635
cmd/api_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,138 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"github.com/adrg/xdg"
"github.com/urfave/cli/v3"
)
// 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(_ context.Context, cmd *cli.Command) error {
var remoteFile, localFile, cmds string
shell := cmd.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(cmd)
default:
return fmt.Errorf("Must specify valid %s", cmd.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 cmd.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(cmd *cli.Command) 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 := cmd.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

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

View File

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

View File

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

78
cmd/branches/rename.go Normal file
View File

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

View File

@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package branches
import (
"testing"
)
func TestBranchesRenameArgs(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
}{
{
name: "valid args",
args: []string{"main", "develop"},
wantErr: false,
},
{
name: "missing both args",
args: []string{},
wantErr: true,
},
{
name: "missing new branch name",
args: []string{"main"},
wantErr: true,
},
{
name: "too many args",
args: []string{"main", "develop", "extra"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRenameArgs(tt.args)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRenameArgs() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/tea/modules/git" "code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/task"
@@ -47,7 +48,10 @@ When a host is specified in the repo-slug, it will override the login specified
} }
func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error { func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
teaCmd := context.InitCommand(cmd) teaCmd, err := context.InitCommand(cmd)
if err != nil {
return err
}
args := teaCmd.Args() args := teaCmd.Args()
if args.Len() < 1 { if args.Len() < 1 {
@@ -57,7 +61,7 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
var ( var (
login *config.Login = teaCmd.Login login *config.Login = teaCmd.Login
owner string = teaCmd.Login.User owner string
repo string repo string
) )
@@ -68,12 +72,19 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
return err return err
} }
debug.Printf("Cloning repository %s into %s", url.String(), dir)
owner, repo = utils.GetOwnerAndRepo(url.Path, login.User) owner, repo = utils.GetOwnerAndRepo(url.Path, login.User)
if url.Host != "" { if url.Host != "" {
login = config.GetLoginByHost(url.Host) var lookupErr error
if login == nil { login, lookupErr = config.GetLoginByHost(url.Host)
return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host) if lookupErr != nil {
return lookupErr
} }
if login == nil {
return fmt.Errorf("no login configured matching host '%s', run 'tea login add' first", url.Host)
}
debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host)
} }
_, err = task.RepoClone( _, err = task.RepoClone(

View File

@@ -6,21 +6,11 @@ package cmd // import "code.gitea.io/tea"
import ( import (
"fmt" "fmt"
"runtime"
"strings"
"code.gitea.io/tea/modules/version"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
// Version holds the current tea version
var Version = "development"
// Tags holds the build tags used
var Tags = ""
// SDK holds the sdk version from go.mod
var SDK = ""
// App creates and returns a tea Command with all subcommands set // App creates and returns a tea Command with all subcommands set
// it was separated from main so docs can be generated for it // it was separated from main so docs can be generated for it
func App() *cli.Command { func App() *cli.Command {
@@ -32,11 +22,10 @@ func App() *cli.Command {
Usage: "command line tool to interact with Gitea", Usage: "command line tool to interact with Gitea",
Description: appDescription, Description: appDescription,
CustomHelpTemplate: helpTemplate, CustomHelpTemplate: helpTemplate,
Version: formatVersion(), Version: version.Format(),
Commands: []*cli.Command{ Commands: []*cli.Command{
&CmdLogin, &CmdLogin,
&CmdLogout, &CmdLogout,
&CmdAutocomplete,
&CmdWhoami, &CmdWhoami,
&CmdIssues, &CmdIssues,
@@ -48,6 +37,8 @@ func App() *cli.Command {
&CmdOrgs, &CmdOrgs,
&CmdRepos, &CmdRepos,
&CmdBranches, &CmdBranches,
&CmdActions,
&CmdWebhooks,
&CmdAddComment, &CmdAddComment,
&CmdOpen, &CmdOpen,
@@ -55,27 +46,14 @@ func App() *cli.Command {
&CmdRepoClone, &CmdRepoClone,
&CmdAdmin, &CmdAdmin,
&CmdApi,
&CmdGenerateManPage,
}, },
EnableShellCompletion: true, EnableShellCompletion: true,
} }
} }
func formatVersion() string {
version := fmt.Sprintf("Version: %s\tgolang: %s",
bold(Version),
strings.ReplaceAll(runtime.Version(), "go", ""))
if len(Tags) != 0 {
version += fmt.Sprintf("\tbuilt with: %s", strings.Replace(Tags, " ", ", ", -1))
}
if len(SDK) != 0 {
version += fmt.Sprintf("\tgo-sdk: %s", SDK)
}
return version
}
var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on
one or multiple Gitea instances & provides local helpers like 'tea pr checkout'. one or multiple Gitea instances & provides local helpers like 'tea pr checkout'.
@@ -85,7 +63,7 @@ upstream repo. tea assumes that local git state is published on the remote befor
doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea. doing operations with tea. Configuration is persisted in $XDG_CONFIG_HOME/tea.
` `
var helpTemplate = bold(` var helpTemplate = fmt.Sprintf("\033[1m%s\033[0m", `
{{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + ` {{.Name}}{{if .Usage}} - {{.Usage}}{{end}}`) + `
{{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}} {{if .Version}}{{if not .HideVersion}}version {{.Version}}{{end}}{{end}}
@@ -127,7 +105,3 @@ var helpTemplate = bold(`
If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea. If you find a bug or want to contribute, we'll welcome you at https://gitea.com/gitea/tea.
More info about Gitea itself on https://about.gitea.com. More info about Gitea itself on https://about.gitea.com.
` `
func bold(t string) string {
return fmt.Sprintf("\033[1m%s\033[0m", t)
}

View File

@@ -5,19 +5,21 @@ package cmd
import ( import (
stdctx "context" stdctx "context"
"errors"
"fmt" "fmt"
"io" "io"
"strings" "strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils" "code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea" "charm.land/huh/v2"
"github.com/AlecAivazis/survey/v2"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -34,12 +36,17 @@ var CmdAddComment = cli.Command{
} }
func runAddComment(_ stdctx.Context, cmd *cli.Command) error { func runAddComment(_ stdctx.Context, cmd *cli.Command) error {
ctx := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
args := ctx.Args() args := ctx.Args()
if args.Len() == 0 { if args.Len() == 0 {
return fmt.Errorf("Please specify issue / pr index") return fmt.Errorf("please specify issue / pr index")
} }
idx, err := utils.ArgToIndex(ctx.Args().First()) idx, err := utils.ArgToIndex(ctx.Args().First())
@@ -56,17 +63,22 @@ func runAddComment(_ stdctx.Context, cmd *cli.Command) error {
body = strings.Join([]string{body, string(bodyStdin)}, "\n\n") body = strings.Join([]string{body, string(bodyStdin)}, "\n\n")
} }
} else if len(body) == 0 { } else if len(body) == 0 {
if err = survey.AskOne(interact.NewMultiline(interact.Multiline{ if err := huh.NewForm(
Message: "Comment:", huh.NewGroup(
Syntax: "md", huh.NewText().
UseEditor: config.GetPreferences().Editor, Title("Comment(markdown):").
}), &body); err != nil { ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&body),
),
).WithTheme(theme.GetTheme()).
Run(); err != nil {
return err return err
} }
} }
if len(body) == 0 { if len(body) == 0 {
return fmt.Errorf("No comment body provided") return errors.New("no comment content provided")
} }
client := ctx.Login.Client() client := ctx.Login.Client()

93
cmd/detail_json.go Normal file
View File

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

View File

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

View File

@@ -1,9 +1,13 @@
// Copyright 2019 The Gitea Authors. All rights reserved. // Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package flags package flags
import ( import (
"errors"
"fmt"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@@ -35,18 +39,68 @@ var OutputFlag = cli.StringFlag{
Usage: "Output format. (simple, table, csv, tsv, yaml, json)", Usage: "Output format. (simple, table, csv, tsv, yaml, json)",
} }
var (
// ErrPage indicates that the provided page value is invalid (less than -1 or equal to 0).
ErrPage = errors.New("page cannot be smaller than 1")
// ErrLimit indicates that the provided limit value is invalid (negative).
ErrLimit = errors.New("limit cannot be negative")
)
const (
defaultPageValue = 1
defaultLimitValue = 30
)
// GetListOptions returns list options derived from the active command.
func GetListOptions(cmd *cli.Command) gitea.ListOptions {
page := cmd.Int("page")
if page == 0 {
page = defaultPageValue
}
pageSize := cmd.Int("limit")
if pageSize == 0 {
pageSize = defaultLimitValue
}
return gitea.ListOptions{
Page: page,
PageSize: pageSize,
}
}
// PaginationFlags provides all pagination related flags
var PaginationFlags = []cli.Flag{
&PaginationPageFlag,
&PaginationLimitFlag,
}
// PaginationPageFlag provides flag for pagination options // PaginationPageFlag provides flag for pagination options
var PaginationPageFlag = cli.StringFlag{ var PaginationPageFlag = cli.IntFlag{
Name: "page", Name: "page",
Aliases: []string{"p"}, Aliases: []string{"p"},
Usage: "specify page, default is 1", Usage: "specify page",
Value: defaultPageValue,
Validator: func(i int) error {
if i < 1 && i != -1 {
return ErrPage
}
return nil
},
} }
// PaginationLimitFlag provides flag for pagination options // PaginationLimitFlag provides flag for pagination options
var PaginationLimitFlag = cli.StringFlag{ var PaginationLimitFlag = cli.IntFlag{
Name: "limit", Name: "limit",
Aliases: []string{"lm"}, Aliases: []string{"lm"},
Usage: "specify limit of items per page", Usage: "specify limit of items per page",
Value: defaultLimitValue,
Validator: func(i int) error {
if i < 0 {
return ErrLimit
}
return nil
},
} }
// LoginOutputFlags defines login and output flags that should // LoginOutputFlags defines login and output flags that should
@@ -103,3 +157,34 @@ var NotificationStateFlag = NewCsvFlag(
func FieldsFlag(availableFields, defaultFields []string) *CsvFlag { func FieldsFlag(availableFields, defaultFields []string) *CsvFlag {
return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields) return NewCsvFlag("fields", "fields to print", []string{"f"}, availableFields, defaultFields)
} }
// ParseState parses a state string and returns the corresponding gitea.StateType
func ParseState(stateStr string) (gitea.StateType, error) {
switch stateStr {
case "all":
return gitea.StateAll, nil
case "", "open":
return gitea.StateOpen, nil
case "closed":
return gitea.StateClosed, nil
default:
return "", fmt.Errorf("unknown state '%s'", stateStr)
}
}
// ParseIssueKind parses a kind string and returns the corresponding gitea.IssueType.
// If kindStr is empty, returns the provided defaultKind.
func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueType, error) {
switch kindStr {
case "":
return defaultKind, nil
case "all":
return gitea.IssueTypeAll, nil
case "issue", "issues":
return gitea.IssueTypeIssue, nil
case "pull", "pulls", "pr":
return gitea.IssueTypePull, nil
default:
return "", fmt.Errorf("unknown kind '%s'", kindStr)
}
}

152
cmd/flags/generic_test.go Normal file
View File

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

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

View File

@@ -6,7 +6,10 @@ package cmd
import ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/issues" "code.gitea.io/tea/cmd/issues"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
@@ -16,6 +19,34 @@ import (
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
type labelData = detailLabelData
type issueData struct {
ID int64 `json:"id"`
Index int64 `json:"index"`
Title string `json:"title"`
State gitea.StateType `json:"state"`
Created time.Time `json:"created"`
Labels []labelData `json:"labels"`
User string `json:"user"`
Body string `json:"body"`
Assignees []string `json:"assignees"`
URL string `json:"url"`
ClosedAt *time.Time `json:"closedAt"`
Comments []commentData `json:"comments"`
}
type issueDetailClient interface {
GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error)
GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error)
}
type issueCommentClient interface {
ListIssueComments(owner, repo string, index int64, opt gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error)
}
type commentData = detailCommentData
// CmdIssues represents to login a gitea server. // CmdIssues represents to login a gitea server.
var CmdIssues = cli.Command{ var CmdIssues = cli.Command{
Name: "issues", Name: "issues",
@@ -48,14 +79,35 @@ func runIssues(ctx stdctx.Context, cmd *cli.Command) error {
} }
func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error { func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
ctx := context.InitCommand(cmd) ctx, idx, err := resolveIssueDetailContext(cmd, index)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true})
idx, err := utils.ArgToIndex(index)
if err != nil { if err != nil {
return err return err
} }
client := ctx.Login.Client()
return runIssueDetailWithClient(ctx, idx, ctx.Login.Client())
}
func resolveIssueDetailContext(cmd *cli.Command, index string) (*context.TeaContext, int64, error) {
ctx, err := context.InitCommand(cmd)
if err != nil {
return nil, 0, err
}
if ctx.IsSet("owner") {
ctx.Owner = ctx.String("owner")
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return nil, 0, err
}
idx, err := utils.ArgToIndex(index)
if err != nil {
return nil, 0, err
}
return ctx, idx, nil
}
func runIssueDetailWithClient(ctx *context.TeaContext, idx int64, client issueDetailClient) error {
issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx) issue, _, err := client.GetIssue(ctx.Owner, ctx.Repo, idx)
if err != nil { if err != nil {
return err return err
@@ -64,6 +116,14 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
if err != nil { if err != nil {
return err return err
} }
if ctx.IsSet("output") {
switch ctx.String("output") {
case "json":
return runIssueDetailAsJSON(ctx, issue)
}
}
print.IssueDetails(issue, reactions) print.IssueDetails(issue, reactions)
if issue.Comments > 0 { if issue.Comments > 0 {
@@ -75,3 +135,39 @@ func runIssueDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
return nil 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

@@ -23,7 +23,7 @@ var CmdIssuesClose = cli.Command{
Description: `Change state of one ore more issues to 'closed'`, Description: `Change state of one ore more issues to 'closed'`,
ArgsUsage: "<issue index> [<issue index>...]", ArgsUsage: "<issue index> [<issue index>...]",
Action: func(ctx stdctx.Context, cmd *cli.Command) error { Action: func(ctx stdctx.Context, cmd *cli.Command) error {
var s = gitea.StateClosed s := gitea.StateClosed
return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s}) return editIssueState(ctx, cmd, gitea.EditIssueOption{State: &s})
}, },
Flags: flags.AllDefaultFlags, Flags: flags.AllDefaultFlags,
@@ -31,10 +31,15 @@ var CmdIssuesClose = cli.Command{
// editIssueState abstracts the arg parsing to edit the given issue // editIssueState abstracts the arg parsing to edit the given issue
func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error { func editIssueState(_ stdctx.Context, cmd *cli.Command, opts gitea.EditIssueOption) error {
ctx := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() == 0 { if ctx.Args().Len() == 0 {
return fmt.Errorf(ctx.Command.ArgsUsage) return fmt.Errorf("missing required argument: %s", ctx.Command.ArgsUsage)
} }
indices, err := utils.ArgsToIndices(ctx.Args().Slice()) indices, err := utils.ArgsToIndices(ctx.Args().Slice())

View File

@@ -26,11 +26,20 @@ var CmdIssuesCreate = cli.Command{
} }
func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error { func runIssuesCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.NumFlags() == 0 { if ctx.IsInteractiveMode() {
return interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo) 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.GetIssuePRCreateFlags(ctx)

View File

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

View File

@@ -5,7 +5,6 @@ package issues
import ( import (
stdctx "context" stdctx "context"
"fmt"
"time" "time"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
@@ -34,33 +33,21 @@ var CmdIssuesList = cli.Command{
// RunIssuesList list issues // RunIssuesList list issues
func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error { func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
ctx := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
if err != nil {
state := gitea.StateOpen return err
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"))
} }
kind := gitea.IssueTypeIssue state, err := flags.ParseState(ctx.String("state"))
switch ctx.String("kind") { if err != nil {
case "", "issues", "issue": return err
kind = gitea.IssueTypeIssue }
case "pulls", "pull", "pr":
kind = gitea.IssueTypePull kind, err := flags.ParseIssueKind(ctx.String("kind"), gitea.IssueTypeIssue)
case "all": if err != nil {
kind = gitea.IssueTypeAll return err
default:
return fmt.Errorf("unknown kind '%s'", ctx.String("kind"))
} }
var err error
var from, until time.Time var from, until time.Time
if ctx.IsSet("from") { if ctx.IsSet("from") {
from, err = dateparse.ParseLocal(ctx.String("from")) from, err = dateparse.ParseLocal(ctx.String("from"))
@@ -85,7 +72,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
var issues []*gitea.Issue var issues []*gitea.Issue
if ctx.Repo != "" { if ctx.Repo != "" {
issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{ issues, _, err = ctx.Login.Client().ListRepoIssues(owner, ctx.Repo, gitea.ListIssueOption{
ListOptions: ctx.GetListOptions(), ListOptions: flags.GetListOptions(cmd),
State: state, State: state,
Type: kind, Type: kind,
KeyWord: ctx.String("keyword"), KeyWord: ctx.String("keyword"),
@@ -97,13 +84,12 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
Since: from, Since: from,
Before: until, Before: until,
}) })
if err != nil { if err != nil {
return err return err
} }
} else { } else {
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{ issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
ListOptions: ctx.GetListOptions(), ListOptions: flags.GetListOptions(cmd),
State: state, State: state,
Type: kind, Type: kind,
KeyWord: ctx.String("keyword"), KeyWord: ctx.String("keyword"),
@@ -116,7 +102,6 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
Before: until, Before: until,
Owner: owner, Owner: owner,
}) })
if err != nil { if err != nil {
return err return err
} }
@@ -127,6 +112,5 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
return err return err
} }
print.IssuesPullsList(issues, ctx.Output, fields) return print.IssuesPullsList(issues, ctx.Output, fields)
return nil
} }

View File

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

358
cmd/issues_test.go Normal file
View File

@@ -0,0 +1,358 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
const (
testOwner = "testOwner"
testRepo = "testRepo"
)
type fakeIssueCommentClient struct {
owner string
repo string
index int64
comments []*gitea.Comment
}
func (f *fakeIssueCommentClient) ListIssueComments(owner, repo string, index int64, _ gitea.ListIssueCommentOptions) ([]*gitea.Comment, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.index = index
return f.comments, nil, nil
}
type fakeIssueDetailClient struct {
owner string
repo string
index int64
issue *gitea.Issue
reactions []*gitea.Reaction
}
func (f *fakeIssueDetailClient) GetIssue(owner, repo string, index int64) (*gitea.Issue, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.index = index
return f.issue, nil, nil
}
func (f *fakeIssueDetailClient) GetIssueReactions(owner, repo string, index int64) ([]*gitea.Reaction, *gitea.Response, error) {
f.owner = owner
f.repo = repo
f.index = index
return f.reactions, nil, nil
}
func toCommentPointers(comments []gitea.Comment) []*gitea.Comment {
result := make([]*gitea.Comment, 0, len(comments))
for i := range comments {
comment := comments[i]
result = append(result, &comment)
}
return result
}
func createTestIssue(comments int, isClosed bool) gitea.Issue {
issue := gitea.Issue{
ID: 42,
Index: 1,
Title: "Test issue",
State: gitea.StateOpen,
Body: "This is a test",
Created: time.Date(2025, 31, 10, 23, 59, 59, 999999999, time.UTC),
Updated: time.Date(2025, 1, 11, 0, 0, 0, 0, time.UTC),
Labels: []*gitea.Label{
{
Name: "example/Label1",
Color: "very red",
Description: "This is an example label",
},
{
Name: "example/Label2",
Color: "hardly red",
Description: "This is another example label",
},
},
Comments: comments,
Poster: &gitea.User{
UserName: "testUser",
},
Assignees: []*gitea.User{
{UserName: "testUser"},
{UserName: "testUser3"},
},
HTMLURL: "<space holder>",
Closed: nil, // 2025-11-10T21:20:19Z
}
if isClosed {
closed := time.Date(2025, 11, 10, 21, 20, 19, 0, time.UTC)
issue.Closed = &closed
}
if isClosed {
issue.State = gitea.StateClosed
} else {
issue.State = gitea.StateOpen
}
return issue
}
func createTestIssueComments(comments int) []gitea.Comment {
baseID := 900
var result []gitea.Comment
for commentID := 0; commentID < comments; commentID++ {
result = append(result, gitea.Comment{
ID: int64(baseID + commentID),
Poster: &gitea.User{
UserName: "Freddy",
},
Body: fmt.Sprintf("This is a test comment #%v", commentID),
Created: time.Date(2025, 11, 3, 12, 0, 0, 0, time.UTC).
Add(time.Duration(commentID) * time.Hour),
})
}
return result
}
func TestRunIssueDetailAsJSON(t *testing.T) {
type TestCase struct {
name string
issue gitea.Issue
comments []gitea.Comment
flagComments bool
}
cmd := cli.Command{
Name: "t",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "comments",
Value: false,
},
&cli.StringFlag{
Name: "output",
Value: "json",
},
},
}
testContext := context.TeaContext{
Owner: testOwner,
Repo: testRepo,
Login: &config.Login{
Name: "testLogin",
URL: "http://127.0.0.1:8081",
},
Command: &cmd,
}
testCases := []TestCase{
{
name: "Simple issue with no comments, no comments requested",
issue: createTestIssue(0, true),
comments: []gitea.Comment{},
flagComments: false,
},
{
name: "Simple issue with no comments, comments requested",
issue: createTestIssue(0, true),
comments: []gitea.Comment{},
flagComments: true,
},
{
name: "Simple issue with comments, no comments requested",
issue: createTestIssue(2, true),
comments: createTestIssueComments(2),
flagComments: false,
},
{
name: "Simple issue with comments, comments requested",
issue: createTestIssue(2, true),
comments: createTestIssueComments(2),
flagComments: true,
},
{
name: "Simple issue with comments, comments requested, not closed",
issue: createTestIssue(2, false),
comments: createTestIssueComments(2),
flagComments: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
client := &fakeIssueCommentClient{
comments: toCommentPointers(testCase.comments),
}
testContext.Login.URL = "https://gitea.example.com"
testCase.issue.HTMLURL = fmt.Sprintf("%s/%s/%s/issues/%d/", testContext.Login.URL, testOwner, testRepo, testCase.issue.Index)
var outBuffer bytes.Buffer
testContext.Writer = &outBuffer
var errBuffer bytes.Buffer
testContext.ErrWriter = &errBuffer
if testCase.flagComments {
require.NoError(t, testContext.Set("comments", "true"))
} else {
require.NoError(t, testContext.Set("comments", "false"))
}
err := runIssueDetailAsJSONWithClient(&testContext, &testCase.issue, client)
require.NoError(t, err, "Failed to run issue detail as JSON")
if testCase.flagComments {
assert.Equal(t, testOwner, client.owner)
assert.Equal(t, testRepo, client.repo)
assert.Equal(t, testCase.issue.Index, client.index)
}
out := outBuffer.String()
require.NotEmpty(t, out, "Unexpected empty output from runIssueDetailAsJSON")
// setting expectations
var expectedLabels []labelData
expectedLabels = []labelData{}
for _, l := range testCase.issue.Labels {
expectedLabels = append(expectedLabels, labelData{
Name: l.Name,
Color: l.Color,
Description: l.Description,
})
}
var expectedAssignees []string
expectedAssignees = []string{}
for _, a := range testCase.issue.Assignees {
expectedAssignees = append(expectedAssignees, a.UserName)
}
var expectedClosedAt *time.Time
if testCase.issue.Closed != nil {
expectedClosedAt = testCase.issue.Closed
}
var expectedComments []commentData
expectedComments = []commentData{}
if testCase.flagComments {
for _, c := range testCase.comments {
expectedComments = append(expectedComments, commentData{
ID: c.ID,
Author: c.Poster.UserName,
Body: c.Body,
Created: c.Created,
})
}
}
expected := issueData{
ID: testCase.issue.ID,
Index: testCase.issue.Index,
Title: testCase.issue.Title,
State: testCase.issue.State,
Created: testCase.issue.Created,
User: testCase.issue.Poster.UserName,
Body: testCase.issue.Body,
URL: testCase.issue.HTMLURL,
ClosedAt: expectedClosedAt,
Labels: expectedLabels,
Assignees: expectedAssignees,
Comments: expectedComments,
}
// validating reality
var actual issueData
dec := json.NewDecoder(bytes.NewReader(outBuffer.Bytes()))
dec.DisallowUnknownFields()
err = dec.Decode(&actual)
require.NoError(t, err, "Failed to unmarshal output into struct")
assert.Equal(t, expected, actual, "Expected structs differ from expected one")
})
}
}
func TestRunIssueDetailUsesOwnerFlag(t *testing.T) {
issueIndex := int64(12)
expectedOwner := "overrideOwner"
expectedRepo := "overrideRepo"
issue := &gitea.Issue{
ID: 99,
Index: issueIndex,
Title: "Owner override test",
State: gitea.StateOpen,
Created: time.Date(2025, 11, 1, 10, 0, 0, 0, time.UTC),
Poster: &gitea.User{
UserName: "tester",
},
HTMLURL: "https://example.test/issues/12",
}
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "testLogin",
URL: "https://gitea.example.com",
Token: "token",
User: "loginUser",
Default: true,
}},
})
cmd := cli.Command{
Name: "issues",
Flags: []cli.Flag{
&flags.LoginFlag,
&flags.RepoFlag,
&flags.RemoteFlag,
&flags.OutputFlag,
&cli.StringFlag{Name: "owner"},
&cli.BoolFlag{Name: "comments"},
},
}
var outBuffer bytes.Buffer
var errBuffer bytes.Buffer
cmd.Writer = &outBuffer
cmd.ErrWriter = &errBuffer
require.NoError(t, cmd.Set("login", "testLogin"))
require.NoError(t, cmd.Set("repo", expectedRepo))
require.NoError(t, cmd.Set("owner", expectedOwner))
require.NoError(t, cmd.Set("comments", "false"))
teaCtx, idx, err := resolveIssueDetailContext(&cmd, fmt.Sprintf("%d", issueIndex))
require.NoError(t, err)
client := &fakeIssueDetailClient{
issue: issue,
reactions: []*gitea.Reaction{},
}
err = runIssueDetailWithClient(teaCtx, idx, client)
require.NoError(t, err, "Expected runIssueDetail to succeed")
assert.Equal(t, expectedOwner, client.owner)
assert.Equal(t, expectedRepo, client.repo)
assert.Equal(t, issueIndex, client.index)
}

View File

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

View File

@@ -46,44 +46,52 @@ var CmdLabelCreate = cli.Command{
} }
func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error { func runLabelCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{RemoteRepo: true}) if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
labelFile := ctx.String("file") labelFile := ctx.String("file")
var err error
if len(labelFile) == 0 { if len(labelFile) == 0 {
_, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{ _, _, err := ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: ctx.String("name"), Name: ctx.String("name"),
Color: ctx.String("color"), Color: ctx.String("color"),
Description: ctx.String("description"), Description: ctx.String("description"),
}) })
} else { return err
f, err := os.Open(labelFile)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var i = 1
for scanner.Scan() {
line := scanner.Text()
color, name, description := splitLabelLine(line)
if color == "" || name == "" {
log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line)
} else {
_, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: name,
Color: color,
Description: description,
})
}
i++
}
} }
return err f, err := os.Open(labelFile)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
i := 1
for scanner.Scan() {
line := scanner.Text()
color, name, description := splitLabelLine(line)
if color == "" || name == "" {
log.Printf("Line %d ignored because lack of enough fields: %s\n", i, line)
} else {
_, _, err = ctx.Login.Client().CreateLabel(ctx.Owner, ctx.Repo, gitea.CreateLabelOption{
Name: name,
Color: color,
Description: description,
})
if err != nil {
return err
}
}
i++
}
return nil
} }
func splitLabelLine(line string) (string, string, string) { func splitLabelLine(line string) (string, string, string) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ package login
import ( import (
"context" "context"
"fmt"
"code.gitea.io/tea/modules/auth" "code.gitea.io/tea/modules/auth"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
@@ -112,7 +113,10 @@ var CmdLoginAdd = cli.Command{
func runLoginAdd(_ context.Context, cmd *cli.Command) error { func runLoginAdd(_ context.Context, cmd *cli.Command) error {
// if no args create login interactive // if no args create login interactive
if cmd.NumFlags() == 0 { if cmd.NumFlags() == 0 {
return interact.CreateLogin() 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 OAuth flag is provided, use OAuth2 PKCE flow

View File

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

View File

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

View File

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

View File

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

View File

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

62
cmd/man.go Normal file
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,10 +32,15 @@ var CmdPullsClean = cli.Command{
} }
func runPullsClean(_ stdctx.Context, cmd *cli.Command) error { func runPullsClean(_ stdctx.Context, cmd *cli.Command) error {
ctx := context.InitCommand(cmd) ctx, err := context.InitCommand(cmd)
ctx.Ensure(context.CtxRequirement{LocalRepo: true}) if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{LocalRepo: true}); err != nil {
return err
}
if ctx.Args().Len() != 1 { if ctx.Args().Len() != 1 {
return fmt.Errorf("Must specify a PR index") return fmt.Errorf("pull request index is required")
} }
idx, err := utils.ArgToIndex(ctx.Args().First()) idx, err := utils.ArgToIndex(ctx.Args().First())
@@ -43,5 +48,8 @@ func runPullsClean(_ stdctx.Context, cmd *cli.Command) error {
return err return err
} }
return task.PullClean(ctx.Login, ctx.Owner, ctx.Repo, idx, ctx.Bool("ignore-sha"), interact.PromptPassword) if err := task.PullClean(ctx.Login, ctx.Owner, ctx.Repo, idx, ctx.Bool("ignore-sha"), interact.PromptPassword); err != nil && !interact.IsQuitting(err) {
return err
}
return nil
} }

View File

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

View File

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

View File

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

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