31 Commits

Author SHA1 Message Date
Lunny Xiao
09bba53aec add integration test 2026-05-06 20:46:52 -07:00
Lunny Xiao
6afe288e4b Merge branch 'main' into lunny/add_reply_code_review 2026-05-06 17:19:08 -07:00
Lunny Xiao
4ff3775934 fix 2026-05-06 17:18:27 -07: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
Lunny Xiao
6af01bb13d Add reply to code review 2026-05-05 21:21:44 -07: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
103 changed files with 3908 additions and 602 deletions

View File

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

2
.gitignore vendored
View File

@@ -17,3 +17,5 @@ dist/
.direnv/ .direnv/
result result
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 to allow passing additional goflags via make CLI
override GOFLAGS := $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' 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) SOURCES ?= $(shell find . -name "*.go" -type f)
# OS specific vars. # OS specific vars.
@@ -64,11 +67,11 @@ vet:
.PHONY: lint .PHONY: lint
lint: lint:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools
.PHONY: lint-fix .PHONY: lint-fix
lint-fix: lint-fix:
$(GO) run $(GOLANGCI_LINT_PACKAGE) run --fix $(GO) run $(GOLANGCI_LINT_PACKAGE) run --build-tags testtools --fix
.PHONY: fmt-check .PHONY: fmt-check
fmt-check: fmt-check:
@@ -93,13 +96,24 @@ docs-check:
exit 1; \ exit 1; \
fi; 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 .PHONY: test
test: test: unit-test integration-test
$(GO) test -tags='sqlite sqlite_unlock_notify' $(PACKAGES)
.PHONY: unit-test-coverage .PHONY: unit-test-coverage
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 .PHONY: tidy
tidy: tidy:
@@ -122,4 +136,3 @@ $(EXECUTABLE): $(SOURCES)
.PHONY: build-image .PHONY: build-image
build-image: build-image:
docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) . docker build --build-arg VERSION=$(TEA_VERSION) -t gitea/tea:$(TEA_VERSION_TAG) .

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/cmd/releases"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/print" "code.gitea.io/tea/modules/print"
@@ -42,10 +43,10 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
tag := ctx.Args().First() tag := ctx.Args().First()
if len(tag) == 0 { if len(tag) == 0 {
return fmt.Errorf("Release tag needed to list attachments") return fmt.Errorf("release tag needed to list attachments")
} }
release, err := getReleaseByTag(ctx.Owner, ctx.Repo, tag, client) release, err := releases.GetReleaseByTag(ctx.Owner, ctx.Repo, tag, client)
if err != nil { if err != nil {
return err return err
} }
@@ -59,21 +60,3 @@ func RunReleaseAttachmentList(_ stdctx.Context, cmd *cli.Command) error {
return print.ReleaseAttachmentsList(attachments, ctx.Output) 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.CmdBranchesList,
&branches.CmdBranchesProtect, &branches.CmdBranchesProtect,
&branches.CmdBranchesUnprotect, &branches.CmdBranchesUnprotect,
&branches.CmdBranchesRename,
}, },
Flags: append([]cli.Flag{ Flags: append([]cli.Flag{
&cli.BoolFlag{ &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) owner, repo = utils.GetOwnerAndRepo(url.Path, login.User)
if url.Host != "" { 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 { 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) debug.Printf("Matched login '%s' for host '%s'", login.Name, url.Host)
} }

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ package flags
import ( import (
"errors" "errors"
"fmt"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -167,7 +168,7 @@ func ParseState(stateStr string) (gitea.StateType, error) {
case "closed": case "closed":
return gitea.StateClosed, nil return gitea.StateClosed, nil
default: 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": case "pull", "pulls", "pr":
return gitea.IssueTypePull, nil return gitea.IssueTypePull, nil
default: 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) ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName)
if err != nil { if err != nil {
return nil, fmt.Errorf("Milestone '%s' not found", milestoneName) return nil, fmt.Errorf("milestone '%s' not found", milestoneName)
} }
opts.Milestone = ms.ID opts.Milestone = ms.ID
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ package pulls
import ( import (
stdctx "context" stdctx "context"
"fmt"
"slices"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
@@ -43,7 +45,8 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
return err 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), ListOptions: flags.GetListOptions(cmd),
State: state, State: state,
}) })
@@ -56,5 +59,21 @@ func RunPullsList(_ stdctx.Context, cmd *cli.Command) error {
return err 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)
} }

29
cmd/pulls/reply.go Normal file
View File

@@ -0,0 +1,29 @@
// 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"
"github.com/urfave/cli/v3"
)
// CmdPullsReply replies to a review comment on a pull request.
var CmdPullsReply = cli.Command{
Name: "reply",
Usage: "Reply to a pull request review comment",
Description: "Reply to a pull request review comment",
ArgsUsage: "<pull index> <comment id> [<reply>]",
Action: func(_ stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
return runPullReviewReply(ctx)
},
Flags: flags.AllDefaultFlags,
}

70
cmd/pulls/reply_test.go Normal file
View File

