Compare commits

..

No commits in common. "main" and "v0.14.2" have entirely different histories.

73 changed files with 276 additions and 3210 deletions

View file

@ -8,28 +8,16 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: git fetch --force --tags
# Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release
# has already been created. Fail here instead, before anything
# is built or published, if the R2 secrets are missing.
- name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
check-latest: true
- name: import gpg
id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
@ -37,18 +25,19 @@ jobs:
id: sdk_version
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
- name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser-pro
version: "~> v2"
version: "~> v1"
args: release --nightly
env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_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 }}
GORELEASER_FORCE_TOKEN: 'gitea'
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
@ -60,24 +49,24 @@ jobs:
DOCKER_LATEST: nightly
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@v7
env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
with:

View file

@ -9,28 +9,16 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: git fetch --force --tags
# Custom publishers (the R2 upload below) run as the very last
# step of goreleaser's publish pipeline, after the Gitea release
# has already been created. Fail here instead, before anything
# is built or published, if the R2 secrets are missing.
- name: check R2 configuration
run: sh scripts/upload-r2.sh --check-config
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
check-latest: true
- name: import gpg
id: import_gpg
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
@ -38,18 +26,19 @@ jobs:
id: sdk_version
run: echo "version=$(go list -f '{{.Version}}' -m gitea.dev/sdk)" >> "$GITHUB_OUTPUT"
- name: goreleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser-pro
version: "~> v2"
version: "~> v1"
args: release
env:
SDK_VERSION: ${{ steps.sdk_version.outputs.version }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_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 }}
GORELEASER_FORCE_TOKEN: 'gitea'
GPGSIGN_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
@ -61,18 +50,18 @@ jobs:
DOCKER_LATEST: nightly
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
uses: actions/checkout@v7
with:
fetch-depth: 0 # all history for all branches and tags
- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4
uses: docker/setup-qemu-action@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
uses: docker/setup-buildx-action@v4
- name: Login to DockerHub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@ -82,7 +71,7 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
uses: docker/build-push-action@v7
env:
ACTIONS_RUNTIME_TOKEN: '' # See https://gitea.com/gitea/act_runner/issues/119
with:

View file

@ -16,11 +16,10 @@ jobs:
name: Lint Build And Unit Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
check-latest: true
- name: lint and build
run: |
make clean
@ -42,29 +41,17 @@ jobs:
GITEA_TEA_TEST_USERNAME: "test01"
GITEA_TEA_TEST_PASSWORD: "test01"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
check-latest: true
- name: wait for the gitea instance to be ready
run: |
for i in $(seq 1 30); do
if curl --noproxy "*" -sf http://gitea:3000/api/v1/version; then
echo "gitea is ready after ${i} attempt(s)"
exit 0
fi
echo "waiting for gitea, attempt ${i}/30"
sleep 2
done
echo "::error::gitea did not become ready within 60s"
exit 1
- run: curl --noproxy "*" http://gitea:3000/api/v1/version # verify connection to instance
- name: integration test
run: |
make integration-test
services:
gitea:
image: docker.gitea.com/gitea:1.27.0
image: docker.gitea.com/gitea:1.26.2
cmd:
- bash
- -c

View file

