feat!: rebuild tea on Cobra with explicit noun/verb commands (#1126)

- **switch tea to the Cobra command tree:** `internal/app` wires the packages from parts 1–4 into explicit noun/verb commands with `--repo`, `--json` / `--jq`, `--sudo` and `--debug`; `main.go` points at it. Build, release and docs tooling (`tools/cli-docs`, Makefile, goreleaser, snap, CI) and README / CONTRIBUTING are updated.
- **remove the urfave/cli command tree:** deletes `cmd/`, `modules/`, the old integration tests, contrib completion scripts and the generated `docs/CLI.md`, and drops their dependencies. This commit only deletes.

## Breaking changes

This is a breaking release. It requires Gitea 28 or newer; configuration moves to separate `config.yml` and `hosts.yml` files and requires fresh authentication, and command names, aliases, flags and output formats change. `docs/migration-1.0.md` maps every old command, flag and environment variable to its replacement.

## Issues

fixes #1118
fixes #1116
fixes #1115
fixes #1094
fixes #1091
fixes #1073
fixes #1052
fixes #1032
fixes #1023
fixes #966
fixes #908
fixes #833
fixes #831
fixes #817
fixes #804
fixes #779
fixes #770
fixes #737
fixes #697
fixes #726
fixes #677
fixes #672
fixes #633
fixes #632
fixes #560
fixes #556
fixes #523
fixes #516
fixes #510
fixes #504
fixes #500
fixes #482
fixes #406
fixes #403
fixes #373
fixes #351
fixes #344
fixes #326
fixes #325
fixes #323
fixes #246
fixes #240
fixes #232
fixes #197
fixes #172
fixes #141
fixes #69
fixes #1110
fixes #569
fixes #480
fixes #666
fixes #670
fixes #457
fixes #643
fixes #498
fixes #490
fixes #1069
fixes #834
fixes #602
fixes #328
fixes #417
fixes #571
fixes #1029
fixes #489
fixes #1050
fixes #162
fixes #430
fixes #1087
fixes #683

Reviewed-on: https://gitea.com/gitea/tea/pulls/1126
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-11 17:23:06 +00:00
1 parent 12e415a7a7
commit f02245449b
456 files changed
+18892 -34015

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"strconv"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) runJobsCommand() *cobra.Command {
var limit, page int
cmd := &cobra.Command{Use: "jobs <run-id>", Short: "List jobs belonging to a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
run, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Actions{Client: target.client, Repository: target.Repo}).JobsPage(cmd.Context(), run, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, jobRow)
}}
pageFlags(cmd, &limit, &page)
return cmd
}
func jobRow(item services.Job) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, item.Status, item.Conclusion)
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"io"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/spf13/cobra"
)
func (a *application) runLogsCommand() *cobra.Command {
var output string
var selected int64
var follow bool
var interval, timeout time.Duration
cmd := &cobra.Command{Use: "logs <run-id>", Short: "Stream logs for a run or a selected job", Annotations: textOutput("run logs emits text"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if interval <= 0 || timeout <= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
}
run, err := number(args[0])
if err != nil {
return err
}
if cmd.Flags().Changed("job") && selected < 1 {
return clierr.Wrap(clierr.Usage, errors.New("--job must be a positive ID"))
}
if output != "" && (follow || cmd.Flags().Changed("job")) {
return clierr.Wrap(clierr.Usage, errors.New("--output-file downloads a run archive and cannot be combined with --job or --follow"))
}
if output != "" {
if err := validateDownloadDestination(output); err != nil {
return err
}
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Actions{Client: target.client, Repository: target.Repo}
if output != "" {
body, err := service.RunLogsArchive(cmd.Context(), run)
if err != nil {
return err
}
defer body.Close()
return writeDownload(cmd, body, output)
}
jobs, err := service.Jobs(cmd.Context(), run)
if err != nil {
return err
}
if selected != 0 {
found := false
for _, job := range jobs {
if job.ID == selected {
jobs = []services.Job{job}
found = true
break
}
}
if !found {
return fmt.Errorf("job %d does not belong to run %d", selected, run)
}
}
if follow {
if len(jobs) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--follow requires exactly one job; select --job"))
}
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
return service.FollowJobLogs(ctx, run, jobs[0].ID, interval, func(chunk []byte) error {
n, err := cmd.OutOrStdout().Write(chunk)
if err == nil && n != len(chunk) {
return io.ErrShortWrite
}
return err
})
}
for _, job := range jobs {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "Job %d: %s\n", job.ID, job.Name); err != nil {
return err
}
logs, err := service.JobLogs(cmd.Context(), job.ID)
if err != nil {
return err
}
_, copyErr := io.Copy(cmd.OutOrStdout(), logs)
closeErr := logs.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
return nil
}}
cmd.Flags().StringVar(&output, "output-file", "", "Download run logs as ZIP to a new file, or - for stdout")
cmd.Flags().Int64Var(&selected, "job", 0, "Only stream this job's logs (must belong to the run)")
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow newly appended logs until the selected job completes")
cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Log polling interval")
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum follow duration")
return cmd
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestRunLogsStreamWithoutMixingDiagnosticsAndVerifyJobMembership(t *testing.T) {
downloads := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
switch r.URL.Path {
case "/api/v1/repos/owner/repo/actions/runs/7/jobs":
if r.URL.Query().Get("page") == "1" {
_, _ = w.Write([]byte(`{"jobs":[{"id":9,"name":"Build"}]}`))
} else {
_, _ = w.Write([]byte(`{"jobs":[]}`))
}
case "/api/v1/repos/owner/repo/actions/runs/7/logs":
_, _ = w.Write([]byte("ZIP archive bytes"))
case "/api/v1/repos/owner/repo/actions/jobs/9/logs":
downloads++
_, _ = w.Write([]byte("exact log\n"))
default:
t.Errorf("unexpected endpoint %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
args := []string{"run", "logs", "7", "-R", "owner/repo", "--job=9", "--no-input"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "exact log\n", out.String())
require.Equal(t, "Job 9: Build\n", diagnostics.String())
require.Equal(t, 1, downloads)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"run", "jobs", "7", "-R", "owner/repo", "--limit=20", "--page=1", "--json=id,name,run_attempt"}))
require.JSONEq(t, `[{"id":9,"name":"Build","run_attempt":0}]`, out.String())
require.Empty(t, diagnostics.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"run", "jobs", "7", "-R", "owner/repo", "--page=2", "--json"}))
require.JSONEq(t, `[]`, out.String())
out.Reset()
diagnostics.Reset()
args[5] = "--job=10"
err := Execute(context.Background(), deps, args)
require.ErrorContains(t, err, "does not belong")
require.Equal(t, 1, downloads)
require.Empty(t, out.String())
path := filepath.Join(t.TempDir(), "logs.zip")
require.NoError(t, Execute(context.Background(), deps, []string{"run", "logs", "7", "-R", "owner/repo", "--output-file", path}))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "ZIP archive bytes", string(data))
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"regexp"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
var actionValueName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func (a *application) actionValueCommand(secret bool) *cobra.Command {
kind := "variable"
if secret {
kind = "secret"
}
group := &cobra.Command{Use: kind, Short: "Manage scoped Actions " + kind + "s"}
var org string
var account bool
group.PersistentFlags().StringVar(&org, "org", "", "Organization scope")
group.PersistentFlags().BoolVar(&account, "account", false, "Current account scope across repositories")
scope := func(cmd *cobra.Command) (services.ActionScope, target, error) {
if (org != "" && account) || ((org != "" || account) && cmd.Flags().Changed("repo")) {
return services.ActionScope{}, target{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, and --repo select mutually exclusive scopes"))
}
target, err := a.resolve(cmd.Context(), org == "" && !account)
return services.ActionScope{Repository: target.Repo, Organization: org, Account: account}, target, err
}
var limit, page int
list := &cobra.Command{Use: "list", Short: "List " + kind + "s in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
selected, target, err := scope(cmd)
if err != nil {
return err
}
if secret {
items, err := (services.ActionSecrets{Client: target.client, Scope: selected}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, secretRow)
}
items, err := (services.ActionVariables{Client: target.client, Scope: selected}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, variableRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
verbs := []string{"set", "delete"}
if !secret {
verbs = append(verbs, "view")
}
for _, verb := range verbs {
var value, file, description string
command := &cobra.Command{Use: verb + " <name>", Short: titleCase(verb) + " an Actions " + kind, Args: argsExact(1)}
if verb == "set" {
command.Flags().StringVar(&value, "value", "", "Value to store (use a file for secrets)")
command.Flags().StringVar(&file, "value-file", "", "Read value from file, or - for stdin")
command.Flags().StringVar(&description, "description", "", "Description")
}
if verb == "delete" {
command.Annotations = textOutput(kind + " deletion does not return a resource")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
name := args[0]
if !actionValueName.MatchString(name) {
return clierr.Wrap(clierr.Usage, errors.New("name must contain letters, numbers, and underscores, and cannot start with a number"))
}
if strings.EqualFold(name, "CI") || strings.HasPrefix(strings.ToUpper(name), "GITEA_") || strings.HasPrefix(strings.ToUpper(name), "GITHUB_") {
return clierr.Wrap(clierr.Usage, errors.New("CI and names beginning GITEA_ or GITHUB_ are reserved"))
}
prompted := false
if verb == "set" {
var err error
if value, err = a.readSecret(cmd, value, file, "value", "value-file"); err != nil {
return err
}
if !cmd.Flags().Changed("value") && !cmd.Flags().Changed("value-file") {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
return clierr.Wrap(clierr.Usage, errors.New("missing value: supply --value or --value-file"))
}
if secret {
value, err = a.deps.Prompter.SecretInput(cmd.Context(), "Secret value")
} else {
value, err = a.deps.Prompter.Input(cmd.Context(), "Variable value")
}
if err != nil {
return err
}
prompted = true
}
if value == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s value cannot be empty", kind))
}
if !secret && len(value) > 65536 {
return clierr.Wrap(clierr.Usage, errors.New("value cannot exceed 64 KiB"))
}
}
selected, target, err := scope(cmd)
if err != nil {
return err
}
if verb == "delete" || prompted {
if err := a.confirm(cmd, verb+" Actions "+kind+" "+name); err != nil {
return err
}
}
if secret {
service := services.ActionSecrets{Client: target.client, Scope: selected}
if verb == "delete" {
return service.Delete(cmd.Context(), name)
}
item, err := service.Set(cmd.Context(), name, value, description)
if err != nil {
return err
}
return writeItem(a, cmd, item, secretRow)
}
service := services.ActionVariables{Client: target.client, Scope: selected}
if verb == "delete" {
return service.Delete(cmd.Context(), name)
}
var item services.ActionVariable
if verb == "view" {
item, err = service.View(cmd.Context(), name)
} else {
item, err = service.Set(cmd.Context(), name, value, description)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, variableRow)
}
group.AddCommand(command)
}
return group
}
func secretRow(item services.ActionSecret) []terminal.Cell { return terminal.Row(item.Name) }
func variableRow(item services.ActionVariable) []terminal.Cell {
return terminal.Row(item.Name, item.Value)
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestSecretValueFileDropsTrailingLineEnding(t *testing.T) {
var stored string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/actions/secrets/BUILD_TOKEN":
var body struct{ Data string }
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
stored = body.Data
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Streams.In = strings.NewReader("sensitive\r\n")
require.NoError(t, Execute(context.Background(), deps, []string{"secret", "set", "BUILD_TOKEN", "-R", "alice/project", "--value-file", "-", "--yes"}))
require.Equal(t, "sensitive", stored)
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) workflowCommand() *cobra.Command {
group := &cobra.Command{Use: "workflow", Short: "Manage Actions workflows"}
group.AddCommand(&cobra.Command{Use: "list", Short: "List repository workflows", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Workflows(cmd.Context())
if err != nil {
return err
}
return writeList(a, cmd, items, workflowRow)
}})
group.AddCommand(&cobra.Command{Use: "view <id>", Short: "View a workflow", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Actions{Client: t.client, Repository: t.Repo}).Workflow(cmd.Context(), args[0])
if err != nil {
return err
}
return writeItem(a, cmd, item, workflowRow)
}})
var ref string
var fields, inputs []string
var follow bool
dispatch := &cobra.Command{Use: "run <id>", Short: "Dispatch a workflow", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if follow && a.options.json != "" {
return clierr.Wrap(clierr.Usage, errors.New("--follow cannot be combined with --json/--jq"))
}
if ref == "" {
branch, err := a.currentBranch(cmd.Context())
if err != nil || branch == "" {
return clierr.Wrap(clierr.Usage, errors.New("--ref is required outside a branch checkout"))
}
ref = branch
}
values := map[string]string{}
for _, field := range append(fields, inputs...) {
pieces := strings.SplitN(field, "=", 2)
if len(pieces) != 2 || pieces[0] == "" {
return clierr.Wrap(clierr.Usage, errors.New("--field/--input requires key=value"))
}
if _, exists := values[pieces[0]]; exists {
return clierr.Wrap(clierr.Usage, errors.New("duplicate workflow input"))
}
values[pieces[0]] = pieces[1]
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Actions{Client: t.client, Repository: t.Repo}
result, err := service.Dispatch(cmd.Context(), args[0], ref, values)
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, result)
}
if result.ID == 0 {
if follow {
return errors.New("server did not return a run ID; cannot follow logs")
}
_, err := fmt.Fprintln(cmd.ErrOrStderr(), "Workflow dispatched; the server did not return a run ID")
return err
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%d\t%s\n", result.ID, result.URL); err != nil {
return err
}
if !follow {
return nil
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Waiting for run %d jobs...\n", result.ID)
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Minute)
defer cancel()
job, err := service.WaitForFirstJob(ctx, result.ID, 2*time.Second)
if err != nil {
return err
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Following job %d (%s)\n", job.ID, job.Name)
return service.FollowJobLogs(ctx, result.ID, job.ID, 2*time.Second, func(chunk []byte) error {
_, err := cmd.OutOrStdout().Write(chunk)
return err
})
}}
dispatch.Flags().StringVarP(&ref, "ref", "r", "", "Branch or tag to run (default: current branch)")
dispatch.Flags().StringArrayVarP(&fields, "field", "f", nil, "Workflow input key=value (repeatable)")
dispatch.Flags().StringArrayVarP(&inputs, "input", "i", nil, "Alias for --field")
dispatch.Flags().BoolVar(&follow, "follow", false, "Follow the first job's logs after dispatch")
group.AddCommand(dispatch)
for _, verb := range []string{"enable", "disable"} {
group.AddCommand(&cobra.Command{Use: verb + " <id>", Short: titleCase(verb) + " a workflow", Annotations: textOutput("workflow state changes do not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if verb == "disable" {
if err := a.confirm(cmd, "Disable workflow "+args[0]); err != nil {
return err
}
}
return (services.Actions{Client: t.client, Repository: t.Repo}).SetWorkflowEnabled(cmd.Context(), args[0], verb == "enable")
}})
}
return group
}
func workflowRow(item services.Workflow) []terminal.Cell {
return terminal.Row(item.ID, item.Name, item.State)
}
func runRow(item services.Run) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10), Link: item.URL}, {Text: item.Title}, {Text: item.Status}, {Text: item.Conclusion}, {Text: item.Branch}, {Text: item.Event}}
}
func (a *application) runCommand() *cobra.Command {
group := &cobra.Command{Use: "run", Short: "Inspect and watch Actions runs"}
group.AddCommand(a.runLogsCommand(), a.artifactCommand(), a.runJobsCommand())
var filters services.RunListOptions
var started timeRange
list := &cobra.Command{Use: "list", Short: "List workflow runs", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
var err error
if filters.Since, filters.Until, err = started.parse(a.deps.Now()); err != nil {
return err
}
if err := filters.Validate(); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Actions{Client: t.client, Repository: t.Repo}).Runs(cmd.Context(), filters)
if err != nil {
return err
}
return writeList(a, cmd, items, runRow)
}}
pageFlags(list, &filters.Limit, &filters.Page)
started.register(list, "since", "Runs started at or after a date, RFC3339 timestamp, or duration ago (24h)", "until", "Runs started at or before a date, RFC3339 timestamp, or duration ago")
list.Flags().StringVar(&filters.Branch, "branch", "", "Filter by branch name")
list.Flags().StringVar(&filters.Event, "event", "", "Filter by triggering event")
list.Flags().StringVar(&filters.Actor, "actor", "", "Filter by triggering username")
list.Flags().StringVar(&filters.Commit, "commit", "", "Filter by triggering commit SHA")
list.Flags().StringVar(&filters.Workflow, "workflow", "", "Filter by workflow file name")
list.Flags().StringSliceVar(&filters.Statuses, "status", nil, "Filter status: pending, waiting, requested, action_required, queued, in_progress, completed, success, failure, skipped, neutral, cancelled, timed_out") //nolint:misspell // Gitea API spelling.
group.AddCommand(list)
var includeJobs bool
viewRun := &cobra.Command{Use: "view <id>", Short: "View a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Actions{Client: t.client, Repository: t.Repo}
item, err := service.Run(cmd.Context(), id)
if err != nil {
return err
}
if !includeJobs || a.options.json != "" {
return writeItem(a, cmd, item, runRow)
}
jobs, err := service.Jobs(cmd.Context(), id)
if err != nil {
return err
}
jobLine := func(job services.Job) []terminal.Cell { return append(terminal.Row("job"), jobRow(job)...) }
color := a.colorEnabled()
return a.writeHuman(cmd, table([]services.Run{item}, runRow, color)+table(jobs, jobLine, color))
}}
viewRun.Flags().BoolVar(&includeJobs, "jobs", true, "Include job summaries after the run details")
group.AddCommand(viewRun)
var interval, timeout time.Duration
watch := &cobra.Command{Use: "watch <id>", Short: "Wait for completion; fail if the run fails", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if interval <= 0 || timeout <= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
}
id, err := number(args[0])
if err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
result, err := (services.Actions{Client: t.client, Repository: t.Repo}).Watch(ctx, id, interval, func(item services.Run) error {
if a.options.json != "" {
_, err := fmt.Fprintf(cmd.ErrOrStderr(), "run %d: %s %s\n", item.ID, item.Status, item.Conclusion)
return err
}
return writeItem(a, cmd, item, runRow)
})
if err != nil {
return err
}
if a.options.json != "" {
if err := writeItem(a, cmd, result, runRow); err != nil {
return err
}
}
if !services.SuccessfulRun(result) {
return errors.New("workflow run did not succeed")
}
return nil
}}
watch.Flags().DurationVar(&interval, "interval", 5*time.Second, "Polling interval")
watch.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum time to wait")
group.AddCommand(watch)
var forceCancel bool
cancelRun := &cobra.Command{Use: "cancel <id>", Short: "Cancel a workflow run while preserving its history", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
message := fmt.Sprintf("Cancel run %d", id)
if forceCancel {
message = fmt.Sprintf("Force-cancel run %d without waiting for runners", id)
}
if err := a.confirm(cmd, message); err != nil {
return err
}
item, err := (services.Actions{Client: target.client, Repository: target.Repo}).CancelRun(cmd.Context(), id, forceCancel)
if err != nil {
return err
}
return writeItem(a, cmd, item, runRow)
}}
cancelRun.Flags().BoolVar(&forceCancel, "force", false, "Cancel without waiting for runner acknowledgment")
group.AddCommand(cancelRun)
var failedOnly bool
rerun := &cobra.Command{Use: "rerun <id>", Short: "Rerun a workflow run", Annotations: textOutput("rerun does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
return (services.Actions{Client: t.client, Repository: t.Repo}).Rerun(cmd.Context(), id, failedOnly)
}}
rerun.Flags().BoolVar(&failedOnly, "failed", false, "Rerun failed jobs only")
group.AddCommand(rerun)
group.AddCommand(&cobra.Command{Use: "delete <id>", Short: "Delete a workflow run", Annotations: textOutput("run deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if err := a.confirm(cmd, fmt.Sprintf("Delete run %d", id)); err != nil {
return err
}
return (services.Actions{Client: t.client, Repository: t.Repo}).DeleteRun(cmd.Context(), id)
}})
return group
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"testing"
"time"
"gitea.dev/tea/internal/services"
"github.com/stretchr/testify/require"
)
func TestGiteaActionsQueuedRunLifecycle(t *testing.T) {
_, deps, out, diagnostics := giteaDeps(t)
execute := outputExecutor(t, &deps, out, diagnostics)
var repo services.Repository
require.NoError(t, json.Unmarshal(execute("repo", "create", fmt.Sprintf("tea-actions-%d", time.Now().UnixNano()), "--auto-init", "--private", "--json"), &repo))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", repo.FullName, "--yes"}); err != nil {
t.Errorf("repository cleanup: %v", err)
}
})
require.NoError(t, json.Unmarshal(execute("repo", "edit", "-R", repo.FullName, "--actions", "--json"), &repo))
require.True(t, repo.ActionsEnabled)
workflow := "name: Tea fixture\non:\n push:\n workflow_dispatch:\njobs:\n build:\n runs-on: tea-disposable-no-runner\n steps:\n - run: echo tea fixture\n"
payload, err := json.Marshal(map[string]string{"branch": repo.DefaultBranch, "content": base64.StdEncoding.EncodeToString([]byte(workflow)), "message": "Disposable workflow fixture"})
require.NoError(t, err)
deps.Streams.In = bytes.NewReader(payload)
execute("api", "repos/"+repo.FullName+"/contents/.gitea/workflows/tea.yml", "--input", "-")
var runs []services.Run
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
require.NoError(t, json.Unmarshal(execute("run", "list", "-R", repo.FullName, "--json"), &runs))
if len(runs) > 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
require.NotEmpty(t, runs, "workflow did not produce a disposable run")
var status services.AccountStatus
require.NoError(t, json.Unmarshal(execute("status", "-R", repo.FullName, "--json"), &status))
require.True(t, status.RunsIncluded)
require.Equal(t, repo.FullName, status.Repository)
require.Empty(t, status.FailingRuns, "queued runs must not be reported as failures")
var matching []services.Run
require.NoError(t, json.Unmarshal(execute("run", "list", "-R", repo.FullName, "--branch", repo.DefaultBranch, "--event", "push", "--json"), &matching))
require.NotEmpty(t, matching)
out.Reset()
diagnostics.Reset()
require.NoError(t, json.Unmarshal(execute("run", "list", "-R", repo.FullName, "--workflow", "tea.yml", "--json"), &matching))
require.NotEmpty(t, matching)
require.NoError(t, json.Unmarshal(execute("run", "list", "-R", repo.FullName, "--branch", "does-not-exist", "--json"), &matching))
require.Empty(t, matching)
id := fmt.Sprint(runs[0].ID)
var jobs []services.Job
require.NoError(t, json.Unmarshal(execute("run", "jobs", id, "-R", repo.FullName, "--json"), &jobs))
require.NotEmpty(t, jobs)
require.Equal(t, "build", jobs[0].Name)
var artifacts []services.Artifact
require.NoError(t, json.Unmarshal(execute("run", "artifact", "list", id, "-R", repo.FullName, "--json"), &artifacts))
require.Empty(t, artifacts)
require.NotNil(t, artifacts)
out.Reset()
diagnostics.Reset()
err = Execute(context.Background(), deps, []string{"run", "logs", id, "-R", repo.FullName, "--job", fmt.Sprint(jobs[0].ID), "--follow", "--interval=10ms", "--timeout=100ms"})
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Empty(t, out.String())
var canceled services.Run
require.NoError(t, json.Unmarshal(execute("run", "cancel", id, "-R", repo.FullName, "--yes", "--json"), &canceled))
require.Equal(t, runs[0].ID, canceled.ID)
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestRunTimeBoundsUseOneClockReading(t *testing.T) {
now := time.Date(2028, 2, 1, 12, 0, 0, 0, time.UTC)
deps, _, _ := testDeps(t)
reads := 0
deps.Now = func() time.Time { reads++; return now }
err := Execute(context.Background(), deps, []string{"run", "list", "--since=1h", "--until=2h"})
require.ErrorContains(t, err, "--since must not be later than --until")
require.Equal(t, 1, reads)
}
func TestRunWatchExitAndOutputContracts(t *testing.T) {
for _, test := range []struct {
name, status, conclusion string
code int
}{
{"success", "completed", "success", 0},
{"failed run", "completed", "failure", clierr.Failure},
{"timeout", "in_progress", "", clierr.Canceled},
} {
t.Run(test.name, func(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/actions/runs/7", r.URL.Path)
_, _ = w.Write([]byte(`{"id":7,"status":"` + test.status + `","conclusion":"` + test.conclusion + `"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"run", "watch", "7", "--repo", "alice/project", "--json=id,status,conclusion", "--interval=1ms", "--timeout=100ms", "--no-input"})
require.Equal(t, test.code, clierr.ExitCode(err))
if test.code == clierr.Canceled {
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Empty(t, out.String())
} else {
require.JSONEq(t, `{"id":7,"status":"completed","conclusion":"`+test.conclusion+`"}`, out.String())
}
require.Contains(t, diagnostics.String(), "run 7:")
})
}
}
func TestWorkflowRunFollowStreamsFirstJobLogs(t *testing.T) {
jobPolls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/actions/workflows/ci.yml/dispatches":
require.Equal(t, "true", r.URL.Query().Get("return_run_details"))
_, _ = w.Write([]byte(`{"workflow_run_id":11,"html_url":"https://gitea.test/owner/repo/actions/runs/11"}`))
case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/11/jobs":
jobPolls++
if r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`{"jobs":[]}`))
return
}
_, _ = w.Write([]byte(`{"jobs":[{"id":21,"name":"build","status":"completed","conclusion":"success"}]}`))
case r.URL.Path == "/api/v1/repos/owner/repo/actions/jobs/21/logs":
_, _ = w.Write([]byte("hello from job\n"))
case r.URL.Path == "/api/v1/repos/owner/repo/actions/runs/11":
_, _ = w.Write([]byte(`{"id":11,"status":"completed","conclusion":"success","display_title":"ci"}`))
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-R", "owner/repo", "--ref", "main", "--follow", "--no-input"}))
require.Contains(t, out.String(), "11\thttps://gitea.test/owner/repo/actions/runs/11")
require.Contains(t, out.String(), "hello from job\n")
require.Contains(t, diagnostics.String(), "Waiting for run 11 jobs")
require.Contains(t, diagnostics.String(), "Following job 21 (build)")
require.GreaterOrEqual(t, jobPolls, 1)
err := Execute(context.Background(), deps, []string{"workflow", "run", "ci.yml", "-R", "owner/repo", "--ref", "main", "--follow", "--json", "--no-input"})
require.Error(t, err)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.Contains(t, err.Error(), "--follow cannot be combined with --json")
}
+210
View File
@@ -0,0 +1,210 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"net/mail"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) adminCommand() *cobra.Command {
group := &cobra.Command{Use: "admin", Short: "Manage instance administration"}
users := &cobra.Command{Use: "user", Short: "Manage registered accounts"}
group.AddCommand(users)
var limit, page int
list := &cobra.Command{Use: "list", Short: "List registered accounts", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
items, err := (services.AdminUsers{Client: target.client}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, adminUserRow)
}}
pageFlags(list, &limit, &page)
users.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "delete"} {
var username, email, fullName, description, website, location, visibility, password, passwordFile string
var admin, active, restricted, prohibitLogin, mustChangePassword, allowGitHook, allowImportLocal, allowCreateOrganization bool
var maxRepos int
opts := services.AdminUserOptions{}
command := &cobra.Command{Use: verb + " <username>", Short: titleCase(verb) + " an account", Args: argsExact(1)}
if verb == "create" {
command.Use = "create [username]"
command.Args = usageArgs(cobra.MaximumNArgs(1))
command.Flags().StringVar(&username, "username", "", "Account username")
}
if verb == "delete" {
command.Annotations = textOutput("account deletion does not return a resource")
}
type stringBinding struct {
name, description string
value *string
option **string
}
stringsToBind := []stringBinding{{"email", "Email address", &email, &opts.Email}, {"full-name", "Display name", &fullName, &opts.FullName}, {"visibility", "Profile visibility: public, limited, private", &visibility, &opts.Visibility}}
type boolBinding struct {
name, description string
value *bool
option **bool
}
boolsToBind := []boolBinding{{"admin", "Administrator privileges", &admin, &opts.Admin}, {"restricted", "Restrict account access", &restricted, &opts.Restricted}, {"prohibit-login", "Prohibit signing in", &prohibitLogin, &opts.ProhibitLogin}}
if verb == "edit" {
stringsToBind = append(stringsToBind, stringBinding{"description", "Profile description", &description, &opts.Description}, stringBinding{"website", "Website URL", &website, &opts.Website}, stringBinding{"location", "Location", &location, &opts.Location})
boolsToBind = append(boolsToBind, boolBinding{"active", "Activate account", &active, &opts.Active}, boolBinding{"allow-git-hook", "Allow Git hooks", &allowGitHook, &opts.AllowGitHook}, boolBinding{"allow-import-local", "Allow local repository imports", &allowImportLocal, &opts.AllowImportLocal}, boolBinding{"allow-create-organization", "Allow creating organizations", &allowCreateOrganization, &opts.AllowCreateOrganization})
command.Flags().IntVar(&maxRepos, "max-repo-creation", 0, "Repository creation limit (-1 for unlimited)")
}
if verb == "create" || verb == "edit" {
for _, binding := range stringsToBind {
command.Flags().StringVar(binding.value, binding.name, "", binding.description)
}
for _, binding := range boolsToBind {
command.Flags().BoolVar(binding.value, binding.name, false, binding.description+" (use =false to revoke)")
}
command.Flags().StringVar(&password, "password", "", "Password (prefer --password-file)")
command.Flags().StringVar(&passwordFile, "password-file", "", "Read password from file or - for stdin; remove one trailing line ending")
command.Flags().BoolVar(&mustChangePassword, "must-change-password", true, "Require password change on next sign-in (use =false to disable)")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
prompted := false
if verb == "create" {
if len(args) > 0 {
if cmd.Flags().Changed("username") {
return clierr.Wrap(clierr.Usage, errors.New("supply username as an argument or --username, not both"))
}
username = args[0]
}
for _, field := range []struct {
value *string
flag string
}{{&username, "username"}, {&email, "email"}} {
completed, err := a.require(cmd, field.value, field.flag)
if err != nil {
return err
}
prompted = prompted || completed
}
opts.Email = &email
opts.FullName = &fullName
if visibility == "" {
visibility = "public"
}
opts.Visibility = &visibility
} else {
username = args[0]
}
if strings.TrimSpace(username) == "" || strings.ContainsAny(username, "/\\ \t\r\n") {
return clierr.Wrap(clierr.Usage, errors.New("invalid account username"))
}
if verb == "create" || verb == "edit" {
for _, binding := range stringsToBind {
if cmd.Flags().Changed(binding.name) {
*binding.option = binding.value
}
}
for _, binding := range boolsToBind {
if cmd.Flags().Changed(binding.name) {
*binding.option = binding.value
}
}
if opts.Email != nil {
address, err := mail.ParseAddress(email)
if err != nil || address.Address != email {
return clierr.Wrap(clierr.Usage, errors.New("--email must contain one email address"))
}
}
if opts.Visibility != nil {
if err := oneOf("visibility", visibility, "public", "limited", "private"); err != nil {
return err
}
}
if cmd.Flags().Changed("max-repo-creation") {
if maxRepos < -1 {
return clierr.Wrap(clierr.Usage, errors.New("--max-repo-creation must be -1 or nonnegative"))
}
opts.MaxRepoCreation = &maxRepos
}
passwordChange := verb == "create" || cmd.Flags().Changed("password") || cmd.Flags().Changed("password-file")
if passwordChange {
var err error
if password, err = a.readSecret(cmd, password, passwordFile, "password", "password-file"); err != nil {
return err
}
if password == "" && !cmd.Flags().Changed("password-file") {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
return clierr.Wrap(clierr.Usage, errors.New("missing password: supply --password-file or --password"))
}
password, err = a.deps.Prompter.SecretInput(cmd.Context(), "Password for "+username)
if err != nil {
return err
}
confirmation, err := a.deps.Prompter.SecretInput(cmd.Context(), "Confirm password")
if err != nil {
return err
}
if password != confirmation {
return clierr.Wrap(clierr.Usage, errors.New("passwords do not match"))
}
prompted = true
}
if password == "" {
return clierr.Wrap(clierr.Usage, errors.New("password cannot be empty"))
}
opts.Password = password
opts.MustChangePassword = &mustChangePassword
} else if cmd.Flags().Changed("must-change-password") {
opts.MustChangePassword = &mustChangePassword
}
if verb == "edit" && opts == (services.AdminUserOptions{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply account fields to edit"))
}
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
if verb == "delete" || prompted {
if err := a.confirm(cmd, verb+" account "+username+" on "+target.Host); err != nil {
return err
}
}
service := services.AdminUsers{Client: target.client}
var item services.AdminUser
switch verb {
case "view":
item, err = service.View(cmd.Context(), username)
case "create":
item, err = service.Create(cmd.Context(), username, opts)
case "edit":
item, err = service.Edit(cmd.Context(), username, opts)
case "delete":
return service.Delete(cmd.Context(), username)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, adminUserRow)
}
users.AddCommand(command)
}
return group
}
func adminUserRow(item services.AdminUser) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Username, item.FullName, "admin="+strconv.FormatBool(item.Admin), "active="+strconv.FormatBool(item.Active))
}
+40
View File
@@ -0,0 +1,40 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestAdminPromptAndFlagsReachEquivalentPayloads(t *testing.T) {
var payloads []map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
payloads = append(payloads, payload)
_, _ = w.Write([]byte(`{"id":1,"login":"account"}`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
prompt := &scriptedPrompter{input: "account@example.test", secret: " preserved password ", accept: true}
deps.Prompter = prompt
deps.Streams.InTTY = true
deps.Streams.ErrTTY = true
require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "create", "account", "--full-name", "Test Account"}))
require.Equal(t, []string{"email"}, prompt.asked)
require.Len(t, prompt.secrets, 2)
require.Len(t, prompt.confirmations, 1)
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "create", "account", "--full-name", "Test Account", "--email", "account@example.test", "--password", " preserved password ", "--no-input"}))
require.Equal(t, payloads[0], payloads[1])
require.Len(t, prompt.asked, 1)
require.Len(t, prompt.secrets, 2)
require.NotContains(t, out.String(), "password")
}
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestAliasCompletionUsesBuiltInCommandsAndFlags(t *testing.T) {
deps, out, _ := testDeps(t)
for _, definition := range [][]string{{"pulls", "pr"}, {"listing", "pulls list"}, {"inspect", "pr view $1"}} {
require.NoError(t, Execute(context.Background(), deps, append([]string{"alias", "set"}, definition...)))
}
for _, tc := range []struct {
args []string
want string
}{
{[]string{"__complete", "pulls", "ch"}, "checkout"},
{[]string{"__complete", "listing"}, "listing"},
{[]string{"__complete", "listing", "--auth"}, "--author"},
{[]string{"__completeNoDesc", "--no-input", "listing", "--ba"}, "--base"},
{[]string{"__complete", "inspect", "7", "--j"}, "--json"},
} {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, tc.args))
require.Contains(t, out.String(), tc.want)
}
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/spf13/cobra"
)
func apiFields(raw, typed []string) (map[string]any, error) {
fields := map[string]any{}
for kind, arguments := range [][]string{raw, typed} {
for _, argument := range arguments {
key, value, ok := strings.Cut(argument, "=")
if !ok || strings.TrimSpace(key) == "" {
return nil, errors.New("API fields require name=value")
}
if _, exists := fields[key]; exists {
return nil, errors.New("API field names must be unique")
}
var parsed any = value
if kind == 1 {
decoder := json.NewDecoder(strings.NewReader(value))
decoder.UseNumber()
var candidate any
if err := decoder.Decode(&candidate); err == nil {
var extra any
if decoder.Decode(&extra) == io.EOF {
parsed = candidate
}
}
}
fields[key] = parsed
}
}
return fields, nil
}
func apiFieldFiles(fields map[string]any, typed []string, stdin io.Reader) error {
consumers := 0
for _, argument := range typed {
_, value, _ := strings.Cut(argument, "=")
if value == "@" {
return errors.New("file-backed API fields require @PATH or @-")
}
if value == "@-" {
consumers++
}
}
if consumers > 1 {
return errors.New("only one API field may read stdin")
}
for _, argument := range typed {
key, value, _ := strings.Cut(argument, "=")
if !strings.HasPrefix(value, "@") {
continue
}
filename := strings.TrimPrefix(value, "@")
if filename == "" {
return errors.New("file-backed API fields require @PATH or @-")
}
data, err := readFileOrStdin(stdin, filename)
if err != nil {
return err
}
fields[key] = string(data)
}
return nil
}
func apiHeaders(values []string) (http.Header, error) {
headers := http.Header{}
for _, argument := range values {
name, value, ok := strings.Cut(argument, ":")
if !ok || name == "" {
return nil, errors.New("API headers require Name: value")
}
for _, char := range name {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("!#$%&'*+-.^_`|~", char)) {
return nil, errors.New("invalid API header name")
}
}
for _, char := range value {
if (char < 32 && char != '\t') || char == 127 {
return nil, errors.New("invalid API header value")
}
}
switch strings.ToLower(name) {
case "authorization", "proxy-authorization", "host", "content-length", "transfer-encoding", "connection", "upgrade", "trailer", "te":
return nil, errors.New("API headers cannot override authentication, host, or transport framing")
}
headers.Add(name, strings.TrimSpace(value))
}
return headers, nil
}
func (a *application) apiCommand() *cobra.Command {
var method, input, output string
var raw, typed, headerValues []string
var include, paginate bool
var maxPages int
cmd := &cobra.Command{Use: "api <endpoint>", Short: "Make an authenticated Gitea API request", Annotations: textOutput("api returns raw responses"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
usage := func(err error) error { return clierr.Wrap(clierr.Usage, err) }
headers, err := apiHeaders(headerValues)
if err != nil {
return usage(err)
}
fields, err := apiFields(raw, typed)
if err != nil {
return usage(err)
}
if cmd.Flags().Changed("input") && input == "" {
return usage(errors.New("--input requires a file path or -"))
}
if input != "" && len(fields) > 0 {
return usage(errors.New("--input conflicts with API fields"))
}
if !cmd.Flags().Changed("method") && (input != "" || len(fields) > 0) {
method = http.MethodPost
}
method = strings.ToUpper(method)
if _, err := http.NewRequest(method, "http://localhost", nil); err != nil || method == "" {
return usage(errors.New("invalid HTTP method"))
}
if paginate && (method != http.MethodGet || input != "") {
return usage(errors.New("--paginate requires a body-free GET request"))
}
if maxPages < 1 {
return usage(errors.New("--max-pages must be positive"))
}
if output != "" {
if err := validateDownloadDestination(output); err != nil {
return err
}
}
if err := apiFieldFiles(fields, typed, a.deps.Streams.In); err != nil {
return usage(err)
}
var body io.Reader
if input == "-" {
body = a.deps.Streams.In
} else if input != "" {
file, err := os.Open(input)
if err != nil {
return err
}
defer file.Close()
body = file
}
endpoint := args[0]
if strings.ContainsAny(strings.NewReplacer("{owner}", "owner", "{repo}", "repo", "{branch}", "branch").Replace(endpoint), "{}") {
return usage(errors.New("unsupported API placeholder; use {owner}, {repo}, and {branch}"))
}
if strings.Contains(endpoint, "{branch}") {
branch, err := a.currentBranch(cmd.Context())
if err != nil || branch == "" {
return usage(errors.New("{branch} requires a checked-out Git branch"))
}
endpoint = strings.ReplaceAll(endpoint, "{branch}", url.PathEscape(branch))
}
requireRepo := strings.Contains(endpoint, "{owner}") || strings.Contains(endpoint, "{repo}")
target, err := a.resolve(cmd.Context(), requireRepo)
if err != nil {
return err
}
if requireRepo {
pieces := strings.SplitN(target.Repo, "/", 2)
endpoint = strings.NewReplacer("{owner}", url.PathEscape(pieces[0]), "{repo}", url.PathEscape(pieces[1])).Replace(endpoint)
}
if len(fields) > 0 {
if method == http.MethodGet || method == http.MethodHead {
parsed, err := url.Parse(endpoint)
if err != nil {
return usage(errors.New("invalid API endpoint"))
}
query := parsed.Query()
for key, value := range fields {
if text, ok := value.(string); ok {
query.Set(key, text)
} else {
data, err := json.Marshal(value)
if err != nil {
return err
}
query.Set(key, string(data))
}
}
parsed.RawQuery = query.Encode()
endpoint = parsed.String()
} else {
data, err := json.Marshal(fields)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
}
return writeOutput(cmd, output, func(destination io.Writer) error {
return (services.API{Client: target.client}).Visit(cmd.Context(), services.APIOptions{Endpoint: endpoint, Method: method, Body: body, Headers: headers, Paginate: paginate, MaxPages: maxPages}, func(response *http.Response) error {
if include {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s %s\n", response.Proto, response.Status); err != nil {
return err
}
keys := make([]string, 0, len(response.Header))
for key := range response.Header {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, value := range response.Header.Values(key) {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s: %s\n", key, value); err != nil {
return err
}
}
}
if _, err := fmt.Fprintln(cmd.ErrOrStderr()); err != nil {
return err
}
}
if _, err := io.Copy(destination, response.Body); err != nil {
return err
}
if paginate {
_, err := fmt.Fprintln(destination)
return err
}
return nil
})
})
}}
cmd.Flags().StringVarP(&method, "method", "X", "GET", "HTTP method")
cmd.Flags().StringVar(&input, "input", "", "Read request body from a file or stdin (-)")
cmd.Flags().StringArrayVarP(&headerValues, "header", "H", nil, "Request header: Name: value")
cmd.Flags().StringArrayVarP(&raw, "raw-field", "f", nil, "String request field: name=value")
cmd.Flags().StringArrayVarP(&typed, "field", "F", nil, "JSON request field: name=value; @PATH or @- reads file text")
cmd.Flags().StringVar(&output, "output-file", "", "Write the complete response to a new file")
cmd.Flags().BoolVar(&include, "include", false, "Print response status and headers to stderr")
cmd.Flags().BoolVar(&paginate, "paginate", false, "Follow scoped next-page links; separate raw pages with newlines")
cmd.Flags().IntVar(&maxPages, "max-pages", 1000, "Maximum pages with --paginate")
return cmd
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/services"
"github.com/stretchr/testify/require"
)
func TestGiteaRawAPIFieldsAndPagination(t *testing.T) {
_, deps, out, diagnostics := giteaDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "create", fmt.Sprintf("tea-api-%d", time.Now().UnixNano()), "--private", "--json"}))
var repo services.Repository
require.NoError(t, json.Unmarshal(out.Bytes(), &repo))
t.Cleanup(func() {
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "delete", "-R", repo.FullName, "--yes"}))
})
for index := 1; index <= 2; index++ {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/{owner}/{repo}/issues", "-R", repo.FullName, "-f", fmt.Sprintf("title=Issue %d", index), "-F", "closed=false"}))
var issue services.Issue
require.NoError(t, json.Unmarshal(out.Bytes(), &issue))
require.Equal(t, int64(index), issue.Number)
}
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/{owner}/{repo}/issues", "-R", repo.FullName, "-X", "GET", "-F", "limit=1", "-f", "state=all", "--paginate", "--include"}))
decoder := json.NewDecoder(strings.NewReader(out.String()))
pages, total := 0, 0
for {
var issues []services.Issue
err := decoder.Decode(&issues)
if err == io.EOF {
break
}
require.NoError(t, err)
pages++
total += len(issues)
}
require.Equal(t, 2, total)
require.Equal(t, 2, pages)
require.Contains(t, diagnostics.String(), "Link:")
require.Contains(t, diagnostics.String(), "200 OK")
for _, tc := range []struct {
until string
count int
}{{"2099-01-01", 2}, {"2020-01-01", 0}} {
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", repo.FullName, "--author", strings.SplitN(repo.FullName, "/", 2)[0], "--until", tc.until, "--json"}))
var issues []services.Issue
require.NoError(t, json.Unmarshal(out.Bytes(), &issues))
require.Len(t, issues, tc.count)
for _, issue := range issues {
require.Equal(t, strings.SplitN(repo.FullName, "/", 2)[0], issue.Author)
}
require.Empty(t, diagnostics.String())
}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/require"
)
func TestAPIFieldsPlaceholdersAndAtomicPagination(t *testing.T) {
calls := 0
failSecond := false
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "/api/v1/repos/alice/project/issues", r.URL.Path)
if r.Method == http.MethodPost {
var fields map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&fields))
require.Equal(t, "123", fields["title"])
require.Equal(t, true, fields["closed"])
require.Equal(t, float64(2), fields["number"])
_, _ = w.Write([]byte(`{"number":2}`))
return
}
require.Equal(t, "open", r.URL.Query().Get("state"))
require.Equal(t, "2", r.URL.Query().Get("limit"))
w.Header().Set("X-Tea-Test", "header-on-stderr")
if r.URL.Query().Get("page") == "2" {
if failSecond {
w.WriteHeader(403)
return
}
_, _ = w.Write([]byte(`[{"number":2}]`))
return
}
w.Header().Set("Link", `<?page=2&state=open&limit=2>; type="application/json"; rel="next"`)
_, _ = w.Write([]byte(`[{"number":1}]`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/{owner}/{repo}/issues", "-R", "alice/project", "-f", "title=123", "-F", "closed=true", "-F", "number=2"}))
require.JSONEq(t, `{"number":2}`, out.String())
out.Reset()
input := filepath.Join(t.TempDir(), "request.json")
require.NoError(t, os.WriteFile(input, []byte(`{"title":"123","closed":true,"number":2}`), 0o600))
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/{owner}/{repo}/issues", "-R", "alice/project", "--input", input}))
require.JSONEq(t, `{"number":2}`, out.String())
out.Reset()
output := filepath.Join(t.TempDir(), "pages.json")
args := []string{"api", "repos/{owner}/{repo}/issues", "-R", "alice/project", "-X", "GET", "-f", "state=open", "-F", "limit=2", "--paginate", "--include", "--output-file", output}
require.NoError(t, Execute(context.Background(), deps, args))
require.Empty(t, out.String())
require.Contains(t, diagnostics.String(), "X-Tea-Test: header-on-stderr")
data, err := os.ReadFile(output)
require.NoError(t, err)
require.Equal(t, "[{\"number\":1}]\n[{\"number\":2}]\n", string(data))
info, err := os.Stat(output)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
failSecond = true
require.NoError(t, os.Remove(output))
require.Error(t, Execute(context.Background(), deps, args))
_, err = os.Stat(output)
require.True(t, os.IsNotExist(err))
require.Equal(t, 6, calls)
}
func TestAPIBranchPlaceholderUsesEscapedCurrentBranch(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "/api/v1/repos/alice/project/branches/feature%2Fx", r.URL.EscapedPath())
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Git = &fakeGit{results: map[string]string{"symbolic-ref --short HEAD": "feature/x"}}
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/{owner}/{repo}/branches/{branch}", "-R", "alice/project"}))
require.Equal(t, 1, calls)
deps.Git = &fakeGit{}
err := Execute(context.Background(), deps, []string{"api", "repos/{owner}/{repo}/branches/{branch}", "-R", "alice/project"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "{branch} requires a checked-out Git branch")
require.Equal(t, 1, calls)
}
func TestAPIDebugLogsRequestsToStderr(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"name":"project"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/alice/project"}))
require.Empty(t, diagnostics.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"--debug", "api", "repos/alice/project?access_token=query-secret"}))
require.JSONEq(t, `{"name":"project"}`, out.String())
require.Contains(t, diagnostics.String(), "[debug] GET "+server.URL+"/api/v1/user -> 200 OK")
require.Contains(t, diagnostics.String(), "[debug] GET "+server.URL+"/api/v1/repos/alice/project?access_token=REDACTED -> 200 OK")
require.NotContains(t, diagnostics.String(), "query-secret")
require.NotContains(t, diagnostics.String(), "token ")
}
func TestAPIWritesErrorResponses(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Tea-Test", "error-header")
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"invalid","errors":["title"]}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"api", "repos/alice/project/issues", "-X", "POST", "-f", "title=", "--include"})
var apiErr *transport.APIError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusUnprocessableEntity, apiErr.Status)
require.Equal(t, `{"message":"invalid","errors":["title"]}`, out.String())
require.Contains(t, diagnostics.String(), "422 Unprocessable Entity")
require.Contains(t, diagnostics.String(), "X-Tea-Test: error-header")
}
func TestAPITypedFieldsInferJSONValues(t *testing.T) {
fields, err := apiFields([]string{"string=true"}, []string{"bool=true", "null=null", "integer=123", "array=[1,2]", "text=ordinary text"})
require.NoError(t, err)
data, err := json.Marshal(fields)
require.NoError(t, err)
require.JSONEq(t, `{"string":"true","bool":true,"null":null,"integer":123,"array":[1,2],"text":"ordinary text"}`, string(data))
}
func TestAPIHeadersPreserveRawResponseAndRejectCredentialOverrides(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "application/octet-stream", r.Header.Get("Accept"))
require.Equal(t, []string{"one", "two"}, r.Header.Values("X-Tea-Test"))
require.Equal(t, "token secret", r.Header.Get("Authorization"))
_, _ = w.Write([]byte{0, 1, 2, 255})
}))
defer server.Close()
deps, out, _ := testDeps(t)
deps.Getenv = envFunc(map[string]string{"TEA_HOST": server.URL, "GITEA_TOKEN": "secret"})
require.NoError(t, Execute(context.Background(), deps, []string{"api", "user/data", "-H", "Accept: application/octet-stream", "-H", "X-Tea-Test: one", "-H", "X-Tea-Test: two"}))
require.Equal(t, []byte{0, 1, 2, 255}, out.Bytes())
for _, header := range []string{"Authorization: sensitive-value", "Host: unrelated.example.org", "Proxy-Authorization: sensitive-value", "Content-Length: 100", "Connection: upgrade", "Bad Name: value", "X-Test: value\r\nAuthorization: sensitive-value", "X-Test: value\x00", "missing-colon"} {
out.Reset()
err := Execute(context.Background(), deps, []string{"api", "user/data", "-H", header})
require.Error(t, err)
require.NotContains(t, err.Error(), "sensitive-value")
require.Empty(t, out.String())
}
require.Equal(t, 1, calls)
}
type rejectAPIInput struct{ t *testing.T }
func (r rejectAPIInput) Read([]byte) (int, error) {
r.t.Error("stdin was consumed before validation")
return 0, io.EOF
}
func TestAPIFieldFilesPreserveTextAndValidateStdinConsumers(t *testing.T) {
path := filepath.Join(t.TempDir(), "body.txt")
require.NoError(t, os.WriteFile(path, []byte("true\n"), 0o600))
typed := []string{"body=@" + path, "comment=@-"}
fields, err := apiFields(nil, typed)
require.NoError(t, err)
require.NoError(t, apiFieldFiles(fields, typed, strings.NewReader("123\n")))
require.Equal(t, "true\n", fields["body"])
require.Equal(t, "123\n", fields["comment"])
deps, _, _ := testDeps(t)
deps.Streams.In = rejectAPIInput{t}
for _, args := range [][]string{
{"api", "user", "-F", "one=@-", "-F", "two=@-"},
{"api", "user", "--input", "-", "-F", "one=@-"},
{"api", "user", "-F", "one=@-", "-F", "one=duplicate"},
} {
require.Error(t, Execute(context.Background(), deps, args))
}
}
+474
View File
@@ -0,0 +1,474 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package app builds the tea command tree and its injected dependencies.
package app
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strings"
"time"
"unicode"
"gitea.dev/tea/internal/aliases"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"gitea.dev/tea/internal/version"
"github.com/spf13/cobra"
)
// Dependencies supplies external boundaries for a fresh CLI invocation.
type Dependencies struct {
Browser terminal.Browser
Editor terminal.Editor
Pager terminal.Pager
Executable func() (string, error)
Git git.Runner
Now func() time.Time
Settings *settings.Store
Credentials *auth.Store
Streams terminal.Streams
Prompter terminal.Prompter
IssueForm terminal.IssueForm
IssueEditForm terminal.IssueEditForm
PullForm terminal.PullForm
ReviewForm terminal.ReviewForm
MergeForm terminal.MergeForm
MilestoneForm terminal.MilestoneForm
Getenv func(string) string
Client func(string, string, bool) *transport.Client
}
// System connects the CLI to the current environment and terminal.
func System() Dependencies {
streams := terminal.System()
store := settings.New(os.Getenv("TEA_CONFIG_DIR"))
prompter := &terminal.FormPrompter{Streams: streams}
return Dependencies{Browser: terminal.SystemBrowser{ErrOut: streams.ErrOut}, Editor: terminal.SystemEditor{Streams: streams}, Pager: terminal.SystemPager{}, Executable: os.Executable, Git: git.System{In: streams.In, ErrOut: streams.ErrOut}, Now: time.Now, Settings: store, Credentials: &auth.Store{Dir: store.Dir, Keyring: auth.SystemKeyring{}}, Streams: streams, Prompter: prompter, IssueForm: prompter, IssueEditForm: prompter, PullForm: prompter, ReviewForm: prompter, MergeForm: prompter, MilestoneForm: prompter, Getenv: os.Getenv, Client: transport.New}
}
type options struct {
host, user, repo, cwd, remote string
sudo string
json, jq string
noInput, interactive, yes bool
debug bool
}
type application struct {
deps Dependencies
options options
}
// NewRoot builds fresh commands. Construction performs no disk, network, or Git IO.
func NewRoot(deps Dependencies) *cobra.Command {
if deps.Executable == nil {
deps.Executable = os.Executable
}
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Git == nil {
deps.Git = git.System{In: deps.Streams.In, ErrOut: deps.Streams.ErrOut}
}
if deps.Browser == nil {
deps.Browser = terminal.SystemBrowser{ErrOut: deps.Streams.ErrOut}
}
if deps.Editor == nil {
deps.Editor = terminal.SystemEditor{Streams: deps.Streams}
}
a := &application{deps: deps}
if client := deps.Client; client != nil {
a.deps.Client = func(instance, token string, insecure bool) *transport.Client {
if a.options.debug {
return client(instance, token, insecure).Debug(deps.Streams.ErrOut)
}
return client(instance, token, insecure)
}
}
root := &cobra.Command{Use: "tea", Short: "Work with Gitea from your terminal", Version: version.Format(), SilenceErrors: true, SilenceUsage: true}
root.SetIn(deps.Streams.In)
root.SetOut(deps.Streams.Out)
root.SetErr(deps.Streams.ErrOut)
root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, err) })
flags := root.PersistentFlags()
flags.StringVar(&a.options.host, "host", "", "Gitea instance URL, including any base path")
flags.StringVar(&a.options.user, "user", "", "Account on the selected instance")
flags.StringVar(&a.options.sudo, "sudo", "", "Perform API requests as this user (requires an administrator account)")
flags.StringVarP(&a.options.repo, "repo", "R", "", "Repository owner/name or URL")
flags.StringVar(&a.options.cwd, "cwd", "", "Working directory for repository discovery")
flags.StringVar(&a.options.remote, "remote", "", "Git remote used for repository discovery")
flags.StringVar(&a.options.json, "json", "", "Output JSON; optionally select comma-separated fields")
flags.Lookup("json").NoOptDefVal = "*"
flags.StringVar(&a.options.jq, "jq", "", "Filter JSON with a jq expression")
flags.BoolVar(&a.options.noInput, "no-input", false, "Disable prompts and implicit editor/browser interactions")
flags.BoolVar(&a.options.interactive, "interactive", false, "Allow prompts with machine output")
flags.BoolVar(&a.options.yes, "yes", false, "Accept confirmations without prompting")
flags.BoolVar(&a.options.debug, "debug", false, "Log the method, URL, status, and duration of each HTTP request to stderr")
root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
if reason := cmd.Annotations["tea.text"]; reason != "" && a.options.json != "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s; --json is not supported", reason))
}
if flag := cmd.Flags().Lookup("web"); flag != nil && flag.Value.String() == "true" && (a.options.json != "" || a.options.jq != "") {
return clierr.Wrap(clierr.Usage, errors.New("--web cannot be combined with --json or --jq"))
}
if a.options.jq != "" && a.options.json == "" {
return clierr.Wrap(clierr.Usage, errors.New("--jq requires --json"))
}
if cmd.Flags().Changed("sudo") {
if a.options.sudo == "" || strings.IndexFunc(a.options.sudo, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--sudo requires a username"))
}
for c := cmd; c.HasParent(); c = c.Parent() {
if c.Name() == "auth" && !c.Parent().HasParent() {
return clierr.Wrap(clierr.Usage, errors.New("--sudo cannot be used with auth commands"))
}
}
}
if err := terminal.ValidateOutput(a.options.json, a.options.jq, commandOutputFields(cmd)); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
if offline(cmd) {
return nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
return a.policy(snapshot).Validate(a.deps.Streams)
}
root.AddCommand(a.configCommand(), a.aliasCommand(), a.authCommand(), a.apiCommand(), a.repoCommand(), a.issueCommand(), a.prCommand(), a.doctorCommand(), a.workflowCommand(), a.runCommand(), a.labelCommand(), a.milestoneCommand(), a.sshKeyCommand(), a.timeCommand(), a.wikiCommand(), a.actionValueCommand(true), a.actionValueCommand(false), a.releaseCommand(), a.organizationCommand(), a.notificationCommand(), a.teamCommand(), a.branchCommand(), a.webhookCommand(), a.adminCommand(), a.searchCommand(), a.statusCommand(false))
root.AddCommand(a.manCommand())
root.AddCommand(&cobra.Command{Use: "version", Short: "Print tea's version", Annotations: textOutput("version prints text"), RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), version.Format())
return err
}})
root.InitDefaultHelpCmd()
root.InitDefaultCompletionCmd()
root.SetVersionTemplate("{{.Version}}\n")
rejectUnknownSubcommands(root)
return root
}
// offline reports commands that must work without valid settings.
func offline(cmd *cobra.Command) bool {
if cmd.Annotations["tea.group"] == "true" {
return true
}
switch cmd.Name() {
case "help", "version", "man", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
return true
}
return cmd.HasParent() && cmd.Parent().Name() == "completion"
}
// rejectUnknownSubcommands makes `tea bogus` and `tea issue bogus` usage errors instead of printing help.
func rejectUnknownSubcommands(cmd *cobra.Command) {
if cmd.HasSubCommands() && !cmd.Runnable() {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations["tea.group"] = "true"
cmd.Args, cmd.SuggestionsMinimumDistance = cobra.ArbitraryArgs, 2
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
err := fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath())
if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 {
err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
}
return clierr.Wrap(clierr.Usage, err)
}
}
for _, child := range cmd.Commands() {
rejectUnknownSubcommands(child)
}
}
func builtins(root *cobra.Command) map[string]bool {
names := map[string]bool{"help": true, "completion": true}
for _, cmd := range root.Commands() {
if cmd.Annotations["tea.alias"] != "true" {
names[cmd.Name()] = true
}
}
return names
}
// Execute expands aliases and executes the fresh root without printing errors.
func Execute(ctx context.Context, deps Dependencies, args []string) error {
root := NewRoot(deps)
if at := commandIndex(root, args); at == len(args) || (args[at] != cobra.ShellCompRequestCmd && args[at] != cobra.ShellCompNoDescRequestCmd) {
args = joinJSONFields(root, args)
}
// Broken settings or aliases must not prevent help or recovery commands.
snapshot, loadErr := deps.Settings.Load()
names := builtins(root)
if commandAt := commandIndex(root, args); commandAt < len(args) {
name := args[commandAt]
completion := name == cobra.ShellCompRequestCmd || name == cobra.ShellCompNoDescRequestCmd
switch {
case loadErr != nil:
// An alias cannot be expanded without settings; report why instead of "unknown command".
if !names[name] && !completion {
return loadErr
}
case completion:
completionArgs := args[commandAt+1:]
if aliasAt := commandIndex(root, completionArgs); aliasAt < len(completionArgs) {
if expanded, err := aliases.ExpandCompletion(completionArgs[aliasAt:], snapshot.Preferences.Aliases, names); err == nil {
args = slices.Concat(args[:commandAt+1], completionArgs[:aliasAt], expanded)
}
}
default:
expanded, err := aliases.Expand(args[commandAt:], snapshot.Preferences.Aliases, names)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
args = joinJSONFields(root, slices.Concat(args[:commandAt], expanded))
}
}
if loadErr == nil {
for name, expansion := range snapshot.Preferences.Aliases {
if aliases.Check(name, snapshot.Preferences.Aliases, names) == nil {
root.AddCommand(&cobra.Command{Use: name, Short: "Alias for " + expansion, Annotations: map[string]string{"tea.alias": "true"}, RunE: func(*cobra.Command, []string) error {
return clierr.Wrap(clierr.Usage, errors.New("alias must be expanded before execution"))
}})
}
}
}
root.SetArgs(args)
err := root.ExecuteContext(ctx)
if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == 401 {
return clierr.Wrap(clierr.Authentication, err)
}
return err
}
var jsonFieldList = regexp.MustCompile(`^[a-z_]+(,[a-z_]+)*$`)
// joinJSONFields accepts `--json number,title` like `--json=number,title` unless the selected command
// does not output those fields, in which case the word stays a positional argument. A bare --json
// still selects every field. Unknown commands, such as aliases before expansion, are joined.
func joinJSONFields(root *cobra.Command, args []string) []string {
for i := 0; i < len(args); i++ {
if args[i] == "--" {
break
}
if args[i] != "--json" || i+1 >= len(args) || !jsonFieldList.MatchString(args[i+1]) {
continue
}
cmd, _, err := root.Find(slices.Concat(args[:i+1], args[i+2:]))
if err == nil && terminal.ValidateOutput(args[i+1], "", commandOutputFields(cmd)) != nil {
continue
}
args = slices.Concat(args[:i], []string{"--json=" + args[i+1]}, args[i+2:])
}
return args
}
// confirm asks for confirmation under the prompt policy unless --yes was supplied.
func (a *application) confirm(cmd *cobra.Command, description string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
return terminal.Confirm(cmd.Context(), description, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
// require prompts for a missing required value under the prompt policy.
func (a *application) require(cmd *cobra.Command, value *string, flag string) (bool, error) {
s, err := a.deps.Settings.Load()
if err != nil {
return false, err
}
return terminal.Require(cmd.Context(), value, flag, a.deps.Streams, a.policy(s), a.deps.Prompter)
}
func (a *application) policy(snapshot settings.Snapshot) terminal.Policy {
return terminal.Policy{Enabled: snapshot.Preferences.Prompt == "enabled", NoInput: a.options.noInput, Machine: a.options.json != "", Interactive: a.options.interactive}
}
// titleCase capitalizes an ASCII command word for help and status text.
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// textOutput marks a command whose output is not a JSON resource, so --json is rejected up front.
func textOutput(reason string) map[string]string {
return map[string]string{"tea.text": reason}
}
// commandIndex skips persistent flags without parsing or mutating command state.
func commandIndex(root *cobra.Command, args []string) int {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--" {
return i + 1
}
if !strings.HasPrefix(arg, "-") || arg == "-" {
return i
}
name := strings.TrimLeft(arg, "-")
if strings.Contains(name, "=") {
continue
}
flag := root.PersistentFlags().Lookup(name)
if flag == nil && len(name) > 0 && !strings.HasPrefix(arg, "--") {
flag = root.PersistentFlags().ShorthandLookup(name[:1])
if len(name) > 1 {
continue
}
}
if flag != nil && flag.NoOptDefVal == "" {
i++
}
}
return len(args)
}
func commandOutputFields(cmd *cobra.Command) []string {
if cmd.Name() == "status" && cmd.Parent() != nil {
if cmd.Parent().Name() == "pr" {
return jsonFields[services.PullStatus]()
}
if cmd.Parent().Parent() == nil {
return jsonFields[services.AccountStatus]()
}
}
if cmd.Parent() == nil {
return nil
}
if cmd.Parent().Name() == "search" {
if cmd.Name() == "repo" {
return jsonFields[services.Repository]()
}
return jsonFields[services.SearchItem]()
}
switch cmd.Parent().Name() {
case "auth":
if cmd.Name() == "status" {
return jsonFields[authStatusRow]()
}
case "config":
if cmd.Name() == "get" || cmd.Name() == "list" {
return []string{
"version", "default_host", "git_protocol", "editor", "prompt", "pager", "browser", "color", "remote",
"ssh_host", "insecure", "oauth_client_id",
"ssh_key", "ssh_agent", "ssh_certificate_principal", "ssh_key_fingerprint",
}
}
case "tea":
if cmd.Name() == "doctor" {
return jsonFields[target]()
}
case "attachment":
return jsonFields[services.IssueAttachment]()
case "artifact":
return jsonFields[services.Artifact]()
case "user":
if cmd.Parent().Parent() != nil && cmd.Parent().Parent().Name() == "admin" {
return jsonFields[services.AdminUser]()
}
case "webhook":
return jsonFields[services.Webhook]()
case "branch":
return jsonFields[services.Branch]()
case "team":
return jsonFields[services.Team]()
case "notification":
return jsonFields[services.Notification]()
case "org":
return jsonFields[services.Organization]()
case "member":
return jsonFields[services.OrganizationMember]()
case "asset":
return jsonFields[services.ReleaseAsset]()
case "release":
return jsonFields[services.Release]()
case "secret":
return jsonFields[services.ActionSecret]()
case "variable":
return jsonFields[services.ActionVariable]()
case "wiki":
if cmd.Name() == "revisions" {
return jsonFields[services.WikiRevision]()
}
return jsonFields[services.WikiPage]()
case "time":
return jsonFields[services.TrackedTime]()
case "ssh-key":
return jsonFields[services.SSHKey]()
case "comment":
return jsonFields[services.Comment]()
case "issue":
return jsonFields[services.Issue]()
case "review":
if cmd.Name() == "request" || cmd.Name() == "cancel-request" {
return jsonFields[services.Pull]()
}
if cmd.Name() == "comments" || cmd.Name() == "reply" {
return jsonFields[services.ReviewComment]()
}
return jsonFields[services.Review]()
case "pr":
switch cmd.Name() {
case "checkout":
return jsonFields[git.CheckoutResult]()
case "checks":
return jsonFields[services.Check]()
case "review":
return jsonFields[services.Review]()
case "clean":
return jsonFields[git.CleanResult]()
}
return jsonFields[services.Pull]()
case "repo":
if cmd.Name() == "sync" {
return jsonFields[git.SyncResult]()
}
return jsonFields[services.Repository]()
case "label":
return jsonFields[services.Label]()
case "milestone":
return jsonFields[services.Milestone]()
case "workflow":
if cmd.Name() == "run" {
return jsonFields[services.DispatchResult]()
}
return jsonFields[services.Workflow]()
case "run":
if cmd.Name() == "jobs" {
return jsonFields[services.Job]()
}
return jsonFields[services.Run]()
}
return nil
}
// jsonFields lists the JSON names of T's fields, which --json can select.
func jsonFields[T any]() []string {
t := reflect.TypeFor[T]()
fields := make([]string, 0, t.NumField())
for field := range t.Fields() {
if name, _, _ := strings.Cut(field.Tag.Get("json"), ","); name != "" && name != "-" {
fields = append(fields, name)
}
}
return fields
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestHelpWorksWithoutValidConfiguration(t *testing.T) {
for _, args := range [][]string{{}, {"help"}, {"version"}, {"man"}, {"man", "pr", "checkout"}, {"completion", "bash"}, {"--help"}} {
deps, out, _ := testDeps(t)
require.NoError(t, os.WriteFile(filepath.Join(deps.Settings.Dir, "config.yml"), []byte("not: ["), 0o600))
require.NoError(t, Execute(context.Background(), deps, args))
require.NotEmpty(t, out.String())
}
}
func TestAliasesAndConfigPersistence(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "prefs", "config list"}))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"prefs"}))
require.Contains(t, out.String(), "git_protocol")
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "preferences", "prefs"}))
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "prompt", "disabled"}))
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "delete", "preferences"}))
err := Execute(context.Background(), deps, []string{"alias", "set", "config", "issue list"})
require.Error(t, err)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
s, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "disabled", s.Preferences.Prompt)
}
func TestBrokenAliasDoesNotBlockOtherCommands(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, os.WriteFile(filepath.Join(deps.Settings.Dir, "config.yml"), []byte("version: 1\naliases:\n status: config list\n dangling: missing\n"), 0o600))
require.NoError(t, Execute(context.Background(), deps, []string{"config", "list"}))
require.Contains(t, out.String(), "git_protocol")
err := Execute(context.Background(), deps, []string{"dangling"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "delete", "status"}))
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "delete", "dangling"}))
}
func TestCommandLineEdgeCases(t *testing.T) {
deps, out, _ := testDeps(t)
err := Execute(context.Background(), deps, []string{"issue", "bogus"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
err = Execute(context.Background(), deps, []string{"isue"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "did you mean issue?")
// A help flag supplied as a flag value must not bypass validation.
err = Execute(context.Background(), deps, []string{"config", "list", "--jq", ".", "--remote", "-h"})
require.ErrorContains(t, err, "--jq requires --json")
require.NoError(t, Execute(context.Background(), deps, []string{"--version"}))
require.NotContains(t, out.String(), "tea version")
}
func TestAliasesAfterGlobalFlagsAndNestedHelp(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "prefs", "config list"}))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"--no-input", "prefs"}))
require.Contains(t, out.String(), "git_protocol")
require.NoError(t, os.WriteFile(filepath.Join(deps.Settings.Dir, "config.yml"), []byte("broken: ["), 0o600))
for _, args := range [][]string{{"issue", "--help"}, {"repo"}, {"auth", "login", "--help"}} {
require.NoError(t, Execute(context.Background(), deps, args))
}
}
func TestJoinJSONFields(t *testing.T) {
for _, tc := range []struct{ args, want []string }{
{[]string{"issue", "list", "--json", "number,title"}, []string{"issue", "list", "--json=number,title"}},
{[]string{"--json", "id", "org", "list"}, []string{"--json=id", "org", "list"}},
{[]string{"issue", "list", "--json"}, []string{"issue", "list", "--json"}},
{[]string{"search", "repo", "foo", "--json"}, []string{"search", "repo", "foo", "--json"}},
{[]string{"issue", "view", "--json", "5"}, []string{"issue", "view", "--json", "5"}},
{[]string{"repo", "clone", "--json", "alice/project"}, []string{"repo", "clone", "--json", "alice/project"}},
{[]string{"issue", "list", "--json", "--state", "open"}, []string{"issue", "list", "--json", "--state", "open"}},
{[]string{"search", "issues", "--", "--json", "id"}, []string{"search", "issues", "--", "--json", "id"}},
{[]string{"issue", "list", "--json", "Number"}, []string{"issue", "list", "--json", "Number"}},
{[]string{"search", "repo", "--json", "tea"}, []string{"search", "repo", "--json", "tea"}},
{[]string{"search", "issue", "--json", "bug"}, []string{"search", "issue", "--json", "bug"}},
{[]string{"repo", "clone", "--json", "project"}, []string{"repo", "clone", "--json", "project"}},
{[]string{"search", "repo", "--json", "name,url", "tea"}, []string{"search", "repo", "--json=name,url", "tea"}},
{[]string{"--json", "number", "my-alias"}, []string{"--json=number", "my-alias"}},
} {
deps, _, _ := testDeps(t)
require.Equal(t, tc.want, joinJSONFields(NewRoot(deps), tc.args), tc.args)
}
}
func TestJSONFieldsAcceptSpaceSeparatedValue(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
_, _ = w.Write([]byte(`[{"id":1,"username":"team","full_name":"Team"}]`))
case "/api/v1/repos/search":
require.Equal(t, "foo", r.URL.Query().Get("q"))
_, _ = w.Write([]byte(`{"data":[{"id":2,"full_name":"alice/foo"}]}`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
for _, args := range [][]string{{"org", "list", "--json", "id,name"}, {"org", "list", "--json=id,name"}, {"--json", "id,name", "org", "list"}} {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, args), args)
require.JSONEq(t, `[{"id":1,"name":"team"}]`, out.String(), args)
}
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"org", "list", "--json"}))
require.Contains(t, out.String(), `"full_name":"Team"`)
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"search", "repo", "foo", "--json"}))
require.Contains(t, out.String(), `"full_name":"alice/foo"`)
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"search", "repo", "--json", "foo"}))
require.Contains(t, out.String(), `"full_name":"alice/foo"`)
}
func TestSudoSendsHeaderAfterIdentityCheck(t *testing.T) {
seen := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.URL.Path+" "+r.Header.Get("Sudo"))
if r.URL.Path == "/api/v1/user" {
_, _ = w.Write([]byte(`{"login":"alice"}`))
return
}
_, _ = w.Write([]byte(`{"id":7,"full_name":"alice/project"}`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"--sudo", "bob", "repo", "view", "--repo", "alice/project", "--json=full_name"}))
require.Equal(t, []string{"/api/v1/user ", "/api/v1/repos/alice/project bob"}, seen)
require.JSONEq(t, `{"full_name":"alice/project"}`, out.String())
requireUsageBeforeRequests(t, "--sudo cannot be used with auth commands", "auth", "status", "--sudo", "bob")
requireUsageBeforeRequests(t, "--sudo requires a username", "repo", "view", "--sudo", "")
}
+209
View File
@@ -0,0 +1,209 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"math"
"os"
"slices"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func number(raw string) (int64, error) {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 {
return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid resource number %q", raw))
}
return n, nil
}
func resourceNumbers(args []string) ([]int64, error) {
result := make([]int64, len(args))
seen := make(map[int64]bool, len(args))
for i, arg := range args {
n, err := number(arg)
if err != nil {
return nil, err
}
if seen[n] {
return nil, clierr.Wrap(clierr.Usage, fmt.Errorf("resource %d is selected more than once; supply unique numbers", n))
}
seen[n] = true
result[i] = n
}
return result, nil
}
// requireNames trims name-list flag entries in place and rejects blanks; hint explains how to clear instead.
func requireNames(kind, hint string, lists ...[]string) error {
trimNames(lists...)
if slices.Contains(slices.Concat(lists...), "") {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s names cannot be empty%s", kind, hint))
}
return nil
}
// trimNames removes surrounding whitespace from each entry in place, since Gitea rejects padded names.
func trimNames(lists ...[]string) {
for _, list := range lists {
for i, name := range list {
list[i] = strings.TrimSpace(name)
}
}
}
// labelFilter splits a comma-separated label filter, trimming entries and rejecting blanks.
func labelFilter(raw string) ([]string, error) {
if raw == "" {
return nil, nil
}
names := strings.Split(raw, ",")
return names, requireNames("label", "", names)
}
// oneOf rejects an enum flag value outside allowed, which needs at least two entries.
func oneOf(flag, value string, allowed ...string) error {
if slices.Contains(allowed, value) {
return nil
}
last := len(allowed) - 1
choices := strings.Join(allowed[:last], ", ")
if last > 1 {
choices += ","
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must be %s or %s", flag, choices, allowed[last]))
}
// pageFlags registers --limit and --page, which reject out-of-range values while parsing.
func pageFlags(cmd *cobra.Command, limit, page *int) {
positiveIntVar(cmd, limit, "limit", 30, 100, "Maximum results (1..100)")
positiveIntVar(cmd, page, "page", 1, math.MaxInt, "Result page")
}
// positiveIntVar registers an int flag that rejects values outside 1..upper while parsing.
func positiveIntVar(cmd *cobra.Command, value *int, name string, initial, upper int, usage string) {
*value = initial
cmd.Flags().Var(positiveInt{value, upper}, name, usage)
}
type positiveInt struct {
value *int
upper int
}
func (f positiveInt) String() string { return strconv.Itoa(*f.value) }
func (f positiveInt) Type() string { return "int" }
func (f positiveInt) Set(raw string) error {
n, err := strconv.Atoi(raw)
switch {
case (err != nil || n < 1) && f.upper == math.MaxInt:
return errors.New("must be a positive integer")
case err != nil || n < 1 || n > f.upper:
return fmt.Errorf("must be 1..%d", f.upper)
}
*f.value = n
return nil
}
func parseRunTime(value string, now time.Time) (*time.Time, error) {
if duration, err := time.ParseDuration(value); err == nil {
if duration < 0 {
return nil, errors.New("relative duration cannot be negative")
}
result := now.Add(-duration)
return &result, nil
}
for _, format := range []string{time.RFC3339Nano, "2006-01-02", "2006-01-02 15:04", "2006-01-02T15:04:05"} {
if result, err := time.ParseInLocation(format, value, now.Location()); err == nil {
return &result, nil
}
}
return nil, errors.New("use a date, RFC3339 timestamp, or duration such as 24h")
}
// timeRange is a pair of lower and upper time bound flags.
type timeRange struct {
flags *pflag.FlagSet
names [2]string
values [2]string
}
// register adds the lower and upper bound flags to cmd.
func (r *timeRange) register(cmd *cobra.Command, lower, lowerUsage, upper, upperUsage string) {
r.flags, r.names = cmd.Flags(), [2]string{lower, upper}
r.flags.StringVar(&r.values[0], lower, "", lowerUsage)
r.flags.StringVar(&r.values[1], upper, "", upperUsage)
}
// parse returns the bounds that were set and rejects inverted ranges.
func (r *timeRange) parse(now time.Time) (since, until *time.Time, err error) {
var bounds [2]*time.Time
for i, flag := range r.names {
if !r.flags.Changed(flag) {
continue
}
if bounds[i], err = parseRunTime(r.values[i], now); err != nil {
return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --%s: %w", flag, err))
}
}
if bounds[0] != nil && bounds[1] != nil && bounds[0].After(*bounds[1]) {
return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must not be later than --%s", r.names[0], r.names[1]))
}
return bounds[0], bounds[1], nil
}
// openRegularFiles opens upload sources, rejecting anything but regular files.
// Callers close the returned files even on error.
func openRegularFiles(paths []string) ([]*os.File, error) {
files := make([]*os.File, 0, len(paths))
for _, path := range paths {
file, err := os.Open(path)
if err != nil {
return files, err
}
files = append(files, file)
info, err := file.Stat()
if err != nil {
return files, err
}
if !info.Mode().IsRegular() {
return files, clierr.Wrap(clierr.Usage, fmt.Errorf("%s is not a regular file", path))
}
}
return files, nil
}
func closeFiles(files []*os.File) {
for _, file := range files {
_ = file.Close()
}
}
func argsExact(n int) cobra.PositionalArgs {
return usageArgs(cobra.ExactArgs(n))
}
// usageArgs reports positional argument errors with the usage exit code.
func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
return clierr.Wrap(clierr.Usage, validate(cmd, args))
}
}
// localFlagsChanged reports whether any command-specific flag was supplied.
func localFlagsChanged(cmd *cobra.Command) bool {
changed := false
cmd.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { changed = changed || flag.Changed })
return changed
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
func TestParseRunTime(t *testing.T) {
now := time.Date(2028, 2, 1, 12, 0, 0, 0, time.UTC)
plus2 := time.FixedZone("UTC+2", 2*60*60)
for _, tc := range []struct {
input string
expected time.Time
}{
{"24h", now.Add(-24 * time.Hour)},
{"0s", now},
{"2028-01-01", time.Date(2028, 1, 1, 0, 0, 0, 0, plus2)},
{"2028-01-01T00:30:00+02:00", time.Date(2027, 12, 31, 22, 30, 0, 0, time.UTC)},
{"2028-01-01 08:00", time.Date(2028, 1, 1, 8, 0, 0, 0, plus2)},
} {
result, err := parseRunTime(tc.input, now.In(plus2))
require.NoError(t, err)
require.True(t, result.Equal(tc.expected), tc.input)
}
for _, input := range []string{"", "-24h", "not-a-date", "2028-02-30"} {
_, err := parseRunTime(input, now)
require.Error(t, err, input)
}
}
func TestParseTimeRangeRejectsOnlyInvertedBounds(t *testing.T) {
for _, tc := range []struct {
since, until string
wantErr bool
}{
{"2026-10-01", "2026-10-02", false},
{"2026-10-01", "2026-10-01", false},
{"2026-10-02", "2026-10-01", true},
} {
cmd := &cobra.Command{}
var bounds timeRange
bounds.register(cmd, "since", "", "until", "")
require.NoError(t, cmd.Flags().Parse([]string{"--since=" + tc.since, "--until=" + tc.until}))
_, _, err := bounds.parse(time.Now())
require.Equal(t, tc.wantErr, err != nil, tc.since+".."+tc.until)
}
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"strconv"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) artifactCommand() *cobra.Command {
group := &cobra.Command{Use: "artifact", Short: "Inspect and download Actions artifacts"}
var limit, page int
list := &cobra.Command{Use: "list <run-id>", Short: "List artifacts belonging to a workflow run", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
run, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Actions{Client: target.client, Repository: target.Repo}).Artifacts(cmd.Context(), run, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, artifactRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
var output string
download := &cobra.Command{Use: "download <artifact-id>", Short: "Download an artifact ZIP without extracting it", Annotations: textOutput("artifact downloads are binary"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
if err := validateDownloadDestination(output); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
body, err := (services.Actions{Client: target.client, Repository: target.Repo}).DownloadArtifact(cmd.Context(), id)
if err != nil {
return err
}
defer body.Close()
return writeDownload(cmd, body, output)
}}
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout")
group.AddCommand(download)
return group
}
func artifactRow(item services.Artifact) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), strconv.FormatBool(item.Expired))
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestArtifactDownloadsValidateDestinationAndKeepBinaryOutputClean(t *testing.T) {
requests := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
switch r.URL.Path {
case "/api/v1/repos/owner/repo/actions/artifacts/9/zip":
require.Equal(t, "token token", r.Header.Get("Authorization"))
http.Redirect(w, r, "/signed?signature=private", http.StatusFound)
case "/signed":
require.Empty(t, r.Header.Get("Authorization"))
_, _ = w.Write([]byte{0x50, 0x4b, 0, 0xff})
default:
t.Errorf("unexpected endpoint %s", r.URL.Path)
w.WriteHeader(404)
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
args := []string{"run", "artifact", "download", "9", "-R", "owner/repo", "--output-file", "-"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, out.Bytes())
require.Empty(t, diagnostics.String())
output := filepath.Join(t.TempDir(), "artifact.zip")
args[len(args)-1] = output
out.Reset()
require.NoError(t, Execute(context.Background(), deps, args))
data, err := os.ReadFile(output)
require.NoError(t, err)
require.Equal(t, []byte{0x50, 0x4b, 0, 0xff}, data)
require.Empty(t, out.String())
before := requests
err = Execute(context.Background(), deps, args)
require.ErrorContains(t, err, "already exists")
require.Equal(t, before, requests)
args[len(args)-1] = "-"
err = Execute(context.Background(), deps, append(args, "--json"))
require.ErrorContains(t, err, "binary")
require.Equal(t, before, requests)
}
+362
View File
@@ -0,0 +1,362 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// verifyAccount checks that a stored credential still authenticates as its account.
func (a *application) verifyAccount(ctx context.Context, policy terminal.Policy, instance, user string, host settings.Host, account settings.Account, token string) error {
client := a.deps.Client(instance, token, host.Insecure)
if account.Tokenless() {
signer, err := auth.SignerForAccount(account, a.sshPassphrase(ctx, policy, account.SSHKey))
if err != nil {
return err
}
client.Signer = signer
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
actual, err := services.CurrentAccount(ctx, client)
if err != nil {
return err
}
if !strings.EqualFold(actual, user) {
return errors.New("credential authenticates a different account")
}
return nil
}
// sshPassphrase prompts for the passphrase of an encrypted SSH key, or returns nil when prompting is not allowed.
func (a *application) sshPassphrase(ctx context.Context, policy terminal.Policy, keyPath string) func() ([]byte, error) {
if a.deps.Prompter == nil || !policy.CanPrompt(a.deps.Streams) {
return nil
}
return func() ([]byte, error) {
passphrase, err := a.deps.Prompter.SecretInput(ctx, "Passphrase for "+keyPath)
return []byte(passphrase), err
}
}
// authStatusRow is one configured account reported by auth status.
type authStatusRow struct {
Host string `json:"host"`
User string `json:"user"`
Active bool `json:"active"`
Store string `json:"credential_store"`
Available bool `json:"credential_available"`
ExpiresAt *time.Time `json:"expires_at"`
Verified *bool `json:"verified"`
Error string `json:"error"`
}
func (a *application) authCommand() *cobra.Command {
group := &cobra.Command{Use: "auth", Short: "Manage instance accounts and credentials"}
group.AddCommand(a.gitCredentialCommand(), a.setupGitCommand())
var withToken bool
var backend string
var oauth bool
var clientID string
var sshKey string
var sshAgentPrincipal string
var sshAgentKey string
var insecure bool
login := &cobra.Command{Use: "login", Short: "Authenticate with a token, OAuth, or SSH HTTP signature", Annotations: textOutput("login does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
methods := 0
for _, selected := range []bool{withToken, oauth, sshKey != "", sshAgentPrincipal != "", sshAgentKey != ""} {
if selected {
methods++
}
}
if methods != 1 {
return clierr.Wrap(clierr.Usage, errors.New("select exactly one of --with-token, --oauth, --ssh-key, --ssh-agent-principal, or --ssh-agent-key"))
}
if clientID != "" && !oauth {
return clierr.Wrap(clierr.Usage, errors.New("--client-id requires --oauth"))
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
instance := a.options.host
if instance == "" {
instance = a.deps.Getenv("TEA_HOST")
}
if instance == "" {
instance = a.deps.Getenv("GITEA_INSTANCE_URL")
}
prompted, err := a.require(cmd, &instance, "host")
if err != nil {
return err
}
instance, err = settings.NormalizeHost(instance)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
host := snapshot.Hosts.Hosts[instance]
insecure = insecure || host.Insecure
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
if sshKey != "" || sshAgentPrincipal != "" || sshAgentKey != "" {
account := settings.Account{}
switch {
case sshKey != "":
account.SSHKey = sshKey
if _, err := os.Stat(sshKey + "-cert.pub"); err == nil {
account.AuthMethod = settings.AuthSSH
account.SSHCertPrincipal = "yes"
} else if errors.Is(err, os.ErrNotExist) {
account.AuthMethod = settings.AuthHTTPSign
} else {
return err
}
case sshAgentPrincipal != "":
account.AuthMethod = settings.AuthSSH
account.SSHAgent = true
account.SSHCertPrincipal = sshAgentPrincipal
case sshAgentKey != "":
account.AuthMethod = settings.AuthHTTPSign
account.SSHAgent = true
account.SSHKeyFingerprint = sshAgentKey
}
signer, err := auth.SignerForAccount(account, a.sshPassphrase(cmd.Context(), a.policy(snapshot), account.SSHKey))
if err != nil {
return clierr.Wrap(clierr.Authentication, err)
}
client := a.deps.Client(instance, "", insecure)
client.Signer = signer
user, err := services.CurrentAccount(cmd.Context(), client)
if err != nil {
return err
}
if selectedUser, _ := a.selectedUser(); !services.MatchAccount(selectedUser, user) {
return clierr.Wrap(clierr.Usage, errors.New("authenticated account does not match --user or TEA_USER"))
}
if prompted {
if err := a.confirm(cmd, "Save credentials for "+user+" on "+instance); err != nil {
return err
}
}
return manager.RegisterHTTPSign(instance, user, account, auth.HostOptions{Insecure: insecure})
}
if err := oneOf("credential-store", backend, settings.StoreKeyring, settings.StoreFile); err != nil {
return err
}
credential := auth.Credential{}
method := settings.AuthToken
if oauth {
if a.options.noInput {
return clierr.Wrap(clierr.Usage, errors.New("OAuth authorization requires interaction; use --with-token with --no-input"))
}
if clientID == "" {
clientID = host.OAuthClientID
}
if clientID == "" {
clientID = auth.DefaultClientID
}
credential, err = auth.OAuthLogin(cmd.Context(), auth.OAuthOptions{Instance: instance, ClientID: clientID, HTTP: a.deps.Client(instance, "", insecure).HTTP, Authorize: func(_ context.Context, authorizationURL string) error {
if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Open this URL to authorize tea:", authorizationURL); err != nil {
return err
}
if err := a.openBrowser(cmd, authorizationURL); err != nil {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not open a browser automatically; open the URL above manually.")
}
return nil
}})
if err != nil {
return err
}
method = settings.AuthOAuth
} else {
data, err := io.ReadAll(io.LimitReader(a.deps.Streams.In, 65537))
if err != nil {
return errors.New("cannot read token from stdin")
}
credential.AccessToken = strings.TrimSpace(string(data))
if credential.AccessToken == "" || len(data) > 65536 || strings.ContainsAny(credential.AccessToken, "\r\n") {
return clierr.Wrap(clierr.Usage, errors.New("stdin must contain one nonempty token"))
}
}
token := credential.AccessToken
client := a.deps.Client(instance, token, insecure)
user, err := services.CurrentAccount(cmd.Context(), client)
if err != nil {
return err
}
if selectedUser, _ := a.selectedUser(); !services.MatchAccount(selectedUser, user) {
return clierr.Wrap(clierr.Usage, errors.New("token account does not match --user or TEA_USER"))
}
if prompted {
if err := a.confirm(cmd, "Save credentials for "+user+" on "+instance); err != nil {
return err
}
}
return manager.Register(instance, user, backend, method, auth.HostOptions{OAuthClientID: clientID, Insecure: insecure}, credential)
}}
login.Flags().BoolVar(&oauth, "oauth", false, "Authorize through a browser using OAuth PKCE")
login.Flags().StringVar(&clientID, "client-id", "", "OAuth client ID registered on the instance (default: the instance's stored ID, then tea's)")
login.Flags().BoolVar(&insecure, "insecure", false, "Skip TLS certificate verification for this instance")
login.Flags().BoolVar(&withToken, "with-token", false, "Read a personal access token from stdin")
login.Flags().StringVar(&sshKey, "ssh-key", "", "Authenticate with an SSH private key via HTTP signatures")
login.Flags().StringVar(&sshAgentPrincipal, "ssh-agent-principal", "", "Authenticate with an ssh-agent certificate principal via HTTP signatures")
login.Flags().StringVar(&sshAgentKey, "ssh-agent-key", "", "Authenticate with an ssh-agent public key fingerprint via HTTP signatures")
login.Flags().StringVar(&backend, "credential-store", settings.StoreKeyring, "Credential backend: keyring or explicitly opted-in file")
group.AddCommand(login)
var verify bool
status := &cobra.Command{Use: "status", Short: "Show configured accounts without revealing credentials", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
selectedHost := ""
if a.options.host != "" {
var err error
selectedHost, err = settings.NormalizeHost(a.options.host)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
}
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
rows := []authStatusRow{}
for instance, host := range s.Hosts.Hosts {
if selectedHost != "" && instance != selectedHost {
continue
}
for user, account := range host.Users {
if !services.MatchAccount(a.options.user, user) {
continue
}
item := authStatusRow{Host: instance, User: user, Active: host.User == user, Store: account.CredentialStore, Available: account.Tokenless()}
var credential auth.Credential
if !item.Available {
var err error
credential, err = a.deps.Credentials.Load(instance, user, account.CredentialStore)
item.Available = err == nil
if item.Available && account.AuthMethod == settings.AuthOAuth && !credential.ExpiresAt.IsZero() {
item.ExpiresAt = &credential.ExpiresAt
}
}
if verify {
err := errors.New("credential unavailable")
if item.Available {
err = a.verifyAccount(cmd.Context(), a.policy(s), instance, user, host, account, credential.AccessToken)
}
verified := err == nil
item.Verified = &verified
if err != nil {
item.Error = err.Error()
}
}
rows = append(rows, item)
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Host == rows[j].Host {
return rows[i].User < rows[j].User
}
return rows[i].Host < rows[j].Host
})
if a.options.json != "" {
return a.writeJSON(cmd, rows)
}
for _, row := range rows {
line := fmt.Sprintf("%s\t%s\tactive=%t\tcredentials=%t", row.Host, row.User, row.Active, row.Available)
if row.ExpiresAt != nil {
line += "\texpires=" + row.ExpiresAt.Format(time.RFC3339)
}
if row.Verified != nil {
line += fmt.Sprintf("\tverified=%t", *row.Verified)
}
if row.Error != "" {
line += "\terror=" + row.Error
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), line); err != nil {
return err
}
}
return nil
}}
status.Flags().BoolVar(&verify, "verify", false, "Check each listed credential against its instance")
group.AddCommand(status)
group.AddCommand(&cobra.Command{Use: "switch <user>", Short: "Select the active account for an instance", Annotations: textOutput("switching accounts does not return a resource"), Args: argsExact(1), RunE: func(_ *cobra.Command, args []string) error {
if a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("--host is required"))
}
instance, err := settings.NormalizeHost(a.options.host)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
host, exists := s.Hosts.Hosts[instance]
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
user, exists := matchAccount(host.Users, args[0])
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance; use tea auth status --host URL to list accounts"))
}
host.User = user
s.Hosts.Hosts[instance] = host
return nil
})
}})
group.AddCommand(&cobra.Command{Use: "logout", Short: "Remove one account and its credentials", Annotations: textOutput("logout does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
selected, err := a.resolveTarget(cmd.Context(), false, false)
if err != nil {
return err
}
instance, user := selected.Host, selected.User
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if _, exists := s.Hosts.Hosts[instance].Users[user]; !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance; use tea auth status --host URL to list accounts"))
}
if err := a.confirm(cmd, "Remove account "+user+" on "+instance); err != nil {
return err
}
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
return manager.Logout(instance, user)
}})
group.AddCommand(&cobra.Command{Use: "refresh", Short: "Refresh the selected OAuth account", Annotations: textOutput("refresh does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
t, err := a.resolveTarget(cmd.Context(), false, false)
if err != nil {
return err
}
account, exists := t.host.Users[t.User]
if !exists {
return clierr.Wrap(clierr.Authentication, errors.New("no selected account on this instance; run tea auth login or tea auth switch"))
}
if account.AuthMethod != settings.AuthOAuth {
return clierr.Wrap(clierr.Usage, errors.New("only OAuth accounts can be refreshed; use tea auth login to replace a token"))
}
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
_, err = manager.Refresh(cmd.Context(), t.Host, t.User, a.deps.Client(t.Host, "", t.host.Insecure).HTTP, true, a.deps.Now())
return clierr.Wrap(clierr.Authentication, err)
}})
return group
}
+522
View File
@@ -0,0 +1,522 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
type statusKeyring struct{ reads []string }
func (s *statusKeyring) Get(_, identity string) (string, error) {
s.reads = append(s.reads, identity)
return `{"access_token":"private-status-token"}`, nil
}
func (*statusKeyring) Set(string, string, string) error { return nil }
func (*statusKeyring) Delete(string, string) error { return nil }
type channelBrowser struct{ opened chan string }
func (b *channelBrowser) Open(_ context.Context, resource, _ string) error {
select {
case b.opened <- resource:
default:
}
return nil
}
func TestLoginValidatesUserEnvironmentWithFlagPrecedence(t *testing.T) {
server := newAuthenticatedServer(http.NotFoundHandler())
defer server.Close()
for _, test := range []struct {
name, environment, flag string
accepted bool
}{
{"environment matches", "ALICE", "", true},
{"environment mismatches", "bob", "", false},
{"flag overrides environment", "bob", "alice", true},
{"flag mismatches", "alice", "bob", false},
} {
t.Run(test.name, func(t *testing.T) {
deps, _, _ := testDeps(t)
deps.Streams.In = strings.NewReader("private-login-token\n")
deps.Getenv = envFunc(map[string]string{"TEA_USER": test.environment})
args := []string{"auth", "login", "--host", server.URL, "--with-token", "--credential-store", "file", "--no-input"}
if test.flag != "" {
args = append(args, "--user", test.flag)
}
err := Execute(context.Background(), deps, args)
if test.accepted {
require.NoError(t, err)
} else {
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.NotContains(t, err.Error(), "private-login-token")
}
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
if test.accepted {
require.Equal(t, "alice", snapshot.Hosts.Hosts[server.URL].User)
} else {
require.Empty(t, snapshot.Hosts.Hosts)
}
})
}
}
func TestLoginInsecureInstance(t *testing.T) {
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.StartTLS()
defer server.Close()
deps, _, _ := testDeps(t)
login := func(flags ...string) error {
deps.Streams.In = strings.NewReader("token\n")
return Execute(context.Background(), deps, append([]string{"auth", "login", "--host", server.URL, "--with-token", "--credential-store", "file", "--no-input"}, flags...))
}
require.Error(t, login())
require.Equal(t, clierr.Usage, clierr.ExitCode(login("--client-id", "custom")))
require.NoError(t, login("--insecure"))
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
host := snapshot.Hosts.Hosts[server.URL]
require.True(t, host.Insecure)
require.Empty(t, host.OAuthClientID)
configureHost(t, deps, server.URL, settings.Host{Insecure: true, OAuthClientID: "custom"})
require.NoError(t, login())
snapshot, err = deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "custom", snapshot.Hosts.Hosts[server.URL].OAuthClientID)
}
func TestAuthSwitchInvalidTargetsAreUsageErrors(t *testing.T) {
for _, host := range []string{"invalid-host", "https://unconfigured.example.org"} {
deps, out, _ := testDeps(t)
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"auth", "switch", "alice", "--host", host})))
require.Empty(t, out.String())
}
deps, out, _ := testDeps(t)
configureHost(t, deps, "https://example.org", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice")})
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"auth", "switch", "bob", "--host", "https://example.org"})))
require.Empty(t, out.String())
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "alice", snapshot.Hosts.Hosts["https://example.org"].User)
configureHost(t, deps, "https://example.org", settings.Host{Users: tokenAccounts(settings.StoreFile, "Alice")})
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "switch", "alice", "--host", "https://example.org"}))
snapshot, err = deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "Alice", snapshot.Hosts.Hosts["https://example.org"].User)
}
func TestLogoutRejectsUnknownAccountBeforeConfirmation(t *testing.T) {
deps, out, diagnostics := testDeps(t)
keyring := &statusKeyring{}
deps.Credentials.Keyring = keyring
configureHost(t, deps, "https://example.org", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreKeyring, "alice")})
err := Execute(context.Background(), deps, []string{"auth", "logout", "--host", "https://example.org", "--user", "bob", "--no-input"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "account is not configured")
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
require.Empty(t, keyring.reads)
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "alice", snapshot.Hosts.Hosts["https://example.org"].User)
}
func TestAuthStatusFiltersBeforeReadingCredentials(t *testing.T) {
deps, out, _ := testDeps(t)
keyring := &statusKeyring{}
deps.Credentials.Keyring = keyring
for _, host := range []string{"https://example.org/base", "https://other.example.org"} {
configureHost(t, deps, host, settings.Host{User: "alice", Users: tokenAccounts(settings.StoreKeyring, "alice", "bob")})
}
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--host", "https://EXAMPLE.org/base/", "--user", "BOB", "--json"}))
require.Equal(t, []string{auth.Identity("https://example.org/base", "bob")}, keyring.reads)
require.JSONEq(t, `[{"host":"https://example.org/base","user":"bob","active":false,"credential_store":"keyring","credential_available":true,"expires_at":null,"verified":null,"error":""}]`, out.String())
out.Reset()
keyring.reads = nil
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--host", "https://unconfigured.example.org", "--json"}))
require.JSONEq(t, `[]`, out.String())
require.Empty(t, keyring.reads)
out.Reset()
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"auth", "status", "--host", "invalid-host"})))
require.Empty(t, out.String())
require.Empty(t, keyring.reads)
}
func TestLogoutSelectsAccountWithoutAuthentication(t *testing.T) {
for _, test := range []struct {
name string
flags []string
environment map[string]string
selected string
}{
{name: "active account", selected: "alice"},
{name: "environment account", environment: map[string]string{"TEA_HOST": "https://example.org", "TEA_USER": "bob", "GITEA_TOKEN": "expired"}, selected: "bob"},
{name: "flag overrides environment", flags: []string{"--host", "https://example.org", "--user", "alice"}, environment: map[string]string{"TEA_HOST": "https://other.example.org", "TEA_USER": "bob"}, selected: "alice"},
} {
t.Run(test.name, func(t *testing.T) {
deps, out, _ := testDeps(t)
keyring := &statusKeyring{}
deps.Credentials.Keyring = keyring
deps.Getenv = envFunc(test.environment)
accounts := tokenAccounts(settings.StoreKeyring, "bob", "charlie")
accounts["alice"] = settings.Account{AuthMethod: settings.AuthOAuth, CredentialStore: settings.StoreKeyring}
configureHost(t, deps, "https://example.org", settings.Host{User: "alice", Users: accounts})
args := append([]string{"auth", "logout", "--yes", "--no-input"}, test.flags...)
require.NoError(t, Execute(context.Background(), deps, args))
require.Empty(t, out.String())
require.Equal(t, []string{auth.Identity("https://example.org", test.selected)}, keyring.reads)
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
host := snapshot.Hosts.Hosts["https://example.org"]
require.NotContains(t, host.Users, test.selected)
require.Len(t, host.Users, 2)
if test.selected == "alice" {
require.Empty(t, host.User)
} else {
require.Equal(t, "alice", host.User)
}
})
}
}
func TestAuthLoginSSHKeyPersistsAndSignsSubsequentRequests(t *testing.T) {
keyPath := writeAppTempSSHKey(t, "")
var requests []http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Header.Clone())
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/v1/user"):
require.NotEmpty(t, r.Header.Get("Signature"))
require.Empty(t, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/v1/repos/alice/project/issues"):
require.NotEmpty(t, r.Header.Get("Signature"))
require.Empty(t, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, _, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{
"auth", "login", "--host", server.URL, "--ssh-key", keyPath, "--no-input", "--yes",
}))
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
account := snapshot.Hosts.Hosts[server.URL].Users["alice"]
require.Equal(t, settings.Account{AuthMethod: "httpsign", SSHKey: keyPath}, account)
require.Empty(t, account.CredentialStore)
require.NoError(t, Execute(context.Background(), deps, []string{
"issue", "list", "-R", "alice/project", "--host", server.URL, "--json",
}))
require.GreaterOrEqual(t, len(requests), 2)
for _, header := range requests {
require.NotEmpty(t, header.Get("Signature"))
require.Empty(t, header.Get("Authorization"))
}
}
func TestAuthLoginSSHKeyCertificateUsesSSHAuthMethod(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "id_ed25519")
_, userKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
_, caKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
userSigner, err := ssh.NewSignerFromKey(userKey)
require.NoError(t, err)
caSigner, err := ssh.NewSignerFromKey(caKey)
require.NoError(t, err)
block, err := ssh.MarshalPrivateKey(userKey, "")
require.NoError(t, err)
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(block), 0o600))
cert := &ssh.Certificate{
Key: userSigner.PublicKey(),
CertType: ssh.UserCert,
ValidPrincipals: []string{"alice"},
ValidAfter: 0,
ValidBefore: ssh.CertTimeInfinity,
}
require.NoError(t, cert.SignCert(rand.Reader, caSigner))
require.NoError(t, os.WriteFile(keyPath+"-cert.pub", ssh.MarshalAuthorizedKey(cert), 0o600))
var method settings.Account
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NotEmpty(t, r.Header.Get("Signature"))
require.NotEmpty(t, r.Header.Get("x-ssh-certificate"))
require.Empty(t, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer server.Close()
deps, _, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{
"auth", "login", "--host", server.URL, "--ssh-key", keyPath, "--no-input", "--yes",
}))
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
method = snapshot.Hosts.Hosts[server.URL].Users["alice"]
require.Equal(t, "ssh", method.AuthMethod)
require.Equal(t, "yes", method.SSHCertPrincipal)
require.Equal(t, keyPath, method.SSHKey)
}
func TestAuthLoginEncryptedSSHKeyPromptsForPassphrase(t *testing.T) {
keyPath := writeAppTempSSHKey(t, "correct horse")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NotEmpty(t, r.Header.Get("Signature"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer server.Close()
deps, _, _ := testDeps(t)
err := Execute(context.Background(), deps, []string{"auth", "login", "--host", server.URL, "--ssh-key", keyPath, "--no-input"})
require.Equal(t, clierr.Authentication, clierr.ExitCode(err))
require.ErrorContains(t, err, "ssh-agent")
prompter := &scriptedPrompter{secret: "correct horse", accept: true}
deps.Prompter = prompter
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "login", "--host", server.URL, "--ssh-key", keyPath}))
require.Equal(t, []string{"Passphrase for " + keyPath}, prompter.secrets)
}
func TestAuthLoginMethodFlagsAreMutuallyExclusive(t *testing.T) {
keyPath := writeAppTempSSHKey(t, "")
deps, out, _ := testDeps(t)
deps.Streams.In = strings.NewReader("token\n")
for _, args := range [][]string{
{"auth", "login", "--host", "https://example.org", "--with-token", "--oauth", "--no-input"},
{"auth", "login", "--host", "https://example.org", "--with-token", "--ssh-key", keyPath, "--no-input"},
{"auth", "login", "--host", "https://example.org", "--oauth", "--ssh-agent-key", "SHA256:abc", "--no-input"},
{"auth", "login", "--host", "https://example.org", "--ssh-key", keyPath, "--ssh-agent-principal", "git", "--no-input"},
{"auth", "login", "--host", "https://example.org", "--ssh-agent-key", "SHA256:abc", "--ssh-agent-principal", "git", "--no-input"},
{"auth", "login", "--host", "https://example.org", "--no-input"},
} {
t.Run(strings.Join(args[3:], " "), func(t *testing.T) {
err := Execute(context.Background(), deps, args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "select exactly one")
require.Empty(t, out.String())
})
}
}
func TestAuthLoginValidatesMethodBeforePromptingForHost(t *testing.T) {
for args, want := range map[string]string{
"auth login": "select exactly one",
"auth login --with-token --client-id x": "--client-id requires --oauth",
} {
t.Run(args, func(t *testing.T) {
deps, out, _ := testDeps(t)
prompt := &scriptedPrompter{input: "https://example.org"}
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
err := Execute(context.Background(), deps, strings.Fields(args))
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, want)
require.Empty(t, prompt.asked)
require.Empty(t, out.String())
})
}
}
// writeAppTempSSHKey writes a new ed25519 private key, encrypted when passphrase is set.
func writeAppTempSSHKey(t *testing.T, passphrase string) string {
t.Helper()
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
var block *pem.Block
if passphrase == "" {
block, err = ssh.MarshalPrivateKey(privateKey, "")
} else {
block, err = ssh.MarshalPrivateKeyWithPassphrase(privateKey, "", []byte(passphrase))
}
require.NoError(t, err)
path := filepath.Join(t.TempDir(), "id_ed25519")
require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600))
return path
}
func TestAuthRefreshExpiredCredentialOnlyOnce(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
require.Equal(t, "/login/oauth/access_token", r.URL.Path)
require.NoError(t, r.ParseForm())
require.Equal(t, "original-refresh", r.Form.Get("refresh_token"))
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintln(w, `{"access_token":"new-access","refresh_token":"rotated-refresh","expires_in":3600,"token_type":"Bearer"}`)
}))
defer server.Close()
deps, out, diagnostics := testDeps(t)
now := time.Now()
deps.Now = func() time.Time { return now }
manager := testManager(t, deps)
require.NoError(t, manager.Register(server.URL, "alice", "file", "oauth", auth.HostOptions{OAuthClientID: "client"}, auth.Credential{AccessToken: "expired", RefreshToken: "original-refresh", ExpiresAt: now.Add(-time.Hour)}))
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "refresh", "--host", server.URL, "--user", "alice", "--no-input"}))
require.Equal(t, 1, requests)
credential, err := deps.Credentials.Load(server.URL, "alice", "file")
require.NoError(t, err)
require.Equal(t, "rotated-refresh", credential.RefreshToken)
require.Equal(t, "new-access", credential.AccessToken)
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
}
func TestAuthRefreshRejectsTokenAccounts(t *testing.T) {
deps, out, _ := testDeps(t)
manager := testManager(t, deps)
require.NoError(t, manager.Register("https://example.org", "alice", "file", "token", auth.HostOptions{}, auth.Credential{AccessToken: "token"}))
err := Execute(context.Background(), deps, []string{"auth", "refresh", "--host", "https://example.org", "--user", "alice", "--no-input"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "only OAuth accounts")
require.Empty(t, out.String())
}
func TestAuthStatusVerifyChecksStoredCredentials(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
require.Equal(t, "/api/v1/user", r.URL.Path)
if r.Header.Get("Authorization") != "token valid-status-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"login":"alice"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
expires := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
require.NoError(t, deps.Credentials.Save(server.URL, "alice", settings.StoreFile, auth.Credential{AccessToken: "valid-status-token", RefreshToken: "refresh", ExpiresAt: expires}))
require.NoError(t, deps.Credentials.Save(server.URL, "bob", settings.StoreFile, auth.Credential{AccessToken: "revoked-status-token"}))
accounts := tokenAccounts(settings.StoreFile, "bob", "carol")
accounts["alice"] = settings.Account{AuthMethod: settings.AuthOAuth, CredentialStore: settings.StoreFile}
configureHost(t, deps, server.URL, settings.Host{User: "alice", Users: accounts})
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--json=user,verified,error"}))
require.Zero(t, requests)
require.JSONEq(t, `[{"user":"alice","verified":null,"error":""},{"user":"bob","verified":null,"error":""},{"user":"carol","verified":null,"error":""}]`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--verify", "--json"}))
require.Equal(t, 2, requests)
var rows []struct {
User string `json:"user"`
ExpiresAt *time.Time `json:"expires_at"`
Verified *bool `json:"verified"`
Error string `json:"error"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &rows))
require.Len(t, rows, 3)
require.Equal(t, "alice", rows[0].User)
require.True(t, rows[0].ExpiresAt.Equal(expires))
require.True(t, *rows[0].Verified)
require.Empty(t, rows[0].Error)
for _, row := range rows[1:] {
require.False(t, *row.Verified, row.User)
require.Nil(t, row.ExpiresAt)
require.NotEmpty(t, row.Error)
}
require.Equal(t, "credential unavailable", rows[2].Error)
require.NotContains(t, out.String(), "status-token")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--verify", "--user", "alice"}))
require.Equal(t, server.URL+"\talice\tactive=true\tcredentials=true\texpires=2030-01-02T03:04:05Z\tverified=true\n", out.String())
}
func TestOAuthLoginOpensAuthorizationURLInBrowser(t *testing.T) {
opened := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/access_token":
require.NoError(t, r.ParseForm())
require.Equal(t, "authorization_code", r.Form.Get("grant_type"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access", "refresh_token": "refresh", "expires_in": 3600, "token_type": "Bearer"})
case "/api/v1/user":
require.Equal(t, "token access", r.Header.Get("Authorization"))
_, _ = w.Write([]byte(`{"login":"alice"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
browser := &channelBrowser{opened: opened}
deps, _, diagnostics := testDeps(t)
deps.Browser = browser
deps.Credentials = &auth.Store{Dir: deps.Settings.Dir, Keyring: &statusKeyring{}}
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
deps.Client = func(instance, token string, insecure bool) *transport.Client {
client := transport.New(instance, token, insecure)
client.HTTP = server.Client()
return client
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
done <- Execute(ctx, deps, []string{"auth", "login", "--host", server.URL, "--oauth", "--client-id", "test-client", "--credential-store", "file", "--yes"})
}()
var resource string
select {
case resource = <-opened:
case <-time.After(3 * time.Second):
t.Fatal("browser was not opened")
}
u, err := url.Parse(resource)
require.NoError(t, err)
require.Equal(t, "/login/oauth/authorize", u.Path)
require.Equal(t, "test-client", u.Query().Get("client_id"))
redirect := u.Query().Get("redirect_uri")
state := u.Query().Get("state")
require.NotEmpty(t, redirect)
require.NotEmpty(t, state)
callback, err := http.Get(redirect + "?code=valid-code&state=" + url.QueryEscape(state))
require.NoError(t, err)
require.Equal(t, http.StatusOK, callback.StatusCode)
_ = callback.Body.Close()
require.NoError(t, <-done)
require.Contains(t, diagnostics.String(), "Open this URL to authorize tea:")
require.Contains(t, diagnostics.String(), resource)
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
normalized, err := settings.NormalizeHost(server.URL)
require.NoError(t, err)
require.Contains(t, snapshot.Hosts.Hosts[normalized].Users, "alice")
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// validateBranch rejects invalid Git refs before making requests.
func validateBranch(name string) error {
if name == "" || name == "@" || strings.HasPrefix(name, "-") || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") || strings.HasSuffix(name, ".") || strings.Contains(name, "..") || strings.Contains(name, "@{") || strings.Contains(name, "//") || strings.ContainsAny(name, " ~^:?*[\\\t\r\n") {
return clierr.Wrap(clierr.Usage, errors.New("invalid branch name"))
}
for part := range strings.SplitSeq(name, "/") {
if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
return clierr.Wrap(clierr.Usage, errors.New("invalid branch name"))
}
}
for _, r := range name {
if r < 32 || r == 127 {
return clierr.Wrap(clierr.Usage, errors.New("invalid branch name"))
}
}
return nil
}
func (a *application) branchCommand() *cobra.Command {
group := &cobra.Command{Use: "branch", Short: "Manage remote branches and protection rules"}
var limit, page int
list := &cobra.Command{Use: "list", Short: "List remote branches", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Branches{Client: target.client}).List(cmd.Context(), target.Repo, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, branchRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"view", "create", "rename", "delete", "protect", "unprotect"} {
var source string
count := 1
use := verb + " <name>"
if verb == "rename" {
count = 2
use = "rename <old-name> <new-name>"
}
arguments := argsExact(count)
if verb == "protect" || verb == "unprotect" {
use = verb + " <name>..."
arguments = usageArgs(cobra.MinimumNArgs(1))
}
command := &cobra.Command{Use: use, Short: titleCase(verb) + " a remote branch", Args: arguments, RunE: func(cmd *cobra.Command, args []string) error {
for _, name := range args {
if err := validateBranch(name); err != nil {
return err
}
}
if source != "" {
if err := validateBranch(source); err != nil {
return err
}
}
if verb == "rename" && args[0] == args[1] {
return clierr.Wrap(clierr.Usage, errors.New("new branch name must differ from the current name"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Branches{Client: target.client}
if verb == "delete" || verb == "unprotect" {
if err := a.confirm(cmd, verb+" branches "+strings.Join(args, ", ")+" in "+target.Repo); err != nil {
return err
}
}
switch verb {
case "view":
item, err := service.View(cmd.Context(), target.Repo, args[0])
if err != nil {
return err
}
return writeItem(a, cmd, item, branchRow)
case "create":
item, err := service.Create(cmd.Context(), target.Repo, args[0], source)
if err != nil {
return err
}
return writeItem(a, cmd, item, branchRow)
case "rename":
return service.Rename(cmd.Context(), target.Repo, args[0], args[1])
case "delete":
return service.Delete(cmd.Context(), target.Repo, args[0])
case "protect", "unprotect":
var failures []error
for _, name := range args {
var err error
if verb == "protect" {
err = service.Protect(cmd.Context(), target.Repo, name)
} else {
err = service.Unprotect(cmd.Context(), target.Repo, name)
}
if err != nil {
failures = append(failures, fmt.Errorf("%s branch %s: %w", verb, name, err))
continue
}
if len(args) > 1 {
if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "%s branch %s\n", verb, name); err != nil {
return err
}
}
}
return errors.Join(failures...)
}
return nil
}}
if verb == "create" {
command.Flags().StringVar(&source, "source", "", "Source branch (default: repository default branch)")
}
if verb != "view" && verb != "create" {
command.Annotations = textOutput("branch " + verb + " does not return a resource")
}
group.AddCommand(command)
}
return group
}
func branchRow(item services.Branch) []terminal.Cell {
return terminal.Row(item.Name, item.SHA, strconv.FormatBool(item.Protected))
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestBranchNames(t *testing.T) {
for _, name := range []string{"main", "feature/next", "release-1.0", "topic.locked"} {
require.NoError(t, validateBranch(name), name)
}
for _, name := range []string{"", "@", "-flag", "/root", "trailing/", "trailing.", "two..dots", "ref@{1}", "a//b", "a/.hidden", "topic.lock", "space here", "bad\x00ref", "bad\x7fref"} {
require.Error(t, validateBranch(name), name)
}
}
func TestBulkBranchProtectionContinuesAfterFailure(t *testing.T) {
var names []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/owner/repo/branch_protections/")
names = append(names, name)
if name == "failed" {
w.WriteHeader(http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"branch", "unprotect", "failed", "succeeded", "-R", "owner/repo", "--no-input"})
require.Error(t, err)
require.Empty(t, names)
err = Execute(context.Background(), deps, []string{"branch", "unprotect", "failed", "succeeded", "-R", "owner/repo", "--yes"})
require.ErrorContains(t, err, "failed")
require.Equal(t, []string{"failed", "succeeded"}, names)
require.Empty(t, out.String())
require.Contains(t, diagnostics.String(), "succeeded")
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import "github.com/spf13/cobra"
func (a *application) openBrowser(command *cobra.Command, resource string) error {
preferences, err := a.preferences()
if err != nil {
return err
}
return a.deps.Browser.Open(command.Context(), resource, preferences.Browser)
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"fmt"
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
type recordingBrowser struct{ resource, command string }
func (b *recordingBrowser) Open(_ context.Context, resource, command string) error {
b.resource, b.command = resource, command
return nil
}
func TestRepositoryWebUsesInjectedBrowserAndConfigPrecedence(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
_, _ = fmt.Fprint(w, `{"full_name":"alice/project","html_url":"https://example.org/alice/project"}`)
}))
defer server.Close()
deps, out, diagnostics := testDeps(t)
browser := &recordingBrowser{}
deps.Browser = browser
deps.Getenv = serverEnv(server.URL, map[string]string{"BROWSER": "environment-browser"})
args := []string{"repo", "view", "--repo", "alice/project", "--web", "--no-input"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "https://example.org/alice/project", browser.resource)
require.Equal(t, "environment-browser", browser.command)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "browser", "configured-browser"}))
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "configured-browser", browser.command)
require.Equal(t, 2, calls)
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, append(args, "--json"))))
require.Equal(t, 2, calls)
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
}
func TestDiscussionViewsShareBrowserContract(t *testing.T) {
for _, group := range []string{"issue", "pr"} {
t.Run(group, func(t *testing.T) {
calls := 0
resource := "https://example.org/alice/project/" + group + "/7"
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
_, _ = fmt.Fprintf(w, `{"number":7,"title":"Discussion","html_url":%q}`, resource)
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
browser := &recordingBrowser{}
deps.Browser = browser
args := []string{group, "view", "7", "--repo", "alice/project", "--web", "--no-input"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, resource, browser.resource)
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, append(args, "--json"))))
require.Equal(t, 1, calls)
})
}
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/spf13/cobra"
)
func (t target) hosts() map[string]settings.Host {
return map[string]settings.Host{t.Host: t.host}
}
// ownsRemote reports whether the Git remote URL raw names the target's instance and repository.
func (t target) ownsRemote(raw string) bool {
host, repo, err := settings.RepositoryURL(raw, t.hosts())
return err == nil && host == t.Host && strings.EqualFold(repo, t.Repo)
}
// remoteCredentials returns Git transport arguments for the URL of the named remote.
func (a *application) remoteCredentials(ctx context.Context, t target, remote string) ([]string, error) {
raw, err := a.git(ctx, "remote", "get-url", remote)
if err != nil {
return nil, err
}
return a.gitTransportArguments(t, settings.RemoteIsSSH(raw))
}
func (a *application) checkoutRemote(ctx context.Context, target target, snapshot settings.Snapshot) (string, error) {
explicit := a.options.remote
if explicit == "" {
explicit = snapshot.Preferences.Remote
}
if explicit != "" {
raw, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", explicit)
if err == nil && target.ownsRemote(raw) {
return explicit, nil
}
return "", errors.New("selected remote does not match the resolved instance and repository")
}
matches, err := a.remotesForRepositoryURLs(ctx, target.hosts(), "", "", target.Repo)
if err != nil {
return "", err
}
preferredRemotes := []string{"upstream", "origin"}
if _, tracking := git.CurrentUpstreamRemote(ctx, a.gitProbe(), a.options.cwd); tracking != "" {
preferredRemotes = append([]string{tracking}, preferredRemotes...)
}
for _, preferred := range preferredRemotes {
if slices.Contains(matches, preferred) {
return preferred, nil
}
}
if len(matches) == 1 {
return matches[0], nil
}
if len(matches) == 0 {
return "", errNoMatchingRemote
}
return "", errors.New("multiple Git remotes match the selected repository; select --remote")
}
var errNoMatchingRemote = errors.New("no Git remote matches the selected repository; add its base remote before checkout")
func (a *application) pullCheckoutCommand() *cobra.Command {
var branch string
var detached, update bool
cmd := &cobra.Command{Use: "checkout <number>", Short: "Check out a pull request in a matching branch or detached HEAD", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
if detached && (cmd.Flags().Changed("branch") || update) {
return clierr.Wrap(clierr.Usage, errors.New("--detach and --branch/--update are mutually exclusive"))
}
if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--branch cannot be empty"))
}
if branch == "" && !detached {
branch = fmt.Sprintf("pr/%d", n)
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
remote, err := a.checkoutRemote(cmd.Context(), target, snapshot)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
pull, err := (services.Pulls{Client: target.client, Repository: target.Repo}).View(cmd.Context(), n)
if err != nil {
return err
}
if pull.HeadSHA == "" {
return errors.New("Gitea did not return the PR head commit")
}
rawRemote, err := a.git(cmd.Context(), "remote", "get-url", remote)
if err != nil {
return err
}
if !target.ownsRemote(rawRemote) {
return clierr.Wrap(clierr.Usage, errors.New("selected Git remote changed; resolve the repository again before checkout"))
}
credentialRemote := rawRemote
options := git.CheckoutOptions{
Detached: detached, Update: update, Directory: a.options.cwd, Remote: remote, Number: n, Branch: branch,
Prompt: a.policy(snapshot).CanPrompt(a.deps.Streams), ExpectedCommit: pull.HeadSHA,
}
undoRemote := func() {}
if !pull.HeadRemoteDeleted && pull.Head != "" && !detached {
headRemote, headURL, undo, ensureErr := a.ensureHeadRemote(cmd.Context(), target, pull)
if ensureErr != nil {
return ensureErr
}
if headRemote != "" {
options.HeadRemote, options.HeadBranch = headRemote, pull.Head
credentialRemote, undoRemote = headURL, undo
}
}
credentialArgs, err := a.gitTransportArguments(target, settings.RemoteIsSSH(credentialRemote))
if err != nil {
undoRemote()
return err
}
options.CredentialArgs = credentialArgs
result, err := git.CheckoutPull(cmd.Context(), a.deps.Git, options)
if err != nil {
undoRemote()
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, result)
}
if result.Detached {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "Checked out %s in detached HEAD\n", result.Commit)
return err
}
if result.Tracking != "" {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Checked out %s (%s) tracking %s\n", result.Branch, result.Commit, result.Tracking)
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Checked out %s (%s)\n", result.Branch, result.Commit)
return err
}}
cmd.Flags().BoolVar(&update, "update", false, "Fast-forward an existing branch to the PR head; reject divergence")
cmd.Flags().BoolVar(&detached, "detach", false, "Check out the PR commit without creating or changing a branch")
cmd.Flags().StringVarP(&branch, "branch", "b", "", "Local branch name (default: pr/NUMBER; existing branch must match PR head)")
return cmd
}
// ensureHeadRemote returns the remote holding the PR head, adding one when none matches.
// An empty name means the head repository has no usable clone URL and the base remote's PR ref should be used.
func (a *application) ensureHeadRemote(ctx context.Context, target target, pull services.Pull) (name, remoteURL string, undo func(), err error) {
matches, err := a.remotesForRepositoryURLs(ctx, target.hosts(), pull.HeadCloneURL, pull.HeadSSHURL, pull.HeadRepo)
if err != nil {
return "", "", nil, err
}
existing := ""
switch {
case a.options.remote != "" && slices.Contains(matches, a.options.remote):
existing = a.options.remote
case len(matches) == 1:
existing = matches[0]
case len(matches) > 1:
for _, preferred := range []string{"origin", "upstream"} {
if slices.Contains(matches, preferred) {
existing = preferred
break
}
}
if existing == "" {
return "", "", nil, clierr.Wrap(clierr.Usage, errors.New("multiple Git remotes match the pull request head repository; select --remote"))
}
}
if existing != "" {
raw, err := a.git(ctx, "remote", "get-url", existing)
return existing, raw, func() {}, err
}
remoteURL, _, urlErr := cloneURLForProtocol(pull.HeadCloneURL, pull.HeadSSHURL, target.gitProtocol)
if urlErr != nil {
return "", "", nil, nil
}
name = "pulls/" + pull.HeadRepo
if pull.HeadRepo == "" {
name = "pulls/head"
}
if _, err := a.git(ctx, "remote", "add", "--", name, remoteURL); err != nil {
return "", "", nil, fmt.Errorf("could not add head remote %s: %w", name, err)
}
// Detached from the command context so an interrupt (Ctrl-C) still removes the half-used remote.
cleanup := context.WithoutCancel(ctx)
return name, remoteURL, func() { _, _ = a.git(cleanup, "remote", "remove", "--", name) }, nil
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestCheckoutRemoteUsesOnlyMatchingRepository(t *testing.T) {
deps, _, _ := testDeps(t)
deps.Git = &fakeGit{results: map[string]string{
"remote": "fork\norigin\nupstream\ntracking", "symbolic-ref --short HEAD": "main", "config --get branch.main.remote": "tracking",
"remote get-url fork": "https://example.org/bob/project.git",
"remote get-url origin": "https://example.org/alice/project.git",
"remote get-url upstream": "https://other.example.org/alice/project.git",
"remote get-url tracking": "https://example.org/alice/project.git",
}}
a := &application{deps: deps}
snapshot := settings.Snapshot{Preferences: settings.Defaults()}
target := target{Host: "https://example.org", Repo: "alice/project"}
remote, err := a.checkoutRemote(context.Background(), target, snapshot)
require.NoError(t, err)
require.Equal(t, "tracking", remote)
a.options.remote = "upstream"
_, err = a.checkoutRemote(context.Background(), target, snapshot)
require.ErrorContains(t, err, "does not match")
a.options.remote = "origin"
remote, err = a.checkoutRemote(context.Background(), target, snapshot)
require.NoError(t, err)
require.Equal(t, "origin", remote)
}
func TestCheckoutSelectedInstanceDisambiguatesSharedSSHRemote(t *testing.T) {
deps, _, _ := testDeps(t)
deps.Git = &fakeGit{results: map[string]string{
"remote": "local\norigin", "symbolic-ref --short HEAD": "main", "config --get branch.main.remote": "origin",
"remote get-url local": "/srv/git/project.git",
"remote get-url origin": "ssh://git@git.example.org:2222/alice/project.git",
}}
a := &application{deps: deps}
snapshot := settings.Snapshot{Preferences: settings.Defaults(), Hosts: settings.Hosts{Hosts: map[string]settings.Host{
"https://example.org/first": {SSHHost: "git.example.org:2222"},
"https://example.org/second": {SSHHost: "git.example.org:2222"},
}}}
remote, err := a.checkoutRemote(context.Background(), target{Host: "https://example.org/second", Repo: "alice/project", host: snapshot.Hosts.Hosts["https://example.org/second"]}, snapshot)
require.NoError(t, err)
require.Equal(t, "origin", remote)
}
func TestCheckoutMatchesRemoteThroughEnvironmentSSHHost(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/pulls/7", r.URL.Path)
_, _ = fmt.Fprint(w, `{"number":7,"head":{"sha":"abc123","ref":"feature","repo":{"full_name":"alice/project","ssh_url":"git@ssh.example.org:alice/project.git"}}}`)
}))
defer server.Close()
deps, out, _ := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"GITEA_INSTANCE_SSH_HOST": "ssh.example.org"})
deps.Git = &fakeGit{results: map[string]string{
"remote": "origin", "remote get-url origin": "git@ssh.example.org:alice/project.git",
"rev-parse --is-inside-work-tree": "true", "check-ref-format --branch pr/7": "",
"check-ref-format --branch feature": "",
"status --porcelain=v1 --untracked-files=normal": "", "for-each-ref --format=%(refname) refs/heads/": "",
"fetch --no-tags -- origin refs/heads/feature": "", "rev-parse --verify FETCH_HEAD^{commit}": "abc123", "switch --create pr/7 abc123": "",
"config branch.pr/7.remote origin": "", "config branch.pr/7.merge refs/heads/feature": "",
}}
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", "7", "--repo", "alice/project", "--remote", "origin", "--no-input", "--json"}))
require.JSONEq(t, `{"branch":"pr/7","commit":"abc123","detached":false,"remote":"origin","tracking":"origin/feature"}`, out.String())
}
func TestSSHCheckoutUsesAccountKeyOnlyForFetch(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/pulls/7", r.URL.Path)
_, _ = fmt.Fprint(w, `{"number":7,"head":{"sha":"abc123","ref":"feature","repo":{"full_name":"alice/project","clone_url":"https://example.org/alice/project.git","ssh_url":"ssh://git@git.example.org:2222/alice/project.git","permissions":{"push":true}}}}`)
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
configureHost(t, deps, server.URL, settings.Host{User: "alice", SSHHost: "git.example.org:2222", Users: map[string]settings.Account{
"alice": {AuthMethod: "token", CredentialStore: "file", SSHKey: "/keys/alice key"},
"bob": {AuthMethod: "token", CredentialStore: "file", SSHKey: "/keys/bob"},
}})
sshArgs, err := git.SSHArguments("/keys/alice key", false)
require.NoError(t, err)
fetch := strings.Join(append(sshArgs, "fetch", "--no-tags", "--", "origin", "refs/heads/feature"), " ")
runner := &fakeGit{results: map[string]string{
"remote": "origin", "remote get-url origin": "ssh://git@git.example.org:2222/alice/project.git",
"rev-parse --is-inside-work-tree": "true", "check-ref-format --branch pr/7": "",
"check-ref-format --branch feature": "",
"status --porcelain=v1 --untracked-files=normal": "", "for-each-ref --format=%(refname) refs/heads/": "",
fetch: "", "rev-parse --verify FETCH_HEAD^{commit}": "abc123", "switch --create pr/7 abc123": "",
"config branch.pr/7.remote origin": "", "config branch.pr/7.merge refs/heads/feature": "",
}}
deps.Git = runner
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", "7", "--repo", "alice/project", "--remote", "origin", "--no-input", "--json"}))
require.Contains(t, runner.calls, fetch)
require.Contains(t, runner.calls, "config branch.pr/7.remote origin")
require.Contains(t, runner.calls, "config branch.pr/7.merge refs/heads/feature")
configured := 0
for _, call := range runner.calls {
if strings.HasPrefix(call, "-c ") {
configured++
require.Equal(t, fetch, call)
}
}
require.Equal(t, 1, configured)
require.False(t, runner.prompt)
require.JSONEq(t, `{"branch":"pr/7","commit":"abc123","detached":false,"remote":"origin","tracking":"origin/feature"}`, out.String())
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullChecksCommand() *cobra.Command {
var watch bool
var interval, timeout time.Duration
cmd := &cobra.Command{Use: "checks <number>", Short: "Show the latest commit-status checks for a pull request", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if interval <= 0 || timeout <= 0 {
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
}
n, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Pulls{Client: target.client, Repository: target.Repo}
var items []services.Check
if watch {
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
items, err = service.WatchChecks(ctx, n, interval, func(items []services.Check) error {
pending := 0
for _, item := range items {
if item.Pending() {
pending++
}
}
_, err := fmt.Fprintf(cmd.ErrOrStderr(), "checks: %d total, %d pending\n", len(items), pending)
return err
})
} else {
items, err = service.Checks(cmd.Context(), n)
}
if err != nil {
return err
}
if err := writeList(a, cmd, items, func(item services.Check) []terminal.Cell { return terminal.Row(item.Name, item.State, item.URL) }); err != nil {
return err
}
for _, item := range items {
if !item.Passed() {
return errors.New("pull request checks have not all succeeded")
}
}
return nil
}}
cmd.Flags().BoolVar(&watch, "watch", false, "Wait for checks to finish, failing immediately on unsuccessful status")
cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Check polling interval")
cmd.Flags().DurationVar(&timeout, "timeout", time.Hour, "Maximum wait duration")
return cmd
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestChecksUseLatestContextAcrossPagesAndReturnFailureWithCleanJSON(t *testing.T) {
state := "success"
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
if r.URL.Path == "/api/v1/repos/owner/repo/pulls/7" {
_, _ = w.Write([]byte(`{"head":{"sha":"abc123"}}`))
return
}
require.Equal(t, "/api/v1/repos/owner/repo/statuses/abc123", r.URL.Path)
switch r.URL.Query().Get("page") {
case "1":
_, _ = w.Write([]byte(`[{"id":1,"context":"build","status":"failure"}]`))
case "2":
_, _ = w.Write([]byte(`[{"id":2,"context":"build","status":"` + state + `","creator":{"login":"excluded"}}]`))
default:
_, _ = w.Write([]byte(`[]`))
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
args := []string{"pr", "checks", "7", "-R", "owner/repo", "--json=id,name,state"}
require.NoError(t, Execute(context.Background(), deps, args))
require.JSONEq(t, `[{"id":2,"name":"build","state":"success"}]`, out.String())
out.Reset()
state = "pending"
err := Execute(context.Background(), deps, args)
require.ErrorContains(t, err, "not all succeeded")
require.JSONEq(t, `[{"id":2,"name":"build","state":"pending"}]`, out.String())
out.Reset()
state = "skipped"
require.NoError(t, Execute(context.Background(), deps, args))
require.Empty(t, diagnostics.String())
}
func TestChecksWatchWaitsForRegistrationAndEmitsOneJSONResult(t *testing.T) {
polls := 0
keepPending := false
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/repos/owner/repo/pulls/7" {
polls++
_, _ = w.Write([]byte(`{"head":{"sha":"abc123"}}`))
return
}
if r.URL.Query().Get("page") != "1" || polls == 1 {
_, _ = w.Write([]byte(`[]`))
return
}
state := "pending"
if polls >= 3 && !keepPending {
state = "success"
}
_, _ = w.Write([]byte(`[{"id":2,"context":"build","status":"` + state + `"}]`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checks", "7", "-R", "owner/repo", "--watch", "--interval=1ms", "--timeout=1s", "--json=name,state"}))
require.Equal(t, 4, polls)
require.JSONEq(t, `[{"name":"build","state":"success"}]`, out.String())
require.Contains(t, diagnostics.String(), "1 pending")
keepPending = true
out.Reset()
diagnostics.Reset()
err := Execute(context.Background(), deps, []string{"pr", "checks", "7", "-R", "owner/repo", "--watch", "--interval=1ms", "--timeout=20ms", "--json"})
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
require.Empty(t, out.String())
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = Execute(ctx, deps, []string{"pr", "checks", "7", "-R", "owner/repo", "--watch"})
require.ErrorIs(t, err, context.Canceled)
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestCloneValidatesRemoteBeforeGitAndReturnsJSON(t *testing.T) {
remote := ""
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project", r.URL.Path)
_, _ = fmt.Fprintf(w, `{"id":7,"full_name":"alice/project","clone_url":%q}`, remote)
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
prefix := credentialCall(t, deps, server.URL, "alice") + " "
runner := &fakeGit{results: map[string]string{prefix + "clone -- " + server.URL + "/alice/project.git destination": ""}}
deps.Git = runner
args := []string{"repo", "clone", "alice/project", "destination", "--no-input", "--json=id,full_name"}
parsed, err := url.Parse(server.URL)
require.NoError(t, err)
for _, invalid := range []string{"ssh://git@" + parsed.Hostname() + "/alice/project.git", "https://unrelated.example.org/alice/project.git", server.URL + "/bob/project.git", server.URL + "/alice/different.git"} {
remote = invalid
require.ErrorContains(t, Execute(context.Background(), deps, args), "clone URL does not match")
require.Empty(t, runner.calls)
require.Empty(t, out.String())
}
remote = server.URL + "/alice/project.git"
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []string{prefix + "clone -- " + remote + " destination"}, runner.calls)
require.False(t, runner.prompt)
require.JSONEq(t, `{"id":7,"full_name":"alice/project"}`, out.String())
require.Empty(t, diagnostics.String())
out.Reset()
runner.calls = nil
runner.results[prefix+"clone --depth 2 -- "+remote+" destination"] = ""
require.NoError(t, Execute(context.Background(), deps, append(args, "--depth", "2")))
require.Equal(t, []string{prefix + "clone --depth 2 -- " + remote + " destination"}, runner.calls)
runner.calls = nil
require.ErrorContains(t, Execute(context.Background(), deps, append(args, "--depth", "-1")), "--depth must be nonnegative")
require.Empty(t, runner.calls)
}
func TestSSHCloneUsesSelectedAccountKeyWithoutPersistingIt(t *testing.T) {
remote := ""
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintf(w, `{"id":7,"full_name":"alice/project","ssh_url":%q}`, remote)
}))
defer server.Close()
parsed, err := url.Parse(server.URL)
require.NoError(t, err)
remote = "git@" + parsed.Hostname() + ":alice/project.git"
deps, _, _ := serverDeps(t, server.URL)
configureHost(t, deps, server.URL, settings.Host{User: "alice", GitProtocol: "ssh", Users: map[string]settings.Account{
"alice": {AuthMethod: "token", CredentialStore: "file", SSHKey: "/keys/alice key"},
"bob": {AuthMethod: "token", CredentialStore: "file", SSHKey: "/keys/bob"},
}})
sshArgs, err := git.SSHArguments("/keys/alice key", false)
require.NoError(t, err)
call := strings.Join(append(sshArgs, "clone", "--", remote, "destination"), " ")
runner := &fakeGit{results: map[string]string{call: ""}}
deps.Git = runner
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", "alice/project", "destination", "--no-input"}))
require.Equal(t, []string{call}, runner.calls)
require.False(t, runner.prompt)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "ssh_agent", "true", "--host", server.URL, "--user", "alice"}))
runner.calls = nil
call = "clone -- " + remote + " destination"
runner.results[call] = ""
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", "alice/project", "destination", "--no-input"}))
require.Equal(t, []string{call}, runner.calls)
}
func TestCloneAddsUpstreamRemoteForForks(t *testing.T) {
remote, parent := "", ""
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/bob/project", r.URL.Path)
_, _ = fmt.Fprintf(w, `{"id":8,"name":"project","full_name":"bob/project","fork":true,"clone_url":%q,"parent":{"id":7,"full_name":"alice/project","default_branch":"main","clone_url":%q}}`, remote, parent)
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
prefix := credentialCall(t, deps, server.URL, "alice") + " "
remote = server.URL + "/bob/project.git"
parent = server.URL + "/alice/project.git"
runner := &fakeGit{results: map[string]string{
prefix + "clone -- " + remote + " destination": "",
"remote add -- upstream " + parent: "",
"config branch.main.remote upstream": "",
"config branch.main.merge refs/heads/main": "",
}}
deps.Git = runner
cwd := t.TempDir()
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", "bob/project", "destination", "--cwd", cwd, "--no-input", "--json=id,full_name,fork"}))
require.Equal(t, []string{
prefix + "clone -- " + remote + " destination",
"remote add -- upstream " + parent,
"config branch.main.remote upstream",
"config branch.main.merge refs/heads/main",
}, runner.calls)
clone := filepath.Join(cwd, "destination")
require.Equal(t, []string{cwd, clone, clone, clone}, runner.dirs)
require.JSONEq(t, `{"id":8,"full_name":"bob/project","fork":true}`, out.String())
}
func TestSSHCloneTrustsAPIEndpointUnlessSSHHostIsConfigured(t *testing.T) {
remote := "ssh://git@ssh.example.test:2222/alice/project.git"
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprintf(w, `{"id":7,"full_name":"alice/project","ssh_url":%q}`, remote)
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
configure := func(sshHost string) {
configureHost(t, deps, server.URL, settings.Host{User: "alice", GitProtocol: "ssh", SSHHost: sshHost, Users: tokenAccounts(settings.StoreFile, "alice")})
}
sshArgs, err := git.SSHArguments("", false)
require.NoError(t, err)
call := strings.Join(append(sshArgs, "clone", "--", remote, "destination"), " ")
runner := &fakeGit{results: map[string]string{call: ""}}
deps.Git = runner
args := []string{"repo", "clone", "alice/project", "destination", "--no-input"}
configure("")
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []string{call}, runner.calls)
runner.calls = nil
configure("git.example.test")
require.ErrorContains(t, Execute(context.Background(), deps, args), "clone URL does not match")
require.Empty(t, runner.calls)
configure("ssh.example.test:2222")
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []string{call}, runner.calls)
runner.calls = nil
remote = "ssh://git@ssh.example.test:2222/alice/other.git"
configure("")
require.ErrorContains(t, Execute(context.Background(), deps, args), "clone URL does not match")
require.Empty(t, runner.calls)
}
func TestCloneAddsSourceRemoteForMirrors(t *testing.T) {
original := ""
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"id":9,"name":"project","full_name":"alice/project","mirror":true,"clone_url":"http://%s/alice/project.git","original_url":%q}`, r.Host, original)
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
clone := credentialCall(t, deps, server.URL, "alice", "clone", "--", server.URL+"/alice/project.git", "destination")
original = "https://github.com/upstream/project.git"
runner := &fakeGit{results: map[string]string{clone: "", "remote add -- source " + original: ""}}
deps.Git = runner
args := []string{"repo", "clone", "alice/project", "destination", "--no-input", "--json"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, []string{clone, "remote add -- source " + original}, runner.calls)
require.NotContains(t, out.String(), "github.com")
for _, unsafe := range []string{"https://user:secret@github.com/upstream/project.git", "file:///srv/project.git", "ext::sh -c touch% /tmp/pwned"} {
original, runner.calls = unsafe, nil
require.NoError(t, Execute(context.Background(), deps, args), unsafe)
require.Equal(t, []string{clone}, runner.calls, unsafe)
}
}
+140
View File
@@ -0,0 +1,140 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func commentRecord(item services.Comment) terminal.Record {
return terminal.Record{Heading: terminal.Row(strconv.FormatInt(item.ID, 10), item.Author), Lines: []string{item.Body, item.URL}}
}
func (a *application) commentCommand() *cobra.Command {
group := &cobra.Command{Use: "comment", Short: "Manage discussion comments"}
var limit, page int
list := &cobra.Command{Use: "list <number>", Short: "List comments on an issue or pull request", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Comments{Client: target.client, Repository: target.Repo}).Page(cmd.Context(), n, limit, page)
if err != nil {
return err
}
return writeRecords(a, cmd, items, commentRecord)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "delete"} {
var body, file string
var useEditor bool
noun := "id"
if verb == "create" {
noun = "number"
}
command := &cobra.Command{Use: verb + " <" + noun + ">", Short: titleCase(verb) + " a discussion comment", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
id, err := number(args[0])
if err != nil {
return err
}
prompted := false
if (verb == "create" || verb == "edit") && !useEditor {
body, err = a.readBody(cmd, body, file)
if err != nil {
return err
}
if !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") {
prompted, err = a.require(cmd, &body, "body")
if err != nil {
return err
}
}
if strings.TrimSpace(body) == "" {
return clierr.Wrap(clierr.Usage, errors.New("comment body cannot be empty"))
}
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Comments{Client: target.client, Repository: target.Repo}
if verb == "create" || verb == "edit" {
if useEditor {
initial := ""
if verb == "edit" {
current, viewErr := service.View(cmd.Context(), id)
if viewErr != nil {
return viewErr
}
initial = current.Body
}
draft, err = a.editDraft(cmd, initial)
if err != nil {
return err
}
body = draft.Body
}
if strings.TrimSpace(body) == "" {
return clierr.Wrap(clierr.Usage, errors.New("comment body cannot be empty"))
}
}
if verb == "delete" || prompted {
description := fmt.Sprintf("%s comment %d on %s", titleCase(verb), id, target.Repo)
if verb == "create" {
description = fmt.Sprintf("Comment on %s#%d", target.Repo, id)
}
if err := a.confirm(cmd, description); err != nil {
return err
}
}
var item services.Comment
switch verb {
case "view":
item, err = service.View(cmd.Context(), id)
case "create":
item, err = service.Create(cmd.Context(), id, body)
case "edit":
item, err = service.Edit(cmd.Context(), id, body)
case "delete":
return service.Delete(cmd.Context(), id)
}
if err != nil {
return err
}
return writeRecord(a, cmd, item, commentRecord)
}}
if verb == "create" || verb == "edit" {
command.Flags().StringVar(&body, "body", "", "Comment body")
command.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
editHelp := "Compose the comment in the configured editor"
if verb == "edit" {
editHelp = "Edit the current comment body in the configured editor"
}
command.Flags().BoolVar(&useEditor, "editor", false, editHelp)
}
if verb == "delete" {
command.Annotations = textOutput("comment deletion does not return a resource")
}
group.AddCommand(command)
}
return group
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestCommentConfirmationNamesTheTarget(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "POST /api/v1/repos/alice/project/issues/3/comments":
_, _ = w.Write([]byte(`{"id":12,"body":"Prompted"}`))
case "DELETE /api/v1/repos/alice/project/issues/comments/12":
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
prompt := &scriptedPrompter{accept: true, input: "Prompted"}
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "create", "3", "-R", "alice/project"}))
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "delete", "12", "-R", "alice/project"}))
require.Equal(t, []string{"Comment on alice/project#3", "Delete comment 12 on alice/project"}, prompt.confirmations)
}
+353
View File
@@ -0,0 +1,353 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"gitea.dev/tea/internal/aliases"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings.Preferences, map[string]string, error) {
preferences := snapshot.Preferences
sources := map[string]string{}
for key, configured := range snapshot.Configured {
if configured {
sources[key] = "config.yml"
}
}
host, source := preferences.DefaultHost, sources["default_host"]
if a.options.host != "" {
host, source = a.options.host, "--host"
} else {
for _, key := range []string{"TEA_HOST", "GITEA_INSTANCE_URL"} {
if value := a.deps.Getenv(key); value != "" {
host, source = value, key
break
}
}
}
if host == "" && len(snapshot.Hosts.Hosts) == 1 {
for name := range snapshot.Hosts.Hosts {
host, source = name, "sole configured instance"
}
}
if host != "" {
normalized, err := settings.NormalizeHost(host)
if err != nil {
return preferences, nil, clierr.Wrap(clierr.Usage, err)
}
preferences.DefaultHost = normalized
sources["default_host"] = source
if protocol := snapshot.Hosts.Hosts[normalized].GitProtocol; protocol != "" {
preferences.GitProtocol = protocol
sources["git_protocol"] = "hosts.yml " + normalized
}
}
if a.options.remote != "" {
preferences.Remote = a.options.remote
sources["remote"] = "--remote"
}
if value := strings.TrimSpace(a.deps.Getenv("TEA_PAGER")); value != "" {
preferences.Pager = value
sources["pager"] = "TEA_PAGER"
} else if value := strings.TrimSpace(a.deps.Getenv("PAGER")); value != "" && sources["pager"] == "" {
preferences.Pager = value
sources["pager"] = "PAGER"
}
if value := strings.TrimSpace(a.deps.Getenv("TEA_COLOR")); value != "" {
preferences.Color = value
sources["color"] = "TEA_COLOR"
}
if value := a.deps.Getenv("TEA_EDITOR"); value != "" {
preferences.Editor = value
sources["editor"] = "TEA_EDITOR"
} else if value := a.deps.Getenv("VISUAL"); value != "" && sources["editor"] == "" {
preferences.Editor = value
sources["editor"] = "VISUAL"
} else if value := a.deps.Getenv("EDITOR"); value != "" && sources["editor"] == "" {
preferences.Editor = value
sources["editor"] = "EDITOR"
}
if value := a.deps.Getenv("BROWSER"); value != "" && sources["browser"] == "" {
preferences.Browser = value
sources["browser"] = "BROWSER"
}
return preferences, sources, nil
}
func (a *application) configCommand() *cobra.Command {
group := &cobra.Command{Use: "config", Short: "Manage preferences", Long: `Manage preferences.
Settings live in config.yml and hosts.yml in the configuration directory
($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both.
config.yml preferences (tea config set <key> <value>):
default_host instance URL used when no --host, environment or repository selects one
git_protocol https or ssh for clone and checkout remotes
editor command used to edit bodies
prompt enabled or disabled
pager command used to page human output
browser command used to open web pages; never disables opening
color auto, always or never
remote Git remote used for repository discovery
aliases command aliases; manage them with tea alias
hosts.yml per-instance settings (tea config set --host <url> <key> <value>):
user active account, changed only by tea auth switch
ssh_host SSH host[:port] when it differs from the web host
insecure true skips TLS certificate verification
git_protocol https or ssh, overriding config.yml for this instance
oauth_client_id OAuth application used by tea auth login
Per-account SSH settings (tea config set --host <url> --user <name> <key> <value>):
ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint
Environment overrides:
TEA_CONFIG_DIR configuration directory
TEA_HOST, GITEA_INSTANCE_URL instance, overriding default_host
GITEA_TOKEN access token for that instance instead of stored credentials
GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true
GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes
TEA_USER account on the selected instance
TEA_REPO repository as owner/name or URL
TEA_EDITOR editor, overriding config.yml
TEA_PAGER pager, overriding config.yml
TEA_COLOR color preference; NO_COLOR disables automatic color
VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none`}
group.AddCommand(&cobra.Command{Use: "path", Short: "Show configuration file paths", Annotations: textOutput("config path prints text"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
_, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml"))
return err
}})
for _, name := range []string{"get", "list"} {
var source bool
command := &cobra.Command{Use: name, Short: "Show effective preferences"}
command.Flags().BoolVar(&source, "show-source", false, "Show the source of each effective value")
if name == "get" {
command.Use = "get <key>"
command.Args = argsExact(1)
} else {
command.Args = argsExact(0)
}
command.RunE = func(cmd *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
preferences, sources, err := a.effectivePreferences(snapshot)
if err != nil {
return err
}
if _, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.user != "" && !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color,
"remote": preferences.Remote,
}
if host, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.host != "" && exists {
for key, value := range map[string]any{"ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
}
}
}
if len(args) > 0 {
value, ok := values[args[0]]
if !ok {
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown preference %q", args[0]))
}
values = map[string]any{args[0]: value}
}
if a.options.json != "" {
if source {
results := map[string]any{}
for key, value := range values {
origin := sources[key]
if origin == "" {
origin = "default"
}
results[key] = struct {
Value any `json:"value"`
Source string `json:"source"`
}{value, origin}
}
return a.writeJSON(cmd, results)
}
return a.writeJSON(cmd, values)
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
suffix := ""
if source {
origin := "default"
if sources[key] != "" {
origin = sources[key]
}
suffix = "\t" + origin
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%v%s\n", key, values[key], suffix); err != nil {
return err
}
}
return nil
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "set <key> <value>", Short: "Set a preference, or an instance setting with --host", Annotations: textOutput("config set does not return a resource"), Args: argsExact(2), RunE: func(_ *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
if a.options.host != "" {
instance, err := settings.NormalizeHost(a.options.host)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
host, exists := s.Hosts.Hosts[instance]
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
switch args[0] {
case "ssh_key":
account.SSHKey = args[1]
case "ssh_agent":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
}
account.SSHAgent = args[1] == "true"
case "ssh_certificate_principal":
account.SSHCertPrincipal = args[1]
case "ssh_key_fingerprint":
account.SSHKeyFingerprint = args[1]
default:
return clierr.Wrap(clierr.Usage, errors.New("supported account settings: ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint"))
}
host.Users[user] = account
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
}
switch args[0] {
case "git_protocol":
host.GitProtocol = args[1]
case "ssh_host":
host.SSHHost = args[1]
case "insecure":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
}
host.Insecure = args[1] == "true"
case "oauth_client_id":
host.OAuthClientID = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("preference %q cannot be set per instance; supported keys: git_protocol, ssh_host, insecure, oauth_client_id", args[0]))
}
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
p := &s.Preferences
switch args[0] {
case "default_host":
if args[1] == "" {
p.DefaultHost = ""
break
}
normalized, err := settings.NormalizeHost(args[1])
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
p.DefaultHost = normalized
case "git_protocol":
p.GitProtocol = args[1]
case "editor":
p.Editor = args[1]
case "prompt":
p.Prompt = args[1]
case "pager":
p.Pager = args[1]
case "browser":
p.Browser = args[1]
case "color":
p.Color = args[1]
case "remote":
p.Remote = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown or immutable preference %q", args[0]))
}
return clierr.Wrap(clierr.Usage, s.Validate())
})
}})
return group
}
func (a *application) aliasCommand() *cobra.Command {
group := &cobra.Command{Use: "alias", Short: "Manage shortcuts to built-in commands"}
group.AddCommand(&cobra.Command{Use: "set <name> <expansion>", Short: "Define a command alias", Annotations: textOutput("alias set does not return a resource"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
s.Preferences.Aliases[args[0]] = args[1]
return clierr.Wrap(clierr.Usage, aliases.Check(args[0], s.Preferences.Aliases, builtins(cmd.Root())))
})
}})
group.AddCommand(&cobra.Command{Use: "delete <name>", Short: "Remove a command alias", Annotations: textOutput("alias deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
if _, ok := s.Preferences.Aliases[args[0]]; !ok {
return clierr.Wrap(clierr.Usage, errors.New("alias does not exist"))
}
delete(s.Preferences.Aliases, args[0])
return nil
})
}})
group.AddCommand(&cobra.Command{Use: "list", Short: "List command aliases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, s.Preferences.Aliases)
}
names := make([]string, 0, len(s.Preferences.Aliases))
for name := range s.Preferences.Aliases {
names = append(names, name)
}
sort.Strings(names)
return a.writeHuman(cmd, table(names, func(name string) []terminal.Cell { return terminal.Row(name, s.Preferences.Aliases[name]) }, a.colorEnabled()))
}})
return group
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestConfigReportsEffectivePreferenceSourcesWithoutAuthentication(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { s.Preferences.DefaultHost = "https://stored.example.org"; return nil }))
configureHost(t, deps, "https://env.example.org/base", settings.Host{GitProtocol: "ssh"})
deps.Getenv = envFunc(map[string]string{"TEA_HOST": "https://ENV.example.org/base/"})
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "default_host", "--show-source", "--json"}))
require.JSONEq(t, `{"default_host":{"value":"https://env.example.org/base","source":"TEA_HOST"}}`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "git_protocol", "--show-source", "--json"}))
require.JSONEq(t, `{"git_protocol":{"value":"ssh","source":"hosts.yml https://env.example.org/base"}}`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "default_host", "--host", "https://flag.example.org", "--show-source", "--json"}))
require.JSONEq(t, `{"default_host":{"value":"https://flag.example.org","source":"--host"}}`, out.String())
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "https://stored.example.org", snapshot.Preferences.DefaultHost)
require.Equal(t, "https", snapshot.Preferences.GitProtocol)
}
func TestInstanceConfigUpdatesPreserveAccountsAndGlobalPreferences(t *testing.T) {
deps, out, _ := testDeps(t)
instance := "https://example.org:8443/base"
configureHost(t, deps, instance, settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice")})
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "editor", "vim"}))
global, err := os.ReadFile(filepath.Join(deps.Settings.Dir, "config.yml"))
require.NoError(t, err)
for _, value := range [][2]string{{"git_protocol", "ssh"}, {"ssh_host", "git.example.org:2222"}, {"insecure", "true"}, {"oauth_client_id", "tea-client"}} {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", value[0], value[1], "--host", instance + "/"}))
}
current, err := os.ReadFile(filepath.Join(deps.Settings.Dir, "config.yml"))
require.NoError(t, err)
require.Equal(t, global, current)
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "alice", snapshot.Hosts.Hosts[instance].User)
require.Len(t, snapshot.Hosts.Hosts[instance].Users, 1)
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "ssh_host", "--host", instance, "--json", "--show-source"}))
require.JSONEq(t, `{"ssh_host":{"value":"git.example.org:2222","source":"hosts.yml https://example.org:8443/base"}}`, out.String())
before, err := os.ReadFile(filepath.Join(deps.Settings.Dir, "hosts.yml"))
require.NoError(t, err)
for _, value := range [][2]string{{"prompt", "disabled"}, {"git_protocol", "invalid"}, {"insecure", "yes"}, {"ssh_host", "bad/path"}, {"user", "other"}, {"oauth_token", "secret"}} {
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"config", "set", value[0], value[1], "--host", instance})))
}
after, err := os.ReadFile(filepath.Join(deps.Settings.Dir, "hosts.yml"))
require.NoError(t, err)
require.Equal(t, before, after)
}
func TestAccountConfigOnlyChangesSelectedAccountMetadata(t *testing.T) {
deps, out, _ := testDeps(t)
instance := "https://example.org/base"
configureHost(t, deps, instance, settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice", "bob")})
for _, setting := range [][2]string{{"ssh_key", "/keys/my key"}, {"ssh_agent", "true"}, {"ssh_certificate_principal", "git"}, {"ssh_key_fingerprint", "SHA256:abcd"}} {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", setting[0], setting[1], "--host", instance, "--user", "bob"}))
}
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
host := snapshot.Hosts.Hosts[instance]
require.Equal(t, "alice", host.User)
require.Equal(t, tokenAccounts(settings.StoreFile, "alice")["alice"], host.Users["alice"])
require.Equal(t, settings.Account{AuthMethod: "token", CredentialStore: "file", SSHKey: "/keys/my key", SSHAgent: true, SSHCertPrincipal: "git", SSHKeyFingerprint: "SHA256:abcd"}, host.Users["bob"])
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "ssh_agent", "--host", instance, "--user", "bob", "--show-source", "--json"}))
require.JSONEq(t, `{"ssh_agent":{"value":true,"source":"hosts.yml https://example.org/base user bob"}}`, out.String())
before, err := os.ReadFile(filepath.Join(deps.Settings.Dir, "hosts.yml"))
require.NoError(t, err)
for _, setting := range [][2]string{{"ssh_agent", "yes"}, {"ssh_key", "bad\npath"}, {"ssh_certificate_principal", "bad\x00principal"}, {"ssh_key_fingerprint", "bad\nprint"}, {"credential_store", "keyring"}, {"auth_method", "oauth"}, {"git_protocol", "ssh"}} {
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"config", "set", setting[0], setting[1], "--host", instance, "--user", "bob"})))
}
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"config", "set", "ssh_agent", "false", "--user", "bob"})))
after, err := os.ReadFile(filepath.Join(deps.Settings.Dir, "hosts.yml"))
require.NoError(t, err)
require.Equal(t, before, after)
}
func TestConfigHelpDocumentsSettingsAndEnvironment(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "--help"}))
for _, name := range []string{
"default_host", "git_protocol", "editor", "prompt", "pager", "browser", "color", "remote", "aliases",
"user", "ssh_host", "insecure", "oauth_client_id", "ssh_key", "ssh_agent", "ssh_certificate_principal", "ssh_key_fingerprint",
"TEA_CONFIG_DIR", "TEA_HOST", "GITEA_INSTANCE_URL", "GITEA_TOKEN", "TEA_USER", "TEA_REPO", "TEA_EDITOR", "VISUAL", "EDITOR",
"TEA_PAGER", "PAGER", "TEA_COLOR", "NO_COLOR", "BROWSER",
} {
require.Contains(t, out.String(), name)
}
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"strconv"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/araddon/dateparse"
)
// parseDeadline parses a --deadline value; an empty value requests clearing.
// Supplied timezone offsets are kept and Unix timestamps are UTC.
func parseDeadline(value string) (*time.Time, *bool, error) {
if value == "" {
return nil, new(true), nil
}
date, err := dateparse.ParseAny(value)
if _, numericError := strconv.ParseInt(value, 10, 64); numericError == nil {
date = date.UTC()
}
if err != nil || date.IsZero() || date.Unix() == 0 || date.Year() < 1 || date.Year() > 9999 {
return nil, nil, clierr.Wrap(clierr.Usage, errors.New("invalid --deadline; use YYYY-MM-DD, RFC3339, a Unix timestamp, or an empty value to clear"))
}
return &date, nil, nil
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestDeadlineInputAndExplicitClearing(t *testing.T) {
for _, family := range []string{"issue", "pr"} {
for _, tc := range []struct{ value, payload string }{
{"2028-01-15", `{"due_date":"2028-01-15T00:00:00Z"}`},
{"2028-01-15T00:30:00+02:00", `{"due_date":"2028-01-15T00:30:00+02:00"}`},
{"01/15/2028", `{"due_date":"2028-01-15T00:00:00Z"}`},
{"1704067200000", `{"due_date":"2024-01-01T00:00:00Z"}`},
{"1704067200", `{"due_date":"2024-01-01T00:00:00Z"}`},
{"", `{"unset_due_date":true}`},
} {
t.Run(family+"/"+tc.value, func(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPatch, r.Method)
collection := "issues"
if family == "pr" {
collection = "pulls"
}
require.Equal(t, "/api/v1/repos/owner/repo/"+collection+"/1", r.URL.Path)
requirePayload(t, tc.payload, r.Body)
_, _ = w.Write([]byte(`{"number":1,"due_date":null}`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{family, "edit", "1", "-R", "owner/repo", "--deadline=" + tc.value, "--json=number,due_date"}))
require.JSONEq(t, `{"number":1,"due_date":null}`, out.String())
})
}
}
}
func TestInvalidDeadlineBeforeInputCollectionOrNetwork(t *testing.T) {
for _, value := range []string{"bad", "2026-02-30", "0", "not-a-timestamp", "999999999999999999999999999999999999"} {
for _, command := range [][]string{{"issue", "edit", "1"}, {"pr", "edit", "1", "--add-reviewers=alice"}, {"issue", "create"}, {"pr", "create"}} {
requireUsageBeforeRequests(t, "invalid --deadline", append(command, "--deadline="+value)...)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"fmt"
"io"
"os"
"strings"
unidiff "gitea.com/noerw/unidiff-comments"
"gitea.dev/tea/internal/terminal"
)
const diffReviewHelp = `# This is the current diff of PR #%d on %s.
# To add code comments, just insert a line inside the diff with your comment,
# prefixed with '# '. For example:
#
# - foo: string,
# - bar: string,
# + foo: int,
# # This is a code comment
# + bar: int,
`
// saveDiffReview writes a temporary annotated-diff file for interactive review.
func saveDiffReview(dir string, n int64, repo string, diff io.Reader) (string, error) {
file, err := os.CreateTemp(dir, fmt.Sprintf("pull-%d-review-*.diff", n))
if err != nil {
return "", err
}
path := file.Name()
if _, err := fmt.Fprintf(file, diffReviewHelp, n, repo); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", err
}
if _, err := io.Copy(file, diff); err != nil {
_ = file.Close()
_ = os.Remove(path)
return "", err
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}
// parseDiffComments extracts inline review comments from an edited unified diff.
func parseDiffComments(r io.Reader) ([]terminal.ReviewCommentDraft, error) {
changeset, err := unidiff.ReadChangeset(r)
if err != nil {
return nil, fmt.Errorf("couldn't parse patch: %w", err)
}
comments := make([]terminal.ReviewCommentDraft, 0)
for _, file := range changeset.Diffs {
for _, c := range file.LineComments {
comment := terminal.ReviewCommentDraft{Body: c.Text, Path: c.Anchor.Path}
comment.Path = strings.TrimPrefix(comment.Path, "a/")
comment.Path = strings.TrimPrefix(comment.Path, "b/")
switch c.Anchor.LineType {
case "ADDED":
comment.NewLine = c.Anchor.Line
case "REMOVED", "CONTEXT":
comment.OldLine = c.Anchor.Line
}
comments = append(comments, comment)
}
}
return comments, nil
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestParseDiffCommentsMapsAddedAndRemovedLines(t *testing.T) {
diff := `diff --git a/sample.go b/sample.go
--- a/sample.go
+++ b/sample.go
@@ -1,4 +1,5 @@
package sample
-func old() {}
# Prefer a clearer name
+func renamed() {}
context
# Keep context notes on the left
`
comments, err := parseDiffComments(strings.NewReader(diff))
require.NoError(t, err)
require.Len(t, comments, 2)
require.Equal(t, "sample.go", comments[0].Path)
require.Equal(t, "Prefer a clearer name", comments[0].Body)
require.EqualValues(t, 2, comments[0].OldLine)
require.Zero(t, comments[0].NewLine)
require.Equal(t, "sample.go", comments[1].Path)
require.Equal(t, "Keep context notes on the left", comments[1].Body)
require.EqualValues(t, 3, comments[1].OldLine)
require.Zero(t, comments[1].NewLine)
}
func TestSaveDiffReviewIncludesHelpHeader(t *testing.T) {
path, err := saveDiffReview(t.TempDir(), 7, "owner/repo", strings.NewReader("diff --git a/x b/x\n"))
require.NoError(t, err)
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "PR #7 on owner/repo")
require.Contains(t, string(data), "diff --git a/x b/x")
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) discussionSearchCommand(pulls bool) *cobra.Command {
noun := "issue"
if pulls {
noun = "pr"
}
var opts services.DiscussionSearchOptions
var updated timeRange
opts.Pulls = pulls
command := &cobra.Command{Use: noun + " [query]...", Short: "Search " + noun + "s across the selected instance or repository", RunE: func(cmd *cobra.Command, args []string) error {
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
return err
}
if opts.Author != "" && opts.Authored {
return clierr.Wrap(clierr.Usage, errors.New("--author and --authored are mutually exclusive"))
}
var err error
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
return err
}
labelNames, err := labelFilter(opts.Labels)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
if target.Repo != "" && opts.Owner != "" && !strings.EqualFold(strings.SplitN(target.Repo, "/", 2)[0], opts.Owner) {
return clierr.Wrap(clierr.Usage, errors.New("--owner conflicts with selected repository"))
}
if opts.Team != "" && opts.Owner == "" {
return clierr.Wrap(clierr.Usage, errors.New("--team requires --owner"))
}
if opts.Team != "" && target.Repo != "" {
return clierr.Wrap(clierr.Usage, errors.New("--team requires instance-wide search; omit --repo or TEA_REPO"))
}
if len(labelNames) > 0 {
if target.Repo != "" {
if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil {
return err
}
}
opts.Labels = strings.Join(labelNames, ",")
}
opts.Query = strings.Join(args, " ")
items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Search(cmd.Context(), opts)
if err != nil {
return err
}
return writeList(a, cmd, items, searchRow)
}}
flags := command.Flags()
pageFlags(command, &opts.Limit, &opts.Page)
flags.StringVar(&opts.State, "state", "open", "State: open, closed, all")
flags.StringVar(&opts.Owner, "owner", "", "Repository owner")
flags.StringVar(&opts.Team, "team", "", "Organization team name (instance search; requires --owner)")
flags.StringVar(&opts.Author, "author", "", "Issue/PR author")
flags.StringVar(&opts.Labels, "label", "", "Comma-separated label names")
flags.StringVar(&opts.Milestones, "milestone", "", "Comma-separated milestone names")
updated.register(command, "since", "Updated since a date, timestamp, or duration ago", "before", "Updated before a date, timestamp, or duration ago")
flags.BoolVar(&opts.Assigned, "assigned", false, "Assigned to the selected account")
flags.BoolVar(&opts.Authored, "authored", false, "Created by the selected account")
flags.BoolVar(&opts.Mentioned, "mentioned", false, "Mentioning the selected account")
if pulls {
flags.BoolVar(&opts.ReviewRequested, "review-requested", false, "Requesting review from the selected account (instance search)")
flags.BoolVar(&opts.Reviewed, "reviewed", false, "Reviewed by the selected account (instance search)")
}
return command
}
func searchRow(item services.SearchItem) []terminal.Cell {
return []terminal.Cell{{Text: fmt.Sprintf("%s#%d", item.Repository, item.Number), Link: item.URL}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestSearchIssueTeamFilter(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "/api/v1/repos/issues/search", r.URL.Path)
query := r.URL.Query()
require.Equal(t, "team-org", query.Get("owner"))
require.Equal(t, "devs", query.Get("team"))
require.Equal(t, "issues", query.Get("type"))
_, _ = w.Write([]byte(`[{"id":1,"number":2,"title":"Team issue","html_url":"https://instance/team-org/repo/issues/2","state":"open","repository":{"full_name":"team-org/repo"}}]`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"search", "issue", "--owner", "team-org", "--team", "devs", "--json=number,title,repository"}))
require.JSONEq(t, `[{"number":2,"title":"Team issue","repository":"team-org/repo"}]`, out.String())
require.Empty(t, diagnostics.String())
require.Equal(t, 1, calls)
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"search", "issue", "--team", "devs"})))
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"search", "issue", "-R", "team-org/repo", "--owner", "team-org", "--team", "devs"})))
require.Equal(t, 1, calls)
}
func TestSearchOwnerMatchesRepositoryCaseInsensitively(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/Alice/project/issues", r.URL.Path)
require.Equal(t, "alice", r.URL.Query().Get("owner"))
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"search", "issue", "-R", "Alice/project", "--owner", "alice", "--json"}))
err := Execute(context.Background(), deps, []string{"search", "issue", "-R", "Alice/project", "--owner", "bob", "--json"})
require.ErrorContains(t, err, "--owner conflicts with selected repository")
}
func TestSearchIssueAcceptsFlexibleTimeBounds(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/issues/search", r.URL.Path)
query := r.URL.Query()
require.Equal(t, "2026-10-04T12:00:00Z", query.Get("since"))
require.Equal(t, "2026-10-05T12:00:00Z", query.Get("before"))
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"search", "issue", "--since", "24h", "--before", "2026-10-05T12:00:00Z", "--json"}))
require.JSONEq(t, `[]`, out.String())
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"search", "issue", "--since", "invalid"})))
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"fmt"
"slices"
"strings"
"time"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// discussionView holds the fields `issue view` and `pr view` render.
type discussionView struct {
kind, title, state, author, action, createdAt, url, body string
number, comments int64
labels []services.Label
fields [][2]string
// extraComments adds kind-specific entries, such as pull request reviews, to --comments.
extraComments func(*cobra.Command, target, int64) ([]discussionComment, error)
}
// discussionComment is one --comments entry; heading and meta are empty for plain comments.
type discussionComment struct {
author, createdAt, heading, body string
meta [2]string
}
func issueView(item services.Issue, repo string, dependencies services.IssueDependencies) discussionView {
return discussionView{
kind: "issue", title: item.Title, state: item.State, author: item.Author, action: "opened",
createdAt: item.CreatedAt, url: item.URL, body: item.Body, number: item.Number, comments: item.Comments, labels: item.Labels,
fields: [][2]string{
{"Assignees", strings.Join(item.Assignees, ", ")},
{"Milestone", milestoneTitle(item.Milestone)},
{"Blocked by", issueReferences(repo, dependencies.BlockedBy)},
{"Blocks", issueReferences(repo, dependencies.Blocks)},
},
}
}
// issueReferences lists issues as #N, qualifying those from other repositories.
func issueReferences(repo string, refs []services.IssueReference) string {
names := make([]string, len(refs))
for i, ref := range refs {
switch {
case ref.Number < 1:
names[i] = "hidden"
case ref.Repository == "" || strings.EqualFold(ref.Repository, repo):
names[i] = fmt.Sprintf("#%d", ref.Number)
default:
names[i] = fmt.Sprintf("%s#%d", ref.Repository, ref.Number)
}
}
return strings.Join(names, ", ")
}
func pullView(item services.Pull) discussionView {
state := pullState(item)
v := discussionView{
kind: "pull request", title: item.Title, state: state, author: item.Author,
action: fmt.Sprintf("wants to merge into %s from %s •", item.Base, item.Head),
createdAt: item.CreatedAt, url: item.URL, body: item.Body, number: item.Number, comments: item.Comments, labels: item.Labels,
fields: [][2]string{
{"Reviewers", strings.Join(slices.Concat(item.Reviewers, item.ReviewerTeams), ", ")},
{"Assignees", strings.Join(item.Assignees, ", ")},
{"Milestone", milestoneTitle(item.Milestone)},
},
extraComments: pullReviewComments,
}
if state == "open" {
mergeable := "no"
if item.Mergeable {
mergeable = "yes"
}
v.fields = append(v.fields, [2]string{"Mergeable", mergeable})
}
return v
}
// pullReviewComments returns submitted review bodies and inline review comments.
func pullReviewComments(cmd *cobra.Command, target target, n int64) ([]discussionComment, error) {
reviews, inline, err := (services.Reviews{Client: target.client, Repository: target.Repo}).Thread(cmd.Context(), n)
if err != nil {
return nil, err
}
var result []discussionComment
for _, review := range reviews {
if strings.TrimSpace(review.Body) == "" || review.State == "PENDING" {
continue
}
result = append(result, discussionComment{
author: review.Author, createdAt: review.SubmittedAt, body: review.Body,
heading: "reviewed (" + review.State + ")", meta: [2]string{"review", review.State},
})
}
for _, comment := range inline {
location := comment.Path
if comment.Line > 0 {
location = fmt.Sprintf("%s:%d", comment.Path, comment.Line)
}
result = append(result, discussionComment{
author: comment.Author, createdAt: comment.CreatedAt, body: comment.Body,
heading: "commented on " + location, meta: [2]string{"path", location},
})
}
return result, nil
}
func milestoneTitle(m *services.IssueMilestone) string {
if m == nil {
return ""
}
return m.Title
}
// writeDiscussion renders a gh-style detail view: a readable summary on a
// terminal and tab-separated key/value lines followed by the body otherwise.
func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discussionView, withComments bool) error {
var comments []discussionComment
if withComments {
items, err := (services.Comments{Client: target.client, Repository: target.Repo}).List(cmd.Context(), v.number)
if err != nil {
return err
}
for _, item := range items {
comments = append(comments, discussionComment{author: item.Author, createdAt: item.CreatedAt, body: item.Body})
}
if v.extraComments != nil {
extra, err := v.extraComments(cmd, target, v.number)
if err != nil {
return err
}
comments = append(comments, extra...)
slices.SortStableFunc(comments, func(x, y discussionComment) int { return compareTimes(x.createdAt, y.createdAt) })
}
}
v.title, v.body = terminal.SafeLine(v.title), terminal.SafeText(v.body)
for i := range v.fields {
v.fields[i][1] = terminal.SafeLine(v.fields[i][1])
}
for i := range comments {
c := &comments[i]
c.heading, c.meta[1], c.body = terminal.SafeLine(c.heading), terminal.SafeLine(c.meta[1]), terminal.SafeText(c.body)
}
color := a.deps.Streams.OutTTY && a.colorEnabled()
labels := make([]string, len(v.labels))
for i, label := range v.labels {
labels[i] = terminal.Colorize(label.Color, terminal.SafeLine(label.Name), color)
}
var b strings.Builder
if !a.deps.Streams.OutTTY {
fmt.Fprintf(&b, "title:\t%s\nstate:\t%s\nauthor:\t%s\nlabels:\t%s\n", v.title, v.state, v.author, strings.Join(labels, ", "))
for _, field := range v.fields {
fmt.Fprintf(&b, "%s:\t%s\n", strings.ToLower(field[0]), field[1])
}
fmt.Fprintf(&b, "comments:\t%d\nnumber:\t%d\nurl:\t%s\n--\n%s\n", v.comments, v.number, v.url, v.body)
for _, comment := range comments {
fmt.Fprintf(&b, "--\nauthor:\t%s\ncreated_at:\t%s\n", comment.author, comment.createdAt)
if comment.meta[0] != "" {
fmt.Fprintf(&b, "%s:\t%s\n", comment.meta[0], comment.meta[1])
}
fmt.Fprintf(&b, "--\n%s\n", comment.body)
}
_, err := fmt.Fprint(cmd.OutOrStdout(), b.String())
return err
}
now := a.deps.Now()
fmt.Fprintf(&b, "%s #%d\n", terminal.Emphasis(v.title, color), v.number)
fmt.Fprintf(&b, "%s • %s %s %s • %s\n", terminal.Colorize(stateColors[v.state], titleCase(v.state), color), v.author, v.action, relativeTime(now, v.createdAt), plural(int(v.comments), "comment"))
for _, field := range append([][2]string{{"Labels", strings.Join(labels, ", ")}}, v.fields...) {
if field[1] != "" {
fmt.Fprintf(&b, "%s: %s\n", terminal.Emphasis(field[0], color), field[1])
}
}
body := strings.TrimSpace(v.body)
if body == "" {
body = "No description provided"
}
fmt.Fprintf(&b, "\n%s\n\n", body)
for _, comment := range comments {
heading := ""
if comment.heading != "" {
heading = comment.heading + " • "
}
fmt.Fprintf(&b, "%s • %s%s\n%s\n\n", terminal.Emphasis(comment.author, color), heading, relativeTime(now, comment.createdAt), strings.TrimSpace(comment.body))
}
fmt.Fprintf(&b, "View this %s on Gitea: %s\n", v.kind, v.url)
return a.writeHuman(cmd, b.String())
}
// compareTimes orders API timestamps chronologically, falling back to text order.
func compareTimes(x, y string) int {
tx, errX := time.Parse(time.RFC3339, x)
ty, errY := time.Parse(time.RFC3339, y)
if errX != nil || errY != nil {
return strings.Compare(x, y)
}
return tx.Compare(ty)
}
// relativeTime formats an API timestamp like gh: "about 3 days ago".
func relativeTime(now time.Time, raw string) string {
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return raw
}
const day = 24 * time.Hour
var n time.Duration
var unit string
switch d := now.Sub(t); {
case d < time.Minute:
return "less than a minute ago"
case d < time.Hour:
n, unit = d/time.Minute, "minute"
case d < day:
n, unit = d/time.Hour, "hour"
case d < 30*day:
n, unit = d/day, "day"
case d < 365*day:
n, unit = d/(30*day), "month"
default:
n, unit = d/(365*day), "year"
}
return "about " + plural(int(n), unit) + " ago"
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestDiscussionViews(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/issues/3":
_, _ = w.Write([]byte(`{"number":3,"title":"Crash\u001b[2J","body":"Steps:\r\n\u001b]52;c;aGk=\u0007run\u202e\r","html_url":"https://example.org/i/3","state":"open","user":{"login":"bob"},"created_at":"2026-01-01T00:00:00Z","labels":[{"name":"bug","color":"ff0000"}],"assignees":[{"login":"alice"}],"comments":1}`))
case "/api/v1/repos/alice/project/issues/3/dependencies", "/api/v1/repos/alice/project/issues/3/blocks":
_, _ = w.Write([]byte(`[]`))
case "/api/v1/repos/alice/project/issues/3/comments":
_, _ = w.Write([]byte(`[{"body":"Confirmed\u001b[31m\n\tok","user":{"login":"carol"},"created_at":"2026-01-02T00:00:00Z"}]`))
case "/api/v1/repos/alice/project/pulls/4":
_, _ = w.Write([]byte(`{"number":4,"title":"Fix","html_url":"https://example.org/p/4","state":"closed","merged":true,"user":{"login":"bob"},"created_at":"2026-01-03T00:00:00Z","base":{"ref":"main"},"head":{"ref":"fix"},"requested_reviewers":[{"login":"alice"}]}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "view", "3", "-R", "alice/project", "--comments"}))
require.Equal(t, "title:\tCrash[2J\nstate:\topen\nauthor:\tbob\nlabels:\tbug\nassignees:\talice\nmilestone:\t\nblocked by:\t\nblocks:\t\ncomments:\t1\nnumber:\t3\nurl:\thttps://example.org/i/3\n--\nSteps:\n]52;c;aGk=run\n--\nauthor:\tcarol\ncreated_at:\t2026-01-02T00:00:00Z\n--\nConfirmed[31m\n\tok\n", out.String())
out.Reset()
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", "4", "-R", "alice/project"}))
require.Equal(t, "Fix #4\nMerged • bob wants to merge into main from fix • about 1 day ago • 0 comments\nReviewers: alice\n\nNo description provided\n\nView this pull request on Gitea: https://example.org/p/4\n", out.String())
}
func TestIssueViewDependencies(t *testing.T) {
supported := true
var paths []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch {
case r.URL.Path == "/api/v1/repos/alice/project/issues/3":
_, _ = w.Write([]byte(`{"number":3,"title":"Crash","html_url":"https://example.org/i/3","state":"open","user":{"login":"bob"},"created_at":"2026-01-03T00:00:00Z"}`))
case !supported:
w.WriteHeader(http.StatusNotFound)
case r.URL.Query().Get("page") != "1":
_, _ = w.Write([]byte(`[]`))
case r.URL.Path == "/api/v1/repos/alice/project/issues/3/dependencies":
_, _ = w.Write([]byte(`[{"number":1,"repository":{"full_name":"alice/project"}},{"number":5,"repository":{"full_name":"bob/lib"}},{"number":0,"title":"HIDDEN"}]`))
case r.URL.Path == "/api/v1/repos/alice/project/issues/3/blocks":
_, _ = w.Write([]byte(`[{"number":9,"repository":{"full_name":"alice/project"}}]`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
args := []string{"issue", "view", "3", "-R", "alice/project"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "Crash #3\nOpen • bob opened about 1 day ago • 0 comments\nBlocked by: #1, bob/lib#5, hidden\nBlocks: #9\n\nNo description provided\n\nView this issue on Gitea: https://example.org/i/3\n", out.String())
out.Reset()
deps.Streams.OutTTY = false
require.NoError(t, Execute(context.Background(), deps, args))
require.Contains(t, out.String(), "\nblocked by:\t#1, bob/lib#5, hidden\nblocks:\t#9\n")
out.Reset()
supported, paths = false, nil
require.NoError(t, Execute(context.Background(), deps, args))
require.Contains(t, out.String(), "\nblocked by:\t\nblocks:\t\n")
require.Equal(t, []string{"/api/v1/repos/alice/project/issues/3", "/api/v1/repos/alice/project/issues/3/dependencies"}, paths)
out.Reset()
paths = nil
require.NoError(t, Execute(context.Background(), deps, append(args, "--json=number")))
require.Equal(t, []string{"/api/v1/repos/alice/project/issues/3"}, paths)
}
func TestRelativeTime(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for raw, want := range map[string]string{
"2025-12-31T23:59:30Z": "less than a minute ago",
"2025-12-31T23:59:00Z": "about 1 minute ago",
"2025-12-31T21:00:00Z": "about 3 hours ago",
"2025-11-01T00:00:00Z": "about 2 months ago",
"2023-12-01T00:00:00Z": "about 2 years ago",
"not a time": "not a time",
} {
require.Equal(t, want, relativeTime(now, raw), raw)
}
}
func TestPullViewCommentsIncludeReviews(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/pulls/4":
_, _ = w.Write([]byte(`{"number":4,"title":"Fix","html_url":"https://example.org/p/4","state":"open","mergeable":true,"user":{"login":"bob"},"created_at":"2026-01-01T00:00:00Z","base":{"ref":"main"},"head":{"ref":"fix"},"comments":1}`))
case "/api/v1/repos/alice/project/issues/4/comments":
_, _ = w.Write([]byte(`[{"body":"Looks close","user":{"login":"carol"},"created_at":"2026-01-02T00:00:00Z"}]`))
case "/api/v1/repos/alice/project/pulls/4/reviews":
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`[]`))
return
}
_, _ = w.Write([]byte(`[
{"id":1,"body":"Please rename","state":"REQUEST_CHANGES","user":{"login":"alice"},"submitted_at":"2026-01-01T12:00:00Z","comments_count":1},
{"id":2,"body":"","state":"APPROVED","user":{"login":"dave"},"submitted_at":"2026-01-03T00:00:00Z"},
{"id":3,"body":"Draft","state":"PENDING","user":{"login":"erin"},"submitted_at":"2026-01-03T00:00:00Z"},
{"id":4,"body":"Great","state":"APPROVED","user":{"login":"alice"},"submitted_at":"2026-01-03T06:00:00Z"}
]`))
case "/api/v1/repos/alice/project/pulls/4/reviews/1/comments":
_, _ = w.Write([]byte(`[{"id":9,"body":"Typo here","path":"main.go","position":12,"user":{"login":"alice"},"created_at":"2026-01-01T12:00:00Z"}]`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", "4", "-R", "alice/project", "--comments"}))
require.Equal(t, "title:\tFix\nstate:\topen\nauthor:\tbob\nlabels:\t\nreviewers:\t\nassignees:\t\nmilestone:\t\nmergeable:\tyes\ncomments:\t1\nnumber:\t4\nurl:\thttps://example.org/p/4\n--\n\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-01T12:00:00Z\nreview:\tREQUEST_CHANGES\n--\nPlease rename\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-01T12:00:00Z\npath:\tmain.go:12\n--\nTypo here\n"+
"--\nauthor:\tcarol\ncreated_at:\t2026-01-02T00:00:00Z\n--\nLooks close\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-03T06:00:00Z\nreview:\tAPPROVED\n--\nGreat\n", out.String())
out.Reset()
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", "4", "-R", "alice/project", "--comments"}))
require.Equal(t, "Fix #4\nOpen • bob wants to merge into main from fix • about 3 days ago • 1 comment\nMergeable: yes\n\nNo description provided\n\n"+
"alice • reviewed (REQUEST_CHANGES) • about 2 days ago\nPlease rename\n\n"+
"alice • commented on main.go:12 • about 2 days ago\nTypo here\n\n"+
"carol • about 2 days ago\nLooks close\n\n"+
"alice • reviewed (APPROVED) • about 18 hours ago\nGreat\n\n"+
"View this pull request on Gitea: https://example.org/p/4\n", out.String())
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"fmt"
"gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
)
func (a *application) doctorCommand() *cobra.Command {
return &cobra.Command{Use: "doctor", Short: "Inspect resolved instance and repository context", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
// Repository discovery is best-effort: doctor must also work outside a clone.
// Only discovery (usage) failures fall back; authentication errors are reported.
target, err := a.resolve(cmd.Context(), true)
if clierr.ExitCode(err) == clierr.Usage && a.options.repo == "" && a.deps.Getenv("TEA_REPO") == "" {
target, err = a.resolve(cmd.Context(), false)
}
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, target)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "instance\t%s\t%s\naccount\t%s\t%s\nrepository\t%s\t%s\n", target.Host, target.HostSource, target.User, target.UserSource, target.Repo, target.RepoSource)
return err
}}
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestEnvironmentCredentialReportsActualAccount(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("unexpected request") }))
defer server.Close()
deps, out, _ := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_USER": "ALICE"})
require.NoError(t, Execute(context.Background(), deps, []string{"doctor", "--json"}))
require.Contains(t, out.String(), `"user":"alice"`)
require.Contains(t, out.String(), `"user_source":"TEA_USER"`)
}
func TestDoctorDiscoversRepositoryBestEffort(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("unexpected request") }))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Git = &fakeGit{results: map[string]string{"remote": "origin", "remote get-url origin": server.URL + "/alice/project.git"}}
require.NoError(t, Execute(context.Background(), deps, []string{"doctor", "--json=repo,repo_source"}))
require.JSONEq(t, `{"repo":"alice/project","repo_source":"remote origin"}`, out.String())
out.Reset()
deps.Getenv = envFunc(map[string]string{"TEA_HOST": server.URL})
require.Equal(t, clierr.Authentication, clierr.ExitCode(Execute(context.Background(), deps, []string{"doctor"})))
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"io"
"os"
"path/filepath"
"gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
)
func writeDownload(cmd *cobra.Command, body io.Reader, output string) error {
return writeOutput(cmd, output, func(destination io.Writer) error { _, err := io.Copy(destination, body); return err })
}
// writeOutput writes to stdout when output is empty or -; otherwise it publishes a complete
// private file without replacing another writer.
func writeOutput(cmd *cobra.Command, output string, write func(io.Writer) error) error {
if output == "" || output == "-" {
return write(cmd.OutOrStdout())
}
file, err := os.CreateTemp(filepath.Dir(output), ".tea-download-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
copyErr := write(file)
closeErr := file.Close()
if copyErr != nil {
if cmd.Context().Err() != nil {
return cmd.Context().Err()
}
return copyErr
}
if closeErr != nil {
return closeErr
}
// Linking never replaces a file created while the download ran.
err = os.Link(file.Name(), output)
if errors.Is(err, os.ErrExist) {
return clierr.Wrap(clierr.Usage, errors.New("output file already exists"))
}
return err
}
func validateDownloadDestination(output string) error {
if output == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --output-file, or --output-file - for stdout"))
}
if output == "-" {
return nil
}
if _, err := os.Lstat(output); err == nil {
return clierr.Wrap(clierr.Usage, errors.New("output file already exists"))
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
type failingDownload struct{}
func (failingDownload) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
func TestDownloadsPublishOnlyCompletePrivateFiles(t *testing.T) {
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
var stdout bytes.Buffer
cmd.SetOut(&stdout)
require.NoError(t, writeDownload(cmd, bytes.NewBufferString("archive"), "-"))
require.Equal(t, "archive", stdout.String())
directory := t.TempDir()
output := filepath.Join(directory, "logs.zip")
require.ErrorIs(t, writeDownload(cmd, failingDownload{}, output), io.ErrUnexpectedEOF)
_, err := os.Stat(output)
require.True(t, errors.Is(err, os.ErrNotExist))
entries, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, entries)
require.NoError(t, writeDownload(cmd, bytes.NewBufferString("archive"), output))
info, err := os.Stat(output)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
require.ErrorContains(t, writeDownload(cmd, bytes.NewBufferString("replacement"), output), "already exists")
data, err := os.ReadFile(output)
require.NoError(t, err)
require.Equal(t, "archive", string(data))
entries, err = os.ReadDir(directory)
require.NoError(t, err)
require.Len(t, entries, 1)
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func finishDraft(draft *terminal.Draft, result *error) {
if draft.Path == "" {
return
}
if *result == nil {
if err := os.Remove(draft.Path); err != nil {
*result = errors.New("operation completed, but its draft could not be removed")
}
}
if *result != nil {
*result = fmt.Errorf("%w; draft saved at %s", *result, draft.Path)
}
}
func (a *application) editor() (terminal.Editor, string, error) {
preferences, err := a.preferences()
if err != nil {
return nil, "", err
}
if preferences.Editor == "" {
return nil, "", clierr.Wrap(clierr.Usage, errors.New("configure an editor using tea config set editor or EDITOR"))
}
return a.deps.Editor, preferences.Editor, nil
}
func (a *application) editDraft(cmd *cobra.Command, body string) (terminal.Draft, error) {
editor, command, err := a.editor()
if err != nil {
return terminal.Draft{}, err
}
return editor.Edit(cmd.Context(), command, body)
}
func (a *application) bodyEditor(cmd *cobra.Command) terminal.BodyEditor {
return func(_ context.Context, initial string) (terminal.Draft, error) { return a.editDraft(cmd, initial) }
}
func (a *application) editPath(cmd *cobra.Command, path string) error {
editor, command, err := a.editor()
if err != nil {
return err
}
return editor.EditPath(cmd.Context(), command, path)
}
// readBody checks source conflicts before reading stdin or invoking any service.
func (a *application) readBody(cmd *cobra.Command, body, file string) (string, error) {
return a.readTextSource(cmd, body, file, "body", "body-file")
}
func (a *application) readTextSource(cmd *cobra.Command, text, file, textFlag, fileFlag string) (string, error) {
if err := validateTextSources(cmd, textFlag, fileFlag); err != nil {
return "", err
}
if !cmd.Flags().Changed(fileFlag) {
return text, nil
}
data, err := readFileOrStdin(a.deps.Streams.In, file)
if err != nil {
return "", fmt.Errorf("read %s: %w", textFlag, err)
}
return string(data), nil
}
// readSecret is readTextSource for credentials: one trailing line ending from a file is dropped.
func (a *application) readSecret(cmd *cobra.Command, text, file, textFlag, fileFlag string) (string, error) {
value, err := a.readTextSource(cmd, text, file, textFlag, fileFlag)
if cmd.Flags().Changed(fileFlag) {
value = strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r")
}
return value, err
}
// readFileOrStdin reads a file, or stdin when path is "-".
func readFileOrStdin(stdin io.Reader, path string) ([]byte, error) {
if path == "-" {
return io.ReadAll(stdin)
}
return os.ReadFile(path)
}
func validateBodySources(cmd *cobra.Command) error {
return validateTextSources(cmd, "body", "body-file")
}
func validateTextSources(cmd *cobra.Command, textFlag, fileFlag string) error {
text, file := cmd.Flags().Changed(textFlag), cmd.Flags().Changed(fileFlag)
if flag := cmd.Flags().Lookup("editor"); flag != nil {
editor, err := cmd.Flags().GetBool("editor")
if err != nil {
return err
}
if editor && (text || file) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--editor, --%s, and --%s are mutually exclusive", textFlag, fileFlag))
}
}
if text && file {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s and --%s are mutually exclusive", textFlag, fileFlag))
}
if file {
path, err := cmd.Flags().GetString(fileFlag)
if err != nil {
return err
}
if path == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s must name a file or -", fileFlag))
}
}
return nil
}
+310
View File
@@ -0,0 +1,310 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/require"
)
func TestCommentEditorDraftLifecycle(t *testing.T) {
for _, mode := range []string{"success", "editor failure", "submission failure"} {
t.Run(mode, func(t *testing.T) {
requests := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
var options struct{ Body string }
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.Equal(t, "edited comment", options.Body)
if mode == "submission failure" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"id":7,"body":"edited comment"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "edited comment")
if mode == "editor failure" {
editor.err = errors.New("editor failed")
}
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
err := Execute(context.Background(), deps, []string{"issue", "comment", "create", "3", "--repo", "alice/project", "--editor", "--no-input", "--json=body"})
require.Equal(t, "chosen-editor", editor.command)
requireDraft(t, path, mode != "success")
if mode == "success" {
require.NoError(t, err)
require.JSONEq(t, `{"body":"edited comment"}`, out.String())
} else {
require.ErrorContains(t, err, "draft saved at "+path)
require.Empty(t, out.String())
}
if mode == "editor failure" {
require.Zero(t, requests)
} else {
require.Equal(t, 1, requests)
}
})
}
}
func TestCommentEditEditorSeedsCurrentBody(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/issues/comments/7":
_, _ = w.Write([]byte(`{"id":7,"body":"current comment"}`))
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/alice/project/issues/comments/7":
var options struct{ Body string }
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.Equal(t, "updated comment", options.Body)
_, _ = w.Write([]byte(`{"id":7,"body":"updated comment"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "updated comment")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "comment", "edit", "7", "--repo", "alice/project", "--editor", "--no-input", "--json=body"}))
require.Equal(t, "current comment", editor.initial)
require.JSONEq(t, `{"body":"updated comment"}`, out.String())
requireDraft(t, path, false)
}
func TestBodySourcesRejectInvalidInputBeforeAuthentication(t *testing.T) {
commands := [][]string{
{"issue", "create"},
{"pr", "create"},
{"issue", "edit", "3"},
{"pr", "edit", "3"},
{"release", "create", "v1"},
{"release", "edit", "v1"},
{"wiki", "create"},
{"wiki", "edit", "Home"},
{"issue", "comment", "create", "3"},
{"pr", "comment", "create", "3"},
{"pr", "comment", "edit", "3"},
{"pr", "review", "3"},
{"pr", "review", "reply", "3", "7"},
{"pr", "review", "submit", "3", "7"},
{"pr", "review", "dismiss", "3", "7"},
}
for _, command := range commands {
t.Run(strings.Join(command, " "), func(t *testing.T) {
for _, input := range [][]string{
{"--body-file="},
{"--body", "text", "--body-file", "missing.md"},
{"--editor", "--body", "text"},
{"--editor", "--body-file", "missing.md"},
} {
want := "mutually exclusive"
if len(input) == 1 {
want = "--body-file must name a file or -"
}
requireUsageBeforeRequests(t, want, append(append(append([]string{}, command...), input...), "--no-input")...)
}
})
}
}
func TestCreationEditorSubmitsTypedBodyAndCleansDraft(t *testing.T) {
for _, tc := range []struct {
name, path, event string
args []string
}{
{"issue", "/api/v1/repos/alice/project/issues", "", []string{"issue", "create", "--title", "Issue"}},
{"pr", "/api/v1/repos/alice/project/pulls", "", []string{"pr", "create", "--title", "PR", "--head", "feature", "--base", "main"}},
{"review", "/api/v1/repos/alice/project/pulls/3/reviews", "COMMENT", []string{"pr", "review", "3"}},
} {
t.Run(tc.name, func(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v1/repos/alice/project/raw/") {
w.WriteHeader(http.StatusNotFound)
return
}
require.Equal(t, tc.path, r.URL.Path)
var options struct{ Event, Body string }
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.Equal(t, tc.event, options.Event)
require.Equal(t, "edited body", options.Body)
_, _ = w.Write([]byte(`{"id":7,"body":"edited body"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "edited body")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "--repo", "alice/project", "--editor", "--no-input", "--json=body")))
require.JSONEq(t, `{"body":"edited body"}`, out.String())
requireDraft(t, path, false)
})
}
}
func TestBodyEditorStartsWithCurrentBody(t *testing.T) {
for _, group := range []string{"issue", "pr"} {
t.Run(group, func(t *testing.T) {
mutations := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"number":3,"body":"existing body"}`))
return
}
require.Equal(t, http.MethodPatch, r.Method)
var options services.IssueEdit
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.NotNil(t, options.Body)
require.Equal(t, "edited body", *options.Body)
mutations++
_, _ = w.Write([]byte(`{"number":3,"body":"edited body"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "edited body")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
require.NoError(t, Execute(context.Background(), deps, []string{group, "edit", "3", "--repo", "alice/project", "--editor", "--no-input", "--json=body"}))
require.Equal(t, "existing body", editor.initial)
require.Equal(t, 1, mutations)
require.JSONEq(t, `{"body":"edited body"}`, out.String())
requireDraft(t, path, false)
})
}
}
func TestReleaseEditorNotes(t *testing.T) {
for _, verb := range []string{"create", "edit"} {
t.Run(verb, func(t *testing.T) {
mutations := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":7,"tag_name":"v1","body":"existing notes"}`))
return
}
var options services.ReleaseOptions
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.NotNil(t, options.Body)
require.Equal(t, "edited notes", *options.Body)
mutations++
_, _ = w.Write([]byte(`{"id":7,"tag_name":"v1","body":"edited notes"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "edited notes")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
args := []string{"release", verb, "v1", "--repo", "alice/project", "--editor", "--no-input", "--json=body"}
require.NoError(t, Execute(context.Background(), deps, args))
initial := ""
if verb == "edit" {
initial = "existing notes"
}
require.Equal(t, initial, editor.initial)
require.JSONEq(t, `{"body":"edited notes"}`, out.String())
requireDraft(t, path, false)
if verb == "edit" {
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, append(args, "v2"))))
}
require.Equal(t, 1, mutations)
})
}
}
func TestWikiEditorRejectsEmptyContentAndKeepsDraft(t *testing.T) {
deps, out, _ := testDeps(t)
server := newAuthenticatedServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Error("empty wiki draft must not be submitted")
}))
defer server.Close()
editor, path := newDraftEditor(t, "")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
args := []string{"wiki", "create", "--title", "Home", "--repo", "alice/project", "--editor", "--no-input"}
err := Execute(context.Background(), deps, args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "wiki content cannot be empty")
require.ErrorContains(t, err, "draft saved at "+path)
requireDraft(t, path, true)
require.Empty(t, out.String())
}
func TestReviewReplyEditorDraftLifecycle(t *testing.T) {
for _, accepted := range []bool{false, true} {
t.Run(map[bool]string{false: "rejected", true: "accepted"}[accepted], func(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/pulls/3/comments/7/replies", r.URL.Path)
var options map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.Equal(t, "Edited reply", options["body"])
if !accepted {
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
_, _ = w.Write([]byte(`{"id":9,"body":"Edited reply"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "Edited reply")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
err := Execute(context.Background(), deps, []string{"pr", "review", "reply", "3", "7", "--repo", "alice/project", "--editor", "--no-input", "--json=body"})
requireDraft(t, path, !accepted)
if accepted {
require.NoError(t, err)
require.JSONEq(t, `{"body":"Edited reply"}`, out.String())
return
}
var apiErr *transport.APIError
require.ErrorAs(t, err, &apiErr)
require.ErrorContains(t, err, "draft saved at "+path)
require.Empty(t, out.String())
})
}
}
func TestReviewLifecycleEditorRejectsEmptyRequiredText(t *testing.T) {
for _, verb := range []string{"submit", "dismiss"} {
t.Run(verb, func(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "submit", verb)
require.Equal(t, http.MethodGet, r.Method)
_, _ = w.Write([]byte(`{"id":7,"body":"Saved draft"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
args := []string{"pr", "review", verb, "3", "7", "--repo", "alice/project", "--editor", "--no-input", "--yes"}
if verb == "submit" {
args = append(args, "--event", "REQUEST_CHANGES")
}
err := Execute(context.Background(), deps, args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "non-empty review text")
require.ErrorContains(t, err, "draft saved at "+path)
if verb == "submit" {
require.Equal(t, "Saved draft", editor.initial)
} else {
require.Empty(t, editor.initial)
}
requireDraft(t, path, true)
require.Empty(t, out.String())
})
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"io"
"gitea.dev/tea/internal/git"
)
func (a *application) git(ctx context.Context, args ...string) (string, error) {
return a.gitIn(ctx, a.options.cwd, args...)
}
// gitIn runs Git in dir, which is relative to the process's working directory, not to --cwd.
func (a *application) gitIn(ctx context.Context, dir string, args ...string) (string, error) {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return "", err
}
return a.deps.Git.Output(ctx, dir, a.policy(snapshot).CanPrompt(a.deps.Streams), args...)
}
// currentBranch names the checked-out branch without writing Git diagnostics.
func (a *application) currentBranch(ctx context.Context) (string, error) {
return a.gitProbe().Output(ctx, a.options.cwd, false, "symbolic-ref", "--short", "HEAD")
}
// gitProbe runs exploratory Git commands whose failures are expected, without Git's diagnostics.
func (a *application) gitProbe() git.Runner {
if runner, ok := a.deps.Git.(git.System); ok {
runner.ErrOut = io.Discard
return runner
}
return a.deps.Git
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"path/filepath"
"strings"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/spf13/cobra"
)
func (a *application) gitCredentialCommand() *cobra.Command {
return &cobra.Command{Use: "git-credential <get|store|erase>", Hidden: true, Annotations: textOutput("Git credentials use Git's protocol"), Short: "Serve Git's credential helper protocol", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if args[0] != "get" && args[0] != "store" && args[0] != "erase" {
return clierr.Wrap(clierr.Usage, errors.New("Git credential operation must be get, store, or erase"))
}
request, err := auth.ReadGitRequest(cmd.InOrStdin())
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
if err := cmd.Context().Err(); err != nil {
return err
}
if args[0] != "get" {
return nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if snapshot.Hosts.Hosts, err = a.environmentHosts(snapshot.Hosts.Hosts); err != nil {
return err
}
environmentHost := a.tokenInstance()
instance, err := auth.MatchGitInstance(request, snapshot.Hosts.Hosts, a.options.host, environmentHost)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
if instance == "" {
return nil
}
host := snapshot.Hosts.Hosts[instance]
user, _ := a.selectedUser()
if user == "" {
user = request.Username
}
if user == "" {
user = host.User
}
if request.Username != "" && user != "" && !strings.EqualFold(request.Username, user) {
return nil
}
// Account keys retain the canonical login returned at authentication time.
if configured, ok := matchAccount(host.Users, user); ok {
user = configured
}
credential := auth.Credential{}
token := a.deps.Getenv("GITEA_TOKEN")
environmentMatches := false
if environmentHost != "" {
normalized, err := settings.NormalizeHost(environmentHost)
environmentMatches = err == nil && normalized == instance
}
if token != "" && environmentMatches {
actual, err := services.CurrentAccount(cmd.Context(), a.deps.Client(instance, token, host.Insecure))
if err != nil {
return err
}
if !services.MatchAccount(user, actual) {
return nil
}
user, credential.AccessToken = actual, token
} else {
if user == "" {
return nil
}
if _, exists := host.Users[user]; !exists {
return nil
}
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
credential, err = manager.GitCredential(cmd.Context(), instance, user, a.deps.Client(instance, "", host.Insecure).HTTP, a.deps.Now())
if err != nil {
return clierr.Wrap(clierr.Authentication, err)
}
}
if strings.ContainsAny(user+credential.AccessToken, "\x00\r\n") {
return clierr.Wrap(clierr.Authentication, errors.New("stored Git credential contains invalid protocol characters; run tea auth login"))
}
if err := cmd.Context().Err(); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "username=%s\npassword=%s\n\n", user, credential.AccessToken)
return err
}}
}
func (a *application) setupGitCommand() *cobra.Command {
var local bool
command := &cobra.Command{Use: "setup-git", Short: "Explicitly register tea as a scoped Git credential helper", Annotations: textOutput("setup-git configures Git"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolveTarget(cmd.Context(), false, false)
if err != nil {
return err
}
executable, configDir, err := a.credentialHelper()
if err != nil {
return err
}
pinned, _ := a.selectedUser()
return git.SetupCredentials(cmd.Context(), a.deps.Git, git.CredentialSetup{Directory: a.options.cwd, Local: local, Instance: target.Host, Executable: executable, User: pinned, ConfigDirectory: configDir})
}}
command.Flags().BoolVar(&local, "local", false, "Configure only the current Git repository instead of global Git configuration")
return command
}
// credentialHelper locates tea's executable and absolute configuration directory for Git's credential helper.
func (a *application) credentialHelper() (executable, configDir string, err error) {
executable, err = a.deps.Executable()
if err != nil {
return "", "", errors.New("cannot locate tea's executable for Git authentication")
}
configDir, err = filepath.Abs(a.deps.Settings.Dir)
if err != nil {
return "", "", errors.New("cannot resolve configuration directory for Git authentication")
}
return executable, configDir, nil
}
func (a *application) gitCredentialArguments(instance, user string) ([]string, error) {
executable, configDir, err := a.credentialHelper()
if err != nil {
return nil, err
}
return git.CredentialArguments(instance, executable, user, configDir)
}
func (a *application) gitTransportArguments(target target, ssh bool) ([]string, error) {
if !ssh {
return a.gitCredentialArguments(target.Host, target.User)
}
account := target.host.Users[target.User]
return git.SSHArguments(account.SSHKey, account.SSHAgent)
}
@@ -0,0 +1,166 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestGiteaGitCredentialHelperClonesPrivateRepository(t *testing.T) {
gitea, deps, out, diagnostics := giteaDeps(t)
deps.Settings = settings.New(filepath.Join(t.TempDir(), "configuration with spaces"))
deps.Credentials.Dir = deps.Settings.Dir
var repo services.Repository
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "create", fmt.Sprintf("tea-git-helper-%d", time.Now().UnixNano()), "--private", "--auto-init", "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &repo))
require.True(t, repo.Private)
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", repo.FullName, "--yes"}); err != nil {
t.Errorf("repository cleanup: %v", err)
}
})
user, _, _ := strings.Cut(repo.FullName, "/")
require.NoError(t, testManager(t, deps).Register(gitea.url, user, "file", "token", auth.HostOptions{}, auth.Credential{AccessToken: gitea.token}))
binaryDir := filepath.Join(t.TempDir(), "binary with 'quote'")
require.NoError(t, os.MkdirAll(binaryDir, 0o700))
binary := filepath.Join(binaryDir, "tea")
if os.PathSeparator == '\\' {
binary += ".exe"
}
build := exec.CommandContext(context.Background(), "go", "build", "-o", binary, ".")
build.Dir = filepath.Join("..", "..")
output, err := build.CombinedOutput()
require.NoError(t, err, "build: %s", output)
deps.Executable = func() (string, error) { return binary, nil }
// The temporary global file exercises setup's default scope without touching
// the user's Git configuration. Git invokes the compiled helper as a child.
environment := []string{}
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if strings.HasPrefix(key, "GIT_TRACE") || key == "GIT_CURL_VERBOSE" {
continue
}
environment = append(environment, entry)
}
globalConfig := filepath.Join(t.TempDir(), "gitconfig")
environment = append(environment, "GIT_CONFIG_GLOBAL="+globalConfig, "GIT_CONFIG_NOSYSTEM=1", "TEA_CONFIG_DIR="+deps.Settings.Dir, "GITEA_TOKEN=", "GITEA_INSTANCE_URL=", "TEA_HOST=", "TEA_USER=", "TEA_REPO=")
runner := git.System{Env: environment, ErrOut: diagnostics}
deps.Git = runner
_, anonymousErr := runner.Output(context.Background(), "", false, "-c", "credential.helper=", "clone", "--", repo.CloneURL, filepath.Join(t.TempDir(), "anonymous-clone"))
require.Error(t, anonymousErr, "private repository must reject an unauthenticated clone")
out.Reset()
diagnostics.Reset()
teaClone := filepath.Join(t.TempDir(), "tea-private-clone")
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", repo.FullName, teaClone, "--no-input", "--json=id"}))
require.JSONEq(t, fmt.Sprintf(`{"id":%d}`, repo.ID), out.String())
_, localHelperErr := runner.Output(context.Background(), teaClone, false, "config", "--local", "--get-all", "credential."+gitea.url+".helper")
var missingLocalHelper *git.CommandError
require.ErrorAs(t, localHelperErr, &missingLocalHelper)
require.Equal(t, 1, missingLocalHelper.ExitCode)
// Build a changed PR on the private repository, then fetch it without setup.
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"branch", "create", "feature", "-R", repo.FullName}))
deps.Streams.In = strings.NewReader(`{"branch":"feature","content":"dGVzdAo=","message":"Disposable private PR fixture"}`)
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/" + repo.FullName + "/contents/change.txt", "--input", "-"}))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", repo.FullName, "--title", "Private checkout", "--head", "feature", "--base", repo.DefaultBranch, "--json"}))
var pull services.Pull
require.NoError(t, json.Unmarshal(out.Bytes(), &pull))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", fmt.Sprint(pull.Number), "-R", repo.FullName, "--cwd", teaClone, "--no-input", "--json"}))
var checkout git.CheckoutResult
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
require.Equal(t, pull.HeadSHA, checkout.Commit)
require.Equal(t, "pr/"+fmt.Sprint(pull.Number), checkout.Branch)
branchesBefore, err := runner.Output(context.Background(), teaClone, false, "for-each-ref", "--format=%(refname):%(objectname)", "refs/heads/")
require.NoError(t, err)
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", fmt.Sprint(pull.Number), "-R", repo.FullName, "--cwd", teaClone, "--detach", "--no-input", "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
require.True(t, checkout.Detached)
require.Empty(t, checkout.Branch)
require.Equal(t, pull.HeadSHA, checkout.Commit)
branchesAfter, err := runner.Output(context.Background(), teaClone, false, "for-each-ref", "--format=%(refname):%(objectname)", "refs/heads/")
require.NoError(t, err)
require.Equal(t, branchesBefore, branchesAfter)
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", fmt.Sprint(pull.Number), "-R", repo.FullName, "--cwd", teaClone, "--no-input", "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
require.False(t, checkout.Detached)
require.Equal(t, "pr/"+fmt.Sprint(pull.Number), checkout.Branch)
branchesAfter, err = runner.Output(context.Background(), teaClone, false, "for-each-ref", "--format=%(refname):%(objectname)", "refs/heads/")
require.NoError(t, err)
require.Equal(t, branchesBefore, branchesAfter)
_, err = runner.Output(context.Background(), teaClone, false, "switch", "--detach", pull.HeadSHA)
require.NoError(t, err)
_, headErr := runner.Output(context.Background(), teaClone, false, "symbolic-ref", "--quiet", "HEAD")
var detachedHead *git.CommandError
require.ErrorAs(t, headErr, &detachedHead)
require.Equal(t, 1, detachedHead.ExitCode)
deps.Streams.In = strings.NewReader(`{"branch":"feature","content":"bmV4dAo=","message":"Disposable fast-forward fixture"}`)
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/" + repo.FullName + "/contents/next.txt", "--input", "-"}))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", fmt.Sprint(pull.Number), "-R", repo.FullName, "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &pull))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", fmt.Sprint(pull.Number), "-R", repo.FullName, "--cwd", teaClone, "--update", "--no-input", "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
require.Equal(t, pull.HeadSHA, checkout.Commit)
localHead, err := runner.Output(context.Background(), teaClone, false, "rev-parse", "HEAD")
require.NoError(t, err)
require.Equal(t, pull.HeadSHA, localHead)
deps.Streams.In = strings.NewReader(fmt.Sprintf(`{"branch":%q,"content":"c3luYwo=","message":"Disposable sync fixture"}`, repo.DefaultBranch))
require.NoError(t, Execute(context.Background(), deps, []string{"api", "repos/" + repo.FullName + "/contents/sync.txt", "--input", "-"}))
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "sync", "-R", repo.FullName, "--cwd", teaClone, "--no-input", "--json=branch,commit,remote"}))
var synced git.SyncResult
require.NoError(t, json.Unmarshal(out.Bytes(), &synced))
require.Equal(t, repo.DefaultBranch, synced.Branch)
localHead, err = runner.Output(context.Background(), teaClone, false, "rev-parse", "HEAD")
require.NoError(t, err)
require.Equal(t, synced.Commit, localHead)
contents, err := os.ReadFile(filepath.Join(teaClone, "sync.txt"))
require.NoError(t, err)
require.Equal(t, "sync\n", string(contents))
if endpoint := os.Getenv("GITEA_TEA_TEST_SSH_ENDPOINT"); endpoint != "" {
verifySSHCloneAndCheckout(t, deps, repo, pull, user, environment, endpoint)
}
// The per-operation helper does not create a global Git configuration file.
_, err = os.Stat(globalConfig)
require.True(t, os.IsNotExist(err))
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "setup-git", "--host", gitea.url, "--no-input"}))
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "setup-git", "--host", gitea.url, "--no-input"}))
require.Empty(t, out.String())
destination := filepath.Join(t.TempDir(), "private-clone")
_, err = runner.Output(context.Background(), "", false, "clone", "--", repo.CloneURL, destination)
require.NoError(t, err, "private repository clone failed")
inside, err := runner.Output(context.Background(), destination, false, "rev-parse", "--is-inside-work-tree")
require.NoError(t, err)
require.Equal(t, "true", inside)
// No credential is embedded in either the persisted remote or helper command.
remote, err := runner.Output(context.Background(), destination, false, "remote", "get-url", "origin")
require.NoError(t, err)
require.Equal(t, repo.CloneURL, remote)
require.False(t, strings.Contains(remote, gitea.token), "remote contains credentials")
helper, err := runner.Output(context.Background(), "", false, "config", "--global", "--get-all", "credential."+gitea.url+".helper")
require.NoError(t, err)
require.Len(t, strings.Split(helper, "\n"), 1)
require.False(t, strings.Contains(helper, gitea.token), "helper command contains credentials")
}
+86
View File
@@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"os"
"strings"
"testing"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestGitCredentialHelperSelectsAccountsAndDoesNotMutateFromGit(t *testing.T) {
deps, out, diagnostics := testDeps(t)
manager := testManager(t, deps)
instance := "https://example.test/gitea"
require.NoError(t, manager.Register(instance, "alice", "file", "token", auth.HostOptions{}, auth.Credential{AccessToken: "alice-token"}))
require.NoError(t, manager.Register(instance, "bob", "file", "token", auth.HostOptions{}, auth.Credential{AccessToken: "bob-token"}))
for _, tc := range []struct{ host, path, user, operation, want string }{
{"example.test", "gitea/owner/repo.git", "", "get", "username=bob\npassword=bob-token\n\n"},
{"example.test", "gitea/owner/repo.git", "alice", "get", "username=alice\npassword=alice-token\n\n"},
{"example.test", "gitea/owner/repo.git", "unknown", "get", ""},
{"other.test", "gitea/owner/repo.git", "alice", "get", ""},
{"example.test", "other/owner/repo.git", "alice", "get", ""},
{"example.test", "gitea/owner/repo.git", "alice", "store", ""},
{"example.test", "gitea/owner/repo.git", "alice", "erase", ""},
} {
out.Reset()
deps.Streams.In = strings.NewReader("protocol=https\nhost=" + tc.host + "\npath=" + tc.path + "\nusername=" + tc.user + "\npassword=replacement-token\n\n")
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "git-credential", tc.operation, "--no-input"}))
require.Equal(t, tc.want, out.String())
require.Empty(t, diagnostics.String())
}
credential, err := deps.Credentials.Load(instance, "alice", "file")
require.NoError(t, err)
require.Equal(t, "alice-token", credential.AccessToken)
deps.Streams.In = strings.NewReader("protocol=https\nhost=example.test\npath=gitea/owner/repo.git\nusername=bob\n\n")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "git-credential", "get", "--user", "alice"}))
require.Empty(t, out.String())
err = Execute(context.Background(), deps, []string{"auth", "git-credential", "get", "--json"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.Empty(t, out.String())
}
func TestGitCredentialHelperNeverEmitsInvalidStoredProtocolValues(t *testing.T) {
deps, out, _ := testDeps(t)
instance := "https://example.test"
manager := testManager(t, deps)
require.NoError(t, manager.Register(instance, "alice", "file", "token", auth.HostOptions{}, auth.Credential{AccessToken: "invalid-secret\nusername=other"}))
deps.Streams.In = strings.NewReader("protocol=https\nhost=example.test\npath=owner/repo.git\n\n")
err := Execute(context.Background(), deps, []string{"auth", "git-credential", "get"})
require.Error(t, err)
require.Equal(t, clierr.Authentication, clierr.ExitCode(err))
require.NotContains(t, err.Error(), "invalid-secret")
require.Empty(t, out.String())
}
func TestGitCredentialEnvironmentTokenIsScopedAndNeverPersisted(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected request %s", r.URL.Path) }))
defer server.Close()
deps, out, _ := testDeps(t)
deps.Getenv = envFunc(map[string]string{"TEA_HOST": server.URL, "GITEA_TOKEN": "environment-token"})
host := strings.TrimPrefix(server.URL, "http://")
deps.Streams.In = strings.NewReader("protocol=http\nhost=" + host + "\npath=alice/project.git\n\n")
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "git-credential", "get"}))
require.Equal(t, "username=alice\npassword=environment-token\n\n", out.String())
files, err := os.ReadDir(deps.Settings.Dir)
require.NoError(t, err)
require.Empty(t, files)
for _, requestHost := range []string{"unrelated.test", host} {
out.Reset()
username := ""
if requestHost == host {
username = "someone-else"
}
deps.Streams.In = strings.NewReader("protocol=http\nhost=" + requestHost + "\npath=alice/project.git\nusername=" + username + "\n\n")
require.NoError(t, Execute(context.Background(), deps, []string{"auth", "git-credential", "get"}))
require.Empty(t, out.String())
}
}
+293
View File
@@ -0,0 +1,293 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testDeps returns hermetic dependencies: no environment, no system keyring and a Git runner without results.
func testDeps(t *testing.T) (Dependencies, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
store := settings.New(t.TempDir())
return Dependencies{
Git: &fakeGit{},
Executable: os.Executable,
Settings: store,
Credentials: &auth.Store{Dir: store.Dir, Keyring: testNoKeyring{}},
Streams: terminal.Streams{In: &bytes.Buffer{}, Out: out, ErrOut: diagnostics},
Getenv: envFunc(nil),
Client: transport.New,
}, out, diagnostics
}
// serverDeps returns testDeps authenticated against serverURL through the environment.
func serverDeps(t *testing.T, serverURL string) (Dependencies, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
deps, out, diagnostics := testDeps(t)
deps.Getenv = serverEnv(serverURL, nil)
return deps, out, diagnostics
}
func envFunc(vars map[string]string) func(string) string {
return func(key string) string { return vars[key] }
}
// serverEnv authenticates against serverURL through the environment, plus extra variables.
func serverEnv(serverURL string, extra map[string]string) func(string) string {
vars := map[string]string{"TEA_HOST": serverURL, "GITEA_TOKEN": "token"}
maps.Copy(vars, extra)
return envFunc(vars)
}
// requireUsageBeforeRequests runs args against a fully configured target and asserts
// that the command fails with a usage error before any request is sent.
func requireUsageBeforeRequests(t *testing.T, want string, args ...string) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusTeapot)
}))
defer server.Close()
deps, out, diagnostics := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"})
err := Execute(context.Background(), deps, args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
require.ErrorContains(t, err, want)
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
}
// requirePayload checks that a JSON request body sends every wanted field with its value and
// leaves every other field null or empty: SDK option structs send unset fields that way.
func requirePayload(t *testing.T, want string, body io.Reader) {
t.Helper()
var wanted, sent map[string]any
require.NoError(t, json.Unmarshal([]byte(want), &wanted))
require.NoError(t, json.NewDecoder(body).Decode(&sent))
for key, value := range wanted {
require.Contains(t, sent, key)
require.Equal(t, value, sent[key], key)
}
for key, value := range sent {
if _, ok := wanted[key]; !ok {
require.Empty(t, value, key)
}
}
}
// newAuthenticatedServer supplies the common identity endpoint for workflow fixtures.
// Tests of identity failures use an explicit httptest server instead.
func newAuthenticatedServer(handler http.Handler) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/v1/user") {
if r.Header.Get("Authorization") == "" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"login":"alice"}`))
return
}
handler.ServeHTTP(w, r)
}))
}
type testNoKeyring struct{}
var errNoTestKeyring = errors.New("system keyring is unavailable in tests")
func (testNoKeyring) Get(string, string) (string, error) { return "", errNoTestKeyring }
func (testNoKeyring) Set(string, string, string) error { return errNoTestKeyring }
func (testNoKeyring) Delete(string, string) error { return errNoTestKeyring }
type fakeGit struct {
results map[string]string
calls []string
dirs []string
prompt bool
}
func (f *fakeGit) Output(_ context.Context, dir string, prompt bool, args ...string) (string, error) {
key := strings.Join(args, " ")
f.calls = append(f.calls, key)
f.dirs = append(f.dirs, dir)
f.prompt = prompt
value, ok := f.results[key]
if !ok {
return "", errors.New("missing fake Git result")
}
return value, nil
}
// scriptedPrompter answers prompts with canned values and records every prompt it receives.
// Input answers from inputs by field, then from input; a prompt without an answer fails.
type scriptedPrompter struct {
inputs map[string]string
input, secret string
accept bool
asked []string
secrets []string
confirmations []string
}
func (p *scriptedPrompter) Input(_ context.Context, field string) (string, error) {
p.asked = append(p.asked, field)
if value, ok := p.inputs[field]; ok {
return value, nil
}
if p.input == "" {
return "", errors.New("unexpected input prompt " + field)
}
return p.input, nil
}
func (p *scriptedPrompter) SecretInput(_ context.Context, title string) (string, error) {
p.secrets = append(p.secrets, title)
if p.secret == "" {
return "", errors.New("unexpected secret prompt " + title)
}
return p.secret, nil
}
func (p *scriptedPrompter) Confirm(_ context.Context, description string) (bool, error) {
p.confirmations = append(p.confirmations, description)
return p.accept, nil
}
// testManager returns the account manager for the stores in deps.
func testManager(t *testing.T, deps Dependencies) auth.Manager {
t.Helper()
manager, err := auth.NewManager(deps.Settings, deps.Credentials)
require.NoError(t, err)
return manager
}
// configureHost stores host as the settings of the instance at url.
func configureHost(t *testing.T, deps Dependencies, url string, host settings.Host) {
t.Helper()
require.NoError(t, deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
s.Hosts.Hosts[url] = host
return nil
}))
}
// tokenAccounts returns one token account per user, kept in store.
func tokenAccounts(store string, users ...string) map[string]settings.Account {
accounts := map[string]settings.Account{}
for _, user := range users {
accounts[user] = settings.Account{AuthMethod: settings.AuthToken, CredentialStore: store}
}
return accounts
}
// configureAccounts configures token accounts at url with active selected,
// and saves the file credential "<user>-token" for each of them.
func configureAccounts(t *testing.T, deps Dependencies, url, active string, users ...string) {
t.Helper()
configureHost(t, deps, url, settings.Host{User: active, Users: tokenAccounts(settings.StoreFile, users...)})
for _, user := range users {
require.NoError(t, deps.Credentials.Save(url, user, settings.StoreFile, auth.Credential{AccessToken: user + "-token"}))
}
}
// credentialCall returns the fakeGit key of a Git call authenticated through the tea credential helper.
func credentialCall(t *testing.T, deps Dependencies, host, user string, args ...string) string {
t.Helper()
executable, err := deps.Executable()
require.NoError(t, err)
credentialArgs, err := git.CredentialArguments(host, executable, user, deps.Settings.Dir)
require.NoError(t, err)
return strings.Join(append(credentialArgs, args...), " ")
}
type draftEditor struct {
draft terminal.Draft
err error
command string
initial string
}
func (e *draftEditor) Edit(_ context.Context, command, initial string) (terminal.Draft, error) {
e.command = command
e.initial = initial
return e.draft, e.err
}
func (*draftEditor) EditPath(context.Context, string, string) error {
return errors.New("unexpected file edit")
}
// newDraftEditor returns an editor whose session saved body to a new draft file, and that file's path.
func newDraftEditor(t *testing.T, body string) (*draftEditor, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "draft.md")
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
return &draftEditor{draft: terminal.Draft{Path: path, Body: body}}, path
}
// requireDraft asserts whether the draft file at path was kept.
func requireDraft(t *testing.T, path string, kept bool) {
t.Helper()
_, err := os.Stat(path)
if kept {
require.NoError(t, err)
} else {
require.ErrorIs(t, err, os.ErrNotExist)
}
}
// selectablesHandler serves the alice/project repository with the given push permission, its issue template,
// assignees, open milestones and labels, as offered by interactive forms, and passes other requests to next.
func selectablesHandler(t *testing.T, push bool, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
firstPage := func(items string) {
if r.URL.Query().Get("page") != "1" {
items = `[]`
}
_, _ = w.Write([]byte(items))
}
switch r.URL.Path {
case "/api/v1/repos/alice/project":
if r.Method != http.MethodGet {
next(w, r)
return
}
_, _ = fmt.Fprintf(w, `{"full_name":"alice/project","default_branch":"main","permissions":{"push":%t,"pull":true}}`, push)
case "/api/v1/repos/alice/project/issue_templates":
_, _ = w.Write([]byte(`[{"name":"Bug","about":"Report","title":"bug: ","content":"## Steps"}]`))
case "/api/v1/repos/alice/project/assignees":
_, _ = w.Write([]byte(`[{"login":"alice"},{"login":"bob"}]`))
case "/api/v1/repos/alice/project/milestones":
assert.Equal(t, "open", r.URL.Query().Get("state"))
firstPage(`[{"id":4,"title":"Release","state":"open"}]`)
case "/api/v1/repos/alice/project/labels":
firstPage(`[{"id":2,"name":"bug","color":"ff0000"},{"id":3,"name":"docs","color":"00ff00"}]`)
default:
next(w, r)
}
}
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// preferences applies environment overrides to the stored preferences, matching config get --show-source.
func (a *application) preferences() (settings.Preferences, error) {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return settings.Preferences{}, err
}
preferences, _, err := a.effectivePreferences(snapshot)
return preferences, err
}
func (a *application) colorEnabled() bool {
preferences, _ := a.preferences()
return terminal.ColorEnabled(preferences.Color, a.deps.Streams.OutTTY, a.deps.Getenv)
}
// writeHuman pages multi-line human list/view text. JSON callers must not use it.
// --no-input does not disable paging.
func (a *application) writeHuman(cmd *cobra.Command, text string) error {
preferences, _ := a.preferences()
return terminal.WriteHuman(cmd.Context(), a.deps.Streams, preferences.Pager, a.deps.Pager, text)
}
// writeJSON writes v as JSON, filtered by --json fields and --jq.
func (a *application) writeJSON(cmd *cobra.Command, v any) error {
return terminal.WriteJSON(cmd.OutOrStdout(), v, a.options.json, a.options.jq)
}
// abortBatch stops a batch instead of recording an item failure.
type abortBatch struct{ error }
func (e abortBatch) Unwrap() error { return e.error }
// batch applies run to each selection and continues past individual failures, which
// it reports prefixed with the selection's label. Human output is one table of the
// results; JSON is an object for one selection (none if it failed), otherwise an array.
func batch[K, T any](a *application, cmd *cobra.Command, keys []K, label func(K) string, run func(K) (T, error), row func(T) []terminal.Cell) error {
results := []T{}
var failures []error
for _, key := range keys {
if err := cmd.Context().Err(); err != nil {
failures = append(failures, err)
break
}
item, err := run(key)
if errors.As(err, new(abortBatch)) {
failures = append(failures, err)
break
}
if err != nil {
failures = append(failures, fmt.Errorf("%s: %w", label(key), err))
continue
}
results = append(results, item)
}
var err error
switch {
case a.options.json == "":
err = a.writeHuman(cmd, table(results, row, a.colorEnabled()))
case len(keys) != 1:
err = a.writeJSON(cmd, results)
case len(results) == 1:
err = a.writeJSON(cmd, results[0])
}
return errors.Join(append(failures, err)...)
}
// writeList writes items as a JSON array, or as one aligned table through the pager.
func writeList[T any](a *application, cmd *cobra.Command, items []T, row func(T) []terminal.Cell) error {
if a.options.json != "" {
return a.writeJSON(cmd, items)
}
return a.writeHuman(cmd, table(items, row, a.colorEnabled()))
}
// writeItem writes one item as a JSON object, or as its list row.
func writeItem[T any](a *application, cmd *cobra.Command, item T, row func(T) []terminal.Cell) error {
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return a.writeHuman(cmd, table([]T{item}, row, a.colorEnabled()))
}
// writeRecord writes one item as a JSON object, or as a human-readable record.
func writeRecord[T any](a *application, cmd *cobra.Command, item T, record func(T) terminal.Record) error {
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return a.writeHuman(cmd, record(item).Render(a.colorEnabled()))
}
// writeRecords writes items as a JSON array, or as consecutive human-readable records.
func writeRecords[T any](a *application, cmd *cobra.Command, items []T, record func(T) terminal.Record) error {
if a.options.json != "" {
return a.writeJSON(cmd, items)
}
color := a.colorEnabled()
var text strings.Builder
for _, item := range items {
text.WriteString(record(item).Render(color))
}
return a.writeHuman(cmd, text.String())
}
// numberedRecord heads an issue or pull request with its emphasized number and title.
func numberedRecord(number int64, title, url string) terminal.Record {
return terminal.Record{Heading: []terminal.Cell{{Text: fmt.Sprintf("#%d", number), Bold: true}, {Text: title}}, Lines: []string{url}}
}
// stateColors colors issue, pull request and milestone states on color terminals.
var stateColors = map[string]string{"open": "2da44e", "closed": "cf222e", "merged": "8250df"}
func table[T any](items []T, row func(T) []terminal.Cell, color bool) string {
rows := make([][]terminal.Cell, len(items))
for i, item := range items {
rows[i] = row(item)
}
return terminal.Table(rows, color)
}
// morePagesHint tells human readers on stderr that the listing continues past this page.
func (a *application) morePagesHint(cmd *cobra.Command, more bool, page int) error {
if !more || a.options.json != "" {
return nil
}
_, err := fmt.Fprintf(cmd.ErrOrStderr(), "more results available; use --page %d\n", page+1)
return err
}
+212
View File
@@ -0,0 +1,212 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"testing"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type recordingPager struct {
command string
input string
calls int
}
func (p *recordingPager) Page(_ context.Context, command string, input io.Reader, streams terminal.Streams) error {
p.calls++
p.command = command
data, err := io.ReadAll(input)
if err != nil {
return err
}
p.input = string(data)
_, err = io.WriteString(streams.Out, "PAGED:"+string(data))
return err
}
// pagedDeps returns serverDeps on a terminal stdout with a recording pager,
// extra environment variables and the given config settings.
func pagedDeps(t *testing.T, serverURL string, env map[string]string, config ...string) (Dependencies, *bytes.Buffer, *bytes.Buffer, *recordingPager) {
t.Helper()
deps, out, diagnostics := testDeps(t)
pager := &recordingPager{}
deps.Pager = pager
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(serverURL, env)
for i := 0; i < len(config); i += 2 {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", config[i], config[i+1]}))
}
return deps, out, diagnostics, pager
}
func TestLabelListAppliesColorAndPagesWithNoInput(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/labels", r.URL.Path)
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":"Defect"},{"id":2,"name":"docs","color":"00ff00","description":"Docs"}]`))
}))
defer server.Close()
deps, out, diagnostics, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager"}, "color", "always")
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, 1, pager.calls)
require.Equal(t, "fake-pager", pager.command)
require.Contains(t, pager.input, "\x1b[38;2;255;0;0mbug\x1b[0m")
require.Contains(t, pager.input, "\x1b[38;2;0;255;0mdocs\x1b[0m")
require.Equal(t, "PAGED:"+pager.input, out.String())
require.Empty(t, diagnostics.String())
}
func TestIssueListRespectsColorNeverAndStillPages(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":1,"title":"One","state":"open"},{"number":2,"title":"Two","state":"closed"}]`))
}))
defer server.Close()
deps, out, _, pager := pagedDeps(t, server.URL, nil, "color", "never", "pager", "configured-pager")
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, 1, pager.calls)
require.Equal(t, "configured-pager", pager.command)
require.NotContains(t, pager.input, "\x1b[")
require.Contains(t, pager.input, "open")
require.Contains(t, pager.input, "closed")
require.Equal(t, "PAGED:"+pager.input, out.String())
}
func TestIssueViewJSONDoesNotPage(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"number":7,"title":"Title","html_url":"https://example.org/alice/project/issues/7","body":"line1\nline2\nline3"}`)
}))
defer server.Close()
deps, out, _, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager"})
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "view", "7", "-R", "alice/project", "--json=number,title", "--no-input"}))
require.Zero(t, pager.calls)
require.JSONEq(t, `{"number":7,"title":"Title"}`, out.String())
}
func TestConfiguredPagerOverridesPagerEnvironment(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":1,"title":"One"},{"number":2,"title":"Two"}]`))
}))
defer server.Close()
deps, _, _, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "env-pager"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "env-pager", pager.command)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "pager", "configured-pager"}))
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "configured-pager", pager.command)
}
func TestTeaPagerAndColorEnvironmentOverrides(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":""},{"id":2,"name":"docs","color":"00ff00","description":""}]`))
}))
defer server.Close()
env := map[string]string{"TEA_PAGER": "tea-pager", "PAGER": "generic-pager", "TEA_COLOR": "always"}
deps, out, _, pager := pagedDeps(t, server.URL, env, "pager", "configured-pager", "color", "never")
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "tea-pager", pager.command)
require.Contains(t, pager.input, "\x1b[38;2;255;0;0mbug\x1b[0m")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "pager", "--show-source", "--json"}))
require.JSONEq(t, `{"pager":{"value":"tea-pager","source":"TEA_PAGER"}}`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "color", "--show-source", "--json"}))
require.JSONEq(t, `{"color":{"value":"always","source":"TEA_COLOR"}}`, out.String())
}
func TestColorAutoUsesStdoutTTY(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":""}]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Streams.OutTTY = false
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "1 bug ff0000\n", out.String())
require.NotContains(t, out.String(), "\x1b[")
out.Reset()
deps.Streams.OutTTY = true
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Contains(t, out.String(), "\x1b[38;2;255;0;0mbug\x1b[0m")
}
func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
issue := func(n int) string {
return fmt.Sprintf(`{"number":%d,"title":"Issue %d","state":"closed","html_url":"https://example.org/i/%d"}`, n, n, n)
}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/milestones/3":
_, _ = w.Write([]byte(`{"id":3,"title":"v1"}`))
case "/api/v1/repos/alice/project/issues":
_, _ = w.Write([]byte("[" + issue(1) + "," + issue(2) + "]"))
case "/api/v1/repos/alice/project/issues/1", "/api/v1/repos/alice/project/issues/2":
_, _ = w.Write([]byte(issue(int(r.URL.Path[len(r.URL.Path)-1] - '0'))))
case "/api/v1/repos/alice/project/branches":
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc"}},{"name":"dev","commit":{"id":"def"}}]`))
case "/api/v1/repos/alice/project/issues/5/comments":
_, _ = w.Write([]byte(`[{"id":1,"body":"one","user":{"login":"alice"}},{"id":2,"body":"two","user":{"login":"bob"}}]`))
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
for _, tc := range []struct {
args []string
want string
}{
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"issue", "close", "1", "2"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"branch", "list"}, "main abc false\ndev def false\n"},
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never")
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project", "--yes")))
require.Equal(t, 1, pager.calls)
require.Equal(t, tc.want, pager.input)
require.Equal(t, "PAGED:"+tc.want, out.String())
})
}
}
func TestListsHintWhenMorePagesExist(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
size, total := min(limit, 50), 120
items := []string{}
for id := (page-1)*size + 1; id <= min(page*size, total); id++ {
items = append(items, fmt.Sprintf(`{"id":%d,"username":"org%d","full_name":"owner/repo%d"}`, id, id, id))
}
w.Header().Set("X-Total-Count", strconv.Itoa(total))
_, _ = w.Write([]byte("[" + strings.Join(items, ",") + "]"))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
for _, args := range [][]string{{"org", "list"}, {"repo", "list"}, {"issue", "list", "-R", "alice/project"}, {"pr", "list", "-R", "alice/project"}} {
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--limit", "100")))
require.Len(t, strings.Split(strings.TrimSpace(out.String()), "\n"), 100, args)
require.Equal(t, "more results available; use --page 2\n", diagnostics.String(), args)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--limit", "100", "--page", "2")))
require.Len(t, strings.Split(strings.TrimSpace(out.String()), "\n"), 20, args)
require.Empty(t, diagnostics.String(), args)
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--json=id")))
require.Empty(t, diagnostics.String(), args)
}
}
+292
View File
@@ -0,0 +1,292 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"encoding/pem"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/kballard/go-shellquote"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// giteaTarget is the disposable instance configured for TestGitea* integration tests.
type giteaTarget struct{ url, token string }
// giteaDeps skips unless a disposable Gitea instance is configured and returns
// dependencies authenticated against it that run the system Git binary.
func giteaDeps(t *testing.T) (giteaTarget, Dependencies, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
instance, tokenFile := os.Getenv("GITEA_TEA_TEST_URL"), os.Getenv("GITEA_TEA_TEST_TOKEN_FILE")
if instance == "" || tokenFile == "" {
t.Skip("disposable Gitea environment is not configured")
}
gitea := giteaTarget{url: instance, token: readTokenFile(t, tokenFile)}
deps, out, diagnostics := testDeps(t)
deps.Git = nil
deps.Getenv = gitea.env(gitea.token)
return gitea, deps, out, diagnostics
}
func (g giteaTarget) env(token string) func(string) string {
return envFunc(map[string]string{"TEA_HOST": g.url, "GITEA_TOKEN": token})
}
func readTokenFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err)
token := strings.TrimSpace(string(data))
require.NotEmpty(t, token)
return token
}
// outputExecutor runs commands with the current value of *deps and returns their
// standard output, requiring success without diagnostics.
func outputExecutor(t *testing.T, deps *Dependencies, out, diagnostics *bytes.Buffer) func(args ...string) []byte {
return func(args ...string) []byte {
t.Helper()
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), *deps, args))
require.Empty(t, diagnostics.String())
return bytes.Clone(out.Bytes())
}
}
// withEnv overlays vars on an existing environment lookup.
func withEnv(getenv func(string) string, vars map[string]string) func(string) string {
return func(key string) string {
if value, ok := vars[key]; ok {
return value
}
return getenv(key)
}
}
func verifyResourceBrowser(t *testing.T, deps Dependencies, resource string, args ...string) {
t.Helper()
require.NotEmpty(t, resource)
browser := &recordingBrowser{}
deps.Browser = browser
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
require.NoError(t, Execute(context.Background(), deps, append(args, "--web", "--no-input")))
require.Equal(t, resource, browser.resource)
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
}
func verifyEditorSubmission(t *testing.T, deps Dependencies, repository, number string, review bool) {
t.Helper()
if runtime.GOOS == "windows" {
t.Log("POSIX editor integration fixture is unavailable on Windows")
return
}
directory := t.TempDir()
program := filepath.Join(directory, "editor with spaces")
require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\nprintf 'Editor integration body' > \"$1\"\n"), 0o700))
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
deps.Editor = terminal.SystemEditor{Directory: directory, Streams: deps.Streams}
deps.Getenv = withEnv(deps.Getenv, map[string]string{"TEA_EDITOR": shellquote.Join(program)})
args := []string{"issue", "comment", "create", number}
if review {
args = []string{"pr", "review", number, "--event", "PENDING"}
}
require.NoError(t, Execute(context.Background(), deps, append(args, "--repo", repository, "--editor", "--no-input", "--json")))
var result struct {
ID int64 `json:"id"`
Body string `json:"body"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
require.Equal(t, "Editor integration body", result.Body)
require.Positive(t, result.ID)
defer func() {
args := []string{"issue", "comment", "delete", fmt.Sprint(result.ID)}
if review {
args = []string{"pr", "review", "delete", number, fmt.Sprint(result.ID)}
}
if err := Execute(context.Background(), deps, append(args, "--repo", repository, "--yes")); err != nil {
t.Errorf("editor submission cleanup: %v", err)
}
}()
files, err := filepath.Glob(filepath.Join(directory, "tea-draft-*.md"))
require.NoError(t, err)
require.Empty(t, files)
require.Empty(t, diagnostics.String())
}
func verifyBodyEditorIntegration(t *testing.T, deps Dependencies, group, repository, number, previous string) {
t.Helper()
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
base := []string{group, "edit", number, "--repo", repository, "--no-input"}
require.NoError(t, Execute(context.Background(), deps, append(base, "--body", "Original editor body")))
defer func() {
if err := Execute(context.Background(), deps, append(base, "--body", previous)); err != nil {
t.Errorf("editor body restoration: %v", err)
}
}()
editor, path := newDraftEditor(t, "Edited server body")
deps.Editor = editor
deps.Getenv = withEnv(deps.Getenv, map[string]string{"TEA_EDITOR": "integration-editor"})
out.Reset()
require.NoError(t, Execute(context.Background(), deps, append(base, "--editor", "--json=body")))
require.Equal(t, "Original editor body", editor.initial)
require.JSONEq(t, `{"body":"Edited server body"}`, out.String())
requireDraft(t, path, false)
require.Empty(t, diagnostics.String())
}
func executeWithEditorIntegration(t *testing.T, deps Dependencies, initial, body string, args ...string) []byte {
t.Helper()
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
editor, path := newDraftEditor(t, body)
deps.Editor = editor
deps.Getenv = withEnv(deps.Getenv, map[string]string{"TEA_EDITOR": "integration-editor"})
require.NoError(t, Execute(context.Background(), deps, append(args, "--editor", "--no-input", "--json")))
require.Equal(t, initial, editor.initial)
require.Equal(t, "integration-editor", editor.command)
requireDraft(t, path, false)
require.Empty(t, diagnostics.String())
return bytes.Clone(out.Bytes())
}
func verifyIssueAttachmentLifecycle(t *testing.T, deps Dependencies, group, repository, number string) {
t.Helper()
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
deps.Streams.Out, deps.Streams.ErrOut = out, diagnostics
run := outputExecutor(t, &deps, out, diagnostics)
execute := func(args ...string) []byte {
t.Helper()
return run(slices.Concat([]string{group, "attachment"}, args, []string{"--repo", repository, "--no-input", "--json"})...)
}
path := filepath.Join(t.TempDir(), "attachment.txt")
require.NoError(t, os.WriteFile(path, []byte("Disposable attachment content\n"), 0o600))
var item services.IssueAttachment
require.NoError(t, json.Unmarshal(execute("upload", number, path), &item))
require.Positive(t, item.ID)
require.Equal(t, "attachment.txt", item.Name)
require.EqualValues(t, 30, item.Size)
id := fmt.Sprint(item.ID)
downloaded := filepath.Join(t.TempDir(), "downloaded.txt")
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "download", number, id, "--repo", repository, "--no-input", "--output-file", downloaded}))
content, err := os.ReadFile(downloaded)
require.NoError(t, err)
require.Equal(t, "Disposable attachment content\n", string(content))
info, err := os.Stat(downloaded)
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
deleted := false
defer func() {
if !deleted {
if err := Execute(context.Background(), deps, []string{group, "attachment", "delete", number, id, "--repo", repository, "--yes", "--no-input"}); err != nil {
t.Errorf("attachment cleanup: %v", err)
}
}
}()
require.NoError(t, json.Unmarshal(execute("edit", number, id, "--name", "renamed.txt"), &item))
require.Equal(t, "renamed.txt", item.Name)
require.NoError(t, json.Unmarshal(execute("view", number, id), &item))
require.Equal(t, "renamed.txt", item.Name)
var items []services.IssueAttachment
require.NoError(t, json.Unmarshal(execute("list", number), &items))
require.Len(t, items, 1)
require.Equal(t, item.ID, items[0].ID)
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "delete", number, id, "--repo", repository, "--yes", "--no-input"}))
deleted = true
require.JSONEq(t, `[]`, string(execute("list", number)))
}
func verifySSHCloneAndCheckout(t *testing.T, deps Dependencies, repo services.Repository, pull services.Pull, user string, environment []string, endpoint string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("SSH fixture uses a POSIX wrapper")
}
directory := t.TempDir()
public, private, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
block, err := ssh.MarshalPrivateKey(private, "disposable Tea SSH fixture")
require.NoError(t, err)
keyFile := filepath.Join(directory, "key with spaces")
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(block), 0o600))
publicKey, err := ssh.NewPublicKey(public)
require.NoError(t, err)
out := &bytes.Buffer{}
deps.Streams.Out = out
deps.Streams.In = bytes.NewReader(ssh.MarshalAuthorizedKey(publicKey))
require.NoError(t, Execute(context.Background(), deps, []string{"ssh-key", "create", "-", "--title", "Disposable SSH transport", "--json"}))
var registered services.SSHKey
require.NoError(t, json.Unmarshal(out.Bytes(), &registered))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"ssh-key", "delete", fmt.Sprint(registered.ID), "--yes"}); err != nil {
t.Errorf("SSH key cleanup: %v", err)
}
})
host, port, err := net.SplitHostPort(endpoint)
require.NoError(t, err)
scan := exec.CommandContext(context.Background(), "ssh-keyscan", "-4", "-T", "5", "-p", port, host)
known, err := scan.Output()
require.NoError(t, err)
require.NotEmpty(t, known)
knownFile := filepath.Join(directory, "known_hosts")
require.NoError(t, os.WriteFile(knownFile, known, 0o600))
realSSH, err := exec.LookPath("ssh")
require.NoError(t, err)
wrapper := "#!/bin/sh\nexec " + shellquote.Join(realSSH, "-4", "-oUserKnownHostsFile="+knownFile, "-oStrictHostKeyChecking=yes") + " \"$@\"\n"
require.NoError(t, os.WriteFile(filepath.Join(directory, "ssh"), []byte(wrapper), 0o700))
env := []string{}
for _, value := range environment {
key, _, _ := strings.Cut(value, "=")
if key != "PATH" && key != "GIT_SSH_COMMAND" && key != "GIT_SSH" {
env = append(env, value)
}
}
env = append(env, "PATH="+directory+string(os.PathListSeparator)+os.Getenv("PATH"), "GIT_SSH_VARIANT=ssh")
deps.Git = git.System{Env: env, ErrOut: deps.Streams.ErrOut}
instance := deps.Getenv("TEA_HOST")
require.NoError(t, deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
h := s.Hosts.Hosts[instance]
h.SSHHost, h.GitProtocol = endpoint, "ssh"
account := h.Users[user]
account.SSHKey, account.SSHAgent = keyFile, false
h.Users[user] = account
s.Hosts.Hosts[instance] = h
return nil
}))
clone := filepath.Join(directory, "private-clone")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", repo.FullName, clone, "--no-input", "--json=id"}))
require.JSONEq(t, fmt.Sprintf(`{"id":%d}`, repo.ID), out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", fmt.Sprint(pull.Number), "--repo", repo.FullName, "--cwd", clone, "--detach", "--no-input", "--json"}))
var checkout git.CheckoutResult
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
require.Equal(t, pull.HeadSHA, checkout.Commit)
require.True(t, checkout.Detached)
_, err = deps.Git.Output(context.Background(), clone, false, "config", "--local", "--get", "core.sshCommand")
var missing *git.CommandError
require.ErrorAs(t, err, &missing)
require.Equal(t, 1, missing.ExitCode)
}
+491
View File
@@ -0,0 +1,491 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/transport"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func TestGiteaMetadataLifecycle(t *testing.T) {
_, deps, out, diagnostics := giteaDeps(t)
execute := outputExecutor(t, &deps, out, diagnostics)
adminUsername := fmt.Sprintf("tea-it-admin-%d", time.Now().UnixNano())
var managedUser services.AdminUser
adminData := execute("admin", "user", "create", adminUsername, "--email", adminUsername+"@example.test", "--password", "local-admin-test-password", "--admin", "--restricted", "--prohibit-login", "--visibility", "limited", "--json")
require.NotContains(t, string(adminData), "local-admin-test-password")
require.NoError(t, json.Unmarshal(adminData, &managedUser))
require.True(t, managedUser.Admin)
require.True(t, managedUser.Restricted)
require.True(t, managedUser.ProhibitLogin)
require.Equal(t, "limited", managedUser.Visibility)
userDeleted := false
t.Cleanup(func() {
if !userDeleted {
if err := Execute(context.Background(), deps, []string{"admin", "user", "delete", adminUsername, "--yes"}); err != nil {
t.Errorf("admin account cleanup: %v", err)
}
}
})
passwordPath := filepath.Join(t.TempDir(), "password")
require.NoError(t, os.WriteFile(passwordPath, []byte("updated-local-password\n"), 0o600))
require.NoError(t, json.Unmarshal(execute("admin", "user", "edit", adminUsername, "--admin=false", "--restricted=false", "--prohibit-login=false", "--active=false", "--full-name", "Managed Account", "--description", "Disposable", "--location", "Test server", "--website", "https://example.test", "--visibility", "private", "--max-repo-creation", "0", "--allow-git-hook=false", "--allow-import-local=false", "--allow-create-organization=false", "--password-file", passwordPath, "--must-change-password=false", "--json"), &managedUser))
require.False(t, managedUser.Admin)
require.False(t, managedUser.Restricted)
require.False(t, managedUser.ProhibitLogin)
require.False(t, managedUser.Active)
require.Equal(t, "Managed Account", managedUser.FullName)
require.Equal(t, "private", managedUser.Visibility)
require.NoError(t, json.Unmarshal(execute("admin", "user", "view", adminUsername, "--json"), &managedUser))
require.Equal(t, "Disposable", managedUser.Description)
var managedUsers []services.AdminUser
require.NoError(t, json.Unmarshal(execute("admin", "user", "list", "--json"), &managedUsers))
require.Contains(t, managedUsers, managedUser)
execute("admin", "user", "delete", adminUsername, "--yes")
userDeleted = true
name := fmt.Sprintf("tea-it-%d", time.Now().UnixNano())
var repo services.Repository
require.NoError(t, json.Unmarshal(execute("repo", "create", name, "--auto-init", "--private", "--json"), &repo))
require.Equal(t, name, repo.Name)
t.Cleanup(func() {
out.Reset()
err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", repo.FullName, "--yes"})
if err != nil {
t.Errorf("repository cleanup: %v", err)
}
})
require.NoError(t, json.Unmarshal(execute("repo", "edit", "-R", repo.FullName, "--name", name+"-renamed", "--website", "https://example.test", "--default-branch", repo.DefaultBranch, "--json"), &repo))
require.Equal(t, name+"-renamed", repo.Name)
require.Equal(t, "https://example.test", repo.Website)
require.NoError(t, json.Unmarshal(execute("repo", "edit", "-R", repo.FullName, "--website=", "--archived=false", "--json"), &repo))
require.Empty(t, repo.Website)
require.False(t, repo.Archived)
scoped := func(args ...string) []byte { return execute(append(args, "-R", repo.FullName, "--json")...) }
templateName := fmt.Sprintf("tea-template-%d", time.Now().UnixNano())
var template, generated services.Repository
require.NoError(t, json.Unmarshal(execute("repo", "create", templateName, "--as-template", "--auto-init", "--default-branch", "trunk", "--private", "--json"), &template))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", template.FullName, "--yes"}); err != nil {
t.Errorf("template cleanup: %v", err)
}
})
require.True(t, template.Template)
generatedName := fmt.Sprintf("tea-generated-%d", time.Now().UnixNano())
require.NoError(t, json.Unmarshal(execute("repo", "create", generatedName, "--template", template.URL, "--private", "--copy-git-hooks=false", "--json"), &generated))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", generated.FullName, "--yes"}); err != nil {
t.Errorf("generated repository cleanup: %v", err)
}
})
require.Equal(t, "trunk", generated.DefaultBranch)
require.False(t, generated.Template)
var foundRepos []services.Repository
require.NoError(t, json.Unmarshal(execute("search", "repo", generatedName, "--owner", "integration", "--private", "--archived=false", "--type", "source", "--json"), &foundRepos))
require.Contains(t, foundRepos, generated)
execute("api", "user/starred/"+generated.FullName, "--method", "PUT")
require.NoError(t, json.Unmarshal(execute("repo", "list", "--starred", "--json"), &foundRepos))
require.Contains(t, foundRepos, generated)
execute("api", "user/starred/"+generated.FullName, "--method", "DELETE")
require.NoError(t, json.Unmarshal(execute("repo", "list", "--watched", "--limit", "1", "--page", "2", "--json"), &foundRepos))
require.Len(t, foundRepos, 1)
if serverRoot := os.Getenv("GITEA_TEA_TEST_REPOSITORY_ROOT"); serverRoot != "" {
migrationName := fmt.Sprintf("tea-migrated-%d", time.Now().UnixNano())
var migrated services.Repository
require.NoError(t, json.Unmarshal(execute("repo", "migrate", "--server-path", filepath.ToSlash(filepath.Join(serverRoot, repo.FullName+".git")), "--name", migrationName, "--private", "--json"), &migrated))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", migrated.FullName, "--yes"}); err != nil {
t.Errorf("migrated repository cleanup: %v", err)
}
})
require.Equal(t, repo.DefaultBranch, migrated.DefaultBranch)
require.True(t, migrated.Private)
}
var branch services.Branch
require.NoError(t, json.Unmarshal(scoped("branch", "create", "feature/initial"), &branch))
require.Equal(t, "feature/initial", branch.Name)
execute("branch", "rename", "feature/initial", "feature/renamed", "-R", repo.FullName)
execute("branch", "protect", "feature/renamed", "-R", repo.FullName)
require.NoError(t, json.Unmarshal(scoped("branch", "view", "feature/renamed"), &branch))
require.True(t, branch.Protected)
require.EqualValues(t, 1, branch.RequiredApprovals)
execute("branch", "unprotect", "feature/renamed", "-R", repo.FullName, "--yes")
require.NoError(t, json.Unmarshal(scoped("branch", "view", "feature/renamed"), &branch))
require.False(t, branch.Protected)
execute("branch", "delete", "feature/renamed", "-R", repo.FullName, "--yes")
var hook services.Webhook
require.NoError(t, json.Unmarshal(scoped("webhook", "create", "http://127.0.0.1:9/hook?key=private-value", "--secret", "local-signing-secret", "--authorization-header", "Bearer local-header", "--active=false", "--events", "issues,release", "--branch-filter", "feature/*"), &hook))
require.False(t, hook.Active)
hookID := strconv.FormatInt(hook.ID, 10)
hookData := scoped("webhook", "edit", hookID, "--active=false", "--clear-authorization")
require.NotContains(t, string(hookData), "local-signing-secret")
require.NotContains(t, string(hookData), "local-header")
require.NotContains(t, string(hookData), "private-value")
require.NoError(t, json.Unmarshal(hookData, &hook))
require.Equal(t, "feature/*", hook.BranchFilter)
require.Contains(t, hook.Events, "release")
require.Contains(t, hook.Events, "issues")
require.NoError(t, json.Unmarshal(scoped("webhook", "edit", hookID, "--events", "push", "--branch-filter=", "--clear-authorization"), &hook))
require.Empty(t, hook.BranchFilter)
require.Equal(t, []string{"push"}, hook.Events)
execute("webhook", "delete", hookID, "-R", repo.FullName, "--yes")
var hooks []services.Webhook
require.NoError(t, json.Unmarshal(scoped("webhook", "list"), &hooks))
require.Empty(t, hooks)
for _, scope := range []string{"--account", "--global"} {
require.NoError(t, json.Unmarshal(execute("webhook", "create", "http://127.0.0.1:9/hook", scope, "--active=false", "--json"), &hook))
hookID := strconv.FormatInt(hook.ID, 10)
require.NoError(t, json.Unmarshal(execute("webhook", "list", scope, "--json"), &hooks))
require.Contains(t, hooks, hook)
execute("webhook", "delete", hookID, scope, "--yes")
}
var label services.Label
require.NoError(t, json.Unmarshal(scoped("label", "create", "--name", "bug", "--color", "ff0000"), &label))
require.Positive(t, label.ID)
id := strconv.FormatInt(label.ID, 10)
require.NoError(t, json.Unmarshal(scoped("label", "edit", id, "--description", "updated"), &label))
require.Equal(t, "updated", label.Description)
var labels []services.Label
require.NoError(t, json.Unmarshal(scoped("label", "list"), &labels))
require.Len(t, labels, 1)
var labeledIssue services.Issue
require.NoError(t, json.Unmarshal(scoped("issue", "create", "--title", "Labeled issue", "--label", "repo:bug"), &labeledIssue))
require.Positive(t, labeledIssue.Number)
require.Equal(t, []services.Label{label}, labeledIssue.Labels)
require.NoError(t, json.Unmarshal(scoped("issue", "edit", strconv.FormatInt(labeledIssue.Number, 10), "--set-labels=", "--title", "Labels cleared"), &labeledIssue))
require.Empty(t, labeledIssue.Labels)
require.NotNil(t, labeledIssue.Labels)
execute("label", "delete", id, "-R", repo.FullName, "--yes")
var milestone services.Milestone
require.NoError(t, json.Unmarshal(scoped("milestone", "create", "--title", "v1", "--deadline", "2026-12-01"), &milestone))
require.Positive(t, milestone.ID)
ms := strconv.FormatInt(milestone.ID, 10)
require.NoError(t, json.Unmarshal(scoped("milestone", "close", ms), &milestone))
require.Equal(t, "closed", milestone.State)
require.NoError(t, json.Unmarshal(scoped("milestone", "reopen", ms), &milestone))
require.Equal(t, "open", milestone.State)
execute("milestone", "delete", ms, "-R", repo.FullName, "--yes")
var issue services.Issue
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, "", "Body", "issue", "create", "--repo", repo.FullName, "--title", "real API issue"), &issue))
require.Positive(t, issue.Number)
require.Equal(t, "real API issue", issue.Title)
issueNumber := strconv.FormatInt(issue.Number, 10)
require.Equal(t, "[]\n", string(scoped("issue", "attachment", "list", issueNumber)))
verifyResourceBrowser(t, deps, repo.URL, "repo", "view", "--repo", repo.FullName)
verifyResourceBrowser(t, deps, issue.URL, "issue", "view", issueNumber, "--repo", repo.FullName)
verifyBodyEditorIntegration(t, deps, "issue", repo.FullName, issueNumber, issue.Body)
verifyIssueAttachmentLifecycle(t, deps, "issue", repo.FullName, issueNumber)
var searchItems []services.SearchItem
for range 100 {
require.NoError(t, json.Unmarshal(scoped("search", "issue", "real API issue", "--authored"), &searchItems))
if len(searchItems) > 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
require.Len(t, searchItems, 1)
require.Equal(t, repo.FullName, searchItems[0].Repository)
require.Equal(t, "issue", searchItems[0].Type)
require.NoError(t, json.Unmarshal(execute("search", "issue", "real API issue", "--owner", "integration", "--authored", "--json"), &searchItems))
foundIssue := false
for _, item := range searchItems {
if item.ID == issue.ID {
foundIssue = true
}
}
require.True(t, foundIssue)
require.Equal(t, "[]\n", string(execute("search", "pr", name, "--json")))
require.NoError(t, json.Unmarshal(scoped("issue", "edit", issueNumber, "--title", "edited", "--body=", "--set-assignees", strings.Split(repo.FullName, "/")[0]), &issue))
require.Equal(t, "edited", issue.Title)
require.Empty(t, issue.Body)
var bulkEdited []services.Issue
require.NoError(t, json.Unmarshal(scoped("issue", "edit", issueNumber, strconv.FormatInt(labeledIssue.Number, 10), "--body", "Bulk edited body"), &bulkEdited))
require.Len(t, bulkEdited, 2)
for _, edited := range bulkEdited {
require.Equal(t, "Bulk edited body", edited.Body)
var saved services.Issue
require.NoError(t, json.Unmarshal(scoped("issue", "view", strconv.FormatInt(edited.Number, 10)), &saved))
require.Equal(t, edited.Body, saved.Body)
}
require.NoError(t, json.Unmarshal(scoped("repo", "edit", "--actions=false"), &repo))
require.False(t, repo.ActionsEnabled)
var accountStatus services.AccountStatus
require.NoError(t, json.Unmarshal(scoped("status"), &accountStatus))
require.Equal(t, "integration", accountStatus.User)
require.Equal(t, repo.FullName, accountStatus.Repository)
require.Len(t, accountStatus.AssignedIssues, 1)
require.Equal(t, issue.ID, accountStatus.AssignedIssues[0].ID)
require.False(t, accountStatus.RunsIncluded)
require.Empty(t, accountStatus.FailingRuns)
scoped("issue", "edit", issueNumber, "--set-assignees=")
var comment services.Comment
require.NoError(t, json.Unmarshal(scoped("issue", "comment", "create", issueNumber, "--body", "First comment"), &comment))
require.Positive(t, comment.ID)
commentID := strconv.FormatInt(comment.ID, 10)
require.NoError(t, json.Unmarshal(scoped("issue", "comment", "edit", commentID, "--body", "Edited comment"), &comment))
require.Equal(t, "Edited comment", comment.Body)
require.NoError(t, json.Unmarshal(scoped("issue", "comment", "view", commentID), &comment))
var comments []services.Comment
require.NoError(t, json.Unmarshal(scoped("issue", "comment", "list", issueNumber), &comments))
require.Len(t, comments, 1)
execute("issue", "comment", "delete", commentID, "-R", repo.FullName, "--yes")
verifyEditorSubmission(t, deps, repo.FullName, issueNumber, false)
require.Equal(t, "[]\n", string(scoped("issue", "comment", "list", issueNumber)))
require.NoError(t, json.Unmarshal(scoped("milestone", "create", "--title", "membership"), &milestone))
ms = strconv.FormatInt(milestone.ID, 10)
scoped("milestone", "issue", "add", ms, issueNumber)
var members []services.Issue
require.NoError(t, json.Unmarshal(scoped("milestone", "issue", "list", ms), &members))
require.Len(t, members, 1)
require.Equal(t, issue.Number, members[0].Number)
scoped("milestone", "issue", "remove", ms, issueNumber)
require.Equal(t, "[]\n", string(scoped("milestone", "issue", "list", ms)))
execute("milestone", "delete", ms, "-R", repo.FullName, "--yes")
require.Equal(t, "[]\n", string(scoped("wiki", "list")))
notifier := fmt.Sprintf("tea-notifier-%d", time.Now().UnixNano())
deps.Streams.In = bytes.NewBufferString(fmt.Sprintf(`{"username":%q,"password":"local-test-password!123","email":%q,"must_change_password":false}`, notifier, notifier+"@example.test"))
execute("api", "admin/users", "--input", "-")
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"api", "admin/users/" + notifier, "--method", "DELETE"}); err != nil {
t.Errorf("notifier cleanup: %v", err)
}
})
deps.Streams.In = bytes.NewBufferString(`{"permission":"write"}`)
execute("api", "repos/"+repo.FullName+"/collaborators/"+notifier, "--method", "PUT", "--input", "-")
deps.Streams.In = bytes.NewBufferString(`{"body":"Notification event"}`)
execute("api", "repos/"+repo.FullName+"/issues/"+issueNumber+"/comments?sudo="+notifier, "--input", "-")
var notifications []services.Notification
for range 100 {
require.NoError(t, json.Unmarshal(scoped("notification", "list", "--type", "issue"), &notifications))
if len(notifications) > 0 {
break
}
time.Sleep(100 * time.Millisecond)
}
require.NotEmpty(t, notifications)
var boundedNotifications []services.Notification
require.NoError(t, json.Unmarshal(scoped("notification", "list", "--type", "issue", "--since=24h", "--before=0s"), &boundedNotifications))
require.NotEmpty(t, boundedNotifications)
notificationID := strconv.FormatInt(notifications[0].ID, 10)
var notification services.Notification
require.NoError(t, json.Unmarshal(scoped("notification", "view", notificationID), &notification))
require.Equal(t, repo.FullName, notification.Repository)
require.NoError(t, json.Unmarshal(scoped("notification", "pin", notificationID), &notification))
require.True(t, notification.Pinned)
require.NoError(t, json.Unmarshal(scoped("notification", "unpin", notificationID), &notification))
require.False(t, notification.Pinned)
require.NoError(t, json.Unmarshal(scoped("notification", "mark-unread", notificationID), &notification))
require.True(t, notification.Unread)
require.NoError(t, json.Unmarshal(scoped("notification", "watch", "--timeout", "500ms", "--interval", "100ms"), &notifications))
require.NotEmpty(t, notifications)
scoped("notification", "mark-read", "--all")
require.Equal(t, "[]\n", string(scoped("notification", "list")))
var release services.Release
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, "", "Release notes", "release", "create", "v1", "--repo", repo.FullName, "--draft"), &release))
require.True(t, release.Draft)
require.Equal(t, "Release notes", release.Body)
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, release.Body, "Edited release notes", "release", "edit", "v1", "--repo", repo.FullName), &release))
require.Equal(t, "Edited release notes", release.Body)
require.NoError(t, json.Unmarshal(scoped("release", "view", "v1"), &release))
require.Equal(t, "Edited release notes", release.Body)
require.NoError(t, json.Unmarshal(scoped("release", "edit", "v1", "--title", "First release", "--prerelease"), &release))
require.Equal(t, "First release", release.Title)
require.True(t, release.Prerelease)
require.NoError(t, json.Unmarshal(scoped("release", "publish", "v1"), &release))
require.False(t, release.Draft)
assetFile := filepath.Join(t.TempDir(), "artifact.txt")
require.NoError(t, os.WriteFile(assetFile, []byte("release asset bytes"), 0o600))
var asset services.ReleaseAsset
require.NoError(t, json.Unmarshal(scoped("release", "asset", "upload", "v1", assetFile), &asset))
require.Equal(t, int64(19), asset.Size)
assetID := strconv.FormatInt(asset.ID, 10)
downloaded := execute("release", "asset", "download", "v1", assetID, "-R", repo.FullName, "--output-file", "-")
require.Equal(t, "release asset bytes", string(downloaded))
require.NoError(t, json.Unmarshal(scoped("release", "asset", "edit", "v1", assetID, "--name", "renamed.txt"), &asset))
require.Equal(t, "renamed.txt", asset.Name)
var assets []services.ReleaseAsset
require.NoError(t, json.Unmarshal(scoped("release", "asset", "list", "v1"), &assets))
require.Len(t, assets, 1)
execute("release", "asset", "delete", "v1", assetID, "-R", repo.FullName, "--yes")
secondAssetFile := filepath.Join(t.TempDir(), "second.txt")
require.NoError(t, os.WriteFile(secondAssetFile, []byte("second attachment"), 0o600))
var bulkAssets []services.ReleaseAsset
require.NoError(t, json.Unmarshal(scoped("release", "asset", "upload", "v1", assetFile, secondAssetFile), &bulkAssets))
require.Len(t, bulkAssets, 2)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"release", "asset", "delete", "v1", bulkAssets[0].Name, bulkAssets[1].Name, "--by-name", "-R", repo.FullName, "--yes"}))
require.Contains(t, diagnostics.String(), "Deleted asset")
scoped("release", "create", "v2", "--asset", assetFile)
require.NoError(t, json.Unmarshal(scoped("release", "asset", "list", "v2"), &assets))
require.Len(t, assets, 1)
var releases []services.Release
require.NoError(t, json.Unmarshal(scoped("release", "edit", "v1", "v2", "--title", "Updated releases"), &releases))
require.Len(t, releases, 2)
execute("release", "delete", "v1", "v2", "-R", repo.FullName, "--delete-tag", "--yes")
var tags []json.RawMessage
require.NoError(t, json.Unmarshal(execute("api", "repos/"+repo.FullName+"/tags"), &tags))
require.Empty(t, tags)
require.Equal(t, "[]\n", string(scoped("release", "list")))
var variable services.ActionVariable
require.NoError(t, json.Unmarshal(scoped("variable", "set", "BUILD_MODE", "--value", "first"), &variable))
require.Equal(t, "first", variable.Value)
scoped("variable", "set", "BUILD_MODE", "--value", "second")
require.NoError(t, json.Unmarshal(scoped("variable", "view", "BUILD_MODE"), &variable))
require.Equal(t, "second", variable.Value)
var variables []services.ActionVariable
require.NoError(t, json.Unmarshal(scoped("variable", "list"), &variables))
require.Len(t, variables, 1)
execute("variable", "delete", "BUILD_MODE", "-R", repo.FullName, "--yes")
require.Equal(t, "[]\n", string(scoped("variable", "list")))
secretOutput := scoped("secret", "set", "BUILD_TOKEN", "--value", "sensitive-local-test-value")
require.NotContains(t, string(secretOutput), "sensitive-local-test-value")
var secrets []services.ActionSecret
require.NoError(t, json.Unmarshal(scoped("secret", "list"), &secrets))
require.Len(t, secrets, 1)
require.Equal(t, "BUILD_TOKEN", secrets[0].Name)
execute("secret", "delete", "BUILD_TOKEN", "-R", repo.FullName, "--yes")
require.Equal(t, "[]\n", string(scoped("secret", "list")))
accountName := fmt.Sprintf("TEA_IT_%d", time.Now().UnixNano())
execute("variable", "set", accountName, "--account", "--value", "account value")
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"variable", "delete", accountName, "--account", "--yes"}); err != nil {
t.Errorf("account variable cleanup: %v", err)
}
})
require.NoError(t, json.Unmarshal(execute("variable", "view", accountName, "--account", "--json"), &variable))
require.Equal(t, "account value", variable.Value)
orgName := fmt.Sprintf("tea-it-org-%d", time.Now().UnixNano())
var organization services.Organization
require.NoError(t, json.Unmarshal(execute("org", "create", orgName, "--description", "Original", "--visibility", "private", "--json"), &organization))
require.Equal(t, "private", organization.Visibility)
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"org", "delete", orgName, "--yes"}); err != nil {
t.Errorf("organization cleanup: %v", err)
}
})
require.NoError(t, json.Unmarshal(execute("webhook", "create", "http://127.0.0.1:9/hook", "--org", orgName, "--active=false", "--json"), &hook))
execute("webhook", "delete", strconv.FormatInt(hook.ID, 10), "--org", orgName, "--yes")
require.NoError(t, json.Unmarshal(execute("org", "edit", orgName, "--description=", "--visibility", "limited", "--repo-admins-can-change-team-access", "--json"), &organization))
require.Empty(t, organization.Description)
require.True(t, organization.RepoAdminChangeTeamAccess)
require.NoError(t, json.Unmarshal(execute("org", "view", orgName, "--json"), &organization))
require.Equal(t, "limited", organization.Visibility)
var organizations []services.Organization
require.NoError(t, json.Unmarshal(execute("org", "list", "--json"), &organizations))
require.Contains(t, organizations, organization)
username, _, _ := strings.Cut(repo.FullName, "/")
execute("org", "member", "public", orgName, username)
var membersList []services.OrganizationMember
require.NoError(t, json.Unmarshal(execute("org", "member", "list", orgName, "--public", "--json"), &membersList))
require.Len(t, membersList, 1)
execute("org", "member", "private", orgName, username)
out.Reset()
membershipErr := Execute(context.Background(), deps, []string{"api", "orgs/" + orgName + "/public_members/" + username})
var missingMembership *transport.APIError
require.ErrorAs(t, membershipErr, &missingMembership)
require.Equal(t, 404, missingMembership.Status)
var team services.Team
require.NoError(t, json.Unmarshal(execute("team", "create", orgName, "--name", "Developers", "--unit", "repo.code=write,repo.issues=read", "--json"), &team))
teamID := strconv.FormatInt(team.ID, 10)
require.Equal(t, "write", team.Units["repo.code"])
require.NoError(t, json.Unmarshal(execute("team", "edit", teamID, "--description", "Edited team", "--all-repositories", "--json"), &team))
require.Equal(t, "write", team.Units["repo.code"])
require.True(t, team.AllRepositories)
require.NoError(t, json.Unmarshal(execute("team", "edit", teamID, "--all-repositories=false", "--json"), &team))
require.False(t, team.AllRepositories)
execute("team", "member", "add", teamID, username)
var teamMembers []services.OrganizationMember
require.NoError(t, json.Unmarshal(execute("team", "member", "list", teamID, "--json"), &teamMembers))
require.Len(t, teamMembers, 1)
execute("team", "member", "remove", teamID, username, "--yes")
require.Equal(t, "[]\n", string(execute("team", "member", "list", teamID, "--json")))
var orgRepo services.Repository
require.NoError(t, json.Unmarshal(execute("repo", "create", "team-repo", "--org", orgName, "--json"), &orgRepo))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", orgRepo.FullName, "--yes"}); err != nil {
t.Errorf("organization repository cleanup: %v", err)
}
})
execute("team", "repo", "add", teamID, "-R", orgRepo.FullName)
var teamRepos []services.Repository
require.NoError(t, json.Unmarshal(execute("team", "repo", "list", teamID, "--json"), &teamRepos))
require.Len(t, teamRepos, 1)
execute("team", "repo", "remove", teamID, "-R", orgRepo.FullName, "--yes")
require.Equal(t, "[]\n", string(execute("team", "repo", "list", teamID, "--json")))
execute("team", "delete", teamID, "--yes")
execute("variable", "set", "ORG_MODE", "--org", orgName, "--value", "org value")
require.NoError(t, json.Unmarshal(execute("variable", "view", "ORG_MODE", "--org", orgName, "--json"), &variable))
require.Equal(t, "org value", variable.Value)
execute("variable", "delete", "ORG_MODE", "--org", orgName, "--yes")
execute("secret", "set", "ORG_TOKEN", "--org", orgName, "--value", "org-local-secret")
require.NoError(t, json.Unmarshal(execute("secret", "list", "--org", orgName, "--json"), &secrets))
require.Len(t, secrets, 1)
execute("secret", "delete", "ORG_TOKEN", "--org", orgName, "--yes")
execute("secret", "set", accountName, "--account", "--value", "account-local-secret")
execute("secret", "delete", accountName, "--account", "--yes")
var wiki services.WikiPage
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, "", "# Unicode ü content", "wiki", "create", "--repo", repo.FullName, "--title", "Home", "--message", "Create page"), &wiki))
require.Equal(t, "# Unicode ü content", wiki.Body)
require.NoError(t, json.Unmarshal(scoped("wiki", "view", "Home"), &wiki))
require.Equal(t, "Home", wiki.Title)
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, wiki.Body, "Updated content", "wiki", "edit", "Home", "--repo", repo.FullName, "--message", "Edit page"), &wiki))
require.Equal(t, "Updated content", wiki.Body)
var pages []services.WikiPage
require.NoError(t, json.Unmarshal(scoped("wiki", "list"), &pages))
require.Len(t, pages, 1)
var revisions []services.WikiRevision
require.NoError(t, json.Unmarshal(scoped("wiki", "revisions", "Home"), &revisions))
require.Len(t, revisions, 2)
execute("wiki", "delete", "Home", "-R", repo.FullName, "--yes")
require.Equal(t, "[]\n", string(scoped("wiki", "list")))
var record services.TrackedTime
require.NoError(t, json.Unmarshal(scoped("time", "create", issueNumber, "1h25m"), &record))
require.Equal(t, int64(5100), record.Seconds)
timeID := strconv.FormatInt(record.ID, 10)
var records []services.TrackedTime
require.NoError(t, json.Unmarshal(scoped("time", "list", "--issue", issueNumber), &records))
require.Len(t, records, 1)
execute("time", "delete", issueNumber, timeID, "-R", repo.FullName, "--yes")
require.Equal(t, "[]\n", string(scoped("time", "list", "--issue", issueNumber)))
scoped("time", "create", issueNumber, "30s")
execute("time", "reset", issueNumber, "-R", repo.FullName, "--yes")
require.Equal(t, "[]\n", string(scoped("time", "list", "--issue", issueNumber)))
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
publicKey, err := ssh.NewPublicKey(privateKey.Public())
require.NoError(t, err)
keyFile := filepath.Join(t.TempDir(), "integration.pub")
require.NoError(t, os.WriteFile(keyFile, ssh.MarshalAuthorizedKey(publicKey), 0o600))
var key services.SSHKey
require.NoError(t, json.Unmarshal(execute("ssh-key", "create", keyFile, "--json"), &key))
require.Positive(t, key.ID)
keyID := strconv.FormatInt(key.ID, 10)
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"ssh-key", "delete", keyID, "--yes"}); err != nil {
t.Errorf("SSH key cleanup: %v", err)
}
})
require.NoError(t, json.Unmarshal(execute("ssh-key", "view", keyID, "--json"), &key))
require.Equal(t, "integration", key.Title)
var keys []services.SSHKey
require.NoError(t, json.Unmarshal(execute("ssh-key", "list", "--json"), &keys))
require.Contains(t, keys, key)
}
+156
View File
@@ -0,0 +1,156 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) issueAttachmentCommand() *cobra.Command {
group := &cobra.Command{Use: "attachment", Short: "Inspect issue or pull request attachments"}
var limit, page int
list := &cobra.Command{Use: "list <number>", Short: "List attachments", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Attachments(cmd.Context(), n, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, attachmentRow)
}}
pageFlags(list, &limit, &page)
view := &cobra.Command{Use: "view <number> <attachment-id>", Short: "View attachment metadata", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
id, err := number(args[1])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Issues{Client: target.client, Repository: target.Repo}).Attachment(cmd.Context(), n, id)
if err != nil {
return err
}
return writeItem(a, cmd, item, attachmentRow)
}}
for _, verb := range []string{"edit", "delete"} {
var name string
command := &cobra.Command{Use: verb + " <number> <attachment-id>", Short: titleCase(verb) + " an attachment", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
id, err := number(args[1])
if err != nil {
return err
}
if verb == "edit" && strings.TrimSpace(name) == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply a nonempty --name"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
if verb == "delete" {
if err := a.confirm(cmd, fmt.Sprintf("Delete attachment %d on %s#%d", id, target.Repo, n)); err != nil {
return err
}
return service.DeleteAttachment(cmd.Context(), n, id)
}
item, err := service.RenameAttachment(cmd.Context(), n, id, name)
if err != nil {
return err
}
return writeItem(a, cmd, item, attachmentRow)
}}
if verb == "edit" {
command.Flags().StringVar(&name, "name", "", "New attachment filename")
}
if verb == "delete" {
command.Annotations = textOutput("attachment deletion does not return a resource")
}
group.AddCommand(command)
}
var uploadName string
upload := &cobra.Command{Use: "upload <number> <file>", Short: "Upload one attachment", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
name := uploadName
if cmd.Flags().Changed("name") && strings.TrimSpace(name) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--name cannot be empty"))
}
if name == "" {
name = filepath.Base(args[1])
}
files, err := openRegularFiles(args[1:])
defer closeFiles(files)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Issues{Client: target.client, Repository: target.Repo}).UploadAttachment(cmd.Context(), n, name, files[0])
if err != nil {
return err
}
return writeItem(a, cmd, item, attachmentRow)
}}
upload.Flags().StringVar(&uploadName, "name", "", "Uploaded filename (default: local filename)")
var output string
download := &cobra.Command{Use: "download <number> <attachment-id>", Short: "Download an attachment to a file or stdout", Annotations: textOutput("attachment downloads are binary"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
id, err := number(args[1])
if err != nil {
return err
}
if err := validateDownloadDestination(output); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
body, err := (services.Issues{Client: target.client, Repository: target.Repo}).DownloadAttachment(cmd.Context(), n, id)
if err != nil {
return err
}
defer body.Close()
return writeDownload(cmd, body, output)
}}
download.Flags().StringVar(&output, "output-file", "", "Destination file (must not exist), or - for stdout")
group.AddCommand(list, view, upload, download)
return group
}
func attachmentRow(item services.IssueAttachment) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Name, strconv.FormatInt(item.Size, 10), item.URL)
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestAttachmentCommandsShareIssueAssetsAndPagination(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
switch r.URL.Path {
case "/api/v1/repos/alice/project/issues/1/assets":
_, _ = w.Write([]byte(`[{"id":1,"name":"one"},{"id":2,"name":"two"}]`))
case "/api/v1/repos/alice/project/issues/1/assets/1":
_, _ = w.Write([]byte(`{"id":1,"name":"one"}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
for _, group := range []string{"issue", "pr"} {
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "list", "1", "--repo", "alice/project", "--limit=1", "--page=2", "--json=id,name"}))
require.JSONEq(t, `[{"id":2,"name":"two"}]`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "list", "1", "--repo", "alice/project", "--limit=1", "--page=3", "--json"}))
require.JSONEq(t, `[]`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "view", "1", "1", "--repo", "alice/project", "--json=id,name"}))
require.JSONEq(t, `{"id":1,"name":"one"}`, out.String())
}
}
func TestAttachmentRenameAndConfirmedDeletion(t *testing.T) {
for _, group := range []string{"issue", "pr"} {
mutations := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/issues/1/assets/1", r.URL.Path)
mutations++
if r.Method == http.MethodPatch {
var body map[string]string
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
require.Equal(t, "renamed.txt", body["name"])
_, _ = w.Write([]byte(`{"id":1,"name":"renamed.txt"}`))
} else {
require.Equal(t, http.MethodDelete, r.Method)
w.WriteHeader(http.StatusNoContent)
}
}))
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "edit", "1", "1", "--repo", "alice/project", "--name", "renamed.txt", "--json=name"}))
require.JSONEq(t, `{"name":"renamed.txt"}`, out.String())
out.Reset()
err := Execute(context.Background(), deps, []string{group, "attachment", "delete", "1", "1", "--repo", "alice/project", "--no-input"})
require.Error(t, err)
require.Equal(t, 1, mutations)
require.NoError(t, Execute(context.Background(), deps, []string{group, "attachment", "delete", "1", "1", "--repo", "alice/project", "--yes", "--no-input"}))
require.Equal(t, 2, mutations)
require.Empty(t, out.String())
for _, args := range [][]string{
{group, "attachment", "edit", "1", "1", "--name="},
{group, "attachment", "delete", "1", "1", "--json"},
} {
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, args)))
}
require.Equal(t, 2, mutations)
server.Close()
}
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/stretchr/testify/require"
)
func TestIssueBulkEditContinuesAfterIndividualFailure(t *testing.T) {
var calls []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPatch, r.Method)
calls = append(calls, r.URL.Path)
var options services.IssueEdit
require.NoError(t, json.NewDecoder(r.Body).Decode(&options))
require.NotNil(t, options.Title)
require.Equal(t, "Updated", *options.Title)
if strings.HasSuffix(r.URL.Path, "/2") {
http.Error(w, `{"message":"permission denied"}`, http.StatusForbidden)
return
}
n := "1"
if strings.HasSuffix(r.URL.Path, "/3") {
n = "3"
}
_, _ = w.Write([]byte(`{"number":` + n + `,"title":"Updated"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"issue", "edit", "1", "2", "3", "--repo", "alice/project", "--title", "Updated", "--no-input", "--json=number,title"})
require.ErrorContains(t, err, "issue 2")
require.Equal(t, clierr.Failure, clierr.ExitCode(err))
require.JSONEq(t, `[{"number":1,"title":"Updated"},{"number":3,"title":"Updated"}]`, out.String())
require.Len(t, calls, 3)
require.Empty(t, diagnostics.String())
}
func TestIssueEditMergesAssigneesWithOneRead(t *testing.T) {
reads := 0
var patched []any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
reads++
_, _ = w.Write([]byte(`{"number":7,"assignees":[{"login":"alice"},{"login":"bob"}]}`))
return
}
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
patched = body["assignees"].([]any)
_, _ = w.Write([]byte(`{"number":7}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "edit", "7", "-R", "alice/project", "--add-assignees", "carol,carol", "--remove-assignees", "bob", "--json"}))
require.Equal(t, 1, reads)
require.Equal(t, []any{"alice", "carol"}, patched)
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type recordingIssueForm struct {
calls int
values terminal.IssueFormValues
choices terminal.IssueFormChoices
}
func (f *recordingIssueForm) CreateIssue(_ context.Context, choices terminal.IssueFormChoices) (terminal.IssueFormValues, error) {
f.calls++
f.choices = choices
return f.values, nil
}
func TestIssueCreateImplicitInteractiveForm(t *testing.T) {
deadline := time.Date(2028, 1, 15, 0, 0, 0, 0, time.UTC)
var createPayload map[string]any
server := newAuthenticatedServer(selectablesHandler(t, true, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/alice/project/issues" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&createPayload))
_, _ = w.Write([]byte(`{"number":9,"title":"Interactive","html_url":"https://example.org/alice/project/issues/9","state":"open"}`))
}))
defer server.Close()
form := &recordingIssueForm{values: terminal.IssueFormValues{
Title: "Interactive", Body: "Body text", Assignees: []string{"alice"}, Milestone: 4, Labels: []int64{2}, Deadline: &deadline,
}}
prompt := &scriptedPrompter{accept: true}
deps, out, diagnostics := serverDeps(t, server.URL)
deps.IssueForm = form
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--json=number,url", "--interactive"}))
require.Equal(t, 1, form.calls)
require.NotNil(t, form.choices.EditBody)
require.True(t, form.choices.CanPush)
require.Equal(t, []string{"alice", "bob"}, form.choices.Assignees)
require.Equal(t, []terminal.IssueMilestoneChoice{{Title: "Release", ID: 4}}, form.choices.Milestones)
require.Equal(t, []terminal.IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "docs", ID: 3}}, form.choices.Labels)
require.Equal(t, []terminal.IssueTemplateChoice{{Name: "Bug", About: "Report", Title: "bug: ", Body: "## Steps"}}, form.choices.Templates)
require.Len(t, prompt.confirmations, 1)
require.Equal(t, "Interactive", createPayload["title"])
require.Equal(t, "Body text", createPayload["body"])
require.Equal(t, []any{"alice"}, createPayload["assignees"])
require.Equal(t, float64(4), createPayload["milestone"])
require.Equal(t, []any{float64(2)}, createPayload["labels"])
require.Equal(t, "2028-01-15T00:00:00Z", createPayload["due_date"])
require.JSONEq(t, `{"number":9,"url":"https://example.org/alice/project/issues/9"}`, out.String())
require.Empty(t, diagnostics.String())
}
func TestIssueCreateInteractiveDraftSurvivesSubmissionFailure(t *testing.T) {
server := newAuthenticatedServer(selectablesHandler(t, false, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/repos/alice/project/issues" {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
_, path := newDraftEditor(t, "saved body")
form := &recordingIssueForm{values: terminal.IssueFormValues{
Title: "Interactive", Body: "saved body", Draft: terminal.Draft{Path: path, Body: "saved body"},
}}
deps, out, _ := serverDeps(t, server.URL)
deps.IssueForm = form
deps.Prompter = &scriptedPrompter{accept: true}
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
err := Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--interactive"})
require.ErrorContains(t, err, "draft saved at "+path)
require.NotNil(t, form.choices.EditBody)
requireDraft(t, path, true)
require.Empty(t, out.String())
}
func TestIssueCreateSkipsInteractiveFormWithoutTTY(t *testing.T) {
form := &recordingIssueForm{}
deps, _, _ := testDeps(t)
deps.IssueForm = form
deps.Streams.InTTY, deps.Streams.ErrTTY = false, false
err := Execute(context.Background(), deps, []string{"issue", "create", "-R", "alice/project", "--no-input"})
require.Error(t, err)
require.Contains(t, err.Error(), "supply --title")
require.Zero(t, form.calls)
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
func TestIssueCreateSeedsFromTemplate(t *testing.T) {
var created []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/issue_templates":
_, _ = w.Write([]byte(`[{"name":"Bug Report","title":"[bug] ","content":"Steps:"},{"name":"Feature","about":"Describe the idea","body":[{"type":"textarea"}]}]`))
case "/api/v1/repos/alice/project/issues":
require.Equal(t, http.MethodPost, r.Method)
payload, err := io.ReadAll(r.Body)
require.NoError(t, err)
created = append(created, string(payload))
_, _ = w.Write([]byte(`{"number":1}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
editor := &draftEditor{draft: terminal.Draft{Body: "edited idea"}}
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"issue", "create", "-R", "alice/project", "--no-input", "--json=number"}, args...))
}
require.NoError(t, run("--template", "bug report"))
require.NoError(t, run("--template", "Bug Report", "--title", "Explicit", "--body", "Override"))
require.NoError(t, run("--template", "feature", "--title", "Idea", "--editor"))
require.Equal(t, "Describe the idea", editor.initial)
err := run("--template", "missing", "--title", "x")
require.ErrorContains(t, err, `issue template "missing" does not exist; available: Bug Report, Feature`)
require.Equal(t, clierr.Failure, clierr.ExitCode(err))
err = run("--template", "feature")
require.ErrorContains(t, err, "missing required input: supply --title")
deps.Streams.In = strings.NewReader("From stdin")
require.NoError(t, run("--template", "bug report", "--body-file", "-"))
err = run("--template", "feature", "--body-file", "-")
require.ErrorContains(t, err, "--body-file - requires --title")
require.Len(t, created, 4)
for i, want := range []string{
`{"title":"[bug] ","body":"Steps:"}`,
`{"title":"Explicit","body":"Override"}`,
`{"title":"Idea","body":"edited idea"}`,
`{"title":"[bug] ","body":"From stdin"}`,
} {
requirePayload(t, want, strings.NewReader(created[i]))
}
}
func TestIssueCreateUploadsAttachmentsAndKeepsIssueOnFailure(t *testing.T) {
var requests, uploaded []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
switch r.URL.Path {
case "/api/v1/repos/alice/project/issues":
_, _ = w.Write([]byte(`{"number":5,"title":"Crash"}`))
case "/api/v1/repos/alice/project/issues/5/assets":
file, header, err := r.FormFile("attachment")
require.NoError(t, err)
content, err := io.ReadAll(file)
require.NoError(t, err)
uploaded = append(uploaded, header.Filename+"="+string(content))
if header.Filename == "second.txt" {
w.WriteHeader(http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": header.Filename})
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
dir := t.TempDir()
first, second := filepath.Join(dir, "first.txt"), filepath.Join(dir, "second.txt")
require.NoError(t, os.WriteFile(first, []byte("one"), 0o600))
args := []string{"issue", "create", "-R", "alice/project", "--title", "Crash", "--attachment", first, "--attachment", second, "--json=number,title"}
require.Error(t, Execute(context.Background(), deps, args))
require.Empty(t, requests)
require.NoError(t, os.WriteFile(second, []byte("two"), 0o600))
err := Execute(context.Background(), deps, args)
require.ErrorContains(t, err, "issue 5 created; attachment second.txt upload failed")
require.Equal(t, clierr.Failure, clierr.ExitCode(err))
require.JSONEq(t, `{"number":5,"title":"Crash"}`, out.String())
require.Equal(t, []string{"first.txt=one", "second.txt=two"}, uploaded)
require.Equal(t, []string{"POST /api/v1/repos/alice/project/issues", "POST /api/v1/repos/alice/project/issues/5/assets", "POST /api/v1/repos/alice/project/issues/5/assets"}, requests)
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) issueEditCommand() *cobra.Command {
var title, body, file string
var useEditor bool
var deadline string
var ref string
var milestone int64
var milestoneName string
var metadata metadataFlags
cmd := &cobra.Command{Use: "edit <number>...", Short: "Edit issue content, assignees, and milestone", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
numbers, err := resourceNumbers(args)
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !localFlagsChanged(cmd) {
if a.policy(snapshot).CanPrompt(a.deps.Streams) && a.deps.IssueEditForm != nil {
return a.editIssuesInteractively(cmd, numbers)
}
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea issue edit --help"))
}
if useEditor && len(numbers) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one issue"))
}
if err := validateMilestoneFlags(cmd, milestoneName, milestone); err != nil {
return err
}
opts := services.IssueEdit{}
if cmd.Flags().Changed("ref") {
opts.Ref = &ref
}
if cmd.Flags().Changed("deadline") {
opts.Deadline, opts.RemoveDeadline, err = parseDeadline(deadline)
if err != nil {
return err
}
}
if cmd.Flags().Changed("title") {
if strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("issue title cannot be empty"))
}
opts.Title = &title
}
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
body, err = a.readBody(cmd, body, file)
if err != nil {
return err
}
opts.Body = &body
}
if cmd.Flags().Changed("milestone") {
opts.Milestone = &milestone
}
if err := metadata.validate(cmd); err != nil {
return err
}
if opts == (services.IssueEdit{}) && !useEditor && !metadata.changed(cmd) && !cmd.Flags().Changed("milestone-name") {
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea issue edit --help"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if cmd.Flags().Changed("milestone-name") {
milestone, err = (services.Milestones{Client: target.client, Repository: target.Repo}).ResolveName(cmd.Context(), milestoneName)
if err != nil {
return err
}
opts.Milestone = &milestone
}
if opts.Labels, opts.Assignees, opts.Changes, err = metadata.resolve(cmd, target); err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
if useEditor {
current, err := service.View(cmd.Context(), numbers[0])
if err != nil {
return err
}
draft, err = a.editDraft(cmd, current.Body)
if err != nil {
return err
}
opts.Body = &draft.Body
}
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
return service.Edit(cmd.Context(), n, opts)
}, issueRow)
}}
cmd.Flags().StringVar(&title, "title", "", "Issue title")
cmd.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag; empty clears")
cmd.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears")
cmd.Flags().StringVar(&body, "body", "", "Issue body (empty clears)")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
cmd.Flags().BoolVar(&useEditor, "editor", false, "Edit the current issue body in the configured editor")
cmd.Flags().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone")
cmd.Flags().Int64Var(&milestone, "milestone", 0, "Milestone ID; 0 clears")
metadata.register(cmd)
return cmd
}
func (a *application) editIssuesInteractively(cmd *cobra.Command, numbers []int64) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
selectables, err := service.CreateSelectables(cmd.Context())
if err != nil {
return err
}
milestones, labels := milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels)
return batch(a, cmd, numbers, issueLabel, func(n int64) (item services.Issue, err error) {
current, err := service.View(cmd.Context(), n)
if err != nil {
return item, err
}
choices := terminal.IssueEditFormChoices{
Title: current.Title, Body: current.Body, Assignees: current.Assignees,
CanPush: selectables.CanPush, AvailableAssignees: selectables.Assignees, Milestones: milestones, AvailableLabels: labels,
EditBody: a.bodyEditor(cmd),
}
if current.Milestone != nil {
choices.Milestone = &terminal.IssueMilestoneChoice{Title: current.Milestone.Title, ID: current.Milestone.ID}
}
choices.Labels = labelChoices(current.Labels)
if current.Deadline != nil {
if parsed, err := time.Parse(time.RFC3339, *current.Deadline); err == nil {
choices.Deadline = &parsed
}
}
values, err := a.deps.IssueEditForm.EditIssue(cmd.Context(), choices)
defer finishDraft(&values.Draft, &err)
if err != nil {
return item, abortBatch{err}
}
if err := a.confirm(cmd, fmt.Sprintf("Edit issue %s#%d: %s", target.Repo, n, values.Title)); err != nil {
return item, abortBatch{err}
}
opts := services.IssueEdit{Title: &values.Title, Body: &values.Body}
if selectables.CanPush {
// Non-nil slices: an empty selection must clear rather than be omitted.
assignees, labels := append([]string{}, values.Assignees...), append([]int64{}, values.Labels...)
opts.Assignees, opts.Labels = &assignees, &labels
if values.ClearMilestone || values.Milestone != 0 {
opts.Milestone = &values.Milestone
}
opts.Deadline = values.Deadline
if values.ClearDeadline {
opts.RemoveDeadline = &values.ClearDeadline
}
}
return service.Edit(cmd.Context(), n, opts)
}, issueRow)
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type issueEditFormFunc func(context.Context, terminal.IssueEditFormChoices) (terminal.IssueEditFormValues, error)
func (f issueEditFormFunc) EditIssue(ctx context.Context, choices terminal.IssueEditFormChoices) (terminal.IssueEditFormValues, error) {
return f(ctx, choices)
}
func TestIssueEditImplicitInteractiveForm(t *testing.T) {
deadline := "2028-01-15T00:00:00Z"
var patchPayload map[string]any
var labelPayload map[string]any
server := newAuthenticatedServer(selectablesHandler(t, true, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/issues/5":
_, _ = w.Write([]byte(`{"id":5,"number":5,"title":"Old","body":"Old body","html_url":"https://example.org/issues/5","state":"open","assignees":[{"login":"alice"},{"login":"carol"}],"labels":[{"id":2,"name":"bug"},{"id":7,"name":"org"}],"milestone":{"id":9,"title":"Shipped"},"due_date":"` + deadline + `"}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/alice/project/issues/5/labels":
require.NoError(t, json.NewDecoder(r.Body).Decode(&labelPayload))
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/alice/project/issues/5":
require.NoError(t, json.NewDecoder(r.Body).Decode(&patchPayload))
_, _ = w.Write([]byte(`{"id":5,"number":5,"title":"Edited","body":"New body","html_url":"https://example.org/issues/5","state":"open"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
calls := 0
prompt := &scriptedPrompter{accept: true}
deps, out, diagnostics := serverDeps(t, server.URL)
deps.IssueEditForm = issueEditFormFunc(func(_ context.Context, choices terminal.IssueEditFormChoices) (terminal.IssueEditFormValues, error) {
require.NotNil(t, choices.EditBody)
calls++
require.Equal(t, "Old", choices.Title)
require.Equal(t, "Old body", choices.Body)
require.Equal(t, []string{"alice", "carol"}, choices.Assignees)
require.Equal(t, &terminal.IssueMilestoneChoice{Title: "Shipped", ID: 9}, choices.Milestone)
require.Equal(t, []terminal.IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "org", ID: 7}}, choices.Labels)
require.NotNil(t, choices.Deadline)
require.True(t, choices.CanPush)
require.Equal(t, []string{"alice", "bob"}, choices.AvailableAssignees)
due := time.Date(2028, 2, 1, 0, 0, 0, 0, time.UTC)
return terminal.IssueEditFormValues{
Title: "Edited", Body: "New body", Assignees: []string{"bob"}, Milestone: 4, Labels: []int64{3}, Deadline: &due,
}, nil
})
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "edit", "5", "-R", "alice/project", "--json=number,title", "--interactive"}))
require.Equal(t, 1, calls)
require.Len(t, prompt.confirmations, 1)
require.Equal(t, "Edited", patchPayload["title"])
require.Equal(t, "New body", patchPayload["body"])
require.Equal(t, []any{"bob"}, patchPayload["assignees"])
require.Equal(t, float64(4), patchPayload["milestone"])
require.Equal(t, []any{float64(3)}, labelPayload["labels"])
require.JSONEq(t, `{"number":5,"title":"Edited"}`, out.String())
require.Empty(t, diagnostics.String())
}
func TestIssueEditAddAndRemoveLabels(t *testing.T) {
puts := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/alice":
w.WriteHeader(http.StatusNotFound)
case r.URL.Path == "/api/v1/repos/alice/project/labels":
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`[]`))
return
}
_, _ = w.Write([]byte(`[{"id":1,"name":"bug"},{"id":2,"name":"docs"},{"id":3,"name":"help"}]`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/issues/7":
_, _ = w.Write([]byte(`{"number":7,"labels":[{"id":1,"name":"bug"},{"id":2,"name":"docs"}]}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/repos/alice/project/issues/7/labels":
puts++
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.JSONEq(t, `{"labels":[1,3]}`, string(body))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[{"id":1,"name":"bug"},{"id":3,"name":"help"}]`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "edit", "7", "-R", "alice/project", "--add-labels", "help", "--remove-labels", "docs", "--json=number,labels"}))
require.Equal(t, 1, puts)
require.Contains(t, out.String(), `"number":7`)
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestIssueListAuthorMentionsAndTimeBounds(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "/api/v1/repos/alice/project/issues", r.URL.Path)
query := r.URL.Query()
require.Equal(t, "alice", query.Get("created_by"))
require.Equal(t, "bob", query.Get("mentioned_by"))
require.Equal(t, "2026-10-03T12:00:00Z", query.Get("since"))
require.Equal(t, "2026-10-04T12:00:00Z", query.Get("before"))
require.Equal(t, "issues", query.Get("type"))
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--author", "alice", "--mentions", "bob", "--since", "24h", "--until", "0h", "--json"}))
require.JSONEq(t, `[]`, out.String())
require.Empty(t, diagnostics.String())
for _, bounds := range [][]string{{"--since", "2026-10-05", "--until", "2026-10-04"}, {"--since", "invalid"}, {"--until", "-1h"}, {"--since="}} {
out.Reset()
args := append([]string{"issue", "list", "-R", "alice/project"}, bounds...)
err := Execute(context.Background(), deps, args)
require.Error(t, err)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.Empty(t, out.String())
}
require.Equal(t, 1, calls)
}
func TestIssueLabelFiltersRejectUnknownRepositoryLabels(t *testing.T) {
var listed []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/labels":
if r.URL.Query().Get("page") == "1" {
_, _ = w.Write([]byte(`[{"id":1,"name":"bug"},{"id":2,"name":"help wanted"}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case "/api/v1/repos/alice/project/issues", "/api/v1/repos/issues/search":
listed = append(listed, r.URL.Path+"?labels="+r.URL.Query().Get("labels"))
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
for _, args := range [][]string{
{"issue", "list", "-R", "alice/project", "--label", "Typo"},
{"issue", "list", "-R", "alice/project", "--label", "bug,Typo"},
{"search", "issue", "-R", "alice/project", "--label", "Typo"},
{"search", "pr", "-R", "alice/project", "--label", "Typo"},
} {
err := Execute(context.Background(), deps, args)
require.Equal(t, clierr.Failure, clierr.ExitCode(err), "%v", err)
require.ErrorContains(t, err, `label "Typo" does not exist`)
}
require.Empty(t, listed)
require.Empty(t, out.String())
for _, args := range [][]string{
{"issue", "list", "-R", "alice/project", "--label", " bug, help wanted"},
{"search", "issue", "-R", "alice/project", "--label", "bug "},
{"search", "issue", "--label", "Typo"},
} {
require.NoError(t, Execute(context.Background(), deps, append(args, "--json")))
}
require.Equal(t, []string{
"/api/v1/repos/alice/project/issues?labels=bug,help wanted",
"/api/v1/repos/alice/project/issues?labels=bug",
"/api/v1/repos/issues/search?labels=Typo",
}, listed)
}
func TestIssueListLinksNumbersOnColorTerminals(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":7,"title":"Short","state":"open","user":{"login":"alice"},"html_url":"https://example.org/i/7"},` +
`{"number":12,"title":"Evil\u001b]52;c;aGk=\u0007\r\nTitle\u202e","state":"closed","user":{"login":"bob"},"html_url":"https://example.org/i/12"}]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
args := []string{"issue", "list", "-R", "alice/project"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "7 Short open alice\n12 Evil]52;c;aGk= Title closed bob\n", out.String())
out.Reset()
deps.Streams.OutTTY = true
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\7\x1b]8;;\x1b\\ Short \x1b[38;2;45;164;78mopen\x1b[0m alice\n"+
"\x1b]8;;https://example.org/i/12\x1b\\12\x1b]8;;\x1b\\ Evil]52;c;aGk= Title \x1b[38;2;207;34;46mclosed\x1b[0m bob\n", out.String())
}
func TestIssueListKindFilter(t *testing.T) {
var seen string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/issues", r.URL.Path)
seen = r.URL.Query().Get("type")
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--kind", "pulls", "--json"}))
require.Equal(t, "pulls", seen)
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"issue", "list", "--kind", "bugs"})))
}
func TestIssueListWithoutRepoPointsToSearch(t *testing.T) {
deps, out, _ := testDeps(t)
deps.Git = &fakeGit{}
err := Execute(context.Background(), deps, []string{"issue", "list", "--no-input"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "tea search issue")
require.Empty(t, out.String())
}
+97
View File
@@ -0,0 +1,97 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestIssueCreationMetadataAndReferenceClearing(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/owner/repo/issues", r.URL.Path)
requirePayload(t, `{"title":"Title","ref":"release/v1","assignees":["alice","bob"],"milestone":4}`, r.Body)
_, _ = w.Write([]byte(`{"number":1,"ref":"release/v1","assignees":[{"login":"alice","email":"excluded@example.test"},{"login":"bob"}],"labels":[{"id":2,"name":"bug","color":"ff0000","description":"Defect","exclusive":true}],"milestone":{"id":4,"title":"Release","description":"excluded"}}`))
return
}
require.Equal(t, http.MethodPatch, r.Method)
require.Equal(t, "/api/v1/repos/owner/repo/issues/1", r.URL.Path)
requirePayload(t, `{"ref":""}`, r.Body)
_, _ = w.Write([]byte(`{"number":1,"ref":"","assignees":null,"milestone":null}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
fields := "--json=number,ref,assignees,milestone,labels"
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "create", "-R", "owner/repo", "--title", "Title", "--ref", "release/v1", "--assignee", "alice,bob", "--milestone", "4", fields, "--no-input"}))
require.JSONEq(t, `{"number":1,"ref":"release/v1","assignees":["alice","bob"],"milestone":{"id":4,"title":"Release"},"labels":[{"id":2,"name":"bug","color":"ff0000","description":"Defect","exclusive":true,"is_archived":false}]}`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "edit", "1", "-R", "owner/repo", "--ref=", fields}))
require.JSONEq(t, `{"number":1,"ref":"","assignees":[],"milestone":null,"labels":[]}`, out.String())
require.Equal(t, 2, calls)
require.Empty(t, diagnostics.String())
}
func TestNameFlagsAreTrimmed(t *testing.T) {
var routes, payloads []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/owner":
w.WriteHeader(http.StatusNotFound)
case r.URL.Path == "/api/v1/repos/owner/repo/labels" && r.URL.Query().Get("page") == "1":
_, _ = w.Write([]byte(`[{"id":2,"name":"bug"}]`))
case r.URL.Path == "/api/v1/repos/owner/repo/labels":
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`{"number":1,"assignees":[{"login":"carol"}]}`))
default:
payload, err := io.ReadAll(r.Body)
require.NoError(t, err)
routes, payloads = append(routes, r.Method+" "+r.URL.Path), append(payloads, string(payload))
if strings.HasSuffix(r.URL.Path, "/requested_reviewers") {
w.WriteHeader(http.StatusNoContent)
return
}
_, _ = w.Write([]byte(`{"number":1}`))
}
}))
defer server.Close()
deps, _, diagnostics := serverDeps(t, server.URL)
for _, args := range [][]string{
{"issue", "create", "--title", "Title", "--assignee", " alice, bob ", "--label", " bug", "--no-input"},
{"issue", "edit", "1", "--add-assignees", " alice"},
{"pr", "review", "request", "1", "--reviewer", " alice"},
{"pr", "edit", "1", "--add-reviewers", "bob "},
{"pr", "create", "--title", "Title", "--head", "feature", "--base", "main", "--reviewer", " dave", "--no-input"},
} {
require.NoError(t, Execute(context.Background(), deps, append(args, "-R", "owner/repo", "--json=number")), args)
}
require.Equal(t, []string{
"POST /api/v1/repos/owner/repo/issues",
"PATCH /api/v1/repos/owner/repo/issues/1",
"POST /api/v1/repos/owner/repo/pulls/1/requested_reviewers",
"POST /api/v1/repos/owner/repo/pulls/1/requested_reviewers",
"POST /api/v1/repos/owner/repo/pulls",
"POST /api/v1/repos/owner/repo/pulls/1/requested_reviewers",
}, routes)
for i, want := range []string{
`{"title":"Title","assignees":["alice","bob"],"labels":[2]}`,
`{"assignees":["carol","alice"]}`,
`{"reviewers":["alice"]}`,
`{"reviewers":["bob"]}`,
`{"title":"Title","head":"feature","base":"main","allow_maintainer_edit":true}`,
`{"reviewers":["dave"]}`,
} {
requirePayload(t, want, strings.NewReader(payloads[i]))
}
require.Empty(t, diagnostics.String())
}
+422
View File
@@ -0,0 +1,422 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) createIssueInteractively(cmd *cobra.Command) (runErr error) {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
selectables, err := service.CreateSelectables(cmd.Context())
if err != nil {
return err
}
choices := terminal.IssueFormChoices{
CanPush: selectables.CanPush, Assignees: selectables.Assignees,
EditBody: a.bodyEditor(cmd),
}
for _, template := range selectables.Templates {
choices.Templates = append(choices.Templates, terminal.IssueTemplateChoice{Name: template.Name, About: template.About, Title: template.Title, Body: issueTemplateBody(template)})
}
choices.Milestones, choices.Labels = milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels)
values, err := a.deps.IssueForm.CreateIssue(cmd.Context(), choices)
defer finishDraft(&values.Draft, &runErr)
if err != nil {
return err
}
if err := a.confirm(cmd, "Create issue on "+target.Repo+": "+values.Title); err != nil {
return err
}
item, err := service.Create(cmd.Context(), services.IssueOptions{
Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone, Labels: values.Labels, Deadline: values.Deadline,
})
if err != nil {
return err
}
return writeRecord(a, cmd, item, issueRecord)
}
// issueTemplateBody seeds a body from markdown content, or from the description of form templates.
func issueTemplateBody(template services.IssueTemplate) string {
if template.Form && template.Content == "" {
return template.About
}
return template.Content
}
// issueTemplate selects a repository issue template by case-insensitive name.
func issueTemplate(ctx context.Context, service services.Issues, name string) (services.IssueTemplate, error) {
templates, err := service.IssueTemplates(ctx)
if err != nil {
return services.IssueTemplate{}, err
}
names := make([]string, len(templates))
for i, template := range templates {
if strings.EqualFold(template.Name, name) {
return template, nil
}
names[i] = template.Name
}
if len(names) == 0 {
return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; %s has no issue templates", name, service.Repository)
}
return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; available: %s", name, strings.Join(names, ", "))
}
// validateMilestoneFlags checks --milestone and --milestone-name before any request.
func validateMilestoneFlags(cmd *cobra.Command, name string, id int64) error {
if cmd.Flags().Changed("milestone-name") {
if cmd.Flags().Changed("milestone") {
return clierr.Wrap(clierr.Usage, errors.New("--milestone and --milestone-name are mutually exclusive"))
}
if strings.TrimSpace(name) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--milestone-name cannot be empty; use --milestone=0 for none"))
}
}
if id < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--milestone must be a positive ID, or 0 for none"))
}
return nil
}
func milestoneChoices(items []services.Milestone) []terminal.IssueMilestoneChoice {
choices := make([]terminal.IssueMilestoneChoice, len(items))
for i, item := range items {
choices[i] = terminal.IssueMilestoneChoice{Title: item.Title, ID: item.ID}
}
return choices
}
func labelChoices(items []services.Label) []terminal.IssueLabelChoice {
choices := make([]terminal.IssueLabelChoice, len(items))
for i, item := range items {
choices[i] = terminal.IssueLabelChoice{Name: item.Name, ID: item.ID}
}
return choices
}
func issueLabel(n int64) string { return fmt.Sprintf("issue %d", n) }
// metadataFlags are the label and assignee flags shared by issue and pull request edits.
type metadataFlags struct {
setLabels, addLabels, removeLabels []string
setAssignees, addAssignees, removeAssignees []string
}
func (f *metadataFlags) register(cmd *cobra.Command) {
cmd.Flags().StringSliceVar(&f.setLabels, "set-labels", nil, "Replace labels by exact name; repo:/org: disambiguate; empty clears")
cmd.Flags().StringSliceVar(&f.addLabels, "add-labels", nil, "Add labels by exact name; repo:/org: disambiguate")
cmd.Flags().StringSliceVar(&f.removeLabels, "remove-labels", nil, "Remove labels by exact name; repo:/org: disambiguate")
cmd.Flags().StringSliceVar(&f.setAssignees, "set-assignees", nil, "Replace assignees; an empty value clears")
cmd.Flags().StringSliceVar(&f.addAssignees, "add-assignees", nil, "Add assignees")
cmd.Flags().StringSliceVar(&f.removeAssignees, "remove-assignees", nil, "Remove assignees")
}
func (f *metadataFlags) validate(cmd *cobra.Command) error {
for _, kind := range []string{"assignees", "labels"} {
if cmd.Flags().Changed("set-"+kind) && (cmd.Flags().Changed("add-"+kind) || cmd.Flags().Changed("remove-"+kind)) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--set-%[1]s cannot be combined with --add-%[1]s or --remove-%[1]s", kind))
}
}
if err := requireNames("assignee", "; use --set-assignees= to clear", f.setAssignees, f.addAssignees, f.removeAssignees); err != nil {
return err
}
return requireNames("label", "; use --set-labels= to clear", f.setLabels, f.addLabels, f.removeLabels)
}
// changed reports whether the flags edit anything; empty --add-*/--remove-* values do not.
func (f *metadataFlags) changed(cmd *cobra.Command) bool {
return cmd.Flags().Changed("set-labels") || cmd.Flags().Changed("set-assignees") || f.incremental()
}
// incremental reports whether any --add-* or --remove-* flag names a value.
func (f *metadataFlags) incremental() bool {
return len(f.addLabels)+len(f.removeLabels)+len(f.addAssignees)+len(f.removeAssignees) > 0
}
// resolve maps set flags to replacements, and add and remove flags to changes that the service
// merges with each item's current state.
func (f *metadataFlags) resolve(cmd *cobra.Command, t target) (labels *[]int64, assignees *[]string, changes *services.MetadataChanges, err error) {
resolver := services.Labels{Client: t.client, Repository: t.Repo}
if cmd.Flags().Changed("set-labels") {
ids, err := resolver.ResolveIssueNames(cmd.Context(), f.setLabels)
if err != nil {
return nil, nil, nil, err
}
labels = &ids
}
if cmd.Flags().Changed("set-assignees") {
names := append([]string{}, f.setAssignees...)
assignees = &names
}
if !f.incremental() {
return labels, assignees, nil, nil
}
changes = &services.MetadataChanges{AddAssignees: f.addAssignees, RemoveAssignees: f.removeAssignees}
if changes.AddLabels, err = resolver.ResolveIssueNames(cmd.Context(), f.addLabels); err != nil {
return nil, nil, nil, err
}
if changes.RemoveLabels, err = resolver.ResolveIssueNames(cmd.Context(), f.removeLabels); err != nil {
return nil, nil, nil, err
}
return labels, assignees, changes, nil
}
func issueRecord(item services.Issue) terminal.Record {
return numberedRecord(item.Number, item.Title, item.URL)
}
func issueRow(item services.Issue) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.Number, 10), Link: item.URL}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}, {Text: item.Author}}
}
func (a *application) issueCommand() *cobra.Command {
group := &cobra.Command{Use: "issue", Short: "Work with repository issues"}
opts := services.ListOptions{State: "open", Limit: 30, Page: 1}
var updated timeRange
list := &cobra.Command{Use: "list", Short: "List repository issues", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
return err
}
if err := oneOf("kind", opts.Kind, "issues", "pulls", "all"); err != nil {
return err
}
var err error
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
return err
}
labelNames, err := labelFilter(opts.Labels)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
if errors.Is(err, errRepositoryRequired) {
return clierr.Wrap(clierr.Usage, errors.New("repository required: use --repo owner/name, or tea search issue for instance-wide results"))
}
return err
}
if len(labelNames) > 0 {
if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil {
return err
}
opts.Labels = strings.Join(labelNames, ",")
}
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
if err != nil {
return err
}
if err := writeList(a, cmd, items, issueRow); err != nil {
return err
}
return a.morePagesHint(cmd, more, opts.Page)
}}
list.Flags().StringVar(&opts.State, "state", "open", "Issue state: open, closed, all")
list.Flags().StringVar(&opts.Kind, "kind", "issues", "Resource kind: issues, pulls, or all")
pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.Search, "search", "", "Search issue titles and bodies")
list.Flags().StringVar(&opts.Assignee, "assignee", "", "Filter by assigned username")
list.Flags().StringVar(&opts.Labels, "label", "", "Filter by comma-separated label names")
list.Flags().StringVar(&opts.Milestones, "milestone", "", "Filter by comma-separated milestone names or IDs")
list.Flags().StringVar(&opts.Author, "author", "", "Filter by author username")
list.Flags().StringVar(&opts.Mentions, "mentions", "", "Filter by mentioned username")
updated.register(list, "since", "Updated since a date, timestamp, or duration ago", "until", "Updated before a date, timestamp, or duration ago")
group.AddCommand(list)
var web, comments bool
view := &cobra.Command{Use: "view <number>", Short: "View a repository issue", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
item, err := service.View(cmd.Context(), n)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, item.URL)
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
dependencies, err := service.Dependencies(cmd.Context(), n)
if err != nil {
return err
}
return a.writeDiscussion(cmd, target, issueView(item, target.Repo, dependencies), comments)
}}
view.Flags().BoolVar(&web, "web", false, "Open the issue in a browser")
view.Flags().BoolVarP(&comments, "comments", "c", false, "View issue comments")
group.AddCommand(view)
var title, body, bodyFile, deadline, templateName string
var useEditor bool
var ref string
var assignees, labelNames, attachmentPaths []string
var milestone int64
var milestoneName string
create := &cobra.Command{Use: "create", Short: "Create a repository issue", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
if err := validateMilestoneFlags(cmd, milestoneName, milestone); err != nil {
return err
}
if err := requireNames("label", "", labelNames); err != nil {
return err
}
if err := requireNames("assignee", "", assignees); err != nil {
return err
}
if cmd.Flags().Changed("template") && strings.TrimSpace(templateName) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--template cannot be empty"))
}
files, err := openRegularFiles(attachmentPaths)
defer closeFiles(files)
if err != nil {
return err
}
var due *time.Time
if cmd.Flags().Changed("deadline") {
due, _, err = parseDeadline(deadline)
if err != nil {
return err
}
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
policy := a.policy(snapshot)
if !localFlagsChanged(cmd) && policy.CanPrompt(a.deps.Streams) && a.deps.IssueForm != nil {
return a.createIssueInteractively(cmd)
}
var resolved target
var templateBody string
if cmd.Flags().Changed("template") {
if resolved, err = a.resolve(cmd.Context(), true); err != nil {
return err
}
template, err := issueTemplate(cmd.Context(), services.Issues{Client: resolved.client, Repository: resolved.Repo}, strings.TrimSpace(templateName))
if err != nil {
return err
}
if !cmd.Flags().Changed("title") {
title = template.Title
}
templateBody = issueTemplateBody(template)
}
if bodyFile == "-" && title == "" {
return clierr.Wrap(clierr.Usage, errors.New("--body-file - requires --title; stdin cannot also supply a prompt"))
}
prompted, err := a.require(cmd, &title, "title")
if err != nil {
return err
}
switch {
case useEditor:
draft, err = a.editDraft(cmd, templateBody)
body = draft.Body
case cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file"):
body, err = a.readBody(cmd, body, bodyFile)
default:
body = templateBody
}
if err != nil {
return err
}
target := resolved
if target.client == nil {
if target, err = a.resolve(cmd.Context(), true); err != nil {
return err
}
}
if cmd.Flags().Changed("milestone-name") {
milestone, err = (services.Milestones{Client: target.client, Repository: target.Repo}).ResolveName(cmd.Context(), milestoneName)
if err != nil {
return err
}
}
labelIDs, err := (services.Labels{Client: target.client, Repository: target.Repo}).ResolveIssueNames(cmd.Context(), labelNames)
if err != nil {
return err
}
if prompted {
if err := a.confirm(cmd, "Create issue on "+target.Repo+": "+title); err != nil {
return err
}
}
service := services.Issues{Client: target.client, Repository: target.Repo}
item, err := service.Create(cmd.Context(), services.IssueOptions{Title: title, Body: body, Deadline: due, Ref: ref, Assignees: assignees, Milestone: milestone, Labels: labelIDs})
if err != nil {
return err
}
var failures []error
for _, file := range files {
if _, err := service.UploadAttachment(cmd.Context(), item.Number, filepath.Base(file.Name()), file); err != nil {
failures = append(failures, fmt.Errorf("issue %d created; attachment %s upload failed: %w", item.Number, filepath.Base(file.Name()), err))
}
}
if err := writeRecord(a, cmd, item, issueRecord); err != nil {
return err
}
return errors.Join(failures...)
}}
create.Flags().StringVar(&title, "title", "", "Issue title")
create.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag")
create.Flags().StringSliceVar(&labelNames, "label", nil, "Apply labels by exact name; use repo: or org: to disambiguate")
create.Flags().StringSliceVar(&assignees, "assignee", nil, "Assign users by username")
create.Flags().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone")
create.Flags().Int64Var(&milestone, "milestone", 0, "Milestone ID")
create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp")
create.Flags().StringVar(&body, "body", "", "Issue body")
create.Flags().StringVar(&bodyFile, "body-file", "", "Read body from a file, or - for stdin")
create.Flags().BoolVar(&useEditor, "editor", false, "Compose the issue body in the configured editor")
create.Flags().StringVar(&templateName, "template", "", "Seed title and body from the repository issue template with this name")
create.Flags().StringArrayVar(&attachmentPaths, "attachment", nil, "File to attach after creation (repeatable)")
group.AddCommand(create)
for _, action := range []string{"close", "reopen"} {
group.AddCommand(&cobra.Command{Use: action + " <number>...", Short: titleCase(action) + " repository issues", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
numbers, err := resourceNumbers(args)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
state := "closed"
if action == "reopen" {
state = "open"
}
service := services.Issues{Client: target.client, Repository: target.Repo}
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
return service.SetState(cmd.Context(), n, state)
}, issueRow)
}})
}
group.AddCommand(a.issueAttachmentCommand(), a.commentCommand(), a.issueEditCommand())
return group
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"io"
"strings"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) labelExportCommand(service func(*cobra.Command) (services.Labels, error)) *cobra.Command {
var output string
command := &cobra.Command{Use: "export", Short: "Export all labels in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateDownloadDestination(output); err != nil {
return err
}
labels, err := service(cmd)
if err != nil {
return err
}
return writeOutput(cmd, output, func(destination io.Writer) error {
items, err := labels.All(cmd.Context())
if err != nil {
return err
}
if a.options.json != "" {
return terminal.WriteJSON(destination, items, a.options.json, a.options.jq)
}
var text strings.Builder
for _, item := range items {
row, err := formatLabel(item)
if err != nil {
return err
}
text.WriteString(row)
}
_, err = io.WriteString(destination, text.String())
return err
})
}}
command.Flags().StringVar(&output, "output-file", "", "New export file, or - for stdout")
return command
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestLabelExportReadsAllPagesAndPublishesOnlyCompleteFiles(t *testing.T) {
fail := false
pages := []string{}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/labels", r.URL.Path)
page := r.URL.Query().Get("page")
pages = append(pages, page)
switch page {
case "1":
_, _ = w.Write([]byte(`[{"name":"good first issue","color":"ff0000","description":"Help wanted"}]`))
case "2":
if fail {
w.WriteHeader(403)
return
}
_, _ = w.Write([]byte(`[{"name":"bug","color":"00ff00","description":""}]`))
default:
_, _ = w.Write([]byte(`[]`))
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
output := filepath.Join(t.TempDir(), "labels.txt")
args := []string{"label", "export", "-R", "alice/project", "--output-file", output}
require.NoError(t, Execute(context.Background(), deps, args))
require.Empty(t, out.String())
require.Equal(t, []string{"1", "2", "3"}, pages)
data, err := os.ReadFile(output)
require.NoError(t, err)
require.Equal(t, "#ff0000 good first issue ;Help wanted\n#00ff00 bug ;\n", string(data))
require.NoError(t, os.Remove(output))
fail = true
require.Error(t, Execute(context.Background(), deps, args))
_, err = os.Stat(output)
require.True(t, os.IsNotExist(err))
}
func TestLabelJSONExportPreservesTextAndTextFailureLeavesNoFile(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("page") {
case "1":
_, _ = w.Write([]byte(`[{"id":1,"name":"first","color":"ff0000","description":""}]`))
case "2":
_, _ = w.Write([]byte(`[{"id":2,"name":"two spaces;name","color":"00ff00","description":"line\nbreak"}]`))
default:
_, _ = w.Write([]byte(`[]`))
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
directory := t.TempDir()
output := filepath.Join(directory, "labels.txt")
args := []string{"label", "export", "-R", "alice/project", "--output-file", output}
require.ErrorContains(t, Execute(context.Background(), deps, args), "label export --json")
entries, err := os.ReadDir(directory)
require.NoError(t, err)
require.Empty(t, entries)
require.Empty(t, out.String())
require.NoError(t, Execute(context.Background(), deps, append(args, "--json=id,name,description")))
data, err := os.ReadFile(output)
require.NoError(t, err)
require.JSONEq(t, `[{"id":1,"name":"first","description":""},{"id":2,"name":"two spaces;name","description":"line\nbreak"}]`, string(data))
require.Empty(t, out.String())
}
func TestEmptyLabelJSONExportIsAnArray(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`[]`)) }))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"label", "export", "-R", "alice/project", "--output-file", "-", "--json"}))
require.JSONEq(t, `[]`, out.String())
require.Empty(t, diagnostics.String())
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestLabelCreateAndEditExclusiveArchived(t *testing.T) {
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
switch calls {
case 1:
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/owner/repo/labels", r.URL.Path)
require.Equal(t, "scoped", payload["name"])
require.Equal(t, "00aa00", payload["color"])
require.Equal(t, true, payload["exclusive"])
_, _ = w.Write([]byte(`{"id":3,"name":"scoped","color":"00aa00","description":"","exclusive":true,"is_archived":false}`))
case 2:
require.Equal(t, http.MethodPatch, r.Method)
require.Equal(t, "/api/v1/repos/owner/repo/labels/3", r.URL.Path)
require.Equal(t, true, payload["is_archived"])
require.Equal(t, false, payload["exclusive"])
_, _ = w.Write([]byte(`{"id":3,"name":"scoped","color":"00aa00","description":"","exclusive":false,"is_archived":true}`))
default:
t.Fatalf("unexpected call %d %s %s", calls, r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
fields := "--json=id,name,color,exclusive,is_archived"
require.NoError(t, Execute(context.Background(), deps, []string{"label", "create", "-R", "owner/repo", "--name", "scoped", "--color", "00aa00", "--exclusive", fields, "--no-input"}))
require.JSONEq(t, `{"id":3,"name":"scoped","color":"00aa00","exclusive":true,"is_archived":false}`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"label", "edit", "3", "-R", "owner/repo", "--exclusive=false", "--archived", fields}))
require.JSONEq(t, `{"id":3,"name":"scoped","color":"00aa00","exclusive":false,"is_archived":true}`, out.String())
require.Equal(t, 2, calls)
require.Empty(t, diagnostics.String())
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bufio"
"errors"
"fmt"
"io"
"regexp"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
)
var labelColor = regexp.MustCompile(`^[0-9a-fA-F]{6}$`)
// parseLabelFile reads "#color name ;description" rows, skipping blank and comment rows.
func parseLabelFile(r io.Reader) ([]services.LabelOptions, error) {
var labels []services.LabelOptions
scanner := bufio.NewScanner(r)
for line := 1; scanner.Scan(); line++ {
label, ok, err := parseLabelRow(scanner.Text(), line)
if err != nil {
return nil, err
}
if ok {
labels = append(labels, label)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(labels) == 0 {
return nil, clierr.Wrap(clierr.Usage, errors.New("label file contains no labels"))
}
return labels, nil
}
func parseLabelRow(text string, line int) (services.LabelOptions, bool, error) {
raw := strings.TrimSpace(text)
parts := strings.SplitN(raw, ";", 2)
fields := strings.Fields(parts[0])
// Rows start with an optional "#" before the color, as Gitea's label templates do.
if raw == "" || strings.HasPrefix(raw, "#") && !labelColor.MatchString(strings.TrimPrefix(fields[0], "#")) {
return services.LabelOptions{}, false, nil
}
if len(fields) < 2 {
return services.LabelOptions{}, false, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid label row %d", line))
}
color, name, description := strings.TrimPrefix(fields[0], "#"), strings.Join(fields[1:], " "), ""
if len(parts) == 2 {
description = strings.TrimSpace(parts[1])
}
if !labelColor.MatchString(color) {
return services.LabelOptions{}, false, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid color on row %d", line))
}
return services.LabelOptions{Name: &name, Color: &color, Description: &description}, true, nil
}
// formatLabel renders a label as a label file row, rejecting labels the row cannot reproduce.
func formatLabel(label services.Label) (string, error) {
color := strings.TrimPrefix(label.Color, "#")
row := fmt.Sprintf("#%s %s ;%s", color, label.Name, label.Description)
parsed, ok, err := parseLabelRow(row, 1)
if err != nil || !ok || strings.ContainsAny(row, "\r\n") || *parsed.Name != label.Name || *parsed.Color != color || *parsed.Description != label.Description {
return "", errors.New("label cannot be represented in the import format; use label export --json to preserve its text")
}
return row + "\n", nil
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/services"
"github.com/stretchr/testify/require"
)
func TestLabelFileRoundTrip(t *testing.T) {
for _, label := range []services.Label{
{Name: "bug", Color: "ff0000", Description: "Something is broken"},
{Name: "kind/feature request", Color: "#00FF00", Description: "Wanted; maybe"},
{Name: "#hash", Color: "0000ff"},
} {
row, err := formatLabel(label)
require.NoError(t, err, label.Name)
parsed, err := parseLabelFile(strings.NewReader(row))
require.NoError(t, err, row)
require.Len(t, parsed, 1)
require.Equal(t, label.Name, *parsed[0].Name)
require.Equal(t, strings.TrimPrefix(label.Color, "#"), *parsed[0].Color)
require.Equal(t, label.Description, *parsed[0].Description)
}
for _, label := range []services.Label{
{Name: "", Color: "ff0000"},
{Name: "two spaces", Color: "ff0000"},
{Name: "semi;colon", Color: "ff0000"},
{Name: "tab\tname", Color: "ff0000"},
{Name: "bug", Color: "red"},
{Name: "bug", Color: "ff0000", Description: "line\nbreak"},
{Name: "bug", Color: "ff0000", Description: " padded"},
} {
_, err := formatLabel(label)
require.ErrorContains(t, err, "cannot be represented", label)
}
}
func TestLabelImportAcceptsColorPrefixedRows(t *testing.T) {
var created []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/labels", r.URL.Path)
payload, err := io.ReadAll(r.Body)
require.NoError(t, err)
var label struct{ Name string }
require.NoError(t, json.Unmarshal(payload, &label))
created = append(created, string(payload))
_, _ = fmt.Fprintf(w, `{"id":%d,"name":%q}`, len(created), label.Name)
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
file := filepath.Join(t.TempDir(), "labels.txt")
require.NoError(t, os.WriteFile(file, []byte("# Gitea default labels\n#ee0701 bug ; Something is not working\n84b6eb enhancement\n"), 0o600))
require.NoError(t, Execute(context.Background(), deps, []string{"label", "create", "-R", "alice/project", "--file", file, "--yes"}))
require.Len(t, created, 2)
requirePayload(t, `{"name":"bug","color":"ee0701","description":"Something is not working"}`, strings.NewReader(created[0]))
requirePayload(t, `{"name":"enhancement","color":"84b6eb"}`, strings.NewReader(created[1]))
require.Equal(t, "1 bug\n2 enhancement\n", out.String())
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"io"
"strconv"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
func (a *application) manCommand() *cobra.Command {
var output string
command := &cobra.Command{
Use: "man [command...]", Short: "Generate the full manual or a command's man page", Hidden: true,
Annotations: textOutput("man pages are troff"),
RunE: func(cmd *cobra.Command, args []string) error {
// Use built-ins only so personal aliases and settings cannot change the manual.
root := NewRoot(Dependencies{})
target, remaining, err := root.Find(args)
if err != nil || len(remaining) != 0 {
return clierr.Wrap(clierr.Usage, errors.New("unknown manual command; use tea help to list commands"))
}
date := ManualDate(a.deps.Getenv)
return writeOutput(cmd, output, func(writer io.Writer) error {
var render func(*cobra.Command) error
render = func(page *cobra.Command) error {
if err := cmd.Context().Err(); err != nil {
return err
}
page.DisableAutoGenTag = true
// GenMan fills an empty Title from the command path by mutating the header, so each page needs its own.
header := &doc.GenManHeader{Section: "1", Date: &date, Source: "Tea", Manual: "Tea Manual"}
if err := doc.GenMan(page, header, writer); err != nil {
return err
}
if len(args) == 0 {
for _, child := range page.Commands() {
if child.Hidden {
continue
}
if err := render(child); err != nil {
return err
}
}
}
return nil
}
return render(target)
})
},
}
command.Flags().StringVarP(&output, "output-file", "o", "", "Write the man page to a new file (default stdout)")
return command
}
// ManualDate dates man pages from SOURCE_DATE_EPOCH, falling back to a fixed date so the
// manual stays reproducible when the variable is unset.
func ManualDate(getenv func(string) string) time.Time {
if seconds, err := strconv.ParseInt(getenv("SOURCE_DATE_EPOCH"), 10, 64); err == nil {
return time.Unix(seconds, 0).UTC()
}
return time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestManWritesSelectedCommandWithoutReplacingFiles(t *testing.T) {
deps, out, _ := testDeps(t)
path := filepath.Join(t.TempDir(), "checkout.1")
args := []string{"man", "pr", "checkout", "--output-file", path}
require.NoError(t, Execute(context.Background(), deps, args))
require.Empty(t, out.String())
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "tea-pr-checkout")
require.Contains(t, string(data), "detach")
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, args)))
after, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, data, after)
}
func TestManRejectsUnknownCommandsAndMachineOutput(t *testing.T) {
for _, args := range [][]string{{"man", "unknown"}, {"man", "pr", "unknown"}, {"man", "--json"}, {"man", "--jq", "."}} {
deps, out, _ := testDeps(t)
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, args)))
require.Empty(t, out.String())
}
}
func TestManIncludesFullCommandTree(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"man"}))
manual := out.String()
for _, command := range []string{"tea-pr-checkout", "tea-issue-comment-create", "tea-auth-login", "tea-run-artifact-download"} {
require.Contains(t, manual, command)
}
require.Contains(t, manual, `.TH "TEA-PR-CHECKOUT" "1" "Jan 2026"`)
require.NotContains(t, manual, "tea-auth-git-credential")
first := manual
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"man"}))
require.Equal(t, first, out.String())
out.Reset()
deps.Getenv = envFunc(map[string]string{"SOURCE_DATE_EPOCH": "1780000000"})
require.NoError(t, Execute(context.Background(), deps, []string{"man", "pr", "checkout"}))
require.Contains(t, out.String(), `.TH "TEA-PR-CHECKOUT" "1" "May 2026"`)
}
func TestCanceledManDoesNotPublishOutputFile(t *testing.T) {
deps, out, _ := testDeps(t)
path := filepath.Join(t.TempDir(), "manual.1")
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.ErrorIs(t, Execute(ctx, deps, []string{"man", "--output-file", path}), context.Canceled)
_, err := os.Stat(path)
require.True(t, os.IsNotExist(err))
require.Empty(t, out.String())
}
+360
View File
@@ -0,0 +1,360 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) labelCommand() *cobra.Command {
group := &cobra.Command{Use: "label", Short: "Manage repository or organization labels"}
var org string
group.PersistentFlags().StringVar(&org, "org", "", "Manage organization labels instead of repository labels")
service := func(cmd *cobra.Command) (services.Labels, error) {
if org != "" && a.options.repo != "" {
return services.Labels{}, clierr.Wrap(clierr.Usage, errors.New("--org and --repo are mutually exclusive"))
}
t, err := a.resolve(cmd.Context(), org == "")
return services.Labels{Client: t.client, Repository: t.Repo, Organization: org}, err
}
group.AddCommand(a.labelExportCommand(service))
var limit, page int
list := &cobra.Command{Use: "list", Short: "List labels in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, err := service(cmd)
if err != nil {
return err
}
items, err := s.List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, labelRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"create", "edit"} {
var name, color, description, file string
var exclusive, archived bool
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " a label"}
if verb == "edit" {
command.Use += " <id>"
command.Args = argsExact(1)
} else {
command.Args = argsExact(0)
}
command.Flags().StringVar(&name, "name", "", "Label name")
command.Flags().StringVar(&color, "color", "", "Six-digit RGB color")
command.Flags().StringVar(&description, "description", "", "Label description")
command.Flags().BoolVar(&exclusive, "exclusive", false, "Only one label from this exclusive group may be applied")
command.Flags().BoolVar(&archived, "archived", false, "Archive the label so it cannot be newly applied")
if verb == "create" {
command.Flags().StringVar(&file, "file", "", "Import color/name rows with optional ;description")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
opts := services.LabelOptions{}
var imported []services.LabelOptions
var id int64
if verb == "edit" {
var err error
id, err = number(args[0])
if err != nil {
return err
}
}
if file != "" {
if cmd.Flags().Changed("name") || cmd.Flags().Changed("color") || cmd.Flags().Changed("description") || cmd.Flags().Changed("exclusive") || cmd.Flags().Changed("archived") {
return clierr.Wrap(clierr.Usage, errors.New("--file cannot be combined with label fields"))
}
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
if imported, err = parseLabelFile(f); err != nil {
return err
}
} else {
if verb == "create" {
if _, err := a.require(cmd, &name, "name"); err != nil {
return err
}
if _, err := a.require(cmd, &color, "color"); err != nil {
return err
}
}
if verb == "create" || cmd.Flags().Changed("name") {
if name == "" {
return clierr.Wrap(clierr.Usage, errors.New("label name cannot be empty"))
}
opts.Name = &name
}
if verb == "create" || cmd.Flags().Changed("color") {
color = strings.TrimPrefix(color, "#")
if !labelColor.MatchString(color) {
return clierr.Wrap(clierr.Usage, errors.New("--color requires six hexadecimal digits"))
}
opts.Color = &color
}
if verb == "create" || cmd.Flags().Changed("description") {
opts.Description = &description
}
if verb == "create" || cmd.Flags().Changed("exclusive") {
opts.Exclusive = &exclusive
}
if cmd.Flags().Changed("archived") {
opts.Archived = &archived
}
if opts.Name == nil && opts.Color == nil && opts.Description == nil && opts.Exclusive == nil && opts.Archived == nil {
return clierr.Wrap(clierr.Usage, errors.New("supply label fields to edit"))
}
}
s, err := service(cmd)
if err != nil {
return err
}
if file != "" {
results := []services.Label{}
var failures []error
for _, row := range imported {
item, err := s.Create(cmd.Context(), row)
if err != nil {
failures = append(failures, fmt.Errorf("label %s: %w", *row.Name, err))
continue
}
results = append(results, item)
}
return errors.Join(append(failures, writeList(a, cmd, results, labelRow))...)
}
var item services.Label
if verb == "create" {
item, err = s.Create(cmd.Context(), opts)
} else {
item, err = s.Edit(cmd.Context(), id, opts)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, labelRow)
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "delete <id>", Short: "Delete a label", Annotations: textOutput("label deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
s, err := service(cmd)
if err != nil {
return err
}
if err := a.confirm(cmd, "Delete label "+args[0]); err != nil {
return err
}
return s.Delete(cmd.Context(), id)
}})
return group
}
func (a *application) milestoneCommand() *cobra.Command {
group := &cobra.Command{Use: "milestone", Short: "Manage repository milestones"}
service := func(cmd *cobra.Command) (services.Milestones, error) {
t, err := a.resolve(cmd.Context(), true)
return services.Milestones{Client: t.client, Repository: t.Repo}, err
}
var limit, page int
var state string
list := &cobra.Command{Use: "list", Short: "List milestones", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if err := oneOf("state", state, "open", "closed", "all"); err != nil {
return err
}
s, err := service(cmd)
if err != nil {
return err
}
items, err := s.List(cmd.Context(), state, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, milestoneRow)
}}
pageFlags(list, &limit, &page)
list.Flags().StringVar(&state, "state", "open", "open, closed, all")
group.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "delete"} {
var title, description, descriptionFile, deadline, createState string
var byName, useEditor bool
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " a milestone"}
if verb == "create" {
command.Args = argsExact(0)
} else {
command.Use += " <id-or-name>"
command.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone title instead of ID")
command.Args = argsExact(1)
}
if verb == "create" || verb == "edit" {
command.Flags().StringVar(&title, "title", "", "Milestone title")
command.Flags().StringVar(&description, "description", "", "Milestone description")
command.Flags().StringVar(&descriptionFile, "description-file", "", "Read description from file, or - for stdin")
command.Flags().BoolVar(&useEditor, "editor", false, "Edit the milestone description in the configured editor")
command.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears")
}
if verb == "create" {
command.Flags().StringVar(&createState, "state", "open", "Milestone state: open or closed")
}
command.RunE = func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if verb == "create" || verb == "edit" {
if err := validateTextSources(cmd, "description", "description-file"); err != nil {
return err
}
}
opts := services.MilestoneOptions{}
var id int64
if verb != "create" {
var err error
if id, err = milestoneSelector(args[0], byName); err != nil {
return err
}
}
var interactiveDeadline *time.Time
descriptionChanged := false
if verb == "create" {
if err := oneOf("state", createState, "open", "closed"); err != nil {
return err
}
opts.State = &createState
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
policy := a.policy(snapshot)
if !localFlagsChanged(cmd) && policy.CanPrompt(a.deps.Streams) && a.deps.MilestoneForm != nil {
values, err := a.deps.MilestoneForm.CreateMilestone(cmd.Context())
if err != nil {
return err
}
title, description = values.Title, values.Description
interactiveDeadline = values.Deadline
descriptionChanged = true
} else if _, err := a.require(cmd, &title, "title"); err != nil {
return err
}
}
if (verb == "create" || verb == "edit") && !useEditor {
var err error
description, err = a.readTextSource(cmd, description, descriptionFile, "description", "description-file")
if err != nil {
return err
}
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-file") {
descriptionChanged = true
}
}
if cmd.Flags().Changed("title") && strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("milestone title cannot be empty"))
}
if verb == "create" || cmd.Flags().Changed("title") {
opts.Title = &title
}
if cmd.Flags().Changed("deadline") || interactiveDeadline != nil {
date := time.Time{}
if interactiveDeadline != nil {
date = *interactiveDeadline
} else if deadline != "" {
parsed, _, err := parseDeadline(deadline)
if err != nil {
return err
}
date = *parsed
}
normalized := date.Format(time.RFC3339)
opts.Deadline = &normalized
}
if verb == "edit" && opts.Title == nil && !descriptionChanged && !useEditor && opts.Deadline == nil {
return clierr.Wrap(clierr.Usage, errors.New("supply milestone fields to edit"))
}
s, err := service(cmd)
if err != nil {
return err
}
if byName {
id, err = s.ResolveName(cmd.Context(), args[0])
if err != nil {
return err
}
}
if verb == "delete" {
if err := a.confirm(cmd, "Delete milestone "+args[0]); err != nil {
return err
}
if a.options.json != "" {
item, err := s.View(cmd.Context(), id)
if err != nil {
return err
}
if err := s.Delete(cmd.Context(), id); err != nil {
return err
}
return writeItem(a, cmd, item, milestoneRow)
}
return s.Delete(cmd.Context(), id)
}
if useEditor {
initial := ""
if verb == "edit" {
current, viewErr := s.View(cmd.Context(), id)
if viewErr != nil {
return viewErr
}
initial = current.Description
}
editorDraft, err = a.editDraft(cmd, initial)
if err != nil {
return err
}
description = editorDraft.Body
descriptionChanged = true
}
if verb == "create" || descriptionChanged {
opts.Description = &description
}
var item services.Milestone
if verb == "view" {
item, err = s.View(cmd.Context(), id)
} else if verb == "create" {
item, err = s.Create(cmd.Context(), opts)
} else {
item, err = s.Edit(cmd.Context(), id, opts)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, milestoneRow)
}
group.AddCommand(command)
}
group.AddCommand(a.milestoneStateCommand("close", service), a.milestoneStateCommand("reopen", service))
group.AddCommand(a.milestoneIssueCommand())
return group
}
func labelRow(item services.Label) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Name, Color: item.Color}, {Text: item.Color, Color: item.Color}, {Text: item.Description}}
}
func milestoneRow(item services.Milestone) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}}
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestBulkMilestoneStateContinuesAfterFailureAndKeepsJSONClean(t *testing.T) {
mutations := []string{}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/project/milestones/")
if r.Method == http.MethodGet {
_, _ = fmt.Fprintf(w, `{"id":%s,"title":"Milestone %s"}`, id, id)
return
}
mutations = append(mutations, id)
if id == "10" {
w.WriteHeader(403)
return
}
_, _ = fmt.Fprintf(w, `{"id":%s,"title":"Milestone %s","state":"closed"}`, id, id)
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"milestone", "close", "9", "10", "11", "-R", "alice/project", "--json=id,state"})
require.ErrorContains(t, err, "milestone 10")
require.Equal(t, clierr.Failure, clierr.ExitCode(err))
require.Equal(t, []string{"9", "10", "11"}, mutations)
require.JSONEq(t, `[{"id":9,"state":"closed"},{"id":11,"state":"closed"}]`, out.String())
require.Empty(t, diagnostics.String())
mutations = nil
out.Reset()
err = Execute(context.Background(), deps, []string{"milestone", "close", "9", "bad", "-R", "alice/project"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.Empty(t, mutations)
require.Empty(t, out.String())
}
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type milestoneFormFunc func(context.Context) (terminal.MilestoneFormValues, error)
func (f milestoneFormFunc) CreateMilestone(ctx context.Context) (terminal.MilestoneFormValues, error) {
return f(ctx)
}
func TestMilestoneCreateInteractiveForm(t *testing.T) {
var body map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/milestones", r.URL.Path)
require.Equal(t, http.MethodPost, r.Method)
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
_, _ = w.Write([]byte(`{"id":9,"title":"Release","description":"Ship it","state":"open","due_on":"2026-12-31T00:00:00Z"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
due := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
deps.MilestoneForm = milestoneFormFunc(func(context.Context) (terminal.MilestoneFormValues, error) {
return terminal.MilestoneFormValues{Title: "Release", Description: "Ship it", Deadline: &due}, nil
})
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "create", "-R", "alice/project", "--json=id,title,description", "--interactive"}))
require.JSONEq(t, `{"id":9,"title":"Release","description":"Ship it"}`, out.String())
require.Empty(t, diagnostics.String())
require.Equal(t, "Release", body["title"])
require.Equal(t, "Ship it", body["description"])
require.Equal(t, "2026-12-31T00:00:00Z", body["due_on"])
out.Reset()
deps.MilestoneForm = milestoneFormFunc(func(context.Context) (terminal.MilestoneFormValues, error) {
t.Fatal("form should not run when create flags are set")
return terminal.MilestoneFormValues{}, nil
})
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"milestone", "create", "-R", "alice/project", "--no-input"})))
require.Empty(t, out.String())
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "create", "-R", "alice/project", "--title", "Flagged", "--json=id", "--interactive"}))
}
func TestMilestoneCreateState(t *testing.T) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/milestones", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
_, _ = w.Write([]byte(`{"id":9,"title":"Done","state":"closed"}`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "create", "-R", "alice/project", "--title", "Done", "--state", "closed", "--json=id,state", "--no-input"}))
require.Equal(t, "closed", payload["state"])
require.JSONEq(t, `{"id":9,"state":"closed"}`, out.String())
}
@@ -0,0 +1,67 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestMilestoneCreateDescriptionFileAndEditor(t *testing.T) {
var createBody map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/milestones", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&createBody))
_, _ = w.Write([]byte(`{"id":4,"title":"Ship","description":"from file","state":"open"}`))
}))
defer server.Close()
deps, out, _ := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
file := filepath.Join(t.TempDir(), "description.md")
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "create", "-R", "alice/project", "--title", "Ship", "--description-file", file, "--json=id,description", "--no-input"}))
require.Equal(t, "from file", createBody["description"])
require.JSONEq(t, `{"id":4,"description":"from file"}`, out.String())
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{
"milestone", "create", "-R", "alice/project", "--title", "Ship", "--description", "x", "--description-file", file, "--no-input",
})))
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{
"milestone", "edit", "4", "-R", "alice/project", "--editor", "--description", "x", "--no-input",
})))
}
func TestMilestoneEditEditorSeedsDescription(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/milestones/4":
_, _ = w.Write([]byte(`{"id":4,"title":"Ship","description":"current notes","state":"open"}`))
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/alice/project/milestones/4":
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
require.Equal(t, "edited notes", body["description"])
_, _ = w.Write([]byte(`{"id":4,"title":"Ship","description":"edited notes","state":"open"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := testDeps(t)
editor, path := newDraftEditor(t, "edited notes")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "edit", "4", "-R", "alice/project", "--editor", "--json=description", "--no-input"}))
require.Equal(t, "current notes", editor.initial)
require.JSONEq(t, `{"description":"edited notes"}`, out.String())
requireDraft(t, path, false)
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/spf13/cobra"
)
// milestoneSelector validates a milestone ID or, with byName, a title before any request.
func milestoneSelector(selector string, byName bool) (int64, error) {
if byName {
if strings.TrimSpace(selector) == "" {
return 0, clierr.Wrap(clierr.Usage, errors.New("milestone name cannot be empty"))
}
return 0, nil
}
return number(selector)
}
// existingMilestone resolves a title, or confirms that an ID exists.
func existingMilestone(ctx context.Context, milestones services.Milestones, selector string, id int64, byName bool) (int64, error) {
if byName {
return milestones.ResolveName(ctx, selector)
}
_, err := milestones.View(ctx, id)
return id, err
}
func (a *application) milestoneIssueCommand() *cobra.Command {
group := &cobra.Command{Use: "issue", Short: "Manage milestone issues and pull requests"}
opts := services.ListOptions{}
var byName bool
list := &cobra.Command{Use: "list <id-or-name>", Short: "List issues and pull requests in a milestone", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := milestoneSelector(args[0], byName)
if err != nil {
return err
}
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
return err
}
if err := oneOf("kind", opts.Kind, "all", "issues", "pulls"); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
id, err = existingMilestone(cmd.Context(), services.Milestones{Client: target.client, Repository: target.Repo}, args[0], id, byName)
if err != nil {
return err
}
opts.Milestones = strconv.FormatInt(id, 10)
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
if err != nil {
return err
}
if err := writeList(a, cmd, items, issueRow); err != nil {
return err
}
return a.morePagesHint(cmd, more, opts.Page)
}}
pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.State, "state", "open", "open, closed, or all")
list.Flags().StringVar(&opts.Kind, "kind", "all", "all, issues, or pulls")
list.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone title instead of ID")
group.AddCommand(list)
for _, verb := range []string{"add", "remove"} {
var byName bool
command := &cobra.Command{Use: verb + " <id-or-name> <number>...", Short: titleCase(verb) + " issues or pull requests in a milestone", Args: usageArgs(cobra.MinimumNArgs(2)), RunE: func(cmd *cobra.Command, args []string) error {
id, err := milestoneSelector(args[0], byName)
if err != nil {
return err
}
numbers, err := resourceNumbers(args[1:])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
id, err = existingMilestone(cmd.Context(), services.Milestones{Client: target.client, Repository: target.Repo}, args[0], id, byName)
if err != nil {
return err
}
service := services.Issues{Client: target.client, Repository: target.Repo}
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
if verb == "remove" {
return service.RemoveMilestone(cmd.Context(), n, id)
}
return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &id})
}, issueRow)
}}
command.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone title instead of ID")
group.AddCommand(command)
}
return group
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestMilestoneNameSelectionResolvesAllPagesBeforeEditing(t *testing.T) {
duplicate := false
edits := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
if r.URL.Path == "/api/v1/repos/alice/project/milestones/9" {
_, _ = w.Write([]byte(`{"id":9,"title":"Release one"}`))
return
}
require.Equal(t, "/api/v1/repos/alice/project/milestones", r.URL.Path)
require.Equal(t, "all", r.URL.Query().Get("state"))
switch r.URL.Query().Get("page") {
case "1":
_, _ = w.Write([]byte(`[{"id":9,"title":"Release one"}]`))
case "2":
if duplicate {
_, _ = w.Write([]byte(`[{"id":10,"title":"Release one"}]`))
} else {
_, _ = w.Write([]byte(`[]`))
}
default:
_, _ = w.Write([]byte(`[]`))
}
return
}
edits++
require.Equal(t, http.MethodPatch, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/milestones/9", r.URL.Path)
_, _ = w.Write([]byte(`{"id":9,"title":"Release one","state":"closed"}`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
args := []string{"milestone", "close", "Release one", "--by-name", "-R", "alice/project", "--json=id,state"}
require.NoError(t, Execute(context.Background(), deps, args))
require.JSONEq(t, `{"id":9,"state":"closed"}`, out.String())
duplicate = true
out.Reset()
require.ErrorContains(t, Execute(context.Background(), deps, args), "ambiguous")
require.Empty(t, out.String())
require.Equal(t, 1, edits)
}
func TestMilestoneDeleteReturnsJSONSnapshotOnlyAfterSuccess(t *testing.T) {
calls := []string{}
fail := false
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/milestones/9", r.URL.Path)
calls = append(calls, r.Method)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":9,"title":"Release one","state":"open"}`))
return
}
require.Equal(t, http.MethodDelete, r.Method)
if fail {
w.WriteHeader(403)
} else {
w.WriteHeader(204)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
args := []string{"milestone", "delete", "9", "-R", "alice/project", "--yes", "--json=id,title"}
require.NoError(t, Execute(context.Background(), deps, args))
require.JSONEq(t, `{"id":9,"title":"Release one"}`, out.String())
require.Equal(t, []string{"GET", "DELETE"}, calls)
fail = true
out.Reset()
require.Error(t, Execute(context.Background(), deps, args))
require.Empty(t, out.String())
}
func TestMilestoneIssueCommandsSelectByName(t *testing.T) {
var calls []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case r.URL.Path == "/api/v1/repos/alice/project/milestones":
if r.URL.Query().Get("page") == "1" {
_, _ = w.Write([]byte(`[{"id":9,"title":"Release one"}]`))
return
}
_, _ = w.Write([]byte(`[]`))
case r.URL.Path == "/api/v1/repos/alice/project/issues" && r.Method == http.MethodGet:
require.Equal(t, "9", r.URL.Query().Get("milestones"))
_, _ = w.Write([]byte(`[]`))
case r.URL.Path == "/api/v1/repos/alice/project/issues/3" && r.Method == http.MethodPatch:
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
require.EqualValues(t, 9, payload["milestone"])
_, _ = w.Write([]byte(`{"number":3,"milestone":{"id":9}}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "issue", "list", "Release one", "--by-name", "-R", "alice/project", "--json"}))
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "issue", "add", "Release one", "3", "--by-name", "-R", "alice/project", "--json=number"}))
require.NotContains(t, calls, "GET /api/v1/repos/alice/project/milestones/9")
err := Execute(context.Background(), deps, []string{"milestone", "issue", "remove", " ", "3", "--by-name", "-R", "alice/project"})
require.ErrorContains(t, err, "milestone name cannot be empty")
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/spf13/cobra"
)
func (a *application) milestoneStateCommand(verb string, service func(*cobra.Command) (services.Milestones, error)) *cobra.Command {
var byName bool
cmd := &cobra.Command{Use: verb + " <id-or-name>...", Short: titleCase(verb) + " one or more milestones", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
ids := make(map[string]int64, len(args))
seenIDs := map[int64]bool{}
selectID := func(argument string, id int64) error {
if seenIDs[id] {
return clierr.Wrap(clierr.Usage, errors.New("selectors resolve to the same milestone"))
}
seenIDs[id], ids[argument] = true, id
return nil
}
for _, argument := range args {
if _, seen := ids[argument]; seen {
return clierr.Wrap(clierr.Usage, errors.New("milestone selectors must be unique"))
}
id, err := milestoneSelector(argument, byName)
if err != nil {
return err
}
ids[argument] = 0
if !byName {
if err := selectID(argument, id); err != nil {
return err
}
}
}
milestones, err := service(cmd)
if err != nil {
return err
}
if byName {
for _, argument := range args {
id, err := milestones.ResolveName(cmd.Context(), argument)
if err != nil {
return err
}
if err := selectID(argument, id); err != nil {
return err
}
}
}
state := "closed"
if verb == "reopen" {
state = "open"
}
return batch(a, cmd, args, func(argument string) string { return "milestone " + argument }, func(argument string) (services.Milestone, error) {
return milestones.Edit(cmd.Context(), ids[argument], services.MilestoneOptions{State: &state})
}, milestoneRow)
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone titles instead of IDs")
return cmd
}
+475
View File
@@ -0,0 +1,475 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"io"
"net/url"
"path/filepath"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func repositoryRecord(item services.Repository) terminal.Record {
return terminal.Record{Heading: []terminal.Cell{{Text: item.FullName, Bold: true}}, Lines: []string{item.URL}}
}
func cloneURLForProtocol(cloneURL, sshURL, protocol string) (remote string, ssh bool, err error) {
remote = cloneURL
if protocol == "ssh" {
remote = sshURL
}
if remote == "" {
return "", false, errors.New("Gitea did not return a clone URL")
}
ssh = settings.RemoteIsSSH(remote)
return remote, ssh, nil
}
// apiSSHHost trusts the SSH authority of the API's ssh_url when the instance has no ssh_host,
// so servers whose SSH host or port differ from the web host clone without extra configuration.
func apiSSHHost(host settings.Host, sshURL string) settings.Host {
if host.SSHHost != "" || !settings.RemoteIsSSH(sshURL) {
return host
}
if u, err := settings.ParseRemote(sshURL); err == nil {
if _, err := settings.SSHEndpoint(u.Host); err == nil {
host.SSHHost = u.Host
}
}
return host
}
// mirrorSourceURL returns a mirror's credential-free network source URL, or "" when there is none to add.
func mirrorSourceURL(item services.Repository) string {
if !item.Mirror || item.OriginalURL == "" {
return ""
}
u, err := url.Parse(item.OriginalURL)
if err != nil || u.Host == "" || strings.ContainsAny(item.OriginalURL, " \t\r\n\\") {
return ""
}
switch u.Scheme {
case "https", "http", "git":
if u.User != nil {
return ""
}
case "ssh":
if _, password := u.User.Password(); password {
return ""
}
default:
return ""
}
return u.String()
}
// ensureHeadPublished prompts to push the head branch when the selected remote tip is missing or differs.
// Cross-repo heads (owner:branch), non-work-trees, and repositories without a matching remote are skipped.
func (a *application) ensureHeadPublished(cmd *cobra.Command, t target, snapshot settings.Snapshot, head string) error {
if head == "" || strings.Contains(head, ":") {
return nil
}
ctx := cmd.Context()
probe := a.gitProbe()
inside, err := probe.Output(ctx, a.options.cwd, false, "rev-parse", "--is-inside-work-tree")
if err != nil || inside != "true" {
return nil
}
local, err := probe.Output(ctx, a.options.cwd, false, "rev-parse", "--verify", "refs/heads/"+head+"^{commit}")
if err != nil {
return nil
}
remote, err := a.checkoutRemote(ctx, t, snapshot)
if err != nil {
if a.options.remote == "" && snapshot.Preferences.Remote == "" && errors.Is(err, errNoMatchingRemote) {
return nil
}
return clierr.Wrap(clierr.Usage, err)
}
remoteTip, tipErr := probe.Output(ctx, a.options.cwd, false, "for-each-ref", "--format=%(objectname)", "refs/remotes/"+remote+"/"+head)
if tipErr == nil && remoteTip == local {
return nil
}
if err := a.confirm(cmd, fmt.Sprintf("Push branch %q to remote %q before creating the pull request?", head, remote)); err != nil {
return err
}
credentials, err := a.remoteCredentials(ctx, t, remote)
if err != nil {
return err
}
args := append(append([]string{}, credentials...), "push", "--", remote, "refs/heads/"+head)
_, err = a.git(ctx, args...)
return err
}
func (a *application) addRepositoryMutations(group *cobra.Command) {
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand())
var forkOrg string
fork := &cobra.Command{Use: "fork [repository]", Short: "Fork the selected repository", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Repositories{Client: t.client}).Fork(cmd.Context(), t.Repo, forkOrg)
if err != nil {
return err
}
return writeRecord(a, cmd, item, repositoryRecord)
}}
fork.Flags().StringVar(&forkOrg, "org", "", "Fork under an organization")
group.AddCommand(fork)
var description, repoName, website, defaultBranch string
var private, template, repoArchived, actionsEnabled bool
edit := &cobra.Command{Use: "edit [repository]", Short: "Edit repository settings", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
}
options := services.RepositoryEdit{}
if cmd.Flags().Changed("name") {
if strings.TrimSpace(repoName) == "" {
return clierr.Wrap(clierr.Usage, errors.New("repository name cannot be empty"))
}
options.Name = &repoName
}
if cmd.Flags().Changed("website") {
options.Website = &website
}
if cmd.Flags().Changed("default-branch") {
if strings.TrimSpace(defaultBranch) == "" {
return clierr.Wrap(clierr.Usage, errors.New("default branch cannot be empty"))
}
options.DefaultBranch = &defaultBranch
}
if cmd.Flags().Changed("archived") {
options.Archived = &repoArchived
}
if cmd.Flags().Changed("description") {
options.Description = &description
}
if cmd.Flags().Changed("private") {
options.Private = &private
}
if cmd.Flags().Changed("as-template") {
options.Template = &template
}
if cmd.Flags().Changed("actions") {
options.ActionsEnabled = &actionsEnabled
}
if options == (services.RepositoryEdit{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea repo edit --help"))
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if repoArchived {
if err := a.confirm(cmd, "Archive repository "+t.Repo); err != nil {
return err
}
}
item, err := (services.Repositories{Client: t.client}).Edit(cmd.Context(), t.Repo, options)
if err != nil {
return err
}
return writeRecord(a, cmd, item, repositoryRecord)
}}
edit.Flags().StringVar(&repoName, "name", "", "Rename the repository")
edit.Flags().StringVar(&website, "website", "", "Repository website; empty clears")
edit.Flags().StringVar(&defaultBranch, "default-branch", "", "Default branch name")
edit.Flags().BoolVar(&repoArchived, "archived", false, "Archive repository; =false restores it")
edit.Flags().StringVar(&description, "description", "", "Repository description")
edit.Flags().BoolVar(&private, "private", false, "Make repository private (use --private=false to make public)")
edit.Flags().BoolVar(&template, "as-template", false, "Make repository a template (use =false to disable)")
edit.Flags().BoolVar(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)")
group.AddCommand(edit)
for _, verb := range []string{"archive", "delete"} {
command := &cobra.Command{Use: verb + " [repository]", Short: titleCase(verb) + " the selected repository", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if err := a.confirm(cmd, verb+" "+t.Host+"/"+t.Repo); err != nil {
return err
}
service := services.Repositories{Client: t.client}
if verb == "delete" {
return service.Delete(cmd.Context(), t.Repo)
}
archived := true
item, err := service.Edit(cmd.Context(), t.Repo, services.RepositoryEdit{Archived: &archived})
if err != nil {
return err
}
return writeRecord(a, cmd, item, repositoryRecord)
}}
if verb == "delete" {
command.Annotations = textOutput("repository deletion does not return a resource")
}
group.AddCommand(command)
}
var depth int
clone := &cobra.Command{Use: "clone <repository> [directory]", Short: "Clone a repository using the configured Git protocol", Args: usageArgs(cobra.RangeArgs(1, 2)), RunE: func(cmd *cobra.Command, args []string) error {
if depth < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--depth must be nonnegative; zero fetches full history"))
}
if a.options.repo != "" {
return clierr.Wrap(clierr.Usage, errors.New("clone repository argument cannot be combined with --repo"))
}
a.options.repo = args[0]
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Repositories{Client: t.client}).View(cmd.Context(), t.Repo)
if err != nil {
return err
}
directory := ""
if len(args) == 2 {
directory = args[1]
}
if err := a.cloneRepository(cmd, t, item, directory, depth); err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return nil
}}
clone.Flags().IntVarP(&depth, "depth", "d", 0, "Number of commits to fetch (0: full history)")
group.AddCommand(clone)
}
// cloneRepository clones item with the configured Git protocol and adds fork and mirror remotes.
// An empty directory lets Git choose it from the repository name.
func (a *application) cloneRepository(cmd *cobra.Command, t target, item services.Repository, directory string, depth int) error {
protocol := t.gitProtocol
host := t.host
if protocol == "ssh" {
host = apiSSHHost(host, item.SSHURL)
}
hosts := map[string]settings.Host{t.Host: host}
remote, remoteIsSSH, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, protocol)
if err != nil {
return err
}
cloneHost, cloneRepo, err := settings.RepositoryURL(remote, hosts)
if err != nil || cloneHost != t.Host || !strings.EqualFold(cloneRepo, t.Repo) || (protocol == "ssh") != remoteIsSSH {
return errors.New("Gitea clone URL does not match the selected instance and repository; check the server root URL and configured ssh_host")
}
gitArgs := []string{"clone"}
if depth > 0 {
gitArgs = append(gitArgs, "--depth", fmt.Sprint(depth))
}
gitArgs = append(gitArgs, "--", remote)
if directory != "" {
gitArgs = append(gitArgs, directory)
} else if directory = filepath.Base(strings.TrimSuffix(item.Name, ".git")); directory == "" || directory == "." || directory == "/" {
directory = item.Name
}
credentialArgs, err := a.gitTransportArguments(t, remoteIsSSH)
if err != nil {
return err
}
gitArgs = append(credentialArgs, gitArgs...)
if _, err := a.git(cmd.Context(), gitArgs...); err != nil {
return err
}
clone := directory
if !filepath.IsAbs(clone) {
clone = filepath.Join(a.options.cwd, clone)
}
if item.Fork && item.Parent != nil {
upstream, upstreamSSH, err := cloneURLForProtocol(item.Parent.CloneURL, item.Parent.SSHURL, protocol)
if err != nil {
return errors.New("cloned the fork, but Gitea did not return a parent clone URL")
}
upstreamHost, upstreamRepo, err := settings.RepositoryURL(upstream, hosts)
if err != nil || upstreamHost != t.Host || !strings.EqualFold(upstreamRepo, item.Parent.FullName) || (protocol == "ssh") != upstreamSSH {
return errors.New("cloned the fork, but its parent clone URL does not match the selected instance")
}
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "upstream", upstream); err != nil {
return fmt.Errorf("cloned the fork, but could not add the upstream remote: %w", err)
}
if branch := item.Parent.DefaultBranch; branch != "" {
if _, err := a.gitIn(cmd.Context(), clone, "config", "branch."+branch+".remote", "upstream"); err != nil {
return fmt.Errorf("cloned the fork and added upstream, but could not configure branch tracking: %w", err)
}
if _, err := a.gitIn(cmd.Context(), clone, "config", "branch."+branch+".merge", "refs/heads/"+branch); err != nil {
return fmt.Errorf("cloned the fork and added upstream, but could not configure branch tracking: %w", err)
}
}
}
// The remote is named source because repository discovery prefers a remote named upstream.
if source := mirrorSourceURL(item); source != "" {
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "source", source); err != nil {
return fmt.Errorf("cloned the mirror, but could not add the source remote: %w", err)
}
}
return nil
}
func pullRecord(item services.Pull) terminal.Record {
return numberedRecord(item.Number, item.Title, item.URL)
}
func (a *application) addPullMutations(group *cobra.Command) {
group.AddCommand(a.pullCreateCommand())
for _, verb := range []string{"close", "reopen"} {
group.AddCommand(&cobra.Command{Use: verb + " <number>", Short: titleCase(verb) + " a pull request", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
state := "closed"
if verb == "reopen" {
state = "open"
}
item, err := (services.Pulls{Client: t.client, Repository: t.Repo}).Edit(cmd.Context(), n, services.PullEdit{State: &state})
if err != nil {
return err
}
return writeRecord(a, cmd, item, pullRecord)
}})
}
group.AddCommand(&cobra.Command{Use: "diff <number>", Short: "Print a pull request diff", Annotations: textOutput("diff returns patch text"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
body, err := (services.Pulls{Client: t.client, Repository: t.Repo}).Diff(cmd.Context(), n)
if err != nil {
return err
}
defer body.Close()
_, err = io.Copy(cmd.OutOrStdout(), body)
return err
}})
group.AddCommand(a.pullReviewCommand())
var method, mergeTitle, mergeMessage, mergeMessageFile string
var deleteBranch, mergeEditor bool
merge := &cobra.Command{Use: "merge [number]", Short: "Merge a pull request", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if method != "merge" && method != "rebase" && method != "rebase-merge" && method != "squash" && method != "fast-forward-only" {
return clierr.Wrap(clierr.Usage, errors.New("unsupported merge method"))
}
if err := validateTextSources(cmd, "message", "message-file"); err != nil {
return err
}
if !mergeEditor {
var err error
mergeMessage, err = a.readTextSource(cmd, mergeMessage, mergeMessageFile, "message", "message-file")
if err != nil {
return err
}
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
policy := a.policy(snapshot)
if len(args) == 0 && (!policy.CanPrompt(a.deps.Streams) || a.deps.MergeForm == nil) {
return clierr.Wrap(clierr.Usage, errors.New("supply a pull request number, or run interactively to choose one"))
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
var n int64
if len(args) == 1 {
n, err = number(args[0])
if err != nil {
return err
}
} else {
n, err = a.selectPullToMerge(cmd, t)
if err != nil {
return err
}
}
if mergeEditor {
editorDraft, err = a.editDraft(cmd, "")
if err != nil {
return err
}
mergeMessage = editorDraft.Body
}
if err := a.confirm(cmd, fmt.Sprintf("Merge pull request %s#%d", t.Repo, n)); err != nil {
return err
}
if !cmd.Flags().Changed("delete-branch") && !a.options.yes && policy.CanPrompt(a.deps.Streams) {
item, err := (services.Pulls{Client: t.client, Repository: t.Repo}).View(cmd.Context(), n)
if err != nil {
return err
}
if !item.HeadRemoteDeleted {
if deleteBranch, err = a.deps.Prompter.Confirm(cmd.Context(), fmt.Sprintf("Delete branch %s after merging?", item.Head)); err != nil {
return err
}
}
}
return (services.Pulls{Client: t.client, Repository: t.Repo}).Merge(cmd.Context(), n, services.MergeOptions{
Method: method, Title: mergeTitle, Message: mergeMessage, DeleteBranch: deleteBranch,
})
}}
merge.Flags().StringVarP(&method, "method", "s", "merge", "Merge strategy")
merge.Flags().StringVar(&method, "style", "merge", "Alias of --method")
_ = merge.Flags().MarkHidden("style")
merge.Flags().StringVar(&mergeTitle, "title", "", "Merge commit title")
merge.Flags().StringVar(&mergeMessage, "message", "", "Merge commit message")
merge.Flags().StringVar(&mergeMessageFile, "message-file", "", "Read merge commit message from file, or - for stdin")
merge.Flags().BoolVar(&mergeEditor, "editor", false, "Compose the merge commit message in the configured editor")
merge.Flags().BoolVar(&deleteBranch, "delete-branch", false, "Delete head branch after merging")
group.AddCommand(merge)
}
func (a *application) selectPullToMerge(cmd *cobra.Command, t target) (int64, error) {
branch, err := a.currentBranch(cmd.Context())
if err != nil {
return 0, clierr.Wrap(clierr.Usage, errors.New("supply a pull number outside a branch checkout"))
}
items, err := (services.Pulls{Client: t.client, Repository: t.Repo}).ListOpen(cmd.Context())
if err != nil {
return 0, err
}
var headMatches, baseMatches []terminal.MergePullChoice
for _, item := range items {
label := item.Head
if owner, _, ok := strings.Cut(item.HeadRepo, "/"); ok && !strings.EqualFold(item.HeadRepo, t.Repo) {
label = owner + ":" + item.Head // fork heads can share a branch name with the local one
}
choice := terminal.MergePullChoice{Number: item.Number, Title: item.Title, Head: label}
switch branch {
case item.Head:
headMatches = append(headMatches, choice)
case item.Base:
baseMatches = append(baseMatches, choice)
}
}
return a.deps.MergeForm.SelectPull(cmd.Context(), append(headMatches, baseMatches...))
}
+191
View File
@@ -0,0 +1,191 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"io"
"slices"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) notificationCommand() *cobra.Command {
group := &cobra.Command{Use: "notification", Short: "Manage account notifications"}
var mine bool
group.PersistentFlags().BoolVar(&mine, "mine", false, "Use all your notifications instead of repository notifications")
service := func(cmd *cobra.Command, requireRepo bool) (services.Notifications, error) {
if mine && a.options.repo != "" {
return services.Notifications{}, clierr.Wrap(clierr.Usage, errors.New("--mine and --repo are mutually exclusive"))
}
target, err := a.resolve(cmd.Context(), requireRepo && !mine)
if err != nil {
return services.Notifications{}, err
}
repository := ""
if (requireRepo || a.options.repo != "") && !mine {
repository = target.Repo
}
return services.Notifications{Client: target.client, Repository: repository}, nil
}
for _, verb := range []string{"list", "watch"} {
opts := services.NotificationOptions{}
var updated timeRange
var interval, timeout time.Duration
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " notifications", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateNotificationStatuses(opts.Statuses); err != nil {
return err
}
for i, kind := range opts.Types {
kind = strings.ToLower(kind)
if !slices.Contains([]string{"issue", "pull", "commit", "repository"}, kind) {
return clierr.Wrap(clierr.Usage, errors.New("--type must contain issue, pull, commit, or repository"))
}
opts.Types[i] = titleCase(kind)
}
var err error
if opts.Since, opts.Before, err = updated.parse(a.deps.Now()); err != nil {
return err
}
if verb == "watch" && (interval <= 0 || timeout <= 0) {
return clierr.Wrap(clierr.Usage, errors.New("--interval and --timeout must be positive"))
}
s, err := service(cmd, true)
if err != nil {
return err
}
if verb == "list" {
items, err := s.List(cmd.Context(), opts)
if err != nil {
return err
}
return writeList(a, cmd, items, notificationRow)
}
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
results := []services.Notification{}
color := a.colorEnabled()
err = s.Watch(ctx, opts, interval, func(items []services.Notification) error {
if a.options.json != "" {
results = append(results, items...)
return nil
}
_, err := io.WriteString(cmd.OutOrStdout(), table(items, notificationRow, color))
return err
})
if errors.Is(err, context.DeadlineExceeded) && cmd.Context().Err() == nil {
err = nil
}
if a.options.json != "" {
if outputErr := a.writeJSON(cmd, results); outputErr != nil {
return outputErr
}
}
return err
}}
pageFlags(command, &opts.Limit, &opts.Page)
command.Flags().StringSliceVar(&opts.Statuses, "status", []string{"unread", "pinned"}, "Filter unread, read, or pinned")
command.Flags().StringSliceVar(&opts.Types, "type", nil, "Filter issue, pull, commit, or repository")
updated.register(command, "since", "Updated since a date, timestamp, or duration such as 24h", "before", "Updated before a date, timestamp, or duration such as 1h")
if verb == "watch" {
command.Flags().DurationVar(&interval, "interval", 5*time.Second, "Polling interval")
command.Flags().DurationVar(&timeout, "timeout", time.Minute, "Maximum watch duration")
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "view <id>", Short: "View a notification thread", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
s, err := service(cmd, false)
if err != nil {
return err
}
item, err := s.View(cmd.Context(), id)
if err != nil {
return err
}
return writeItem(a, cmd, item, notificationRow)
}})
for _, mark := range []struct{ verb, status, filter string }{
{"mark-read", "read", "unread"}, {"mark-unread", "unread", "read"}, {"pin", "pinned", "unread"}, {"unpin", "read", "pinned"},
} {
verb := mark.verb
var all bool
var filters []string
var before string
command := &cobra.Command{Use: verb + " [id]...", Short: titleCase(verb) + " selected or filtered notifications", Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 && !all {
return clierr.Wrap(clierr.Usage, errors.New("supply notification IDs or --all"))
}
if len(args) > 0 && all {
return clierr.Wrap(clierr.Usage, errors.New("notification IDs and --all are mutually exclusive"))
}
return nil
}, RunE: func(cmd *cobra.Command, args []string) error {
numbers, err := resourceNumbers(args)
if err != nil {
return err
}
if !cmd.Flags().Changed("status") {
filters = []string{mark.filter}
}
if err := validateNotificationStatuses(filters); err != nil {
return err
}
var until time.Time
if cmd.Flags().Changed("before") {
parsed, err := parseRunTime(before, a.deps.Now())
if err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --before: %w", err))
}
until = *parsed
}
if !all && (cmd.Flags().Changed("status") || cmd.Flags().Changed("before")) {
return clierr.Wrap(clierr.Usage, errors.New("--status and --before require --all"))
}
s, err := service(cmd, all)
if err != nil {
return err
}
if !all {
return batch(a, cmd, numbers, func(id int64) string { return fmt.Sprintf("notification %d", id) }, func(id int64) (services.Notification, error) {
return s.Mark(cmd.Context(), id, mark.status)
}, notificationRow)
}
results, err := s.MarkAll(cmd.Context(), mark.status, filters, until)
if err != nil {
return err
}
return writeList(a, cmd, results, notificationRow)
}}
command.Flags().BoolVar(&all, "all", false, "Apply to all notifications matching filters in the selected scope")
command.Flags().StringSliceVar(&filters, "status", nil, "Filter statuses for --all")
command.Flags().StringVar(&before, "before", "", "Updated before a date, timestamp, or duration (requires --all)")
group.AddCommand(command)
}
return group
}
func validateNotificationStatuses(statuses []string) error {
for _, status := range statuses {
if !slices.Contains([]string{"unread", "read", "pinned"}, status) {
return clierr.Wrap(clierr.Usage, errors.New("--status must contain unread, read, or pinned"))
}
}
return nil
}
func notificationRow(item services.Notification) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Repository, item.Title, item.URL)
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestNotificationBoundsShareClockAndReachServer(t *testing.T) {
now := time.Date(2028, 2, 1, 12, 0, 0, 0, time.UTC)
calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "/api/v1/notifications", r.URL.Path)
require.Equal(t, "2028-01-31T12:00:00Z", r.URL.Query().Get("since"))
require.Equal(t, "2028-02-01T11:00:00Z", r.URL.Query().Get("before"))
require.Equal(t, []string{"unread", "pinned"}, r.URL.Query()["status-types"])
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
reads := 0
deps.Now = func() time.Time { reads++; return now }
err := Execute(context.Background(), deps, []string{"notification", "list", "--mine", "--since=24h", "--before=1h", "--json"})
require.NoError(t, err)
require.Equal(t, 1, reads)
require.Equal(t, 1, calls)
require.JSONEq(t, `[]`, out.String())
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func organizationRow(item services.Organization) []terminal.Cell {
return terminal.Row(item.Name, item.FullName, item.Visibility)
}
func (a *application) organizationCommand() *cobra.Command {
group := &cobra.Command{Use: "org", Short: "Manage organizations"}
var all bool
var username string
var limit, page int
list := &cobra.Command{Use: "list", Short: "List organizations on the selected instance", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if all && username != "" {
return clierr.Wrap(clierr.Usage, errors.New("--all and --username are mutually exclusive"))
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
items, more, err := (services.Organizations{Client: target.client}).List(cmd.Context(), !all && username == "", username, limit, page)
if err != nil {
return err
}
if err := writeList(a, cmd, items, organizationRow); err != nil {
return err
}
return a.morePagesHint(cmd, more, page)
}}
list.Flags().BoolVar(&all, "all", false, "List public organizations instead of your memberships")
list.Flags().StringVar(&username, "username", "", "List organizations for a username")
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "delete"} {
var fullName, description, website, location, visibility string
var adminAccess bool
command := &cobra.Command{Use: verb + " <name>", Short: titleCase(verb) + " an organization", Args: argsExact(1)}
if verb == "delete" {
command.Annotations = textOutput("organization deletion does not return a resource")
}
if verb == "create" || verb == "edit" {
command.Flags().StringVar(&fullName, "full-name", "", "Display name")
command.Flags().StringVar(&description, "description", "", "Organization description")
command.Flags().StringVar(&website, "website", "", "Website URL")
command.Flags().StringVar(&location, "location", "", "Location")
command.Flags().StringVar(&visibility, "visibility", "public", "public, limited, or private")
command.Flags().BoolVar(&adminAccess, "repo-admins-can-change-team-access", false, "Allow repository administrators to change team access")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
name := args[0]
if strings.TrimSpace(name) == "" {
return clierr.Wrap(clierr.Usage, errors.New("organization name cannot be empty"))
}
opts := services.OrganizationOptions{}
if verb == "create" {
opts.Name = &name
opts.Visibility = &visibility
opts.RepoAdminChangeTeamAccess = &adminAccess
}
if verb == "create" || verb == "edit" {
if err := oneOf("visibility", visibility, "public", "limited", "private"); err != nil {
return err
}
if cmd.Flags().Changed("full-name") {
opts.FullName = &fullName
}
if cmd.Flags().Changed("description") {
opts.Description = &description
}
if cmd.Flags().Changed("website") {
opts.Website = &website
}
if cmd.Flags().Changed("location") {
opts.Location = &location
}
if cmd.Flags().Changed("visibility") {
opts.Visibility = &visibility
}
if cmd.Flags().Changed("repo-admins-can-change-team-access") {
opts.RepoAdminChangeTeamAccess = &adminAccess
}
if verb == "edit" && opts == (services.OrganizationOptions{}) {
return clierr.Wrap(clierr.Usage, errors.New("supply organization fields to edit"))
}
}
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
service := services.Organizations{Client: target.client}
if verb == "delete" {
if err := a.confirm(cmd, "Delete organization "+name); err != nil {
return err
}
return service.Delete(cmd.Context(), name)
}
var item services.Organization
switch verb {
case "view":
item, err = service.View(cmd.Context(), name)
case "create":
item, err = service.Create(cmd.Context(), opts)
case "edit":
item, err = service.Edit(cmd.Context(), name, opts)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, organizationRow)
}
group.AddCommand(command)
}
group.AddCommand(a.organizationMemberCommand())
return group
}
func (a *application) organizationMemberCommand() *cobra.Command {
group := &cobra.Command{Use: "member", Short: "Manage organization membership"}
var public bool
var limit, page int
list := &cobra.Command{Use: "list <org>", Short: "List organization members", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
items, err := (services.Organizations{Client: target.client}).Members(cmd.Context(), args[0], public, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, func(item services.OrganizationMember) []terminal.Cell {
return terminal.Row(item.Username, item.FullName)
})
}}
list.Flags().BoolVar(&public, "public", false, "List publicly visible memberships")
pageFlags(list, &limit, &page)
group.AddCommand(list)
for _, verb := range []string{"remove", "public", "private"} {
group.AddCommand(&cobra.Command{Use: verb + " <org> <username>", Short: titleCase(verb) + " an organization membership", Annotations: textOutput("membership changes do not return a resource"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), false)
if err != nil {
return err
}
service := services.Organizations{Client: target.client}
if verb == "remove" {
if err := a.confirm(cmd, "Remove "+args[1]+" from organization "+args[0]); err != nil {
return err
}
return service.RemoveMember(cmd.Context(), args[0], args[1])
}
return service.SetMemberPublic(cmd.Context(), args[0], args[1], verb == "public")
}})
}
return group
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestCommandsWithoutOutputRejectJSON runs every leaf command with --json against a server
// that accepts everything. A command that then succeeds without printing returns no resource
// and must reject --json up front through textOutput.
func TestCommandsWithoutOutputRejectJSON(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`{}`))
default:
w.WriteHeader(http.StatusNoContent)
}
}))
t.Cleanup(server.Close)
deps, _, _ := testDeps(t)
var walk func(*cobra.Command)
walk = func(command *cobra.Command) {
for _, sub := range command.Commands() {
walk(sub)
}
if command.HasSubCommands() || command.Annotations["tea.text"] != "" {
return
}
path := strings.Fields(command.CommandPath())[1:]
t.Run(strings.Join(path, " "), func(t *testing.T) {
t.Parallel()
// Numeric and name-shaped positionals reach different validators.
for _, prefix := range []string{"", "x"} {
var args []string
for command.Args != nil && command.Args(command, args) != nil && len(args) < 3 {
args = append(args, prefix+strconv.Itoa(len(args)+1))
}
deps, out, _ := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
err := Execute(ctx, deps, slices.Concat(path, args, []string{"--json", "--yes", "--no-input"}))
cancel()
if err == nil && out.Len() == 0 {
t.Errorf("%v succeeds with --json but prints nothing; annotate it with textOutput", args)
}
}
})
}
walk(NewRoot(deps))
}
func TestJSONAllowlistsAreNeverEmpty(t *testing.T) {
deps, _, _ := testDeps(t)
var walk func(*cobra.Command)
walk = func(cmd *cobra.Command) {
if fields := commandOutputFields(cmd); fields != nil {
require.NotEmpty(t, fields, cmd.CommandPath())
}
for _, child := range cmd.Commands() {
walk(child)
}
}
walk(NewRoot(deps))
}
func TestOutputFieldAllowlists(t *testing.T) {
for _, tc := range []struct {
args []string
fields []string
bad string
}{
{[]string{"config", "list"}, []string{"git_protocol", "ssh_key_fingerprint"}, "not_a_preference"},
{[]string{"pr", "checkout", "1"}, []string{"branch", "commit", "detached", "remote", "tracking"}, "id"},
{[]string{"pr", "view", "1"}, []string{"head_repo", "head_remote_deleted", "mergeable"}, "unknown"},
{[]string{"repo", "sync"}, []string{"branch", "commit", "remote"}, "id"},
{[]string{"repo", "view"}, []string{"parent"}, "unknown"},
{[]string{"run", "view", "1"}, []string{"event", "actor", "commit", "attempt", "url"}, "unknown"},
{[]string{"workflow", "run", "ci.yml"}, []string{"id", "url"}, "name"},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
deps, out, _ := testDeps(t)
command, _, err := NewRoot(deps).Find(tc.args)
require.NoError(t, err)
fields := commandOutputFields(command)
for _, name := range tc.fields {
require.Contains(t, fields, name)
}
err = Execute(context.Background(), deps, append(tc.args, "--json="+tc.bad, "--no-input"))
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, `"`+tc.bad+`"`)
require.Empty(t, out.String())
})
}
}
func TestJSONFieldsFollowTags(t *testing.T) {
type item struct {
ID int64 `json:"id"`
Optional string `json:"optional,omitempty"`
Hidden string `json:"-"`
Untagged string
}
require.Equal(t, []string{"id", "optional"}, jsonFields[item]())
}
+168
View File
@@ -0,0 +1,168 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/spf13/cobra"
)
func (a *application) pullCleanCommand() *cobra.Command {
var ignoreSHA bool
cmd := &cobra.Command{Use: "clean <number>", Short: "Delete local and remote feature branches for a closed pull request", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
repository, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
if repository.DefaultBranch == "" {
return clierr.Wrap(clierr.Usage, errors.New("repository has no default branch; cannot clean while checked out on the feature branch"))
}
pull, err := (services.Pulls{Client: target.client, Repository: target.Repo}).View(cmd.Context(), n)
if err != nil {
return err
}
if pull.State == "open" {
return errors.New("pull request is still open; close or merge it before cleaning branches")
}
if pull.Head == "" {
return errors.New("Gitea did not return the pull request head branch")
}
if !ignoreSHA && pull.HeadSHA == "" {
return errors.New("Gitea did not return the pull request head commit; retry with --ignore-sha")
}
matchingRemotes, err := a.remotesForRepositoryURLs(cmd.Context(), target.hosts(), pull.HeadCloneURL, pull.HeadSSHURL, pull.HeadRepo)
if err != nil {
return err
}
localBranch, remote, err := git.FindCleanBranch(cmd.Context(), a.deps.Git, git.FindCleanBranchOptions{
Directory: a.options.cwd, HeadSHA: pull.HeadSHA, HeadBranch: pull.Head, IgnoreSHA: ignoreSHA, DefaultBranch: repository.DefaultBranch,
PreferredNames: []string{fmt.Sprintf("pr/%d", n)}, MatchingRemotes: matchingRemotes,
})
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
deleteRemote := !pull.HeadRemoteDeleted && pull.HeadCanPush
if deleteRemote && remote == "" {
switch len(matchingRemotes) {
case 0:
return clierr.Wrap(clierr.Usage, errors.New("no Git remote matches the pull request head repository; add it before deleting the remote branch"))
case 1:
remote = matchingRemotes[0]
default:
return clierr.Wrap(clierr.Usage, errors.New("multiple Git remotes match the pull request head repository; select --remote"))
}
}
if a.options.remote != "" {
if deleteRemote && remote != "" && remote != a.options.remote {
return clierr.Wrap(clierr.Usage, errors.New("selected --remote does not match the pull request head remote"))
}
if remote == "" {
remote = a.options.remote
}
}
var credentialArgs []string
if deleteRemote {
if credentialArgs, err = a.remoteCredentials(cmd.Context(), target, remote); err != nil {
return err
}
}
description := fmt.Sprintf("Delete local branch %q for pull request %s#%d", localBranch, target.Repo, n)
if ignoreSHA {
description += " (matched by name; local commits not in the pull request are lost)"
}
if deleteRemote {
description += fmt.Sprintf(" and remote branch %q", remote+"/"+pull.Head)
}
if err := a.confirm(cmd, description); err != nil {
return err
}
result, err := git.CleanPull(cmd.Context(), a.deps.Git, git.CleanOptions{
Directory: a.options.cwd, DefaultBranch: repository.DefaultBranch, LocalBranch: localBranch,
Remote: remote, RemoteBranch: pull.Head, CredentialArgs: credentialArgs,
Prompt: a.policy(snapshot).CanPrompt(a.deps.Streams), DeleteRemote: deleteRemote,
})
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, result)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Deleted local branch %s\n", result.LocalBranch)
if err != nil {
return err
}
if result.RemoteDeleted {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Deleted remote branch %s/%s\n", result.Remote, result.RemoteBranch)
return err
}
if result.RemoteSkipped {
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", result.RemoteSkipReason)
return err
}
return nil
}}
cmd.Flags().BoolVar(&ignoreSHA, "ignore-sha", false, "Find the local branch by name instead of commit hash")
return cmd
}
func (a *application) remotesForRepositoryURLs(ctx context.Context, hosts map[string]settings.Host, cloneURL, sshURL, fullName string) ([]string, error) {
names, err := a.git(ctx, "remote")
if err != nil {
return nil, err
}
wanted := map[string]bool{}
addWanted := func(host, repo string) {
wanted[host+"\t"+strings.ToLower(repo)] = true
}
for _, raw := range []string{cloneURL, sshURL} {
if raw == "" {
continue
}
host, repo, err := settings.RepositoryURL(raw, hosts)
if err != nil {
continue
}
addWanted(host, repo)
}
if fullName != "" {
for host := range hosts {
addWanted(host, fullName)
}
}
matches := []string{}
for name := range strings.FieldsSeq(names) {
raw, err := a.git(ctx, "remote", "get-url", name)
if err != nil {
return nil, err
}
host, repo, err := settings.RepositoryURL(raw, hosts)
if err != nil {
continue
}
if wanted[host+"\t"+strings.ToLower(repo)] {
matches = append(matches, name)
}
}
return matches, nil
}
+125
View File
@@ -0,0 +1,125 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"fmt"
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings"
"github.com/stretchr/testify/require"
)
func TestPullCleanRejectsOpenPullBeforeGitMutation(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project":
_, _ = fmt.Fprint(w, `{"full_name":"alice/project","default_branch":"main"}`)
case "/api/v1/repos/alice/project/pulls/7":
_, _ = fmt.Fprint(w, `{"number":7,"state":"open","head":{"sha":"abc123","ref":"feature","repo":{"full_name":"alice/project","clone_url":"https://example.org/alice/project.git","permissions":{"push":true}}}}`)
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
runner := &fakeGit{}
deps.Git = runner
err := Execute(context.Background(), deps, []string{"pr", "clean", "7", "--repo", "alice/project", "--no-input"})
require.ErrorContains(t, err, "still open")
require.Empty(t, runner.calls)
require.Empty(t, out.String())
}
func TestPullCleanDeletesLocalAndRemoteBranches(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project":
_, _ = fmt.Fprint(w, `{"full_name":"alice/project","default_branch":"main","clone_url":"https://example.org/alice/project.git"}`)
case "/api/v1/repos/alice/project/pulls/7":
_, _ = fmt.Fprint(w, `{"number":7,"state":"closed","merged":true,"head":{"sha":"abc123","ref":"feature","label":"alice:feature","repo":{"full_name":"alice/project","clone_url":"https://example.org/alice/project.git","ssh_url":"ssh://git@example.org/alice/project.git","permissions":{"push":true}}}}`)
default:
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
configureHost(t, deps, server.URL, settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice")})
deleteRemote := credentialCall(t, deps, server.URL, "alice", "push", "--delete", "--", "origin", "feature")
runner := &fakeGit{results: map[string]string{
deleteRemote: "",
"remote": "origin",
"remote get-url origin": "https://example.org/alice/project.git",
"rev-parse --is-inside-work-tree": "true",
"for-each-ref --format=%(objectname)%09%(refname:short)%09%(upstream:short) refs/heads/": "abc123\tpr/7\torigin/feature",
"check-ref-format --branch pr/7": "",
"check-ref-format --branch main": "",
"status --porcelain=v1 --untracked-files=normal": "",
"rev-parse --verify refs/heads/pr/7^{commit}": "abc123",
"rev-parse --verify refs/heads/main^{commit}": "mainsha",
"symbolic-ref --short HEAD": "pr/7",
"switch -- main": "",
"branch -D -- pr/7": "",
}}
deps.Git = runner
err := Execute(context.Background(), deps, []string{"pr", "clean", "7", "--repo", "alice/project", "--no-input", "--json"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "--yes")
assertNoMutation := func() {
t.Helper()
for _, call := range runner.calls {
require.NotContains(t, call, "switch --")
require.NotContains(t, call, "branch -D")
require.NotContains(t, call, "push --delete")
}
require.Empty(t, out.String())
}
assertNoMutation()
runner.calls = nil
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
prompt := &scriptedPrompter{}
deps.Prompter = prompt
err = Execute(context.Background(), deps, []string{"pr", "clean", "7", "--repo", "alice/project"})
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
require.Len(t, prompt.confirmations, 1)
assertNoMutation()
runner.calls = nil
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "clean", "7", "--repo", "alice/project", "--no-input", "--json", "--yes"}))
require.Contains(t, runner.calls, "branch -D -- pr/7")
require.Contains(t, runner.calls, "switch -- main")
require.Contains(t, runner.calls, deleteRemote)
require.Contains(t, out.String(), `"local_branch":"pr/7"`)
require.Contains(t, out.String(), `"remote_deleted":true`)
}
func TestPullCleanRequiresIgnoreSHAWhenHeadMissingLocally(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project":
_, _ = fmt.Fprint(w, `{"full_name":"alice/project","default_branch":"main"}`)
case "/api/v1/repos/alice/project/pulls/7":
_, _ = fmt.Fprint(w, `{"number":7,"state":"closed","head":{"sha":"abc123","ref":"feature","repo":{"full_name":"alice/project","clone_url":"https://example.org/alice/project.git","permissions":{"push":false}}}}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Git = &fakeGit{results: map[string]string{
"remote": "origin",
"remote get-url origin": "https://example.org/alice/project.git",
"rev-parse --is-inside-work-tree": "true",
"for-each-ref --format=%(objectname)%09%(refname:short)%09%(upstream:short) refs/heads/": "deadbeef\tfeature\torigin/feature",
"rev-parse --verify refs/remotes/origin/feature^{commit}": "deadbeef",
}}
err := Execute(context.Background(), deps, []string{"pr", "clean", "7", "--repo", "alice/project", "--no-input"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "--ignore-sha")
require.Empty(t, out.String())
}
+342
View File
@@ -0,0 +1,342 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullCreateCommand() *cobra.Command {
var options services.PullOptions
var bodyFile, deadline, milestoneName, topic string
var labelNames, reviewers, reviewerTeams, attachments []string
var useEditor, draft, agit, allowMaintainerEdits bool
create := &cobra.Command{Use: "create", Short: "Create a pull request", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
if agit {
if a.options.json != "" {
return clierr.Wrap(clierr.Usage, errors.New("--json/--jq cannot be combined with --agit; AGit creates the pull request through git push"))
}
for _, name := range []string{"assignee", "label", "milestone", "milestone-name", "deadline", "reviewer", "reviewer-team", "attachment"} {
if cmd.Flags().Changed(name) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be combined with --agit; AGit pushes carry only a title and body", name))
}
}
} else if topic != "" {
return clierr.Wrap(clierr.Usage, errors.New("--topic requires --agit"))
}
if err := validateMilestoneFlags(cmd, milestoneName, options.Milestone); err != nil {
return err
}
if err := requireNames("label", "", labelNames); err != nil {
return err
}
if err := requireNames("assignee", "", options.Assignees); err != nil {
return err
}
trimNames(reviewers, reviewerTeams)
reviews := services.ReviewRequestChanges{Add: services.ReviewRequestInput{Users: reviewers, Teams: reviewerTeams}}
requestReviews := len(reviewers)+len(reviewerTeams) > 0
if requestReviews {
if err := reviews.Validate(); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
}
files, err := openRegularFiles(attachments)
defer closeFiles(files)
if err != nil {
return err
}
if cmd.Flags().Changed("deadline") {
if options.Deadline, _, err = parseDeadline(deadline); err != nil {
return err
}
}
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if !localFlagsChanged(cmd) && a.policy(s).CanPrompt(a.deps.Streams) && a.deps.PullForm != nil {
return a.createPullInteractively(cmd, s)
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
options.Labels, err = (services.Labels{Client: t.client, Repository: t.Repo}).ResolveIssueNames(cmd.Context(), labelNames)
if err != nil {
return err
}
if cmd.Flags().Changed("milestone-name") {
if options.Milestone, err = (services.Milestones{Client: t.client, Repository: t.Repo}).ResolveName(cmd.Context(), milestoneName); err != nil {
return err
}
}
if options.Head == "" {
owner, branch := a.defaultPullHead(cmd.Context())
if branch == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --head outside a branch checkout"))
}
options.Head = branch
if baseOwner, _, _ := strings.Cut(t.Repo, "/"); !agit && owner != "" && !strings.EqualFold(owner, baseOwner) {
options.Head = owner + ":" + branch
}
}
if options.Base == "" {
repo, err := (services.Repositories{Client: t.client}).View(cmd.Context(), t.Repo)
if err != nil {
return err
}
if options.Base = repo.DefaultBranch; options.Base == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --base; repository has no default branch"))
}
}
var commitBody string
if options.Title == "" {
if subject, body, ok := a.singleCommitMessage(cmd.Context(), options.Base, options.Head); ok {
options.Title, commitBody = subject, body
} else if options.Title = terminal.DefaultPullTitle(options.Head); options.Title == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --title"))
}
}
service := services.Pulls{Client: t.client, Repository: t.Repo}
if useEditor {
var seed string
if seed, err = service.Template(cmd.Context(), options.Base); err != nil {
return err
}
if seed == "" {
seed = commitBody
}
editorDraft, err = a.editDraft(cmd, seed)
options.Body = editorDraft.Body
} else if !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") {
options.Body = commitBody
} else {
options.Body, err = a.readBody(cmd, options.Body, bodyFile)
}
if err != nil {
return err
}
if draft {
options.Title = services.AddDraftPrefix(options.Title)
}
if agit {
return a.pushAgit(cmd, t, s, git.AgitOptions{Remote: a.options.remote, Head: options.Head, Base: options.Base, Topic: topic, Title: options.Title, Body: options.Body})
}
options.AllowMaintainerEdit = &allowMaintainerEdits
reviewService := services.Reviews{Client: t.client, Repository: t.Repo}
if len(reviewerTeams) > 0 {
if err := reviewService.CheckRequestTeams(cmd.Context(), reviewerTeams); err != nil {
return err
}
}
if err := a.ensureHeadPublished(cmd, t, s, options.Head); err != nil {
return err
}
item, err := service.Create(cmd.Context(), options)
if err != nil {
return err
}
var failures []error
if requestReviews {
if updated, err := reviewService.ChangeRequests(cmd.Context(), item.Number, reviews); err != nil {
failures = append(failures, fmt.Errorf("request reviews: %w", err))
} else {
item = updated
}
}
for _, file := range files {
if _, err := (services.Issues{Client: t.client, Repository: t.Repo}).UploadAttachment(cmd.Context(), item.Number, filepath.Base(file.Name()), file); err != nil {
failures = append(failures, fmt.Errorf("upload %s: %w", file.Name(), err))
}
}
if err := writeRecord(a, cmd, item, pullRecord); err != nil {
return err
}
if len(failures) > 0 {
return fmt.Errorf("pull request #%d was created, but follow-up steps failed: %w", item.Number, errors.Join(failures...))
}
return nil
}}
create.Flags().StringSliceVar(&options.Assignees, "assignee", nil, "Assign users by username")
create.Flags().StringSliceVar(&labelNames, "label", nil, "Apply labels by exact name; repo:/org: disambiguate")
create.Flags().Int64Var(&options.Milestone, "milestone", 0, "Milestone ID")
create.Flags().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone")
create.Flags().StringVar(&options.Title, "title", "", "Pull request title (default: derived from the head branch)")
create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp")
create.Flags().StringVar(&options.Body, "body", "", "Pull request body")
create.Flags().StringVar(&bodyFile, "body-file", "", "Read body from a file, or - for stdin")
create.Flags().BoolVar(&useEditor, "editor", false, "Compose the pull request body in the configured editor")
create.Flags().StringVar(&options.Head, "head", "", "Head branch, optionally user:branch (default: current branch)")
create.Flags().StringVar(&options.Base, "base", "", "Base branch (default: repository default branch)")
create.Flags().BoolVar(&draft, "draft", false, "Prefix title with WIP for Gitea draft behavior")
create.Flags().BoolVar(&allowMaintainerEdits, "allow-maintainer-edits", true, "Allow maintainers to push to the head branch")
create.Flags().BoolVar(&allowMaintainerEdits, "edits", true, "Alias of --allow-maintainer-edits")
_ = create.Flags().MarkHidden("edits")
create.Flags().BoolVar(&agit, "agit", false, "Create the pull request through AGit push (refs/for)")
create.Flags().StringSliceVar(&reviewers, "reviewer", nil, "Request reviews from users by username")
create.Flags().StringSliceVar(&reviewerTeams, "reviewer-team", nil, "Request reviews from teams within the repository's organization")
create.Flags().StringArrayVar(&attachments, "attachment", nil, "Attach a file to the pull request (repeatable)")
create.Flags().StringVar(&topic, "topic", "", "AGit topic name (default: head branch); requires --agit")
return create
}
// pushAgit creates a pull request by pushing the head branch to refs/for/<base>/<topic>.
// Without --remote it pushes to the one remote that matches the resolved repository.
func (a *application) pushAgit(cmd *cobra.Command, t target, s settings.Snapshot, opts git.AgitOptions) error {
if _, branch, ok := strings.Cut(opts.Head, ":"); ok {
opts.Head = branch
}
if opts.Topic == "" {
opts.Topic = opts.Head
}
var err error
if opts.Remote == "" {
if opts.Remote, err = a.checkoutRemote(cmd.Context(), t, s); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("AGit push: %w", err))
}
}
if opts.CredentialArgs, err = a.remoteCredentials(cmd.Context(), t, opts.Remote); err != nil {
return err
}
opts.Directory, opts.Prompt = a.options.cwd, a.policy(s).CanPrompt(a.deps.Streams)
return git.PushAgitPR(cmd.Context(), a.deps.Git, opts)
}
func (a *application) defaultPullHead(ctx context.Context) (owner, branch string) {
probe := a.gitProbe()
branch, remote := git.CurrentUpstreamRemote(ctx, probe, a.options.cwd)
if branch == "" {
return "", ""
}
if remote == "" {
return "", branch
}
raw, err := probe.Output(ctx, a.options.cwd, false, "remote", "get-url", remote)
if err != nil || raw == "" {
return "", branch
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return "", branch
}
_, repo, err := settings.RepositoryURL(raw, snapshot.Hosts.Hosts)
if err != nil {
return "", branch
}
owner, _, _ = strings.Cut(repo, "/")
return owner, branch
}
// singleCommitMessage returns the subject and body of the only commit head has ahead of base.
// Any Git failure, or a head with zero or several commits, reports ok=false.
func (a *application) singleCommitMessage(ctx context.Context, base, head string) (subject, body string, ok bool) {
if _, branch, found := strings.Cut(head, ":"); found {
head = branch
}
if base == "" || head == "" || strings.HasPrefix(base, "-") || strings.HasPrefix(head, "-") {
return "", "", false
}
probe := a.gitProbe()
remotes := []string{"upstream", "origin"}
if a.options.remote != "" {
remotes = []string{a.options.remote}
}
count, err := "", errors.New("no base reference")
for _, ref := range append(remotes, "") {
if ref != "" {
ref += "/"
}
if count, err = probe.Output(ctx, a.options.cwd, false, "rev-list", "--count", ref+base+".."+head); err == nil {
break
}
}
if err != nil || count != "1" {
return "", "", false
}
message, err := probe.Output(ctx, a.options.cwd, false, "log", "-1", "--format=%s%n%n%b", head)
if err != nil {
return "", "", false
}
subject, body, _ = strings.Cut(message, "\n")
subject, body = strings.TrimSpace(subject), strings.TrimSpace(body)
return subject, body, subject != ""
}
func (a *application) createPullInteractively(cmd *cobra.Command, snapshot settings.Snapshot) (runErr error) {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
repo, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
selectables, err := (services.Pulls{Client: target.client, Repository: target.Repo}).CreateSelectables(cmd.Context())
if err != nil {
return err
}
baseOwner, _, _ := strings.Cut(target.Repo, "/")
headOwner, headBranch := a.defaultPullHead(cmd.Context())
template, err := (services.Pulls{Client: target.client, Repository: target.Repo}).Template(cmd.Context(), repo.DefaultBranch)
if err != nil {
return err
}
title, body, _ := a.singleCommitMessage(cmd.Context(), repo.DefaultBranch, headBranch)
if template != "" {
body = template
}
choices := terminal.PullFormChoices{
BaseOwner: baseOwner, Base: repo.DefaultBranch, HeadOwner: headOwner, HeadBranch: headBranch, Title: title, Body: body,
AllowMaintainerEdits: true, CanPush: selectables.CanPush, Assignees: selectables.Assignees,
EditBody: a.bodyEditor(cmd),
}
choices.Milestones, choices.Labels = milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels)
values, err := a.deps.PullForm.CreatePull(cmd.Context(), choices)
defer finishDraft(&values.Draft, &runErr)
if err != nil {
return err
}
if values.Base == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply a target branch; repository has no default branch"))
}
if values.Agit && a.options.json != "" {
return clierr.Wrap(clierr.Usage, errors.New("--json/--jq cannot be combined with AGit create; AGit creates the pull request through git push"))
}
if err := a.confirm(cmd, "Create pull request on "+target.Repo+": "+values.Title); err != nil {
return err
}
if values.Agit {
return a.pushAgit(cmd, target, snapshot, git.AgitOptions{Remote: a.options.remote, Head: values.Head, Base: values.Base, Topic: values.Topic, Title: values.Title, Body: values.Body})
}
options := services.PullOptions{
Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone,
Labels: values.Labels, Deadline: values.Deadline, Base: values.Base, Head: values.Head,
AllowMaintainerEdit: &values.AllowMaintainerEdits,
}
if err := a.ensureHeadPublished(cmd, target, snapshot, options.Head); err != nil {
return err
}
item, err := (services.Pulls{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), options)
if err != nil {
return err
}
return writeRecord(a, cmd, item, pullRecord)
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type recordingPullForm struct {
calls int
values terminal.PullFormValues
choices terminal.PullFormChoices
}
func (f *recordingPullForm) CreatePull(_ context.Context, choices terminal.PullFormChoices) (terminal.PullFormValues, error) {
f.calls++
f.choices = choices
return f.values, nil
}
func TestPullCreateImplicitInteractiveForm(t *testing.T) {
deadline := time.Date(2028, 1, 15, 0, 0, 0, 0, time.UTC)
var createPayload map[string]any
server := newAuthenticatedServer(selectablesHandler(t, true, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/alice/project/raw/.github/pull_request_template.md":
require.Equal(t, "main", r.URL.Query().Get("ref"))
_, _ = w.Write([]byte("## Summary\n"))
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/alice/project/raw/"):
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls":
require.NoError(t, json.NewDecoder(r.Body).Decode(&createPayload))
_, _ = w.Write([]byte(`{"number":9,"title":"Interactive","html_url":"https://example.org/alice/project/pulls/9","state":"open","base":{"ref":"main"},"head":{"ref":"feature"}}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
allow := true
form := &recordingPullForm{values: terminal.PullFormValues{
IssueFormValues: terminal.IssueFormValues{
Title: "Interactive", Body: "Body text", Assignees: []string{"alice"}, Milestone: 4, Labels: []int64{2}, Deadline: &deadline,
},
Base: "main", Head: "feature", AllowMaintainerEdits: allow,
}}
prompt := &scriptedPrompter{accept: true}
deps, out, diagnostics := serverDeps(t, server.URL)
deps.PullForm = form
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
deps.Git = &fakeGit{results: map[string]string{
"symbolic-ref --short HEAD": "feature",
"rev-parse --is-inside-work-tree": "true",
"rev-parse --verify refs/heads/feature^{commit}": "localsha",
"for-each-ref --format=%(objectname) refs/remotes/origin/feature": "localsha",
"remote": "origin",
"remote get-url origin": server.URL + "/alice/project.git",
"config --get branch.feature.remote": "origin",
"rev-list --count origin/main..feature": "1",
"log -1 --format=%s%n%n%b feature": "Add login\n\nDetails",
}}
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--json=number,url", "--interactive"}))
require.Equal(t, 1, form.calls)
require.Equal(t, "Add login", form.choices.Title)
require.Equal(t, "## Summary\n", form.choices.Body)
require.NotNil(t, form.choices.EditBody)
require.True(t, form.choices.CanPush)
require.Equal(t, "main", form.choices.Base)
require.Equal(t, "feature", form.choices.HeadBranch)
require.Equal(t, []string{"alice", "bob"}, form.choices.Assignees)
require.Equal(t, []terminal.IssueMilestoneChoice{{Title: "Release", ID: 4}}, form.choices.Milestones)
require.Equal(t, []terminal.IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "docs", ID: 3}}, form.choices.Labels)
require.Len(t, prompt.confirmations, 1)
require.Equal(t, "Interactive", createPayload["title"])
require.Equal(t, "Body text", createPayload["body"])
require.Equal(t, "main", createPayload["base"])
require.Equal(t, "feature", createPayload["head"])
require.Equal(t, true, createPayload["allow_maintainer_edit"])
require.Equal(t, []any{"alice"}, createPayload["assignees"])
require.Equal(t, float64(4), createPayload["milestone"])
require.Equal(t, []any{float64(2)}, createPayload["labels"])
require.Equal(t, "2028-01-15T00:00:00Z", createPayload["due_date"])
require.JSONEq(t, `{"number":9,"url":"https://example.org/alice/project/pulls/9"}`, out.String())
require.Empty(t, diagnostics.String())
}
func TestPullCreateDefaultsFromForkBranchWithoutTTY(t *testing.T) {
for _, remote := range []string{"fork", "pulls/bob/project"} {
t.Run(remote, func(t *testing.T) { testPullCreateDefaultsFromForkBranch(t, remote) })
}
}
func testPullCreateDefaultsFromForkBranch(t *testing.T, remote string) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project":
_, _ = w.Write([]byte(`{"full_name":"alice/project","default_branch":"main"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls":
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
_, _ = w.Write([]byte(`{"number":1,"html_url":"https://example.org/pulls/1"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
form := &recordingPullForm{}
deps, _, _ := serverDeps(t, server.URL)
deps.PullForm = form
deps.Git = &fakeGit{results: map[string]string{
"symbolic-ref --short HEAD": "über-fix",
"config --get branch.über-fix.remote": remote,
"remote get-url " + remote: server.URL + "/bob/project.git",
}}
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--no-input"}))
require.Zero(t, form.calls)
require.Equal(t, "Über Fix", payload["title"])
require.Equal(t, "bob:über-fix", payload["head"])
require.Equal(t, "main", payload["base"])
}
+301
View File
@@ -0,0 +1,301 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
func publishCreateEnv(t *testing.T, creates *int) (Dependencies, *fakeGit, string) {
t.Helper()
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path)
*creates++
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
require.Equal(t, "feature", payload["head"])
_, _ = w.Write([]byte(`{"number":7,"title":"PR","html_url":"https://example.test/pulls/7"}`))
}))
t.Cleanup(server.Close)
deps, _, _ := serverDeps(t, server.URL)
pushKey := credentialCall(t, deps, server.URL, "alice", "push", "--", "origin", "refs/heads/feature")
runner := &fakeGit{results: map[string]string{
"rev-parse --is-inside-work-tree": "true",
"rev-parse --verify refs/heads/feature^{commit}": "localsha",
"remote": "origin",
"remote get-url origin": server.URL + "/alice/project.git",
"config --get branch.feature.remote": "origin",
pushKey: "",
}}
deps.Git = runner
return deps, runner, pushKey
}
func TestPullCreateAgitPushesToMatchingRemoteWithoutRemoteFlag(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/alice/project":
_, _ = w.Write([]byte(`{"full_name":"alice/project","default_branch":"main","permissions":{"push":true}}`))
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/alice/project/raw/"):
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodGet:
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
pushKey := credentialCall(t, deps, server.URL, "alice", "push", "-o", "title=Title", "--", "gitea", "refs/heads/feature:refs/for/main/feature")
runner := &fakeGit{results: map[string]string{
"remote": "origin gitea",
"remote get-url origin": server.URL + "/bob/project.git",
"remote get-url gitea": server.URL + "/alice/project.git",
"rev-parse --is-inside-work-tree": "true",
pushKey: "",
}}
deps.Git = runner
deps.Prompter = &scriptedPrompter{accept: true}
deps.PullForm = &recordingPullForm{values: terminal.PullFormValues{
IssueFormValues: terminal.IssueFormValues{Title: "Title"}, Agit: true, Base: "main", Head: "feature",
}}
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "--agit", "-R", "alice/project", "--title", "Title", "--head", "feature", "--base", "main", "--no-input"}))
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--interactive"}))
pushes := 0
for _, call := range runner.calls {
if strings.Contains(call, " push ") {
require.Equal(t, pushKey, call)
pushes++
}
}
require.Equal(t, 2, pushes)
runner.results["remote"] = "one two"
runner.results["remote get-url one"], runner.results["remote get-url two"] = server.URL+"/alice/project.git", server.URL+"/alice/project.git"
err := Execute(context.Background(), deps, []string{"pr", "create", "--agit", "-R", "alice/project", "--title", "Title", "--head", "feature", "--base", "main", "--no-input"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "select --remote")
}
func TestPullCreateSkipsPushWhenHeadAlreadyPublished(t *testing.T) {
creates := 0
deps, runner, pushKey := publishCreateEnv(t, &creates)
runner.results["for-each-ref --format=%(objectname) refs/remotes/origin/feature"] = "localsha"
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--json=number", "--no-input"}))
require.Equal(t, 1, creates)
require.NotContains(t, runner.calls, pushKey)
for _, call := range runner.calls {
require.NotContains(t, call, "push --")
}
}
func TestPullCreatePushesWithYesWhenHeadUnpublished(t *testing.T) {
creates := 0
deps, runner, pushKey := publishCreateEnv(t, &creates)
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--json=number", "--no-input", "--yes"}))
require.Equal(t, 1, creates)
require.Contains(t, runner.calls, pushKey)
}
func TestPullCreateRequiresYesWhenHeadUnpublishedWithoutInput(t *testing.T) {
creates := 0
deps, runner, pushKey := publishCreateEnv(t, &creates)
err := Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--json=number", "--no-input"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "--yes")
require.Zero(t, creates)
require.NotContains(t, runner.calls, pushKey)
}
func TestPullCreateCanceledWhenPublishDeclined(t *testing.T) {
creates := 0
deps, runner, pushKey := publishCreateEnv(t, &creates)
prompt := &scriptedPrompter{accept: false}
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
err := Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main"})
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
require.Len(t, prompt.confirmations, 1)
require.Zero(t, creates)
require.NotContains(t, runner.calls, pushKey)
}
func TestPullCreateAllowMaintainerEditsDefaultAndOverride(t *testing.T) {
for _, tc := range []struct {
name string
flags []string
want bool
}{
{"default true", nil, true},
{"explicit false", []string{"--allow-maintainer-edits=false"}, false},
{"edits alias", []string{"--edits=false"}, false},
} {
t.Run(tc.name, func(t *testing.T) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
_, _ = w.Write([]byte(`{"number":7,"title":"PR"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
args := append([]string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--json=number", "--no-input"}, tc.flags...)
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, tc.want, payload["allow_maintainer_edit"])
})
}
}
func TestPullCreateRequestsReviewsAndUploadsAttachments(t *testing.T) {
var reviewPayload map[string]any
var uploaded []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/alice":
_, _ = w.Write([]byte(`{"id":1}`))
case r.URL.Path == "/api/v1/orgs/alice/teams" && r.URL.Query().Get("page") == "1":
_, _ = w.Write([]byte(`[{"id":2,"name":"core"}]`))
case r.URL.Path == "/api/v1/orgs/alice/teams":
_, _ = w.Write([]byte(`[]`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls":
_, _ = w.Write([]byte(`{"number":7,"title":"PR","html_url":"https://example.test/pulls/7"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls/7/requested_reviewers":
require.NoError(t, json.NewDecoder(r.Body).Decode(&reviewPayload))
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/pulls/7":
_, _ = w.Write([]byte(`{"number":7,"title":"PR","requested_reviewers":[{"login":"bob"},{"login":"carol"}],"requested_reviewers_teams":[{"name":"core"}]}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/issues/7/assets":
file, header, err := r.FormFile("attachment")
require.NoError(t, err)
_ = file.Close()
uploaded = append(uploaded, header.Filename)
if header.Filename == "broken.txt" {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(`{"id":1,"name":"` + header.Filename + `"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
dir := t.TempDir()
for _, name := range []string{"log.txt", "broken.txt"} {
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(name), 0o600))
}
deps, out, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{
"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--json=number,reviewers,reviewer_teams", "--no-input",
"--reviewer", "bob,carol", "--reviewer-team", "core", "--attachment", filepath.Join(dir, "log.txt"), "--attachment", filepath.Join(dir, "broken.txt"),
})
require.ErrorContains(t, err, "pull request #7 was created")
require.ErrorContains(t, err, "broken.txt")
require.Equal(t, map[string]any{"reviewers": []any{"bob", "carol"}, "team_reviewers": []any{"core"}}, reviewPayload)
require.Equal(t, []string{"log.txt", "broken.txt"}, uploaded)
require.JSONEq(t, `{"number":7,"reviewers":["bob","carol"],"reviewer_teams":["core"]}`, out.String())
}
func TestPullCreateRejectsUnknownReviewerTeamBeforeCreating(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/orgs/alice":
_, _ = w.Write([]byte(`{"id":1}`))
case "/api/v1/orgs/alice/teams":
_, _ = w.Write([]byte(`[]`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--no-input", "--reviewer-team", "ghost"})
require.ErrorContains(t, err, `team "ghost" is unavailable`)
}
func TestPullCreateDerivesTitleAndBodyFromSingleCommit(t *testing.T) {
message := "Add login form\n\nExplains why."
for _, tc := range []struct {
name, title, body string
results map[string]string
flags []string
}{
{"single commit", "Add login form", "Explains why.", map[string]string{"rev-list --count origin/main..feature": "1", "log -1 --format=%s%n%n%b feature": message}, nil},
{"base remote", "Add login form", "Explains why.", map[string]string{"rev-list --count upstream/main..feature": "1", "log -1 --format=%s%n%n%b feature": message}, nil},
{"local base", "Add login form", "", map[string]string{"rev-list --count main..feature": "1", "log -1 --format=%s%n%n%b feature": "Add login form"}, nil},
{"explicit body kept", "Add login form", "Mine", map[string]string{"rev-list --count origin/main..feature": "1", "log -1 --format=%s%n%n%b feature": message}, []string{"--body", "Mine"}},
{"explicit title kept", "Mine", "", map[string]string{"rev-list --count origin/main..feature": "1", "log -1 --format=%s%n%n%b feature": message}, []string{"--title", "Mine"}},
{"several commits", "Feature", "", map[string]string{"rev-list --count origin/main..feature": "2", "log -1 --format=%s%n%n%b feature": message}, nil},
{"git failure", "Feature", "", nil, nil},
} {
t.Run(tc.name, func(t *testing.T) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
_, _ = w.Write([]byte(`{"number":7}`))
}))
defer server.Close()
deps, _, diagnostics := serverDeps(t, server.URL)
deps.Git = &fakeGit{results: tc.results}
args := append([]string{"pr", "create", "-R", "alice/project", "--head", "feature", "--base", "main", "--json=number", "--no-input"}, tc.flags...)
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, tc.title, payload["title"])
require.Equal(t, tc.body, payload["body"])
require.Empty(t, diagnostics.String())
})
}
}
func TestPullCreateEditorSeedsPullRequestTemplate(t *testing.T) {
var payload map[string]any
var templateRequests []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/v1/repos/alice/project/raw/"):
require.Equal(t, "main", r.URL.Query().Get("ref"))
templateRequests = append(templateRequests, strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/project/raw/"))
if r.URL.Path != "/api/v1/repos/alice/project/raw/.gitea/PULL_REQUEST_TEMPLATE.md" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte("## Checklist\n"))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls":
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
_, _ = w.Write([]byte(`{"number":7}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, _, _ := testDeps(t)
editor, _ := newDraftEditor(t, "filled")
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "editor"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "-R", "alice/project", "--title", "PR", "--head", "feature", "--base", "main", "--editor", "--json=number", "--no-input"}))
require.Equal(t, "## Checklist\n", editor.initial)
require.Equal(t, []string{"PULL_REQUEST_TEMPLATE.md", "pull_request_template.md", ".gitea/PULL_REQUEST_TEMPLATE.md"}, templateRequests)
require.Equal(t, "filled", payload["body"])
}
func TestPullCreateHelpListsDefaultsOnce(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "create", "--help"}))
require.Contains(t, out.String(), "(default true)")
require.NotContains(t, out.String(), "(default true) (default true)")
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullEditCommand() *cobra.Command {
var title, body, file, base string
var deadline string
var draft, ready, maintainer bool
var useEditor bool
var milestone int64
var milestoneName string
var metadata metadataFlags
var reviewers services.ReviewRequestChanges
cmd := &cobra.Command{Use: "edit <number>", Short: "Edit pull request content, base, and draft state", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
n, err := number(args[0])
if err != nil {
return err
}
if draft && ready {
return clierr.Wrap(clierr.Usage, errors.New("--draft and --ready are mutually exclusive"))
}
if err := validateMilestoneFlags(cmd, milestoneName, milestone); err != nil {
return err
}
opts := services.PullEdit{}
if cmd.Flags().Changed("deadline") {
opts.Deadline, opts.RemoveDeadline, err = parseDeadline(deadline)
if err != nil {
return err
}
}
if cmd.Flags().Changed("add-reviewers") || cmd.Flags().Changed("remove-reviewers") || cmd.Flags().Changed("add-reviewer-teams") || cmd.Flags().Changed("remove-reviewer-teams") {
trimNames(reviewers.Add.Users, reviewers.Remove.Users, reviewers.Add.Teams, reviewers.Remove.Teams)
if err := reviewers.Validate(); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
opts.ReviewerChanges = &reviewers
}
if cmd.Flags().Changed("milestone") {
opts.Milestone = &milestone
}
if err := metadata.validate(cmd); err != nil {
return err
}
if cmd.Flags().Changed("title") {
if strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("pull request title cannot be empty"))
}
opts.Title = &title
}
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
body, err = a.readBody(cmd, body, file)
if err != nil {
return err
}
opts.Body = &body
}
if cmd.Flags().Changed("base") {
if strings.TrimSpace(base) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--base cannot be empty"))
}
opts.Base = &base
}
if cmd.Flags().Changed("allow-maintainer-edit") {
opts.AllowMaintainerEdit = &maintainer
}
if opts == (services.PullEdit{}) && !useEditor && !draft && !ready && !metadata.changed(cmd) && !cmd.Flags().Changed("milestone-name") {
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea pr edit --help"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if cmd.Flags().Changed("milestone-name") {
milestone, err = (services.Milestones{Client: target.client, Repository: target.Repo}).ResolveName(cmd.Context(), milestoneName)
if err != nil {
return err
}
opts.Milestone = &milestone
}
if opts.Labels, opts.Assignees, opts.Changes, err = metadata.resolve(cmd, target); err != nil {
return err
}
service := services.Pulls{Client: target.client, Repository: target.Repo}
if draft || ready {
if opts.Title == nil {
current, err := service.View(cmd.Context(), n)
if err != nil {
return err
}
title = current.Title
}
if draft {
title = services.AddDraftPrefix(title)
} else {
title = services.StripDraftPrefix(title)
}
if strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("draft marker removal leaves an empty title; supply --title"))
}
opts.Title = &title
}
if useEditor {
current, err := service.View(cmd.Context(), n)
if err != nil {
return err
}
editorDraft, err = a.editDraft(cmd, current.Body)
if err != nil {
return err
}
opts.Body = &editorDraft.Body
}
item, err := service.Edit(cmd.Context(), n, opts)
if err != nil {
return err
}
return writeRecord(a, cmd, item, pullRecord)
}}
cmd.Flags().StringVar(&title, "title", "", "Pull request title")
cmd.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears")
cmd.Flags().StringVar(&body, "body", "", "Pull request body (empty clears)")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
cmd.Flags().BoolVar(&useEditor, "editor", false, "Edit the current pull request body in the configured editor")
cmd.Flags().StringVar(&base, "base", "", "Target branch")
cmd.Flags().BoolVar(&draft, "draft", false, "Mark the pull request as a draft")
cmd.Flags().BoolVar(&ready, "ready", false, "Remove the draft title marker")
cmd.Flags().BoolVar(&maintainer, "allow-maintainer-edit", false, "Allow maintainers to modify the head branch")
cmd.Flags().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone")
cmd.Flags().Int64Var(&milestone, "milestone", 0, "Milestone ID; 0 clears")
metadata.register(cmd)
cmd.Flags().StringSliceVar(&reviewers.Add.Users, "add-reviewers", nil, "Request reviews from users")
cmd.Flags().StringSliceVar(&reviewers.Remove.Users, "remove-reviewers", nil, "Remove requests for users")
cmd.Flags().StringSliceVar(&reviewers.Add.Teams, "add-reviewer-teams", nil, "Request reviews from organization teams")
cmd.Flags().StringSliceVar(&reviewers.Remove.Teams, "remove-reviewer-teams", nil, "Remove requests for organization teams")
return cmd
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestPullEditPreservesOmittedFields(t *testing.T) {
for _, tc := range []struct {
name string
flags []string
current string
patch string
reads int
}{
{"ready reads existing title", []string{"--ready"}, "[wIp] existing", `{"title":"existing"}`, 1},
{"draft is idempotent", []string{"--draft", "--title", "wip: already"}, "", `{"title":"wip: already"}`, 0},
{"explicit empty and false", []string{"--body=", "--allow-maintainer-edit=false"}, "", `{"body":"","allow_maintainer_edit":false}`, 0},
{"base only", []string{"--base", "stable"}, "", `{"base":"stable"}`, 0},
} {
t.Run(tc.name, func(t *testing.T) {
reads, writes := 0, 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/pulls/7", r.URL.Path)
if r.Method == http.MethodGet {
reads++
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"number": 7, "title": tc.current}))
return
}
require.Equal(t, http.MethodPatch, r.Method)
writes++
requirePayload(t, tc.patch, r.Body)
_, _ = w.Write([]byte(`{"number":7,"title":"result"}`))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
args := append([]string{"pr", "edit", "7", "-R", "alice/project", "--json=number"}, tc.flags...)
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, tc.reads, reads)
require.Equal(t, 1, writes)
require.JSONEq(t, `{"number":7}`, out.String())
require.Empty(t, diagnostics.String())
})
}
}
func TestPullEditAddsAssigneesWithoutExtraReads(t *testing.T) {
var calls []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"number":7,"assignees":[{"login":"alice"}]}`))
return
}
requirePayload(t, `{"assignees":["alice","bob"]}`, r.Body)
_, _ = w.Write([]byte(`{"number":7}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "edit", "7", "-R", "alice/project", "--add-assignees", "bob,Alice", "--json=number"}))
require.Equal(t, []string{"GET /api/v1/repos/alice/project/issues/7", "PATCH /api/v1/repos/alice/project/pulls/7"}, calls)
}
+355
View File
@@ -0,0 +1,355 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"github.com/stretchr/testify/require"
)
func TestGiteaPullEditLifecycle(t *testing.T) {
gitea, deps, out, diagnostics := giteaDeps(t)
execute := outputExecutor(t, &deps, out, diagnostics)
out.Reset()
diagnostics.Reset()
identityErr := Execute(context.Background(), deps, []string{"issue", "create", "-R", "integration/nonexistent", "--title", "Must not submit", "--body", "Body", "--user", "tea-deliberately-wrong-account", "--no-input"})
require.ErrorContains(t, identityErr, "does not belong to the selected account")
require.Equal(t, clierr.Authentication, clierr.ExitCode(identityErr))
require.Empty(t, out.String())
require.Empty(t, diagnostics.String())
var repo services.Repository
name := fmt.Sprintf("tea-pr-edit-%d", time.Now().UnixNano())
require.NoError(t, json.Unmarshal(execute("repo", "create", name, "--auto-init", "--private", "--json"), &repo))
t.Cleanup(func() {
if err := Execute(context.Background(), deps, []string{"repo", "delete", "-R", repo.FullName, "--yes"}); err != nil {
t.Errorf("repository cleanup: %v", err)
}
})
execute("branch", "create", "feature", "-R", repo.FullName)
deps.Streams.In = strings.NewReader("{\"branch\":\"feature\",\"content\":\"dGVzdAo=\",\"message\":\"Disposable PR change\"}")
execute("api", "repos/"+repo.FullName+"/contents/change.txt", "--input", "-")
var label services.Label
require.NoError(t, json.Unmarshal(execute("label", "create", "--name", "review", "--color", "00ff00", "-R", repo.FullName, "--json"), &label))
exportPath := filepath.Join(t.TempDir(), "labels.txt")
execute("label", "export", "-R", repo.FullName, "--output-file", exportPath)
exported, err := os.ReadFile(exportPath)
require.NoError(t, err)
require.Contains(t, string(exported), "00ff00 review ;")
jsonExportPath := exportPath + ".json"
execute("label", "export", "-R", repo.FullName, "--output-file", jsonExportPath, "--json")
jsonExport, err := os.ReadFile(jsonExportPath)
require.NoError(t, err)
var exportedLabels []services.Label
require.NoError(t, json.Unmarshal(jsonExport, &exportedLabels))
require.Contains(t, exportedLabels, label)
var milestone services.Milestone
require.NoError(t, json.Unmarshal(execute("milestone", "create", "--title", "Disposable", "-R", repo.FullName, "--json"), &milestone))
var dated services.Milestone
require.NoError(t, json.Unmarshal(execute("milestone", "edit", fmt.Sprint(milestone.ID), "-R", repo.FullName, "--deadline", "2028-01-15", "--json"), &dated))
require.NotNil(t, dated.Deadline)
preserved := *dated.Deadline
for _, verb := range []string{"view", "close", "reopen"} {
var selected services.Milestone
require.NoError(t, json.Unmarshal(execute("milestone", verb, milestone.Title, "--by-name", "-R", repo.FullName, "--json"), &selected))
require.Equal(t, milestone.ID, selected.ID)
require.NotNil(t, selected.Deadline)
require.Equal(t, preserved, *selected.Deadline)
if verb == "close" {
require.Equal(t, "closed", selected.State)
} else {
require.Equal(t, "open", selected.State)
}
}
require.NoError(t, json.Unmarshal(execute("milestone", "edit", fmt.Sprint(milestone.ID), "-R", repo.FullName, "--deadline=", "--json"), &dated))
require.Nil(t, dated.Deadline)
var secondMilestone services.Milestone
require.NoError(t, json.Unmarshal(execute("milestone", "create", "--title", "Bulk milestone", "-R", repo.FullName, "--json"), &secondMilestone))
for _, verb := range []string{"close", "reopen"} {
var changed []services.Milestone
require.NoError(t, json.Unmarshal(execute("milestone", verb, milestone.Title, secondMilestone.Title, "--by-name", "-R", repo.FullName, "--json"), &changed))
require.Len(t, changed, 2)
require.Equal(t, milestone.ID, changed[0].ID)
require.Equal(t, secondMilestone.ID, changed[1].ID)
for _, item := range changed {
if verb == "close" {
require.Equal(t, "closed", item.State)
} else {
require.Equal(t, "open", item.State)
}
}
}
var pull services.Pull
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, "", "Editor PR body", "pr", "create", "-R", repo.FullName, "--title", "[wip] Initial", "--head", "feature", "--base", repo.DefaultBranch, "--deadline", "2028-01-15", "--assignee", strings.SplitN(repo.FullName, "/", 2)[0], "--label", "review", "--milestone-name", milestone.Title), &pull))
require.Equal(t, []services.Label{label}, pull.Labels)
require.Equal(t, []string{strings.SplitN(repo.FullName, "/", 2)[0]}, pull.Assignees)
require.Equal(t, &services.IssueMilestone{ID: milestone.ID, Title: milestone.Title}, pull.Milestone)
require.NotNil(t, pull.Deadline)
require.Equal(t, "2028-01-15T00:00:00Z", *pull.Deadline)
n := fmt.Sprint(pull.Number)
require.Equal(t, "[]\n", string(execute("pr", "attachment", "list", n, "-R", repo.FullName, "--json")))
verifyResourceBrowser(t, deps, pull.URL, "pr", "view", n, "--repo", repo.FullName)
verifyBodyEditorIntegration(t, deps, "pr", repo.FullName, n, pull.Body)
verifyIssueAttachmentLifecycle(t, deps, "pr", repo.FullName, n)
var pullStatus services.PullStatus
require.NoError(t, json.Unmarshal(execute("pr", "status", "-R", repo.FullName, "--json"), &pullStatus))
require.Len(t, pullStatus.Authored, 1)
require.Equal(t, pull.Number, pullStatus.Authored[0].Number)
require.Equal(t, repo.FullName, pullStatus.Authored[0].Repository)
require.Len(t, pullStatus.Assigned, 1)
require.Equal(t, pull.Number, pullStatus.Assigned[0].Number)
require.Equal(t, repo.FullName, pullStatus.Assigned[0].Repository)
require.Empty(t, pullStatus.ReviewRequested)
var filtered []services.Pull
require.NoError(t, json.Unmarshal(execute("pr", "list", "-R", repo.FullName, "--author", strings.SplitN(repo.FullName, "/", 2)[0], "--base", repo.DefaultBranch, "--sort", "oldest", "--milestone", fmt.Sprint(milestone.ID), "--label", "review", "--json"), &filtered))
require.Len(t, filtered, 1)
require.Equal(t, pull.Number, filtered[0].Number)
require.Equal(t, strings.SplitN(repo.FullName, "/", 2)[0], filtered[0].Author)
require.NotEmpty(t, filtered[0].UpdatedAt)
require.NoError(t, json.Unmarshal(execute("pr", "list", "-R", repo.FullName, "--base", "tea-nonexistent-target-branch", "--json"), &filtered))
require.Empty(t, filtered)
var head struct {
Head struct {
SHA string `json:"sha"`
} `json:"head"`
}
require.NoError(t, json.Unmarshal(execute("api", "repos/"+repo.FullName+"/pulls/"+n), &head))
require.NotEmpty(t, head.Head.SHA)
execute("repo", "edit", "-R", repo.FullName, "--private=false")
workingTree := filepath.Join(t.TempDir(), "checkout")
runner := git.System{In: deps.Streams.In, ErrOut: diagnostics}
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", repo.FullName, workingTree, "--depth", "1", "--no-input", "--json"}))
var cloned services.Repository
require.NoError(t, json.Unmarshal(out.Bytes(), &cloned))
require.Equal(t, repo.ID, cloned.ID)
require.Equal(t, repo.FullName, cloned.FullName)
shallow, err := runner.Output(context.Background(), workingTree, false, "rev-parse", "--is-shallow-repository")
require.NoError(t, err)
require.Equal(t, "true", shallow)
var checkout git.CheckoutResult
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "checkout", n, "-R", repo.FullName, "--cwd", workingTree, "--no-input", "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &checkout))
require.Contains(t, diagnostics.String(), "FETCH_HEAD")
require.Equal(t, "pr/"+n, checkout.Branch)
require.Equal(t, head.Head.SHA, checkout.Commit)
actual, err := runner.Output(context.Background(), workingTree, false, "rev-parse", "HEAD")
require.NoError(t, err)
require.Equal(t, head.Head.SHA, actual)
execute("repo", "edit", "-R", repo.FullName, "--private=true")
for _, state := range []string{"failure", "success"} {
payload, err := json.Marshal(map[string]string{"state": state, "context": "tea-check", "description": "Disposable check"})
require.NoError(t, err)
deps.Streams.In = bytes.NewReader(payload)
execute("api", "repos/"+repo.FullName+"/statuses/"+head.Head.SHA, "--input", "-")
}
var checks []services.Check
require.NoError(t, json.Unmarshal(execute("pr", "checks", n, "-R", repo.FullName, "--json"), &checks))
require.Len(t, checks, 1)
require.Equal(t, "success", checks[0].State)
require.Equal(t, "tea-check", checks[0].Name)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--set-labels", "repo:review", "--json"), &pull))
require.Equal(t, []services.Label{label}, pull.Labels)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--set-labels=", "--json"), &pull))
require.Empty(t, pull.Labels)
require.NotNil(t, pull.Labels)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--ready", "--body", "Updated", "--milestone-name", milestone.Title, "--allow-maintainer-edit=false", "--json"), &pull))
require.Equal(t, "Initial", pull.Title)
require.Equal(t, "Updated", pull.Body)
execute("pr", "edit", n, "-R", repo.FullName, "--milestone=0", "--body=", "--set-assignees=", "--draft", "--deadline=")
var raw struct {
Milestone *struct{ ID int64 }
Assignees []any
Title, Body string
Deadline *string `json:"due_date"`
}
require.NoError(t, json.Unmarshal(execute("api", "repos/"+repo.FullName+"/pulls/"+n), &raw))
require.Nil(t, raw.Milestone)
require.Nil(t, raw.Deadline)
require.Empty(t, raw.Assignees)
require.Empty(t, raw.Body)
require.Equal(t, "WIP: Initial", raw.Title)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--deadline", "2028-02-01T00:30:00+02:00", "--json"), &pull))
assertEditedDeadline(t, pull.Deadline, "2028-02-01")
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--deadline=", "--json"), &pull))
require.Nil(t, pull.Deadline)
var issue services.Issue
owner, _, _ := strings.Cut(repo.FullName, "/")
require.NoError(t, json.Unmarshal(execute("issue", "create", "-R", repo.FullName, "--title", "Deadline fixture", "--deadline", "2028-01-15", "--ref", "feature", "--assignee", owner, "--milestone-name", milestone.Title, "--json"), &issue))
require.NotNil(t, issue.Deadline)
require.Equal(t, "feature", issue.Ref)
require.Equal(t, []string{owner}, issue.Assignees)
require.NotNil(t, issue.Milestone)
require.Equal(t, milestone.ID, issue.Milestone.ID)
issueNumber := fmt.Sprint(issue.Number)
require.NoError(t, json.Unmarshal(execute("issue", "edit", issueNumber, "-R", repo.FullName, "--ref", repo.DefaultBranch, "--json"), &issue))
require.Equal(t, repo.DefaultBranch, issue.Ref)
require.NoError(t, json.Unmarshal(execute("issue", "edit", issueNumber, "-R", repo.FullName, "--ref=", "--set-assignees=", "--milestone=0", "--json"), &issue))
require.Empty(t, issue.Ref)
require.Empty(t, issue.Assignees)
require.Nil(t, issue.Milestone)
require.NoError(t, json.Unmarshal(execute("issue", "edit", issueNumber, "-R", repo.FullName, "--deadline", "2028-02-01T00:30:00+02:00", "--json"), &issue))
assertEditedDeadline(t, issue.Deadline, "2028-02-01")
require.NoError(t, json.Unmarshal(execute("issue", "edit", issueNumber, "-R", repo.FullName, "--deadline=", "--json"), &issue))
require.Nil(t, issue.Deadline)
commentsFile := filepath.Join(t.TempDir(), "comments.json")
require.NoError(t, os.WriteFile(commentsFile, []byte(`[{"path":"change.txt","body":"Disposable inline review","new_position":1}]`), 0o600))
var review services.Review
require.NoError(t, json.Unmarshal(execute("pr", "review", n, "-R", repo.FullName, "--comments-file", commentsFile, "--json"), &review))
require.NotZero(t, review.ID)
require.NotEmpty(t, review.Author)
require.Equal(t, "COMMENT", review.State)
var reviews []services.Review
require.NoError(t, json.Unmarshal(execute("pr", "review", "list", n, "-R", repo.FullName, "--json"), &reviews))
require.Len(t, reviews, 1)
var comments []services.ReviewComment
require.NoError(t, json.Unmarshal(execute("pr", "review", "comments", n, "-R", repo.FullName, "--json"), &comments))
require.Len(t, comments, 1)
require.Equal(t, "change.txt", comments[0].Path)
require.EqualValues(t, 1, comments[0].Line)
commentID := fmt.Sprint(comments[0].ID)
require.Contains(t, string(execute("pr", "review", "reply", n, commentID, "-R", repo.FullName, "--body", "Disposable reply")), "Disposable reply")
require.NoError(t, json.Unmarshal(execute("pr", "review", "comments", n, "-R", repo.FullName, "--json"), &comments))
require.Len(t, comments, 2)
require.Equal(t, "Disposable reply", comments[1].Body)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "resolve", commentID, "-R", repo.FullName}))
require.Empty(t, out.String())
require.Contains(t, diagnostics.String(), "resolved")
require.NoError(t, json.Unmarshal(execute("pr", "review", "comments", n, "-R", repo.FullName, "--review", fmt.Sprint(review.ID), "--json"), &comments))
require.NotEmpty(t, comments[0].Resolver)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "unresolve", commentID, "-R", repo.FullName}))
require.Empty(t, out.String())
require.NoError(t, json.Unmarshal(execute("pr", "review", "comments", n, "-R", repo.FullName, "--json"), &comments))
require.Empty(t, comments[0].Resolver)
var pending services.Review
require.NoError(t, json.Unmarshal(execute("pr", "review", n, "-R", repo.FullName, "--event", "PENDING", "--body", "Preserved draft text", "--json"), &pending))
require.Equal(t, "PENDING", pending.State)
var submitted services.Review
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, pending.Body, "Edited draft text", "pr", "review", "submit", n, fmt.Sprint(pending.ID), "-R", repo.FullName, "--event", "COMMENT"), &submitted))
require.Equal(t, "COMMENT", submitted.State)
require.Equal(t, "Edited draft text", submitted.Body)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "delete", n, fmt.Sprint(submitted.ID), "-R", repo.FullName, "--yes"}))
verifyEditorSubmission(t, deps, repo.FullName, n, true)
require.Empty(t, out.String())
require.Contains(t, diagnostics.String(), "Deleted review")
if reviewerTokenFile := os.Getenv("GITEA_TEA_TEST_REVIEWER_TOKEN_FILE"); reviewerTokenFile != "" {
reviewerDeps := deps
reviewerDeps.Getenv = gitea.env(readTokenFile(t, reviewerTokenFile))
out.Reset()
require.NoError(t, Execute(context.Background(), reviewerDeps, []string{"api", "user"}))
var reviewer struct{ Login string }
require.NoError(t, json.Unmarshal(out.Bytes(), &reviewer))
require.NotEmpty(t, reviewer.Login)
deps.Streams.In = strings.NewReader(`{"permission":"write"}`)
execute("api", "repos/"+repo.FullName+"/collaborators/"+reviewer.Login, "--method", "PUT", "--input", "-")
var requested services.Pull
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--add-reviewers", reviewer.Login, "--json"), &requested))
require.Contains(t, requested.Reviewers, reviewer.Login)
require.Empty(t, requested.ReviewerTeams)
out.Reset()
require.NoError(t, Execute(context.Background(), reviewerDeps, []string{"pr", "status", "-R", repo.FullName, "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &pullStatus))
require.Empty(t, pullStatus.Authored)
require.Len(t, pullStatus.ReviewRequested, 1)
require.Equal(t, pull.Number, pullStatus.ReviewRequested[0].Number)
require.Equal(t, repo.FullName, pullStatus.ReviewRequested[0].Repository)
require.NoError(t, json.Unmarshal(execute("pr", "review", "cancel-request", n, "-R", repo.FullName, "--reviewer", reviewer.Login, "--json"), &requested))
require.NotContains(t, requested.Reviewers, reviewer.Login)
require.NoError(t, json.Unmarshal(execute("pr", "review", "request", n, "-R", repo.FullName, "--reviewer", reviewer.Login, "--json"), &requested))
require.Contains(t, requested.Reviewers, reviewer.Login)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--remove-reviewers", reviewer.Login, "--body", "Updated alongside review request", "--json"), &requested))
require.NotContains(t, requested.Reviewers, reviewer.Login)
require.Equal(t, "Updated alongside review request", requested.Body)
execute("pr", "review", "request", n, "-R", repo.FullName, "--reviewer", reviewer.Login)
out.Reset()
require.NoError(t, Execute(context.Background(), reviewerDeps, []string{"pr", "review", n, "-R", repo.FullName, "--event", "APPROVED", "--json"}))
var approval services.Review
require.NoError(t, json.Unmarshal(out.Bytes(), &approval))
require.Equal(t, "APPROVED", approval.State)
out.Reset()
require.NoError(t, Execute(context.Background(), reviewerDeps, []string{"pr", "status", "-R", repo.FullName, "--json"}))
require.NoError(t, json.Unmarshal(out.Bytes(), &pullStatus))
require.Empty(t, pullStatus.ReviewRequested)
require.NoError(t, json.Unmarshal(executeWithEditorIntegration(t, deps, "", "Disposable dismissal reason", "pr", "review", "dismiss", n, fmt.Sprint(approval.ID), "-R", repo.FullName, "--yes"), &approval))
require.True(t, approval.Dismissed)
require.NoError(t, json.Unmarshal(execute("pr", "review", "undismiss", n, fmt.Sprint(approval.ID), "-R", repo.FullName, "--json"), &approval))
require.False(t, approval.Dismissed)
testTeamReviewRequests(t, &deps, execute, reviewer.Login)
}
execute("pr", "close", n, "-R", repo.FullName)
var pulls []services.Pull
require.NoError(t, json.Unmarshal(execute("pr", "list", "--state=closed", "-R", repo.FullName, "--json"), &pulls))
require.Len(t, pulls, 1)
require.Equal(t, "closed", pulls[0].State)
}
func testTeamReviewRequests(t *testing.T, deps *Dependencies, execute func(...string) []byte, reviewer string) {
t.Helper()
orgName := fmt.Sprintf("tea-review-org-%d", time.Now().UnixNano())
execute("org", "create", orgName, "--visibility", "private")
t.Cleanup(func() {
if err := Execute(context.Background(), *deps, []string{"org", "delete", orgName, "--yes"}); err != nil {
t.Errorf("review organization cleanup: %v", err)
}
})
var team services.Team
require.NoError(t, json.Unmarshal(execute("team", "create", orgName, "--name", "Reviewers", "--unit", "repo.code=read,repo.pulls=write", "--json"), &team))
teamID := fmt.Sprint(team.ID)
execute("team", "member", "add", teamID, reviewer)
var repo services.Repository
require.NoError(t, json.Unmarshal(execute("repo", "create", "review-target", "--org", orgName, "--auto-init", "--private", "--json"), &repo))
t.Cleanup(func() {
if err := Execute(context.Background(), *deps, []string{"repo", "delete", "-R", repo.FullName, "--yes"}); err != nil {
t.Errorf("team review repository cleanup: %v", err)
}
})
execute("team", "repo", "add", teamID, "-R", repo.FullName)
execute("branch", "create", "feature", "-R", repo.FullName)
deps.Streams.In = strings.NewReader(`{"branch":"feature","content":"dGVzdAo=","message":"Disposable team review change"}`)
execute("api", "repos/"+repo.FullName+"/contents/change.txt", "--input", "-")
var pull services.Pull
require.NoError(t, json.Unmarshal(execute("pr", "create", "-R", repo.FullName, "--title", "Team review fixture", "--head", "feature", "--base", repo.DefaultBranch, "--json"), &pull))
n := fmt.Sprint(pull.Number)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--add-reviewer-teams", team.Name, "--json"), &pull))
require.Equal(t, []string{team.Name}, pull.ReviewerTeams)
require.Empty(t, pull.Reviewers)
require.NoError(t, json.Unmarshal(execute("pr", "review", "cancel-request", n, "-R", repo.FullName, "--team", team.Name, "--json"), &pull))
require.Empty(t, pull.ReviewerTeams)
require.NoError(t, json.Unmarshal(execute("pr", "review", "request", n, "-R", repo.FullName, "--team", team.Name, "--json"), &pull))
require.Equal(t, []string{team.Name}, pull.ReviewerTeams)
require.NoError(t, json.Unmarshal(execute("pr", "edit", n, "-R", repo.FullName, "--remove-reviewer-teams", team.Name, "--json"), &pull))
require.Empty(t, pull.ReviewerTeams)
}
func assertEditedDeadline(t *testing.T, value *string, day string) {
t.Helper()
require.NotNil(t, value)
date, err := time.Parse(time.RFC3339, *value)
require.NoError(t, err)
require.Equal(t, day, date.Format("2006-01-02"))
require.Equal(t, 59, date.Minute())
require.Equal(t, 59, date.Second())
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"net/http"
"testing"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type mergeFormFunc func(context.Context, []terminal.MergePullChoice) (int64, error)
func (f mergeFormFunc) SelectPull(ctx context.Context, choices []terminal.MergePullChoice) (int64, error) {
return f(ctx, choices)
}
func TestPullMergeInteractiveSelectsMatchingBranch(t *testing.T) {
merges := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/pulls":
require.Equal(t, "open", r.URL.Query().Get("state"))
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`[]`))
return
}
_, _ = w.Write([]byte(`[
{"number":3,"title":"Other","state":"open","base":{"ref":"main"},"head":{"ref":"other","label":"alice:other"}},
{"number":7,"title":"Feature","state":"open","base":{"ref":"main"},"head":{"ref":"feature","label":"alice:feature"}},
{"number":9,"title":"Into feature","state":"open","base":{"ref":"feature"},"head":{"ref":"topic","label":"alice:topic"}}
]`))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/pulls/7":
_, _ = w.Write([]byte(`{"number":7,"head":{"ref":"feature","sha":"abc123"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls/7/merge":
merges++
w.WriteHeader(http.StatusOK)
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
calls := 0
prompt := &scriptedPrompter{accept: true}
deps, _, _ := serverDeps(t, server.URL)
deps.MergeForm = mergeFormFunc(func(_ context.Context, choices []terminal.MergePullChoice) (int64, error) {
calls++
require.Len(t, choices, 2)
require.EqualValues(t, 7, choices[0].Number)
require.EqualValues(t, 9, choices[1].Number)
return 7, nil
})
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
deps.Git = &fakeGit{results: map[string]string{"symbolic-ref --short HEAD": "feature"}}
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "merge", "-R", "alice/project", "--interactive", "--yes"}))
require.Equal(t, 1, calls)
require.Equal(t, 1, merges)
}
func TestPullMergeRequiresNumberWithoutInteractive(t *testing.T) {
deps, _, _ := testDeps(t)
deps.MergeForm = mergeFormFunc(func(context.Context, []terminal.MergePullChoice) (int64, error) {
t.Fatal("merge form should not run")
return 0, nil
})
err := Execute(context.Background(), deps, []string{"pr", "merge", "-R", "alice/project", "--no-input"})
require.Error(t, err)
require.Contains(t, err.Error(), "pull request number")
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"net/http"
"os"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
func TestPullMergeSendsTitleAndMessage(t *testing.T) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
require.Equal(t, "/api/v1/repos/alice/project/pulls/3", r.URL.Path)
_, _ = w.Write([]byte(`{"number":3,"head":{"sha":"abc123"}}`))
return
}
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/pulls/3/merge", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{
"pr", "merge", "3", "-R", "alice/project", "--method", "squash",
"--title", "Squash title", "--message", "Squash body", "--yes",
}))
require.Equal(t, "squash", payload["Do"])
require.Equal(t, "Squash title", payload["MergeTitleField"])
require.Equal(t, "Squash body", payload["MergeMessageField"])
require.Equal(t, false, payload["delete_branch_after_merge"])
require.Equal(t, "abc123", payload["head_commit_id"])
payload = nil
require.NoError(t, Execute(context.Background(), deps, []string{
"pr", "merge", "3", "-R", "alice/project", "--style", "rebase", "--yes",
}))
require.Equal(t, "rebase", payload["Do"])
}
func TestPullMergeMessageFileAndEditor(t *testing.T) {
var payload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"number":3,"head":{"sha":"abc123"}}`))
return
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
deps, _, _ := testDeps(t)
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "chosen-editor"})
file := t.TempDir() + "/message.md"
require.NoError(t, os.WriteFile(file, []byte("from file"), 0o600))
require.NoError(t, Execute(context.Background(), deps, []string{
"pr", "merge", "3", "-R", "alice/project", "--message-file", file, "--yes",
}))
require.Equal(t, "from file", payload["MergeMessageField"])
deps.Editor, _ = newDraftEditor(t, "from editor")
require.NoError(t, Execute(context.Background(), deps, []string{
"pr", "merge", "3", "-R", "alice/project", "--editor", "--yes", "--no-input",
}))
require.Equal(t, "from editor", payload["MergeMessageField"])
require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{
"pr", "merge", "3", "-R", "alice/project", "--message", "x", "--editor", "--yes",
})))
}
func TestPullMergePromptsToDeleteBranch(t *testing.T) {
for _, tc := range []struct {
name string
accept, tty bool
flags []string
want bool
confirmations int
}{
{"accepted", true, true, nil, true, 2},
{"explicit flag skips prompt", true, true, []string{"--delete-branch=false"}, false, 1},
{"no terminal keeps default", true, false, []string{"--yes"}, false, 0},
} {
t.Run(tc.name, func(t *testing.T) {
var payload map[string]any
views := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/pulls/3":
views++
_, _ = w.Write([]byte(`{"number":3,"head":{"ref":"feature","sha":"abc123"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls/3/merge":
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
prompt := &scriptedPrompter{accept: tc.accept}
deps, _, _ := serverDeps(t, server.URL)
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = tc.tty, tc.tty
require.NoError(t, Execute(context.Background(), deps, append([]string{"pr", "merge", "3", "-R", "alice/project"}, tc.flags...)))
require.Equal(t, tc.want, payload["delete_branch_after_merge"])
require.Len(t, prompt.confirmations, tc.confirmations)
promptViews := 0
if tc.confirmations == 2 {
promptViews = 1
}
require.Equal(t, 1+promptViews, views, "the merge reads the current head; the prompt reads the branch")
})
}
}
+336
View File
@@ -0,0 +1,336 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullReviewCommand() *cobra.Command {
var event, body, file, commentsFile, commit string
var useEditor bool
review := &cobra.Command{Use: "review <number>", Short: "Submit or inspect pull request reviews", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
n, err := number(args[0])
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
policy := a.policy(snapshot)
if !localFlagsChanged(cmd) && policy.CanPrompt(a.deps.Streams) && a.deps.ReviewForm != nil {
return a.createReviewInteractively(cmd, n)
}
if err := oneOf("event", event, "APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING"); err != nil {
return err
}
if useEditor {
draft, err = a.editDraft(cmd, "")
body = draft.Body
} else {
body, err = a.readBody(cmd, body, file)
}
if err != nil {
return err
}
opts := services.ReviewOptions{Event: event, Body: body, Commit: commit}
if commentsFile != "" {
if commentsFile == "-" {
return clierr.Wrap(clierr.Usage, errors.New("--comments-file requires a file; use --body-file - for stdin"))
}
file, err := os.Open(commentsFile)
if err != nil {
return err
}
defer file.Close()
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&opts.Comments); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid inline comments: %w", err))
}
if opts.Comments == nil {
return clierr.Wrap(clierr.Usage, errors.New("inline comments must be a JSON array"))
}
if err := decoder.Decode(new(any)); err != io.EOF {
return clierr.Wrap(clierr.Usage, errors.New("inline comments file must contain exactly one JSON array"))
}
for _, comment := range opts.Comments {
if err := comment.Validate(); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
}
}
prompted := false
if (event == "COMMENT" || event == "REQUEST_CHANGES" || event == "PENDING") && strings.TrimSpace(body) == "" && len(opts.Comments) == 0 {
if useEditor || cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
return clierr.Wrap(clierr.Usage, errors.New("review body cannot be empty without inline comments"))
}
prompted, err = a.require(cmd, &body, "body")
if err != nil {
return err
}
if strings.TrimSpace(body) == "" {
return clierr.Wrap(clierr.Usage, errors.New("review body cannot be empty"))
}
opts.Body = body
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if prompted {
if err := a.confirm(cmd, fmt.Sprintf("Submit %s review on %s#%d: %s", event, target.Repo, n, body)); err != nil {
return err
}
}
item, err := (services.Reviews{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), n, opts)
if err != nil {
return err
}
return writeRecord(a, cmd, item, reviewRecord)
}}
review.Flags().StringVar(&event, "event", "COMMENT", "Review event: APPROVED, REQUEST_CHANGES, COMMENT, PENDING")
review.Flags().StringVar(&body, "body", "", "Review body")
review.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
review.Flags().BoolVar(&useEditor, "editor", false, "Compose the review in the configured editor")
review.Flags().StringVar(&commentsFile, "comments-file", "", "JSON array of path, body, old_position/new_position inline comments")
review.Flags().StringVar(&commit, "commit", "", "Commit to review (default: current PR head)")
for _, verb := range []string{"list", "view", "comments"} {
var limit, page int
var reviewID int64
use := verb + " <number>"
count := 1
if verb == "view" {
use += " <review-id>"
count = 2
}
command := &cobra.Command{Use: use, Short: titleCase(verb) + " pull request reviews", Args: argsExact(count), RunE: func(cmd *cobra.Command, args []string) error {
n, err := number(args[0])
if err != nil {
return err
}
if reviewID < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--review must be a positive review ID"))
}
if verb == "view" {
reviewID, err = number(args[1])
if err != nil {
return err
}
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Reviews{Client: target.client, Repository: target.Repo}
switch verb {
case "view":
item, err := service.View(cmd.Context(), n, reviewID)
if err != nil {
return err
}
return writeRecord(a, cmd, item, reviewRecord)
case "list":
items, err := service.List(cmd.Context(), n, limit, page)
if err != nil {
return err
}
return writeRecords(a, cmd, items, reviewRecord)
case "comments":
items, err := service.Comments(cmd.Context(), n, reviewID, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, func(item services.ReviewComment) []terminal.Cell {
return terminal.Row(strconv.FormatInt(item.ID, 10), fmt.Sprintf("%s:%d", item.Path, item.Line), item.Author, item.Body)
})
}
return nil
}}
if verb != "view" {
pageFlags(command, &limit, &page)
}
if verb == "comments" {
command.Flags().Int64Var(&reviewID, "review", 0, "Limit comments to one review ID")
}
review.AddCommand(command)
}
for _, verb := range []string{"resolve", "unresolve"} {
review.AddCommand(&cobra.Command{Use: verb + " <comment-id>", Short: titleCase(verb) + " an inline review conversation", Annotations: textOutput("conversation state changes do not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := number(args[0])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if err := (services.Reviews{Client: target.client, Repository: target.Repo}).Resolve(cmd.Context(), id, verb == "resolve"); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "Comment %d %sd\n", id, verb)
return err
}})
}
review.AddCommand(a.pullReviewReplyCommand())
review.AddCommand(a.pullReviewRequestCommand(true), a.pullReviewRequestCommand(false))
for _, verb := range []string{"submit", "delete", "dismiss", "undismiss"} {
review.AddCommand(a.pullReviewLifecycleCommand(verb))
}
return review
}
func (a *application) pullReviewReplyCommand() *cobra.Command {
var body, file string
var useEditor bool
cmd := &cobra.Command{Use: "reply <number> <comment-id>", Short: "Reply to an inline review comment", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
n, err := number(args[0])
if err != nil {
return err
}
id, err := number(args[1])
if err != nil {
return err
}
if useEditor {
draft, err = a.editDraft(cmd, "")
body = draft.Body
} else {
body, err = a.readBody(cmd, body, file)
}
if err != nil {
return err
}
prompted := false
if !useEditor && !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") {
prompted, err = a.require(cmd, &body, "body")
if err != nil {
return err
}
}
if strings.TrimSpace(body) == "" {
return clierr.Wrap(clierr.Usage, errors.New("reply body cannot be empty"))
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
if prompted {
if err := a.confirm(cmd, fmt.Sprintf("Reply to review comment %d on %s#%d: %s", id, target.Repo, n, body)); err != nil {
return err
}
}
item, err := (services.Reviews{Client: target.client, Repository: target.Repo}).Reply(cmd.Context(), n, id, body)
if err != nil {
return err
}
return writeRecord(a, cmd, item, func(item services.ReviewComment) terminal.Record {
return terminal.Record{Heading: terminal.Row(strconv.FormatInt(item.ID, 10)), Lines: []string{item.Body, item.URL}}
})
}}
cmd.Flags().BoolVar(&useEditor, "editor", false, "Write the reply in the configured editor")
cmd.Flags().StringVar(&body, "body", "", "Reply body")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
return cmd
}
func reviewRecord(item services.Review) terminal.Record {
return terminal.Record{Heading: terminal.Row(strconv.FormatInt(item.ID, 10), item.State, item.Author), Lines: []string{item.Body, item.URL}}
}
func (a *application) createReviewInteractively(cmd *cobra.Command, n int64) (runErr error) {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
values, err := a.deps.ReviewForm.CreateReview(cmd.Context(), terminal.ReviewFormChoices{
ReviewDiff: func(context.Context) ([]terminal.ReviewCommentDraft, error) {
return a.collectDiffReviewComments(cmd, target, n)
},
EditBody: a.bodyEditor(cmd),
})
defer finishDraft(&values.Draft, &runErr)
if err != nil {
return err
}
opts := services.ReviewOptions{Event: values.Event, Body: values.Body}
for _, comment := range values.Comments {
opts.Comments = append(opts.Comments, services.InlineComment(comment))
}
description := fmt.Sprintf("Submit %s review on %s#%d", values.Event, target.Repo, n)
if values.Body != "" {
description += ": " + values.Body
}
if err := a.confirm(cmd, description); err != nil {
return err
}
item, err := (services.Reviews{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), n, opts)
if err != nil {
return err
}
return writeRecord(a, cmd, item, reviewRecord)
}
func (a *application) collectDiffReviewComments(cmd *cobra.Command, t target, n int64) ([]terminal.ReviewCommentDraft, error) {
diff, err := (services.Pulls{Client: t.client, Repository: t.Repo}).Diff(cmd.Context(), n)
if err != nil {
return nil, err
}
defer diff.Close()
path, err := saveDiffReview("", n, t.Repo, diff)
if err != nil {
return nil, err
}
comments, err := a.reviewDiffFile(cmd, path)
if err != nil {
return nil, fmt.Errorf("%w; the annotated diff is kept at %s", err, path)
}
_ = os.Remove(path)
return comments, nil
}
// reviewDiffFile lets the user annotate the diff at path and returns the validated comments.
func (a *application) reviewDiffFile(cmd *cobra.Command, path string) ([]terminal.ReviewCommentDraft, error) {
if err := a.editPath(cmd, path); err != nil {
return nil, err
}
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("couldn't load diff: %w", err)
}
defer file.Close()
comments, err := parseDiffComments(file)
if err != nil {
return nil, err
}
for _, comment := range comments {
if err := services.InlineComment(comment).Validate(); err != nil {
return nil, err
}
}
return comments, nil
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require"
)
type reviewFormFunc func(context.Context, terminal.ReviewFormChoices) (terminal.ReviewFormValues, error)
func (f reviewFormFunc) CreateReview(ctx context.Context, choices terminal.ReviewFormChoices) (terminal.ReviewFormValues, error) {
return f(ctx, choices)
}
func TestPullReviewImplicitInteractiveForm(t *testing.T) {
var createPayload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/repos/alice/project/pulls/3/reviews", r.URL.Path)
require.NoError(t, json.NewDecoder(r.Body).Decode(&createPayload))
_, _ = w.Write([]byte(`{"id":11,"state":"APPROVED","body":"Looks good","html_url":"https://example.org/reviews/11","user":{"login":"alice"}}`))
}))
defer server.Close()
calls := 0
prompt := &scriptedPrompter{accept: true}
deps, out, diagnostics := serverDeps(t, server.URL)
deps.ReviewForm = reviewFormFunc(func(_ context.Context, choices terminal.ReviewFormChoices) (terminal.ReviewFormValues, error) {
calls++
require.NotNil(t, choices.ReviewDiff)
require.NotNil(t, choices.EditBody)
return terminal.ReviewFormValues{
Event: "APPROVED", Body: "Looks good",
Comments: []terminal.ReviewCommentDraft{{Path: "sample.go", Body: "nice", NewLine: 2}},
}, nil
})
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "3", "-R", "alice/project", "--json=id,state", "--interactive"}))
require.Equal(t, 1, calls)
require.Len(t, prompt.confirmations, 1)
require.Equal(t, "APPROVED", createPayload["event"])
require.Equal(t, "Looks good", createPayload["body"])
comments, ok := createPayload["comments"].([]any)
require.True(t, ok)
require.Len(t, comments, 1)
require.JSONEq(t, `{"id":11,"state":"APPROVED"}`, out.String())
require.Empty(t, diagnostics.String())
}
func TestPullReviewInteractiveDiffRoundTrip(t *testing.T) {
var createPayload map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/pulls/4.diff":
_, _ = w.Write([]byte(`diff --git a/sample.go b/sample.go
--- a/sample.go
+++ b/sample.go
@@ -1,2 +1,3 @@
package sample
+func added() {}
`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/alice/project/pulls/4/reviews":
require.NoError(t, json.NewDecoder(r.Body).Decode(&createPayload))
_, _ = w.Write([]byte(`{"id":12,"state":"COMMENT","body":"nits","user":{"login":"alice"}}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
prompt := &scriptedPrompter{accept: true}
deps, out, _ := testDeps(t)
deps.ReviewForm = reviewFormFunc(func(ctx context.Context, choices terminal.ReviewFormChoices) (terminal.ReviewFormValues, error) {
comments, err := choices.ReviewDiff(ctx)
require.NoError(t, err)
require.Len(t, comments, 1)
require.Equal(t, "sample.go", comments[0].Path)
require.Equal(t, "annotate added line", comments[0].Body)
require.EqualValues(t, 2, comments[0].NewLine)
return terminal.ReviewFormValues{Event: "COMMENT", Body: "nits", Comments: comments}, nil
})
deps.Prompter = prompt
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
deps.Editor = pathAnnotatingEditor{}
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "true"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "review", "4", "-R", "alice/project", "--json=id", "--interactive"}))
require.Equal(t, "COMMENT", createPayload["event"])
require.JSONEq(t, `{"id":12}`, out.String())
}
func TestPullReviewDiffFailureKeepsAnnotatedDiff(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/pulls/4.diff", r.URL.Path, "no review may be submitted")
_, _ = w.Write([]byte("diff --git a/sample.go b/sample.go\n"))
}))
defer server.Close()
editor := &failingPathEditor{}
deps, _, _ := testDeps(t)
deps.ReviewForm = reviewFormFunc(func(ctx context.Context, choices terminal.ReviewFormChoices) (terminal.ReviewFormValues, error) {
_, err := choices.ReviewDiff(ctx)
return terminal.ReviewFormValues{}, err
})
deps.Prompter = &scriptedPrompter{accept: true}
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
deps.Editor = editor
deps.Getenv = serverEnv(server.URL, map[string]string{"EDITOR": "true"})
err := Execute(context.Background(), deps, []string{"pr", "review", "4", "-R", "alice/project", "--interactive"})
require.ErrorContains(t, err, "annotated diff is kept at "+editor.path)
require.FileExists(t, editor.path)
require.NoError(t, os.Remove(editor.path))
}
type failingPathEditor struct{ path string }
func (*failingPathEditor) Edit(context.Context, string, string) (terminal.Draft, error) {
return terminal.Draft{}, nil
}
func (e *failingPathEditor) EditPath(_ context.Context, _, path string) error {
e.path = path
return errors.New("editor exited with status 1")
}
type pathAnnotatingEditor struct{}
func (pathAnnotatingEditor) Edit(context.Context, string, string) (terminal.Draft, error) {
return terminal.Draft{}, nil
}
func (pathAnnotatingEditor) EditPath(_ context.Context, _, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
annotated := strings.Replace(string(data), "+func added() {}\n", "+func added() {}\n# annotate added line\n", 1)
return os.WriteFile(path, []byte(annotated), 0o600)
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullReviewLifecycleCommand(verb string) *cobra.Command {
var body, file, event string
var useEditor bool
cmd := &cobra.Command{Use: verb + " <number> <review-id>", Short: titleCase(verb) + " a pull request review", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft
defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
n, err := number(args[0])
if err != nil {
return err
}
id, err := number(args[1])
if err != nil {
return err
}
if verb == "submit" {
if err := oneOf("event", event, "APPROVED", "REQUEST_CHANGES", "COMMENT"); err != nil {
return err
}
}
var suppliedBody *string
if verb == "submit" || verb == "dismiss" {
body, err = a.readBody(cmd, body, file)
if err != nil {
return err
}
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
suppliedBody = &body
}
if verb == "dismiss" {
if suppliedBody == nil && !useEditor {
if _, err := a.require(cmd, &body, "body"); err != nil {
return err
}
}
if strings.TrimSpace(body) == "" && !useEditor {
return clierr.Wrap(clierr.Usage, errors.New("supply a non-empty dismissal reason with --body or --body-file"))
}
} else if suppliedBody != nil && strings.TrimSpace(body) == "" && event == "REQUEST_CHANGES" {
return clierr.Wrap(clierr.Usage, errors.New("review body cannot be empty for REQUEST_CHANGES"))
}
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
service := services.Reviews{Client: target.client, Repository: target.Repo}
if useEditor {
initial := ""
if verb == "submit" {
current, err := service.View(cmd.Context(), n, id)
if err != nil {
return err
}
initial = current.Body
}
draft, err = a.editDraft(cmd, initial)
if err != nil {
return err
}
body = draft.Body
suppliedBody = &body
if strings.TrimSpace(body) == "" && (verb == "dismiss" || event == "REQUEST_CHANGES") {
return clierr.Wrap(clierr.Usage, errors.New("supply non-empty review text in the editor"))
}
}
if verb == "delete" || verb == "dismiss" {
message := fmt.Sprintf("%s review %d on %s#%d", verb, id, target.Repo, n)
if verb == "dismiss" {
message += ": " + body
}
if err := a.confirm(cmd, message); err != nil {
return err
}
}
if verb == "submit" && event == "REQUEST_CHANGES" && suppliedBody == nil {
current, err := service.View(cmd.Context(), n, id)
if err != nil {
return err
}
body = current.Body
if strings.TrimSpace(body) == "" {
body = ""
if _, err := a.require(cmd, &body, "body"); err != nil {
return err
}
if strings.TrimSpace(body) == "" {
return clierr.Wrap(clierr.Usage, errors.New("review body cannot be empty for REQUEST_CHANGES"))
}
if err := a.confirm(cmd, fmt.Sprintf("Submit REQUEST_CHANGES review on %s#%d: %s", target.Repo, n, body)); err != nil {
return err
}
}
suppliedBody = &body
}
if verb == "delete" {
if err := service.Delete(cmd.Context(), n, id); err != nil {
return err
}
_, err := fmt.Fprintf(cmd.ErrOrStderr(), "Deleted review %d\n", id)
return err
}
var item services.Review
if verb == "submit" {
item, err = service.Submit(cmd.Context(), n, id, services.ReviewSubmitOptions{Event: event, Body: suppliedBody})
} else {
item, err = service.Dismiss(cmd.Context(), n, id, verb == "dismiss", body)
}
if err != nil {
return err
}
return writeRecord(a, cmd, item, reviewRecord)
}}
if verb == "submit" || verb == "dismiss" {
cmd.Flags().BoolVar(&useEditor, "editor", false, "Edit review text in the configured editor")
cmd.Flags().StringVar(&body, "body", "", "Review body or dismissal reason (submission preserves draft body when omitted)")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
}
if verb == "submit" {
cmd.Flags().StringVar(&event, "event", "COMMENT", "Review event: APPROVED, REQUEST_CHANGES, COMMENT")
}
if verb == "delete" {
cmd.Annotations = textOutput("review deletion does not return a resource")
}
return cmd
}
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff. Show more