@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"context"
"testing"
"code.gitea.io/tea/modules/config"
"github.com/stretchr/testify/assert"
)
func TestReply(t *testing.T) {
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "testLogin",
URL: "https://gitea.example.com",
Token: "test-token",
User: "testUser",
Default: true,
}},
})
t.Cleanup(func() {
config.SetConfigForTesting(config.LocalConfig{})
})
tests := []struct {
name string
args []string
wantErr bool
errContains string
}{
{
name: "no arguments",
args: []string{},
wantErr: true,
errContains: "pull request index and comment ID are required",
},
{
name: "missing comment id",
args: []string{"1"},
wantErr: true,
errContains: "pull request index and comment ID are required",
},
{
name: "pull index and comment id",
args: []string{"1", "2"},
wantErr: true,
errContains: "no reply content provided",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := CmdPullsReply
args := append([]string{"reply"}, tt.args...)
args = append(args, "--login", "testLogin", "--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
}
})
}
}

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 ( import (
stdctx "context" stdctx "context"
"fmt" "fmt"
"os"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/print"
"code.gitea.io/tea/modules/utils" "code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -30,18 +32,27 @@ var CmdPullsReview = cli.Command{
return err return err
} }
if ctx.Args().Len() != 1 { if !ctx.Args().Present() {
return fmt.Errorf("must specify a PR index") return fmt.Errorf("must specify at least one PR index")
} }
idx, err := utils.ArgToIndex(ctx.Args().First()) // This command is intentionally interactive. Fail early in CI / non-TTY
if err != nil { // contexts rather than hanging on prompts.
return err 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) { for _, arg := range ctx.Args().Slice() {
return err 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 return nil
}, },
Flags: flags.AllDefaultFlags, 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

@@ -4,13 +4,20 @@
package pulls package pulls
import ( import (
"errors"
"fmt" "fmt"
"io"
"strings" "strings"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/config"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils" "code.gitea.io/tea/modules/utils"
"charm.land/huh/v2"
) )
// runPullReview handles the common logic for approving/rejecting pull requests // runPullReview handles the common logic for approving/rejecting pull requests
@@ -40,3 +47,80 @@ func runPullReview(ctx *context.TeaContext, state gitea.ReviewStateType, require
return task.CreatePullReview(ctx, idx, state, comment, nil) 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)
}
// runPullReviewReply handles replying to a specific review comment on a pull request.
func runPullReviewReply(ctx *context.TeaContext) error {
if err := ctx.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
if ctx.Args().Len() < 2 {
return fmt.Errorf("pull request index and comment ID are required")
}
idx, err := utils.ArgToIndex(ctx.Args().First())
if err != nil {
return err
}
commentID, err := utils.ArgToIndex(ctx.Args().Get(1))
if err != nil {
return err
}
body, err := getCommentBody(ctx, ctx.Args().Slice()[2:], "Reply(markdown):", "reply")
if err != nil {
return err
}
return task.ReplyToPullReviewComment(ctx, idx, commentID, body)
}
func getCommentBody(ctx *context.TeaContext, extraArgs []string, promptTitle, noun string) (string, error) {
body := strings.Join(extraArgs, " ")
if interact.IsStdinPiped() {
bodyStdin, err := io.ReadAll(ctx.Reader)
if err != nil {
return "", err
}
if len(bodyStdin) != 0 {
body = strings.Join([]string{body, string(bodyStdin)}, "\n\n")
}
} else if len(body) == 0 {
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title(promptTitle).
ExternalEditor(config.GetPreferences().Editor).
EditorExtension("md").
Value(&body),
),
).WithTheme(theme.GetTheme()).Run(); err != nil {
return "", err
}
}
if len(strings.TrimSpace(body)) == 0 {
return "", errors.New("no " + noun + " content provided")
}
return body, nil
}

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 err != nil {
if resp != nil && resp.StatusCode == http.StatusConflict { 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 return err
} }

View File

