Compare commits

..
Author SHA1 Message Date
bircni 98175d7135 Merge branch 'main' into refactor/embed-credstore 2026-08-06 05:06:10 +00:00
Bo-Yi WuandClaude Fable 5 b645a189a2 fix(login): delete OAuth token using the stored login name
- Pass the stored login name to DeleteOAuthToken instead of the
  CLI-provided one: the login lookup is case-insensitive but credstore
  keys are exact-match, so deleting with different casing left the
  encrypted token orphaned in the credential store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:59:44 +08:00
Bo-Yi WuandClaude Fable 5 f6d939a8df refactor(credstore): embed credential store and drop sdk-go dependency
- Embed the minimal credstore subset used by tea (SecureStore,
  EncryptedFileStore, KeyringStore, FileStore) as modules/credstore so
  external SDK renames can no longer break the build
- Keep the on-disk format fully compatible: AES-256-GCM values with the
  v1: prefix, credentials.json / credentials.json.enc paths, and the
  Token JSON field names are unchanged, verified by a ciphertext fixture
  generated with sdk-go v1.1.0
- Store the keyring master key under a tea-owned account name
- Reuse the existing kernel-level filelock module instead of the
  upstream lockfile protocol, removing a stale-lock race
- Cover roundtrip, keyring-unavailable fallback, and fixture decryption
  with tests using a mocked keyring
- Remove github.com/go-signet/sdk-go and promote
  github.com/zalando/go-keyring to a direct dependency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:59:07 +08:00
