24 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
53 changed files with 2391 additions and 318 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

@@ -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

@@ -61,12 +61,20 @@ func runReleaseAttachmentDelete(_ stdctx.Context, cmd *cli.Command) error {
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{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
return err 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:] {
var attachment *gitea.Attachment var attachment *gitea.Attachment

View File

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

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

@@ -32,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()
return err
}
} }
return open.Start(config.GetConfigPath()) return open.Start(config.GetConfigPath())
} }

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{
@@ -76,7 +76,7 @@ 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,7 @@ var CmdPulls = cli.Command{
&pulls.CmdPullsApprove, &pulls.CmdPullsApprove,
&pulls.CmdPullsReject, &pulls.CmdPullsReject,
&pulls.CmdPullsMerge, &pulls.CmdPullsMerge,
&pulls.CmdPullsReply,
&pulls.CmdPullsReviewComments, &pulls.CmdPullsReviewComments,
&pulls.CmdPullsResolve, &pulls.CmdPullsResolve,
&pulls.CmdPullsUnresolve, &pulls.CmdPullsUnresolve,
@@ -109,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{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
fmt.Printf("error while loading reviews: %v\n", err) 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") {

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

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,11 +32,18 @@ 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
// contexts rather than hanging on prompts.
if os.Getenv("CI") != "" || !print.IsInteractive() || interact.IsStdinPiped() {
return fmt.Errorf("pull review requires an interactive terminal")
}
for _, arg := range ctx.Args().Slice() {
idx, err := utils.ArgToIndex(arg)
if err != nil { if err != nil {
return err return err
} }
@@ -42,6 +51,8 @@ var CmdPullsReview = cli.Command{
if err := interact.ReviewPull(ctx, idx); err != nil && !interact.IsQuitting(err) { if err := interact.ReviewPull(ctx, idx); err != nil && !interact.IsQuitting(err) {
return err return err
} }
}
return nil return nil
}, },
Flags: flags.AllDefaultFlags, Flags: flags.AllDefaultFlags,

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
@@ -58,3 +65,62 @@ func runResolveComment(ctx *context.TeaContext, action func(*context.TeaContext,
return action(ctx, commentID) 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")
}
})
}
}

View File

