35 Commits

Author SHA1 Message Date
techknowlogick
6134351048 bump go deps 2026-05-14 12:01:28 -04:00
Renovate Bot
8be4dae66e fix(deps): update module github.com/go-authgate/sdk-go to v0.11.0 (#988)
Reviewed-on: https://gitea.com/gitea/tea/pulls/988
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-14 16:00:02 +00:00
Renovate Bot
b8dcb8a442 fix(deps): update module golang.org/x/term to v0.43.0 (#989)
Reviewed-on: https://gitea.com/gitea/tea/pulls/989
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-14 15:59:53 +00:00
Renovate Bot
01632d927e fix(deps): update module code.gitea.io/sdk/gitea to v0.25.1 (#991)
Reviewed-on: https://gitea.com/gitea/tea/pulls/991
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-14 15:59:44 +00:00
Renovate Bot
1b79be7cea fix(deps): update module github.com/urfave/cli/v3 to v3.9.0 (#992)
Reviewed-on: https://gitea.com/gitea/tea/pulls/992
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-14 15:59:34 +00:00
Minjie Fang
2cc45f1cce fix(deps): update github.com/urfave/cli to v3.9.0 (#993)
Fix https://gitea.com/gitea/tea/issues/975

Reviewed-on: https://gitea.com/gitea/tea/pulls/993
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Minjie Fang <wingsallen@gmail.com>
Co-committed-by: Minjie Fang <wingsallen@gmail.com>
2026-05-14 05:15:33 +00:00
Minjie Fang
2b64762a32 Fix login edit to check config existence (#987)
Fix https://gitea.com/gitea/tea/issues/561

Reviewed-on: https://gitea.com/gitea/tea/pulls/987
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Minjie Fang <wingsallen@gmail.com>
Co-committed-by: Minjie Fang <wingsallen@gmail.com>
2026-05-10 01:28:06 +00:00
Carlos Grillet
19dd8b1b4b fix(deps): update module code.gitea.io/sdk/gitea to v0.25.0 (#984)
Bumping gitea SDK to version v0.25.0

Currently there is an issue when users try to use SSH to authenticate to a gitea server. The issue is already reported here #983

The problem was that `*gitea.HTTPSign` embeds `ssh.Signer` (not `ssh.AlgorithmSigner`).

`httpsig v1.2.4` type-asserts the signer to `ssh.AlgorithmSigner` for RSA keys and panics because `*HTTPSign` doesn't expose `SignWithAlgorithm`.

Fix: SDK v0.25.0 adds `SignWithAlgorithm` to `HTTPSign`, satisfying `ssh.AlgorithmSigner`.
Reviewed-on: https://gitea.com/gitea/tea/pulls/984
Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com>
Co-authored-by: Carlos Grillet <carlosbeta5000@gmail.com>
Co-committed-by: Carlos Grillet <carlosbeta5000@gmail.com>
2026-05-07 17:29:39 +00:00
cpamayo
f617f26da0 fix: pass the name flag value as the organization FullName (#832)
This change proposes that, when creating an organization using the CLI, the value provided in the `--name` parameter is used as the organization `FullName`.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/832
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: cpamayo <anderson.carl3@mayo.edu>
Co-committed-by: cpamayo <anderson.carl3@mayo.edu>
2026-05-07 00:14:22 +00:00
Minjie Fang
a5ecf06c2a Fix login edit to open one editor only (#977)
Fix https://gitea.com/gitea/tea/issues/906

Reviewed-on: https://gitea.com/gitea/tea/pulls/977
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Minjie Fang <wingsallen@gmail.com>
Co-committed-by: Minjie Fang <wingsallen@gmail.com>
2026-05-07 00:12:12 +00:00
Renovate Bot
e686e8d0bd fix(deps): update module github.com/go-authgate/sdk-go to v0.10.0 (#976)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/go-authgate/sdk-go](https://github.com/go-authgate/sdk-go) | `v0.9.0` → `v0.10.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-authgate%2fsdk-go/v0.10.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-authgate%2fsdk-go/v0.9.0/v0.10.0?slim=true) |

---

### Release Notes

<details>
<summary>go-authgate/sdk-go (github.com/go-authgate/sdk-go)</summary>

### [`v0.10.0`](https://github.com/go-authgate/sdk-go/releases/tag/v0.10.0)

[Compare Source](https://github.com/go-authgate/sdk-go/compare/v0.9.0...v0.10.0)

#### Changelog

##### Others

- [`5b43693`](5b436935ca): feat(jwksauth)!: align with upstream JWT\_PRIVATE\_CLAIM\_PREFIX ([#&#8203;27](https://github.com/go-authgate/sdk-go/issues/27)) ([@&#8203;appleboy](https://github.com/appleboy))

</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 [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNjAuNiIsInVwZGF0ZWRJblZlciI6IjQzLjE2MC42IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/tea/pulls/976
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-04 02:06:58 +00:00
ghainer
22ff601988 feat: add additional admin users subcommands (#842)
## Summary

Adds admin user management commands to the tea CLI, enabling admins to create, edit, and delete user accounts.

## Features Added

### Admin User Management Commands

- **Create users**: `tea admin users create` - Create new user accounts with configurable options
- **Edit users**: `tea admin users edit <username>` - Update user properties including password, permissions, and profile settings
- **Delete users**: `tea admin users delete <username>` - Remove user accounts with confirmation prompt

### Implementation Details

#### Create Command (`admin users create`)
- Required: username
- Optional: email, full name, password
- Flags: admin, restricted, prohibit-login, visibility
- Password input: command-line flag, file, stdin, or interactive prompt with confirmation
- Default: users must change password on first login (use `--no-must-change-password` to skip)
- Post-creation updates for admin/restricted/prohibit-login (not available during creation)

#### Edit Command (`admin users edit`)
- Updates only explicitly provided fields (partial updates)
- Password change support with the same input methods as create
- Editable fields:
  - Profile: email, full name, description, website, location
  - Permissions: admin/restricted/active status
  - Settings: visibility, max repo creation limits
  - Advanced: git hooks, local imports, organization creation
- Default: password changes require password change on next login (use `--no-must-change-password` to skip)

#### Delete Command (`admin users delete`)
- Confirmation prompt by default
- `--confirm` flag to skip confirmation
- Displays user details before deletion

### Security Features

- Secure password input via interactive prompts (hidden input)
- Multiple password input methods: flag, file, stdin, interactive
- Password confirmation for interactive mode
- Whitespace trimming for file/stdin inputs

### Password Input Methods

1. **Command-line flag**: `--password <value>`
2. **File input**: `--password-file <file>` - Read from file
3. **Stdin input**: `--password-stdin` - Read from stdin
4. **Interactive prompt**: Automatically prompts if password not provided (with confirmation)

For edit command: Use `--password=""` to trigger interactive prompt.

## Usage Examples

```bash
# Create a new user
tea admin users create --username john --email john@example.com --admin --no-must-change-password

# Create with interactive password prompt
tea admin users create jane --email jane@example.com

# Edit user properties
tea admin users edit john --email newemail@example.com --restricted

# Change user password (will prompt if not provided)
tea admin users edit john --password=""
tea admin users edit john --password-file /path/to/password.txt

# Delete a user (with confirmation)
tea admin users delete olduser

# Delete without confirmation
tea admin users delete olduser --confirm
```

## Related Issue

Resolves #161

## Testing

- Unit tests for all commands
- Flag validation and default value tests
- Password input method tests (file, stdin, interactive)
- Test coverage for all user option structures
- Confirmation logic tests for delete command

## Technical Details

- Uses Gitea SDK `AdminCreateUser`, `AdminEditUser`, and `AdminDeleteUser` APIs
- Follows existing tea CLI patterns and conventions
- Handles fields not available during creation via post-creation updates
- Partial update support for edit command (only updates explicitly set fields)
- Consistent with other tea commands (webhooks, secrets) in password handling and confirmation patterns

All tests pass and the implementation integrates with existing tea CLI infrastructure.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/842
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: ghainer <gehainer@gmail.com>
Co-committed-by: ghainer <gehainer@gmail.com>
2026-05-02 23:50:36 +00:00
Brandon Fryslie
9d6ae4bf02 feat(ssh-keys): add ssh-keys command to manage SSH public keys (#940)
## Summary

- Adds `tea ssh-keys` command group (aliases: `ssh-key`, `keys`) under the SETUP category
- Mirrors the interface of `gh ssh-key add/list/delete`
- Three subcommands: `add <keyfile>`, `list`, `delete <id>`

## Commands

\`\`\`sh
tea ssh-keys add ~/.ssh/id_ed25519.pub                     # title defaults to filename stem
tea ssh-keys add ~/.ssh/id_rsa.pub --title "work laptop"
tea ssh-keys add ~/.ssh/deploy.pub --read-only             # authentication-only key
tea ssh-keys list
tea ssh-keys list --output json
tea ssh-keys delete 42                                     # prompts for confirmation
tea ssh-keys delete 42 --force                             # skip prompt
\`\`\`

## Test plan

- [x] `make lint` — 0 issues
- [x] `make fmt-check` — passes
- [x] `go test ./cmd/sshkeys/... -run TestKeyTitle` — unit tests pass (no server needed)
- [ ] Integration tests with live Gitea instance:
  \`\`\`sh
  GITEA_TEA_TEST_URL=https://your-gitea \
  GITEA_TEA_TEST_TOKEN=<token> \
  go test ./cmd/sshkeys/... -v -run TestSSHKey
  \`\`\`
  Exercises full add → SDK-verify → delete → 404-verify lifecycle.

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Brandon Fryslie <530235+brandon-fryslie@users.noreply.github.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/940
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Brandon Fryslie <186614+brandroid@noreply.gitea.com>
Co-committed-by: Brandon Fryslie <186614+brandroid@noreply.gitea.com>
2026-05-02 18:24:08 +00:00
Matěj Cepl
2985824ab0 Multiple PRs (#848)
This is an effort to allow tea pr review to work with multiple reviews.

Fixes: #847
Reviewed-on: https://gitea.com/gitea/tea/pulls/848
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-05-02 17:01:40 +00:00
Lunny Xiao
83b718ac34 Move integration tests to tests/ directory (#973)
Reviewed-on: https://gitea.com/gitea/tea/pulls/973
2026-05-02 04:18:45 +00:00
Renovate Bot
1f6fd97fc1 fix(deps): update module github.com/go-authgate/sdk-go to v0.9.0 (#974)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/go-authgate/sdk-go](https://github.com/go-authgate/sdk-go) | `v0.8.0` → `v0.9.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-authgate%2fsdk-go/v0.9.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-authgate%2fsdk-go/v0.8.0/v0.9.0?slim=true) |

---

### Release Notes

<details>
<summary>go-authgate/sdk-go (github.com/go-authgate/sdk-go)</summary>

### [`v0.9.0`](https://github.com/go-authgate/sdk-go/releases/tag/v0.9.0)

[Compare Source](https://github.com/go-authgate/sdk-go/compare/v0.8.0...v0.9.0)

#### Changelog

##### Documentation updates

- [`86d33f3`](86d33f315c): docs(jwksauth): tighten readme table column widths ([@&#8203;appleboy](https://github.com/appleboy))

##### Others

- [`545d96f`](545d96fd43): refactor(jwksauth)!: rename Tenant to Domain and add Tenant sub-claim ([#&#8203;25](https://github.com/go-authgate/sdk-go/issues/25)) ([@&#8203;appleboy](https://github.com/appleboy))
- [`1e73580`](1e73580c87): feat(jwksauth)!: adopt slog-style Logger interface ([#&#8203;24](https://github.com/go-authgate/sdk-go/issues/24)) ([@&#8203;appleboy](https://github.com/appleboy))
- [`7af1bc4`](7af1bc4637): test(jwksauth): fix stale Tenant references in policy reject test ([#&#8203;26](https://github.com/go-authgate/sdk-go/issues/26)) ([@&#8203;appleboy](https://github.com/appleboy))

</details>

---

Reviewed-on: https://gitea.com/gitea/tea/pulls/974
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-05-02 02:04:23 +00:00
Renovate Bot
27e6083e23 fix(deps): update module github.com/go-authgate/sdk-go to v0.8.0 (#972)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/go-authgate/sdk-go](https://github.com/go-authgate/sdk-go) | `v0.7.0` → `v0.8.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fgo-authgate%2fsdk-go/v0.8.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fgo-authgate%2fsdk-go/v0.7.0/v0.8.0?slim=true) |

---

### Release Notes

<details>
<summary>go-authgate/sdk-go (github.com/go-authgate/sdk-go)</summary>

### [`v0.8.0`](https://github.com/go-authgate/sdk-go/releases/tag/v0.8.0)

[Compare Source](https://github.com/go-authgate/sdk-go/compare/v0.7.0...v0.8.0)

#### Changelog

##### Refactor

- [`62ccff0`](62ccff06c8): refactor(jwksauth): share OIDC discovery and drop tenant cache ([#&#8203;23](https://github.com/go-authgate/sdk-go/issues/23)) ([@&#8203;appleboy](https://github.com/appleboy))
- [`088ee3b`](088ee3bd2d): refactor(sdk): harden HTTP reads and improve code quality ([#&#8203;18](https://github.com/go-authgate/sdk-go/issues/18)) ([@&#8203;appleboy](https://github.com/appleboy))
- [`aa17bc2`](aa17bc2373): refactor: simplify oauth client and token source flows ([#&#8203;22](https://github.com/go-authgate/sdk-go/issues/22)) ([@&#8203;appleboy](https://github.com/appleboy))

</details>

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/972
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-01 23:16:49 +00:00
Lunny Xiao
5d2d1a6e0c fix(webhook): Fix when creating webhook, branch filter and auth header cannot be added (#964)
Fix #963

Reviewed-on: https://gitea.com/gitea/tea/pulls/964
2026-05-01 16:45:52 +00:00
Oleksii Zaremskyi
88421bb888 fix: read --assignee flag value instead of nonexistent --assigned-to (#971)
## What this PR does

`tea issues list --assignee USERNAME` currently returns every issue regardless of the assignee value — even nonexistent users return a full unfiltered list. Discovered against **tea v0.14.0** (with go-sdk v0.24.1) and reproduced on current `master` (commit `dd81b33`). This PR fixes that.

## Root cause

Two distinct bugs on the same flag, both in `cmd/issues/list.go`:

1. **Per-repo path** (`tea issues list --repo OWNER/REPO --assignee USER`): the code reads `ctx.String("assigned-to")` for `AssignedBy`, but the flag is defined as `--assignee` in `cmd/flags/issue_pr.go:66`. The lookup always returns `""`, so the SDK omits the `assigned_by` query parameter and the API returns everything.

2. **Global path** (`tea issues list --assignee USER`, no `--repo`): this hits `/repos/issues/search`, which silently ignores `assigned_by`. Even after fix #1 the no-repo form would still return unfiltered results. Verified directly:
   - `GET /repos/issues/search?assigned_by=USER&owner=ORG&state=open` → all open issues
   - `GET /repos/issues/search?assigned=true&owner=ORG&state=open` → only the issues assigned to the authenticated user

   The endpoint only supports `assigned=true` (boolean self-filter), not arbitrary-user filtering, and `ListIssueOption` doesn't expose that field. Rather than misleading the caller, the no-repo path now returns a clear error.

## Changes

Both changes are in `cmd/issues/list.go`:

1. Read `ctx.String("assignee")` instead of the non-existent flag name `"assigned-to"` (lines 80 and 97).
2. In the no-`--repo` branch, return `errors.New("--assignee requires --repo (...)")` when the flag is set.

`cmd/pulls/list.go` does not expose an assignee filter, so it's unaffected. The `--author` mapping (`CreatedBy ← ctx.String("author")`) was already correct and is the model the fix follows.

## Manual verification

Tested against a local Gitea instance with three open issues (only one assigned to the test user):

| Command | Before | After |
|---|---|---|
| `tea issues list --repo X --assignee me` | all 3 | only the 1 assigned ✓ |
| `tea issues list --repo X --assignee nonexistent` | all 3 | `Error: not found` ✓ |
| `tea issues list --repo X --author me` | only the 1 (control) | unchanged ✓ |
| `tea issues list --assignee me` (no `--repo`) | all 3 (silent) | clear error ✓ |
| `tea issues list` (no flags) | all 3 | unchanged ✓ |

---------

Co-authored-by: claude_1 <claude_1@bot.gqx.lol>
Reviewed-on: https://gitea.com/gitea/tea/pulls/971
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Oleksii Zaremskyi <grossqx@gmail.com>
Co-committed-by: Oleksii Zaremskyi <grossqx@gmail.com>
2026-05-01 16:39:48 +00:00
Wesley Moore
dd81b33cec Fix man page section (#969)
Co-authored-by: Wesley Moore <wes@wezm.net>
Co-committed-by: Wesley Moore <wes@wezm.net>
2026-04-29 15:04:55 +00:00
Renovate Bot
b100d4c939 fix(deps): update module github.com/go-authgate/sdk-go to v0.7.0 (#970)
Reviewed-on: https://gitea.com/gitea/tea/pulls/970
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-29 03:28:15 +00:00
Renovate Bot
892905d482 chore(deps): update docker.gitea.com/gitea docker tag to v1.26.1 (#968)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [docker.gitea.com/gitea](https://github.com/go-gitea/gitea) | service | patch | `1.26.0` → `1.26.1` |

---

### Release Notes

<details>
<summary>go-gitea/gitea (docker.gitea.com/gitea)</summary>

### [`v1.26.1`](https://github.com/go-gitea/gitea/releases/tag/v1.26.1)

[Compare Source](https://github.com/go-gitea/gitea/compare/v1.26.0...v1.26.1)

- BUGFIXES   \* Add event.schedule context for schedule actions task ([#&#8203;37320](https://github.com/go-gitea/gitea/issues/37320)) ([#&#8203;37348](https://github.com/go-gitea/gitea/issues/37348))   \* Fix an issue where changing an organization's visibility caused problems when users had forked its repositories. ([#&#8203;37324](https://github.com/go-gitea/gitea/issues/37324)) ([#&#8203;37344](https://github.com/go-gitea/gitea/issues/37344))   \* Use modern "git update-index --cacheinfo" syntax to support more file names ([#&#8203;37338](https://github.com/go-gitea/gitea/issues/37338)) ([#&#8203;37343](https://github.com/go-gitea/gitea/issues/37343))   \* Fix URL related escaping for oauth2 ([#&#8203;37334](https://github.com/go-gitea/gitea/issues/37334)) ([#&#8203;37340](https://github.com/go-gitea/gitea/issues/37340))   \* When the requested arch rpm is missing fall back to noarch ([#&#8203;37236](https://github.com/go-gitea/gitea/issues/37236)) ([#&#8203;37339](https://github.com/go-gitea/gitea/issues/37339))   \* Fix actions concurrency groups cross-branch leak ([#&#8203;37311](https://github.com/go-gitea/gitea/issues/37311)) ([#&#8203;37331](https://github.com/go-gitea/gitea/issues/37331))   \* Fix bug when accessing user badges ([#&#8203;37321](https://github.com/go-gitea/gitea/issues/37321)) ([#&#8203;37329](https://github.com/go-gitea/gitea/issues/37329))   \* Fix AppFullLink ([#&#8203;37325](https://github.com/go-gitea/gitea/issues/37325)) ([#&#8203;37328](https://github.com/go-gitea/gitea/issues/37328))   \* Fix container auth for public instance ([#&#8203;37290](https://github.com/go-gitea/gitea/issues/37290)) ([#&#8203;37294](https://github.com/go-gitea/gitea/issues/37294))   \* Enhance GetActionWorkflow to support fallback references ([#&#8203;37189](https://github.com/go-gitea/gitea/issues/37189)) ([#&#8203;37283](https://github.com/go-gitea/gitea/issues/37283))   \* Fix vite manifest update masking build errors ([#&#8203;37279](https://github.com/go-gitea/gitea/issues/37279)) ([#&#8203;37310](https://github.com/go-gitea/gitea/issues/37310))   \* Fix Mermaid diagrams failing when node labels contain line breaks ([#&#8203;37296](https://github.com/go-gitea/gitea/issues/37296)) ([#&#8203;37299](https://github.com/go-gitea/gitea/issues/37299))   \* Use TriggerEvent instead of Event in workflow runs API response for scheduled runs ([#&#8203;37288](https://github.com/go-gitea/gitea/issues/37288)) [#&#8203;37360](https://github.com/go-gitea/gitea/issues/37360)   \* Add URL to Learn more about blocking a user. ([#&#8203;37355](https://github.com/go-gitea/gitea/issues/37355)) [#&#8203;37367](https://github.com/go-gitea/gitea/issues/37367)   \* Fix button layout shift when collapsing file tree in editor ([#&#8203;37363](https://github.com/go-gitea/gitea/issues/37363)) [#&#8203;37375](https://github.com/go-gitea/gitea/issues/37375)   \* Fix org team assignee/reviewer lookups for team member permissions ([#&#8203;37365](https://github.com/go-gitea/gitea/issues/37365)) [#&#8203;37391](https://github.com/go-gitea/gitea/issues/37391)   \* Fix repo init README EOL ([#&#8203;37388](https://github.com/go-gitea/gitea/issues/37388)) [#&#8203;37399](https://github.com/go-gitea/gitea/issues/37399)   \* Fix: dump with default zip type produces uncompressed zip ([#&#8203;37401](https://github.com/go-gitea/gitea/issues/37401))[#&#8203;37402](https://github.com/go-gitea/gitea/issues/37402)

</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 [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNDAuMCIsInVwZGF0ZWRJblZlciI6IjQzLjE0MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/tea/pulls/968
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-04-25 18:12:06 +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
100 changed files with 3638 additions and 627 deletions

View File

@@ -12,13 +12,9 @@ jobs:
# uses: golang/govulncheck-action@v1
# with:
# go-version-file: 'go.mod'
check-and-test:
check-and-unit:
name: Lint Build And Unit Coverage
runs-on: ubuntu-latest
env:
HTTP_PROXY: ""
GITEA_TEA_TEST_URL: "http://gitea:3000"
GITEA_TEA_TEST_USERNAME: "test01"
GITEA_TEA_TEST_PASSWORD: "test01"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
@@ -32,14 +28,30 @@ jobs:
make fmt-check
make docs-check
make build
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
- name: test and coverage
- name: unit test and coverage
run: |
make test
make unit-test-coverage
integration-test:
name: Integration Test
runs-on: ubuntu-latest
env:
HTTP_PROXY: ""
GITEA_TEA_TEST_URL: "http://gitea:3000"
GITEA_TEA_TEST_USERNAME: "test01"
GITEA_TEA_TEST_PASSWORD: "test01"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
- name: integration test
run: |
make integration-test
services:
gitea:
image: docker.gitea.com/gitea:1.25.5
image: docker.gitea.com/gitea:1.26.1
cmd:
- bash
- -c

2
.gitignore vendored
View File

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

View File

@@ -30,7 +30,10 @@ LDFLAGS := -X "code.gitea.io/tea/modules/version.Version=$(TEA_VERSION)" -X "cod
# override to allow passing additional goflags via make CLI
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)'
PACKAGES ?= $(shell $(GO) list ./...)
PACKAGES ?= $(shell $(GO) list ./... | grep -v '^code.gitea.io/tea/tests')
UNIT_PACKAGES ?= $(PACKAGES)
INTEGRATION_PACKAGES ?= $(shell $(GO) list ./tests/... 2>/dev/null)
INTEGRATION_TEST_TAGS ?= testtools
SOURCES ?= $(shell find . -name "*.go" -type f)
# OS specific vars.
@@ -64,11 +67,11 @@ vet:
.PHONY: lint
lint:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools
.PHONY: lint-fix
lint-fix:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix
.PHONY: fmt-check
fmt-check:
@@ -93,13 +96,24 @@ docs-check:
exit 1; \
fi;
.PHONY: unit-test
unit-test:
$(GO) test $(UNIT_PACKAGES)
.PHONY: integration-test
integration-test:
@if [ -n "$(INTEGRATION_PACKAGES)" ]; then \
$(GO) test -tags='$(INTEGRATION_TEST_TAGS)' $(INTEGRATION_PACKAGES); \
else \
echo "No integration test packages found"; \
fi
.PHONY: test
test:
$(GO) test -tags='sqlite sqlite_unlock_notify' $(PACKAGES)
test: unit-test integration-test
.PHONY: unit-test-coverage
unit-test-coverage:
$(GO) test -tags='sqlite sqlite_unlock_notify' -cover -coverprofile coverage.out $(PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
$(GO) test -cover -coverprofile coverage.out $(UNIT_PACKAGES) && echo "\n==>\033[32m Ok\033[m\n" || exit 1
.PHONY: tidy
tidy:
@@ -122,4 +136,3 @@ $(EXECUTABLE): $(SOURCES)
.PHONY: build-image
build-image:
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .

View File

@@ -20,6 +20,10 @@ var CmdActionsWorkflows = cli.Command{
Action: runWorkflowsDefault,
Commands: []*cli.Command{
&workflows.CmdWorkflowsList,
&workflows.CmdWorkflowsView,
&workflows.CmdWorkflowsDispatch,
&workflows.CmdWorkflowsEnable,
&workflows.CmdWorkflowsDisable,
},
}

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

@@ -6,8 +6,6 @@ package workflows
import (
stdctx "context"
"fmt"
"path/filepath"
"strings"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
@@ -22,15 +20,12 @@ var CmdWorkflowsList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List repository workflows",
Description: "List workflow files in the repository with active/inactive status",
Description: "List workflows in the repository with their status",
Action: RunWorkflowsList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.AllDefaultFlags...),
Flags: flags.AllDefaultFlags,
}
// RunWorkflowsList lists workflow files in the repository
// 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 {
@@ -41,51 +36,15 @@ func RunWorkflowsList(ctx stdctx.Context, cmd *cli.Command) error {
}
client := c.Login.Client()
// Try to list workflow files from .gitea/workflows directory
var workflows []*gitea.ContentsResponse
// Try .gitea/workflows first, then .github/workflows
workflowDir := ".gitea/workflows"
contents, _, err := client.ListContents(c.Owner, c.Repo, "", workflowDir)
resp, _, err := client.ListRepoActionWorkflows(c.Owner, c.Repo)
if err != nil {
workflowDir = ".github/workflows"
contents, _, err = client.ListContents(c.Owner, c.Repo, "", workflowDir)
if err != nil {
fmt.Printf("No workflow files found\n")
return nil
}
return fmt.Errorf("failed to list workflows: %w", err)
}
// Filter for workflow files (.yml and .yaml)
for _, content := range contents {
if content.Type == "file" {
ext := strings.ToLower(filepath.Ext(content.Name))
if ext == ".yml" || ext == ".yaml" {
content.Path = workflowDir + "/" + content.Name
workflows = append(workflows, content)
}
}
var workflows []*gitea.ActionWorkflow
if resp != nil {
workflows = resp.Workflows
}
if len(workflows) == 0 {
fmt.Printf("No workflow files found\n")
return nil
}
// Check which workflows have runs to determine active status
workflowStatus := make(map[string]bool)
// Get recent runs to check activity
runs, _, err := client.ListRepoActionRuns(c.Owner, c.Repo, gitea.ListRepoActionRunsOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err == nil && runs != nil {
for _, run := range runs.WorkflowRuns {
// Extract workflow file name from path
workflowFile := filepath.Base(run.Path)
workflowStatus[workflowFile] = true
}
}
return print.WorkflowsList(workflows, workflowStatus, c.Output)
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

@@ -39,6 +39,9 @@ var cmdAdminUsers = cli.Command{
},
Commands: []*cli.Command{
&users.CmdUserList,
&users.CmdUserCreate,
&users.CmdUserEdit,
&users.CmdUserDelete,
},
Flags: users.CmdUserList.Flags,
}

220
cmd/admin/users/create.go Normal file
View File

@@ -0,0 +1,220 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package users
import (
stdctx "context"
"fmt"
"io"
"os"
"strings"
"syscall"
"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"
"golang.org/x/term"
)
// CmdUserCreate represents a sub command of users to create a user
var CmdUserCreate = cli.Command{
Name: "create",
Aliases: []string{"add", "new"},
Usage: "Create a new user",
Description: "Create a new user account",
ArgsUsage: " ", // command does not accept arguments
Action: RunUserCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"u"},
Usage: "Username for the new user (required)",
Required: true,
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"p"},
Usage: "Password for the new user (will prompt if not provided)",
},
&cli.StringFlag{
Name: "password-file",
Usage: "Read password from file",
},
&cli.BoolFlag{
Name: "password-stdin",
Usage: "Read password from stdin",
},
&cli.StringFlag{
Name: "email",
Aliases: []string{"e"},
Usage: "Email address for the new user (required)",
Required: true,
},
&cli.StringFlag{
Name: "full-name",
Usage: "Full name for the new user",
},
&cli.BoolFlag{
Name: "admin",
Usage: "Make the user an administrator",
},
&cli.BoolFlag{
Name: "restricted",
Usage: "Make the user restricted",
},
&cli.BoolFlag{
Name: "prohibit-login",
Usage: "Prohibit the user from logging in",
},
&cli.BoolFlag{
Name: "no-must-change-password",
Usage: "Don't require the user to change password on first login (default: password change required)",
},
&cli.StringFlag{
Name: "visibility",
Usage: "Visibility of the user profile (public, limited, private)",
Value: "public",
},
}, flags.AllDefaultFlags...),
}
// RunUserCreate creates a new user
func RunUserCreate(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
username := ctx.String("username")
password := ctx.String("password")
email := ctx.String("email")
fullName := ctx.String("full-name")
isAdmin := ctx.Bool("admin")
restricted := ctx.Bool("restricted")
prohibitLogin := ctx.Bool("prohibit-login")
noMustChangePassword := ctx.Bool("no-must-change-password")
visibility := ctx.String("visibility")
// Get password from various sources in priority order
if password == "" {
if ctx.String("password-file") != "" {
// Read from file
content, err := os.ReadFile(ctx.String("password-file"))
if err != nil {
return fmt.Errorf("failed to read password file: %w", err)
}
password = strings.TrimSpace(string(content))
} else if ctx.Bool("password-stdin") {
// Read from stdin
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read password from stdin: %w", err)
}
password = strings.TrimSpace(string(content))
} else {
// Interactive prompt (hidden input)
fmt.Printf("Enter password for '%s': ", username)
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Println() // Add newline after hidden input
password = string(bytePassword)
if password == "" {
return fmt.Errorf("password cannot be empty")
}
// Confirm password (only for interactive mode)
fmt.Printf("Confirm password for '%s': ", username)
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password confirmation: %w", err)
}
fmt.Println() // Add newline after hidden input
passwordConfirm := string(bytePasswordConfirm)
if password != passwordConfirm {
return fmt.Errorf("passwords do not match")
}
}
}
if password == "" {
return fmt.Errorf("password cannot be empty")
}
if email == "" {
return fmt.Errorf("email is required")
}
client := ctx.Login.Client()
// Build create options
createOpts := gitea.CreateUserOption{
LoginName: username,
Username: username,
Password: password,
Email: email,
FullName: fullName,
SendNotify: false,
}
// Set must change password flag (pointer to bool required)
// By default, require user to change password on first login
// Only set to false if --no-must-change-password flag is explicitly set
mustChangePassword := !noMustChangePassword
createOpts.MustChangePassword = &mustChangePassword
vis, err := parseUserVisibility(visibility)
if err != nil {
return err
}
createOpts.Visibility = vis
// Create the user
user, _, err := client.AdminCreateUser(createOpts)
if err != nil {
return err
}
// Admin, Restricted, and ProhibitLogin cannot be set during user creation
// We need to update them via AdminEditUser after creation if any of these flags are set
if isAdmin || restricted || prohibitLogin {
editOpts := gitea.EditUserOption{
LoginName: username, // Required field
}
if isAdmin {
editOpts.Admin = &isAdmin
}
if restricted {
editOpts.Restricted = &restricted
}
if prohibitLogin {
editOpts.ProhibitLogin = &prohibitLogin
}
// Update user with admin/restricted/prohibit-login settings
_, err = client.AdminEditUser(username, editOpts)
if err != nil {
return fmt.Errorf("user created but failed to update admin/restricted/prohibit-login status: %w", err)
}
// Refresh user info to reflect the changes
user, _, err = client.GetUserInfo(username)
if err != nil {
return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err)
}
}
print.UserDetails(user)
return nil
}

77
cmd/admin/users/delete.go Normal file
View File

@@ -0,0 +1,77 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package users
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdUserDelete represents a sub command of users to delete a user
var CmdUserDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm", "remove"},
Usage: "Delete a user",
Description: "Delete a user account",
ArgsUsage: "<username>",
Action: RunUserDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm deletion without prompting",
},
}, flags.AllDefaultFlags...),
}
// RunUserDelete deletes a user
func RunUserDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if ctx.Args().Len() == 0 {
return fmt.Errorf("username is required")
}
client := ctx.Login.Client()
username := ctx.Args().First()
// Get user details first to show what we're deleting
user, _, err := client.GetUserInfo(username)
if err != nil {
return fmt.Errorf("failed to get user info: %w", err)
}
if !ctx.Bool("confirm") {
userInfo := fmt.Sprintf("%s (ID: %d)", user.UserName, user.ID)
if user.Email != "" {
userInfo += fmt.Sprintf(" - %s", user.Email)
}
if user.IsAdmin {
userInfo += " [admin]"
}
fmt.Printf("Are you sure you want to delete user %s? [y/N] ", userInfo)
var response string
fmt.Scanln(&response)
if !isConfirmationAccepted(response) {
fmt.Println("Deletion canceled.")
return nil
}
}
_, err = client.AdminDeleteUser(username)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
fmt.Printf("User '%s' deleted successfully\n", username)
return nil
}

374
cmd/admin/users/edit.go Normal file
View File

@@ -0,0 +1,374 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package users
import (
stdctx "context"
"fmt"
"io"
"os"
"strings"
"syscall"
"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"
"golang.org/x/term"
)
// CmdUserEdit represents a sub command of users to edit a user
var CmdUserEdit = cli.Command{
Name: "edit",
Aliases: []string{"update", "e", "u"},
Usage: "Edit a user",
Description: "Edit user account properties",
ArgsUsage: "<username>",
Action: RunUserEdit,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "password",
Usage: "New password (use empty value --password=\"\" to trigger interactive prompt)",
Value: "",
},
&cli.StringFlag{
Name: "password-file",
Usage: "Read password from file",
},
&cli.BoolFlag{
Name: "password-stdin",
Usage: "Read password from stdin",
},
&cli.StringFlag{
Name: "email",
Aliases: []string{"e"},
Usage: "Email address",
},
&cli.StringFlag{
Name: "full-name",
Usage: "Full name",
},
&cli.StringFlag{
Name: "description",
Usage: "User description",
},
&cli.StringFlag{
Name: "website",
Usage: "Website URL",
},
&cli.StringFlag{
Name: "location",
Usage: "Location",
},
&cli.BoolFlag{
Name: "admin",
Usage: "Make the user an administrator",
},
&cli.BoolFlag{
Name: "no-admin",
Usage: "Remove administrator status",
},
&cli.BoolFlag{
Name: "restricted",
Usage: "Make the user restricted",
},
&cli.BoolFlag{
Name: "no-restricted",
Usage: "Remove restricted status",
},
&cli.BoolFlag{
Name: "prohibit-login",
Usage: "Prohibit the user from logging in",
},
&cli.BoolFlag{
Name: "allow-login",
Usage: "Allow the user to log in",
},
&cli.BoolFlag{
Name: "active",
Usage: "Activate the user",
},
&cli.BoolFlag{
Name: "inactive",
Usage: "Deactivate the user",
},
&cli.BoolFlag{
Name: "no-must-change-password",
Usage: "Don't require the user to change password on next login (default: password change required)",
},
&cli.StringFlag{
Name: "visibility",
Usage: "Visibility of the user profile (public, limited, private)",
},
&cli.IntFlag{
Name: "max-repo-creation",
Usage: "Maximum number of repositories the user can create (-1 for unlimited)",
},
&cli.BoolFlag{
Name: "allow-git-hook",
Usage: "Allow the user to use git hooks",
},
&cli.BoolFlag{
Name: "no-allow-git-hook",
Usage: "Disallow the user from using git hooks",
},
&cli.BoolFlag{
Name: "allow-import-local",
Usage: "Allow the user to import local repositories",
},
&cli.BoolFlag{
Name: "no-allow-import-local",
Usage: "Disallow the user from importing local repositories",
},
&cli.BoolFlag{
Name: "allow-create-organization",
Usage: "Allow the user to create organizations",
},
&cli.BoolFlag{
Name: "no-allow-create-organization",
Usage: "Disallow the user from creating organizations",
},
}, flags.AllDefaultFlags...),
}
// RunUserEdit edits an existing user
func RunUserEdit(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if ctx.Args().Len() == 0 {
return fmt.Errorf("username is required")
}
client := ctx.Login.Client()
username := ctx.Args().First()
// Verify the user exists before attempting an update.
_, _, err = client.GetUserInfo(username)
if err != nil {
return fmt.Errorf("failed to get user info: %w", err)
}
// Build edit options, starting with required LoginName
editOpts := gitea.EditUserOption{
LoginName: username,
}
// Update email if set
if ctx.IsSet("email") {
email := ctx.String("email")
editOpts.Email = &email
}
// Update full name if set
if ctx.IsSet("full-name") {
fullName := ctx.String("full-name")
editOpts.FullName = &fullName
}
// Update description if set
if ctx.IsSet("description") {
description := ctx.String("description")
editOpts.Description = &description
}
// Update website if set
if ctx.IsSet("website") {
website := ctx.String("website")
editOpts.Website = &website
}
// Update location if set
if ctx.IsSet("location") {
location := ctx.String("location")
editOpts.Location = &location
}
// Handle admin status
if ctx.IsSet("admin") {
admin := ctx.Bool("admin")
editOpts.Admin = &admin
} else if ctx.IsSet("no-admin") {
admin := false
editOpts.Admin = &admin
}
// Handle restricted status
if ctx.IsSet("restricted") {
restricted := ctx.Bool("restricted")
editOpts.Restricted = &restricted
} else if ctx.IsSet("no-restricted") {
restricted := false
editOpts.Restricted = &restricted
}
// Handle prohibit login status
if ctx.IsSet("prohibit-login") {
prohibitLogin := ctx.Bool("prohibit-login")
editOpts.ProhibitLogin = &prohibitLogin
} else if ctx.IsSet("allow-login") {
prohibitLogin := false
editOpts.ProhibitLogin = &prohibitLogin
}
// Handle active status
if ctx.IsSet("active") {
active := ctx.Bool("active")
editOpts.Active = &active
} else if ctx.IsSet("inactive") {
active := false
editOpts.Active = &active
}
// Handle must change password - will be set when password is changed unless flag is set
// Handle visibility
if ctx.IsSet("visibility") {
vis, err := parseUserVisibility(ctx.String("visibility"))
if err != nil {
return err
}
editOpts.Visibility = vis
}
// Handle max repo creation
if ctx.IsSet("max-repo-creation") {
maxRepoCreation := ctx.Int("max-repo-creation")
editOpts.MaxRepoCreation = &maxRepoCreation
}
// Handle allow git hook
if ctx.IsSet("allow-git-hook") {
allowGitHook := ctx.Bool("allow-git-hook")
editOpts.AllowGitHook = &allowGitHook
} else if ctx.IsSet("no-allow-git-hook") {
allowGitHook := false
editOpts.AllowGitHook = &allowGitHook
}
// Handle allow import local
if ctx.IsSet("allow-import-local") {
allowImportLocal := ctx.Bool("allow-import-local")
editOpts.AllowImportLocal = &allowImportLocal
} else if ctx.IsSet("no-allow-import-local") {
allowImportLocal := false
editOpts.AllowImportLocal = &allowImportLocal
}
// Handle allow create organization
if ctx.IsSet("allow-create-organization") {
allowCreateOrg := ctx.Bool("allow-create-organization")
editOpts.AllowCreateOrganization = &allowCreateOrg
} else if ctx.IsSet("no-allow-create-organization") {
allowCreateOrg := false
editOpts.AllowCreateOrganization = &allowCreateOrg
}
// Handle password if any password flag is set or if password flag was provided (even without value)
shouldChangePassword := ctx.IsSet("password") || ctx.IsSet("password-file") || ctx.Bool("password-stdin")
if shouldChangePassword {
password := ctx.String("password")
// Get password from various sources in priority order
if password == "" {
if ctx.IsSet("password-file") && ctx.String("password-file") != "" {
// Read from file
content, err := os.ReadFile(ctx.String("password-file"))
if err != nil {
return fmt.Errorf("failed to read password file: %w", err)
}
password = strings.TrimSpace(string(content))
} else if ctx.Bool("password-stdin") {
// Read from stdin
content, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("failed to read password from stdin: %w", err)
}
password = strings.TrimSpace(string(content))
} else {
// Interactive prompt (hidden input) - triggered when --password is used without value
fmt.Printf("Enter new password for '%s': ", username)
bytePassword, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Println() // Add newline after hidden input
password = string(bytePassword)
if password == "" {
return fmt.Errorf("password cannot be empty")
}
// Confirm password (only for interactive mode)
fmt.Printf("Confirm new password for '%s': ", username)
bytePasswordConfirm, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read password confirmation: %w", err)
}
fmt.Println() // Add newline after hidden input
passwordConfirm := string(bytePasswordConfirm)
if password != passwordConfirm {
return fmt.Errorf("passwords do not match")
}
}
}
if password == "" {
return fmt.Errorf("password cannot be empty")
}
editOpts.Password = password
// When password is changed, require user to change password on next login by default
// Only set to false if --no-must-change-password flag is explicitly set
if !ctx.IsSet("no-must-change-password") {
mustChangePassword := true
editOpts.MustChangePassword = &mustChangePassword
} else {
mustChangePassword := false
editOpts.MustChangePassword = &mustChangePassword
}
}
// Only proceed with update if at least one field is being modified
hasChanges := editOpts.Email != nil ||
editOpts.FullName != nil ||
editOpts.Description != nil ||
editOpts.Website != nil ||
editOpts.Location != nil ||
editOpts.Admin != nil ||
editOpts.Restricted != nil ||
editOpts.ProhibitLogin != nil ||
editOpts.Active != nil ||
editOpts.Visibility != nil ||
editOpts.MaxRepoCreation != nil ||
editOpts.AllowGitHook != nil ||
editOpts.AllowImportLocal != nil ||
editOpts.AllowCreateOrganization != nil ||
editOpts.Password != ""
if !hasChanges {
return fmt.Errorf("no changes specified")
}
// Update the user
_, err = client.AdminEditUser(username, editOpts)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
// Refresh user info to reflect the changes
updatedUser, _, err := client.GetUserInfo(username)
if err != nil {
return fmt.Errorf("user updated but failed to retrieve updated user info: %w", err)
}
print.UserDetails(updatedUser)
return nil
}

32
cmd/admin/users/shared.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package users
import (
"fmt"
"strings"
"code.gitea.io/sdk/gitea"
)
func parseUserVisibility(visibility string) (*gitea.VisibleType, error) {
switch visibility {
case "public":
vis := gitea.VisibleTypePublic
return &vis, nil
case "limited":
vis := gitea.VisibleTypeLimited
return &vis, nil
case "private":
vis := gitea.VisibleTypePrivate
return &vis, nil
default:
return nil, fmt.Errorf("invalid visibility: %s (must be public, limited, or private)", visibility)
}
}
func isConfirmationAccepted(response string) bool {
trimmed := strings.TrimSpace(response)
return strings.EqualFold(trimmed, "y") || strings.EqualFold(trimmed, "yes")
}

View File

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

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/releases"
"code.gitea.io/tea/modules/context"
"code.gitea.io/sdk/gitea"
@@ -42,12 +43,12 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
client := ctx.Login.Client()
if ctx.Args().Len() < 2 {
return fmt.Errorf("No release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText)
return fmt.Errorf("no release tag or attachment names specified.\nUsage:\t%s", ctx.Command.UsageText)
}
tag := ctx.Args().First()
if len(tag) == 0 {
return fmt.Errorf("Release tag needed to delete attachment")
return fmt.Errorf("release tag needed to delete attachment")
}
if !ctx.Bool("confirm") {
@@ -55,16 +56,24 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
return nil
}
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil {
return err
}
existing, _, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return err
var existing []*gitea.Attachment
for page := 1; ; {
page_attachments, resp, err := client.ListReleaseAttachments(ctx.Owner, ctx.Repo, release.ID, gitea.ListReleaseAttachmentsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return err
}
existing = append(existing, page_attachments...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
for _, name := range ctx.Args().Slice()[1:] {
@@ -75,7 +84,7 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
}
}
if attachment == nil {
return fmt.Errorf("Release does not have attachment named '%s'", name)
return fmt.Errorf("release does not have attachment named '%s'", name)
}
_, err = client.DeleteReleaseAttachment(ctx.Owner, ctx.Repo, release.ID, attachment.ID)

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/releases"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
@@ -42,10 +43,10 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
tag := ctx.Args().First()
if len(tag) == 0 {
return fmt.Errorf("Release tag needed to list attachments")
return fmt.Errorf("release tag needed to list attachments")
}
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil {
return err
}
@@ -59,21 +60,3 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
return print.ReleaseAttachmentsList(attachments, ctx.Output)
}
func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
if len(rl) == 0 {
return nil, fmt.Errorf("Repo does not have any release")
}
for _, r := range rl {
if r.TagName == tag {
return r, nil
}
}
return nil, fmt.Errorf("Release tag does not exist")
}

View File

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

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

@@ -76,9 +76,13 @@ func runRepoClone(ctx stdctx.Context, cmd *cli.Command) error {
owner, repo = utils.GetOwnerAndRepo(url.Path, login.User)
if url.Host != "" {
login = config.GetLoginByHost(url.Host)
var lookupErr error
login, lookupErr = config.GetLoginByHost(url.Host)
if lookupErr != nil {
return lookupErr
}
if login == nil {
return fmt.Errorf("No login configured matching host '%s', run `tea login add` first", url.Host)
return fmt.Errorf("no login configured matching host '%s', run 'tea login add' first", url.Host)
}
debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host)
}

View File

@@ -45,6 +45,8 @@ func App() *cli.Command {
&CmdNotifications,
&CmdRepoClone,
&CmdSSHKeys,
&CmdAdmin,
&CmdApi,

View File

@@ -46,7 +46,7 @@ func runAddComment(_ stdctx.Context, cmd *cli.Command) error {
args := ctx.Args()
if args.Len() == 0 {
return fmt.Errorf("Please specify issue / pr index")
return fmt.Errorf("please specify issue / pr index")
}
idx, err := utils.ArgToIndex(ctx.Args().First())

View File

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

View File

@@ -5,6 +5,7 @@ package flags
import (
"errors"
"fmt"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3"
@@ -167,7 +168,7 @@ func ParseState(stateStr string) (gitea.StateType, error) {
case "closed":
return gitea.StateClosed, nil
default:
return "", errors.New("unknown state '" + stateStr + "'")
return "", fmt.Errorf("unknown state '%s'", stateStr)
}
}
@@ -184,6 +185,6 @@ func ParseIssueKind(kindStr string, defaultKind gitea.IssueType) (gitea.IssueTyp
case "pull", "pulls", "pr":
return gitea.IssueTypePull, nil
default:
return "", errors.New("unknown kind '" + kindStr + "'")
return "", fmt.Errorf("unknown kind '%s'", kindStr)
}
}

View File

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

View File

@@ -5,6 +5,7 @@ package issues
import (
stdctx "context"
"errors"
"time"
"code.gitea.io/tea/cmd/flags"
@@ -77,7 +78,7 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
Type: kind,
KeyWord: ctx.String("keyword"),
CreatedBy: ctx.String("author"),
AssignedBy: ctx.String("assigned-to"),
AssignedBy: ctx.String("assignee"),
MentionedBy: ctx.String("mentions"),
Labels: labels,
Milestones: milestones,
@@ -88,13 +89,15 @@ func RunIssuesList(_ stdctx.Context, cmd *cli.Command) error {
return err
}
} else {
if ctx.IsSet("assignee") {
return errors.New("--assignee requires --repo (global issue search does not support assignee filter)")
}
issues, _, err = ctx.Login.Client().ListIssues(gitea.ListIssueOption{
ListOptions: flags.GetListOptions(cmd),
State: state,
Type: kind,
KeyWord: ctx.String("keyword"),
CreatedBy: ctx.String("author"),
AssignedBy: ctx.String("assigned-to"),
MentionedBy: ctx.String("mentions"),
Labels: labels,
Milestones: milestones,

View File

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

View File

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

View File

@@ -5,12 +5,13 @@ package login
import (
"context"
"log"
"fmt"
"os"
"os/exec"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/utils"
"github.com/skratchdot/open-golang/open"
"github.com/urfave/cli/v3"
@@ -28,14 +29,17 @@ var CmdLoginEdit = cli.Command{
}
func runLoginEdit(_ context.Context, _ *cli.Command) error {
ymlPath := config.GetConfigPath()
if e, ok := os.LookupEnv("EDITOR"); ok && e != "" {
cmd := exec.Command(e, config.GetConfigPath())
cmd := exec.Command(e, ymlPath)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Fatal(err.Error())
}
return cmd.Run()
}
return open.Start(config.GetConfigPath())
if exist, _ := utils.FileExist(ymlPath); !exist {
fmt.Printf("Config file does not exist, please run login add first\n")
return nil
}
return open.Start(ymlPath)
}

View File

@@ -7,7 +7,6 @@ import (
"bufio"
"context"
"fmt"
"log"
"net/url"
"os"
"strings"
@@ -93,7 +92,7 @@ var CmdLoginHelper = cli.Command{
}
if len(wants["host"]) == 0 {
log.Fatal("Hostname is required")
return fmt.Errorf("hostname is required")
} else if len(wants["protocol"]) == 0 {
wants["protocol"] = "http"
}
@@ -104,20 +103,24 @@ var CmdLoginHelper = cli.Command{
var lookupErr error
userConfig, lookupErr = config.GetLoginByName(loginName)
if lookupErr != nil {
log.Fatal(lookupErr)
return lookupErr
}
if userConfig == nil {
log.Fatalf("Login '%s' not found", loginName)
return fmt.Errorf("login '%s' not found", loginName)
}
} else {
userConfig = config.GetLoginByHost(wants["host"])
var lookupErr error
userConfig, lookupErr = config.GetLoginByHost(wants["host"])
if lookupErr != nil {
return lookupErr
}
if userConfig == nil {
log.Fatalf("No login found for host '%s'", wants["host"])
return fmt.Errorf("no login found for host '%s'", wants["host"])
}
}
if len(userConfig.GetAccessToken()) == 0 {
log.Fatal("User not set")
return fmt.Errorf("user not set")
}
host, err := url.Parse(userConfig.URL)

View File

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

View File

@@ -4,14 +4,14 @@
package organizations
import (
"fmt"
stdctx "context"
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
@@ -25,7 +25,7 @@ var CmdOrganizationCreate = cli.Command{
ArgsUsage: "<organization name>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Name: "full-name",
Aliases: []string{"n"},
},
&cli.StringFlag{
@@ -75,8 +75,8 @@ func RunOrganizationCreate(_ stdctx.Context, cmd *cli.Command) error {
}
org, _, err := ctx.Login.Client().CreateOrg(gitea.CreateOrgOption{
Name: ctx.Args().First(),
// FullName: , // not really meaningful for orgs (not displayed in webui, use description instead?)
Name: ctx.Args().First(),
FullName: ctx.String("full-name"),
Description: ctx.String("description"),
Website: ctx.String("website"),
Location: ctx.String("location"),

View File

@@ -77,6 +77,9 @@ var CmdPulls = cli.Command{
&pulls.CmdPullsApprove,
&pulls.CmdPullsReject,
&pulls.CmdPullsMerge,
&pulls.CmdPullsReviewComments,
&pulls.CmdPullsResolve,
&pulls.CmdPullsUnresolve,
},
}
@@ -106,11 +109,20 @@ func runPullDetail(_ stdctx.Context, cmd *cli.Command, index string) error {
return err
}
reviews, _, err := client.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
fmt.Printf("error while loading reviews: %v\n", 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
}
if ctx.IsSet("output") {

View File

@@ -5,6 +5,8 @@ package pulls
import (
stdctx "context"
"fmt"
"slices"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
@@ -43,7 +45,8 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
return err
}
prs, _, err := ctx.Login.Client().ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{
client := ctx.Login.Client()
prs, _, err := client.ListRepoPullRequests(ctx.Owner, ctx.Repo, gitea.ListPullRequestsOptions{
ListOptions: flags.GetListOptions(cmd),
State: state,
})
@@ -56,5 +59,21 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
return err
}
return print.PullsList(prs, ctx.Output, fields)
var ciStatuses map[int64]*gitea.CombinedStatus
if slices.Contains(fields, "ci") {
ciStatuses = map[int64]*gitea.CombinedStatus{}
for _, pr := range prs {
if pr.Head == nil || pr.Head.Sha == "" {
continue
}
ci, _, err := client.GetCombinedStatus(ctx.Owner, ctx.Repo, pr.Head.Sha)
if err != nil {
fmt.Printf("error fetching CI status for PR #%d: %v\n", pr.Index, err)
continue
}
ciStatuses[pr.Index] = ci
}
}
return print.PullsList(prs, ctx.Output, fields, ciStatuses)
}

30
cmd/pulls/resolve.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdPullsResolve resolves a review comment on a pull request
var CmdPullsResolve = cli.Command{
Name: "resolve",
Usage: "Resolve a review comment on a pull request",
Description: "Resolve a review comment on a pull request",
ArgsUsage: "<comment id>",
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
return runResolveComment(ctx, task.ResolvePullReviewComment)
},
Flags: flags.AllDefaultFlags,
}

View File

@@ -6,10 +6,12 @@ package pulls
import (
stdctx "context"
"fmt"
"os"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
@@ -30,18 +32,27 @@ var CmdPullsReview = cli.Command{
return err
}
if ctx.Args().Len() != 1 {
return fmt.Errorf("must specify a PR index")
if !ctx.Args().Present() {
return fmt.Errorf("must specify at least one PR index")
}
idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil {
return err
// This command is intentionally interactive. Fail early in CI / non-TTY
// contexts rather than hanging on prompts.
if os.Getenv("CI") != "" || !print.IsInteractive() || interact.IsStdinPiped() {
return fmt.Errorf("pull review requires an interactive terminal")
}
if err := interact.ReviewPull(ctx, idx); err != nil && !interact.IsQuitting(err) {
return err
for _, arg := range ctx.Args().Slice() {
idx, err := utils.ArgToIndex(arg)
if err != nil {
return err
}
if err := interact.ReviewPull(ctx, idx); err != nil && !interact.IsQuitting(err) {
return err
}
}
return nil
},
Flags: flags.AllDefaultFlags,

View File

@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3"
)
var reviewCommentFieldsFlag = flags.FieldsFlag(print.PullReviewCommentFields, []string{
"id", "path", "line", "body", "reviewer", "resolver",
})
// CmdPullsReviewComments lists review comments on a pull request
var CmdPullsReviewComments = cli.Command{
Name: "review-comments",
Aliases: []string{"rc"},
Usage: "List review comments on a pull request",
Description: "List review comments on a pull request",
ArgsUsage: "<pull index>",
Action: runPullsReviewComments,
Flags: append([]cli.Flag{reviewCommentFieldsFlag}, flags.AllDefaultFlags...),
}
func runPullsReviewComments(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() < 1 {
return fmt.Errorf("pull request index is required")
}
idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil {
return err
}
comments, err := task.ListPullReviewComments(ctx, idx)
if err != nil {
return err
}
fields, err := reviewCommentFieldsFlag.GetValues(cmd)
if err != nil {
return err
}
return print.PullReviewCommentsList(comments, ctx.Output, fields)
}

View File

@@ -40,3 +40,21 @@ func runPullReview(ctx *context.TeaContext, state gitea.ReviewStateType, require
return task.CreatePullReview(ctx, idx, state, comment, nil)
}
// runResolveComment handles the common logic for resolving/unresolving review comments
func runResolveComment(ctx *context.TeaContext, action func(*context.TeaContext, int64) error) error {
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() < 1 {
return fmt.Errorf("comment ID is required")
}
commentID, err := utils.ArgToIndex(ctx.Args().First())
if err != nil {
return err
}
return action(ctx, commentID)
}

62
cmd/pulls/review_test.go Normal file
View File

@@ -0,0 +1,62 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestReview(t *testing.T) {
if os.Getenv("GITEA_TEA_TEST_URL") == "" {
t.Skip("GITEA_TEA_TEST_URL is not set, skipping test")
}
tests := []struct {
name string
args []string
wantErr bool
errContains string
}{
{
name: "no arguments",
args: []string{},
wantErr: true,
errContains: "must specify at least one PR index",
},
{
name: "one argument",
args: []string{"1"},
wantErr: false,
},
{
name: "two arguments",
args: []string{"1", "2"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := CmdPullsReview
args := append(tt.args, "--repo", "user/repo")
err := cmd.Run(context.Background(), args)
if tt.wantErr {
assert.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}
// Don't assert no error, because we expect an error about the missing
// remote. Just assert that the error is not the one we're looking for.
if err != nil {
assert.NotContains(t, err.Error(), "must specify at least one PR index")
}
})
}
}

30
cmd/pulls/unresolve.go Normal file
View File

@@ -0,0 +1,30 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdPullsUnresolve unresolves a review comment on a pull request
var CmdPullsUnresolve = cli.Command{
Name: "unresolve",
Usage: "Unresolve a review comment on a pull request",
Description: "Unresolve a review comment on a pull request",
ArgsUsage: "<comment id>",
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
return runResolveComment(ctx, task.UnresolvePullReviewComment)
},
Flags: flags.AllDefaultFlags,
}

View File

@@ -104,7 +104,7 @@ func runReleaseCreate(_ stdctx.Context, cmd *cli.Command) error {
})
if err != nil {
if resp != nil && resp.StatusCode == http.StatusConflict {
return fmt.Errorf("There already is a release for this tag")
return fmt.Errorf("there is already a release for this tag")
}
return err
}

View File

@@ -55,7 +55,7 @@ func runReleaseDelete(_ stdctx.Context, cmd *cli.Command) error {
}
for _, tag := range ctx.Args().Slice() {
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
release, err := GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil {
return err
}

View File

@@ -81,7 +81,7 @@ func runReleaseEdit(_ stdctx.Context, cmd *cli.Command) error {
}
for _, tag := range ctx.Args().Slice() {
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
release, err := GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil {
return err
}

View File

@@ -5,7 +5,6 @@ package releases
import (
stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
@@ -48,21 +47,3 @@ func RunReleasesList(_ stdctx.Context, cmd *cli.Command) error {
return print.ReleasesList(releases, ctx.Output)
}
func getReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
if len(rl) == 0 {
return nil, fmt.Errorf("Repo does not have any release")
}
for _, r := range rl {
if r.TagName == tag {
return r, nil
}
}
return nil, fmt.Errorf("Release tag does not exist")
}

35
cmd/releases/utils.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package releases
import (
"fmt"
"code.gitea.io/sdk/gitea"
)
// GetReleaseByTag finds a release by its tag name.
func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
for page := 1; ; {
rl, resp, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
if page == 1 && len(rl) == 0 {
return nil, fmt.Errorf("repo does not have any release")
}
for _, r := range rl {
if r.TagName == tag {
return r, nil
}
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
return nil, fmt.Errorf("release tag does not exist")
}

View File

@@ -15,13 +15,13 @@ import (
"github.com/urfave/cli/v3"
)
// CmdRepos represents to login a gitea server.
// CmdRepos represents the command to manage repositories.
var CmdRepos = cli.Command{
Name: "repos",
Aliases: []string{"repo"},
Category: catEntities,
Usage: "Show repository details",
Description: "Show repository details",
Usage: "Manage repositories",
Description: "Manage repositories",
ArgsUsage: "[<repo owner>/<repo name>]",
Action: runRepos,
Commands: []*cli.Command{

View File

@@ -70,7 +70,7 @@ func runReposSearch(_ stdctx.Context, cmd *cli.Command) error {
org, resp, err := client.GetOrg(teaCmd.String("owner"))
if err != nil {
if resp == nil || resp.StatusCode != http.StatusNotFound {
return fmt.Errorf("Could not find owner: %w", err)
return fmt.Errorf("could not find owner: %w", err)
}
// if owner is no org, its a user

32
cmd/sshkeys.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"code.gitea.io/tea/cmd/sshkeys"
"github.com/urfave/cli/v3"
)
// CmdSSHKeys represents the ssh-keys command group
var CmdSSHKeys = cli.Command{
Name: "ssh-keys",
Aliases: []string{"ssh-key"},
Category: catSetup,
Usage: "Manage SSH public keys",
Description: "List, add, or delete SSH public keys on the current user's account",
ArgsUsage: " ",
Action: runSSHKeys,
Commands: []*cli.Command{
&sshkeys.CmdSSHKeyList,
&sshkeys.CmdSSHKeyAdd,
&sshkeys.CmdSSHKeyDelete,
},
Flags: sshkeys.CmdSSHKeyList.Flags,
}
func runSSHKeys(ctx stdctx.Context, cmd *cli.Command) error {
return sshkeys.RunSSHKeyList(ctx, cmd)
}

74
cmd/sshkeys/add.go Normal file
View File

@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sshkeys
import (
stdctx "context"
"fmt"
"os"
"path/filepath"
"strings"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdSSHKeyAdd represents a sub command of ssh-keys to add an SSH public key
var CmdSSHKeyAdd = cli.Command{
Name: "add",
Usage: "Add an SSH public key",
Description: "Add an SSH public key to the current user's profile",
ArgsUsage: "<key-file>",
Action: RunSSHKeyAdd,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "title",
Aliases: []string{"t"},
Usage: "Title for the key (defaults to the filename without extension)",
},
}, flags.LoginOutputFlags...),
}
// RunSSHKeyAdd reads a public key file and registers it with the Gitea instance
func RunSSHKeyAdd(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if ctx.Args().Len() < 1 {
return fmt.Errorf("key file path is required")
}
keyFile := ctx.Args().First()
keyBytes, err := os.ReadFile(keyFile)
if err != nil {
return fmt.Errorf("could not read key file '%s': %w", keyFile, err)
}
keyContent := strings.TrimSpace(string(keyBytes))
if keyContent == "" {
return fmt.Errorf("key file '%s' is empty", keyFile)
}
title := ctx.String("title")
if title == "" {
base := filepath.Base(keyFile)
title = strings.TrimSuffix(base, filepath.Ext(base))
}
key, _, err := ctx.Login.Client().CreatePublicKey(gitea.CreateKeyOption{
Title: title,
Key: keyContent,
})
if err != nil {
return err
}
fmt.Printf("Key '%s' (id: %d) added successfully.\n", key.Title, key.ID)
return nil
}

33
cmd/sshkeys/add_test.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sshkeys
import (
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestKeyTitleFromFilename(t *testing.T) {
cases := []struct {
input string
expected string
}{
{"id_ed25519.pub", "id_ed25519"},
{"id_rsa.pub", "id_rsa"},
{"/home/user/.ssh/id_ed25519.pub", "id_ed25519"},
{"mykey", "mykey"}, // no extension
{"my.key.pub", "my.key"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
base := filepath.Base(tc.input)
title := strings.TrimSuffix(base, filepath.Ext(base))
assert.Equal(t, tc.expected, title)
})
}
}

76
cmd/sshkeys/delete.go Normal file
View File

@@ -0,0 +1,76 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sshkeys
import (
stdctx "context"
"fmt"
"strconv"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdSSHKeyDelete represents a sub command of ssh-keys to delete an SSH key by ID
var CmdSSHKeyDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "Delete an SSH key",
Description: "Delete an SSH key from the current user's profile by its numeric ID",
ArgsUsage: "<key-id>",
Action: RunSSHKeyDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "Confirm deletion (required)",
},
}, flags.LoginOutputFlags...),
}
// RunSSHKeyDelete removes an SSH key by its numeric ID
func RunSSHKeyDelete(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
if ctx.Args().Len() < 1 {
return fmt.Errorf("key ID is required")
}
keyID, err := strconv.ParseInt(ctx.Args().First(), 10, 64)
if err != nil {
return fmt.Errorf("invalid key ID '%s': must be a number", ctx.Args().First())
}
client := ctx.Login.Client()
key, resp, err := client.GetPublicKey(keyID)
if err != nil {
if resp != nil && resp.StatusCode == 404 {
return fmt.Errorf("SSH key with ID %d not found", keyID)
}
return err
}
if !ctx.Bool("confirm") {
fmt.Printf("Are you sure you want to delete SSH key '%s' (id: %d)? [y/N] ", key.Title, keyID)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.")
return nil
}
}
if _, err = client.DeletePublicKey(keyID); err != nil {
return err
}
fmt.Printf("SSH key '%s' deleted successfully\n", key.Title)
return nil
}

47
cmd/sshkeys/list.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sshkeys
import (
stdctx "context"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdSSHKeyList represents a sub command of ssh-keys to list the current user's SSH keys
var CmdSSHKeyList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List SSH keys",
Description: "List the SSH keys registered for the current user",
ArgsUsage: " ", // command does not accept arguments
Action: RunSSHKeyList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
}, flags.LoginOutputFlags...),
}
// RunSSHKeyList lists SSH keys for the current user
func RunSSHKeyList(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client()
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
ListOptions: flags.GetListOptions(cmd),
})
if err != nil {
return err
}
return print.SSHKeysList(keys, ctx.Output)
}

View File

@@ -41,7 +41,7 @@ func runTrackedTimesAdd(_ stdctx.Context, cmd *cli.Command) error {
}
if ctx.Args().Len() < 2 {
return fmt.Errorf("No issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText)
return fmt.Errorf("no issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText)
}
issue, err := utils.ArgToIndex(ctx.Args().First())

View File

@@ -36,7 +36,7 @@ func runTrackedTimesDelete(_ stdctx.Context, cmd *cli.Command) error {
client := ctx.Login.Client()
if ctx.Args().Len() < 2 {
return fmt.Errorf("No issue or time ID specified.\nUsage:\t%s", ctx.Command.UsageText)
return fmt.Errorf("no issue or time ID specified.\nUsage:\t%s", ctx.Command.UsageText)
}
issue, err := utils.ArgToIndex(ctx.Args().First())

View File

@@ -35,7 +35,7 @@ func runTrackedTimesReset(_ stdctx.Context, cmd *cli.Command) error {
client := ctx.Login.Client()
if ctx.Args().Len() != 1 {
return fmt.Errorf("No issue specified.\nUsage:\t%s", ctx.Command.UsageText)
return fmt.Errorf("no issue specified.\nUsage:\t%s", ctx.Command.UsageText)
}
issue, err := utils.ArgToIndex(ctx.Args().First())

View File

@@ -89,30 +89,26 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error {
config["secret"] = secret
}
if branchFilter != "" {
config["branch_filter"] = branchFilter
}
if authHeader != "" {
config["authorization_header"] = authHeader
}
var hook *gitea.Hook
if c.IsGlobal {
return fmt.Errorf("global webhooks not yet supported in this version")
} else if len(c.Org) > 0 {
hook, _, err = client.CreateOrgHook(c.Org, gitea.CreateHookOption{
Type: webhookType,
Config: config,
Events: events,
Active: active,
Type: webhookType,
Config: config,
Events: events,
Active: active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
})
} else {
hook, _, err = client.CreateRepoHook(c.Owner, c.Repo, gitea.CreateHookOption{
Type: webhookType,
Config: config,
Events: events,
Active: active,
Type: webhookType,
Config: config,
Events: events,
Active: active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
})
}
if err != nil {

View File

@@ -79,8 +79,6 @@ func TestWebhookConfigConstruction(t *testing.T) {
name string
url string
secret string
branchFilter string
authHeader string
expectedKeys []string
expectedValues map[string]string
}{
@@ -106,44 +104,16 @@ func TestWebhookConfigConstruction(t *testing.T) {
"secret": "my-secret",
},
},
{
name: "Config with branch filter",
url: "https://example.com/webhook",
branchFilter: "main,develop",
expectedKeys: []string{"url", "http_method", "content_type", "branch_filter"},
expectedValues: map[string]string{
"url": "https://example.com/webhook",
"http_method": "post",
"content_type": "json",
"branch_filter": "main,develop",
},
},
{
name: "Config with auth header",
url: "https://example.com/webhook",
authHeader: "Bearer token123",
expectedKeys: []string{"url", "http_method", "content_type", "authorization_header"},
expectedValues: map[string]string{
"url": "https://example.com/webhook",
"http_method": "post",
"content_type": "json",
"authorization_header": "Bearer token123",
},
},
{
name: "Complete config",
url: "https://example.com/webhook",
secret: "secret123",
branchFilter: "main",
authHeader: "X-Token: abc",
expectedKeys: []string{"url", "http_method", "content_type", "secret", "branch_filter", "authorization_header"},
expectedKeys: []string{"url", "http_method", "content_type", "secret"},
expectedValues: map[string]string{
"url": "https://example.com/webhook",
"http_method": "post",
"content_type": "json",
"secret": "secret123",
"branch_filter": "main",
"authorization_header": "X-Token: abc",
"url": "https://example.com/webhook",
"http_method": "post",
"content_type": "json",
"secret": "secret123",
},
},
}
@@ -159,12 +129,6 @@ func TestWebhookConfigConstruction(t *testing.T) {
if tt.secret != "" {
config["secret"] = tt.secret
}
if tt.branchFilter != "" {
config["branch_filter"] = tt.branchFilter
}
if tt.authHeader != "" {
config["authorization_header"] = tt.authHeader
}
// Check all expected keys exist
for _, key := range tt.expectedKeys {
@@ -184,11 +148,13 @@ func TestWebhookConfigConstruction(t *testing.T) {
func TestWebhookCreateOptions(t *testing.T) {
tests := []struct {
name string
webhookType string
events []string
active bool
config map[string]string
name string
webhookType string
events []string
active bool
config map[string]string
branchFilter string
authHeader string
}{
{
name: "Gitea webhook",
@@ -200,6 +166,8 @@ func TestWebhookCreateOptions(t *testing.T) {
"http_method": "post",
"content_type": "json",
},
branchFilter: "main",
authHeader: "X-Token: abc",
},
{
name: "Slack webhook",
@@ -228,16 +196,20 @@ func TestWebhookCreateOptions(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
option := gitea.CreateHookOption{
Type: gitea.HookType(tt.webhookType),
Config: tt.config,
Events: tt.events,
Active: tt.active,
Type: gitea.HookType(tt.webhookType),
Config: tt.config,
Events: tt.events,
Active: tt.active,
BranchFilter: tt.branchFilter,
AuthorizationHeader: tt.authHeader,
}
assert.Equal(t, gitea.HookType(tt.webhookType), option.Type)
assert.Equal(t, tt.events, option.Events)
assert.Equal(t, tt.active, option.Active)
assert.Equal(t, tt.config, option.Config)
assert.Equal(t, tt.branchFilter, option.BranchFilter)
assert.Equal(t, tt.authHeader, option.AuthorizationHeader)
})
}
}

View File

@@ -97,11 +97,14 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.IsSet("secret") {
config["secret"] = cmd.String("secret")
}
branchFilter := hook.BranchFilter
if cmd.IsSet("branch-filter") {
config["branch_filter"] = cmd.String("branch-filter")
branchFilter = cmd.String("branch-filter")
}
authHeader := hook.AuthorizationHeader
if cmd.IsSet("authorization-header") {
config["authorization_header"] = cmd.String("authorization-header")
authHeader = cmd.String("authorization-header")
}
// Update events if specified
@@ -126,15 +129,19 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("global webhooks not yet supported in this version")
} else if len(c.Org) > 0 {
_, err = client.EditOrgHook(c.Org, int64(webhookID), gitea.EditHookOption{
Config: config,
Events: events,
Active: &active,
Config: config,
Events: events,
Active: &active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
})
} else {
_, err = client.EditRepoHook(c.Owner, c.Repo, int64(webhookID), gitea.EditHookOption{
Config: config,
Events: events,
Active: &active,
Config: config,
Events: events,
Active: &active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
})
}
if err != nil {

View File

@@ -128,12 +128,10 @@ func TestUpdateActiveInactiveFlags(t *testing.T) {
func TestUpdateConfigPreservation(t *testing.T) {
// Test that existing configuration is preserved when not updated
originalConfig := map[string]string{
"url": "https://old.example.com/webhook",
"secret": "old-secret",
"branch_filter": "main",
"authorization_header": "Bearer old-token",
"http_method": "post",
"content_type": "json",
"url": "https://old.example.com/webhook",
"secret": "old-secret",
"http_method": "post",
"content_type": "json",
}
tests := []struct {
@@ -147,53 +145,32 @@ func TestUpdateConfigPreservation(t *testing.T) {
"url": "https://new.example.com/webhook",
},
expectedConfig: map[string]string{
"url": "https://new.example.com/webhook",
"secret": "old-secret",
"branch_filter": "main",
"authorization_header": "Bearer old-token",
"http_method": "post",
"content_type": "json",
"url": "https://new.example.com/webhook",
"secret": "old-secret",
"http_method": "post",
"content_type": "json",
},
},
{
name: "Update secret and auth header",
name: "Update secret",
updates: map[string]string{
"secret": "new-secret",
"authorization_header": "X-Token: new-token",
"secret": "new-secret",
},
expectedConfig: map[string]string{
"url": "https://old.example.com/webhook",
"secret": "new-secret",
"branch_filter": "main",
"authorization_header": "X-Token: new-token",
"http_method": "post",
"content_type": "json",
},
},
{
name: "Clear branch filter",
updates: map[string]string{
"branch_filter": "",
},
expectedConfig: map[string]string{
"url": "https://old.example.com/webhook",
"secret": "old-secret",
"branch_filter": "",
"authorization_header": "Bearer old-token",
"http_method": "post",
"content_type": "json",
"url": "https://old.example.com/webhook",
"secret": "new-secret",
"http_method": "post",
"content_type": "json",
},
},
{
name: "No updates",
updates: map[string]string{},
expectedConfig: map[string]string{
"url": "https://old.example.com/webhook",
"secret": "old-secret",
"branch_filter": "main",
"authorization_header": "Bearer old-token",
"http_method": "post",
"content_type": "json",
"url": "https://old.example.com/webhook",
"secret": "old-secret",
"http_method": "post",
"content_type": "json",
},
},
}
@@ -217,6 +194,61 @@ func TestUpdateConfigPreservation(t *testing.T) {
}
}
func TestUpdateBranchFilterAndAuthHeaderHandling(t *testing.T) {
tests := []struct {
name string
originalBranchFilter string
originalAuthHeader string
setBranchFilter bool
newBranchFilter string
setAuthorizationHeader bool
newAuthHeader string
expectedBranchFilter string
expectedAuthHeader string
}{
{
name: "Preserve values",
originalBranchFilter: "main",
originalAuthHeader: "Bearer old-token",
expectedBranchFilter: "main",
expectedAuthHeader: "Bearer old-token",
},
{
name: "Update branch filter",
originalBranchFilter: "main",
setBranchFilter: true,
newBranchFilter: "develop",
expectedBranchFilter: "develop",
expectedAuthHeader: "",
},
{
name: "Update authorization header",
originalAuthHeader: "Bearer old-token",
setAuthorizationHeader: true,
newAuthHeader: "X-Token: new-token",
expectedBranchFilter: "",
expectedAuthHeader: "X-Token: new-token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
branchFilter := tt.originalBranchFilter
if tt.setBranchFilter {
branchFilter = tt.newBranchFilter
}
authHeader := tt.originalAuthHeader
if tt.setAuthorizationHeader {
authHeader = tt.newAuthHeader
}
assert.Equal(t, tt.expectedBranchFilter, branchFilter)
assert.Equal(t, tt.expectedAuthHeader, authHeader)
})
}
}
func TestUpdateEventsHandling(t *testing.T) {
tests := []struct {
name string

View File

@@ -274,7 +274,7 @@ Manage and checkout pull requests
**--comments**: Whether to display comments (will prompt if not provided & run interactively)
**--fields, -f**="": Comma-separated list of fields to print. Available values:
index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments
index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci
(default: "index,title,state,author,milestone,updated,labels")
**--limit, --lm**="": specify limit of items per page (default: 30)
@@ -296,7 +296,7 @@ Manage and checkout pull requests
List pull requests of the repository
**--fields, -f**="": Comma-separated list of fields to print. Available values:
index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments
index,state,author,author-id,url,title,body,mergeable,base,base-commit,head,diff,patch,created,updated,deadline,assignees,milestone,labels,comments,ci
(default: "index,title,state,author,milestone,updated,labels")
**--limit, --lm**="": specify limit of items per page (default: 30)
@@ -483,6 +483,46 @@ Merge a pull request
**--title, -t**="": Merge commit title
### review-comments, rc
List review comments on a pull request
**--fields, -f**="": Comma-separated list of fields to print. Available values:
id,body,reviewer,path,line,resolver,created,updated,url
(default: "id,path,line,body,reviewer,resolver")
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### resolve
Resolve a review comment on a pull request
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### unresolve
Unresolve a review comment on a pull request
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## labels, label
Manage issue labels
@@ -1003,12 +1043,12 @@ Create an organization
**--description, -d**="":
**--full-name, -n**="":
**--location, -L**="":
**--login, -l**="": Use a different Gitea Login. Optional
**--name, -n**="":
**--repo-admins-can-change-team-access**:
**--visibility, -v**="":
@@ -1025,7 +1065,7 @@ Delete users Organizations
## repos, repo
Show repository details
Manage repositories
**--fields, -f**="": Comma-separated list of fields to print. Available values:
description,forks,id,name,owner,stars,ssh,updated,url,permission,type
@@ -1341,6 +1381,26 @@ Unprotect branches
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### rename, rn
Rename a branch
**--fields, -f**="": Comma-separated list of fields to print. Available values:
name,protected,user-can-merge,user-can-push,protection
(default: "name,protected,user-can-merge,user-can-push")
**--limit, --lm**="": specify limit of items per page (default: 30)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--page, -p**="": specify page (default: 1)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## actions, action
Manage repository actions
@@ -1533,13 +1593,65 @@ Manage repository workflows
List repository workflows
**--limit, --lm**="": specify limit of items per page (default: 30)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### view, show, get
View workflow details
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--page, -p**="": specify page (default: 1)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### dispatch, trigger, run
Dispatch a workflow run
**--follow, -f**: follow log output after dispatching
**--input, -i**="": workflow input in key=value format (can be specified multiple times)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--ref, -r**="": branch or tag to dispatch on (default: current branch)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### enable
Enable a workflow
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### disable
Disable a workflow
**--confirm, -y**: confirm disable without prompting
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
@@ -1829,6 +1941,50 @@ Clone a repository locally
**--login, -l**="": Use a different Gitea Login. Optional
## ssh-keys, ssh-key
Manage SSH public keys
**--limit, --lm**="": specify limit of items per page (default: 30)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--page, -p**="": specify page (default: 1)
### list, ls
List SSH keys
**--limit, --lm**="": specify limit of items per page (default: 30)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--page, -p**="": specify page (default: 1)
### add
Add an SSH public key
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--title, -t**="": Title for the key (defaults to the filename without extension)
### delete, rm
Delete an SSH key
**--confirm, -y**: Confirm deletion (required)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
## admin, a
Operations requiring admin access on the Gitea instance
@@ -1873,6 +2029,116 @@ List Users
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
#### create, add, new
Create a new user
**--admin**: Make the user an administrator
**--email, -e**="": Email address for the new user (required)
**--full-name**="": Full name for the new user
**--login, -l**="": Use a different Gitea Login. Optional
**--no-must-change-password**: Don't require the user to change password on first login (default: password change required)
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--password, -p**="": Password for the new user (will prompt if not provided)
**--password-file**="": Read password from file
**--password-stdin**: Read password from stdin
**--prohibit-login**: Prohibit the user from logging in
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--restricted**: Make the user restricted
**--username, -u**="": Username for the new user (required)
**--visibility**="": Visibility of the user profile (public, limited, private) (default: "public")
#### edit, update, e, u
Edit a user
**--active**: Activate the user
**--admin**: Make the user an administrator
**--allow-create-organization**: Allow the user to create organizations
**--allow-git-hook**: Allow the user to use git hooks
**--allow-import-local**: Allow the user to import local repositories
**--allow-login**: Allow the user to log in
**--description**="": User description
**--email, -e**="": Email address
**--full-name**="": Full name
**--inactive**: Deactivate the user
**--location**="": Location
**--login, -l**="": Use a different Gitea Login. Optional
**--max-repo-creation**="": Maximum number of repositories the user can create (-1 for unlimited) (default: 0)
**--no-admin**: Remove administrator status
**--no-allow-create-organization**: Disallow the user from creating organizations
**--no-allow-git-hook**: Disallow the user from using git hooks
**--no-allow-import-local**: Disallow the user from importing local repositories
**--no-must-change-password**: Don't require the user to change password on next login (default: password change required)
**--no-restricted**: Remove restricted status
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--password**="": New password (use empty value --password="" to trigger interactive prompt)
**--password-file**="": Read password from file
**--password-stdin**: Read password from stdin
**--prohibit-login**: Prohibit the user from logging in
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--restricted**: Make the user restricted
**--visibility**="": Visibility of the user profile (public, limited, private)
**--website**="": Website URL
#### delete, rm, remove
Delete a user
**--confirm, -y**: confirm deletion without prompting
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## api
Make an authenticated API request

View File

@@ -1,8 +1,93 @@
# Gitea actions workflows
## Workflow management with tea
### List workflows
```bash
# List all workflows in the repository
tea actions workflows list
```
### View workflow details
```bash
# View details of a specific workflow by ID or filename
tea actions workflows view deploy.yml
```
### Dispatch (trigger) a workflow
```bash
# Dispatch a workflow on the current branch
tea actions workflows dispatch deploy.yml
# Dispatch on a specific branch
tea actions workflows dispatch deploy.yml --ref main
# Dispatch with workflow inputs
tea actions workflows dispatch deploy.yml --ref main --input env=staging --input version=1.2.3
# Dispatch and follow log output
tea actions workflows dispatch ci.yml --ref feature/my-pr --follow
```
### Enable / disable workflows
```bash
# Disable a workflow
tea actions workflows disable deploy.yml --confirm
# Enable a workflow
tea actions workflows enable deploy.yml
```
## Example: Re-trigger CI from an AI-driven PR flow
Use `tea actions workflows dispatch` to re-run a specific workflow after
pushing changes in an automated PR workflow:
```bash
# Push changes to a feature branch, then re-trigger CI
git push origin feature/auto-fix
tea actions workflows dispatch check-and-test --ref feature/auto-fix --follow
```
## Example: Dispatch a workflow with `workflow_dispatch` trigger
```yaml
name: deploy
on:
workflow_dispatch:
inputs:
env:
description: "Target environment"
required: true
default: "staging"
version:
description: "Version to deploy"
required: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Deploy
run: |
echo "Deploying version ${{ gitea.event.inputs.version }} to ${{ gitea.event.inputs.env }}"
```
Trigger this workflow from the CLI:
```bash
tea actions workflows dispatch deploy.yml --ref main --input env=production --input version=2.0.0
```
## Merge Pull request on approval
``` Yaml
```yaml
---
name: Pull request
on:

63
go.mod
View File

@@ -5,47 +5,46 @@ go 1.26
require (
charm.land/glamour/v2 v2.0.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.3
code.gitea.io/gitea-vet v0.2.3
code.gitea.io/sdk/gitea v0.24.1
code.gitea.io/sdk/gitea v0.25.1
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
github.com/adrg/xdg v0.5.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/enescakir/emoji v1.0.0
github.com/go-authgate/sdk-go v0.6.1
github.com/go-git/go-git/v5 v5.17.2
github.com/go-authgate/sdk-go v0.11.0
github.com/go-git/go-git/v5 v5.19.0
github.com/muesli/termenv v0.16.0
github.com/olekukonko/tablewriter v1.1.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.11.1
github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.8.0
golang.org/x/crypto v0.49.0
github.com/urfave/cli/v3 v3.9.0
golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.41.0
golang.org/x/sys v0.44.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect
charm.land/bubbles/v2 v2.1.0 // indirect
charm.land/bubbletea/v2 v2.0.6 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260511125431-fe5d686e0c99 // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -58,32 +57,32 @@ require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.7 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/olekukonko/errors v1.3.0 // indirect
github.com/olekukonko/ll v0.1.8 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -91,13 +90,13 @@ require (
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
golang.org/x/net v0.52.0 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

151
go.sum
View File

@@ -1,27 +1,21 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8=
code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA=
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -29,14 +23,14 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
@@ -59,10 +53,10 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
@@ -71,8 +65,8 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6g
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa h1:bmNUSF4m+fwrzZAOhluMSZxdM4bk+SWN0Ni2DimCZm8=
github.com/charmbracelet/x/exp/slice v0.0.0-20260311145557-c83711a11ffa/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/slice v0.0.0-20260511125431-fe5d686e0c99 h1:e4VttUIAVgO4neqnJG80U4BE//1kcvyOrJ5utftPXQE=
github.com/charmbracelet/x/exp/slice v0.0.0-20260511125431-fe5d686e0c99/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -102,8 +96,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
@@ -112,42 +106,34 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog=
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-authgate/sdk-go v0.2.0 h1:w22f+sAg/YMqnLOcS/4SAuMZXTbPurzkSQBsjb1hcbw=
github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho=
github.com/go-authgate/sdk-go v0.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624/jw=
github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk=
github.com/go-authgate/sdk-go v0.11.0 h1:ZTfJ0rzeDn4QBqAmF9VKS3CqlKhE8+0tJxg8OGNtIzo=
github.com/go-authgate/sdk-go v0.11.0/go.mod h1:sa0ige5wtayj2WcnXlxa8wGuyi5z/c/chc0mXPJTl/Q=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk=
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/go-git/go-git/v5 v5.19.0 h1:+WkVUQZSy/F1Gb13udrMKjIM2PrzsNfDKFSfo5tkMtc=
github.com/go-git/go-git/v5 v5.19.0/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
@@ -163,15 +149,15 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -182,18 +168,16 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4=
github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U=
github.com/olekukonko/errors v1.3.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -225,40 +209,38 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -273,22 +255,21 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -226,7 +226,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt
codeChan := make(chan string, 1)
stateChan := make(chan string, 1)
errChan := make(chan error, 1)
portChan := make(chan int, 1)
// Parse the redirect URL to get the path
parsedURL, err := url.Parse(opts.RedirectURL)
@@ -311,7 +310,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt
if port == 0 {
addr := listener.Addr().(*net.TCPAddr)
port = addr.Port
portChan <- port
// Update redirect URL with actual port
parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port)

View File

@@ -5,7 +5,6 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
@@ -74,7 +73,8 @@ func GetConfigPath() string {
}
if err != nil {
log.Fatal("unable to get or create config file")
fmt.Fprintln(os.Stderr, "unable to get or create config file")
os.Exit(1)
}
return configFilePath

View File

@@ -8,7 +8,6 @@ import (
"crypto/tls"
"errors"
"fmt"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
@@ -132,7 +131,7 @@ func GetDefaultLogin() (*Login, error) {
}
if len(config.Logins) == 0 {
return nil, errors.New("No available login")
return nil, errors.New("no available login")
}
for _, l := range config.Logins {
if l.Default {
@@ -178,50 +177,51 @@ func GetLoginByName(name string) (*Login, error) {
}
// GetLoginByToken get login by token
func GetLoginByToken(token string) *Login {
func GetLoginByToken(token string) (*Login, error) {
if token == "" {
return nil
return nil, nil
}
err := loadConfig()
if err != nil {
log.Fatal(err)
if err := loadConfig(); err != nil {
return nil, err
}
for _, l := range config.Logins {
if l.Token == token {
return &l
return &l, nil
}
}
return nil
return nil, nil
}
// GetLoginByHost finds a login by it's server URL
func GetLoginByHost(host string) *Login {
logins := GetLoginsByHost(host)
if len(logins) > 0 {
return logins[0]
// GetLoginByHost finds a login by its server URL
func GetLoginByHost(host string) (*Login, error) {
logins, err := GetLoginsByHost(host)
if err != nil {
return nil, err
}
return nil
if len(logins) > 0 {
return logins[0], nil
}
return nil, nil
}
// GetLoginsByHost returns all logins matching a host
func GetLoginsByHost(host string) []*Login {
err := loadConfig()
if err != nil {
log.Fatal(err)
func GetLoginsByHost(host string) ([]*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
var matches []*Login
for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil {
log.Fatal(err)
return nil, err
}
if loginURL.Host == host {
matches = append(matches, &config.Logins[i])
}
}
return matches
return matches, nil
}
// DeleteLogin delete a login by name from config
@@ -417,12 +417,13 @@ func doOAuthRefresh(l *Login) (*oauth2.Token, error) {
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
// Refresh OAuth token if expired or near expiry
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
fmt.Fprintf(os.Stderr, "Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
os.Exit(1)
}
httpClient := &http.Client{}
if l.Insecure {
cookieJar, _ := cookiejar.New(nil)
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{
Jar: cookieJar,
@@ -443,12 +444,18 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
}
if l.SSHCertPrincipal != "" {
l.askForSSHPassphrase()
if err := l.askForSSHPassphrase(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
os.Exit(1)
}
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase))
}
if l.SSHKeyFingerprint != "" {
l.askForSSHPassphrase()
if err := l.askForSSHPassphrase(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
os.Exit(1)
}
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase))
}
@@ -456,25 +463,25 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
if err != nil {
var versionError *gitea.ErrUnknownVersion
if !errors.As(err, &versionError) {
log.Fatal(err)
fmt.Fprintf(os.Stderr, "Failed to create Gitea client: %s\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
}
return client
}
func (l *Login) askForSSHPassphrase() {
func (l *Login) askForSSHPassphrase() error {
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" {
if err := huh.NewInput().
return huh.NewInput().
Title("ssh-key is encrypted please enter the passphrase: ").
Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword).
Value(&l.SSHPassphrase).
WithTheme(theme.GetTheme()).
Run(); err != nil {
log.Fatal(err)
}
Run()
}
return nil
}
// GetSSHHost returns SSH host name

View File

@@ -0,0 +1,13 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build testtools
package config
import "time"
// AcquireConfigLockForTesting exposes the internal lock helper to integration tests.
func AcquireConfigLockForTesting(lockPath string, timeout time.Duration) (func() error, error) {
return acquireConfigLock(lockPath, timeout)
}

View File

@@ -20,7 +20,7 @@ import (
"golang.org/x/term"
)
var errNotAGiteaRepo = errors.New("No Gitea login found. You might want to specify --repo (and --login) to work outside of a repository")
var errNotAGiteaRepo = errors.New("no Gitea login found; you might want to specify --repo (and --login) to work outside of a repository")
// ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt.
var ErrCommandCanceled = errors.New("command canceled")
@@ -83,6 +83,8 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
}
if repoFlagPathExists {
repoPath = repoFlag
} else {
c.RepoSlug = repoFlag
}
}
@@ -90,12 +92,6 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
remoteFlag = config.GetPreferences().FlagDefaults.Remote
}
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
}
// Create env login before repo context detection so it participates in remote URL matching
var extraLogins []config.Login
envLogin := GetLoginByEnvVar()
@@ -108,17 +104,20 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
// otherwise attempt PWD. if no repo is found, continue with default login
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
return nil, err
if c.RepoSlug == "" {
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
}
}
if len(repoFlag) != 0 && !repoFlagPathExists {
// if repoFlag is not a valid path, use it to override repoSlug
c.RepoSlug = repoFlag
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
return nil, err
}
}
}
// If env vars are set, always use the env login (but repo slug was already

View File

@@ -80,7 +80,7 @@ func (r TeaRepo) TeaFindBranchBySha(sha, repoURL string) (b *git_config.Branch,
return nil, err
}
if remote == nil {
return nil, fmt.Errorf("No remote found for '%s'", repoURL)
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
}
remoteName := remote.Config().Name
@@ -133,7 +133,7 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config.
return nil, err
}
if remote == nil {
return nil, fmt.Errorf("No remote found for '%s'", repoURL)
return nil, fmt.Errorf("no remote found for '%s'", repoURL)
}
remoteName := remote.Config().Name

View File

@@ -180,19 +180,25 @@ func fetchIssueSelectables(login *config.Login, owner, repo string, done chan is
r.MilestoneList[i] = m.Title
}
labels, _, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
r.Err = err
done <- r
return
}
r.LabelMap = make(map[string]int64)
r.LabelList = make([]string, len(labels))
for i, l := range labels {
r.LabelMap[l.Name] = l.ID
r.LabelList[i] = l.Name
r.LabelList = make([]string, 0)
for page := 1; ; {
labels, resp, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
r.Err = err
done <- r
return
}
for _, l := range labels {
r.LabelMap[l.Name] = l.ID
r.LabelList = append(r.LabelList, l.Name)
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
done <- r

View File

@@ -41,7 +41,7 @@ func CreateLogin() error {
}
_, err := url.Parse(s)
if err != nil {
return fmt.Errorf("Invalid URL: %v", err)
return fmt.Errorf("invalid URL: %v", err)
}
return nil
}).
@@ -69,7 +69,7 @@ func CreateLogin() error {
}
for _, login := range logins {
if login.Name == name {
return fmt.Errorf("Login with name '%s' already exists", name)
return fmt.Errorf("login with name '%s' already exists", name)
}
}
return nil
@@ -154,7 +154,7 @@ func CreateLogin() error {
Value(&tokenScopes).
Validate(func(s []string) error {
if len(s) == 0 {
return errors.New("At least one scope is required")
return errors.New("at least one scope is required")
}
return nil
}).

View File

@@ -58,7 +58,7 @@ func getPullIndex(ctx *context.TeaContext, branch string) (int64, error) {
return 0, err
}
if len(prs) == 0 {
return 0, fmt.Errorf("No open PRs found")
return 0, fmt.Errorf("no open PRs found")
}
opts.ListOptions.Page++
prOptions := make([]string, 0)

View File

@@ -154,27 +154,23 @@ func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) erro
return t.print(output)
}
// WorkflowsList prints a list of workflow files with active status
func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) error {
// ActionWorkflowsList prints a list of workflows from the workflow API
func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error {
t := table{
headers: []string{
"Active",
"ID",
"Name",
"Path",
"State",
},
}
machineReadable := isMachineReadable(output)
for _, workflow := range workflows {
// Check if this workflow file is active (has runs)
isActive := activeStatus[workflow.Name]
activeIndicator := formatBoolean(isActive, !machineReadable)
for _, wf := range workflows {
t.addRow(
activeIndicator,
workflow.Name,
workflow.Path,
wf.ID,
wf.Name,
wf.Path,
wf.State,
)
}
@@ -186,3 +182,34 @@ func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]
t.sort(1, true) // Sort by name column
return t.print(output)
}
// ActionWorkflowDetails prints detailed information about a workflow
func ActionWorkflowDetails(wf *gitea.ActionWorkflow) {
fmt.Printf("ID: %s\n", wf.ID)
fmt.Printf("Name: %s\n", wf.Name)
fmt.Printf("Path: %s\n", wf.Path)
fmt.Printf("State: %s\n", wf.State)
if wf.HTMLURL != "" {
fmt.Printf("URL: %s\n", wf.HTMLURL)
}
if wf.BadgeURL != "" {
fmt.Printf("Badge: %s\n", wf.BadgeURL)
}
if !wf.CreatedAt.IsZero() {
fmt.Printf("Created: %s\n", FormatTime(wf.CreatedAt, false))
}
if !wf.UpdatedAt.IsZero() {
fmt.Printf("Updated: %s\n", FormatTime(wf.UpdatedAt, false))
}
}
// ActionWorkflowDispatchResult prints the result of a workflow dispatch
func ActionWorkflowDispatchResult(details *gitea.RunDetails) {
fmt.Printf("Workflow dispatched successfully\n")
if details != nil {
fmt.Printf("Run ID: %d\n", details.WorkflowRunID)
if details.HTMLURL != "" {
fmt.Printf("URL: %s\n", details.HTMLURL)
}
}
}

View File

@@ -123,6 +123,87 @@ func TestActionWorkflowJobsListWithData(t *testing.T) {
require.NoError(t, ActionWorkflowJobsList(jobs, ""))
}
func TestActionWorkflowsListEmpty(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with empty list: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList([]*gitea.ActionWorkflow{}, ""))
}
func TestActionWorkflowsListWithData(t *testing.T) {
workflows := []*gitea.ActionWorkflow{
{
ID: "1",
Name: "CI",
Path: ".gitea/workflows/ci.yml",
State: "active",
},
{
ID: "2",
Name: "Deploy",
Path: ".gitea/workflows/deploy.yml",
State: "disabled_manually",
},
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowsList panicked with data: %v", r)
}
}()
require.NoError(t, ActionWorkflowsList(workflows, ""))
}
func TestActionWorkflowDetails(t *testing.T) {
wf := &gitea.ActionWorkflow{
ID: "1",
Name: "CI Pipeline",
Path: ".gitea/workflows/ci.yml",
State: "active",
HTMLURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml",
BadgeURL: "https://gitea.example.com/owner/repo/actions/workflows/ci.yml/badge.svg",
CreatedAt: time.Now().Add(-24 * time.Hour),
UpdatedAt: time.Now().Add(-1 * time.Hour),
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDetails panicked: %v", r)
}
}()
ActionWorkflowDetails(wf)
}
func TestActionWorkflowDispatchResult(t *testing.T) {
details := &gitea.RunDetails{
WorkflowRunID: 42,
HTMLURL: "https://gitea.example.com/owner/repo/actions/runs/42",
}
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked: %v", r)
}
}()
ActionWorkflowDispatchResult(details)
}
func TestActionWorkflowDispatchResultNil(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ActionWorkflowDispatchResult panicked with nil: %v", r)
}
}()
ActionWorkflowDispatchResult(nil)
}
func TestFormatDurationMinutes(t *testing.T) {
now := time.Now()

View File

@@ -12,7 +12,7 @@ import (
var ciStatusSymbols = map[gitea.StatusState]string{
gitea.StatusSuccess: "✓ ",
gitea.StatusPending: " ",
gitea.StatusPending: " ",
gitea.StatusWarning: "⚠ ",
gitea.StatusError: "✘ ",
gitea.StatusFailure: "❌ ",
@@ -42,16 +42,19 @@ func PullDetails(pr *gitea.PullRequest, reviews []*gitea.PullReview, ciStatus *g
out += formatReviews(pr, reviews)
if ciStatus != nil {
var summary, errors string
if ciStatus != nil && len(ciStatus.Statuses) != 0 {
out += "- CI:\n"
for _, s := range ciStatus.Statuses {
summary += ciStatusSymbols[s.State]
if s.State != gitea.StatusSuccess {
errors += fmt.Sprintf(" - [**%s**:\t%s](%s)\n", s.Context, s.Description, s.TargetURL)
symbol := ciStatusSymbols[s.State]
if s.TargetURL != "" {
out += fmt.Sprintf(" - %s[**%s**](%s)", symbol, s.Context, s.TargetURL)
} else {
out += fmt.Sprintf(" - %s**%s**", symbol, s.Context)
}
}
if len(ciStatus.Statuses) != 0 {
out += fmt.Sprintf("- CI: %s\n%s", summary, errors)
if s.Description != "" {
out += fmt.Sprintf(": %s", s.Description)
}
out += "\n"
}
}
@@ -89,6 +92,20 @@ func formatPRState(pr *gitea.PullRequest) string {
return string(pr.State)
}
func formatCIStatus(ci *gitea.CombinedStatus, machineReadable bool) string {
if ci == nil || len(ci.Statuses) == 0 {
return ""
}
if machineReadable {
return string(ci.State)
}
items := make([]string, 0, len(ci.Statuses))
for _, s := range ci.Statuses {
items = append(items, fmt.Sprintf("%s%s", ciStatusSymbols[s.State], s.Context))
}
return strings.Join(items, ", ")
}
func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
result := ""
if len(reviews) == 0 {
@@ -138,8 +155,8 @@ func formatReviews(pr *gitea.PullRequest, reviews []*gitea.PullReview) string {
}
// PullsList prints a listing of pulls
func PullsList(prs []*gitea.PullRequest, output string, fields []string) error {
return printPulls(prs, output, fields)
func PullsList(prs []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
return printPulls(prs, output, fields, ciStatuses)
}
// PullFields are all available fields to print with PullsList()
@@ -168,9 +185,10 @@ var PullFields = []string{
"milestone",
"labels",
"comments",
"ci",
}
func printPulls(pulls []*gitea.PullRequest, output string, fields []string) error {
func printPulls(pulls []*gitea.PullRequest, output string, fields []string, ciStatuses map[int64]*gitea.CombinedStatus) error {
labelMap := map[int64]string{}
printables := make([]printable, len(pulls))
machineReadable := isMachineReadable(output)
@@ -183,7 +201,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) erro
}
}
// store items with printable interface
printables[i] = &printablePull{x, &labelMap}
printables[i] = &printablePull{x, &labelMap, &ciStatuses}
}
t := tableFromItems(fields, printables, machineReadable)
@@ -193,6 +211,7 @@ func printPulls(pulls []*gitea.PullRequest, output string, fields []string) erro
type printablePull struct {
*gitea.PullRequest
formattedLabels *map[int64]string
ciStatuses *map[int64]*gitea.CombinedStatus
}
func (x printablePull) FormatField(field string, machineReadable bool) string {
@@ -252,6 +271,13 @@ func (x printablePull) FormatField(field string, machineReadable bool) string {
return x.DiffURL
case "patch":
return x.PatchURL
case "ci":
if x.ciStatuses != nil {
if ci, ok := (*x.ciStatuses)[x.Index]; ok {
return formatCIStatus(ci, machineReadable)
}
}
return ""
}
return ""
}

View File

@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"code.gitea.io/sdk/gitea"
)
// PullReviewCommentFields are all available fields to print with PullReviewCommentsList()
var PullReviewCommentFields = []string{
"id",
"body",
"reviewer",
"path",
"line",
"resolver",
"created",
"updated",
"url",
}
// PullReviewCommentsList prints a listing of pull review comments
func PullReviewCommentsList(comments []*gitea.PullReviewComment, output string, fields []string) error {
printables := make([]printable, len(comments))
for i, c := range comments {
printables[i] = &printablePullReviewComment{c}
}
t := tableFromItems(fields, printables, isMachineReadable(output))
return t.print(output)
}
type printablePullReviewComment struct {
*gitea.PullReviewComment
}
func (x printablePullReviewComment) FormatField(field string, machineReadable bool) string {
switch field {
case "id":
return fmt.Sprintf("%d", x.ID)
case "body":
return x.Body
case "reviewer":
if x.Reviewer != nil {
return formatUserName(x.Reviewer)
}
return ""
case "path":
return x.Path
case "line":
if x.LineNum != 0 {
return fmt.Sprintf("%d", x.LineNum)
}
if x.OldLineNum != 0 {
return fmt.Sprintf("%d", x.OldLineNum)
}
return ""
case "resolver":
if x.Resolver != nil {
return formatUserName(x.Resolver)
}
return ""
case "created":
return FormatTime(x.Created, machineReadable)
case "updated":
return FormatTime(x.Updated, machineReadable)
case "url":
return x.HTMLURL
}
return ""
}

189
modules/print/pull_test.go Normal file
View File

@@ -0,0 +1,189 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"bytes"
"encoding/json"
"slices"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestPR(index int64, title string) *gitea.PullRequest {
now := time.Now()
return &gitea.PullRequest{
Index: index,
Title: title,
State: gitea.StateOpen,
Poster: &gitea.User{UserName: "testuser"},
Head: &gitea.PRBranchInfo{Ref: "branch", Name: "branch"},
Base: &gitea.PRBranchInfo{Ref: "main", Name: "main"},
Created: &now,
Updated: &now,
}
}
func TestFormatCIStatusNil(t *testing.T) {
assert.Equal(t, "", formatCIStatus(nil, false))
assert.Equal(t, "", formatCIStatus(nil, true))
}
func TestFormatCIStatusEmpty(t *testing.T) {
ci := &gitea.CombinedStatus{Statuses: []*gitea.Status{}}
assert.Equal(t, "", formatCIStatus(ci, false))
assert.Equal(t, "", formatCIStatus(ci, true))
}
func TestFormatCIStatusMachineReadable(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
},
}
assert.Equal(t, "success", formatCIStatus(ci, true))
ci.State = gitea.StatusPending
ci.Statuses = []*gitea.Status{
{State: gitea.StatusPending, Context: "build"},
}
assert.Equal(t, "pending", formatCIStatus(ci, true))
}
func TestFormatCIStatusSingle(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
},
}
assert.Equal(t, "✓ lint", formatCIStatus(ci, false))
}
func TestFormatCIStatusMultiple(t *testing.T) {
ci := &gitea.CombinedStatus{
State: gitea.StatusFailure,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "lint"},
{State: gitea.StatusPending, Context: "build"},
{State: gitea.StatusFailure, Context: "test"},
},
}
assert.Equal(t, "✓ lint, ⏳ build, ❌ test", formatCIStatus(ci, false))
}
func TestFormatCIStatusAllStates(t *testing.T) {
tests := []struct {
state gitea.StatusState
context string
expected string
}{
{gitea.StatusSuccess, "s", "✓ s"},
{gitea.StatusPending, "p", "⏳ p"},
{gitea.StatusWarning, "w", "⚠ w"},
{gitea.StatusError, "e", "✘ e"},
{gitea.StatusFailure, "f", "❌ f"},
}
for _, tt := range tests {
ci := &gitea.CombinedStatus{
State: tt.state,
Statuses: []*gitea.Status{{State: tt.state, Context: tt.context}},
}
assert.Equal(t, tt.expected, formatCIStatus(ci, false), "state: %s", tt.state)
}
}
func TestPullsListWithCIField(t *testing.T) {
prs := []*gitea.PullRequest{
newTestPR(1, "feat: add feature"),
newTestPR(2, "fix: bug fix"),
}
ciStatuses := map[int64]*gitea.CombinedStatus{
1: {
State: gitea.StatusSuccess,
Statuses: []*gitea.Status{
{State: gitea.StatusSuccess, Context: "ci/build"},
},
},
2: {
State: gitea.StatusFailure,
Statuses: []*gitea.Status{
{State: gitea.StatusFailure, Context: "ci/test"},
},
},
}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
&printablePull{prs[1], &map[int64]string{}, &ciStatuses},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 2)
assert.Equal(t, "1", result[0]["index"])
assert.Equal(t, "success", result[0]["ci"])
assert.Equal(t, "2", result[1]["index"])
assert.Equal(t, "failure", result[1]["ci"])
}
func TestPullsListCIFieldEmpty(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "no ci")}
ciStatuses := map[int64]*gitea.CombinedStatus{}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, &ciStatuses},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
assert.Equal(t, "", result[0]["ci"])
}
func TestPullsListNilCIStatusesWithCIField(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "nil ci")}
buf := &bytes.Buffer{}
tbl := tableFromItems(
[]string{"index", "ci"},
[]printable{
&printablePull{prs[0], &map[int64]string{}, nil},
},
true,
)
require.NoError(t, tbl.fprint(buf, "json"))
var result []map[string]string
require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
require.Len(t, result, 1)
assert.Equal(t, "", result[0]["ci"])
}
func TestPullsListNoCIFieldNoPanic(t *testing.T) {
prs := []*gitea.PullRequest{newTestPR(1, "test")}
require.NoError(t, PullsList(prs, "", []string{"index", "title"}, nil))
}
func TestPullFieldsContainsCI(t *testing.T) {
assert.True(t, slices.Contains(PullFields, "ci"), "PullFields should contain 'ci'")
}

44
modules/print/sshkey.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"code.gitea.io/sdk/gitea"
)
// SSHKeysList prints a table of SSH public keys
func SSHKeysList(keys []*gitea.PublicKey, output string) error {
if len(keys) == 0 {
fmt.Printf("No SSH keys found\n")
return nil
}
t := tableWithHeader(
"ID",
"Title",
"Fingerprint",
"KeyType",
"ReadOnly",
"Created",
)
for _, k := range keys {
readOnly := "false"
if k.ReadOnly {
readOnly = "true"
}
t.addRow(
fmt.Sprintf("%d", k.ID),
k.Title,
k.Fingerprint,
k.KeyType,
readOnly,
FormatTime(k.Created, false),
)
}
return t.print(output)
}

View File

@@ -150,8 +150,7 @@ func outputYaml(f io.Writer, headers []string, values [][]string) error {
})
valueNode := &yaml.Node{Kind: yaml.ScalarNode, Value: val}
intVal, _ := strconv.Atoi(val)
if strconv.Itoa(intVal) == val {
if _, err := strconv.ParseInt(val, 10, 64); err == nil {
valueNode.Tag = "!!int"
} else {
valueNode.Tag = "!!str"

View File

@@ -67,13 +67,21 @@ func WebhookDetails(hook *gitea.Hook) {
if method, ok := hook.Config["http_method"]; ok {
fmt.Printf("- **HTTP Method**: %s\n", method)
}
if branchFilter, ok := hook.Config["branch_filter"]; ok && branchFilter != "" {
branchFilter := hook.BranchFilter
if branchFilter == "" {
branchFilter = hook.Config["branch_filter"]
}
if branchFilter != "" {
fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
}
if _, hasSecret := hook.Config["secret"]; hasSecret {
fmt.Printf("- **Secret**: (configured)\n")
}
if _, hasAuth := hook.Config["authorization_header"]; hasAuth {
hasAuth := hook.AuthorizationHeader != ""
if !hasAuth {
_, hasAuth = hook.Config["authorization_header"]
}
if hasAuth {
fmt.Printf("- **Authorization Header**: (configured)\n")
}
}

View File

@@ -81,17 +81,17 @@ func TestWebhookDetails(t *testing.T) {
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"branch_filter": "main,develop",
"secret": "secret-value",
"authorization_header": "Bearer token123",
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
},
},
{
@@ -238,16 +238,14 @@ func TestWebhookConfigHandling(t *testing.T) {
{
name: "Config with all fields",
config: map[string]string{
"url": "https://example.com/webhook",
"secret": "my-secret",
"authorization_header": "Bearer token",
"content_type": "json",
"http_method": "post",
"branch_filter": "main",
"url": "https://example.com/webhook",
"secret": "my-secret",
"content_type": "json",
"http_method": "post",
},
expectedURL: "https://example.com/webhook",
hasSecret: true,
hasAuthHeader: true,
hasAuthHeader: false,
},
{
name: "Config with minimal fields",
@@ -341,17 +339,17 @@ func TestWebhookDetailsFormatting(t *testing.T) {
ID: 123,
Type: "gitea",
Config: map[string]string{
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"branch_filter": "main,develop",
"secret": "secret-value",
"authorization_header": "Bearer token123",
"url": "https://example.com/webhook",
"content_type": "json",
"http_method": "post",
"secret": "secret-value",
},
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"},
Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
}
// Test that all expected fields are included in details
@@ -379,8 +377,8 @@ func TestWebhookDetailsFormatting(t *testing.T) {
assert.Equal(t, "https://example.com/webhook", hook.Config["url"])
assert.Equal(t, "json", hook.Config["content_type"])
assert.Equal(t, "post", hook.Config["http_method"])
assert.Equal(t, "main,develop", hook.Config["branch_filter"])
assert.Equal(t, "main,develop", hook.BranchFilter)
assert.Contains(t, hook.Config, "secret")
assert.Contains(t, hook.Config, "authorization_header")
assert.Equal(t, "Bearer token123", hook.AuthorizationHeader)
assert.Equal(t, []string{"push", "pull_request", "issues"}, hook.Events)
}

View File

@@ -15,7 +15,7 @@ import (
func CreateIssue(login *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error {
// title is required
if len(opts.Title) == 0 {
return fmt.Errorf("Title is required")
return fmt.Errorf("title is required")
}
issue, _, err := login.Client().CreateIssue(repoOwner, repoName, opts)

View File

@@ -13,16 +13,23 @@ import (
// ResolveLabelNames returns a list of label IDs for a given list of label names
func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames))
labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return nil, err
}
for _, l := range labels {
if utils.Contains(labelNames, l.Name) {
labelIDs = append(labelIDs, l.ID)
page := 1
for {
labels, resp, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
for _, l := range labels {
if utils.Contains(labelNames, l.Name) {
labelIDs = append(labelIDs, l.ID)
}
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
return labelIDs, nil
}

View File

@@ -20,12 +20,13 @@ import (
func SetupHelper(login config.Login) (ok bool, err error) {
// Check that the URL is not blank
if login.URL == "" {
return false, fmt.Errorf("Invalid gitea url")
return false, fmt.Errorf("invalid Gitea URL")
}
// get all helper to URL in git config
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
var currentHelpers []byte
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", fmt.Sprintf("credential.%s.helper", login.URL)).Output(); err != nil {
if currentHelpers, err = exec.Command("git", "config", "--global", "--get-all", helperKey).Output(); err != nil {
currentHelpers = []byte{}
}
@@ -37,10 +38,10 @@ func SetupHelper(login config.Login) (ok bool, err error) {
}
// Add tea helper
if _, err = exec.Command("git", "config", "--global", fmt.Sprintf("credential.%s.helper", login.URL), "").Output(); err != nil {
return false, fmt.Errorf("git config --global %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), err)
} else if _, err = exec.Command("git", "config", "--global", "--add", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper").Output(); err != nil {
return false, fmt.Errorf("git config --global --add %s %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper", err)
if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil {
return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err)
} else if _, err = exec.Command("git", "config", "--global", "--add", helperKey, "!tea login helper").Output(); err != nil {
return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err)
}
return true, nil
@@ -62,7 +63,11 @@ func CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCe
}
// ... if we already use this token
if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) {
if login := config.GetLoginByToken(token); login != nil {
login, err := config.GetLoginByToken(token)
if err != nil {
return err
}
if login != nil {
return fmt.Errorf("token already been used, delete login '%s' first", login.Name)
}
}
@@ -161,11 +166,19 @@ func generateToken(login config.Login, user, pass, otp, scopes string) (string,
}
client := login.Client(opts...)
tl, _, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil {
return "", err
var tl []*gitea.AccessToken
for page := 1; ; {
page_tokens, resp, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return "", err
}
tl = append(tl, page_tokens...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
host, _ := os.Hostname()
tokenName := host + "-tea"

View File

@@ -19,11 +19,22 @@ import (
// a matching private key in ~/.ssh/. If no match is found, path is empty.
func findSSHKey(client *gitea.Client) (string, error) {
// get keys registered on gitea instance
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
if err != nil || len(keys) == 0 {
return "", err
var keys []*gitea.PublicKey
for page := 1; ; {
page_keys, resp, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return "", err
}
keys = append(keys, page_keys...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
if len(keys) == 0 {
return "", nil
}
// enumerate ~/.ssh/*.pub files

View File

@@ -17,7 +17,7 @@ import (
func CreateMilestone(login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error {
// title is required
if len(title) == 0 {
return fmt.Errorf("Title is required")
return fmt.Errorf("title is required")
}
mile, _, err := login.Client().CreateMilestone(repoOwner, repoName, gitea.CreateMilestoneOption{

View File

@@ -39,7 +39,7 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign
// if remote head branch is already deleted, pr.Head.Ref points to "pulls/<idx>/head"
remoteBranch := pr.Head.Ref
remoteDeleted := remoteBranch == fmt.Sprintf("refs/pull/%d/head", pr.Index)
remoteDeleted := isRemoteDeleted(pr)
if remoteDeleted {
remoteBranch = pr.Head.Name // this still holds the original branch name
fmt.Printf("Remote branch '%s' already deleted.\n", remoteBranch)
@@ -62,9 +62,9 @@ func PullClean(login *config.Login, repoOwner, repoName string, index int64, ign
}
if branch == nil {
if ignoreSHA {
return fmt.Errorf("Remote branch %s not found in local repo", remoteBranch)
return fmt.Errorf("remote branch %s not found in local repo", remoteBranch)
}
return fmt.Errorf(`Remote branch %s not found in local repo.
return fmt.Errorf(`remote branch %s not found in local repo.
Either you don't track this PR, or the local branch has diverged from the remote.
If you still want to continue & are sure you don't loose any important commits,
call me again with the --ignore-sha flag`, remoteBranch)

View File

@@ -18,7 +18,7 @@ func PullMerge(login *config.Login, repoOwner, repoName string, index int64, opt
return err
}
if !success {
return fmt.Errorf("Failed to merge PR. Is it still open?")
return fmt.Errorf("failed to merge PR, is it still open?")
}
return nil
}

View File

@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/context"
)
// ListPullReviewComments lists all review comments across all reviews for a PR
func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) {
c := ctx.Login.Client()
var reviews []*gitea.PullReview
for page := 1; ; {
page_reviews, resp, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
return nil, err
}
reviews = append(reviews, page_reviews...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
var allComments []*gitea.PullReviewComment
for _, review := range reviews {
comments, _, err := c.ListPullReviewComments(ctx.Owner, ctx.Repo, idx, review.ID)
if err != nil {
return nil, err
}
allComments = append(allComments, comments...)
}
return allComments, nil
}
// ResolvePullReviewComment resolves a review comment
func ResolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()
_, err := c.ResolvePullReviewComment(ctx.Owner, ctx.Repo, commentID)
if err != nil {
return err
}
fmt.Printf("Comment %d resolved\n", commentID)
return nil
}
// UnresolvePullReviewComment unresolves a review comment
func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()
_, err := c.UnresolvePullReviewComment(ctx.Owner, ctx.Repo, commentID)
if err != nil {
return err
}
fmt.Printf("Comment %d unresolved\n", commentID)
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"os/user"
"path/filepath"
"strings"
"syscall"
)
// PathExists returns whether the given file or directory exists or not
@@ -38,18 +39,19 @@ func exists(path string, expectDir bool) (bool, error) {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
} else if err.(*os.PathError).Err.Error() == "not a directory" {
// some middle segment of path is a file, cannot traverse
// FIXME: catches error on linux; go does not provide a way to catch this properly..
}
var pathErr *os.PathError
if errors.As(err, &pathErr) && errors.Is(pathErr.Err, syscall.ENOTDIR) {
// a middle segment of path is a file, cannot traverse
return false, nil
}
return false, err
}
isDir := f.IsDir()
if isDir && !expectDir {
return false, errors.New("A directory with the same name exists")
return false, errors.New("a directory with the same name exists")
} else if !isDir && expectDir {
return false, errors.New("A file with the same name exists")
return false, errors.New("a file with the same name exists")
}
return true, nil
}

View File

@@ -21,17 +21,17 @@ func ValidateAuthenticationMethod(
// Normalize URL
serverURL, err := NormalizeURL(giteaURL)
if err != nil {
return nil, fmt.Errorf("Unable to parse URL: %s", err)
return nil, fmt.Errorf("unable to parse URL: %s", err)
}
if !sshAgent && sshCertPrincipal == "" && sshKey == "" {
// .. if we have enough information to authenticate
if len(token) == 0 && (len(user)+len(passwd)) == 0 {
return nil, fmt.Errorf("No token set")
return nil, fmt.Errorf("no token set")
} else if len(user) != 0 && len(passwd) == 0 {
return nil, fmt.Errorf("No password set")
return nil, fmt.Errorf("no password set")
} else if len(user) == 0 && len(passwd) != 0 {
return nil, fmt.Errorf("No user set")
return nil, fmt.Errorf("no user set")
}
}
return serverURL, nil

10
tests/README.md Normal file
View File

@@ -0,0 +1,10 @@
This directory contains integration tests that exercise tea against external services or external executables.
- Unit tests stay next to the packages they cover.
- Integration tests live under `tests/` so they can be run separately.
Common targets:
- `make unit-test`
- `make integration-test`
- `make test`

View File

@@ -0,0 +1,139 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"code.gitea.io/sdk/gitea"
teacmd "code.gitea.io/tea/cmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func runAdminCommand(t *testing.T, args []string) error {
t.Helper()
adminCmd := teacmd.CmdAdmin
return adminCmd.Run(context.Background(), args)
}
func createAdminTestUser(t *testing.T, client *gitea.Client, username, password string) {
t.Helper()
mustChangePassword := false
user, _, err := client.AdminCreateUser(gitea.CreateUserOption{
LoginName: username,
Username: username,
Email: username + "@example.com",
Password: password,
MustChangePassword: &mustChangePassword,
})
require.NoError(t, err)
require.Equal(t, username, user.UserName)
t.Cleanup(func() {
if _, err := client.AdminDeleteUser(username); err != nil {
t.Logf("failed to delete integration test user %q: %v", username, err)
}
})
}
func TestAdminUsersCreateRequiresEmail(t *testing.T) {
login := createIntegrationLogin(t)
err := runAdminCommand(t, []string{
"admin", "users", "create",
"--username", fmt.Sprintf("create-no-email-%d", time.Now().UnixNano()),
"--password", "secret123",
"--login", login.Name,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "email")
}
func TestAdminUsersCreateAndDelete(t *testing.T) {
login := createIntegrationLogin(t)
client := login.Client()
username := fmt.Sprintf("tea-admin-create-%d", time.Now().UnixNano())
err := runAdminCommand(t, []string{
"admin", "users", "create",
"--username", username,
"--email", username + "@example.com",
"--password", "secret123",
"--admin",
"--prohibit-login",
"--visibility", "limited",
"--login", login.Name,
})
require.NoError(t, err)
createdUser, _, err := client.GetUserInfo(username)
require.NoError(t, err)
assert.Equal(t, username, createdUser.UserName)
assert.Equal(t, username+"@example.com", createdUser.Email)
assert.True(t, createdUser.IsAdmin)
assert.True(t, createdUser.ProhibitLogin)
assert.Equal(t, gitea.VisibleTypeLimited, createdUser.Visibility)
err = runAdminCommand(t, []string{
"admin", "users", "delete", username,
"--confirm",
"--login", login.Name,
})
require.NoError(t, err)
_, _, err = client.GetUserInfo(username)
require.Error(t, err)
}
func TestAdminUsersEdit(t *testing.T) {
login := createIntegrationLogin(t)
client := login.Client()
username := fmt.Sprintf("tea-admin-edit-%d", time.Now().UnixNano())
oldPassword := "old-secret"
newPassword := "new-secret"
createAdminTestUser(t, client, username, oldPassword)
passwordFile := filepath.Join(t.TempDir(), "password.txt")
require.NoError(t, os.WriteFile(passwordFile, []byte(newPassword+"\n"), 0o600))
err := runAdminCommand(t, []string{
"admin", "users", "edit", username,
"--email", username + "+new@example.com",
"--full-name", "Tea Integration",
"--restricted",
"--password-file", passwordFile,
"--no-must-change-password",
"--visibility", "private",
"--login", login.Name,
})
require.NoError(t, err)
updatedUser, _, err := client.GetUserInfo(username)
require.NoError(t, err)
assert.Equal(t, username+"+new@example.com", updatedUser.Email)
assert.Equal(t, "Tea Integration", updatedUser.FullName)
assert.True(t, updatedUser.IsActive)
assert.True(t, updatedUser.Restricted)
assert.False(t, updatedUser.ProhibitLogin)
assert.Equal(t, gitea.VisibleTypePrivate, updatedUser.Visibility)
passwordClient, err := gitea.NewClient(
integrationGiteaURL,
gitea.SetBasicAuth(username, newPassword),
gitea.SetGiteaVersion(""),
)
require.NoError(t, err)
me, _, err := passwordClient.GetMyUserInfo()
require.NoError(t, err)
assert.Equal(t, username, me.UserName)
}

View File

@@ -3,7 +3,7 @@
//go:build unix
package config
package integration
import (
"fmt"
@@ -12,10 +12,11 @@ import (
"path/filepath"
"testing"
"time"
"code.gitea.io/tea/modules/config"
)
func TestConfigLock_CrossProcess(t *testing.T) {
// Create a temp directory for test
tmpDir, err := os.MkdirTemp("", "tea-lock-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
@@ -24,15 +25,16 @@ func TestConfigLock_CrossProcess(t *testing.T) {
lockPath := filepath.Join(tmpDir, "config.yml.lock")
// Acquire lock in main process
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
unlock, err := config.AcquireConfigLockForTesting(lockPath, 5*time.Second)
if err != nil {
t.Fatalf("failed to acquire lock: %v", err)
}
defer unlock()
defer func() {
if err := unlock(); err != nil {
t.Fatalf("failed to release lock: %v", err)
}
}()
// Spawn a subprocess that tries to acquire the same lock
// The subprocess should fail to acquire within timeout
script := fmt.Sprintf(`
package main
@@ -48,19 +50,16 @@ func main() {
}
defer file.Close()
// Try non-blocking lock
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
// Lock is held - expected behavior
os.Exit(0)
}
// Lock was acquired - unexpected
syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
os.Exit(1)
}
`, lockPath)
// Write and run the test script
scriptPath := filepath.Join(tmpDir, "locktest.go")
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
t.Fatalf("failed to write test script: %v", err)
@@ -78,5 +77,4 @@ func main() {
t.Errorf("subprocess execution failed: %v", err)
}
}
// Exit code 0 means lock was properly held - success
}

View File

@@ -0,0 +1,59 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"os"
"os/exec"
"testing"
"code.gitea.io/tea/modules/config"
teacontext "code.gitea.io/tea/modules/context"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestInitCommand_WithRepoSlugSkipsLocalRepoDetection(t *testing.T) {
tmpDir := t.TempDir()
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "test-login",
URL: "https://gitea.example.com",
Token: "token",
User: "login-user",
Default: true,
}},
})
cmd := exec.Command("git", "init", "--object-format=sha256", tmpDir)
cmd.Env = os.Environ()
require.NoError(t, cmd.Run())
oldWd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(tmpDir))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldWd))
})
cliCmd := cli.Command{
Name: "branches",
Flags: []cli.Flag{
&cli.StringFlag{Name: "login"},
&cli.StringFlag{Name: "repo"},
&cli.StringFlag{Name: "remote"},
&cli.StringFlag{Name: "output"},
},
}
require.NoError(t, cliCmd.Set("repo", "owner/repo"))
ctx, err := teacontext.InitCommand(&cliCmd)
require.NoError(t, err)
require.Equal(t, "owner", ctx.Owner)
require.Equal(t, "repo", ctx.Repo)
require.Equal(t, "owner/repo", ctx.RepoSlug)
require.Nil(t, ctx.LocalRepo)
require.NotNil(t, ctx.Login)
require.Equal(t, "test-login", ctx.Login.Name)
}

View File

@@ -1,7 +1,7 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
package integration
import (
"os"
@@ -9,11 +9,11 @@ import (
"path/filepath"
"testing"
teagit "code.gitea.io/tea/modules/git"
"github.com/stretchr/testify/assert"
)
func TestRepoFromPath_Worktree(t *testing.T) {
// Create a temporary directory for test
tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*")
assert.NoError(t, err)
defer os.RemoveAll(tmpDir)
@@ -21,21 +21,17 @@ func TestRepoFromPath_Worktree(t *testing.T) {
mainRepoPath := filepath.Join(tmpDir, "main-repo")
worktreePath := filepath.Join(tmpDir, "worktree")
// Initialize main repository
cmd := exec.Command("git", "init", mainRepoPath)
assert.NoError(t, cmd.Run())
// Configure git for the test
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
assert.NoError(t, cmd.Run())
// Add a remote to the main repository
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
assert.NoError(t, cmd.Run())
// Create an initial commit (required for worktree)
readmePath := filepath.Join(mainRepoPath, "README.md")
err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644)
assert.NoError(t, err)
@@ -44,19 +40,14 @@ func TestRepoFromPath_Worktree(t *testing.T) {
cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit")
assert.NoError(t, cmd.Run())
// Create a worktree
cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
assert.NoError(t, cmd.Run())
// Test: Open repository from worktree path
repo, err := RepoFromPath(worktreePath)
repo, err := teagit.RepoFromPath(worktreePath)
assert.NoError(t, err, "Should be able to open worktree")
// Test: Read config from worktree (should read from main repo's config)
config, err := repo.Config()
assert.NoError(t, err, "Should be able to read config")
// Verify that remotes are accessible from worktree
assert.NotEmpty(t, config.Remotes, "Should be able to read remotes from worktree")
assert.Contains(t, config.Remotes, "origin", "Should have origin remote")
assert.Equal(t, "https://gitea.com/owner/repo.git", config.Remotes["origin"].URLs[0], "Should have correct remote URL")

View File

@@ -0,0 +1,104 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/task"
"github.com/stretchr/testify/require"
)
var (
integrationGiteaURL string
integrationUsername string
integrationPassword string
integrationToken string
integrationTokenID int64
integrationSetupErr error
integrationClient *gitea.Client
)
func TestMain(m *testing.M) {
integrationGiteaURL = os.Getenv("GITEA_TEA_TEST_URL")
integrationUsername = os.Getenv("GITEA_TEA_TEST_USERNAME")
integrationPassword = os.Getenv("GITEA_TEA_TEST_PASSWORD")
if integrationGiteaURL != "" {
if integrationUsername == "" || integrationPassword == "" {
integrationSetupErr = fmt.Errorf("GITEA_TEA_TEST_USERNAME and GITEA_TEA_TEST_PASSWORD are required for integration tests")
} else {
integrationClient, integrationSetupErr = gitea.NewClient(
integrationGiteaURL,
gitea.SetBasicAuth(integrationUsername, integrationPassword),
gitea.SetGiteaVersion(""),
)
if integrationSetupErr == nil {
tokenName := fmt.Sprintf("tea-integration-%d", time.Now().UnixNano())
var token *gitea.AccessToken
token, _, integrationSetupErr = integrationClient.CreateAccessToken(gitea.CreateAccessTokenOption{
Name: tokenName,
Scopes: []gitea.AccessTokenScope{gitea.AccessTokenScopeAll},
})
if integrationSetupErr == nil {
integrationToken = token.Token
integrationTokenID = token.ID
}
}
}
}
exitCode := m.Run()
if integrationClient != nil && integrationTokenID != 0 {
if _, err := integrationClient.DeleteAccessToken(integrationTokenID); err != nil {
fmt.Fprintf(os.Stderr, "failed to delete integration token %d: %v\n", integrationTokenID, err)
if exitCode == 0 {
exitCode = 1
}
}
}
os.Exit(exitCode)
}
func useTempConfigPath(t *testing.T) string {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.yml")
config.SetConfigPathForTesting(configPath)
config.SetConfigForTesting(config.LocalConfig{})
t.Cleanup(func() {
config.SetConfigForTesting(config.LocalConfig{})
config.SetConfigPathForTesting("")
})
return configPath
}
func createIntegrationLogin(t *testing.T) *config.Login {
t.Helper()
_ = useTempConfigPath(t)
if integrationGiteaURL == "" {
t.Skip("GITEA_TEA_TEST_URL is not set, skipping integration test")
}
require.NoError(t, integrationSetupErr)
require.NotEmpty(t, integrationToken, "integration token setup failed")
require.NoError(t, task.CreateLogin("integration", integrationToken, "", "", "", "", "", integrationGiteaURL, "", "", true, false, false, false))
login, err := config.GetLoginByName("integration")
require.NoError(t, err)
require.NotNil(t, login)
return login
}

View File

@@ -1,28 +1,26 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repos
package integration
import (
"context"
"fmt"
"os"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/cmd/repos"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestCreateRepoObjectFormat(t *testing.T) {
giteaURL := os.Getenv("GITEA_TEA_TEST_URL")
if giteaURL == "" {
t.Skip("GITEA_TEA_TEST_URL is not set, skipping test")
}
login := createIntegrationLogin(t)
client := login.Client()
timestamp := time.Now().Unix()
tests := []struct {
name string
args []string
@@ -56,22 +54,15 @@ func TestCreateRepoObjectFormat(t *testing.T) {
},
}
giteaUserName := os.Getenv("GITEA_TEA_TEST_USERNAME")
giteaUserPasword := os.Getenv("GITEA_TEA_TEST_PASSWORD")
err := task.CreateLogin("test", "", giteaUserName, giteaUserPasword, "", "", "", giteaURL, "", "", true, false, false, false)
if err != nil && err.Error() != "login name 'test' has already been used" {
t.Fatal(err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reposCmd := &cli.Command{
Name: "repos",
Commands: []*cli.Command{&CmdRepoCreate},
Commands: []*cli.Command{&repos.CmdRepoCreate},
}
tt.args = append(tt.args, "--login", "test")
args := append([]string{"repos", "create"}, tt.args...)
args = append(args, "--login", login.Name)
err := reposCmd.Run(context.Background(), args)
if tt.wantErr {
@@ -82,7 +73,12 @@ func TestCreateRepoObjectFormat(t *testing.T) {
return
}
assert.NoError(t, err)
require.NoError(t, err)
t.Cleanup(func() {
if _, delErr := client.DeleteRepo(login.User, tt.wantOpts.Name); delErr != nil {
t.Logf("failed to delete integration test repo %q: %v", tt.wantOpts.Name, delErr)
}
})
})
}
}

View File

@@ -0,0 +1,115 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"strconv"
"testing"
"time"
"code.gitea.io/sdk/gitea"
sshkeyscmd "code.gitea.io/tea/cmd/sshkeys"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
"golang.org/x/crypto/ssh"
)
// generateTestPublicKey creates a fresh ed25519 keypair and returns a temp
// file path containing the public key in authorized_keys format.
func generateTestPublicKey(t *testing.T) string {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
sshPub, err := ssh.NewPublicKey(priv.Public())
require.NoError(t, err)
pubKeyStr := fmt.Sprintf("ssh-ed25519 %s tea-test-key", base64.StdEncoding.EncodeToString(sshPub.Marshal()))
f, err := os.CreateTemp(t.TempDir(), "test-*.pub")
require.NoError(t, err)
_, err = f.WriteString(pubKeyStr)
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}
func sshKeysCmd() *cli.Command {
return &cli.Command{
Name: "ssh-keys",
Commands: []*cli.Command{
&sshkeyscmd.CmdSSHKeyList,
&sshkeyscmd.CmdSSHKeyAdd,
&sshkeyscmd.CmdSSHKeyDelete,
},
}
}
func TestSSHKeyAddAndDelete(t *testing.T) {
login := createIntegrationLogin(t)
pubKeyFile := generateTestPublicKey(t)
keyTitle := fmt.Sprintf("tea-test-%d", time.Now().Unix())
cmd := sshKeysCmd()
client := login.Client()
err := cmd.Run(context.Background(), []string{
"ssh-keys", "add", pubKeyFile,
"--title", keyTitle,
"--login", login.Name,
})
require.NoError(t, err)
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
ListOptions: gitea.ListOptions{Page: -1},
})
require.NoError(t, err)
var addedKey *gitea.PublicKey
for _, key := range keys {
if key.Title == keyTitle {
addedKey = key
break
}
}
require.NotNil(t, addedKey, "added key not found in key list")
t.Cleanup(func() {
client.DeletePublicKey(addedKey.ID) //nolint:errcheck
})
err = cmd.Run(context.Background(), []string{
"ssh-keys", "delete", strconv.FormatInt(addedKey.ID, 10),
"--confirm",
"--login", login.Name,
})
assert.NoError(t, err)
_, resp, err := client.GetPublicKey(addedKey.ID)
assert.Error(t, err)
if assert.NotNil(t, resp) {
assert.Equal(t, 404, resp.StatusCode)
}
}
func TestSSHKeyList(t *testing.T) {
login := createIntegrationLogin(t)
cmd := sshKeysCmd()
err := cmd.Run(context.Background(), []string{
"ssh-keys", "list",
"--login", login.Name,
})
assert.NoError(t, err)
}