46 changed files with 1575 additions and 1319 deletions
+10 -4
View File
@@ -12,10 +12,11 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
# Custom publishers (the R2 upload below) run as the very last # Custom publishers (the R2 mirror 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. Fail here instead, before anything # has already been created and every artifact already uploaded
# is built or published, if the R2 secrets are missing. # to S3. Fail here instead, before anything is built or
# 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:
@@ -45,6 +46,11 @@ 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 }}
@@ -71,7 +77,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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+10 -4
View File
@@ -13,10 +13,11 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- run: git fetch --force --tags - run: git fetch --force --tags
# Custom publishers (the R2 upload below) run as the very last # Custom publishers (the R2 mirror 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. Fail here instead, before anything # has already been created and every artifact already uploaded
# is built or published, if the R2 secrets are missing. # to S3. Fail here instead, before anything is built or
# 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,6 +47,11 @@ 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 }}
@@ -72,7 +78,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@dbcb813823bdd20940b903addbd779551569679f # v4 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
+18 -7
View File
@@ -76,13 +76,24 @@ 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
# Uploads the release artifacts to Cloudflare R2. A `blobs:` entry is blobs:
# not used here since the blob pipe authenticates from the global -
# AWS_* env with no per-entry credentials; `publishers:` supports provider: s3
# per-entry `env:` instead, so it's used to invoke bucket: "{{ .Env.S3_BUCKET }}"
# scripts/upload-r2.sh once per artifact. Custom publishers inherit region: "{{ .Env.S3_REGION }}"
# almost nothing from the environment, hence the explicit R2_* directory: "tea/{{.Version}}"
# forwarding below. extra_files:
- 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
@@ -1,73 +0,0 @@
// 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
@@ -1,161 +0,0 @@
// 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")
}
+5 -28
View File
@@ -100,10 +100,6 @@ 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"},
@@ -137,22 +133,12 @@ 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: body, Body: ctx.String("description"),
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 != "" {
@@ -222,18 +208,9 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error)
val := ctx.String("title") val := ctx.String("title")
opts.Title = &val opts.Title = &val
} }
body, err := resolveEditBody( if ctx.IsSet("description") {
ctx.String("description"), val := ctx.String("description")
ctx.IsSet("description"), opts.Body = &val
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")
+3 -43
View File
@@ -5,18 +5,13 @@ 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
@@ -52,44 +47,9 @@ func runIssuesCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
return err return err
} }
issue, err := task.CreateIssue(requestCtx, ctx.Login, return 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
@@ -1,41 +0,0 @@
// 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,7 +31,6 @@ var CmdLogin = cli.Command{
&login.CmdLoginSetDefault, &login.CmdLoginSetDefault,
&login.CmdLoginHelper, &login.CmdLoginHelper,
&login.CmdLoginOAuthRefresh, &login.CmdLoginOAuthRefresh,
&login.CmdLoginStatus,
}, },
} }
-59
View File
@@ -1,59 +0,0 @@
// 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"))
}
+1 -48
View File
@@ -5,9 +5,6 @@ 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"
@@ -15,7 +12,6 @@ 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"
) )
@@ -84,12 +80,6 @@ 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 {
@@ -118,7 +108,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"))
} }
pr, err := task.CreatePull( return task.CreatePull(
requestCtx, requestCtx,
ctx, ctx,
ctx.String("base"), ctx.String("base"),
@@ -126,41 +116,4 @@ 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
@@ -1,48 +0,0 @@
// 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
@@ -1,45 +0,0 @@
// 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,12 +109,6 @@ 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
@@ -227,8 +221,6 @@ 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
@@ -255,8 +247,6 @@ 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
@@ -389,8 +379,6 @@ 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>
@@ -449,8 +437,6 @@ 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
+6 -7
View File
@@ -2,7 +2,7 @@ module gitea.dev/tea
go 1.26.0 go 1.26.0
toolchain go1.26.6 toolchain go1.26.5
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
golang.org/x/crypto v0.56.0 github.com/zalando/go-keyring v0.2.8
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,12 +76,11 @@ 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.57.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect golang.org/x/tools v0.47.0 // indirect
) )
retract v1.3.3 // accidental release, tag deleted retract v1.3.3 // accidental release, tag deleted
+10 -12
View File
@@ -89,8 +89,6 @@ 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=
@@ -162,19 +160,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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
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=
@@ -191,13 +189,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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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=
+1 -3
View File
@@ -366,9 +366,7 @@ 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)
// Don't wait for the opener to exit, so a browser that holds the return open.Run(url)
// 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,16 +6,11 @@ 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"
@@ -94,36 +89,3 @@ 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")
}
}
+2 -1
View File
@@ -8,8 +8,9 @@ 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"
) )
+4 -9
View File
@@ -240,11 +240,14 @@ 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(name) _ = DeleteOAuthToken(storedName)
} }
return saveConfigUnsafe() return saveConfigUnsafe()
@@ -446,14 +449,6 @@ 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
@@ -0,0 +1,49 @@
// 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
@@ -0,0 +1,8 @@
// 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
@@ -0,0 +1,259 @@
// 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
@@ -0,0 +1,140 @@
// 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
@@ -0,0 +1,171 @@
// 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
@@ -0,0 +1,220 @@
// 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
@@ -0,0 +1,76 @@
// 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
@@ -0,0 +1,81 @@
// 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
@@ -0,0 +1,58 @@
// 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
@@ -0,0 +1,242 @@
// 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
@@ -0,0 +1,57 @@
// 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
@@ -0,0 +1,89 @@
// 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())
})
}
}
+1 -12
View File
@@ -5,13 +5,11 @@ 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"
@@ -36,16 +34,7 @@ func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) e
return err return err
} }
issue, err := task.CreateIssue(ctx, login, owner, repo, opts) return 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 {
+19 -37
View File
@@ -200,9 +200,25 @@ func CreateLogin(ctx context.Context) error {
} }
printTitleAndContent("Selected ssh-key:", sshKey) printTitleAndContent("Selected ssh-key:", sshKey)
sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey) // ssh certificate
if err != nil { if strings.Contains(sshKey, "principals") {
return err sshCertPrincipal = regexp.MustCompile(`.*?principals: (.*?)[,|\s]`).FindStringSubmatch(sshKey)[1]
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")
}
} }
} }
} }
@@ -258,40 +274,6 @@ 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
@@ -1,69 +0,0 @@
// 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)
})
}
}
+1 -9
View File
@@ -8,7 +8,6 @@ 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"
@@ -135,18 +134,11 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error)
return err return err
} }
pr, err := task.CreatePull( return 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
@@ -1,145 +0,0 @@
// 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
@@ -1,38 +0,0 @@
// 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)))
}
+10 -5
View File
@@ -10,19 +10,24 @@ 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 returns the created issue // CreateIssue creates an issue in the given repo and prints the result
func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) { func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error {
// title is required // title is required
if len(opts.Title) == 0 { if len(opts.Title) == 0 {
return nil, fmt.Errorf("title is required") return 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 nil, fmt.Errorf("could not create issue: %s", err) return fmt.Errorf("could not create issue: %s", err)
} }
return issue, nil print.IssueDetails(issue, nil)
fmt.Println(issue.HTMLURL)
return nil
} }
-23
View File
@@ -48,29 +48,6 @@ 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
@@ -1,67 +0,0 @@
// 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
@@ -1,75 +0,0 @@
// 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")
})
}
+13 -12
View File
@@ -14,6 +14,7 @@ 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"
) )
@@ -23,26 +24,24 @@ var (
consecutive = regexp.MustCompile(`[\s]{2,}`) consecutive = regexp.MustCompile(`[\s]{2,}`)
) )
// CreatePull creates a PR in the given repo and returns the created PR // CreatePull creates a PR in the given repo and prints the result
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (*gitea.PullRequest, error) { func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (err 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 nil, err return 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 nil, fmt.Errorf("no local git repo detected, please specify head branch") return 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 nil, err return err
} }
head = GetHeadSpec(headOwner, headBranch, ctx.Owner) head = GetHeadSpec(headOwner, headBranch, ctx.Owner)
@@ -50,7 +49,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 nil, fmt.Errorf("can't create PR from %s to %s", head, base) return fmt.Errorf("can't create PR from %s to %s", head, base)
} }
// default is head branch name // default is head branch name
@@ -59,7 +58,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 nil, fmt.Errorf("title is required") return fmt.Errorf("title is required")
} }
client := ctx.Login.Client() client := ctx.Login.Client()
@@ -75,7 +74,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 nil, fmt.Errorf("could not create PR from %s to %s:%s: %s", head, ctx.Owner, base, err) return 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 {
@@ -83,11 +82,13 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
AllowMaintainerEdit: allowMaintainerEdits, AllowMaintainerEdit: allowMaintainerEdits,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("could not enable maintainer edit on pull: %v", err) return fmt.Errorf("could not enable maintainer edit on pull: %v", err)
} }
} }
return pr, nil print.PullDetails(pr, nil, nil)
return err
} }
// GetDefaultPRBase retrieves the default base branch for the given repo // GetDefaultPRBase retrieves the default base branch for the given repo
+3 -31
View File
@@ -19,36 +19,8 @@ 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 nil return fmt.Errorf("failed to merge PR, is it still open?")
}
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
@@ -1,146 +0,0 @@
// 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())
}
+8 -4
View File
@@ -6,9 +6,13 @@
# 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).
# #
# It is invoked once per release artifact via a goreleaser # This is the R2 half of the release process's parallel S3+R2 upload
# `publishers:` entry, and is the only artifact storage upload in the # period: goreleaser's `blobs:` pipe still uploads every release
# release process. # artifact to AWS S3, and this script is invoked once per artifact
# (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>
@@ -20,7 +24,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. # has already been created and every artifact already uploaded to S3.
# #
# 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.