mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
1 parent
12e415a7a7
commit
f02245449b
456 files changed
+18892
-34015
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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", "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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]}}
|
||||
}
|
||||
@@ -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"})))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}}
|
||||
}
|
||||
@@ -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"})))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(), ®istered))
|
||||
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)
|
||||
}
|
||||
@@ -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"), ¬ifications))
|
||||
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), ¬ification))
|
||||
require.Equal(t, repo.FullName, notification.Repository)
|
||||
require.NoError(t, json.Unmarshal(scoped("notification", "pin", notificationID), ¬ification))
|
||||
require.True(t, notification.Pinned)
|
||||
require.NoError(t, json.Unmarshal(scoped("notification", "unpin", notificationID), ¬ification))
|
||||
require.False(t, notification.Pinned)
|
||||
require.NoError(t, json.Unmarshal(scoped("notification", "mark-unread", notificationID), ¬ification))
|
||||
require.True(t, notification.Unread)
|
||||
require.NoError(t, json.Unmarshal(scoped("notification", "watch", "--timeout", "500ms", "--interval", "100ms"), ¬ifications))
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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]}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user