mirror of
https://gitea.com/gitea/tea.git
synced 2026-09-15 19:38:13 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98175d7135 | ||
|
|
b645a189a2 | ||
|
|
f6d939a8df |
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ var CmdLogin = cli.Command{
|
|||||||
&login.CmdLoginSetDefault,
|
&login.CmdLoginSetDefault,
|
||||||
&login.CmdLoginHelper,
|
&login.CmdLoginHelper,
|
||||||
&login.CmdLoginOAuthRefresh,
|
&login.CmdLoginOAuthRefresh,
|
||||||
&login.CmdLoginStatus,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
@@ -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]{})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ...
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user