@@ -55,7 +55,7 @@ func runReleaseDelete(_ stdctx.Context, cmd *cli.Command) error {
} }
for _, tag := range ctx.Args().Slice() { 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 { if err != nil {
return err return err
} }

View File

@@ -81,7 +81,7 @@ func runReleaseEdit(_ stdctx.Context, cmd *cli.Command) error {
} }
for _, tag := range ctx.Args().Slice() { 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 { if err != nil {
return err return err
} }

View File

@@ -5,7 +5,6 @@ package releases
import ( import (
stdctx "context" stdctx "context"
"fmt"
"code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/cmd/flags"
"code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/context"
@@ -48,21 +47,3 @@ func RunReleasesList(_ stdctx.Context, cmd *cli.Command) error {
return print.ReleasesList(releases, ctx.Output) 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" "github.com/urfave/cli/v3"
) )
// CmdRepos represents to login a gitea server. // CmdRepos represents the command to manage repositories.
var CmdRepos = cli.Command{ var CmdRepos = cli.Command{
Name: "repos", Name: "repos",
Aliases: []string{"repo"}, Aliases: []string{"repo"},
Category: catEntities, Category: catEntities,
Usage: "Show repository details", Usage: "Manage repositories",
Description: "Show repository details", Description: "Manage repositories",
ArgsUsage: "[<repo owner>/<repo name>]", ArgsUsage: "[<repo owner>/<repo name>]",
Action: runRepos, Action: runRepos,
Commands: []*cli.Command{ 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")) org, resp, err := client.GetOrg(teaCmd.String("owner"))
if err != nil { if err != nil {
if resp == nil || resp.StatusCode != http.StatusNotFound { 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 // 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 { 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()) 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() client := ctx.Login.Client()
if ctx.Args().Len() < 2 { 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()) 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() client := ctx.Login.Client()
if ctx.Args().Len() != 1 { 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()) 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 config["secret"] = secret
} }
if branchFilter != "" {
config["branch_filter"] = branchFilter
}
if authHeader != "" {
config["authorization_header"] = authHeader
}
var hook *gitea.Hook var hook *gitea.Hook
if c.IsGlobal { if c.IsGlobal {
return fmt.Errorf("global webhooks not yet supported in this version") return fmt.Errorf("global webhooks not yet supported in this version")
} else if len(c.Org) > 0 { } else if len(c.Org) > 0 {
hook, _, err = client.CreateOrgHook(c.Org, gitea.CreateHookOption{ hook, _, err = client.CreateOrgHook(c.Org, gitea.CreateHookOption{
Type: webhookType, Type: webhookType,
Config: config, Config: config,
Events: events, Events: events,
Active: active, Active: active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
}) })
} else { } else {
hook, _, err = client.CreateRepoHook(c.Owner, c.Repo, gitea.CreateHookOption{ hook, _, err = client.CreateRepoHook(c.Owner, c.Repo, gitea.CreateHookOption{
Type: webhookType, Type: webhookType,
Config: config, Config: config,
Events: events, Events: events,
Active: active, Active: active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
}) })
} }
if err != nil { if err != nil {

View File

@@ -79,8 +79,6 @@ func TestWebhookConfigConstruction(t *testing.T) {
name string name string
url string url string
secret string secret string
branchFilter string
authHeader string
expectedKeys []string expectedKeys []string
expectedValues map[string]string expectedValues map[string]string
}{ }{
@@ -106,44 +104,16 @@ func TestWebhookConfigConstruction(t *testing.T) {
"secret": "my-secret", "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", name: "Complete config",
url: "https://example.com/webhook", url: "https://example.com/webhook",
secret: "secret123", secret: "secret123",
branchFilter: "main", expectedKeys: []string{"url", "http_method", "content_type", "secret"},
authHeader: "X-Token: abc",
expectedKeys: []string{"url", "http_method", "content_type", "secret", "branch_filter", "authorization_header"},
expectedValues: map[string]string{ expectedValues: map[string]string{
"url": "https://example.com/webhook", "url": "https://example.com/webhook",
"http_method": "post", "http_method": "post",
"content_type": "json", "content_type": "json",
"secret": "secret123", "secret": "secret123",
"branch_filter": "main",
"authorization_header": "X-Token: abc",
}, },
}, },
} }
@@ -159,12 +129,6 @@ func TestWebhookConfigConstruction(t *testing.T) {
if tt.secret != "" { if tt.secret != "" {
config["secret"] = 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 // Check all expected keys exist
for _, key := range tt.expectedKeys { for _, key := range tt.expectedKeys {
@@ -184,11 +148,13 @@ func TestWebhookConfigConstruction(t *testing.T) {
func TestWebhookCreateOptions(t *testing.T) { func TestWebhookCreateOptions(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
webhookType string webhookType string
events []string events []string
active bool active bool
config map[string]string config map[string]string
branchFilter string
authHeader string
}{ }{
{ {
name: "Gitea webhook", name: "Gitea webhook",
@@ -200,6 +166,8 @@ func TestWebhookCreateOptions(t *testing.T) {
"http_method": "post", "http_method": "post",
"content_type": "json", "content_type": "json",
}, },
branchFilter: "main",
authHeader: "X-Token: abc",
}, },
{ {
name: "Slack webhook", name: "Slack webhook",
@@ -228,16 +196,20 @@ func TestWebhookCreateOptions(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
option := gitea.CreateHookOption{ option := gitea.CreateHookOption{
Type: gitea.HookType(tt.webhookType), Type: gitea.HookType(tt.webhookType),
Config: tt.config, Config: tt.config,
Events: tt.events, Events: tt.events,
Active: tt.active, Active: tt.active,
BranchFilter: tt.branchFilter,
AuthorizationHeader: tt.authHeader,
} }
assert.Equal(t, gitea.HookType(tt.webhookType), option.Type) assert.Equal(t, gitea.HookType(tt.webhookType), option.Type)
assert.Equal(t, tt.events, option.Events) assert.Equal(t, tt.events, option.Events)
assert.Equal(t, tt.active, option.Active) assert.Equal(t, tt.active, option.Active)
assert.Equal(t, tt.config, option.Config) 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") { if cmd.IsSet("secret") {
config["secret"] = cmd.String("secret") config["secret"] = cmd.String("secret")
} }
branchFilter := hook.BranchFilter
if cmd.IsSet("branch-filter") { 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") { if cmd.IsSet("authorization-header") {
config["authorization_header"] = cmd.String("authorization-header") authHeader = cmd.String("authorization-header")
} }
// Update events if specified // 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") return fmt.Errorf("global webhooks not yet supported in this version")
} else if len(c.Org) > 0 { } else if len(c.Org) > 0 {
_, err = client.EditOrgHook(c.Org, int64(webhookID), gitea.EditHookOption{ _, err = client.EditOrgHook(c.Org, int64(webhookID), gitea.EditHookOption{
Config: config, Config: config,
Events: events, Events: events,
Active: &active, Active: &active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
}) })
} else { } else {
_, err = client.EditRepoHook(c.Owner, c.Repo, int64(webhookID), gitea.EditHookOption{ _, err = client.EditRepoHook(c.Owner, c.Repo, int64(webhookID), gitea.EditHookOption{
Config: config, Config: config,
Events: events, Events: events,
Active: &active, Active: &active,
BranchFilter: branchFilter,
AuthorizationHeader: authHeader,
}) })
} }
if err != nil { if err != nil {

View File

@@ -128,12 +128,10 @@ func TestUpdateActiveInactiveFlags(t *testing.T) {
func TestUpdateConfigPreservation(t *testing.T) { func TestUpdateConfigPreservation(t *testing.T) {
// Test that existing configuration is preserved when not updated // Test that existing configuration is preserved when not updated
originalConfig := map[string]string{ originalConfig := map[string]string{
"url": "https://old.example.com/webhook", "url": "https://old.example.com/webhook",
"secret": "old-secret", "secret": "old-secret",
"branch_filter": "main", "http_method": "post",
"authorization_header": "Bearer old-token", "content_type": "json",
"http_method": "post",
"content_type": "json",
} }
tests := []struct { tests := []struct {
@@ -147,53 +145,32 @@ func TestUpdateConfigPreservation(t *testing.T) {
"url": "https://new.example.com/webhook", "url": "https://new.example.com/webhook",
}, },
expectedConfig: map[string]string{ expectedConfig: map[string]string{
"url": "https://new.example.com/webhook", "url": "https://new.example.com/webhook",
"secret": "old-secret", "secret": "old-secret",
"branch_filter": "main", "http_method": "post",
"authorization_header": "Bearer old-token", "content_type": "json",
"http_method": "post",
"content_type": "json",
}, },
}, },
{ {
name: "Update secret and auth header", name: "Update secret",
updates: map[string]string{ updates: map[string]string{
"secret": "new-secret", "secret": "new-secret",
"authorization_header": "X-Token: new-token",
}, },
expectedConfig: map[string]string{ expectedConfig: map[string]string{
"url": "https://old.example.com/webhook", "url": "https://old.example.com/webhook",
"secret": "new-secret", "secret": "new-secret",
"branch_filter": "main", "http_method": "post",
"authorization_header": "X-Token: new-token", "content_type": "json",
"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",
}, },
}, },
{ {
name: "No updates", name: "No updates",
updates: map[string]string{}, updates: map[string]string{},
expectedConfig: map[string]string{ expectedConfig: map[string]string{
"url": "https://old.example.com/webhook", "url": "https://old.example.com/webhook",
"secret": "old-secret", "secret": "old-secret",
"branch_filter": "main", "http_method": "post",
"authorization_header": "Bearer old-token", "content_type": "json",
"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) { func TestUpdateEventsHandling(t *testing.T) {
tests := []struct { tests := []struct {
name string 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) **--comments**: Whether to display comments (will prompt if not provided & run interactively)
**--fields, -f**="": Comma-separated list of fields to print. Available values: **--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") (default: "index,title,state,author,milestone,updated,labels")
**--limit, --lm**="": specify limit of items per page (default: 30) **--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 List pull requests of the repository
**--fields, -f**="": Comma-separated list of fields to print. Available values: **--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") (default: "index,title,state,author,milestone,updated,labels")
**--limit, --lm**="": specify limit of items per page (default: 30) **--limit, --lm**="": specify limit of items per page (default: 30)
@@ -483,6 +483,58 @@ Merge a pull request
**--title, -t**="": Merge commit title **--title, -t**="": Merge commit title
### reply
Reply to a pull request review comment
**--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
### 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 ## labels, label
Manage issue labels Manage issue labels
@@ -1003,12 +1055,12 @@ Create an organization
**--description, -d**="": **--description, -d**="":
**--full-name, -n**="":
**--location, -L**="": **--location, -L**="":
**--login, -l**="": Use a different Gitea Login. Optional **--login, -l**="": Use a different Gitea Login. Optional
**--name, -n**="":
**--repo-admins-can-change-team-access**: **--repo-admins-can-change-team-access**:
**--visibility, -v**="": **--visibility, -v**="":
@@ -1025,7 +1077,7 @@ Delete users Organizations
## repos, repo ## repos, repo
Show repository details Manage repositories
**--fields, -f**="": Comma-separated list of fields to print. Available values: **--fields, -f**="": Comma-separated list of fields to print. Available values:
description,forks,id,name,owner,stars,ssh,updated,url,permission,type description,forks,id,name,owner,stars,ssh,updated,url,permission,type
@@ -1341,6 +1393,26 @@ Unprotect branches
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional **--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 ## actions, action
Manage repository actions Manage repository actions
@@ -1533,13 +1605,65 @@ Manage repository workflows
List 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 **--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) **--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 **--remote, -R**="": Discover Gitea login from remote. Optional
@@ -1829,6 +1953,50 @@ Clone a repository locally
**--login, -l**="": Use a different Gitea Login. Optional **--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 ## admin, a
Operations requiring admin access on the Gitea instance Operations requiring admin access on the Gitea instance
@@ -1873,6 +2041,116 @@ List Users
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional **--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 ## api
Make an authenticated API request Make an authenticated API request

View File

@@ -1,8 +1,93 @@
# Gitea actions workflows # 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 ## Merge Pull request on approval
``` Yaml ```yaml
--- ---
name: Pull request name: Pull request
on: on:

49
go.mod
View File

@@ -5,36 +5,35 @@ go 1.26
require ( require (
charm.land/glamour/v2 v2.0.0 charm.land/glamour/v2 v2.0.0
charm.land/huh/v2 v2.0.3 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/gitea-vet v0.2.3
code.gitea.io/sdk/gitea v0.24.1 code.gitea.io/sdk/gitea v0.25.0
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/enescakir/emoji v1.0.0 github.com/enescakir/emoji v1.0.0
github.com/go-authgate/sdk-go v0.6.1 github.com/go-authgate/sdk-go v0.10.0
github.com/go-git/go-git/v5 v5.17.2 github.com/go-git/go-git/v5 v5.18.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/olekukonko/tablewriter v1.1.4 github.com/olekukonko/tablewriter v1.1.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.8.0 github.com/urfave/cli/v3 v3.8.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.43.0
golang.org/x/term v0.41.0 golang.org/x/term v0.42.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect charm.land/bubbles/v2 v2.1.0 // indirect
charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
github.com/42wim/httpsig v1.2.4 // indirect github.com/42wim/httpsig v1.2.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -42,10 +41,10 @@ require (
github.com/catppuccin/go v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
github.com/charmbracelet/x/ansi v0.11.6 // 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/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-20260406091427-a791e22d5143 // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -61,28 +60,28 @@ require (
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.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-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // 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/go-git/go-billy/v5 v5.8.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/gorilla/css v1.0.1 // 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/errors v1.2.0 // indirect
github.com/olekukonko/ll v0.1.7 // indirect github.com/olekukonko/ll v0.1.8 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
@@ -91,13 +90,13 @@ require (
github.com/skeema/knownhosts v1.3.2 // indirect github.com/skeema/knownhosts v1.3.2 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // 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/yuin/goldmark-emoji v1.0.6 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect github.com/zalando/go-keyring v0.2.8 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.44.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
) )

119
go.sum
View File

@@ -1,27 +1,21 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
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 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= 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/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 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= 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.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= 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 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= 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.25.0 h1:wSJlL0Qv+ODY2OdF0L7fwt86wgf1C/0g3xIXZ6eC5zI=
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.25.0/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8=
code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 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 h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= 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 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -29,8 +23,8 @@ 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.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= 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 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= 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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
@@ -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/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 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= 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-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg= github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 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 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= 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= 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/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 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/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-20260406091427-a791e22d5143 h1:aEppolah2k9c0LzKX2fk5ryuyQ0Lq8kCOjkvMw1b8o4=
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-20260406091427-a791e22d5143/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 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA=
github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= 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= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -112,14 +106,12 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 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 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog=
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= 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.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 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.10.0 h1:MNcfV6XSPs63SWPDdLqoJ9CFiKlXIue1RmiAbTXDAEI=
github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho= github.com/go-authgate/sdk-go v0.10.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU=
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-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= 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-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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
@@ -128,26 +120,20 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
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 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-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.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5Jk= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= 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/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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 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= 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.21/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.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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -184,10 +170,8 @@ github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= 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 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= 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.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/ll v0.1.8/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/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= 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/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 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
@@ -225,8 +209,6 @@ 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/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 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= 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 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/cli/v3 v3.8.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 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -234,31 +216,31 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 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/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.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 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.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= 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-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-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-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-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.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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 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/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= 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/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-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-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.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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-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.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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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) codeChan := make(chan string, 1)
stateChan := make(chan string, 1) stateChan := make(chan string, 1)
errChan := make(chan error, 1) errChan := make(chan error, 1)
portChan := make(chan int, 1)
// Parse the redirect URL to get the path // Parse the redirect URL to get the path
parsedURL, err := url.Parse(opts.RedirectURL) parsedURL, err := url.Parse(opts.RedirectURL)
@@ -311,7 +310,6 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOpt
if port == 0 { if port == 0 {
addr := listener.Addr().(*net.TCPAddr) addr := listener.Addr().(*net.TCPAddr)
port = addr.Port port = addr.Port
portChan <- port
// Update redirect URL with actual port // Update redirect URL with actual port
parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port) parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port)

View File

@@ -5,7 +5,6 @@ package config
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -74,7 +73,8 @@ func GetConfigPath() string {
} }
if err != nil { 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 return configFilePath

View File

@@ -8,7 +8,6 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
@@ -132,7 +131,7 @@ func GetDefaultLogin() (*Login, error) {
} }
if len(config.Logins) == 0 { if len(config.Logins) == 0 {
return nil, errors.New("No available login") return nil, errors.New("no available login")
} }
for _, l := range config.Logins { for _, l := range config.Logins {
if l.Default { if l.Default {
@@ -178,50 +177,51 @@ func GetLoginByName(name string) (*Login, error) {
} }
// GetLoginByToken get login by token // GetLoginByToken get login by token
func GetLoginByToken(token string) *Login { func GetLoginByToken(token string) (*Login, error) {
if token == "" { if token == "" {
return nil return nil, nil
} }
err := loadConfig() if err := loadConfig(); err != nil {
if err != nil { return nil, err
log.Fatal(err)
} }
for _, l := range config.Logins { for _, l := range config.Logins {
if l.Token == token { if l.Token == token {
return &l return &l, nil
} }
} }
return nil return nil, nil
} }
// GetLoginByHost finds a login by it's server URL // GetLoginByHost finds a login by its server URL
func GetLoginByHost(host string) *Login { func GetLoginByHost(host string) (*Login, error) {
logins := GetLoginsByHost(host) logins, err := GetLoginsByHost(host)
if len(logins) > 0 { if err != nil {
return logins[0] return nil, err
} }
return nil if len(logins) > 0 {
return logins[0], nil
}
return nil, nil
} }
// GetLoginsByHost returns all logins matching a host // GetLoginsByHost returns all logins matching a host
func GetLoginsByHost(host string) []*Login { func GetLoginsByHost(host string) ([]*Login, error) {
err := loadConfig() if err := loadConfig(); err != nil {
if err != nil { return nil, err
log.Fatal(err)
} }
var matches []*Login var matches []*Login
for i := range config.Logins { for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL) loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil { if err != nil {
log.Fatal(err) return nil, err
} }
if loginURL.Host == host { if loginURL.Host == host {
matches = append(matches, &config.Logins[i]) matches = append(matches, &config.Logins[i])
} }
} }
return matches return matches, nil
} }
// DeleteLogin delete a login by name from config // 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 { func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
// Refresh OAuth token if expired or near expiry // Refresh OAuth token if expired or near expiry
if err := l.RefreshOAuthTokenIfNeeded(); err != nil { 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{} httpClient := &http.Client{}
if l.Insecure { if l.Insecure {
cookieJar, _ := cookiejar.New(nil) cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{ httpClient = &http.Client{
Jar: cookieJar, Jar: cookieJar,
@@ -443,12 +444,18 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
} }
if l.SSHCertPrincipal != "" { 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)) options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase))
} }
if l.SSHKeyFingerprint != "" { 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)) 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 { if err != nil {
var versionError *gitea.ErrUnknownVersion var versionError *gitea.ErrUnknownVersion
if !errors.As(err, &versionError) { 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) fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
} }
return client return client
} }
func (l *Login) askForSSHPassphrase() { func (l *Login) askForSSHPassphrase() error {
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" { 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: "). Title("ssh-key is encrypted please enter the passphrase: ").
Validate(huh.ValidateNotEmpty()). Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword). EchoMode(huh.EchoModePassword).
Value(&l.SSHPassphrase). Value(&l.SSHPassphrase).
WithTheme(theme.GetTheme()). WithTheme(theme.GetTheme()).
Run(); err != nil { Run()
log.Fatal(err)
}
} }
return nil
} }
// GetSSHHost returns SSH host name // 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" "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. // ErrCommandCanceled is returned when the user explicitly cancels an interactive prompt.
var ErrCommandCanceled = errors.New("command canceled") var ErrCommandCanceled = errors.New("command canceled")
@@ -83,6 +83,8 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
} }
if repoFlagPathExists { if repoFlagPathExists {
repoPath = repoFlag repoPath = repoFlag
} else {
c.RepoSlug = repoFlag
} }
} }
@@ -90,12 +92,6 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
remoteFlag = config.GetPreferences().FlagDefaults.Remote 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 // Create env login before repo context detection so it participates in remote URL matching
var extraLogins []config.Login var extraLogins []config.Login
envLogin := GetLoginByEnvVar() 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, // 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 // 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 c.RepoSlug == "" {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { if repoPath == "" {
// we can deal with that, commands needing the optional values use ctx.Ensure() if repoPath, err = os.Getwd(); err != nil {
} else { return nil, err
return nil, err }
} }
}
if len(repoFlag) != 0 && !repoFlagPathExists { if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
// if repoFlag is not a valid path, use it to override repoSlug if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
c.RepoSlug = repoFlag // 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 // 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 return nil, err
} }
if remote == nil { 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 remoteName := remote.Config().Name
@@ -133,7 +133,7 @@ func (r TeaRepo) TeaFindBranchByName(branchName, repoURL string) (b *git_config.
return nil, err return nil, err
} }
if remote == nil { 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 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 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.LabelMap = make(map[string]int64)
r.LabelList = make([]string, len(labels)) r.LabelList = make([]string, 0)
for i, l := range labels { for page := 1; ; {
r.LabelMap[l.Name] = l.ID labels, resp, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
r.LabelList[i] = l.Name 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 done <- r

View File

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

View File

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

View File

@@ -154,27 +154,23 @@ func ActionWorkflowJobsList(jobs []*gitea.ActionWorkflowJob, output string) erro
return t.print(output) return t.print(output)
} }
// WorkflowsList prints a list of workflow files with active status // ActionWorkflowsList prints a list of workflows from the workflow API
func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]bool, output string) error { func ActionWorkflowsList(workflows []*gitea.ActionWorkflow, output string) error {
t := table{ t := table{
headers: []string{ headers: []string{
"Active", "ID",
"Name", "Name",
"Path", "Path",
"State",
}, },
} }
machineReadable := isMachineReadable(output) for _, wf := range workflows {
for _, workflow := range workflows {
// Check if this workflow file is active (has runs)
isActive := activeStatus[workflow.Name]
activeIndicator := formatBoolean(isActive, !machineReadable)
t.addRow( t.addRow(
activeIndicator, wf.ID,
workflow.Name, wf.Name,
workflow.Path, wf.Path,
wf.State,
) )
} }
@@ -186,3 +182,34 @@ func WorkflowsList(workflows []*gitea.ContentsResponse, activeStatus map[string]
t.sort(1, true) // Sort by name column t.sort(1, true) // Sort by name column
return t.print(output) 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, "")) 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) { func TestFormatDurationMinutes(t *testing.T) {
now := time.Now() now := time.Now()

View File

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

View File

@@ -67,13 +67,21 @@ func WebhookDetails(hook *gitea.Hook) {
if method, ok := hook.Config["http_method"]; ok { if method, ok := hook.Config["http_method"]; ok {
fmt.Printf("- **HTTP Method**: %s\n", method) 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) fmt.Printf("- **Branch Filter**: %s\n", branchFilter)
} }
if _, hasSecret := hook.Config["secret"]; hasSecret { if _, hasSecret := hook.Config["secret"]; hasSecret {
fmt.Printf("- **Secret**: (configured)\n") 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") fmt.Printf("- **Authorization Header**: (configured)\n")
} }
} }