@ -1,5 +1,3 @@
version: 2
before:
hooks:
- go mod tidy
@ -76,43 +74,15 @@ builds:
- cmd: sh .goreleaser.checksum.sh {{ .Path }}
- cmd: sh .goreleaser.checksum.sh {{ .Path }}.xz
# Uploads the release artifacts to Cloudflare R2. A `blobs:` entry is
# not used 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
# goreleaser's release pipe already registers `release.extra_files`
# (./**.xz and ./**.xz.sha256, see the `release:` block below) as
# UploadableFile artifacts, and `internal/exec`'s filterArtifacts
# appends this block's own extra_files with no de-duplication. It
# can't be globbed away, since gobwas/glob (via goreleaser/fileglob)
# has no substring-exclusion matcher. It's harmless: PUT is
# idempotent, and the `./**.xz` glob below is kept deliberately so
# this publisher declares its own complete file set rather than
# implicitly depending on the `release:` block's globs.
#
# checksum: true mirrors goreleaser's generated checksums.txt;
# signature: true additionally mirrors checksums.txt.sig, which the
# `signs:` block below produces by GPG-signing that checksum file.
# Without signature: true, artifacts downloaded from the R2 mirror
# would have no signature file to verify against.
publishers:
- name: cloudflare-r2
checksum: true
signature: true
blobs:
-
provider: s3
bucket: "{{ .Env.S3_BUCKET }}"
region: "{{ .Env.S3_REGION }}"
folder: "tea/{{.Version}}"
extra_files:
- glob: ./**.xz
- glob: ./**.sha256
cmd: sh scripts/upload-r2.sh {{ abs .ArtifactPath }} tea/{{ .Version }}/{{ .ArtifactName }}
env:
- R2_ENDPOINT={{ index .Env "R2_ENDPOINT" }}
- R2_BUCKET={{ index .Env "R2_BUCKET" }}
- R2_ACCESS_KEY_ID={{ index .Env "R2_ACCESS_KEY_ID" }}
- R2_SECRET_ACCESS_KEY={{ index .Env "R2_SECRET_ACCESS_KEY" }}
archives:
- format: binary
@ -134,10 +104,10 @@ signs:
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
snapshot:
version_template: "{{ .Branch }}-devel"
name_template: "{{ .Branch }}-devel"
nightly:
version_template: "{{ .Branch }}"
name_template: "{{ .Branch }}"
gitea_urls:
api: https://gitea.com/api/v1

View file

@ -118,12 +118,7 @@ unit-test-coverage:
.PHONY: tidy
tidy:
$(eval GO_TOOLCHAIN := $(shell grep -Eo '^toolchain\s+go[0-9.]+' go.mod | cut -d' ' -f2))
$(GO) mod tidy
@# workaround https://github.com/golang/go/issues/75331: restore toolchain if tidy dropped it
@if [ -n "$(GO_TOOLCHAIN)" ] && ! grep -qE '^toolchain\s' go.mod; then \
$(GO) mod edit -toolchain=$(GO_TOOLCHAIN); \
fi
.PHONY: check
check: test

View file

@ -7,6 +7,7 @@ import (
stdctx "context"
"errors"
"fmt"
"io"
"strings"
gitea "gitea.dev/sdk"
@ -31,13 +32,7 @@ var CmdCommentsAdd = cli.Command{
Description: "Add a comment to an issue or pull request.",
ArgsUsage: "<issue / pr index> [<comment body>]",
Action: RunCommentsAdd,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "description",
Aliases: []string{"d"},
Usage: "comment body (alternative to the positional argument)",
},
}, flags.AllDefaultFlags...),
Flags: flags.AllDefaultFlags,
}
// RunCommentsAdd creates a new comment.
@ -59,12 +54,18 @@ func RunCommentsAdd(requestCtx stdctx.Context, cmd *cli.Command) error {
return err
}
stdinPiped := interact.IsStdinPiped()
body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader)
if err != nil {
return err
}
if len(body) == 0 && !stdinPiped {
body := strings.Join(ctx.Args().Tail(), " ")
// Only consume stdin if no positional body was given. interact.IsStdinPiped()
// is true for any non-TTY stdin (CI, subshells, agent harnesses) — not just
// piped data — so reading unconditionally would block forever in those
// contexts when the body is supplied via args.
if len(body) == 0 && interact.IsStdinPiped() {
if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil {
return err
} else if len(bodyStdin) != 0 {
body = string(bodyStdin)
}
} else if len(body) == 0 {
if err := huh.NewForm(
huh.NewGroup(
huh.NewText().

View file

@ -1,37 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package comments
import "io"
// resolveBody picks the comment body from the non-interactive sources, in
// precedence order:
//
// 1. the positional argument (kept first for back-compat with the historical
// 'tea comment <idx> "<body>"' shorthand),
// 2. the -d/--description flag (mirrors the body flag on 'issue create',
// 'issue edit' and 'pr create'),
// 3. piped stdin.
//
// stdin is only read when stdinPiped is true (a non-TTY stdin, e.g. CI,
// subshells or agent harnesses) and no body was supplied otherwise, so the
// command never blocks reading an interactive terminal when a body is already
// given. An empty result means the caller should fall back to the editor (when
// interactive) or error out.
func resolveBody(positional, description string, stdinPiped bool, stdin io.Reader) (string, error) {
if len(positional) != 0 {
return positional, nil
}
if len(description) != 0 {
return description, nil
}
if stdinPiped {
stdinBytes, err := io.ReadAll(stdin)
if err != nil {
return "", err
}
return string(stdinBytes), nil
}
return "", nil
}

View file

@ -1,101 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package comments
import (
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveBody(t *testing.T) {
testCases := []struct {
name string
positional string
description string
stdinPiped bool
stdin string
expected string
}{
{
name: "positional only",
positional: "from positional",
expected: "from positional",
},
{
name: "description flag only",
description: "from -d",
expected: "from -d",
},
{
name: "positional wins over description for back-compat",
positional: "from positional",
description: "from -d",
expected: "from positional",
},
{
name: "description wins over piped stdin",
description: "from -d",
stdinPiped: true,
stdin: "from stdin",
expected: "from -d",
},
{
name: "piped stdin used when nothing else given",
stdinPiped: true,
stdin: "from stdin",
expected: "from stdin",
},
{
name: "stdin ignored when not piped (interactive terminal)",
stdinPiped: false,
stdin: "should never be read",
expected: "",
},
{
name: "empty when no source provided",
stdinPiped: false,
expected: "",
},
{
name: "piped but empty stdin yields empty body",
stdinPiped: true,
stdin: "",
expected: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body, err := resolveBody(tc.positional, tc.description, tc.stdinPiped, strings.NewReader(tc.stdin))
require.NoError(t, err)
assert.Equal(t, tc.expected, body)
})
}
}
// TestResolveBodyDoesNotReadStdinWhenBodyGiven guards the original bug: when a
// body is supplied positionally (or via -d), stdin must not be consumed, so the
// command can never block on a non-TTY stdin under CI / agent harnesses.
func TestResolveBodyDoesNotReadStdinWhenBodyGiven(t *testing.T) {
reader := &trackingReader{}
body, err := resolveBody("positional body", "", true, reader)
require.NoError(t, err)
assert.Equal(t, "positional body", body)
assert.False(t, reader.read, "stdin must not be read when a body is supplied")
}
// trackingReader records whether Read was ever called.
type trackingReader struct {
read bool
}
func (r *trackingReader) Read(p []byte) (int, error) {
r.read = true
return 0, io.EOF
}

View file

@ -7,6 +7,7 @@ import (
stdctx "context"
"errors"
"fmt"
"io"
"strings"
gitea "gitea.dev/sdk"
@ -30,16 +31,10 @@ var CmdCommentsEdit = cli.Command{
Usage: "Edit the body of an existing comment",
Description: `Edit the body of an existing comment by its comment ID. Use 'tea comments list <issue>' to find IDs.
The new body can be supplied as a positional argument, via -d/--description, piped on stdin, or (if none is given and stdin is a terminal) entered in your $EDITOR.`,
The new body can be supplied as a positional argument, piped on stdin, or (if neither is given and stdin is a terminal) entered in your $EDITOR.`,
ArgsUsage: "<comment id> [<new body>]",
Action: RunCommentsEdit,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "description",
Aliases: []string{"d"},
Usage: "new comment body (alternative to the positional argument)",
},
}, flags.AllDefaultFlags...),
Flags: flags.AllDefaultFlags,
}
// RunCommentsEdit updates the body of an existing comment.
@ -61,12 +56,14 @@ func RunCommentsEdit(requestCtx stdctx.Context, cmd *cli.Command) error {
return fmt.Errorf("invalid comment id %q: %s", ctx.Args().First(), err)
}
stdinPiped := interact.IsStdinPiped()
body, err := resolveBody(strings.Join(ctx.Args().Tail(), " "), ctx.String("description"), stdinPiped, ctx.Reader)
if err != nil {
return err
}
if len(body) == 0 && !stdinPiped {
body := strings.Join(ctx.Args().Tail(), " ")
if len(body) == 0 && interact.IsStdinPiped() {
if bodyStdin, err := io.ReadAll(ctx.Reader); err != nil {
return err
} else if len(bodyStdin) != 0 {
body = string(bodyStdin)
}
} else if len(body) == 0 {
// Fetch current body to pre-populate the editor.
client := ctx.Login.Client()
current, _, fetchErr := client.Issues.GetIssueComment(requestCtx, ctx.Owner, ctx.Repo, id)

View file

@ -1,73 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"fmt"
"io"
"os"
"golang.org/x/term"
)
// stdinPiped reports whether stdin is not a terminal, e.g. when a description
// is piped from a file, command substitution, or a CI harness.
func stdinPiped() bool {
return !term.IsTerminal(int(os.Stdin.Fd()))
}
// resolveCreateBody returns the issue/PR description for create commands.
//
// Precedence:
// 1. --description-file (read from the file, or stdin when the path is "-")
// 2. --description
// 3. piped stdin
func resolveCreateBody(description, descriptionFile string, descriptionFileSet, stdinPiped bool, stdin io.Reader) (string, error) {
if descriptionFileSet {
return readDescriptionSource(descriptionFile, stdin)
}
if description != "" {
return description, nil
}
if stdinPiped {
return readDescriptionStdin(stdin)
}
return "", nil
}
// resolveEditBody returns the new issue/PR body when a description flag was
// provided, or nil when the caller should leave the body unchanged.
func resolveEditBody(description string, descriptionSet bool, descriptionFile string, descriptionFileSet bool, stdin io.Reader) (*string, error) {
if descriptionFileSet {
body, err := readDescriptionSource(descriptionFile, stdin)
if err != nil {
return nil, err
}
return &body, nil
}
if descriptionSet {
body := description
return &body, nil
}
return nil, nil
}
func readDescriptionSource(source string, stdin io.Reader) (string, error) {
if source == "-" {
return readDescriptionStdin(stdin)
}
data, err := os.ReadFile(source)
if err != nil {
return "", fmt.Errorf("could not read description file %q: %w", source, err)
}
return string(data), nil
}
func readDescriptionStdin(stdin io.Reader) (string, error) {
data, err := io.ReadAll(stdin)
if err != nil {
return "", fmt.Errorf("could not read description from stdin: %w", err)
}
return string(data), nil
}

View file

@ -1,161 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package flags
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveCreateBody(t *testing.T) {
file := filepath.Join(t.TempDir(), "body.md")
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
tests := []struct {
name string
description string
descriptionFile string
descriptionFileSet bool
stdinPiped bool
stdin string
want string
}{
{
name: "description flag",
description: "from -d",
want: "from -d",
},
{
name: "description file",
descriptionFile: file,
descriptionFileSet: true,
want: "from file",
},
{
name: "description file wins over description",
description: "from -d",
descriptionFile: file,
descriptionFileSet: true,
want: "from file",
},
{
name: "dash reads stdin",
descriptionFile: "-",
descriptionFileSet: true,
stdin: "from stdin",
want: "from stdin",
},
{
name: "description wins over piped stdin",
description: "from -d",
stdinPiped: true,
stdin: "from stdin",
want: "from -d",
},
{
name: "piped stdin",
stdinPiped: true,
stdin: "from stdin",
want: "from stdin",
},
{
name: "empty description falls back to piped stdin",
description: "",
stdinPiped: true,
stdin: "from stdin",
want: "from stdin",
},
{
name: "empty when no source provided",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveCreateBody(tt.description, tt.descriptionFile, tt.descriptionFileSet, tt.stdinPiped, strings.NewReader(tt.stdin))
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestResolveEditBody(t *testing.T) {
file := filepath.Join(t.TempDir(), "body.md")
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
tests := []struct {
name string
description string
descriptionSet bool
descriptionFile string
descriptionFileSet bool
stdin string
wantBody string
wantSet bool
}{
{
name: "no description flag",
},
{
name: "description flag",
description: "from -d",
descriptionSet: true,
wantBody: "from -d",
wantSet: true,
},
{
name: "empty description clears body",
descriptionSet: true,
wantSet: true,
},
{
name: "description file",
descriptionFile: file,
descriptionFileSet: true,
wantBody: "from file",
wantSet: true,
},
{
name: "description file wins over description",
description: "from -d",
descriptionSet: true,
descriptionFile: file,
descriptionFileSet: true,
wantBody: "from file",
wantSet: true,
},
{
name: "dash reads stdin",
descriptionFile: "-",
descriptionFileSet: true,
stdin: "from stdin",
wantBody: "from stdin",
wantSet: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveEditBody(tt.description, tt.descriptionSet, tt.descriptionFile, tt.descriptionFileSet, strings.NewReader(tt.stdin))
require.NoError(t, err)
if !tt.wantSet {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.wantBody, *got)
})
}
}
func TestResolveDescriptionSourceError(t *testing.T) {
_, err := resolveCreateBody("", filepath.Join(t.TempDir(), "missing.md"), true, false, strings.NewReader(""))
require.ErrorContains(t, err, "could not read description file")
}

View file

@ -100,10 +100,6 @@ var issuePRFlags = append([]cli.Flag{
Name: "description",
Aliases: []string{"d"},
},
&cli.StringFlag{
Name: "description-file",
Usage: "Read description from file ('-' for stdin)",
},
&cli.StringFlag{
Name: "referenced-version",
Aliases: []string{"v"},
@ -137,22 +133,12 @@ var IssuePRCreateFlags = append([]cli.Flag{
// GetIssuePRCreateFlags parses all IssuePREditFlags
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{
Title: ctx.String("title"),
Body: body,
Body: ctx.String("description"),
Assignees: strings.Split(ctx.String("assignees"), ","),
}
var err error
date := ctx.String("deadline")
if date != "" {
@ -191,18 +177,10 @@ func GetIssuePRCreateFlags(requestCtx stdctx.Context, ctx *context.TeaContext) (
// IssuePREditFlags defines flags for editing properties of issues and PRs
var IssuePREditFlags = append([]cli.Flag{
&cli.StringFlag{
Name: "set-assignees",
Usage: "Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees",
},
&cli.StringFlag{
Name: "add-assignees",
Aliases: []string{"a"},
Usage: "Comma-separated list of usernames to assign. Takes precedence over --remove-assignees",
},
&cli.StringFlag{
Name: "remove-assignees",
Usage: "Comma-separated list of usernames to remove",
Usage: "Comma-separated list of usernames to assign",
},
&cli.StringFlag{
Name: "add-labels",
@ -222,18 +200,9 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error)
val := ctx.String("title")
opts.Title = &val
}
body, err := resolveEditBody(
ctx.String("description"),
ctx.IsSet("description"),
ctx.String("description-file"),
ctx.IsSet("description-file"),
ctx.Reader,
)
if err != nil {
return nil, err
}
if body != nil {
opts.Body = body
if ctx.IsSet("description") {
val := ctx.String("description")
opts.Body = &val
}
if ctx.IsSet("referenced-version") {
val := ctx.String("referenced-version")
@ -255,25 +224,17 @@ func GetIssuePREditFlags(ctx *context.TeaContext) (*task.EditIssueOption, error)
opts.Deadline = &t
}
}
if ctx.IsSet("set-assignees") {
val := ctx.String("set-assignees")
opts.SetAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
}
if ctx.IsSet("add-assignees") {
val := ctx.String("add-assignees")
opts.AddAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
}
if ctx.IsSet("remove-assignees") {
val := ctx.String("remove-assignees")
opts.RemoveAssignees = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
opts.AddAssignees = strings.Split(val, ",")
}
if ctx.IsSet("add-labels") {
val := ctx.String("add-labels")
opts.AddLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
opts.AddLabels = strings.Split(val, ",")
}
if ctx.IsSet("remove-labels") {
val := ctx.String("remove-labels")
opts.RemoveLabels = strings.Split(strings.ReplaceAll(val, " ", ""), ",")
opts.RemoveLabels = strings.Split(val, ",")
}
return &opts, nil
}

View file

@ -5,18 +5,13 @@ package issues
import (
stdctx "context"
"encoding/json"
"fmt"
"io"
gitea "gitea.dev/sdk"
"github.com/urfave/cli/v3"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/interact"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"github.com/urfave/cli/v3"
)
// 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
}
issue, err := task.CreateIssue(requestCtx, ctx.Login,
return task.CreateIssue(requestCtx, ctx.Login,
ctx.Owner,
ctx.Repo,
*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,
})
}

View file

@ -1,41 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"bytes"
"encoding/json"
"testing"
gitea "gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteCreatedIssueAsJSON(t *testing.T) {
issue := &gitea.Issue{
Index: 42,
Title: "test title",
HTMLURL: "https://gitea.example.com/owner/repo/issues/42",
State: gitea.StateOpen,
}
var buf bytes.Buffer
require.NoError(t, writeCreatedIssueAsJSON(&buf, issue))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, float64(42), got["index"])
assert.Equal(t, "test title", got["title"])
assert.Equal(t, "https://gitea.example.com/owner/repo/issues/42", got["url"])
assert.Equal(t, "open", got["state"])
// exactly the lean field set, nothing extra
assert.Len(t, got, 4)
// machine-readable output must not contain terminal escape sequences
assert.NotContains(t, buf.String(), "\x1b")
}

View file

@ -1,115 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
stdctx "context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
"golang.org/x/crypto/ssh"
)
func TestRunIssuesListWithSSHPubkeyLoginDoesNotDeadlock(t *testing.T) {
t.Parallel()
sshKeyPath, fingerprint := writeTestSSHKey(t)
var versionRequests atomic.Int32
var issueRequests atomic.Int32
var signedVersionRequests atomic.Int32
var signedIssueRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
versionRequests.Add(1)
if r.Header.Get("Signature") != "" {
signedVersionRequests.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.26.4"}`))
case "/api/v1/repos/gitea/tea/issues":
issueRequests.Add(1)
if r.Header.Get("Signature") != "" {
signedIssueRequests.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "ssh-login",
URL: server.URL,
SSHKey: sshKeyPath,
SSHKeyFingerprint: fingerprint,
VersionCheck: true,
Default: true,
}},
})
cmd := cli.Command{
Name: CmdIssuesList.Name,
Flags: CmdIssuesList.Flags,
}
require.NoError(t, cmd.Set("login", "ssh-login"))
require.NoError(t, cmd.Set("repo", "gitea/tea"))
require.NoError(t, cmd.Set("output", "json"))
done := make(chan error, 1)
go func() {
done <- RunIssuesList(stdctx.Background(), &cmd)
}()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("RunIssuesList deadlocked while bootstrapping the server version for HTTPSign authentication")
}
assert.EqualValues(t, 1, versionRequests.Load())
assert.EqualValues(t, 0, signedVersionRequests.Load())
assert.EqualValues(t, 1, issueRequests.Load())
assert.EqualValues(t, 1, signedIssueRequests.Load())
}
func writeTestSSHKey(t *testing.T) (string, string) {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600))
signer, err := ssh.NewSignerFromKey(privateKey)
require.NoError(t, err)
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
}

View file

@ -31,7 +31,6 @@ var CmdLogin = cli.Command{
&login.CmdLoginSetDefault,
&login.CmdLoginHelper,
&login.CmdLoginOAuthRefresh,
&login.CmdLoginStatus,
},
}

View file

@ -1,59 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package login
import (
"context"
"fmt"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"github.com/urfave/cli/v3"
)
// CmdLoginStatus represents a command to show authentication status for logins.
var CmdLoginStatus = cli.Command{
Name: "status",
Usage: "Show authentication status for Gitea logins",
Description: `Verify the stored token for one or all Gitea logins and report its validity.`,
ArgsUsage: "[<login name>]",
Action: RunLoginStatus,
Flags: []cli.Flag{&flags.OutputFlag},
}
// RunLoginStatus verifies one login, or every configured login when no name is
// provided, and prints a short authentication report.
func RunLoginStatus(requestCtx context.Context, cmd *cli.Command) error {
var logins []config.Login
switch cmd.Args().Len() {
case 0:
var err error
logins, err = config.GetLogins()
if err != nil {
return err
}
case 1:
login, err := config.GetLoginByName(cmd.Args().First())
if err != nil {
return err
}
if login == nil {
return fmt.Errorf("login '%s' not found", cmd.Args().First())
}
logins = []config.Login{*login}
default:
return fmt.Errorf("too many arguments")
}
statuses := make([]print.LoginStatus, 0, len(logins))
for i := range logins {
statuses = append(statuses, task.CheckLoginStatus(requestCtx, &logins[i]))
}
return print.LoginStatuses(statuses, cmd.String("output"))
}

View file

@ -63,12 +63,12 @@ func listNotifications(requestCtx stdctx.Context, cmd *cli.Command, status []git
var news []*gitea.NotificationThread
var err error
all := cmd.Bool("mine")
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: all})
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
client := ctx.Login.Client()
all := ctx.Bool("mine")
// This enforces pagination (see https://github.com/go-gitea/gitea/issues/16733)
listOpts := flags.GetListOptions(cmd)

View file

@ -1,55 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package notifications
import (
stdctx "context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)
func TestRunNotificationsListMineDoesNotProbeGitRepository(t *testing.T) {
gitPath := filepath.Join(t.TempDir(), "git")
gitScript := "#!/bin/sh\necho 'git should not be called' >&2\nexit 1\n"
if runtime.GOOS == "windows" {
gitPath += ".bat"
gitScript = "@echo git should not be called 1>&2\r\nexit /b 1\r\n"
}
require.NoError(t, os.WriteFile(gitPath, []byte(gitScript), 0o755))
t.Setenv("PATH", filepath.Dir(gitPath))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/notifications", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "default",
URL: server.URL,
Token: "token",
User: "user",
Default: true,
}},
})
cmd := cli.Command{
Name: CmdNotificationsList.Name,
Flags: CmdNotificationsList.Flags,
}
require.NoError(t, cmd.Set("mine", "true"))
require.NoError(t, cmd.Set("output", "json"))
require.NoError(t, RunNotificationsList(stdctx.Background(), &cmd))
}

View file

@ -24,7 +24,7 @@ var CmdNotificationsMarkRead = cli.Command{
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
@ -48,7 +48,7 @@ var CmdNotificationsMarkUnread = cli.Command{
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
@ -72,7 +72,7 @@ var CmdNotificationsMarkPinned = cli.Command{
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
@ -95,7 +95,7 @@ var CmdNotificationsUnpin = cli.Command{
ArgsUsage: "[all | <notification id>]",
Flags: flags.NotificationFlags,
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommandWithOptions(cmd, context.InitOptions{SkipLocalRepo: cmd.Bool("mine")})
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}

View file

@ -78,7 +78,6 @@ var CmdPulls = cli.Command{
&pulls.CmdPullsApprove,
&pulls.CmdPullsReject,
&pulls.CmdPullsMerge,
&pulls.CmdPullsReply,
&pulls.CmdPullsReviewComments,
&pulls.CmdPullsResolve,
&pulls.CmdPullsUnresolve,

View file

@ -5,9 +5,6 @@ package pulls
import (
stdctx "context"
"encoding/json"
"fmt"
"io"
gitea "gitea.dev/sdk"
"github.com/urfave/cli/v3"
@ -15,7 +12,6 @@ import (
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/interact"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/utils"
)
@ -84,12 +80,6 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
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
opts, err := flags.GetIssuePRCreateFlags(requestCtx, ctx)
if err != nil {
@ -118,7 +108,7 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits"))
}
pr, err := task.CreatePull(
return task.CreatePull(
requestCtx,
ctx,
ctx.String("base"),
@ -126,41 +116,4 @@ func runPullsCreate(requestCtx stdctx.Context, cmd *cli.Command) error {
allowMaintainerEdits,
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,
})
}

View file

@ -1,48 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls_test
import (
"context"
"testing"
"gitea.dev/tea/cmd"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestPullsCreateAgitOutputRejected verifies that --output (parsed via the
// urfave/cli v3 ancestor-flag cascade, since create itself does not declare
// it) is rejected for the agit flow before any API call or git push happens.
func TestPullsCreateAgitOutputRejected(t *testing.T) {
config.SetConfigForTesting(config.LocalConfig{
Logins: []config.Login{{
Name: "testLogin",
URL: "https://gitea.example.com",
Token: "test-token",
User: "testUser",
Default: true,
}},
})
t.Cleanup(func() {
config.SetConfigForTesting(config.LocalConfig{})
})
app := cmd.App()
args := []string{
"tea", "pulls", "create",
"--agit",
"--output", "json",
"--head", "topic-branch",
"--title", "test",
"--login", "testLogin",
"--repo", "user/repo",
}
err := app.Run(context.Background(), args)
require.Error(t, err)
assert.Contains(t, err.Error(), "--output cannot be combined with --agit")
}

View file

@ -1,45 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
"bytes"
"encoding/json"
"testing"
gitea "gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteCreatedPullAsJSON(t *testing.T) {
pr := &gitea.PullRequest{
Index: 33,
Title: "test title",
HTMLURL: "https://gitea.example.com/owner/repo/pulls/33",
State: gitea.StateOpen,
Base: &gitea.PRBranchInfo{Ref: "main"},
Head: &gitea.PRBranchInfo{Ref: "feature"},
}
var buf bytes.Buffer
require.NoError(t, writeCreatedPullAsJSON(&buf, pr))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, float64(33), got["index"])
assert.Equal(t, "test title", got["title"])
assert.Equal(t, "https://gitea.example.com/owner/repo/pulls/33", got["url"])
assert.Equal(t, "open", got["state"])
assert.Equal(t, "main", got["base"])
assert.Equal(t, "feature", got["head"])
// exactly the lean field set, nothing extra
assert.Len(t, got, 6)
// machine-readable output must not contain terminal escape sequences
assert.NotContains(t, buf.String(), "\x1b")
}

View file

@ -1,29 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package pulls
import (
stdctx "context"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdPullsReply replies to a review comment on a pull request.
var CmdPullsReply = cli.Command{
Name: "reply",
Usage: "Reply to a pull request review comment",
Description: "Reply to a pull request review comment",
ArgsUsage: "<pull index> <comment id> [<reply>]",
Action: func(requestCtx stdctx.Context, cmd *cli.Command) error {
ctx, err := context.InitCommand(cmd)
if err != nil {
return err
}
return runPullReviewReply(requestCtx, ctx)
},
Flags: flags.AllDefaultFlags,
}

View file

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

View file

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

View file

@ -109,12 +109,6 @@ Return the stored token for a URL (git credential protocol)
Refresh an OAuth token
### status
Show authentication status for Gitea logins
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
## logout
Log out from a Gitea server
@ -227,8 +221,6 @@ Create an issue on repository
**--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--labels, -L**="": Comma-separated list of labels to assign
**--login, -l**="": Use a different Gitea Login. Optional
@ -247,7 +239,7 @@ Create an issue on repository
Edit one or more issues
**--add-assignees, -a**="": Comma-separated list of usernames to assign. Takes precedence over --remove-assignees
**--add-assignees, -a**="": Comma-separated list of usernames to assign
**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels
@ -255,8 +247,6 @@ Edit one or more issues
**--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--login, -l**="": Use a different Gitea Login. Optional
**--milestone, -m**="": Milestone to assign
@ -265,14 +255,10 @@ Edit one or more issues
**--remote, -R**="": Discover Gitea login from remote. Optional
**--remove-assignees**="": Comma-separated list of usernames to remove
**--remove-labels**="": Comma-separated list of labels to remove
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--set-assignees**="": Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees
**--title, -t**="":
### reopen, open
@ -389,8 +375,6 @@ Create a pull-request
**--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)
**--head**="": Branch name of the PR source (default is current one). To specify a different head repo, use <user>:<branch>
@ -439,7 +423,7 @@ Change state of one or more pull requests to 'open'
Edit one or more pull requests
**--add-assignees, -a**="": Comma-separated list of usernames to assign. Takes precedence over --remove-assignees
**--add-assignees, -a**="": Comma-separated list of usernames to assign
**--add-labels, -L**="": Comma-separated list of labels to assign. Takes precedence over --remove-labels
@ -449,8 +433,6 @@ Edit one or more pull requests
**--description, -d**="":
**--description-file**="": Read description from file ('-' for stdin)
**--draft**: Mark as draft by prepending "WIP: " to the title (idempotent)
**--login, -l**="": Use a different Gitea Login. Optional
@ -463,16 +445,12 @@ Edit one or more pull requests
**--remote, -R**="": Discover Gitea login from remote. Optional
**--remove-assignees**="": Comma-separated list of usernames to remove
**--remove-labels**="": Comma-separated list of labels to remove
**--remove-reviewers**="": Comma-separated list of usernames to remove from reviewers
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
**--set-assignees**="": Clear all existing assignees and assign comma-separated list of usernames. Takes precedence over --add-assignees and --remove-assignees
**--title, -t**="":
### review
@ -529,18 +507,6 @@ Merge a pull request
**--title, -t**="": Merge commit title
### reply
Reply to a pull request review comment
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### review-comments, rc
List review comments on a pull request
@ -1949,8 +1915,6 @@ Update a webhook
Manage comments on issues and pull requests
**--description, -d**="": comment body (alternative to the positional argument)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
@ -1963,8 +1927,6 @@ Manage comments on issues and pull requests
Add a comment to an issue or pull request
**--description, -d**="": comment body (alternative to the positional argument)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
@ -1993,8 +1955,6 @@ List comments on an issue or pull request
Edit the body of an existing comment
**--description, -d**="": new comment body (alternative to the positional argument)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)

30
go.mod
View file

@ -1,30 +1,28 @@
module gitea.dev/tea
go 1.26.0
toolchain go1.26.6
go 1.26
require (
charm.land/glamour/v2 v2.0.1
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.5
charm.land/lipgloss/v2 v2.0.4
code.gitea.io/gitea-vet v0.2.3
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c
gitea.dev/sdk v1.2.0
gitea.dev/sdk v1.1.0
github.com/adrg/xdg v0.5.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/enescakir/emoji v1.0.0
github.com/go-signet/sdk-go v1.1.0
github.com/go-authgate/sdk-go v0.14.0
github.com/muesli/termenv v0.16.0
github.com/olekukonko/tablewriter v1.1.4
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/stretchr/testify v1.11.1
github.com/urfave/cli-docs/v3 v3.1.0
github.com/urfave/cli/v3 v3.10.1
golang.org/x/crypto v0.56.0
github.com/urfave/cli/v3 v3.10.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
gopkg.in/yaml.v3 v3.0.1
)
@ -58,8 +56,10 @@ require (
github.com/fatih/color v1.19.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
@ -72,16 +72,18 @@ require (
github.com/olekukonko/ll v0.1.8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // 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/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
retract v1.3.3 // accidental release, tag deleted

67
go.sum
View file

@ -6,14 +6,14 @@ charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI=
code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE=
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaVkLHZCF+iB4wTxINmVAToe2geZGayk9LMbA=
gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI=
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
gitea.dev/sdk v1.1.0 h1:wLlz03WkLEiXa2bQpO1JQBTlYf7tQI2neYtZK1kU+TE=
gitea.dev/sdk v1.1.0/go.mod h1:Zfl+EZXdsGGCLkryDfsmvYrQo6GKMl4U3BJA8Beu+cs=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@ -72,6 +72,7 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
@ -89,20 +90,27 @@ 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/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/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/go-authgate/sdk-go v0.14.0 h1:s1i/UCX2Edf3A1pKDW6oXv+oACQfTroxiGY52eqKx+4=
github.com/go-authgate/sdk-go v0.14.0/go.mod h1:sa0ige5wtayj2WcnXlxa8wGuyi5z/c/chc0mXPJTl/Q=
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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@ -128,11 +136,15 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8=
github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw=
github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I=
github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
@ -148,8 +160,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM=
github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -162,47 +174,48 @@ 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-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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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-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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
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-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.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -31,7 +31,9 @@ func NewClient(login *config.Login) *Client {
}
httpClient := &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: login.Insecure}),
Transport: httputil.WrapTransport(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: login.Insecure},
}),
}
return &Client{

View file

@ -201,7 +201,9 @@ func performBrowserOAuthFlow(ctx context.Context, opts OAuthOptions) (serverURL
// createHTTPClient creates an HTTP client with optional insecure setting
func createHTTPClient(insecure bool) *http.Client {
return &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: insecure}),
Transport: httputil.WrapTransport(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}),
}
}
@ -366,9 +368,7 @@ func startLocalServerAndOpenBrowser(authURL, expectedState string, opts *OAuthOp
var openBrowser = func(url string) error {
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
// foreground can't block the wait for the callback.
return open.Start(url)
return open.Run(url)
}
// createLoginFromToken creates a login entry using the obtained access token
@ -412,11 +412,16 @@ func createLoginFromToken(ctx context.Context, name, serverURL string, token *oa
}
login.SSHHost = parsedURL.Host
// Save tokens and add login to config
if err := config.AddOAuthLogin(&login, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
// Add login to config
if err := config.AddLogin(&login); err != nil {
return err
}
// Save tokens to credstore
if err := config.SaveOAuthToken(login.Name, token.AccessToken, token.RefreshToken, token.Expiry); err != nil {
return fmt.Errorf("failed to save token to secure store: %s", err)
}
fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
return nil
}

View file

@ -6,16 +6,11 @@ package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -94,36 +89,3 @@ func TestPerformBrowserOAuthFlow_RedirectURIMatchesAcrossAuthorizeAndExchange(t
assert.Equal(t, authorizeRedirectURI, exchangeRedirectURI,
"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")
}
}

View file

@ -9,25 +9,13 @@ import (
"time"
"github.com/adrg/xdg"
"github.com/go-signet/sdk-go/credstore"
"github.com/go-authgate/sdk-go/credstore"
"golang.org/x/oauth2"
)
var (
tokenStore *credstore.SecureStore[credstore.Token]
tokenStoreOnce sync.Once
saveOAuthTokenToStore = func(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
return getTokenStore().Save(loginName, credstore.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
ClientID: loginName,
})
}
deleteOAuthTokenFromStore = func(loginName string) error {
return getTokenStore().Delete(loginName)
}
)
func getTokenStore() *credstore.SecureStore[credstore.Token] {
@ -49,12 +37,17 @@ func LoadOAuthToken(loginName string) (*credstore.Token, error) {
// SaveOAuthToken saves OAuth tokens to the secure store.
func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error {
return saveOAuthTokenToStore(loginName, accessToken, refreshToken, expiresAt)
return getTokenStore().Save(loginName, credstore.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
ClientID: loginName,
})
}
// DeleteOAuthToken removes tokens from the secure store.
func DeleteOAuthToken(loginName string) error {
return deleteOAuthTokenFromStore(loginName)
return getTokenStore().Delete(loginName)
}
// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to

View file

@ -269,34 +269,6 @@ func AddLogin(login *Login) error {
})
}
// AddOAuthLogin saves the OAuth token and login profile as one operation.
// The profile is only written after secure token storage succeeds.
func AddOAuthLogin(login *Login, accessToken, refreshToken string, expiresAt time.Time) error {
return withConfigLock(func() error {
// Check for duplicate login names before touching credential storage.
for _, existing := range config.Logins {
if strings.EqualFold(existing.Name, login.Name) {
return fmt.Errorf("login name '%s' already exists", login.Name)
}
}
if err := SaveOAuthToken(login.Name, accessToken, refreshToken, expiresAt); err != nil {
return fmt.Errorf("failed to save token to secure store: %w", err)
}
config.Logins = append(config.Logins, *login)
if err := saveConfigUnsafe(); err != nil {
config.Logins = config.Logins[:len(config.Logins)-1]
if deleteErr := DeleteOAuthToken(login.Name); deleteErr != nil {
return errors.Join(err, fmt.Errorf("failed to clean up OAuth token after config save failure: %w", deleteErr))
}
return err
}
return nil
})
}
// SaveLoginTokens updates the token fields for an existing login.
// This is used after browser-based re-authentication to save new tokens.
func SaveLoginTokens(login *Login) error {
@ -418,7 +390,9 @@ func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
}
httpClient := &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
Transport: httputil.WrapTransport(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
}),
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
@ -446,27 +420,15 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
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
// fails fast instead of hanging forever. These bound connection setup and
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
// large attachment uploads) are unaffected.
httpClient := &http.Client{
Transport: httputil.WrapTransport(nil),
}
httpClient := &http.Client{}
if l.Insecure {
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{
Jar: cookieJar,
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
Jar: cookieJar,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
@ -475,10 +437,6 @@ func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Clien
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
}
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
// transport already sets: this is the SDK's own guarantee, so the UA survives
// even if the client is ever given a transport that didn't come from WrapTransport.
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
if debug.IsDebug() {
options = append(options, gitea.SetDebugMode())

View file

@ -1,110 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
gitea "gitea.dev/sdk"
"golang.org/x/crypto/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoginClientWithSSHPubkeyDoesNotDeadlockOnFirstRequest(t *testing.T) {
t.Parallel()
sshKeyPath, fingerprint := writeTestSSHKey(t)
var versionRequests atomic.Int32
var issueRequests atomic.Int32
var signedVersionRequests atomic.Int32
var signedIssueRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/version":
versionRequests.Add(1)
if r.Header.Get("Signature") != "" {
signedVersionRequests.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"1.26.4"}`))
case "/api/v1/repos/gitea/tea/issues":
issueRequests.Add(1)
if r.Header.Get("Signature") != "" {
signedIssueRequests.Add(1)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
login := &Login{
Name: "ssh-login",
URL: server.URL,
SSHKey: sshKeyPath,
SSHKeyFingerprint: fingerprint,
VersionCheck: true,
}
type result struct {
issues []*gitea.Issue
err error
}
done := make(chan result, 1)
go func() {
issues, _, err := login.Client().Issues.ListRepoIssues(context.Background(), "gitea", "tea", gitea.ListIssueOption{})
done <- result{issues: issues, err: err}
}()
select {
case res := <-done:
require.NoError(t, res.err)
assert.Empty(t, res.issues)
case <-time.After(2 * time.Second):
t.Fatal("ListRepoIssues deadlocked while bootstrapping the server version for SSH-signed requests")
}
assert.EqualValues(t, 1, versionRequests.Load())
assert.EqualValues(t, 0, signedVersionRequests.Load())
assert.EqualValues(t, 1, issueRequests.Load())
assert.EqualValues(t, 1, signedIssueRequests.Load())
}
func writeTestSSHKey(t *testing.T) (string, string) {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519")
require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600))
signer, err := ssh.NewSignerFromKey(privateKey)
require.NoError(t, err)
return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey())
}

View file

@ -37,12 +37,6 @@ type TeaContext struct {
LocalRepo *git.TeaRepo // is set if flags specified a local repo via --repo, or if $PWD is a git repo
}
// InitOptions controls which optional sources InitCommand may inspect.
type InitOptions struct {
// SkipLocalRepo avoids probing the current directory for a git repository.
SkipLocalRepo bool
}
// GetRemoteRepoHTMLURL returns the web-ui url of the remote repo,
// after ensuring a remote repo is present in the context.
func (ctx *TeaContext) GetRemoteRepoHTMLURL() (string, error) {
@ -67,12 +61,6 @@ func shouldPromptFallbackLogin(login *config.Login, canPrompt bool) bool {
// the remotes of the .git repo specified in repoFlag or $PWD, and using overrides from
// command flags. If a local git repo can't be found, repo slug values are unset.
func InitCommand(cmd *cli.Command) (*TeaContext, error) {
return InitCommandWithOptions(cmd, InitOptions{})
}
// InitCommandWithOptions resolves the application context like InitCommand, with
// optional controls for commands that do not need repository context.
func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, error) {
// these flags are used as overrides to the context detection via local git repo
repoFlag := cmd.String("repo")
loginFlag := cmd.String("login")
@ -88,7 +76,7 @@ func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, er
)
// check if repoFlag can be interpreted as path to local repo.
if len(repoFlag) != 0 && !opts.SkipLocalRepo {
if len(repoFlag) != 0 {
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
return nil, err
}
@ -97,8 +85,6 @@ func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, er
} else {
c.RepoSlug = repoFlag
}
} else if len(repoFlag) != 0 {
c.RepoSlug = repoFlag
}
if len(remoteFlag) == 0 {
@ -115,27 +101,25 @@ func InitCommandWithOptions(cmd *cli.Command, opts InitOptions) (*TeaContext, er
extraLogins = append(extraLogins, *envLogin)
}
if !opts.SkipLocalRepo {
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
// otherwise attempt PWD. if no repo is found, continue with default login
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
// try to read local git repo & extract context: if repoFlag specifies a valid path, read repo in that dir,
// otherwise attempt PWD. if no repo is found, continue with default login
if repoPath == "" {
if repoPath, err = os.Getwd(); err != nil {
return nil, err
}
}
var localSlug string
if c.LocalRepo, c.Login, localSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == git.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
return nil, err
}
}
if c.RepoSlug == "" && localSlug != "" {
c.RepoSlug = localSlug
var localSlug string
if c.LocalRepo, c.Login, localSlug, err = contextFromLocalRepo(repoPath, remoteFlag, extraLogins); err != nil {
if err == errNotAGiteaRepo || err == git.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
return nil, err
}
}
if c.RepoSlug == "" && localSlug != "" {
c.RepoSlug = localSlug
}
// If env vars are set, always use the env login (but repo slug was already
// resolved by contextFromLocalRepo with the env login in the match list)
@ -166,7 +150,7 @@ and then run your command again`)
if shouldPromptFallbackLogin(c.Login, canPrompt) {
fallback := false
if err := huh.NewConfirm().
Title(fmt.Sprintf("NOTE: no login matched this repository. Fall back to login '%s'?", c.Login.Name)).
Title(fmt.Sprintf("NOTE: no gitea login detected, whether falling back to login '%s'?", c.Login.Name)).
Value(&fallback).
WithTheme(theme.GetTheme()).
Run(); err != nil {
@ -176,7 +160,7 @@ and then run your command again`)
return nil, ErrCommandCanceled
}
} else if !c.Login.Default {
fmt.Fprintf(os.Stderr, "NOTE: no login matched this repository, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
fmt.Fprintf(os.Stderr, "NOTE: no gitea login detected, falling back to login '%s' in non-interactive mode.\n", c.Login.Name)
}
}

View file

@ -164,20 +164,7 @@ func (r *cliRepository) CreateTrackingBranch(localBranchName, remoteBranchName,
}
func (r *cliRepository) Checkout(ref ReferenceName) error {
args := []string{"checkout"}
switch {
case ref.IsBranch():
// `git checkout refs/heads/<branch>` detaches HEAD, while the short branch
// name switches to the local branch as intended.
args = append(args, ref.Short())
case ref.IsRemote():
// Be explicit about detached HEAD when checking out a remote-tracking ref.
args = append(args, "--detach", ref.String())
default:
args = append(args, ref.String())
}
_, err := r.git(nil, nil, args...)
_, err := r.git(nil, nil, "checkout", ref.String())
return err
}

View file

@ -4,7 +4,6 @@
package httputil
import (
"crypto/tls"
"fmt"
"net/http"
"runtime"
@ -21,14 +20,12 @@ func UserAgent() string {
return ua
}
// WrapTransport returns tea's standard HTTP transport: an *http.Transport
// preset with tea's connection / response-header timeouts (see timeoutTransport)
// and decorated to add the User-Agent header on every request. The supplied
// tlsConfig is attached as-is (nil is fine); callers use it for insecure /
// skip-verify logins. This is the single entry point for building a tea HTTP
// client transport, so the timeouts can't be accidentally omitted.
func WrapTransport(tlsConfig *tls.Config) http.RoundTripper {
return &userAgentTransport{base: timeoutTransport(tlsConfig)}
// WrapTransport wraps an http.RoundTripper to add the User-Agent header.
func WrapTransport(base http.RoundTripper) http.RoundTripper {
if base == nil {
base = http.DefaultTransport
}
return &userAgentTransport{base: base}
}
type userAgentTransport struct {
@ -36,11 +33,6 @@ type userAgentTransport struct {
}
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Set the UA at the transport so every client built from WrapTransport
// identifies itself, including the non-SDK clients (oauth2 flow, token
// refresh) that never pass through the SDK's own SetUserAgent. For SDK
// clients this overlaps gitea.SetUserAgent; both use httputil.UserAgent(),
// so the duplicate Header.Set is a no-op.
req.Header.Set("User-Agent", UserAgent())
return t.base.RoundTrip(req)
}

View file

@ -1,57 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"crypto/tls"
"net"
"net/http"
"time"
)
// Timeout values applied to every Gitea API request. These are deliberately
// connection-establishment and time-to-first-response-byte timeouts, NOT an
// overall request deadline: a large release-attachment upload can legitimately
// run for minutes, and as long as bytes keep flowing none of these fire. They
// only trip when a server accepts the connection but never (or far too slowly)
// starts responding — the "hangs forever" case from a stalled or unresponsive
// server (issue #1018).
const (
// DialTimeout bounds establishing the TCP connection.
DialTimeout = 10 * time.Second
// TLSHandshakeTimeout bounds completing the TLS handshake.
TLSHandshakeTimeout = 10 * time.Second
// ResponseHeaderTimeout bounds the wait, after the request is written, for
// the server to begin sending response headers. This is the only timeout
// that protects against a server which accepts the connection but then goes
// silent — the originally reported #1018 symptom; DialTimeout/
// TLSHandshakeTimeout do not, because the connection already succeeded.
//
// The value must clear Gitea's legitimate synchronous pre-response work.
// Profiling a self-hosted Gitea 1.24.6 (on hardware slower than gitea.com)
// showed creating a pull request that triggers conflict detection across
// ~1500 changed files takes ~10s before the first byte (3 runs: 10.06 /
// 10.08 / 10.24s); clean-diff PR creation was ~1s and large attachment
// uploads ~9ms. 120s is ~12x that measured worst case, leaving generous
// headroom for larger repos and busier servers while still failing in two
// minutes instead of hanging forever.
ResponseHeaderTimeout = 120 * time.Second
)
// timeoutTransport returns an *http.Transport configured with tea's standard
// timeouts. The supplied tlsConfig is attached as-is (callers use it for
// insecure / skip-verify logins). It is a clone of http.DefaultTransport so
// connection pooling, proxy support and HTTP/2 keep working. Callers obtain it
// through WrapTransport, which also adds the User-Agent header.
func timeoutTransport(tlsConfig *tls.Config) *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.DialContext = (&net.Dialer{
Timeout: DialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext
t.TLSHandshakeTimeout = TLSHandshakeTimeout
t.ResponseHeaderTimeout = ResponseHeaderTimeout
t.TLSClientConfig = tlsConfig
return t
}

View file

@ -1,91 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package httputil
import (
"net"
"net/http"
"testing"
"time"
)
// TestWrapTransportTimeouts verifies the transport returned by WrapTransport
// carries tea's standard timeout values, so a stalled server can't make tea
// hang forever (issue #1018).
func TestWrapTransportTimeouts(t *testing.T) {
rt := WrapTransport(nil)
uat, ok := rt.(*userAgentTransport)
if !ok {
t.Fatalf("WrapTransport returned %T, want *userAgentTransport", rt)
}
tr, ok := uat.base.(*http.Transport)
if !ok {
t.Fatalf("underlying base is %T, want *http.Transport", uat.base)
}
if tr.TLSHandshakeTimeout != TLSHandshakeTimeout {
t.Errorf("TLSHandshakeTimeout = %v, want %v", tr.TLSHandshakeTimeout, TLSHandshakeTimeout)
}
if tr.ResponseHeaderTimeout != ResponseHeaderTimeout {
t.Errorf("ResponseHeaderTimeout = %v, want %v", tr.ResponseHeaderTimeout, ResponseHeaderTimeout)
}
if tr.DialContext == nil {
t.Error("DialContext is nil, want a dialer with DialTimeout")
}
}
// newStallListener returns a listener that accepts connections, reads the
// request, then goes silent without ever sending response headers — the
// "server accepts the connection but never responds" case ResponseHeaderTimeout
// guards against. The returned closer stops the listener.
func newStallListener(t *testing.T) (addr string, closer func()) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
done := make(chan struct{})
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
buf := make([]byte, 4096)
_, _ = c.Read(buf) // drain the request, then never respond
<-done // hold the connection open until the test ends
c.Close()
}(conn)
}
}()
return ln.Addr().String(), func() {
close(done)
ln.Close()
}
}
// TestResponseHeaderTimeoutFires proves a request to a server that accepts the
// connection and request but never sends response headers aborts via
// ResponseHeaderTimeout rather than hanging. It builds the transport the same
// way WrapTransport does, with a short ResponseHeaderTimeout so the test is fast.
func TestResponseHeaderTimeoutFires(t *testing.T) {
addr, closer := newStallListener(t)
defer closer()
tr := timeoutTransport(nil)
tr.ResponseHeaderTimeout = 2 * time.Second
client := &http.Client{Transport: &userAgentTransport{base: tr}}
start := time.Now()
_, err := client.Get("http://" + addr + "/")
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected a timeout error from stalled server, got nil")
}
if elapsed > 10*time.Second {
t.Errorf("request took %v; ResponseHeaderTimeout did not fire", elapsed)
}
t.Logf("request failed as expected after %v: %v", elapsed, err)
}

View file

@ -5,13 +5,11 @@ package interact
import (
"context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
@ -36,16 +34,7 @@ func CreateIssue(ctx context.Context, login *config.Login, owner, repo string) e
return err
}
issue, err := task.CreateIssue(ctx, login, owner, repo, opts)
if err != nil {
return err
}
print.IssueDetails(issue, nil)
fmt.Println(issue.HTMLURL)
return nil
return task.CreateIssue(ctx, login, owner, repo, opts)
}
func promptIssueProperties(ctx context.Context, login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error {

View file

@ -8,7 +8,6 @@ import (
"slices"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/task"
@ -41,10 +40,9 @@ func EditIssue(requestCtx stdctx.Context, ctx context.TeaContext, index int64) (
Deadline: i.Deadline,
}
i.Assignees = cleanAssignees(i.Assignees)
if len(i.Assignees) != 0 {
for _, a := range i.Assignees {
opts.SetAssignees = append(opts.SetAssignees, a.UserName)
opts.AddAssignees = append(opts.AddAssignees, a.UserName)
}
}
@ -111,7 +109,7 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
return nil
}
currAssignees := o.SetAssignees
currAssignees := o.AddAssignees
newAssignees := selectables.Assignees
for _, c := range currAssignees {
@ -121,14 +119,10 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
}
// assignees
if currAssignees, err = promptMultiSelectWithPreselect("Set Assignees:", currAssignees, newAssignees, "[other]"); err != nil {
if o.AddAssignees, err = promptMultiSelect("Add Assignees:", newAssignees, "[other]"); err != nil {
return err
}
if len(currAssignees) == 0 && len(o.SetAssignees) > 0 {
o.RemoveAssignees = o.SetAssignees
}
o.SetAssignees = currAssignees
printTitleAndContent("Assignees:", strings.Join(o.SetAssignees, "\n"))
printTitleAndContent("Assignees:", strings.Join(o.AddAssignees, "\n"))
// milestone
if len(selectables.MilestoneList) != 0 {
@ -181,13 +175,3 @@ func promptIssueEditProperties(requestCtx stdctx.Context, ctx *context.TeaContex
return nil
}
func cleanAssignees(list []*gitea.User) []*gitea.User {
out := make([]*gitea.User, 0, len(list))
for _, a := range list {
if strings.TrimSpace(a.UserName) != "" {
out = append(out, a)
}
}
return out
}

View file

@ -200,9 +200,25 @@ func CreateLogin(ctx context.Context) error {
}
printTitleAndContent("Selected ssh-key:", sshKey)
sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey)
if err != nil {
return err
// ssh certificate
if strings.Contains(sshKey, "principals") {
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)
}
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{
string(gitea.AccessTokenScopeAll),
string(gitea.AccessTokenScopeRepo),

View file

@ -1,69 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSSHPubkeySelection(t *testing.T) {
tests := []struct {
name string
display string
wantSSHKey string
wantCertPrincipal string
wantKeyFingerprint string
wantSSHAgent bool
wantErr bool
}{
{
name: "local ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (/home/user/.ssh/id_ed25519.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantKeyFingerprint: "SHA256:abc",
},
{
name: "agent ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (ssh-agent)",
wantKeyFingerprint: "SHA256:abc",
wantSSHAgent: true,
},
{
name: "local certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1,user2 (/home/user/.ssh/id_ed25519-cert.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantCertPrincipal: "user1",
},
{
name: "agent certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1 (ssh-agent)",
wantCertPrincipal: "user1",
wantSSHAgent: true,
},
{
name: "unexpected display",
display: "ssh-ed25519 comment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sshKey, certPrincipal, keyFingerprint, sshAgent, err := parseSSHPubkeySelection(tt.display)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantSSHKey, sshKey)
assert.Equal(t, tt.wantCertPrincipal, certPrincipal)
assert.Equal(t, tt.wantKeyFingerprint, keyFingerprint)
assert.Equal(t, tt.wantSSHAgent, sshAgent)
})
}
}

View file

@ -5,6 +5,7 @@ package interact
import (
"fmt"
"os"
"gitea.dev/tea/modules/theme"
@ -13,7 +14,7 @@ import (
// printTitleAndContent prints a title and content with the gitea theme
func printTitleAndContent(title, content string) {
hasDarkBG := theme.HasDarkBackground()
hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
style := lipgloss.NewStyle().
Foreground(theme.GetTheme().Theme(hasDarkBG).Blurred.Title.GetForeground()).Bold(true).
Padding(0, 1)

View file

@ -92,26 +92,10 @@ func promptDatetime(prompt string) (val *time.Time, err error) {
// promptSelect creates a generic multiselect prompt, with processing of custom values.
func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) {
opts := huh.NewOptions(makeSelectOpts(options, customVal, "")...)
return runMultiSelect(prompt, opts, customVal)
}
// promptMultiSelectWithPreselect creates a generic multiselect prompt with preselected values and processing of custom values.
func promptMultiSelectWithPreselect(prompt string, selected []string, options []string, customVal string) ([]string, error) {
opts := make([]huh.Option[string], 0, len(selected)+len(options)+1)
for _, name := range selected {
opts = append(opts, huh.NewOption(name, name).Selected(true))
}
opts = append(opts, huh.NewOptions(makeSelectOpts(options, customVal, "")...)...)
return runMultiSelect(prompt, opts, customVal)
}
func runMultiSelect(prompt string, opts []huh.Option[string], customVal string) ([]string, error) {
var selection []string
if err := huh.NewMultiSelect[string]().
Title(prompt).
Options(opts...).
Options(huh.NewOptions(makeSelectOpts(options, customVal, "")...)...).
Value(&selection).
WithTheme(theme.GetTheme()).
Run(); err != nil {

View file

@ -8,7 +8,6 @@ import (
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/task"
"gitea.dev/tea/modules/theme"
@ -135,18 +134,11 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext) (err error)
return err
}
pr, err := task.CreatePull(
return task.CreatePull(
requestCtx,
ctx,
base,
head,
&allowMaintainerEdits,
&opts)
if err != nil {
return err
}
print.PullDetails(pr, nil, nil)
return nil
}

View file

@ -1,145 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strings"
"time"
)
// LoginStatus contains the authentication status of a single configured login.
type LoginStatus struct {
Name string
URL string
User string
Valid bool
AuthMethod string
TokenExpiry time.Time
Helper bool
Default bool
Error string
}
// LoginStatusFields are the available fields to print with LoginStatuses.
var LoginStatusFields = []string{
"name",
"url",
"user",
"valid",
"auth_method",
"token_expiry",
"helper",
"default",
}
// LoginStatuses prints authentication status for one or more logins.
func LoginStatuses(statuses []LoginStatus, output string) error {
if output != "" {
printables := make([]printable, len(statuses))
for i := range statuses {
printables[i] = statuses[i]
}
t := tableFromItems(LoginStatusFields, printables, isMachineReadable(output))
return t.print(output)
}
if len(statuses) == 0 {
fmt.Println("No logins configured.")
return nil
}
for i, status := range statuses {
if i > 0 {
fmt.Println()
}
printLoginStatusReport(status)
}
return nil
}
func printLoginStatusReport(status LoginStatus) {
name := status.Name
if status.Default {
name += " (default)"
}
fmt.Println(name)
if status.Valid {
line := " ✔ Logged in to " + status.URL
if status.User != "" {
line += " as " + status.User
}
fmt.Println(line)
tokenLine := " ✔ Token is valid"
if status.AuthMethod != "" {
tokenLine += " (" + status.AuthMethod
if !status.TokenExpiry.IsZero() {
tokenLine += ", " + formatTokenExpiry(status.TokenExpiry)
}
tokenLine += ")"
}
fmt.Println(tokenLine)
} else {
message := status.Error
if message == "" {
message = "Login failed"
}
fmt.Println(" ✗ " + message)
}
if status.Helper {
fmt.Println(" ✔ Git credential helper configured")
} else {
fmt.Println(" ✗ Git credential helper not configured")
}
}
func formatExpiryDuration(t time.Time) string {
d := time.Until(t)
if d < 0 {
return "expired"
}
if d < time.Minute {
return "in less than a minute"
}
return "in " + strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s")
}
func formatTokenExpiry(t time.Time) string {
if t.Before(time.Now()) {
return "expired"
}
return "expires " + formatExpiryDuration(t)
}
// FormatField implements the printable interface for LoginStatus.
func (s LoginStatus) FormatField(field string, machineReadable bool) string {
switch field {
case "name":
return s.Name
case "url":
return s.URL
case "user":
return s.User
case "valid":
return formatBoolean(s.Valid, !machineReadable)
case "auth_method":
return s.AuthMethod
case "token_expiry":
if s.TokenExpiry.IsZero() {
return ""
}
if machineReadable {
return FormatTime(s.TokenExpiry, true)
}
return formatExpiryDuration(s.TokenExpiry)
case "helper":
return formatBoolean(s.Helper, !machineReadable)
case "default":
return formatBoolean(s.Default, !machineReadable)
}
return ""
}

View file

@ -1,38 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestLoginStatusFormatField(t *testing.T) {
status := LoginStatus{
Name: "gitea",
URL: "https://gitea.com",
User: "alice",
Valid: true,
AuthMethod: "oauth",
TokenExpiry: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC),
Helper: true,
Default: true,
}
assert.Equal(t, "gitea", status.FormatField("name", false))
assert.Equal(t, "https://gitea.com", status.FormatField("url", false))
assert.Equal(t, "alice", status.FormatField("user", false))
assert.Equal(t, "true", status.FormatField("valid", true))
assert.Equal(t, "✔", status.FormatField("valid", false))
assert.Equal(t, "oauth", status.FormatField("auth_method", true))
assert.Equal(t, "2026-08-27T12:00:00Z", status.FormatField("token_expiry", true))
assert.Equal(t, "true", status.FormatField("helper", true))
assert.Equal(t, "✔", status.FormatField("default", false))
}
func TestFormatExpiryDurationExpired(t *testing.T) {
assert.Equal(t, "expired", formatExpiryDuration(time.Now().Add(-time.Hour)))
}

View file

@ -1,49 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
stdctx "context"
"fmt"
"strings"
gitea "gitea.dev/sdk"
)
// ResolveAssigneeOpts resolves assignee names to IssueAssigneesOption. Returns nil if names is empty.
func ResolveAssigneeOpts(names []string) *gitea.IssueAssigneesOption {
names = cleanAssignees(names)
if len(names) == 0 {
return nil
}
return &gitea.IssueAssigneesOption{Assignees: names}
}
// ApplyAssigneeChanges adds and removes assignees on an issue or pull request.
func ApplyAssigneeChanges(requestCtx stdctx.Context, client *gitea.Client, owner, repo string, index int64, add, rm *gitea.IssueAssigneesOption) error {
if rm != nil {
_, _, err := client.Issues.DeleteIssueAssignees(requestCtx, owner, repo, index, *rm)
if err != nil {
return fmt.Errorf("could not remove assignees: %s", err)
}
}
if add != nil {
_, _, err := client.Issues.AddIssueAssignees(requestCtx, owner, repo, index, *add)
if err != nil {
return fmt.Errorf("could not add assignees: %s", err)
}
}
return nil
}
func cleanAssignees(list []string) []string {
out := make([]string, 0, len(list))
for _, a := range list {
if strings.TrimSpace(a) != "" {
out = append(out, a)
}
}
return out
}

View file

@ -10,19 +10,24 @@ import (
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CreateIssue creates an issue in the given repo and returns the created issue
func CreateIssue(requestCtx stdctx.Context, rlogin *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) (*gitea.Issue, error) {
// 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) error {
// title is required
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)
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
}

View file

@ -23,26 +23,23 @@ type EditIssueOption struct {
Deadline *time.Time
AddLabels []string
RemoveLabels []string
SetAssignees []string
AddAssignees []string
RemoveAssignees []string
AddReviewers []string
RemoveReviewers []string
// RemoveAssignees []string // NOTE: with the current go-sdk, clearing assignees is not possible.
}
// Normalizes the options into parameters that can be passed to the sdk.
// the returned value will be nil, when no change to this part of the issue is requested.
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, *gitea.IssueAssigneesOption, *gitea.IssueAssigneesOption, error) {
func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.Client) (*gitea.EditIssueOption, *gitea.IssueLabelsOption, *gitea.IssueLabelsOption, error) {
addLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.AddLabels)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, err
}
rmLabelOpts, err := ResolveLabelOpts(requestCtx, client, ctx.Owner, ctx.Repo, o.RemoveLabels)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, err
}
addAssigneeOpts := ResolveAssigneeOpts(o.AddAssignees)
rmAssigneeOpts := ResolveAssigneeOpts(o.RemoveAssignees)
issueOpts := gitea.EditIssueOption{}
var issueOptsDirty bool
@ -61,7 +58,7 @@ func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.Te
if o.Milestone != nil {
id, err := ResolveMilestoneID(requestCtx, client, ctx.Owner, ctx.Repo, *o.Milestone)
if err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, nil, err
}
issueOpts.Milestone = gitea.OptionalInt64(id)
issueOptsDirty = true
@ -73,16 +70,15 @@ func (o EditIssueOption) toSdkOptions(requestCtx stdctx.Context, ctx *context.Te
issueOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
o.SetAssignees = cleanAssignees(o.SetAssignees)
if len(o.SetAssignees) != 0 {
issueOpts.Assignees = o.SetAssignees
if len(o.AddAssignees) != 0 {
issueOpts.Assignees = o.AddAssignees
issueOptsDirty = true
}
if issueOptsDirty {
return &issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
return &issueOpts, addLabelOpts, rmLabelOpts, nil
}
return nil, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, nil
return nil, addLabelOpts, rmLabelOpts, nil
}
// EditIssue edits an issue and returns the updated issue.
@ -91,7 +87,7 @@ func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea
client = ctx.Login.Client()
}
issueOpts, addLabelOpts, rmLabelOpts, addAssigneeOpts, rmAssigneeOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
issueOpts, addLabelOpts, rmLabelOpts, err := opts.toSdkOptions(requestCtx, ctx, client)
if err != nil {
return nil, err
}
@ -100,10 +96,6 @@ func EditIssue(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea
return nil, err
}
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
return nil, err
}
var issue *gitea.Issue
if issueOpts != nil {
issue, _, err = client.Issues.EditIssue(requestCtx, ctx.Owner, ctx.Repo, opts.Index, *issueOpts)

View file

@ -48,29 +48,6 @@ func SetupHelper(login config.Login) (ok bool, err error) {
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
func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error {
// checks ...

View file

@ -1,67 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"strings"
"time"
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/print"
)
// CheckLoginStatus verifies the stored token for a login against the server and
// returns a printable status. Unlike config.Login.Client, refresh failures are
// captured in the returned status instead of terminating the process.
func CheckLoginStatus(ctx context.Context, login *config.Login) print.LoginStatus {
status := print.LoginStatus{
Name: login.Name,
URL: login.URL,
AuthMethod: loginAuthMethod(login),
TokenExpiry: loginTokenExpiry(login),
Helper: HasGitCredentialHelper(*login),
Default: login.Default,
}
if login.GetAccessToken() == "" {
status.Error = "Login failed: no access token configured"
return status
}
if err := login.RefreshOAuthTokenIfNeeded(); err != nil {
status.Error = "Token refresh failed: " + strings.TrimPrefix(err.Error(), "failed to refresh token: ")
return status
}
// A successful refresh updates the token in the secure store, so re-read the
// expiry for the status line.
status.TokenExpiry = loginTokenExpiry(login)
user, _, err := login.ClientWithoutRefresh().Users.GetMyUserInfo(ctx)
if err != nil {
status.Error = fmt.Sprintf("Login failed: %s", err)
return status
}
status.Valid = true
status.User = user.UserName
return status
}
func loginAuthMethod(login *config.Login) string {
if login.IsOAuth() {
return config.AuthMethodOAuth
}
return "token"
}
func loginTokenExpiry(login *config.Login) time.Time {
expiry := login.GetTokenExpiry()
if expiry.Equal(time.Unix(0, 0)) {
return time.Time{}
}
return expiry
}

View file

@ -1,75 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
)
func TestCheckLoginStatus(t *testing.T) {
// Keep helper detection isolated from the developer's real git config.
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), ".gitconfig"))
t.Run("valid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/user", r.URL.Path)
assert.Equal(t, "token secret-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":1,"login":"alice"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
})
assert.True(t, status.Valid)
assert.Empty(t, status.Error)
assert.Equal(t, "test", status.Name)
assert.Equal(t, server.URL, status.URL)
assert.Equal(t, "alice", status.User)
assert.Equal(t, "token", status.AuthMethod)
assert.False(t, status.Helper)
})
t.Run("invalid token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"token is invalid"}`))
}))
defer server.Close()
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: server.URL,
Token: "expired-token",
VersionCheck: false,
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "token is invalid")
})
t.Run("missing token", func(t *testing.T) {
status := CheckLoginStatus(context.Background(), &config.Login{
Name: "test",
URL: "https://gitea.example.com",
})
assert.False(t, status.Valid)
assert.Contains(t, status.Error, "no access token configured")
})
}

View file

@ -77,9 +77,9 @@ func doPRFetch(
localRemote *local_git.Remote,
callback func(string) (string, error),
) (string, error) {
_ = callback
localRemoteName := localRemote.Config().Name
localBranchName := pr.Head.Ref
// get auth & fetch remote via its configured protocol
url, err := localRepo.TeaRemoteURL(localRemoteName)
if err != nil {
return "", err

View file

@ -14,6 +14,7 @@ import (
"gitea.dev/tea/modules/config"
"gitea.dev/tea/modules/context"
local_git "gitea.dev/tea/modules/git"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/utils"
)
@ -23,26 +24,24 @@ var (
consecutive = regexp.MustCompile(`[\s]{2,}`)
)
// CreatePull creates a PR in the given repo and returns the created PR
func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head string, allowMaintainerEdits *bool, opts *gitea.CreateIssueOption) (*gitea.PullRequest, error) {
var err error
// 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) (err error) {
// default is default branch
if len(base) == 0 {
base, err = GetDefaultPRBase(requestCtx, ctx.Login, ctx.Owner, ctx.Repo)
if err != nil {
return nil, err
return err
}
}
// default is current one
if len(head) == 0 {
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)
if err != nil {
return nil, err
return err
}
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
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
@ -59,7 +58,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
}
// title is required
if len(opts.Title) == 0 {
return nil, fmt.Errorf("title is required")
return fmt.Errorf("title is required")
}
client := ctx.Login.Client()
@ -75,7 +74,7 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
Deadline: opts.Deadline,
})
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 {
@ -83,11 +82,13 @@ func CreatePull(requestCtx stdctx.Context, ctx *context.TeaContext, base, head s
AllowMaintainerEdit: allowMaintainerEdits,
})
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

View file

@ -25,8 +25,6 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.
if err != nil {
return nil, err
}
addAssigneeOpts := ResolveAssigneeOpts(opts.AddAssignees)
rmAssigneeOpts := ResolveAssigneeOpts(opts.RemoveAssignees)
prOpts := gitea.EditPullRequestOption{}
var prOptsDirty bool
@ -53,9 +51,8 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.
prOpts.RemoveDeadline = gitea.OptionalBool(true)
}
}
opts.SetAssignees = cleanAssignees(opts.SetAssignees)
if len(opts.SetAssignees) != 0 {
prOpts.Assignees = opts.SetAssignees
if len(opts.AddAssignees) != 0 {
prOpts.Assignees = opts.AddAssignees
prOptsDirty = true
}
@ -63,10 +60,6 @@ func EditPull(requestCtx stdctx.Context, ctx *context.TeaContext, client *gitea.
return nil, err
}
if err := ApplyAssigneeChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, addAssigneeOpts, rmAssigneeOpts); err != nil {
return nil, err
}
if err := ApplyReviewerChanges(requestCtx, client, ctx.Owner, ctx.Repo, opts.Index, opts.AddReviewers, opts.RemoveReviewers); err != nil {
return nil, err
}

View file

@ -19,36 +19,8 @@ func PullMerge(requestCtx stdctx.Context, login *config.Login, repoOwner, repoNa
if err != nil {
return err
}
if success {
return nil
}
return fmt.Errorf("failed to merge PR #%d: %s", index,
mergeFailureReason(requestCtx, client, repoOwner, repoName, index))
}
// mergeFailureReason returns why merging was refused. The SDK reports refusal as
// success=false and discards Gitea's explanatory body, so the reason has to be
// re-derived from the PR. Costs one API call, on the failure path only.
func mergeFailureReason(requestCtx stdctx.Context, client *gitea.Client, repoOwner, repoName string, index int64) string {
// Fallback naming the conditions tea cannot observe, used when the PR looks
// mergeable but the merge was refused anyway.
const refused = "the server refused the merge; check required status checks, requested reviews, or branch protection rules"
pr, _, err := client.PullRequests.GetPullRequest(requestCtx, repoOwner, repoName, index)
if err != nil || pr == nil {
return refused
}
switch {
case pr.HasMerged:
return "it has already been merged"
case pr.State == gitea.StateClosed:
return "it is closed"
case pr.Draft:
return "it is a draft; mark it ready for review first"
case !pr.Mergeable:
return "it has conflicting files or is otherwise not mergeable"
default:
return refused
if !success {
return fmt.Errorf("failed to merge PR, is it still open?")
}
return nil
}

View file

@ -1,146 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mergeTestServer answers the merge POST with mergeStatus and the PR GET with
// prJSON, or a 404 if prJSON is empty.
func mergeTestServer(t *testing.T, prJSON string, mergeStatus int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/merge"):
w.WriteHeader(mergeStatus)
// Gitea explains itself here; the SDK discards it.
_, _ = w.Write([]byte(`{"message":"Please try again later"}`))
case r.Method == http.MethodGet:
if prJSON == "" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"pull request does not exist"}`))
return
}
_, _ = w.Write([]byte(prJSON))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusInternalServerError)
}
}))
}
func pullJSON(state string, merged, draft, mergeable bool) string {
return fmt.Sprintf(
`{"number":3,"state":%q,"merged":%t,"draft":%t,"mergeable":%t,"head":{"sha":"abc123"}}`,
state, merged, draft, mergeable)
}
func TestPullMerge(t *testing.T) {
tests := []struct {
name string
pr string
mergeStatus int
wantErr string
}{
{
name: "success",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusOK,
},
{
name: "created is also success",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusCreated,
},
{
// gitea/tea#1022: an open PR with conflicts was reported as
// possibly not open.
name: "conflicting files",
pr: pullJSON("open", false, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it has conflicting files or is otherwise not mergeable",
},
{
name: "already merged",
pr: pullJSON("closed", true, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it has already been merged",
},
{
name: "closed",
pr: pullJSON("closed", false, false, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it is closed",
},
{
name: "draft",
pr: pullJSON("open", false, true, false),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: it is a draft; mark it ready for review first",
},
{
// Open and mergeable, yet refused.
name: "refused while mergeable",
pr: pullJSON("open", false, false, true),
mergeStatus: http.StatusMethodNotAllowed,
wantErr: "failed to merge PR #3: the server refused the merge; check required status checks, requested reviews, or branch protection rules",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := mergeTestServer(t, tt.pr, tt.mergeStatus)
defer server.Close()
err := PullMerge(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 3, gitea.MergePullRequestOption{Style: gitea.MergeStyleMerge})
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
})
}
}
// A refusal must still explain itself when the follow-up PR lookup fails.
func TestPullMergeReasonUnavailable(t *testing.T) {
server := mergeTestServer(t, "", http.StatusMethodNotAllowed)
defer server.Close()
err := PullMerge(t.Context(), &config.Login{
Name: "test",
URL: server.URL,
Token: "secret-token",
VersionCheck: false,
}, "owner", "repo", 3, gitea.MergePullRequestOption{
Style: gitea.MergeStyleMerge,
// Set so the SDK skips its own pre-merge PR lookup.
HeadCommitId: "abc123",
})
require.Error(t, err)
assert.Equal(t, "failed to merge PR #3: the server refused the merge; check required status checks, requested reviews, or branch protection rules", err.Error())
}

View file

@ -56,21 +56,6 @@ func ResolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext
return nil
}
// ReplyToPullReviewComment replies to a review comment on a pull request.
func ReplyToPullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, idx, commentID int64, body string) error {
c := ctx.Login.Client()
comment, _, err := c.PullRequests.CreatePullReviewCommentReply(requestCtx, ctx.Owner, ctx.Repo, idx, commentID, gitea.CreatePullReviewCommentReplyOptions{
Body: body,
})
if err != nil {
return err
}
fmt.Println(comment.HTMLURL)
return nil
}
// UnresolvePullReviewComment unresolves a review comment
func UnresolvePullReviewComment(requestCtx stdctx.Context, ctx *context.TeaContext, commentID int64) error {
c := ctx.Login.Client()

View file

@ -1,31 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package theme
import (
"os"
"charm.land/lipgloss/v2"
"golang.org/x/term"
)
// defaultDarkBackground is the background to assume when we cannot detect one. It
// matches the default lipgloss falls back to.
const defaultDarkBackground = true
// HasDarkBackground reports whether the terminal has a dark background.
//
// It only asks the terminal when stdin and stdout are both terminals. Detection
// works by writing an escape sequence to the output and waiting for the terminal
// to answer on the input, and nothing answers when stdio is redirected, so asking
// means waiting on a reply that never comes. On Windows that wait is unbounded:
// lipgloss opens the console directly rather than giving up, which is why tea used
// to hang at start-up under a service or a CI runner.
func HasDarkBackground() bool {
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return defaultDarkBackground
}
return lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
}

View file

@ -6,6 +6,7 @@ package theme
import (
"charm.land/huh/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/compat"
)
// TeaTheme implements the huh.Theme interface with tea-cli styling.
@ -15,8 +16,7 @@ type TeaTheme struct{}
func (t TeaTheme) Theme(isDark bool) *huh.Styles {
theme := huh.ThemeCharm(isDark)
lightDark := lipgloss.LightDark(isDark)
title := lightDark(lipgloss.Color("#02BA84"), lipgloss.Color("#02BF87"))
title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")}
theme.Focused.Title = theme.Focused.Title.Foreground(title).Bold(true)
theme.Blurred = theme.Focused
return theme

View file

@ -1,64 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package theme
import (
"os/exec"
"slices"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// compatPkg detects the terminal background from package-level vars, so importing
// it anywhere makes tea query the terminal before main() runs. On Windows that
// query can block forever when stdio is redirected, which hung every tea command,
// including tea --version.
//
// lipgloss.LightDark covers what we need without the package-level detection, so
// nothing in tea should depend on compat again.
const compatPkg = "charm.land/lipgloss/v2/compat"
func TestBinaryDoesNotImportLipglossCompat(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go is not on PATH")
}
out, err := exec.Command("go", "list", "-deps", "gitea.dev/tea").Output()
require.NoError(t, err, "go list -deps")
imported := slices.Contains(strings.Fields(string(out)), compatPkg)
assert.False(t, imported,
"%s is back in tea's import graph. It detects the terminal background from "+
"package-level vars, so tea queries the terminal before main() runs, and on "+
"Windows that hangs at start-up when stdio is redirected.", compatPkg)
}
// Under go test neither stdin nor stdout is a terminal, so HasDarkBackground must
// take the default and return, rather than querying and waiting for an answer.
func TestHasDarkBackgroundDoesNotBlockWithoutTTY(t *testing.T) {
done := make(chan bool, 1)
go func() {
done <- HasDarkBackground()
}()
select {
case got := <-done:
assert.Equal(t, defaultDarkBackground, got)
case <-time.After(5 * time.Second):
t.Fatal("HasDarkBackground blocked when stdio is not a terminal")
}
}
// The title color has to come from the isDark we are handed. It used to come from
// a process-wide value that compat detected at init, which ignored this argument.
func TestThemeHonorsIsDark(t *testing.T) {
dark := GetTheme().Theme(true).Focused.Title.GetForeground()
light := GetTheme().Theme(false).Focused.Title.GetForeground()
assert.NotEqual(t, dark, light, "Theme ignored isDark when picking the title color")
}

View file

@ -1,6 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>gitea/renovate-config"
"local>gitea/renovate-config",
"local>gitea/renovate-config:security",
"local>gitea/renovate-config:go-deps"
]
}

View file

@ -1,108 +0,0 @@
#!/bin/sh
# Copyright 2026 The Gitea Authors. All rights reserved.
# SPDX-License-Identifier: MIT
#
# upload-r2.sh uploads a single local file to a single object key in a
# Cloudflare R2 bucket, using curl's built-in AWS SigV4 signer (R2 is
# S3-API compatible).
#
# It is invoked once per release artifact via a goreleaser
# `publishers:` entry, and is the only artifact storage upload in the
# release process.
#
# Usage:
# upload-r2.sh <local-file> <remote-key>
# upload-r2.sh --check-config
#
# The second form only validates that the required environment
# variables below are set (it does not touch the network or the
# filesystem beyond that), and is meant to be run as an early
# preflight step in CI: goreleaser custom publishers run as the very
# last step of the publish pipeline, so without a preflight check a
# missing R2_* secret would only be discovered after the Gitea release
# has already been created.
#
# Required environment variables:
# R2_ENDPOINT Base URL of the R2 endpoint, e.g.
# https://<account>.r2.cloudflarestorage.com
# R2_BUCKET Destination bucket name.
# R2_ACCESS_KEY_ID R2 access key id.
# R2_SECRET_ACCESS_KEY R2 secret access key.
set -eu
# check_env validates that all required R2_* environment variables are
# set and non-empty, printing a single "missing required environment
# variable(s): ..." message and exiting non-zero otherwise. Used by
# both the normal upload mode and --check-config, so the validation
# logic only exists in one place.
check_env() {
missing=""
if [ -z "${R2_ENDPOINT:-}" ]; then
missing="$missing R2_ENDPOINT"
fi
if [ -z "${R2_BUCKET:-}" ]; then
missing="$missing R2_BUCKET"
fi
if [ -z "${R2_ACCESS_KEY_ID:-}" ]; then
missing="$missing R2_ACCESS_KEY_ID"
fi
if [ -z "${R2_SECRET_ACCESS_KEY:-}" ]; then
missing="$missing R2_SECRET_ACCESS_KEY"
fi
if [ -n "$missing" ]; then
echo "upload-r2.sh: missing required environment variable(s):$missing" >&2
exit 1
fi
}
if [ "$#" -eq 1 ] && [ "$1" = "--check-config" ]; then
check_env
echo "upload-r2.sh: R2 configuration OK"
exit 0
fi
if [ "$#" -ne 2 ]; then
echo "usage: upload-r2.sh <local-file> <remote-key>" >&2
echo " upload-r2.sh --check-config" >&2
exit 1
fi
local_file="$1"
remote_key="$2"
if [ ! -f "$local_file" ]; then
echo "upload-r2.sh: local file not found: $local_file" >&2
exit 1
fi
check_env
# Strip a single trailing slash from the endpoint, if present, so that
# building the path-style URL below never produces a double slash.
endpoint="${R2_ENDPOINT%/}"
url="$endpoint/$R2_BUCKET/$remote_key"
# Credentials are passed to curl through a config file read from
# stdin rather than as a command-line argument, so they never show up
# in `ps` output.
#
# --fail-with-body (instead of plain --fail) still exits non-zero on
# HTTP errors, but also prints R2's XML error body, which is where the
# actual error code lives (SignatureDoesNotMatch, NoSuchBucket,
# AccessDenied, ...); with plain --fail that body is discarded and the
# failure is silent. --retry 3 (without --retry-all-errors) still
# retries the transient cases (5xx, 408, 429, connection failures);
# --retry-all-errors would additionally retry permanent 4xx responses
# three times with backoff, which only delays an inevitable failure.
printf 'user = "%s:%s"\n' "$R2_ACCESS_KEY_ID" "$R2_SECRET_ACCESS_KEY" | curl \
--config - \
--fail-with-body \
--silent \
--show-error \
--retry 3 \
--aws-sigv4 "aws:amz:auto:s3" \
--upload-file "$local_file" \
"$url"

View file

@ -1,98 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
teagit "gitea.dev/tea/modules/git"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTeaCheckoutRemoteReferenceKeepsWorktreeClean(t *testing.T) {
clonePath := setupGitCheckoutTestRepo(t)
t.Chdir(clonePath)
repo, err := teagit.RepoFromPath(clonePath)
require.NoError(t, err)
err = repo.TeaCheckout(teagit.NewRemoteReferenceName("origin", "feature/test-branch"))
require.NoError(t, err)
assert.Empty(t, gitOutput(t, clonePath, "status", "--porcelain"))
assert.Equal(t, "HEAD", gitOutput(t, clonePath, "rev-parse", "--abbrev-ref", "HEAD"))
}
func TestTeaCreateBranchTracksRemoteBranch(t *testing.T) {
clonePath := setupGitCheckoutTestRepo(t)
t.Chdir(clonePath)
repo, err := teagit.RepoFromPath(clonePath)
require.NoError(t, err)
err = repo.TeaCreateBranch("pulls/123", "feature/test-branch", "origin")
require.NoError(t, err)
err = repo.TeaCheckout(teagit.NewBranchReferenceName("pulls/123"))
require.NoError(t, err)
assert.Empty(t, gitOutput(t, clonePath, "status", "--porcelain"))
assert.Equal(t, "origin", gitOutput(t, clonePath, "config", "--get", "branch.pulls/123.remote"))
assert.Equal(t, "refs/heads/feature/test-branch", gitOutput(t, clonePath, "config", "--get", "branch.pulls/123.merge"))
assert.Equal(t, "pulls/123", gitOutput(t, clonePath, "rev-parse", "--abbrev-ref", "HEAD"))
}
func setupGitCheckoutTestRepo(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
remotePath := filepath.Join(tmpDir, "remote.git")
seedPath := filepath.Join(tmpDir, "seed")
clonePath := filepath.Join(tmpDir, "clone")
runGit(t, tmpDir, "init", "--bare", remotePath)
runGit(t, tmpDir, "init", seedPath)
runGit(t, seedPath, "config", "user.email", "test@example.com")
runGit(t, seedPath, "config", "user.name", "Test User")
require.NoError(t, os.WriteFile(filepath.Join(seedPath, "README.md"), []byte("# Test Repo\n"), 0o644))
runGit(t, seedPath, "add", "README.md")
runGit(t, seedPath, "commit", "-m", "Initial commit")
runGit(t, seedPath, "branch", "-M", "main")
runGit(t, seedPath, "remote", "add", "origin", remotePath)
runGit(t, seedPath, "push", "-u", "origin", "main")
runGit(t, seedPath, "checkout", "-b", "feature/test-branch")
require.NoError(t, os.WriteFile(filepath.Join(seedPath, "feature.txt"), []byte("feature\n"), 0o644))
runGit(t, seedPath, "add", "feature.txt")
runGit(t, seedPath, "commit", "-m", "Add feature")
runGit(t, seedPath, "push", "-u", "origin", "feature/test-branch")
runGit(t, tmpDir, "clone", remotePath, clonePath)
return clonePath
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(output)))
}
func gitOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
require.NoErrorf(t, err, "git %s failed: %s", strings.Join(args, " "), strings.TrimSpace(string(output)))
return strings.TrimSpace(string(output))
}

View file

@ -29,8 +29,6 @@ func TestRepoFromPath_Worktree(t *testing.T) {
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "config", "user.name", "Test User")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "config", "commit.gpgsign", "false")
assert.NoError(t, cmd.Run())
cmd = exec.Command("git", "-C", mainRepoPath, "remote", "add", "origin", "https://gitea.com/owner/repo.git")
assert.NoError(t, cmd.Run())

View file

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

View file

@ -1,131 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"fmt"
"testing"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd"
"github.com/stretchr/testify/require"
)
func TestEditIssue_ModifiesAssignees(t *testing.T) {
// This test verifies that EditIssue correctly modifies assignees of an issue via set, add, and remove.
// It sets up a test repository and organization with known users, then calls EditIssue and checks the results.
login := createIntegrationLogin(t)
client := login.Client()
orgName := fmt.Sprintf("issue-org-%d", time.Now().UnixNano()%1_000_000)
orgRepoName := fmt.Sprintf("issue-repo-%d", time.Now().UnixNano()%1_000_000)
ctx := context.Background()
// Clean up any existing test data that might interfere with the test.
_, _ = client.Repositories.DeleteRepo(ctx, orgName, orgRepoName)
_, _ = client.Organizations.DeleteOrg(ctx, orgName)
_, _ = client.Admin.DeleteUser(ctx, "user1")
_, _ = client.Admin.DeleteUser(ctx, "user2")
_, _, err := client.Admin.CreateOrg(ctx, integrationUsername, gitea.CreateOrgOption{Name: orgName})
require.NoError(t, err)
t.Cleanup(func() {
if _, delErr := client.Organizations.DeleteOrg(ctx, orgName); delErr != nil {
t.Logf("failed to delete integration test org %q: %v", orgName, delErr)
}
})
orgRepo, _, err := client.Repositories.CreateOrgRepo(ctx, orgName, gitea.CreateRepoOption{Name: orgRepoName})
require.NoError(t, err)
t.Cleanup(func() {
if _, delErr := client.Repositories.DeleteRepo(ctx, orgName, orgRepoName); delErr != nil {
t.Logf("failed to delete integration test repo %q: %v", orgRepoName, delErr)
}
})
user1, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user1", Password: "user1!1234", Email: "user1@test.com"})
require.NoError(t, err)
user2, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user2", Password: "user2!1234", Email: "user2@test.com"})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = client.Admin.DeleteUser(ctx, "user1")
_, _ = client.Admin.DeleteUser(ctx, "user2")
})
permission := gitea.AccessModeOwner
team, _, err := client.Organizations.CreateTeam(ctx, orgName, gitea.CreateTeamOption{Name: "writers", Permission: permission})
require.NoError(t, err)
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user1")
require.NoError(t, err)
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user2")
require.NoError(t, err)
_, err = client.Organizations.AddTeamRepository(ctx, team.ID, orgName, orgRepoName)
require.NoError(t, err)
assigneeValid, _, err := client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user1.UserName)
require.NoError(t, err)
require.True(t, assigneeValid)
assigneeValid, _, err = client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user2.UserName)
require.NoError(t, err)
require.True(t, assigneeValid)
orgIssue, _, err := client.Issues.CreateIssue(ctx, orgName, orgRepoName, gitea.CreateIssueOption{Title: "issue_integration_test", Assignees: []string{integrationUsername}, Closed: false})
require.NoError(t, err)
require.Equal(t, integrationUsername, orgIssue.Assignees[0].UserName)
curUser, _, err := client.Users.GetMyUserInfo(ctx)
require.NoError(t, err)
getNames := func(issue *gitea.Issue) []string {
names := make([]string, len(issue.Assignees))
for i, u := range issue.Assignees {
names[i] = u.UserName
}
return names
}
checkAssignees := func(expected []string) {
updatedIssue, _, err := client.Issues.GetIssue(ctx, orgName, orgRepoName, orgIssue.Index)
require.NoError(t, err)
require.ElementsMatch(t, getNames(updatedIssue), expected)
}
app := cmd.App()
// test set overwrites add and remove
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user1", "--add-assignees", "user2", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{user1.UserName})
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user2," + integrationUsername, "--add-assignees", "user1", "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{user2.UserName, curUser.UserName})
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user1", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{user1.UserName})
// test remove one assignee
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user1", "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{})
// test add multiple assignees and overwrites remove
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user1," + integrationUsername, "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{curUser.UserName, user1.UserName})
// test add one assignee
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user2", "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{curUser.UserName, user1.UserName, user2.UserName})
// test remove multiple assignees
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user1,user2," + integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{})
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--set-assignees", "user_not_exists"})
require.Error(t, err)
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--add-assignees", "user_not_exists"})
require.Error(t, err)
err = app.Run(ctx, []string{"tea", "i", "edit", "1", "--remove-assignees", "user_not_exists"})
require.Error(t, err)
}

View file

@ -80,7 +80,7 @@ func TestResolveLabelNames_ReturnsRepoAndOrgLabels(t *testing.T) {
runGit("commit", "--allow-empty", "-m", "Initial commit")
runGit("push", "-u", "origin", "HEAD:branch-with-labels")
waitForBranches(t, orgRepo.FullName, "branch-with-labels")
waitForBranches(t, orgRepo.FullName)
_ = runTeaCommand(
t, "pr", "create", "--repo", orgRepo.FullName,
"--login", login.Name, "--base", "main", "--head", "branch-with-labels",
@ -94,7 +94,7 @@ func TestResolveLabelNames_ReturnsRepoAndOrgLabels(t *testing.T) {
require.ElementsMatch(t, labels, []*gitea.Label{orgLabel, repoLabel})
}
func waitForBranches(t *testing.T, repoFullName string, branchName string) {
func waitForBranches(t *testing.T, repoFullName string) {
t.Helper()
url := fmt.Sprintf("%s/api/v1/repos/%s/branches", os.Getenv("GITEA_TEA_TEST_URL"), repoFullName)
@ -112,7 +112,7 @@ func waitForBranches(t *testing.T, repoFullName string, branchName string) {
for _, b := range branches {
have[b.Name] = true
}
if have["main"] && have[branchName] {
if have["main"] && have["branch-with-labels"] {
return
}
}

View file

@ -1,156 +0,0 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"context"
"fmt"
"os/exec"
"strings"
"testing"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd"
"github.com/stretchr/testify/require"
)
func TestEditPull_ModifiesAssignees(t *testing.T) {
// This test verifies that EditPull correctly modifies assignees of an pull request via set, add, and remove.
// It sets up a test repository and organization with known users, then calls EditPull and checks the results.
login := createIntegrationLogin(t)
client := login.Client()
orgName := fmt.Sprintf("pull-org-%d", time.Now().UnixNano()%1_000_000)
orgRepoName := fmt.Sprintf("pull-repo-%d", time.Now().UnixNano()%1_000_000)
ctx := context.Background()
// Clean up any existing test data that might interfere with the test.
_, _ = client.Repositories.DeleteRepo(ctx, orgName, orgRepoName)
_, _ = client.Organizations.DeleteOrg(ctx, orgName)
_, _ = client.Admin.DeleteUser(ctx, "user1")
_, _ = client.Admin.DeleteUser(ctx, "user2")
_, _, err := client.Admin.CreateOrg(ctx, integrationUsername, gitea.CreateOrgOption{Name: orgName})
require.NoError(t, err)
t.Cleanup(func() {
if _, delErr := client.Organizations.DeleteOrg(ctx, orgName); delErr != nil {
t.Logf("failed to delete integration test org %q: %v", orgName, delErr)
}
})
orgRepo, _, err := client.Repositories.CreateOrgRepo(ctx, orgName, gitea.CreateRepoOption{Name: orgRepoName})
require.NoError(t, err)
t.Cleanup(func() {
if _, delErr := client.Repositories.DeleteRepo(ctx, orgName, orgRepoName); delErr != nil {
t.Logf("failed to delete integration test repo %q: %v", orgRepoName, delErr)
}
})
user1, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user1", Password: "user1!1234", Email: "user1@test.com"})
require.NoError(t, err)
user2, _, err := client.Admin.CreateUser(ctx, gitea.CreateUserOption{Username: "user2", Password: "user2!1234", Email: "user2@test.com"})
require.NoError(t, err)
t.Cleanup(func() {
_, _ = client.Admin.DeleteUser(ctx, "user1")
_, _ = client.Admin.DeleteUser(ctx, "user2")
})
permission := gitea.AccessModeOwner
team, _, err := client.Organizations.CreateTeam(ctx, orgName, gitea.CreateTeamOption{Name: "writers", Permission: permission})
require.NoError(t, err)
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user1")
require.NoError(t, err)
_, err = client.Organizations.AddTeamMember(ctx, team.ID, "user2")
require.NoError(t, err)
_, err = client.Organizations.AddTeamRepository(ctx, team.ID, orgName, orgRepoName)
require.NoError(t, err)
assigneeValid, _, err := client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user1.UserName)
require.NoError(t, err)
require.True(t, assigneeValid)
assigneeValid, _, err = client.Repositories.CheckRepoIssueAssignee(ctx, orgName, orgRepoName, user2.UserName)
require.NoError(t, err)
require.True(t, assigneeValid)
tmpDir := t.TempDir()
runGit := func(args ...string) {
cmd := exec.Command("git", args...)
cmd.Dir = tmpDir
require.NoError(t, cmd.Run())
}
runGit("init")
runGit("config", "user.email", "test@test.com")
runGit("config", "user.name", "test")
httpsURL := fmt.Sprintf("%s/%s.git", login.URL, orgRepo.FullName)
httpsURL = strings.Replace(httpsURL, "://", fmt.Sprintf("://%s:%s@", login.Name, login.Token), 1)
runGit("remote", "add", "origin", httpsURL)
runGit("checkout", "-b", "main")
runGit("commit", "--allow-empty", "-m", "Initial commit")
runGit("push", "-u", "origin", "HEAD:main")
runGit("checkout", "-b", "branch-with-assignees")
runGit("commit", "--allow-empty", "-m", "Initial commit")
runGit("push", "-u", "origin", "HEAD:branch-with-assignees")
waitForBranches(t, orgRepo.FullName, "branch-with-assignees")
getNames := func(pr *gitea.PullRequest) []string {
names := make([]string, len(pr.Assignees))
for i, u := range pr.Assignees {
names[i] = u.UserName
}
return names
}
checkAssignees := func(expected []string) {
updatedPr, _, err := client.PullRequests.GetPullRequest(ctx, orgName, orgRepoName, 1)
require.NoError(t, err)
require.ElementsMatch(t, getNames(updatedPr), expected)
}
app := cmd.App()
err = app.Run(ctx, []string{"tea", "pr", "create", "--repo", orgRepo.FullName, "--base", "main", "--head", "branch-with-assignees", "--a", integrationUsername})
require.NoError(t, err)
checkAssignees([]string{integrationUsername})
curUser, _, err := client.Users.GetMyUserInfo(ctx)
require.NoError(t, err)
// test set overwrites add and remove
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user1", "--add-assignees", "user2", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{user1.UserName})
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user2," + integrationUsername, "--add-assignees", "user1", "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{user2.UserName, curUser.UserName})
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user1", "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{user1.UserName})
// test remove one assignee
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user1", "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{})
// test add multiple assignees and overwrites remove
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user1," + integrationUsername, "--remove-assignees", integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{curUser.UserName, user1.UserName})
// test add one assignee
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user2", "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{curUser.UserName, user1.UserName, user2.UserName})
// test remove multiple assignees
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user1,user2," + integrationUsername, "--repo", orgRepo.FullName})
require.NoError(t, err)
checkAssignees([]string{})
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--set-assignees", "user_not_exists"})
require.Error(t, err)
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--add-assignees", "user_not_exists"})
require.Error(t, err)
err = app.Run(ctx, []string{"tea", "pr", "edit", "1", "--remove-assignees", "user_not_exists"})
require.Error(t, err)
}