Compare commits

..
Author SHA1 Message Date
9c12138d62 Make tea issues create honor --output json (#1114)
## Problem

Follow-up to #1111, covering the issues side of the same hole: `tea issues create` accepts `--output` (it parses through the urfave/cli v3 ancestor-flag cascade — `issues` carries the flag via `AllDefaultFlags`, `create` never declares it) but the action never reads it. Without this fix, `tea issues create --output json | jq .url` feeds jq a markdown document.

The default output is doubly hostile to consumers: glamour renders the details as markdown (with OSC 8 hyperlinks around the URL when piped), and a second bare `fmt.Println(issue.HTMLURL)` line follows it.

## What this changes

- `task.CreateIssue` now returns the created `*gitea.Issue` instead of printing it.
- `runIssuesCreate` switches on `--output`, mirroring the detail-command precedent and the merged create-PR behavior from #1111: `--output json` emits compact lean JSON; any other value (or no flag) falls through to the previous rendering, byte-identical to before.
- Lean JSON shape: `index`, `title`, `url`, `state` — matching `createdPullJSON` in `cmd/pulls/create.go`, including its post-review compact encoding.
- The interactive path is untouched — it only triggers when zero flags are set, so `--output` can never be active there.

Example:

```
$ tea issues create --output json --title "bug: thing" | jq -r .url
https://gitea.example.com/owner/repo/issues/42
```

There is no agit-flow equivalent on issues, so no extra guard is needed — unlike the pulls side, every creation path produces an `*gitea.Issue`.

---------

Co-authored-by: Danilo Sousa <code@danilosousa.net>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1114
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: ongolk <238961+ongolk@noreply.gitea.com>
2026-09-10 04:29:39 +00:00
Jan BaerandLunny Xiao 4d09587d4c fix(pulls): report the real reason when a merge is refused (#1107)
## Problem

`tea pr merge <index>` reports the same misleading error for every refusal:

```
failed to merge PR, is it still open?
```

The PR usually *is* still open — `tea pr <index>` shows it as open and lists `Conflicting files` — so the message sends users looking in the wrong direction.

## Root cause

Gitea answers an unmergeable PR with a 405 and a body naming the actual cause. The SDK's `MergePullRequest` is built on `getStatusCode`, which returns only the status code and never calls `statusCodeToErr`, so the body is discarded. tea receives `success=false, err=nil` with no server explanation to pass on, and fell back to guessing that the PR might be closed.

## Changes

- Derive the refusal reason from the pull request when a merge fails: already merged, closed, draft, or not mergeable.
- When the PR looks mergeable but was refused anyway, name the conditions tea cannot observe (required status checks, requested reviews, branch protection) instead of guessing.
- Include the PR index in the error.
- Add table-driven tests for every reason, plus the case where the follow-up PR lookup fails.

The extra API call happens only on the failure path.

Fixes #1022

---------

Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1107
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Jan Baer <jan.s.baer@googlemail.com>
2026-09-09 19:15:46 +00:00
b2bab268d7 Make tea pulls create honor --output json (#1111)
## Problem

`tea pulls create` accepts `--output` (the flag parses successfully) but never reads it — the action always prints glamour-rendered markdown regardless of the requested format. That is a trap for anyone scripting the CLI: `tea pr create --output json | jq .url` quietly feeds jq a markdown document.

The current output is also hostile to URL-scraping consumers even without `--output`: glamour autolinks the bare PR URL into an OSC 8 terminal hyperlink, so piped stdout contains

```
\x1b]8;;https://host/owner/repo/pulls/33\x1b\\https://host/owner/repo/pulls/33\x1b]8;;\x1b\\
```

instead of a plain URL (repro: `tea pr create ... | cat -v`).

## Why the flag parses but does nothing

`create` itself does not declare `--output`: its flag set (`IssuePRCreateFlags`) carries no `OutputFlag`. The flag parses anyway because urfave/cli v3 resolves flags through `Command.lookupAppliedFlag`, which searches `appliedFlags` — "local flags for current command **or persistent flags from ancestors**". The parent `pulls` command carries `--output` via `AllDefaultFlags`, so the flag reaches the subcommand's parser while being absent from `create --help` — and was never consulted by the action.

## What this changes

- `task.CreatePull` now returns the created `*gitea.PullRequest` instead of printing it.
- `runPullsCreate` switches on `--output`, mirroring the existing detail-command precedent (`RunPullsDetails` in `cmd/pulls.go`): `--output json` emits a lean JSON object; any other value (or no flag at all) falls through to the previous `print.PullDetails` rendering, byte-identical to before.
- Lean JSON shape, since a freshly created PR has no reviews/comments/CI yet: `index`, `title`, `url`, `state`, `base`, `head`.
- `--agit` combined with `--output` now fails fast with an explicit error before any API call or `git push`: the agit flow creates the PR server-side via push and returns no object to print.
- The interactive path is untouched — it only triggers when zero flags are set, so `--output` can never be active there.

Example:

```
$ tea pr create --output json --title "fix: thing" | jq -r .url
https://gitea.example.com/owner/repo/pulls/33
```

---------

Co-authored-by: Danilo Sousa <code@danilosousa.net>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1111
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: ongolk <238961+ongolk@noreply.gitea.com>
2026-09-08 19:20:40 +00:00
Renovate BotandLunny Xiao 58931b5d17 fix(deps): update module golang.org/x/crypto to v0.56.0 [security] (#1109)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | [`v0.55.0` → `v0.56.0`](https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.55.0...refs/tags/v0.56.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fcrypto/v0.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fcrypto/v0.55.0/v0.56.0?slim=true) |

---

### Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh
[CVE-2026-78662](https://nvd.nist.gov/vuln/detail/CVE-2026-78662) / [GO-2026-6354](https://pkg.go.dev/vuln/GO-2026-6354)

<details>
<summary>More information</summary>

#### Details
Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.

Now, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81316](https://go.dev/issue/81316)
- [https://go.dev/cl/826504](https://go.dev/cl/826504)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6354) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

### Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh
[CVE-2026-56855](https://nvd.nist.gov/vuln/detail/CVE-2026-56855) / [GO-2026-6355](https://pkg.go.dev/vuln/GO-2026-6355)

<details>
<summary>More information</summary>

#### Details
Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.

Now, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.

#### Severity
Unknown

#### References
- [https://go.dev/issue/81317](https://go.dev/issue/81317)
- [https://go.dev/cl/826524](https://go.dev/cl/826524)
- [https://groups.google.com/g/golang-announce/c/1y3fb2np35U](https://groups.google.com/g/golang-announce/c/1y3fb2np35U)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6355) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

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

Reviewed-on: https://gitea.com/gitea/tea/pulls/1109
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-09-03 06:40:04 +00:00
Renovate BotandLunny Xiao c2947c23d9 fix(deps): update module golang.org/x/crypto to v0.55.0 [security] (#1106)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/crypto](https://pkg.go.dev/golang.org/x/crypto) | [`v0.54.0` → `v0.55.0`](https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.54.0...refs/tags/v0.55.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fcrypto/v0.55.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fcrypto/v0.54.0/v0.55.0?slim=true) |

---

### Source-address critical option not enforced for non-public-key auth callbacks in golang.org/x/crypto/ssh
[CVE-2026-56854](https://nvd.nist.gov/vuln/detail/CVE-2026-56854) / [GO-2026-6303](https://pkg.go.dev/vuln/GO-2026-6303)

<details>
<summary>More information</summary>

#### Details
The source-address critical option in the Permissions returned by an authentication callback was only enforced for the PublicKeyCallback and VerifiedPublicKeyCallback paths, extending the fix for CVE-2026-46595. Permissions returned by the PasswordCallback, KeyboardInteractiveCallback, NoClientAuthCallback, and GSSAPIWithMICConfig.AllowLogin callbacks were not validated against the client's remote address, so a source-address restriction set by those callbacks was silently ignored. The check is now applied to the Permissions returned by any authentication callback.

#### Severity
Unknown

#### References
- [https://go.dev/issue/80213](https://go.dev/issue/80213)
- [https://go.dev/cl/797040](https://go.dev/cl/797040)

This data is provided by [OSV](https://osv.dev/vulnerability/GO-2026-6303) and the [Go Vulnerability Database](https://github.com/golang/vulndb) ([CC-BY 4.0](https://github.com/golang/vulndb#license)).
</details>

---

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

Reviewed-on: https://gitea.com/gitea/tea/pulls/1106
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-29 04:46:28 +00:00
Lunny Xiao 8bfdec40c6 feat(login): add status command (#1087) (#1105)
Implements #1087.

Adds `tea login status [<login name>] [-o <format>]`, which verifies the stored token for one or all configured logins and reports:

- login name/URL and default status
- whether the token is valid (via `GET /api/v1/user`)
- auth method and token expiry
- whether the git credential helper is configured

Machine-readable output is available via the usual `-o` formats with fields `name`, `url`, `user`, `valid`, `auth_method`, `token_expiry`, `helper`, and `default`.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1105
Reviewed-by: bircni <bircni@icloud.com>
2026-08-28 23:55:13 +00:00
Lunny Xiaoandbircni 22d43ec9b6 fix(login): avoid panic when parsing auto-discovered SSH keys (#1100)
## Problem

`tea login add` can panic while auto-discovering SSH keys. The interactive login flow calls `regexp.FindStringSubmatch` and immediately indexes `[1]` without checking whether the regex matched. When the selected key display string does not have the expected format, the returned slice is `nil` and tea crashes with:

```
panic: runtime error: index out of range [1] with length 0
```

This is the crash reported in #527.

## Root cause

`regexp.Regexp.FindStringSubmatch` returns `nil` when the input does not match. Indexing that result with `[1]` assumes a match and causes the panic. The same unchecked pattern exists for SSH certificates and plain public keys in `modules/interact/login.go`.

## Changes

- Extract auto-discovered SSH key/certificate display parsing into `parseSSHPubkeySelection`.
- Add a `regexpSubmatch` helper that returns an error when a regex does not match, so login fails with a descriptive error instead of panicking.
- Add table-driven tests for local/agent keys, local/agent certificates, and malformed input.

Fixes #527

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1100
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-23 19:20:19 +00:00
Lunny Xiaoandbircni bfda25be63 Read issue/PR description from stdin or a file (#1096)
Closes #1095.

`tea issues create` and `tea pulls create` now resolve the description in the same way as comments: when stdin is piped and neither `--description` nor `--description-file` is given, the body is read from stdin. Both create and edit commands also accept:

```text
--description-file <path>   # '-' reads stdin
```

This avoids the PowerShell 5.1 argument mangling and ANSI code page issues described in #1095.

## Changes

- Add `--description-file` to `issues create`, `issues edit`, `pulls create`, and `pulls edit`.
- Create commands fall back to piped stdin when no description flag is set.
- Add unit tests for the new body resolution.

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1096
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-23 12:46:22 +00:00
Renovate Botandbircni dfe89dfb6b fix(deps): update go toolchain directive to v1.26.6 [security] (#1103)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-23 12:44:52 +00:00
Renovate Botandbircni ee531914cd chore(deps): update docker/login-action digest to dbcb813 (#1082)
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
2026-08-16 13:01:15 +00:00
James Braidandbircni 276a4b735a fix(oauth): don't wait for the browser opener to exit (#1093)
Fixes `tea login add --oauth` hanging after the user authenticates in the
browser.

`xdg-open` (at least on Debian) runs the browser in the foreground, so it does
not exit until the browser does. `open.Run` waits for it, so tea is blocked and
doesn't get the oAuth callback from the browser.

This only happens when `xdg-open` has to start the browser. With one already
running, the new process hands off and exits immediately.

`open.Start` launches the opener and returns. The test mocks `xdg-open` with a
script that holds the foreground and fails if `openBrowser` waits on it.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1093
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: James Braid <jamesb@loreland.org>
2026-08-16 12:57:47 +00:00
Lunny Xiaoandbircni 943d4c1512 Drop AWS S3 release upload, keep only Cloudflare R2 (#1092)
The release pipeline uploaded artifacts to both AWS S3 (goreleaser `blobs:`) and Cloudflare R2 (custom publisher) during the migration period. The migration is done, so this removes the S3 half:

- drop the `blobs:` block from `.goreleaser.yaml`
- drop the `AWS_*` / `S3_*` env from the nightly and tag release workflows
- update the comments in `.goreleaser.yaml` and `scripts/upload-r2.sh` accordingly

Cloudflare R2 upload (including the early `--check-config` preflight step) is unchanged, and the `AWS_*` repo secrets are no longer used.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1092
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-15 20:34:32 +00:00
Lunny Xiaoandbircni 4233ebcbb1 ci: Drop AWS S3 release upload, keep only Cloudflare R2 (#1092)
The release pipeline uploaded artifacts to both AWS S3 (goreleaser `blobs:`) and Cloudflare R2 (custom publisher) during the migration period. The migration is done, so this removes the S3 half:

- drop the `blobs:` block from `.goreleaser.yaml`
- drop the `AWS_*` / `S3_*` env from the nightly and tag release workflows
- update the comments in `.goreleaser.yaml` and `scripts/upload-r2.sh` accordingly

Cloudflare R2 upload (including the early `--check-config` preflight step) is unchanged, and the `AWS_*` repo secrets are no longer used.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1092
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
2026-08-15 20:17:51 +00:00
46 changed files with 1319 additions and 1575 deletions
+4 -10
View File
@@ -12,11 +12,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
# Custom publishers (the R2 mirror below) run as the very last # Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release # step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded # has already been created. Fail here instead, before anything
# to S3. Fail here instead, before anything is built or # is built or published, if the R2 secrets are missing.
# published, if the R2 secrets are missing.
- name: check R2 configuration - name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config run: sh scripts/upload-r2.sh --check-config
env: env:
@@ -46,11 +45,6 @@ jobs:
env: env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }} SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -77,7 +71,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+4 -10
View File
@@ -13,11 +13,10 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
# Custom publishers (the R2 mirror below) run as the very last # Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release # step of goreleaser's publish pipeline, after the Gitea release
# has already been created and every artifact already uploaded # has already been created. Fail here instead, before anything
# to S3. Fail here instead, before anything is built or # is built or published, if the R2 secrets are missing.
# published, if the R2 secrets are missing.
- name: check R2 configuration - name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config run: sh scripts/upload-r2.sh --check-config
env: env:
@@ -47,11 +46,6 @@ jobs:
env: env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }} SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
AWS_REGION: ${{ secrets.AWS_REGION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.AWS_BUCKET }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -78,7 +72,7 @@ jobs:
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+7 -18
View File
@@ -76,24 +76,13 @@ builds:
- cmd: sh .goreleaser.checksum.sh {{ .Path }} - cmd: sh .goreleaser.checksum.sh {{ .Path }}
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz - cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
blobs: # Uploads the release artifacts to Cloudflare R2. A `blobs:` entry is
- # not used here since the blob pipe authenticates from the global
provider: s3 # AWS_* env with no per-entry credentials; `publishers:` supports
bucket: "{{ .Env.S3_BUCKET }}" # per-entry `env:` instead, so it's used to invoke
region: "{{ .Env.S3_REGION }}" # scripts/upload-r2.sh once per artifact. Custom publishers inherit
directory: "tea/{{.Version}}" # almost nothing from the environment, hence the explicit R2_*
extra_files: # forwarding below.
- glob: ./**.xz
- glob: ./**.sha256
# Mirrors the S3 `blobs:` upload above into Cloudflare R2 during the
# parallel S3+R2 period (S3 will be removed once migration completes).
# A second `blobs:` entry is impossible here since the blob pipe
# authenticates from the global AWS_* env with no per-entry
# credentials; `publishers:` supports per-entry `env:` instead, so
# it's used to invoke scripts/upload-r2.sh once per artifact. Custom
# publishers inherit almost nothing from the environment, hence the
# explicit R2_* forwarding below.
# #
# This publisher fires more than once per distinct key because # This publisher fires more than once per distinct key because
# goreleaser's release pipe already registers `release.extra_files` # goreleaser's release pipe already registers `release.extra_files`
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"fmt"
"io"
"os"
"golang.org/x/term"
)
// stdinPiped reports whether stdin is not a terminal, e.g. when a description
// is piped from a file, command substitution, or a CI harness.
func stdinPiped() bool {
return !term.IsTerminal(int(os.Stdin.Fd()))
}
// resolveCreateBody returns the issue/PR description for create commands.
//
// Precedence:
// 1. --description-file (read from the file, or stdin when the path is "-")
// 2. --description
// 3. piped stdin
func resolveCreateBody(description, descriptionFile string, descriptionFileSet, stdinPiped bool, stdin io.Reader) (string, error) {
if descriptionFileSet {
return readDescriptionSource(descriptionFile, stdin)
}
if description != "" {
return description, nil
}
if stdinPiped {
return readDescriptionStdin(stdin)
}
return "", nil
}
// resolveEditBody returns the new issue/PR body when a description flag was
// provided, or nil when the caller should leave the body unchanged.
func resolveEditBody(description string, descriptionSet bool, descriptionFile string, descriptionFileSet bool, stdin io.Reader) (*string, error) {
if descriptionFileSet {
body, err := readDescriptionSource(descriptionFile, stdin)
if err != nil {
return nil, err
}
return &body, nil
}
if descriptionSet {
body := description
return &body, nil
}
return nil, nil
}
func readDescriptionSource(source string, stdin io.Reader) (string, error) {
if source == "-" {
return readDescriptionStdin(stdin)
}
data, err := os.ReadFile(source)
if err != nil {
return "", fmt.Errorf("could not read description file %q: %w", source, err)
}
return string(data), nil
}
func readDescriptionStdin(stdin io.Reader) (string, error) {
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("could not read description from stdin: %w", err)
}
return string(data), nil
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveCreateBody(t *testing.T) {
file := filepath.Join(t.TempDir(), "body.md")
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
tests := []struct {
name string
description string
descriptionFile string
descriptionFileSet bool
stdinPiped bool
stdin string
want string
}{
{
name: "description flag",
description: "from -d",
want: "from -d",
},
{
name: "description file",
descriptionFile: file,
descriptionFileSet: true,
want: "from file",
},
{
name: "description file wins over description",
description: "from -d",
descriptionFile: file,
descriptionFileSet: true,
want: "from file",
},
{
name: "dash reads stdin",
descriptionFile: "-",
descriptionFileSet: true,
stdin: "from stdin",
want: "from stdin",
},
{
name: "description wins over piped stdin",
description: "from -d",
stdinPiped: true,
stdin: "from stdin",
want: "from -d",
},
{
name: "piped stdin",
stdinPiped: true,
stdin: "from stdin",
want: "from stdin",
},
{
name: "empty description falls back to piped stdin",
description: "",
stdinPiped: true,
stdin: "from stdin",
want: "from stdin",
},
{
name: "empty when no source provided",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveCreateBody(tt.description, tt.descriptionFile, tt.descriptionFileSet, tt.stdinPiped, strings.NewReader(tt.stdin))
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestResolveEditBody(t *testing.T) {
file := filepath.Join(t.TempDir(), "body.md")
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
tests := []struct {
name string
description string
descriptionSet bool
descriptionFile string
descriptionFileSet bool
stdin string
wantBody string
wantSet bool
}{
{
name: "no description flag",
},
{
name: "description flag",
description: "from -d",
descriptionSet: true,
wantBody: "from -d",
wantSet: true,
},
{
name: "empty description clears body",
descriptionSet: true,
wantSet: true,
},
{
name: "description file",
descriptionFile: file,
descriptionFileSet: true,
wantBody: "from file",
wantSet: true,
},
{
name: "description file wins over description",
description: "from -d",
descriptionSet: true,
descriptionFile: file,
descriptionFileSet: true,
wantBody: "from file",
wantSet: true,
},
{
name: "dash reads stdin",
descriptionFile: "-",
descriptionFileSet: true,
stdin: "from stdin",
wantBody: "from stdin",
wantSet: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveEditBody(tt.description, tt.descriptionSet, tt.descriptionFile, tt.descriptionFileSet, strings.NewReader(tt.stdin))
require.NoError(t, err)
if !tt.wantSet {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.wantBody, *got)
})
}
}
func TestResolveDescriptionSourceError(t *testing.T) {
_, err := resolveCreateBody("", filepath.Join(t.TempDir(), "missing.md"), true, false, strings.NewReader(""))
require.ErrorContains(t, err, "could not read description file")
}
+28 -5
View File
@@ -100,6 +100,10 @@ var issuePRFlags = append([]cli.Flag{
Name: "description", Name: "description",
Aliases: []string{"d"}, Aliases: []string{"d"},
}, },
&cli.StringFlag{
Name: "description-file",
Usage: "Read description from file ('-' for stdin)",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "referenced-version", Name: "referenced-version",
Aliases: []string{"v"}, Aliases: []string{"v"},
@@ -133,12 +137,22 @@ var IssuePRCreateFlags = append([]cli.Flag{
// GetIssuePRCreateFlags parses all IssuePREditFlags // GetIssuePRCreateFlags parses all IssuePREditFlags
func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) { func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (*gitea.CreateIssueOption, error) {
body, err := resolveCreateBody(
ctx.String("description"),
ctx.String("description-file"),
ctx.IsSet("description-file"),
stdinPiped(),
ctx.Reader,
)
if err != nil {
return nil, err
}
opts := gitea.CreateIssueOption{ opts := gitea.CreateIssueOption{
Title: ctx.String("title"), Title: ctx.String("title"),
Body: ctx.String("description"), Body: body,
Assignees: strings.Split(ctx.String("assignees"), ","), Assignees: strings.Split(ctx.String("assignees"), ","),
} }
var err error
date := ctx.String("deadline") date := ctx.String("deadline")
if date != "" { if date != "" {
@@ -208,9 +222,18 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error)
val := ctx.String("title") val := ctx.String("title")
opts.Title = &val opts.Title = &val
} }
if ctx.IsSet("description") { body, err := resolveEditBody(
val := ctx.String("description") ctx.String("description"),
opts.Body = &val ctx.IsSet("description"),
ctx.String("description-file"),
ctx.IsSet("description-file"),
ctx.Reader,
)
if err != nil {
return nil, err
}
if body != nil {
opts.Body = body
} }
if ctx.IsSet("referenced-version") { if ctx.IsSet("referenced-version") {
val := ctx.String("referenced-version") val := ctx.String("referenced-version")
+43 -3
View File
@@ -5,13 +5,18 @@ package issues
import ( import (
stdctx "context" stdctx "context"
"encoding/json"
"fmt"
"io"
gitea "gitea.dev/sdk"
"github.com/urfave/cli/v3"
"gitea.dev/tea/cmd/flags" "gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context" "gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/interact" "gitea.dev/tea/modules/interact"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task" "gitea.dev/tea/modules/task"
"github.com/urfave/cli/v3"
) )
// CmdIssuesCreate represents a sub command of issues to create issue // CmdIssuesCreate represents a sub command of issues to create issue
@@ -47,9 +52,44 @@ func runIssuesCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
return err return err
} }
return task.CreateIssue(requestCtx, ctx.Login, issue, err := task.CreateIssue(requestCtx, ctx.Login,
ctx.Owner, ctx.Owner,
ctx.Repo, ctx.Repo,
*opts, *opts,
) )
if err != nil {
return err
}
if ctx.IsSet("output") {
switch ctx.String("output") {
case "json":
return writeCreatedIssueAsJSON(ctx.Writer, issue)
}
}
print.IssueDetails(issue, nil)
fmt.Println(issue.HTMLURL)
return nil
}
// createdIssueJSON is the machine-readable representation of a freshly
// created issue, mirroring the create-PR equivalent in cmd/pulls/create.go
// (createdPullJSON).
type createdIssueJSON struct {
Index int64 `json:"index"`
Title string `json:"title"`
URL string `json:"url"`
State gitea.StateType `json:"state"`
}
func writeCreatedIssueAsJSON(w io.Writer, issue *gitea.Issue) error {
return json.NewEncoder(w).Encode(createdIssueJSON{
Index: issue.Index,
Title: issue.Title,
URL: issue.HTMLURL,
State: issue.State,
})
} }
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"bytes"
"encoding/json"
"testing"
gitea "gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteCreatedIssueAsJSON(t *testing.T) {
issue := &gitea.Issue{
Index: 42,
Title: "test title",
HTMLURL: "https://gitea.example.com/owner/repo/issues/42",
State: gitea.StateOpen,
}
var buf bytes.Buffer
require.NoError(t, writeCreatedIssueAsJSON(&buf, issue))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, float64(42), got["index"])
assert.Equal(t, "test title", got["title"])
assert.Equal(t, "https://gitea.example.com/owner/repo/issues/42", got["url"])
assert.Equal(t, "open", got["state"])
// exactly the lean field set, nothing extra
assert.Len(t, got, 4)
// machine-readable output must not contain terminal escape sequences
assert.NotContains(t, buf.String(), "\x1b")
}
+1
View File
@@ -31,6 +31,7 @@ var CmdLogin = cli.Command{
&login.CmdLoginSetDefault, &login.CmdLoginSetDefault,
&login.CmdLoginHelper, &login.CmdLoginHelper,
&login.CmdLoginOAuthRefresh, &login.CmdLoginOAuthRefresh,
&login.CmdLoginStatus,
}, },
} }
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"fmt"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdLoginStatus represents a command to show authentication status for logins.
var CmdLoginStatus = cli.Command{
Name: "status",
Usage: "Show authentication status for Gitea logins",
Description: `Verify the stored token for one or all Gitea logins and report its validity.`,
ArgsUsage: "[<login name>]",
Action: RunLoginStatus,
Flags: []cli.Flag{&flags.OutputFlag},
}
// RunLoginStatus verifies one login, or every configured login when no name is
// provided, and prints a short authentication report.
func RunLoginStatus(requestCtx context.Context, cmd *cli.Command) error {
var logins []config.Login
switch cmd.Args().Len() {
case 0:
var err error
logins, err = config.GetLogins()
if err != nil {
return err
}
case 1:
login, err := config.GetLoginByName(cmd.Args().First())
if err != nil {
return err
}
if login == nil {
return fmt.Errorf("login '%s' not found", cmd.Args().First())
}
logins = []config.Login{*login}
default:
return fmt.Errorf("too many arguments")
}
statuses := make([]print.LoginStatus, 0, len(logins))
for i := range logins {
statuses = append(statuses, task.CheckLoginStatus(requestCtx, &logins[i]))
}
return print.LoginStatuses(statuses, cmd.String("output"))
}
+48 -1
View File
@@ -5,6 +5,9 @@ package pulls
import ( import (
stdctx "context" stdctx "context"
"encoding/json"
"fmt"
"io"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
@@ -12,6 +15,7 @@ import (
"gitea.dev/tea/cmd/flags" "gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context" "gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/interact" "gitea.dev/tea/modules/interact"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task" "gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/utils" "gitea.dev/tea/modules/utils"
) )
@@ -80,6 +84,12 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
return nil return nil
} }
// agit flow creates the PR via git push and returns no PR object, so
// --output cannot be honored there; fail fast before any API calls
if ctx.Bool("agit") && ctx.IsSet("output") {
return fmt.Errorf("--output cannot be combined with --agit: the PR is created via git push, so no pull request object is available to print")
}
// else use args to create PR // else use args to create PR
opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx) opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx)
if err != nil { if err != nil {
@@ -108,7 +118,7 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits")) allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits"))
} }
return task.CreatePull( pr, err := task.CreatePull(
requestCtx, requestCtx,
ctx, ctx,
ctx.String("base"), ctx.String("base"),
@@ -116,4 +126,41 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
allowMaintainerEdits, allowMaintainerEdits,
opts, opts,
) )
if err != nil {
return err
}
if ctx.IsSet("output") {
switch ctx.String("output") {
case "json":
return writeCreatedPullAsJSON(ctx.Writer, pr)
}
}
print.PullDetails(pr, nil, nil)
return nil
}
// createdPullJSON is the machine-readable representation of a freshly
// created pull request. A new PR has no reviews, comments or CI yet, so
// this is intentionally leaner than the detail view's pullData (cmd/pulls.go).
type createdPullJSON struct {
Index int64 `json:"index"`
Title string `json:"title"`
URL string `json:"url"`
State gitea.StateType `json:"state"`
Base string `json:"base"`
Head string `json:"head"`
}
func writeCreatedPullAsJSON(w io.Writer, pr *gitea.PullRequest) error {
return json.NewEncoder(w).Encode(createdPullJSON{
Index: pr.Index,
Title: pr.Title,
URL: pr.HTMLURL,
State: pr.State,
Base: pr.Base.Ref,
Head: pr.Head.Ref,
})
} }
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls_test
import (
"context"
"testing"
"gitea.dev/tea/cmd"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPullsCreateAgitOutputRejected verifies that --output (parsed via the
// urfave/cli v3 ancestor-flag cascade, since create itself does not declare
// it) is rejected for the agit flow before any API call or git push happens.
func TestPullsCreateAgitOutputRejected(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{})
})
app := cmd.App()
args := []string{
"tea", "pulls", "create",
"--agit",
"--output", "json",
"--head", "topic-branch",
"--title", "test",
"--login", "testLogin",
"--repo", "user/repo",
}
err := app.Run(context.Background(), args)
require.Error(t, err)
assert.Contains(t, err.Error(), "--output cannot be combined with --agit")
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"bytes"
"encoding/json"
"testing"
gitea "gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteCreatedPullAsJSON(t *testing.T) {
pr := &gitea.PullRequest{
Index: 33,
Title: "test title",
HTMLURL: "https://gitea.example.com/owner/repo/pulls/33",
State: gitea.StateOpen,
Base: &gitea.PRBranchInfo{Ref: "main"},
Head: &gitea.PRBranchInfo{Ref: "feature"},
}
var buf bytes.Buffer
require.NoError(t, writeCreatedPullAsJSON(&buf, pr))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, float64(33), got["index"])
assert.Equal(t, "test title", got["title"])
assert.Equal(t, "https://gitea.example.com/owner/repo/pulls/33", got["url"])
assert.Equal(t, "open", got["state"])
assert.Equal(t, "main", got["base"])
assert.Equal(t, "feature", got["head"])
// exactly the lean field set, nothing extra
assert.Len(t, got, 6)
// machine-readable output must not contain terminal escape sequences
assert.NotContains(t, buf.String(), "\x1b")
}
+14
View File
@@ -109,6 +109,12 @@ Return the stored token for a URL (git credential protocol)
Refresh an OAuth token Refresh an OAuth token
### status
Show authentication status for Gitea logins
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
## logout ## logout
Log out from a Gitea server Log out from a Gitea server
@@ -221,6 +227,8 @@ Create an issue on repository
**--description, -d**="": **--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--labels, -L**="": Comma-separated list of labels to assign **--labels, -L**="": Comma-separated list of labels to assign
**--login, -l**="": Use a different Gitea Login. Optional **--login, -l**="": Use a different Gitea Login. Optional
@@ -247,6 +255,8 @@ Edit one or more issues
**--description, -d**="": **--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--login, -l**="": Use a different Gitea Login. Optional **--login, -l**="": Use a different Gitea Login. Optional
**--milestone, -m**="": Milestone to assign **--milestone, -m**="": Milestone to assign
@@ -379,6 +389,8 @@ Create a pull-request
**--description, -d**="": **--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--draft**: Create as a draft (prepends "WIP: " to the title; Gitea treats WIP-prefixed PRs as drafts) **--draft**: Create as a draft (prepends "WIP: " to the title; Gitea treats WIP-prefixed PRs as drafts)
**--head**="": Branch name of the PR source (default is current one). To specify a different head repo, use <user>:<branch> **--head**="": Branch name of the PR source (default is current one). To specify a different head repo, use <user>:<branch>
@@ -437,6 +449,8 @@ Edit one or more pull requests
**--description, -d**="": **--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--draft**: Mark as draft by prepending "WIP: " to the title (idempotent) **--draft**: Mark as draft by prepending "WIP: " to the title (idempotent)
**--login, -l**="": Use a different Gitea Login. Optional **--login, -l**="": Use a different Gitea Login. Optional
+7 -6
View File
@@ -2,7 +2,7 @@ module gitea.dev/tea
go 1.26.0 go 1.26.0
toolchain go1.26.5 toolchain go1.26.6
require ( require (
charm.land/glamour/v2 v2.0.1 charm.land/glamour/v2 v2.0.1
@@ -14,14 +14,14 @@ require (
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-signet/sdk-go v1.1.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/olekukonko/tablewriter v1.1.4 github.com/olekukonko/tablewriter v1.1.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.10.1 github.com/urfave/cli/v3 v3.10.1
github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.56.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0 golang.org/x/term v0.45.0
@@ -76,11 +76,12 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/zalando/go-keyring v0.2.8 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.47.0 // indirect golang.org/x/tools v0.48.0 // indirect
) )
retract v1.3.3 // accidental release, tag deleted retract v1.3.3 // accidental release, tag deleted
+12 -10
View File
@@ -89,6 +89,8 @@ github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0= github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= 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/go-signet/sdk-go v1.1.0 h1:wHKg9P+goQ14A1Q0gtC6m3mCzRFWwL1peAGz/zhmAZQ=
github.com/go-signet/sdk-go v1.1.0/go.mod h1:bmi7nDAu7o6MQnUE3K7ZNEKU4xqh3u/SMbPC5GanOR8=
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=
@@ -160,19 +162,19 @@ github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cma
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -189,13 +191,13 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+3 -1
View File
@@ -366,7 +366,9 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts *OAuthOp
var openBrowser = func(url string) error { var openBrowser = func(url string) error {
fmt.Printf("Please authorize the application by visiting this URL in your browser:\n%s\n", url) fmt.Printf("Please authorize the application by visiting this URL in your browser:\n%s\n", url)
return open.Run(url) // Don't wait for the opener to exit, so a browser that holds the
// foreground can't block the wait for the callback.
return open.Start(url)
} }
// createLoginFromToken creates a login entry using the obtained access token // createLoginFromToken creates a login entry using the obtained access token
+38
View File
@@ -6,11 +6,16 @@ package auth
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath"
"runtime"
"sync" "sync"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -89,3 +94,36 @@ func TestPerformBrowserOAuthFlow_RedirectURIMatchesAcrossAuthorizeAndExchange(t
assert.Equal(t, authorizeRedirectURI, exchangeRedirectURI, assert.Equal(t, authorizeRedirectURI, exchangeRedirectURI,
"redirect_uri must match between authorize and token exchange (RFC 6749 §4.1.3)") "redirect_uri must match between authorize and token exchange (RFC 6749 §4.1.3)")
} }
// Regression test for the browser opener hang: xdg-open does not exit until
// the browser it launched does, and the callback is only consumed after
// openBrowser returns. Waiting on the opener hangs the CLI even though the
// user authenticated successfully.
func TestOpenBrowser_DoesNotWaitForOpener(t *testing.T) {
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
t.Skip("xdg-open is not the opener on this platform")
}
const (
fakeOpenerSleepTime = 10 * time.Second
openBrowserTimeout = 2 * time.Second
)
// A stand-in xdg-open that holds the foreground the way a browser it had
// to launch would.
dir := t.TempDir()
opener := filepath.Join(dir, "xdg-open")
script := fmt.Sprintf("#!/bin/sh\nexec sleep %d\n", int(fakeOpenerSleepTime.Seconds()))
require.NoError(t, os.WriteFile(opener, []byte(script), 0o755))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
done := make(chan error, 1)
go func() { done <- openBrowser("http://127.0.0.1:1/") }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(openBrowserTimeout):
t.Fatal("openBrowser blocked on the opener; the callback would never be consumed")
}
}
+1 -2
View File
@@ -8,9 +8,8 @@ import (
"sync" "sync"
"time" "time"
"gitea.dev/tea/modules/credstore"
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/go-signet/sdk-go/credstore"
"golang.org/x/oauth2" "golang.org/x/oauth2"
) )
+9 -4
View File
@@ -240,14 +240,11 @@ func DeleteLogin(name string) error {
} }
isOAuth := config.Logins[idx].IsOAuth() isOAuth := config.Logins[idx].IsOAuth()
// Use the stored login name, not the CLI-provided one: the lookup above
// is case-insensitive, but credstore keys are exact-match.
storedName := config.Logins[idx].Name
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...) config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
// Clean up credstore tokens for OAuth logins // Clean up credstore tokens for OAuth logins
if isOAuth { if isOAuth {
_ = DeleteOAuthToken(storedName) _ = DeleteOAuthToken(name)
} }
return saveConfigUnsafe() return saveConfigUnsafe()
@@ -449,6 +446,14 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
os.Exit(1) os.Exit(1)
} }
return l.ClientWithoutRefresh(options...)
}
// ClientWithoutRefresh returns a client to operate the Gitea API without
// attempting an automatic OAuth token refresh. Commands that need to handle
// token refresh errors themselves (such as 'tea login status') should use this
// instead of Client, which prints to stderr and exits on refresh failure.
func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Client {
// Configure transport-level timeouts so a stalled or unresponsive server // Configure transport-level timeouts so a stalled or unresponsive server
// fails fast instead of hanging forever. These bound connection setup and // fails fast instead of hanging forever. These bound connection setup and
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g. // time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
-49
View File
@@ -1,49 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"encoding/json"
"fmt"
)
// Codec handles encoding/decoding values to/from strings for storage.
type Codec[T any] interface {
Encode(v T) (string, error)
Decode(s string) (T, error)
}
// JSONCodec encodes T as JSON.
type JSONCodec[T any] struct{}
// Encode marshals v to a JSON string.
func (JSONCodec[T]) Encode(v T) (string, error) {
data, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("failed to marshal data: %w", err)
}
return string(data), nil
}
// Decode unmarshals a JSON string into T.
func (JSONCodec[T]) Decode(s string) (T, error) {
var v T
if err := json.Unmarshal([]byte(s), &v); err != nil {
return v, fmt.Errorf("failed to unmarshal data: %w", err)
}
return v, nil
}
// StringCodec is the identity codec for plain strings.
type StringCodec struct{}
// Encode returns the string as-is.
func (StringCodec) Encode(v string) (string, error) {
return v, nil
}
// Decode returns the string as-is.
func (StringCodec) Decode(s string) (string, error) {
return s, nil
}
-8
View File
@@ -1,8 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package credstore provides secure storage for OAuth tokens. Values are
// AES-256-GCM-encrypted into a JSON file while only the 32-byte master key
// lives in the OS keyring; when the keyring is unavailable the store falls
// back to plaintext file storage.
package credstore
-259
View File
@@ -1,259 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"sync"
)
// masterKeySize is the AES-256 key length in bytes.
const masterKeySize = 32
// masterKeyUser is the keyring account name under which the master key is
// stored. It must never change: installations hold their master key under
// this exact name.
const masterKeyUser = "__tea_master_key__"
// sealedPrefix versions the on-disk encrypted value format so a future
// algorithm change can be detected instead of guessed at.
const sealedPrefix = "v1:"
// masterKey manages a per-service AES-256 key held in the OS keyring and
// caches the derived AEAD in memory. See EncryptedFileStore for why only the
// key lives in the keyring.
type masterKey struct {
store *KeyringStore[string]
mu sync.Mutex
aead cipher.AEAD // cached after the first successful load or create
}
// loadLocked returns the cached or keyring-held AEAD. It returns ErrNotFound
// unwrapped when no key exists yet so callers can distinguish "no key" from
// "keyring unavailable". m.mu must be held.
func (m *masterKey) loadLocked() (cipher.AEAD, error) {
if m.aead != nil {
return m.aead, nil
}
encoded, err := m.store.Load(masterKeyUser)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, err
}
// e.g. Linux headless without Secret Service, or keyring locked.
return nil, fmt.Errorf("failed to read master key: %w", err)
}
key, decodeErr := base64.StdEncoding.DecodeString(encoded)
if decodeErr != nil || len(key) != masterKeySize {
return nil, errors.New("corrupted master key in keyring")
}
return m.cacheLocked(key)
}
// cacheLocked builds the AEAD for key and caches it. m.mu must be held.
func (m *masterKey) cacheLocked(key []byte) (cipher.AEAD, error) {
aead, err := newGCM(key)
if err != nil {
return nil, err
}
m.aead = aead
return aead, nil
}
// load returns the AEAD without ever creating a key, so decryption paths
// cannot mint a key that has no chance of opening existing ciphertext.
func (m *masterKey) load() (cipher.AEAD, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.loadLocked()
}
// get returns the AEAD, generating and persisting a new key on first use.
func (m *masterKey) get() (cipher.AEAD, error) {
m.mu.Lock()
defer m.mu.Unlock()
aead, err := m.loadLocked()
if err == nil {
return aead, nil
}
if !errors.Is(err, ErrNotFound) {
return nil, err
}
// First use: generate and persist a new key.
key := make([]byte, masterKeySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("failed to generate master key: %w", err)
}
if err := m.store.Save(masterKeyUser, base64.StdEncoding.EncodeToString(key)); err != nil {
return nil, fmt.Errorf("failed to store master key: %w", err)
}
return m.cacheLocked(key)
}
// available reports whether the keyring can serve the master key without
// creating one: a cached or stored valid key counts, and so does a clean
// not-found (the key is generated lazily on first Save). A corrupted key or
// an unreachable keyring does not.
func (m *masterKey) available() bool {
m.mu.Lock()
defer m.mu.Unlock()
_, err := m.loadLocked()
return err == nil || errors.Is(err, ErrNotFound)
}
// newGCM creates an AES-256-GCM AEAD for the given key.
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
return gcm, nil
}
// sealValue encrypts plaintext with AES-256-GCM and returns
// "v1:" + base64(nonce || ciphertext).
func sealValue(aead cipher.AEAD, plaintext string) (string, error) {
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// Seal appends ciphertext+tag to nonce, so the stored value is self-contained.
sealed := aead.Seal(nonce, nonce, []byte(plaintext), nil)
return sealedPrefix + base64.StdEncoding.EncodeToString(sealed), nil
}
// openValue decrypts a value produced by sealValue.
func openValue(aead cipher.AEAD, encoded string) (string, error) {
rest, ok := strings.CutPrefix(encoded, sealedPrefix)
if !ok {
return "", errors.New("unrecognized encrypted value format")
}
data, err := base64.StdEncoding.DecodeString(rest)
if err != nil {
return "", fmt.Errorf("failed to decode encrypted value: %w", err)
}
if len(data) < aead.NonceSize() {
return "", errors.New("encrypted value too short")
}
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
// Wrong key or tampered value — GCM authentication failed.
return "", fmt.Errorf("failed to decrypt value (key mismatch or tampering): %w", err)
}
return string(plaintext), nil
}
// encryptedCodec wraps an inner codec with AES-256-GCM encryption using a
// keyring-held master key.
type encryptedCodec[T any] struct {
inner Codec[T]
key *masterKey
}
// Encode encodes v with the inner codec and encrypts the result.
func (c encryptedCodec[T]) Encode(v T) (string, error) {
aead, err := c.key.get()
if err != nil {
return "", err
}
plaintext, err := c.inner.Encode(v)
if err != nil {
return "", err
}
return sealValue(aead, plaintext)
}
// Decode decrypts s and decodes the plaintext with the inner codec.
func (c encryptedCodec[T]) Decode(s string) (T, error) {
var zero T
aead, err := c.key.load()
if err != nil {
if errors.Is(err, ErrNotFound) {
// Deliberately not wrapping ErrNotFound: the value exists but
// cannot be decrypted, which must not read as "no data stored".
return zero, errors.New("cannot decrypt stored value: master key not found in keyring")
}
return zero, err
}
plaintext, err := openValue(aead, s)
if err != nil {
return zero, err
}
return c.inner.Decode(plaintext)
}
// EncryptedFileStore stores values encrypted with AES-256-GCM in a JSON file,
// keeping only the 32-byte master key in the OS keyring. The keyring payload
// is a constant 44 bytes (base64) regardless of value size, so it never hits
// the Windows Credential Manager 2560-byte blob limit or the macOS/Linux
// keyring item size limits. The values themselves (which can be several KB
// for tokens with groups claims) are encrypted into a file with 0600
// permissions, file locking, and atomic writes.
//
// EncryptedFileStore implements Store[T] and Prober.
type EncryptedFileStore[T any] struct {
file *FileStore[T]
key *masterKey
}
// NewEncryptedFileStore creates an EncryptedFileStore. serviceName is the
// keyring service under which the master key is stored; filePath is the
// encrypted data file. Panics if codec is nil.
func NewEncryptedFileStore[T any](
serviceName, filePath string,
codec Codec[T],
) *EncryptedFileStore[T] {
if codec == nil {
panic("credstore: NewEncryptedFileStore called with nil codec")
}
key := &masterKey{store: NewStringKeyringStore(serviceName)}
return &EncryptedFileStore[T]{
file: NewFileStore[T](filePath, encryptedCodec[T]{inner: codec, key: key}),
key: key,
}
}
// Probe reports whether the OS keyring can serve the master key. It is
// read-only: the key itself is generated lazily on the first Save. Once the
// key is cached in memory, Probe keeps reporting true even if the keyring
// later becomes unavailable, because the store remains operational with the
// cached key.
func (e *EncryptedFileStore[T]) Probe() bool {
return e.key.available()
}
// Load loads and decrypts data for the given client ID.
func (e *EncryptedFileStore[T]) Load(clientID string) (T, error) {
return e.file.Load(clientID)
}
// Save encrypts and saves data for the given client ID.
func (e *EncryptedFileStore[T]) Save(clientID string, data T) error {
return e.file.Save(clientID, data)
}
// Delete removes data for the given client ID from the file.
func (e *EncryptedFileStore[T]) Delete(clientID string) error {
return e.file.Delete(clientID)
}
// String returns a description of this store.
func (e *EncryptedFileStore[T]) String() string {
return "encrypted-file: " + e.file.filePath
}
-140
View File
@@ -1,140 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
// newLargeTestToken builds a token whose access token is several KB,
// mimicking real JWTs with large groups claims that exceed the Windows
// Credential Manager 2560-byte blob limit.
func newLargeTestToken(clientID string) Token {
return Token{
AccessToken: "header." + strings.Repeat("groups-claim-payload-", 300) + ".sig",
RefreshToken: "test-refresh-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
ClientID: clientID,
}
}
func newTestEncryptedStore(t *testing.T) (*EncryptedFileStore[Token], string) {
t.Helper()
keyring.MockInit()
path := filepath.Join(t.TempDir(), "tokens.enc")
return NewEncryptedFileStore[Token]("test-service", path, JSONCodec[Token]{}), path
}
func TestEncryptedFileStoreSaveAndLoad(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt))
}
func TestEncryptedFileStoreFileContainsNoPlaintext(t *testing.T) {
store, path := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
raw, err := os.ReadFile(path)
require.NoError(t, err)
assert.NotContains(t, string(raw), "groups-claim-payload")
assert.NotContains(t, string(raw), tok.RefreshToken)
}
func TestEncryptedFileStoreKeyringHoldsOnlySmallMasterKey(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
// The token itself must not be in the keyring.
_, err := keyring.Get("test-service", "test-client")
assert.ErrorIs(t, err, keyring.ErrNotFound)
// Only the 44-byte base64 master key may live in the keyring —
// well under the Windows Credential Manager 2560-byte blob limit.
encoded, err := keyring.Get("test-service", masterKeyUser)
require.NoError(t, err)
assert.Len(t, encoded, 44)
key, err := base64.StdEncoding.DecodeString(encoded)
require.NoError(t, err)
assert.Len(t, key, 32)
}
func TestEncryptedFileStoreLoadNotFound(t *testing.T) {
store, _ := newTestEncryptedStore(t)
_, err := store.Load("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestEncryptedFileStoreDelete(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestEncryptedFileStoreSaveEmptyClientID(t *testing.T) {
store, _ := newTestEncryptedStore(t)
err := store.Save("", newLargeTestToken("x"))
assert.ErrorIs(t, err, ErrEmptyClientID)
}
func TestEncryptedFileStoreProbe(t *testing.T) {
store, _ := newTestEncryptedStore(t)
assert.True(t, store.Probe())
}
func TestEncryptedFileStoreCorruptMasterKeyFailsLoad(t *testing.T) {
store, _ := newTestEncryptedStore(t)
tok := newLargeTestToken("test-client")
require.NoError(t, store.Save(tok.ClientID, tok))
// A fresh store whose keyring holds a corrupted key must fail to decrypt
// rather than return garbage or mint a new key.
require.NoError(t, keyring.Set("test-service", masterKeyUser, "not-base64!"))
fresh := NewEncryptedFileStore[Token]("test-service", store.file.filePath, JSONCodec[Token]{})
assert.False(t, fresh.Probe())
_, err := fresh.Load("test-client")
require.Error(t, err)
assert.NotErrorIs(t, err, ErrNotFound)
}
func TestEncryptedFileStoreNilCodecPanics(t *testing.T) {
assert.Panics(t, func() {
NewEncryptedFileStore[string]("svc", "path", nil)
})
}
func TestEncryptedFileStoreString(t *testing.T) {
store, path := newTestEncryptedStore(t)
assert.Equal(t, "encrypted-file: "+path, store.String())
}
-171
View File
@@ -1,171 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"gitea.dev/tea/modules/filelock"
)
// storageMap manages encoded values for multiple clients.
type storageMap struct {
Data map[string]string `json:"data"` // clientID -> encoded value
}
// FileStore stores values in a JSON file with file locking and atomic writes.
type FileStore[T any] struct {
filePath string
codec Codec[T]
}
// NewFileStore creates a new FileStore with the given codec.
// Panics if codec is nil.
func NewFileStore[T any](filePath string, codec Codec[T]) *FileStore[T] {
if codec == nil {
panic("credstore: NewFileStore called with nil codec")
}
return &FileStore[T]{filePath: filePath, codec: codec}
}
// readStorageMap reads and unmarshals the storage map from the file.
// Returns an empty initialized map if the file does not exist.
func (f *FileStore[T]) readStorageMap() (storageMap, error) {
var m storageMap
data, err := os.ReadFile(f.filePath)
if err != nil {
if os.IsNotExist(err) {
m.Data = make(map[string]string)
return m, nil
}
return m, fmt.Errorf("failed to read file %q: %w", f.filePath, err)
}
if err := json.Unmarshal(data, &m); err != nil {
return m, fmt.Errorf("failed to parse file %q: %w", f.filePath, err)
}
if m.Data == nil {
m.Data = make(map[string]string)
}
return m, nil
}
// ensureDir creates the parent directory of the store file if it does not exist.
func (f *FileStore[T]) ensureDir() error {
if err := os.MkdirAll(filepath.Dir(f.filePath), 0o700); err != nil {
return fmt.Errorf("failed to create store directory: %w", err)
}
return nil
}
// writeStorageMap marshals and atomically writes the storage map to the file.
func (f *FileStore[T]) writeStorageMap(m storageMap) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tempFile := f.filePath + ".tmp"
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// WriteFile only applies the mode when creating the file; enforce it in
// case a stale temp file with looser permissions was left behind.
if err := os.Chmod(tempFile, 0o600); err != nil {
_ = os.Remove(tempFile)
return fmt.Errorf("failed to set temp file permissions: %w", err)
}
if err := os.Rename(tempFile, f.filePath); err != nil {
_ = os.Remove(tempFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
// withFileLock acquires an exclusive cross-process lock on filePath+".lock",
// runs fn, and releases the lock. The kernel-level lock (flock/LockFileEx via
// modules/filelock) is released automatically if the process dies, so no
// stale-lock heuristics are needed. The .lock file itself remains on disk.
func (f *FileStore[T]) withFileLock(fn func() error) error {
return filelock.New(f.filePath+".lock", filelock.DefaultTimeout).WithLock(fn)
}
// Load loads data from the file for the given client ID.
// No file lock is needed: Save uses atomic rename, so reads always see a
// consistent snapshot on POSIX systems.
func (f *FileStore[T]) Load(clientID string) (T, error) {
var zero T
m, err := f.readStorageMap()
if err != nil {
return zero, err
}
encoded, ok := m.Data[clientID]
if !ok {
return zero, ErrNotFound
}
decoded, err := f.codec.Decode(encoded)
if err != nil {
return zero, fmt.Errorf("failed to decode value from %q: %w", f.filePath, err)
}
return decoded, nil
}
// Save saves data to the file for the given client ID.
// Uses file locking to prevent race conditions.
// Automatically creates parent directories if they do not exist.
func (f *FileStore[T]) Save(clientID string, data T) error {
if clientID == "" {
return ErrEmptyClientID
}
encoded, err := f.codec.Encode(data)
if err != nil {
return fmt.Errorf("failed to encode value for storage: %w", err)
}
if err := f.ensureDir(); err != nil {
return err
}
return f.withFileLock(func() error {
m, err := f.readStorageMap()
if err != nil {
return err
}
m.Data[clientID] = encoded
return f.writeStorageMap(m)
})
}
// Delete removes data for the given client ID from the file.
func (f *FileStore[T]) Delete(clientID string) error {
return f.withFileLock(func() error {
m, err := f.readStorageMap()
if err != nil {
return err
}
if _, ok := m.Data[clientID]; !ok {
return nil
}
delete(m.Data, clientID)
return f.writeStorageMap(m)
})
}
// String returns a description of this store.
func (f *FileStore[T]) String() string {
return "file: " + f.filePath
}
-220
View File
@@ -1,220 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestFileStore(t *testing.T) (*FileStore[Token], string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.json")
return NewFileStore[Token](path, JSONCodec[Token]{}), path
}
func TestFileStoreSaveAndLoad(t *testing.T) {
store, _ := newTestFileStore(t)
tok := Token{
AccessToken: "test-access-token",
RefreshToken: "test-refresh-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
assert.Equal(t, tok.ClientID, loaded.ClientID)
}
func TestFileStoreLoadNotFound(t *testing.T) {
store, _ := newTestFileStore(t)
_, err := store.Load("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStoreLoadFromExistingFileNotFound(t *testing.T) {
store, _ := newTestFileStore(t)
require.NoError(t, store.Save("client-1", Token{AccessToken: "token-1", ClientID: "client-1"}))
_, err := store.Load("client-2")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStoreDelete(t *testing.T) {
store, _ := newTestFileStore(t)
require.NoError(t, store.Save("test-client", Token{AccessToken: "test-token", ClientID: "test-client"}))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestFileStoreDeleteNonexistent(t *testing.T) {
store, _ := newTestFileStore(t)
// Should not error when deleting from nonexistent file
assert.NoError(t, store.Delete("nonexistent"))
}
func TestFileStoreDeletePreservesOtherClients(t *testing.T) {
store, _ := newTestFileStore(t)
for _, id := range []string{"client-1", "client-2"} {
require.NoError(t, store.Save(id, Token{AccessToken: "token-" + id, ClientID: id}))
}
require.NoError(t, store.Delete("client-1"))
loaded, err := store.Load("client-2")
require.NoError(t, err)
assert.Equal(t, "token-client-2", loaded.AccessToken)
}
func TestFileStoreConcurrentWrites(t *testing.T) {
store, _ := newTestFileStore(t)
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(id int) {
defer wg.Done()
tok := Token{
AccessToken: fmt.Sprintf("access-token-%d", id),
ClientID: fmt.Sprintf("client-%d", id),
}
assert.NoError(t, store.Save(tok.ClientID, tok))
}(i)
}
wg.Wait()
// Verify all tokens were saved by loading each one
for i := range goroutines {
clientID := fmt.Sprintf("client-%d", i)
loaded, err := store.Load(clientID)
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("access-token-%d", i), loaded.AccessToken)
}
// The kernel lock is released after the saves: the lock file (which
// legitimately remains on disk with flock-style locking) must be
// immediately re-lockable without hitting the timeout.
require.NoError(t, store.withFileLock(func() error { return nil }))
}
func TestFileStoreSaveEmptyClientID(t *testing.T) {
store, _ := newTestFileStore(t)
err := store.Save("", Token{AccessToken: "tok"})
assert.ErrorIs(t, err, ErrEmptyClientID)
}
func TestFileStoreFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission test is not applicable on Windows")
}
store, path := newTestFileStore(t)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
}
func TestFileStoreSaveCreatesParentDirectories(t *testing.T) {
nestedPath := filepath.Join(t.TempDir(), "a", "b", "c", "tokens.json")
store := NewFileStore[Token](nestedPath, JSONCodec[Token]{})
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
loaded, err := store.Load("c1")
require.NoError(t, err)
assert.Equal(t, "tok", loaded.AccessToken)
}
func TestFileStoreInvalidJSON(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, os.WriteFile(path, []byte("{invalid"), 0o600))
_, err := store.Load("any")
assert.Error(t, err)
}
func TestFileStoreNullDataField(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, os.WriteFile(path, []byte(`{"data": null}`), 0o600))
// A null data map must read as empty, not crash.
_, err := store.Load("any")
assert.ErrorIs(t, err, ErrNotFound)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
}
func TestFileStoreWithFileLockPropagatesErrorAndReleases(t *testing.T) {
store, _ := newTestFileStore(t)
sentinel := fmt.Errorf("sentinel failure")
err := store.withFileLock(func() error { return sentinel })
assert.ErrorIs(t, err, sentinel)
// The lock must have been released despite the error: re-acquiring
// immediately must succeed without hitting the timeout.
assert.NoError(t, store.withFileLock(func() error { return nil }))
}
func TestFileStoreSaveLeavesNoTempFile(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
_, err := os.Stat(path + ".tmp")
assert.True(t, os.IsNotExist(err), "temp file left behind after successful save")
}
func TestFileStoreDeleteAbsentKeyDoesNotRewriteFile(t *testing.T) {
store, path := newTestFileStore(t)
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
before, err := os.ReadFile(path)
require.NoError(t, err)
// Deleting a key that is not present must be a no-op write-wise:
// other clients' data stays byte-identical on disk.
require.NoError(t, store.Delete("absent"))
after, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, string(before), string(after))
}
func TestFileStoreString(t *testing.T) {
store := NewFileStore[Token]("/path/to/tokens.json", JSONCodec[Token]{})
assert.Equal(t, "file: /path/to/tokens.json", store.String())
}
func TestFileStoreNilCodecPanics(t *testing.T) {
assert.Panics(t, func() {
NewFileStore[string]("path", nil)
})
}
-76
View File
@@ -1,76 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"errors"
"fmt"
"github.com/zalando/go-keyring"
)
// KeyringStore stores values in the OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager).
type KeyringStore[T any] struct {
serviceName string
codec Codec[T]
}
// NewKeyringStore creates a new KeyringStore with the given codec.
// Panics if codec is nil.
func NewKeyringStore[T any](serviceName string, codec Codec[T]) *KeyringStore[T] {
if codec == nil {
panic("credstore: NewKeyringStore called with nil codec")
}
return &KeyringStore[T]{serviceName: serviceName, codec: codec}
}
// Load loads data from the keyring for the given client ID.
func (k *KeyringStore[T]) Load(clientID string) (T, error) {
var zero T
data, err := keyring.Get(k.serviceName, clientID)
if err != nil {
if errors.Is(err, keyring.ErrNotFound) {
return zero, ErrNotFound
}
return zero, fmt.Errorf("failed to read from keyring: %w", err)
}
decoded, err := k.codec.Decode(data)
if err != nil {
return zero, fmt.Errorf("failed to decode keyring data: %w", err)
}
return decoded, nil
}
// Save saves data to the keyring for the given client ID.
func (k *KeyringStore[T]) Save(clientID string, data T) error {
if clientID == "" {
return ErrEmptyClientID
}
encoded, err := k.codec.Encode(data)
if err != nil {
return fmt.Errorf("failed to encode data for keyring: %w", err)
}
if err := keyring.Set(k.serviceName, clientID, encoded); err != nil {
return fmt.Errorf("failed to save to keyring: %w", err)
}
return nil
}
// Delete removes data for the given client ID from the keyring.
func (k *KeyringStore[T]) Delete(clientID string) error {
err := keyring.Delete(k.serviceName, clientID)
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
return fmt.Errorf("failed to delete from keyring: %w", err)
}
return nil
}
// String returns a description of this store.
func (k *KeyringStore[T]) String() string {
return "keyring: " + k.serviceName
}
-81
View File
@@ -1,81 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
func TestKeyringStoreSaveAndLoad(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
require.NoError(t, store.Save("my-client", "eyJhbGciOiJSUzI1NiJ9"))
loaded, err := store.Load("my-client")
require.NoError(t, err)
assert.Equal(t, "eyJhbGciOiJSUzI1NiJ9", loaded)
}
func TestKeyringStoreLoadNotFound(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
_, err := store.Load("nonexistent")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestKeyringStoreDelete(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
require.NoError(t, store.Save("test-client", "test-token"))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
func TestKeyringStoreDeleteNonexistent(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
// Should not error when deleting nonexistent key
assert.NoError(t, store.Delete("nonexistent"))
}
func TestKeyringStoreOverwriteExisting(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
require.NoError(t, store.Save("test-client", "token-v1"))
require.NoError(t, store.Save("test-client", "token-v2"))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, "token-v2", loaded)
}
func TestKeyringStoreSaveEmptyClientID(t *testing.T) {
keyring.MockInit()
store := NewStringKeyringStore("test-service")
err := store.Save("", "tok")
assert.ErrorIs(t, err, ErrEmptyClientID)
}
func TestKeyringStoreString(t *testing.T) {
store := NewStringKeyringStore("my-service")
assert.Equal(t, "keyring: my-service", store.String())
}
func TestKeyringStoreNilCodecPanics(t *testing.T) {
assert.Panics(t, func() {
NewKeyringStore[string]("svc", nil)
})
}
-58
View File
@@ -1,58 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
// Prober is an optional interface that a Store can implement to test
// whether its backend is available.
type Prober interface {
Probe() bool
}
// DefaultSecureStore creates a SecureStore with the given codec and sensible defaults.
// The primary backend is an EncryptedFileStore writing to filePath+".enc"
// with its master key in the OS keyring; see EncryptedFileStore for why only
// the key lives there. When the keyring is unavailable, it falls back to
// plaintext file storage at filePath.
func DefaultSecureStore[T any](serviceName, filePath string, codec Codec[T]) *SecureStore[T] {
return NewSecureStore[T](
NewEncryptedFileStore[T](serviceName, filePath+".enc", codec),
NewFileStore[T](filePath, codec))
}
// SecureStore is a composite Store that uses the keyring-backed primary
// store when the keyring is available and falls back to file-based storage
// otherwise. The active backend is chosen once at construction time.
type SecureStore[T any] struct {
active Store[T]
}
// NewSecureStore creates a SecureStore. If kr implements Prober and the probe
// succeeds, kr is used as the active store. Otherwise, file is used as the
// fallback.
func NewSecureStore[T any](kr, file Store[T]) *SecureStore[T] {
if p, ok := kr.(Prober); ok && p.Probe() {
return &SecureStore[T]{active: kr}
}
return &SecureStore[T]{active: file}
}
// Load loads data from the active store.
func (s *SecureStore[T]) Load(clientID string) (T, error) {
return s.active.Load(clientID)
}
// Save saves data to the active store.
func (s *SecureStore[T]) Save(clientID string, data T) error {
return s.active.Save(clientID, data)
}
// Delete removes data from the active store.
func (s *SecureStore[T]) Delete(clientID string) error {
return s.active.Delete(clientID)
}
// String returns a description of the active store.
func (s *SecureStore[T]) String() string {
return s.active.String()
}
-242
View File
@@ -1,242 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zalando/go-keyring"
)
// mockStore is a simple mock implementing Store[T] for testing.
type mockStore[T any] struct {
data map[string]T
name string
}
func newMockStore[T any](name string) *mockStore[T] {
return &mockStore[T]{
data: make(map[string]T),
name: name,
}
}
func (m *mockStore[T]) Load(clientID string) (T, error) {
data, ok := m.data[clientID]
if !ok {
var zero T
return zero, ErrNotFound
}
return data, nil
}
func (m *mockStore[T]) Save(clientID string, data T) error {
m.data[clientID] = data
return nil
}
func (m *mockStore[T]) Delete(clientID string) error {
delete(m.data, clientID)
return nil
}
func (m *mockStore[T]) String() string {
return m.name
}
// mockProberStore implements both Store[T] and Prober.
type mockProberStore[T any] struct {
mockStore[T]
probeResult bool
}
func newMockProberStore[T any](name string, probeResult bool) *mockProberStore[T] {
return &mockProberStore[T]{
mockStore: mockStore[T]{data: make(map[string]T), name: name},
probeResult: probeResult,
}
}
func (m *mockProberStore[T]) Probe() bool {
return m.probeResult
}
func TestSecureStoreUsesKeyringWhenProbeSucceeds(t *testing.T) {
kr := newMockProberStore[Token]("keyring: test", true)
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
tok := Token{
AccessToken: "test-token",
ClientID: "test-client",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
require.NoError(t, store.Save(tok.ClientID, tok))
// Should be in keyring, not file
assert.Contains(t, kr.data, "test-client")
assert.NotContains(t, file.data, "test-client")
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, "test-token", loaded.AccessToken)
assert.Equal(t, "keyring: test", store.String())
}
func TestSecureStoreFallsBackToFileWhenProbeFails(t *testing.T) {
kr := newMockProberStore[Token]("keyring: test", false)
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
tok := Token{
AccessToken: "test-token",
ClientID: "test-client",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
require.NoError(t, store.Save(tok.ClientID, tok))
// Should be in file, not keyring
assert.Contains(t, file.data, "test-client")
assert.NotContains(t, kr.data, "test-client")
assert.Equal(t, "file: test", store.String())
}
func TestSecureStoreFallsBackWhenKrNotProber(t *testing.T) {
// kr does not implement Prober, should fall back to file
kr := newMockStore[Token]("keyring: test")
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
assert.Equal(t, "file: test", store.String())
}
func TestSecureStoreDelete(t *testing.T) {
kr := newMockProberStore[Token]("keyring: test", true)
file := newMockStore[Token]("file: test")
store := NewSecureStore[Token](kr, file)
tok := Token{
AccessToken: "test-token",
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
require.NoError(t, store.Delete("test-client"))
_, err := store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
// TestDefaultTokenSecureStoreRoundTrip verifies the happy path: with a working
// keyring, Save writes AES-256-GCM ciphertext (v1: prefix) to filePath+".enc",
// Load returns the identical token, and Delete makes Load return ErrNotFound.
func TestDefaultTokenSecureStoreRoundTrip(t *testing.T) {
keyring.MockInit() // avoid touching the real OS keyring
plainPath := filepath.Join(t.TempDir(), "credentials.json")
store := DefaultTokenSecureStore("test-service", plainPath)
tok := Token{
AccessToken: "secret-access-token",
RefreshToken: "secret-refresh-token",
TokenType: "Bearer",
ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second),
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, tok.AccessToken, loaded.AccessToken)
assert.Equal(t, tok.RefreshToken, loaded.RefreshToken)
assert.Equal(t, tok.TokenType, loaded.TokenType)
assert.Equal(t, tok.ClientID, loaded.ClientID)
assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt))
// The encrypted file must exist and contain only v1:-prefixed ciphertext.
raw, err := os.ReadFile(plainPath + ".enc")
require.NoError(t, err)
assert.Contains(t, string(raw), `"v1:`)
assert.NotContains(t, string(raw), "secret-access-token")
assert.NotContains(t, string(raw), "secret-refresh-token")
// No plaintext fallback file may be created.
_, err = os.Stat(plainPath)
assert.ErrorIs(t, err, os.ErrNotExist)
require.NoError(t, store.Delete("test-client"))
_, err = store.Load("test-client")
assert.ErrorIs(t, err, ErrNotFound)
}
// TestDefaultTokenSecureStoreFallbackWithoutKeyring verifies the CI/headless
// path: when the OS keyring is unavailable, the store falls back to the
// plaintext file and Save/Load still succeed.
func TestDefaultTokenSecureStoreFallbackWithoutKeyring(t *testing.T) {
keyring.MockInitWithError(errors.New("keyring unavailable"))
t.Cleanup(keyring.MockInit) // restore a working mock for later tests
plainPath := filepath.Join(t.TempDir(), "credentials.json")
store := DefaultTokenSecureStore("test-service", plainPath)
tok := Token{
AccessToken: "fallback-token",
ClientID: "test-client",
}
require.NoError(t, store.Save(tok.ClientID, tok))
assert.Equal(t, "file: "+plainPath, store.String())
loaded, err := store.Load("test-client")
require.NoError(t, err)
assert.Equal(t, "fallback-token", loaded.AccessToken)
// Plaintext file exists, encrypted file does not.
raw, err := os.ReadFile(plainPath)
require.NoError(t, err)
assert.True(t, strings.Contains(string(raw), "fallback-token"))
_, err = os.Stat(plainPath + ".enc")
assert.ErrorIs(t, err, os.ErrNotExist)
}
// Format-stability fixture: a fixed master key and a credentials.json.enc
// file in the "v1:" AES-256-GCM format. They must remain decryptable so
// users do not lose their stored tokens when upgrading tea.
const (
fixtureMasterKeyB64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
fixtureEncFile = `{
"data": {
"fixture-login": "v1:KallKg6+rJ3Sbxf6Kz1E5yF9bRgqq0Of00ZSctEY2Dem6qpm2wt9RdpCqSMdoX9AQ6/u9ujuC4a0LPb1n3ryXm0EJGrFpXHff0ukpatB1OZhdYlgcbuA8EFpPF/rSgN1hMXOXYQFn64r3iIEaXkgW69s887RNLbaxXALy3o7qvzmEWXuTPtEy3x+J4O6pmbDusvqgVWrOLPT9A1fSJnXWcViUcG13JF0X36NFPc149hsf1S0OUB2Uwn3hVl8jIISaw=="
}
}`
)
// TestDefaultTokenSecureStoreReadsFixtureData verifies on-disk format
// compatibility: the AES-256-GCM "v1:" ciphertext format (as produced by the
// original SDK implementation) with the master key in the keyring is
// decrypted correctly.
func TestDefaultTokenSecureStoreReadsFixtureData(t *testing.T) {
keyring.MockInit()
require.NoError(t, keyring.Set("tea-cli", masterKeyUser, fixtureMasterKeyB64))
plainPath := filepath.Join(t.TempDir(), "credentials.json")
require.NoError(t, os.WriteFile(plainPath+".enc", []byte(fixtureEncFile), 0o600))
store := DefaultTokenSecureStore("tea-cli", plainPath)
loaded, err := store.Load("fixture-login")
require.NoError(t, err)
assert.Equal(t, "fixture-access-token", loaded.AccessToken)
assert.Equal(t, "fixture-refresh-token", loaded.RefreshToken)
assert.Equal(t, "Bearer", loaded.TokenType)
assert.Equal(t, "fixture-login", loaded.ClientID)
assert.True(t, loaded.ExpiresAt.Equal(time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC)))
}
-57
View File
@@ -1,57 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"errors"
"time"
)
// ErrNotFound indicates that no data was found for the given client ID.
var ErrNotFound = errors.New("not found")
// ErrEmptyClientID is returned when an empty client ID is passed to Save.
var ErrEmptyClientID = errors.New("client ID cannot be empty")
// Store defines the interface for loading, saving, and deleting data by client ID.
type Store[T any] interface {
Load(clientID string) (T, error)
Save(clientID string, data T) error
Delete(clientID string) error
String() string
}
// Token represents saved tokens for a specific client.
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"`
IDToken string `json:"id_token,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
ClientID string `json:"client_id"`
}
// IsExpired reports whether the token has expired.
// Returns false if ExpiresAt is zero (token has no expiry).
func (t *Token) IsExpired() bool {
return !t.ExpiresAt.IsZero() && time.Now().After(t.ExpiresAt)
}
// IsValid reports whether the token has a non-empty access token and is not expired.
func (t *Token) IsValid() bool {
return t.AccessToken != "" && !t.IsExpired()
}
// NewStringKeyringStore creates a KeyringStore for plain string values.
func NewStringKeyringStore(serviceName string) *KeyringStore[string] {
return NewKeyringStore[string](serviceName, StringCodec{})
}
// DefaultTokenSecureStore creates a SecureStore for Token values with sensible defaults.
// Tokens are AES-256-GCM-encrypted to filePath+".enc" with the master key in
// the OS keyring; see DefaultSecureStore for details.
func DefaultTokenSecureStore(serviceName, filePath string) *SecureStore[Token] {
return DefaultSecureStore[Token](serviceName, filePath, JSONCodec[Token]{})
}
-89
View File
@@ -1,89 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package credstore
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestTokenIsExpired(t *testing.T) {
tests := []struct {
name string
expiresAt time.Time
want bool
}{
{
name: "expired token",
expiresAt: time.Now().Add(-1 * time.Hour),
want: true,
},
{
name: "not expired token",
expiresAt: time.Now().Add(1 * time.Hour),
want: false,
},
{
name: "zero expiry (no expiry)",
expiresAt: time.Time{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &Token{
AccessToken: "test-token",
ExpiresAt: tt.expiresAt,
}
assert.Equal(t, tt.want, token.IsExpired())
})
}
}
func TestTokenIsValid(t *testing.T) {
tests := []struct {
name string
accessToken string
expiresAt time.Time
want bool
}{
{
name: "valid token with future expiry",
accessToken: "test-token",
expiresAt: time.Now().Add(1 * time.Hour),
want: true,
},
{
name: "valid token with zero expiry",
accessToken: "test-token",
expiresAt: time.Time{},
want: true,
},
{
name: "expired token",
accessToken: "test-token",
expiresAt: time.Now().Add(-1 * time.Hour),
want: false,
},
{
name: "empty access token",
accessToken: "",
expiresAt: time.Now().Add(1 * time.Hour),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := &Token{
AccessToken: tt.accessToken,
ExpiresAt: tt.expiresAt,
}
assert.Equal(t, tt.want, token.IsValid())
})
}
}
+12 -1
View File
@@ -5,11 +5,13 @@ package interact
import ( import (
"context" "context"
"fmt"
"strings" "strings"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config" "gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task" "gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme" "gitea.dev/tea/modules/theme"
@@ -34,7 +36,16 @@ func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) e
return err return err
} }
return task.CreateIssue(ctx, login, owner, repo, opts) issue, err := task.CreateIssue(ctx, login, owner, repo, opts)
if err != nil {
return err
}
print.IssueDetails(issue, nil)
fmt.Println(issue.HTMLURL)
return nil
} }
func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error { func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error {
+37 -19
View File
@@ -200,25 +200,9 @@ func CreateLogin(ctx context.Context) error {
} }
printTitleAndContent("Selected ssh-key:", sshKey) printTitleAndContent("Selected ssh-key:", sshKey)
// ssh certificate sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey)
if strings.Contains(sshKey, "principals") { if err != nil {
sshCertPrincipal = regexp.MustCompile(`.*?principals: (.*?)[,|\s]`).FindStringSubmatch(sshKey)[1] return err
if strings.Contains(sshKey, "(ssh-agent)") {
sshAgent = true
sshKey = ""
} else {
sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1]
sshKey = strings.TrimSuffix(sshKey, "-cert.pub")
}
} else {
sshKeyFingerprint = regexp.MustCompile(`(SHA256:.*?)\s`).FindStringSubmatch(sshKey)[1]
if strings.Contains(sshKey, "(ssh-agent)") {
sshAgent = true
sshKey = ""
} else {
sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1]
sshKey = strings.TrimSuffix(sshKey, ".pub")
}
} }
} }
} }
@@ -274,6 +258,40 @@ func CreateLogin(ctx context.Context) error {
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper) return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)
} }
func parseSSHPubkeySelection(display string) (sshKey, sshCertPrincipal, sshKeyFingerprint string, sshAgent bool, err error) {
if strings.Contains(display, "principals") {
if sshCertPrincipal, err = regexpSubmatch(regexp.MustCompile(`.*?principals: (.*?)[,|\s]`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate principal from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", sshCertPrincipal, "", true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, "-cert.pub"), sshCertPrincipal, "", false, nil
}
if sshKeyFingerprint, err = regexpSubmatch(regexp.MustCompile(`(SHA256:.*?)\s`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key fingerprint from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", "", sshKeyFingerprint, true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, ".pub"), "", sshKeyFingerprint, false, nil
}
func regexpSubmatch(re *regexp.Regexp, s string) (string, error) {
match := re.FindStringSubmatch(s)
if len(match) < 2 {
return "", fmt.Errorf("no match")
}
return match[1], nil
}
var tokenScopeOpts = []string{ var tokenScopeOpts = []string{
string(gitea.AccessTokenScopeAll), string(gitea.AccessTokenScopeAll),
string(gitea.AccessTokenScopeRepo), string(gitea.AccessTokenScopeRepo),
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSSHPubkeySelection(t *testing.T) {
tests := []struct {
name string
display string
wantSSHKey string
wantCertPrincipal string
wantKeyFingerprint string
wantSSHAgent bool
wantErr bool
}{
{
name: "local ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (/home/user/.ssh/id_ed25519.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantKeyFingerprint: "SHA256:abc",
},
{
name: "agent ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (ssh-agent)",
wantKeyFingerprint: "SHA256:abc",
wantSSHAgent: true,
},
{
name: "local certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1,user2 (/home/user/.ssh/id_ed25519-cert.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantCertPrincipal: "user1",
},
{
name: "agent certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1 (ssh-agent)",
wantCertPrincipal: "user1",
wantSSHAgent: true,
},
{
name: "unexpected display",
display: "ssh-ed25519 comment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sshKey, certPrincipal, keyFingerprint, sshAgent, err := parseSSHPubkeySelection(tt.display)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantSSHKey, sshKey)
assert.Equal(t, tt.wantCertPrincipal, certPrincipal)
assert.Equal(t, tt.wantKeyFingerprint, keyFingerprint)
assert.Equal(t, tt.wantSSHAgent, sshAgent)
})
}
}
+9 -1
View File
@@ -8,6 +8,7 @@ import (
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context" "gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task" "gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme" "gitea.dev/tea/modules/theme"
@@ -134,11 +135,18 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error)
return err return err
} }
return task.CreatePull( pr, err := task.CreatePull(
requestCtx, requestCtx,
ctx, ctx,
base, base,
head, head,
&allowMaintainerEdits, &allowMaintainerEdits,
&opts) &opts)
if err != nil {
return err
}
print.PullDetails(pr, nil, nil)
return nil
} }
+145
View File
@@ -0,0 +1,145 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
)
// LoginStatus contains the authentication status of a single configured login.
type LoginStatus struct {
Name string
URL string
User string
Valid bool
AuthMethod string
TokenExpiry time.Time
Helper bool
Default bool
Error string
}
// LoginStatusFields are the available fields to print with LoginStatuses.
var LoginStatusFields = []string{
"name",
"url",
"user",
"valid",
"auth_method",
"token_expiry",
"helper",
"default",
}
// LoginStatuses prints authentication status for one or more logins.
func LoginStatuses(statuses []LoginStatus, output string) error {
if output != "" {
printables := make([]printable, len(statuses))
for i := range statuses {
printables[i] = statuses[i]
}
t := tableFromItems(LoginStatusFields, printables, isMachineReadable(output))
return t.print(output)
}
if len(statuses) == 0 {
fmt.Println("No logins configured.")
return nil
}
for i, status := range statuses {
if i > 0 {
fmt.Println()
}
printLoginStatusReport(status)
}
return nil
}
func printLoginStatusReport(status LoginStatus) {
name := status.Name
if status.Default {
name += " (default)"
}
fmt.Println(name)
if status.Valid {
line := " ✔ Logged in to " + status.URL
if status.User != "" {
line += " as " + status.User
}
fmt.Println(line)
tokenLine := " ✔ Token is valid"
if status.AuthMethod != "" {
tokenLine += " (" + status.AuthMethod
if !status.TokenExpiry.IsZero() {
tokenLine += ", " + formatTokenExpiry(status.TokenExpiry)
}
tokenLine += ")"
}
fmt.Println(tokenLine)
} else {
message := status.Error
if message == "" {
message = "Login failed"
}
fmt.Println(" ✗ " + message)
}
if status.Helper {
fmt.Println(" ✔ Git credential helper configured")
} else {
fmt.Println(" ✗ Git credential helper not configured")
}
}
func formatExpiryDuration(t time.Time) string {
d := time.Until(t)
if d < 0 {
return "expired"
}
if d < time.Minute {
return "in less than a minute"
}
return "in " + strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
}
func formatTokenExpiry(t time.Time) string {
if t.Before(time.Now()) {
return "expired"
}
return "expires " + formatExpiryDuration(t)
}
// FormatField implements the printable interface for LoginStatus.
func (s LoginStatus) FormatField(field string, machineReadable bool) string {
switch field {
case "name":
return s.Name
case "url":
return s.URL
case "user":
return s.User
case "valid":
return formatBoolean(s.Valid, !machineReadable)
case "auth_method":
return s.AuthMethod
case "token_expiry":
if s.TokenExpiry.IsZero() {
return ""
}
if machineReadable {
return FormatTime(s.TokenExpiry, true)
}
return formatExpiryDuration(s.TokenExpiry)
case "helper":
return formatBoolean(s.Helper, !machineReadable)
case "default":
return formatBoolean(s.Default, !machineReadable)
}
return ""
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLoginStatusFormatField(t *testing.T) {
status := LoginStatus{
Name: "gitea",
URL: "https://gitea.com",
User: "alice",
Valid: true,
AuthMethod: "oauth",
TokenExpiry: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC),
Helper: true,
Default: true,
}
assert.Equal(t, "gitea", status.FormatField("name", false))
assert.Equal(t, "https://gitea.com", status.FormatField("url", false))
assert.Equal(t, "alice", status.FormatField("user", false))
assert.Equal(t, "true", status.FormatField("valid", true))
assert.Equal(t, "✔", status.FormatField("valid", false))
assert.Equal(t, "oauth", status.FormatField("auth_method", true))
assert.Equal(t, "2026-08-27T12:00:00Z", status.FormatField("token_expiry", true))
assert.Equal(t, "true", status.FormatField("helper", true))
assert.Equal(t, "✔", status.FormatField("default", false))
}
func TestFormatExpiryDurationExpired(t *testing.T) {
assert.Equal(t, "expired", formatExpiryDuration(time.Now().Add(-time.Hour)))
}
+5 -10
View File
@@ -10,24 +10,19 @@ import (
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config" "gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
) )
// CreateIssue creates an issue in the given repo and prints the result // CreateIssue creates an issue in the given repo and returns the created issue
func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error { func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) {
// title is required // title is required
if len(opts.Title) == 0 { if len(opts.Title) == 0 {
return fmt.Errorf("title is required") return nil, fmt.Errorf("title is required")
} }
issue, _, err := rlogin.Client().Issues.CreateIssue(requestCtx, repoOwner, repoName, opts) issue, _, err := rlogin.Client().Issues.CreateIssue(requestCtx, repoOwner, repoName, opts)
if err != nil { if err != nil {
return fmt.Errorf("could not create issue: %s", err) return nil, fmt.Errorf("could not create issue: %s", err)
} }
print.IssueDetails(issue, nil) return issue, nil
fmt.Println(issue.HTMLURL)
return nil
} }
+23
View File
@@ -48,6 +48,29 @@ func SetupHelper(login config.Login) (ok bool, err error) {
return true, nil return true, nil
} }
// HasGitCredentialHelper reports whether tea is registered as a git credential
// helper for the given login. It mirrors the global git config lookup used by
// SetupHelper.
func HasGitCredentialHelper(login config.Login) bool {
if login.URL == "" {
return false
}
helperKey := fmt.Sprintf("credential.%s.helper", login.URL)
currentHelpers, err := exec.Command("git", "config", "--global", "--get-all", helperKey).Output()
if err != nil {
return false
}
for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") {
if strings.HasSuffix(strings.TrimSpace(line), "login helper") {
return true
}
}
return false
}
// CreateLogin create a login to be stored in config // CreateLogin create a login to be stored in config
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error { func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
// checks ... // checks ...
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"strings"
"time"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CheckLoginStatus verifies the stored token for a login against the server and
// returns a printable status. Unlike config.Login.Client, refresh failures are
// captured in the returned status instead of terminating the process.
func CheckLoginStatus(ctx context.Context, login *config.Login) print.LoginStatus {
status := print.LoginStatus{
Name: login.Name,
URL: login.URL,
AuthMethod: loginAuthMethod(login),
TokenExpiry: loginTokenExpiry(login),
Helper: HasGitCredentialHelper(*login),
Default: login.Default,
}
if login.GetAccessToken() == "" {
status.Error = "Login failed: no access token configured"
return status
}
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
status.Error = "Token refresh failed: " + strings.TrimPrefix(err.Error(), "failed to refresh token: ")
return status
}
// A successful refresh updates the token in the secure store, so re-read the
// expiry for the status line.
status.TokenExpiry = loginTokenExpiry(login)
user, _, err := login.ClientWithoutRefresh().Users.GetMyUserInfo(ctx)
if err != nil {
status.Error = fmt.Sprintf("Login failed: %s", err)
return status
}
status.Valid = true
status.User = user.UserName
return status
}
func loginAuthMethod(login *config.Login) string {
if login.IsOAuth() {
return config.AuthMethodOAuth
}
return "token"
}
func loginTokenExpiry(login *config.Login) time.Time {
expiry := login.GetTokenExpiry()
if expiry.Equal(time.Unix(0, 0)) {
return time.Time{}
}
return expiry
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
)
func TestCheckLoginStatus(t *testing.T) {
// Keep helper detection isolated from the developer's real git config.
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), ".gitconfig"))
t.Run("valid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/user", r.URL.Path)
assert.Equal(t, "token secret-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1,"login":"alice"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
})
assert.True(t, status.Valid)
assert.Empty(t, status.Error)
assert.Equal(t, "test", status.Name)
assert.Equal(t, server.URL, status.URL)
assert.Equal(t, "alice", status.User)
assert.Equal(t, "token", status.AuthMethod)
assert.False(t, status.Helper)
})
t.Run("invalid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"token is invalid"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "expired-token",
VersionCheck: false,
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "token is invalid")
})
t.Run("missing token", func(t *testing.T) {
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: "https://gitea.example.com",
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "no access token configured")
})
}
+12 -13
View File
@@ -14,7 +14,6 @@ import (
"gitea.dev/tea/modules/config" "gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context" "gitea.dev/tea/modules/context"
local_git "gitea.dev/tea/modules/git" local_git "gitea.dev/tea/modules/git"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/utils" "gitea.dev/tea/modules/utils"
) )
@@ -24,24 +23,26 @@ var (
consecutive = regexp.MustCompile(`[\s]{2,}`) consecutive = regexp.MustCompile(`[\s]{2,}`)
) )
// CreatePull creates a PR in the given repo and prints the result // CreatePull creates a PR in the given repo and returns the created PR
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (err error) { func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (*gitea.PullRequest, error) {
var err error
// default is default branch // default is default branch
if len(base) == 0 { if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo) base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
if err != nil { if err != nil {
return err return nil, err
} }
} }
// default is current one // default is current one
if len(head) == 0 { if len(head) == 0 {
if ctx.LocalRepo == nil { if ctx.LocalRepo == nil {
return fmt.Errorf("no local git repo detected, please specify head branch") return nil, fmt.Errorf("no local git repo detected, please specify head branch")
} }
headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo) headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo)
if err != nil { if err != nil {
return err return nil, err
} }
head = GetHeadSpec(headOwner, headBranch, ctx.Owner) head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
@@ -49,7 +50,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
// head & base may not be the same // head & base may not be the same
if head == base { if head == base {
return fmt.Errorf("can't create PR from %s to %s", head, base) return nil, fmt.Errorf("can't create PR from %s to %s", head, base)
} }
// default is head branch name // default is head branch name
@@ -58,7 +59,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
} }
// title is required // title is required
if len(opts.Title) == 0 { if len(opts.Title) == 0 {
return fmt.Errorf("title is required") return nil, fmt.Errorf("title is required")
} }
client := ctx.Login.Client() client := ctx.Login.Client()
@@ -74,7 +75,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
Deadline: opts.Deadline, Deadline: opts.Deadline,
}) })
if err != nil { if err != nil {
return fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err) return nil, fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err)
} }
if allowMaintainerEdits != nil && pr.AllowMaintainerEdit != *allowMaintainerEdits { if allowMaintainerEdits != nil && pr.AllowMaintainerEdit != *allowMaintainerEdits {
@@ -82,13 +83,11 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
AllowMaintainerEdit: allowMaintainerEdits, AllowMaintainerEdit: allowMaintainerEdits,
}) })
if err != nil { if err != nil {
return fmt.Errorf("could not enable maintainer edit on pull: %v", err) return nil, fmt.Errorf("could not enable maintainer edit on pull: %v", err)
} }
} }
print.PullDetails(pr, nil, nil) return pr, nil
return err
} }
// GetDefaultPRBase retrieves the default base branch for the given repo // GetDefaultPRBase retrieves the default base branch for the given repo
+31 -3
View File
@@ -19,8 +19,36 @@ func PullMerge(requestCtx stdctx.Context, login *config.Login, repoOwner, repoNa
if err != nil { if err != nil {
return err return err
} }
if !success { if success {
return fmt.Errorf("failed to merge PR, is it still open?") return nil
}
return fmt.Errorf("failed to merge PR #%d: %s", index,
mergeFailureReason(requestCtx, client, repoOwner, repoName, index))
}
// mergeFailureReason returns why merging was refused. The SDK reports refusal as
// success=false and discards Gitea's explanatory body, so the reason has to be
// re-derived from the PR. Costs one API call, on the failure path only.
func mergeFailureReason(requestCtx stdctx.Context, client *gitea.Client, repoOwner, repoName string, index int64) string {
// Fallback naming the conditions tea cannot observe, used when the PR looks
// mergeable but the merge was refused anyway.
const refused = "the server refused the merge; check required status checks, requested reviews, or branch protection rules"
pr, _, err := client.PullRequests.GetPullRequest(requestCtx, repoOwner, repoName, index)
if err != nil || pr == nil {
return refused
}
switch {
case pr.HasMerged:
return "it has already been merged"
case pr.State == gitea.StateClosed:
return "it is closed"
case pr.Draft:
return "it is a draft; mark it ready for review first"
case !pr.Mergeable:
return "it has conflicting files or is otherwise not mergeable"
default:
return refused
} }
return nil
} }
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mergeTestServer answers the merge POST with mergeStatus and the PR GET with
// prJSON, or a 404 if prJSON is empty.
func mergeTestServer(t *testing.T, prJSON string, mergeStatus int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/merge"):
w.WriteHeader(mergeStatus)
// Gitea explains itself here; the SDK discards it.
_, _ = w.Write([]byte(`{"message":"Please try again later"}`))
case r.Method == http.MethodGet:
if prJSON == "" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"pull request does not exist"}`))
return
}
_, _ = w.Write([]byte(prJSON))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
}))
}
func pullJSON(state string, merged, draft, mergeable bool) string {
return fmt.Sprintf(
`{"number":3,"state":%q,"merged":%t,"draft":%t,"mergeable":%t,"head":{"sha":"abc123"}}`,
state, merged, draft, mergeable)
}
func TestPullMerge(t *testing.T) {
tests := []struct {
name string
pr string
mergeStatus int
wantErr string
}{
{
name: "success",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusOK,
},
{
name: "created is also success",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusCreated,
},
{
// gitea/tea#1022: an open PR with conflicts was reported as
// possibly not open.
name: "conflicting files",
pr: pullJSON("open", false, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it has conflicting files or is otherwise not mergeable",
},
{
name: "already merged",
pr: pullJSON("closed", true, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it has already been merged",
},
{
name: "closed",
pr: pullJSON("closed", false, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it is closed",
},
{
name: "draft",
pr: pullJSON("open", false, true, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it is a draft; mark it ready for review first",
},
{
// Open and mergeable, yet refused.
name: "refused while mergeable",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: the server refused the merge; check required status checks, requested reviews, or branch protection rules",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := mergeTestServer(t, tt.pr, tt.mergeStatus)
defer server.Close()
err := PullMerge(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 3, gitea.MergePullRequestOption{Style: gitea.MergeStyleMerge})
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
})
}
}
// A refusal must still explain itself when the follow-up PR lookup fails.
func TestPullMergeReasonUnavailable(t *testing.T) {
server := mergeTestServer(t, "", http.StatusMethodNotAllowed)
defer server.Close()
err := PullMerge(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 3, gitea.MergePullRequestOption{
Style: gitea.MergeStyleMerge,
// Set so the SDK skips its own pre-merge PR lookup.
HeadCommitId: "abc123",
})
require.Error(t, err)
assert.Equal(t, "failed to merge PR #3: the server refused the merge; check required status checks, requested reviews, or branch protection rules", err.Error())
}
+4 -8
View File
@@ -6,13 +6,9 @@
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is # Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
# S3-API compatible). # S3-API compatible).
# #
# This is the R2 half of the release process's parallel S3+R2 upload # It is invoked once per release artifact via a goreleaser
# period: goreleaser's `blobs:` pipe still uploads every release # `publishers:` entry, and is the only artifact storage upload in the
# artifact to AWS S3, and this script is invoked once per artifact # release process.
# (via a goreleaser `publishers:` entry) to mirror the same artifact
# into R2. Once the migration away from S3 is complete, the `blobs:`
# block and the AWS_* secrets can be dropped without touching this
# script.
# #
# Usage: # Usage:
# upload-r2.sh <local-file> <remote-key> # upload-r2.sh <local-file> <remote-key>
@@ -24,7 +20,7 @@
# preflight step in CI: goreleaser custom publishers run as the very # preflight step in CI: goreleaser custom publishers run as the very
# last step of the publish pipeline, so without a preflight check a # last step of the publish pipeline, so without a preflight check a
# missing R2_* secret would only be discovered after the Gitea release # missing R2_* secret would only be discovered after the Gitea release
# has already been created and every artifact already uploaded to S3. # has already been created.
# #
# Required environment variables: # Required environment variables:
# R2_ENDPOINT Base URL of the R2 endpoint, e.g. # R2_ENDPOINT Base URL of the R2 endpoint, e.g.