View File

@@ -81,17 +81,17 @@ func TestWebhookDetails(t *testing.T) {
ID: 123, ID: 123,
Type: "gitea", Type: "gitea",
Config: map[string]string{ Config: map[string]string{
"url": "https://example.com/webhook", "url": "https://example.com/webhook",
"content_type": "json", "content_type": "json",
"http_method": "post", "http_method": "post",
"branch_filter": "main,develop", "secret": "secret-value",
"secret": "secret-value",
"authorization_header": "Bearer token123",
}, },
Events: []string{"push", "pull_request", "issues"}, BranchFilter: "main,develop",
Active: true, AuthorizationHeader: "Bearer token123",
Created: now.Add(-24 * time.Hour), Events: []string{"push", "pull_request", "issues"},
Updated: now, Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
}, },
}, },
{ {
@@ -238,16 +238,14 @@ func TestWebhookConfigHandling(t *testing.T) {
{ {
name: "Config with all fields", name: "Config with all fields",
config: map[string]string{ config: map[string]string{
"url": "https://example.com/webhook", "url": "https://example.com/webhook",
"secret": "my-secret", "secret": "my-secret",
"authorization_header": "Bearer token", "content_type": "json",
"content_type": "json", "http_method": "post",
"http_method": "post",
"branch_filter": "main",
}, },
expectedURL: "https://example.com/webhook", expectedURL: "https://example.com/webhook",
hasSecret: true, hasSecret: true,
hasAuthHeader: true, hasAuthHeader: false,
}, },
{ {
name: "Config with minimal fields", name: "Config with minimal fields",
@@ -341,17 +339,17 @@ func TestWebhookDetailsFormatting(t *testing.T) {
ID: 123, ID: 123,
Type: "gitea", Type: "gitea",
Config: map[string]string{ Config: map[string]string{
"url": "https://example.com/webhook", "url": "https://example.com/webhook",
"content_type": "json", "content_type": "json",
"http_method": "post", "http_method": "post",
"branch_filter": "main,develop", "secret": "secret-value",
"secret": "secret-value",
"authorization_header": "Bearer token123",
}, },
Events: []string{"push", "pull_request", "issues"}, BranchFilter: "main,develop",
Active: true, AuthorizationHeader: "Bearer token123",
Created: now.Add(-24 * time.Hour), Events: []string{"push", "pull_request", "issues"},
Updated: now, Active: true,
Created: now.Add(-24 * time.Hour),
Updated: now,
} }
// Test that all expected fields are included in details // 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, "https://example.com/webhook", hook.Config["url"])
assert.Equal(t, "json", hook.Config["content_type"]) assert.Equal(t, "json", hook.Config["content_type"])
assert.Equal(t, "post", hook.Config["http_method"]) 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, "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) 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 { func CreateIssue(login *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error {
// title is required // title is required
if len(opts.Title) == 0 { 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) 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 // 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) { func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) {
labelIDs := make([]int64, 0, len(labelNames)) labelIDs := make([]int64, 0, len(labelNames))
labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{ page := 1
ListOptions: gitea.ListOptions{Page: -1}, for {
}) labels, resp, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
if err != nil { ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
return nil, err })
} if err != nil {
for _, l := range labels { return nil, err
if utils.Contains(labelNames, l.Name) {
labelIDs = append(labelIDs, l.ID)
} }
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 return labelIDs, nil
} }

View File

@@ -20,12 +20,13 @@ import (
func SetupHelper(login config.Login) (ok bool, err error) { func SetupHelper(login config.Login) (ok bool, err error) {
// Check that the URL is not blank // Check that the URL is not blank
if login.URL == "" { 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 // get all helper to URL in git config
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
var currentHelpers []byte 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{} currentHelpers = []byte{}
} }
@@ -37,10 +38,10 @@ func SetupHelper(login config.Login) (ok bool, err error) {
} }
// Add tea helper // Add tea helper
if _, err = exec.Command("git", "config", "--global", fmt.Sprintf("credential.%s.helper", login.URL), "").Output(); err != nil { if _, err = exec.Command("git", "config", "--global", helperKey, "").Output(); err != nil {
return false, fmt.Errorf("git config --global %s, error: %s", fmt.Sprintf("credential.%s.helper", login.URL), err) return false, fmt.Errorf("git config --global %s, error: %s", helperKey, err)
} else if _, err = exec.Command("git", "config", "--global", "--add", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper").Output(); err != nil { } 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", fmt.Sprintf("credential.%s.helper", login.URL), "!tea login helper", err) return false, fmt.Errorf("git config --global --add %s %s, error: %s", helperKey, "!tea login helper", err)
} }
return true, nil 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 we already use this token
if shouldCheckTokenUniqueness(token, sshAgent, sshKey, sshCertPrincipal, sshKeyFingerprint) { 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) 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...) client := login.Client(opts...)
tl, _, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{ var tl []*gitea.AccessToken
ListOptions: gitea.ListOptions{Page: -1}, for page := 1; ; {
}) page_tokens, resp, err := client.ListAccessTokens(gitea.ListAccessTokensOptions{
if err != nil { ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
return "", err })
if err != nil {
return "", err
}
tl = append(tl, page_tokens...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
} }
host, _ := os.Hostname() host, _ := os.Hostname()
tokenName := host + "-tea" tokenName := host + "-tea"