@@ -11,13 +11,14 @@ import (
// GetReleaseByTag finds a release by its tag name. // GetReleaseByTag finds a release by its tag name.
func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) { func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Release, error) {
rl, _, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{ for page := 1; ; {
ListOptions: gitea.ListOptions{Page: -1}, rl, resp, err := client.ListReleases(owner, repo, gitea.ListReleasesOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(rl) == 0 { if page == 1 && len(rl) == 0 {
return nil, fmt.Errorf("repo does not have any release") return nil, fmt.Errorf("repo does not have any release")
} }
for _, r := range rl { for _, r := range rl {
@@ -25,5 +26,10 @@ func GetReleaseByTag(owner, repo, tag string, client *gitea.Client) (*gitea.Rele
return r, nil return r, nil
} }
} }
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
return nil, fmt.Errorf("release tag does not exist") 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{

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

@@ -89,14 +89,6 @@ 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")
@@ -106,6 +98,8 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error {
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{
@@ -113,6 +107,8 @@ func runWebhooksCreate(ctx stdctx.Context, cmd *cli.Command) error {
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 {
@@ -189,6 +153,8 @@ func TestWebhookCreateOptions(t *testing.T) {
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",
@@ -232,12 +200,16 @@ func TestWebhookCreateOptions(t *testing.T) {
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
@@ -129,12 +132,16 @@ func runWebhooksUpdate(ctx stdctx.Context, cmd *cli.Command) error {
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

@@ -130,8 +130,6 @@ func TestUpdateConfigPreservation(t *testing.T) {
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",
"authorization_header": "Bearer old-token",
"http_method": "post", "http_method": "post",
"content_type": "json", "content_type": "json",
} }
@@ -149,37 +147,18 @@ func TestUpdateConfigPreservation(t *testing.T) {
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",
"authorization_header": "Bearer old-token",
"http_method": "post", "http_method": "post",
"content_type": "json", "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",
"authorization_header": "X-Token: new-token",
"http_method": "post",
"content_type": "json",
},
},
{
name: "Clear branch filter",
updates: map[string]string{
"branch_filter": "",
},
expectedConfig: map[string]string{
"url": "https://old.example.com/webhook",
"secret": "old-secret",
"branch_filter": "",
"authorization_header": "Bearer old-token",
"http_method": "post", "http_method": "post",
"content_type": "json", "content_type": "json",
}, },
@@ -190,8 +169,6 @@ func TestUpdateConfigPreservation(t *testing.T) {
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",
"authorization_header": "Bearer old-token",
"http_method": "post", "http_method": "post",
"content_type": "json", "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

@@ -483,6 +483,18 @@ 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 ### review-comments, rc
List review comments on a pull request List review comments on a pull request
@@ -1043,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**="":
@@ -1065,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
@@ -1941,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
@@ -1985,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

10
go.mod
View File

@@ -5,15 +5,15 @@ 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
@@ -42,7 +42,7 @@ require (
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-20260330092749-0f94982c930b // 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-20260406091427-a791e22d5143 // 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

20
go.sum
View File

@@ -6,12 +6,12 @@ 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.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= code.gitea.io/sdk/gitea v0.25.0 h1:wSJlL0Qv+ODY2OdF0L7fwt86wgf1C/0g3xIXZ6eC5zI=
code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= code.gitea.io/sdk/gitea v0.25.0/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
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=
@@ -55,8 +55,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
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-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM= github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA= 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=
@@ -110,8 +110,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/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.6.1 h1:oQREINU63YckTRdJ+0VBmN6ewFSMXa0D862w8624/jw= github.com/go-authgate/sdk-go v0.10.0 h1:MNcfV6XSPs63SWPDdLqoJ9CFiKlXIue1RmiAbTXDAEI=
github.com/go-authgate/sdk-go v0.6.1/go.mod h1:55PLAPuu8GDK0omOwG6lx4c+9/T6dJwZd8kecUueLEk= github.com/go-authgate/sdk-go v0.10.0/go.mod h1:Afx/Dbyvf8pw4YeOqVEVdDW2WHhn534Sb2/TaFQktuU=
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=
@@ -120,8 +120,8 @@ 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.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM=
github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=

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

@@ -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,6 +104,13 @@ 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.RepoSlug == "" {
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
}
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil { if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists { if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure() // we can deal with that, commands needing the optional values use ctx.Ensure()
@@ -115,10 +118,6 @@ func InitCommand(cmd *cli.Command) (*TeaContext, error) {
return nil, err return nil, err
} }
} }
if len(repoFlag) != 0 && !repoFlagPathExists {
// if repoFlag is not a valid path, use it to override repoSlug
c.RepoSlug = repoFlag
} }
// 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

@@ -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{ r.LabelMap = make(map[string]int64)
ListOptions: gitea.ListOptions{Page: -1}, r.LabelList = make([]string, 0)
for page := 1; ; {
labels, resp, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
r.Err = err r.Err = err
done <- r done <- r
return return
} }
r.LabelMap = make(map[string]int64) for _, l := range labels {
r.LabelList = make([]string, len(labels))
for i, l := range labels {
r.LabelMap[l.Name] = l.ID r.LabelMap[l.Name] = l.ID
r.LabelList[i] = l.Name r.LabelList = append(r.LabelList, l.Name)
}
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
} }
done <- r done <- r

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

@@ -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

@@ -84,10 +84,10 @@ func TestWebhookDetails(t *testing.T) {
"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",
}, },
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"}, Events: []string{"push", "pull_request", "issues"},
Active: true, Active: true,
Created: now.Add(-24 * time.Hour), Created: now.Add(-24 * time.Hour),
@@ -240,14 +240,12 @@ func TestWebhookConfigHandling(t *testing.T) {
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",
@@ -344,10 +342,10 @@ func TestWebhookDetailsFormatting(t *testing.T) {
"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",
}, },
BranchFilter: "main,develop",
AuthorizationHeader: "Bearer token123",
Events: []string{"push", "pull_request", "issues"}, Events: []string{"push", "pull_request", "issues"},
Active: true, Active: true,
Created: now.Add(-24 * time.Hour), Created: now.Add(-24 * time.Hour),
@@ -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

@@ -13,8 +13,10 @@ 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{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -24,6 +26,11 @@ func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []st
labelIDs = append(labelIDs, l.ID) labelIDs = append(labelIDs, l.ID)
} }
} }
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
return labelIDs, nil return labelIDs, nil
} }

View File

@@ -166,12 +166,20 @@ 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{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil { if err != nil {
return "", err 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,12 +19,23 @@ 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{
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
}) })
if err != nil || len(keys) == 0 { if err != nil {
return "", err 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
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub") glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")

View File

@@ -14,12 +14,20 @@ import (
func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) { func ListPullReviewComments(ctx *context.TeaContext, idx int64) ([]*gitea.PullReviewComment, error) {
c := ctx.Login.Client() c := ctx.Login.Client()
reviews, _, err := c.ListPullReviews(ctx.Owner, ctx.Repo, idx, gitea.ListPullReviewsOptions{ var reviews []*gitea.PullReview
ListOptions: gitea.ListOptions{Page: -1}, 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 { if err != nil {
return nil, err return nil, err
} }
reviews = append(reviews, page_reviews...)
if resp == nil || resp.NextPage == 0 {
break
}
page = resp.NextPage
}
var allComments []*gitea.PullReviewComment var allComments []*gitea.PullReviewComment
for _, review := range reviews { for _, review := range reviews {
@@ -46,6 +54,21 @@ func ResolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
return nil 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 // UnresolvePullReviewComment unresolves a review comment
func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error { func UnresolvePullReviewComment(ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client() c := ctx.Login.Client()

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
}

View File

@@ -0,0 +1,109 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"encoding/base64"
"fmt"
"strconv"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/cmd/pulls"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestPullsReply(t *testing.T) {
login := createIntegrationLogin(t)
client := login.Client()
timestamp := time.Now().UnixNano()
repoName := fmt.Sprintf("tea-pr-reply-%d", timestamp)
featureBranch := fmt.Sprintf("reply-test-%d", timestamp)
replyBody := fmt.Sprintf("Thanks for the review %d", timestamp)
repo, _, err := client.CreateRepo(gitea.CreateRepoOption{
Name: repoName,
AutoInit: true,
DefaultBranch: "main",
})
require.NoError(t, err)
t.Cleanup(func() {
if _, delErr := client.DeleteRepo(login.User, repoName); delErr != nil {
t.Logf("failed to delete integration test repo %q: %v", repoName, delErr)
}
})
baseBranch := repo.DefaultBranch
if baseBranch == "" {
baseBranch = "main"
}
_, _, err = client.CreateFile(login.User, repoName, "review.txt", gitea.CreateFileOptions{
FileOptions: gitea.FileOptions{
Message: "add review target",
BranchName: baseBranch,
NewBranchName: featureBranch,
},
Content: base64.StdEncoding.EncodeToString([]byte("line for review\n")),
})
require.NoError(t, err)
pr, _, err := client.CreatePullRequest(login.User, repoName, gitea.CreatePullRequestOption{
Base: baseBranch,
Head: featureBranch,
Title: "Integration test for pr reply",
Body: "Adds a file so we can reply to a review comment.",
})
require.NoError(t, err)
review, _, err := client.CreatePullReview(login.User, repoName, pr.Index, gitea.CreatePullReviewOptions{
State: gitea.ReviewStateComment,
Body: "Please take another look.",
Comments: []gitea.CreatePullReviewComment{{
Path: "review.txt",
Body: "Could you clarify this line?",
NewLineNum: 1,
}},
})
require.NoError(t, err)
comments, _, err := client.ListPullReviewComments(login.User, repoName, pr.Index, review.ID)
require.NoError(t, err)
require.Len(t, comments, 1)
pullsCmd := &cli.Command{
Name: "pulls",
Commands: []*cli.Command{&pulls.CmdPullsReply},
}
err = pullsCmd.Run(context.Background(), []string{
"pulls",
"reply",
strconv.FormatInt(pr.Index, 10),
strconv.FormatInt(comments[0].ID, 10),
replyBody,
"--login",
login.Name,
"--repo",
repo.FullName,
})
require.NoError(t, err)
require.Eventually(t, func() bool {
reviewComments, _, listErr := client.ListPullReviewComments(login.User, repoName, pr.Index, review.ID)
if listErr != nil {
t.Logf("failed to list review comments: %v", listErr)
return false
}
for _, reviewComment := range reviewComments {
if reviewComment.Body == replyBody && reviewComment.ReviewID == review.ID {
return true
}
}
return false
}, 10*time.Second, 500*time.Millisecond)
}

View File

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

View File

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