View File

@@ -19,11 +19,22 @@ import (
// a matching private key in ~/.ssh/. If no match is found, path is empty. // a matching private key in ~/.ssh/. If no match is found, path is empty.
func findSSHKey(client *gitea.Client) (string, error) { func findSSHKey(client *gitea.Client) (string, error) {
// get keys registered on gitea instance // get keys registered on gitea instance
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{ var keys []*gitea.PublicKey
ListOptions: gitea.ListOptions{Page: -1}, for page := 1; ; {
}) page_keys, resp, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{
if err != nil || len(keys) == 0 { ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
return "", err })
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 // 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 { func CreateMilestone(login *config.Login, repoOwner, repoName, title, description string, deadline *time.Time, state gitea.StateType) error {
// title is required // title is required
if len(title) == 0 { 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{ 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" // if remote head branch is already deleted, pr.Head.Ref points to "pulls/<idx>/head"
remoteBranch := pr.Head.Ref remoteBranch := pr.Head.Ref
remoteDeleted := remoteBranch == fmt.Sprintf("refs/pull/%d/head", pr.Index) remoteDeleted := isRemoteDeleted(pr)
if remoteDeleted { if remoteDeleted {
remoteBranch = pr.Head.Name // this still holds the original branch name remoteBranch = pr.Head.Name // this still holds the original branch name
fmt.Printf("Remote branch '%s' already deleted.\n", remoteBranch) 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 branch == nil {
if ignoreSHA { 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. 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, If you still want to continue & are sure you don't loose any important commits,
call me again with the --ignore-sha flag`, remoteBranch) 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 return err
} }
if !success { 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 return nil
} }

View File

@@ -0,0 +1,83 @@
// 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
}
// ReplyToPullReviewComment replies to a review comment on a pull request.
func ReplyToPullReviewComment(ctx *context.TeaContext, idx, commentID int64, body string) error {
c := ctx.Login.Client()
comment, _, err := c.CreatePullReviewCommentReply(ctx.Owner, ctx.Repo, idx, commentID, gitea.CreatePullReviewCommentReplyOptions{
Body: body,
})
if err != nil {
return err
}
fmt.Println(comment.HTMLURL)
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" "os/user"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
) )
// PathExists returns whether the given file or directory exists or not // 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 err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return false, nil return false, nil
} else if err.(*os.PathError).Err.Error() == "not a directory" { }
// some middle segment of path is a file, cannot traverse var pathErr *os.PathError
// FIXME: catches error on linux; go does not provide a way to catch this properly.. 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, nil
} }
return false, err return false, err
} }
isDir := f.IsDir() isDir := f.IsDir()
if isDir && !expectDir { 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 { } 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 return true, nil
} }

View File

@@ -21,17 +21,17 @@ func ValidateAuthenticationMethod(
// Normalize URL // Normalize URL
serverURL, err := NormalizeURL(giteaURL) serverURL, err := NormalizeURL(giteaURL)
if err != nil { 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 !sshAgent && sshCertPrincipal == "" && sshKey == "" {
// .. if we have enough information to authenticate // .. if we have enough information to authenticate
if len(token) == 0 && (len(user)+len(passwd)) == 0 { 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 { } 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 { } 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 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 //go:build unix
package config package integration
import ( import (
"fmt" "fmt"
@@ -12,10 +12,11 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"code.gitea.io/tea/modules/config"
) )
func TestConfigLock_CrossProcess(t *testing.T) { func TestConfigLock_CrossProcess(t *testing.T) {
// Create a temp directory for test
tmpDir, err := os.MkdirTemp("", "tea-lock-test") tmpDir, err := os.MkdirTemp("", "tea-lock-test")
if err != nil { if err != nil {
t.Fatalf("failed to create temp dir: %v", err) 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") lockPath := filepath.Join(tmpDir, "config.yml.lock")
// Acquire lock in main process unlock, err := config.AcquireConfigLockForTesting(lockPath, 5*time.Second)
unlock, err := acquireConfigLock(lockPath, 5*time.Second)
if err != nil { if err != nil {
t.Fatalf("failed to acquire lock: %v", err) 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(` script := fmt.Sprintf(`
package main package main
@@ -48,19 +50,16 @@ func main() {
} }
defer file.Close() defer file.Close()
// Try non-blocking lock
err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) err = syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil { if err != nil {
// Lock is held - expected behavior
os.Exit(0) os.Exit(0)
} }
// Lock was acquired - unexpected
syscall.Flock(int(file.Fd()), syscall.LOCK_UN) syscall.Flock(int(file.Fd()), syscall.LOCK_UN)
os.Exit(1) os.Exit(1)
} }
`, lockPath) `, lockPath)
// Write and run the test script
scriptPath := filepath.Join(tmpDir, "locktest.go") scriptPath := filepath.Join(tmpDir, "locktest.go")
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
t.Fatalf("failed to write test script: %v", err) t.Fatalf("failed to write test script: %v", err)
@@ -78,5 +77,4 @@ func main() {
t.Errorf("subprocess execution failed: %v", err) 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. // Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
package git package integration
import ( import (
"os" "os"
@@ -9,11 +9,11 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
teagit "code.gitea.io/tea/modules/git"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestRepoFromPath_Worktree(t *testing.T) { func TestRepoFromPath_Worktree(t *testing.T) {
// Create a temporary directory for test
tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*") tmpDir, err := os.MkdirTemp("", "tea-worktree-test-*")
assert.NoError(t, err) assert.NoError(t, err)
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -21,21 +21,17 @@ func TestRepoFromPath_Worktree(t *testing.T) {
mainRepoPath := filepath.Join(tmpDir, "main-repo") mainRepoPath := filepath.Join(tmpDir, "main-repo")
worktreePath := filepath.Join(tmpDir, "worktree") worktreePath := filepath.Join(tmpDir, "worktree")
// Initialize main repository
cmd := exec.Command("git", "init", mainRepoPath) cmd := exec.Command("git", "init", mainRepoPath)
assert.NoError(t, cmd.Run()) assert.NoError(t, cmd.Run())
// Configure git for the test
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com") cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.email", "test@example.com")
assert.NoError(t, cmd.Run()) assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User") cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
assert.NoError(t, cmd.Run()) 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") cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
assert.NoError(t, cmd.Run()) assert.NoError(t, cmd.Run())
// Create an initial commit (required for worktree)
readmePath := filepath.Join(mainRepoPath, "README.md") readmePath := filepath.Join(mainRepoPath, "README.md")
err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644) err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0o644)
assert.NoError(t, err) assert.NoError(t, err)
@@ -44,19 +40,14 @@ func TestRepoFromPath_Worktree(t *testing.T) {
cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit") cmd = exec.Command("git", "-C", mainRepoPath, "commit", "-m", "Initial commit")
assert.NoError(t, cmd.Run()) assert.NoError(t, cmd.Run())
// Create a worktree
cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch") cmd = exec.Command("git", "-C", mainRepoPath, "worktree", "add", worktreePath, "-b", "test-branch")
assert.NoError(t, cmd.Run()) assert.NoError(t, cmd.Run())
// Test: Open repository from worktree path repo, err := teagit.RepoFromPath(worktreePath)
repo, err := RepoFromPath(worktreePath)
assert.NoError(t, err, "Should be able to open worktree") 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() config, err := repo.Config()
assert.NoError(t, err, "Should be able to read 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.NotEmpty(t, config.Remotes, "Should be able to read remotes from worktree")
assert.Contains(t, config.Remotes, "origin", "Should have origin remote") 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") 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
}

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