mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: add terminal output and forms package (#1124)
- `internal/terminal`: streams and the prompting policy, interactive forms (issue, PR, review, merge, milestone), editor / pager / browser launching, JSON and `--jq` output. - An aligned table renderer and a record renderer for human output; both strip control and escape characters from server-supplied text so titles and bodies cannot inject terminal sequences. Reviewed-on: https://gitea.com/gitea/tea/pulls/1124 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
88b91faa54
commit
faea86cd94
27 files changed
+1941
-1
No files matched your search
@@ -17,7 +17,9 @@ require (
|
||||
github.com/davidmz/go-pageant v1.0.2
|
||||
github.com/enescakir/emoji v1.0.0
|
||||
github.com/go-signet/sdk-go v1.1.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/mattn/go-runewidth v0.0.23
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/olekukonko/tablewriter v1.1.4
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
@@ -60,10 +62,10 @@ require (
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/go-version v1.9.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
|
||||
@@ -99,6 +99,10 @@ github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaX
|
||||
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
|
||||
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
|
||||
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
|
||||
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Browser opens an explicitly requested resource using a configured command.
|
||||
type Browser interface {
|
||||
Open(context.Context, string, string) error
|
||||
}
|
||||
|
||||
// SystemBrowser runs the browser without a shell and routes diagnostics to stderr.
|
||||
type SystemBrowser struct{ ErrOut io.Writer }
|
||||
|
||||
// Open validates the resource and invokes the selected browser directly.
|
||||
func (b SystemBrowser) Open(ctx context.Context, resource, command string) error {
|
||||
u, err := url.Parse(resource)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || strings.ContainsAny(resource, "\x00\r\n") {
|
||||
return errors.New("browser requires an HTTP or HTTPS resource URL without credentials")
|
||||
}
|
||||
var args []string
|
||||
if command == "" || command == "auto" {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
args = []string{"open"}
|
||||
case "windows":
|
||||
args = []string{"rundll32", "url.dll,FileProtocolHandler"}
|
||||
default:
|
||||
args = []string{"xdg-open"}
|
||||
}
|
||||
} else {
|
||||
if command == "never" {
|
||||
return errors.New("browser opening is disabled; configure browser or BROWSER")
|
||||
}
|
||||
var ok bool
|
||||
if args, ok = commandArgs(command); !ok {
|
||||
return errors.New("browser command contains malformed quoted arguments")
|
||||
}
|
||||
}
|
||||
process := exec.CommandContext(ctx, args[0], append(args[1:], resource)...)
|
||||
process.Stdout, process.Stderr = b.ErrOut, b.ErrOut
|
||||
if err := process.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("browser could not be opened; configure browser or BROWSER: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBrowserRejectsInvalidURLsBeforeLaunchingCommand(t *testing.T) {
|
||||
for _, resource := range []string{"javascript:alert(1)", "file:///tmp/file", "https://user:secret@example.org", "https://example.org\n"} {
|
||||
err := (SystemBrowser{}).Open(context.Background(), resource, "missing-browser-command")
|
||||
require.ErrorContains(t, err, "resource URL without credentials")
|
||||
require.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserUsesLiteralArgumentsAndDiagnosticWriter(t *testing.T) {
|
||||
printf, err := exec.LookPath("printf")
|
||||
if err != nil {
|
||||
t.Skip("printf executable is unavailable")
|
||||
}
|
||||
var diagnostics bytes.Buffer
|
||||
resource := "https://example.org/$(echo-secret)"
|
||||
command := shellquote.Join(printf, "%s")
|
||||
require.NoError(t, (SystemBrowser{ErrOut: &diagnostics}).Open(context.Background(), resource, command))
|
||||
require.Equal(t, resource, diagnostics.String())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
require.ErrorIs(t, (SystemBrowser{}).Open(ctx, resource, command), context.Canceled)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ColorEnabled resolves the color preference against terminal and environment policy.
|
||||
// auto colors TTY stdout unless NO_COLOR is set or TERM is dumb; always forces color; never disables it.
|
||||
func ColorEnabled(preference string, outTTY bool, getenv func(string) string) bool {
|
||||
switch preference {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return false
|
||||
default: // auto and unknown fall back to terminal detection
|
||||
if getenv != nil && (getenv("NO_COLOR") != "" || getenv("TERM") == "dumb") {
|
||||
return false
|
||||
}
|
||||
return outTTY
|
||||
}
|
||||
}
|
||||
|
||||
// Colorize applies a 24-bit foreground color from a six-digit RGB hex value.
|
||||
func Colorize(hex, text string, enabled bool) string {
|
||||
if !enabled || text == "" {
|
||||
return text
|
||||
}
|
||||
hex = strings.TrimPrefix(strings.TrimSpace(hex), "#")
|
||||
if len(hex) != 6 {
|
||||
return text
|
||||
}
|
||||
r, errR := strconv.ParseUint(hex[0:2], 16, 8)
|
||||
g, errG := strconv.ParseUint(hex[2:4], 16, 8)
|
||||
b, errB := strconv.ParseUint(hex[4:6], 16, 8)
|
||||
if errR != nil || errG != nil || errB != nil {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text)
|
||||
}
|
||||
|
||||
// Emphasis applies simple bold styling for TTY human tables.
|
||||
func Emphasis(text string, enabled bool) string {
|
||||
if !enabled || text == "" {
|
||||
return text
|
||||
}
|
||||
return "\x1b[1m" + text + "\x1b[0m"
|
||||
}
|
||||
|
||||
// Hyperlink wraps text in an OSC 8 terminal hyperlink to url. URLs with control
|
||||
// characters are left unlinked so server data cannot inject escape sequences.
|
||||
func Hyperlink(url, text string, enabled bool) string {
|
||||
if !enabled || url == "" || text == "" || strings.ContainsFunc(url, unicode.IsControl) {
|
||||
return text
|
||||
}
|
||||
return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestColorEnabledRespectsPreferenceAndEnvironment(t *testing.T) {
|
||||
getenv := func(key string) string {
|
||||
if key == "NO_COLOR" {
|
||||
return "1"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
require.True(t, ColorEnabled("always", false, nil))
|
||||
require.False(t, ColorEnabled("never", true, nil))
|
||||
require.True(t, ColorEnabled("auto", true, func(string) string { return "" }))
|
||||
require.False(t, ColorEnabled("auto", false, func(string) string { return "" }))
|
||||
require.False(t, ColorEnabled("auto", true, getenv))
|
||||
require.True(t, ColorEnabled("always", true, getenv))
|
||||
require.False(t, ColorEnabled("auto", true, func(key string) string { return map[string]string{"TERM": "dumb"}[key] }))
|
||||
}
|
||||
|
||||
func TestColorizeAndEmphasis(t *testing.T) {
|
||||
require.Equal(t, "bug", Colorize("ff0000", "bug", false))
|
||||
require.Equal(t, "bug", Colorize("bad", "bug", true))
|
||||
require.Equal(t, "\x1b[38;2;255;0;0mbug\x1b[0m", Colorize("#ff0000", "bug", true))
|
||||
require.Equal(t, "open", Emphasis("open", false))
|
||||
require.Equal(t, "\x1b[1mopen\x1b[0m", Emphasis("open", true))
|
||||
}
|
||||
|
||||
func TestHyperlink(t *testing.T) {
|
||||
require.Equal(t, "#7", Hyperlink("https://example.org/i/7", "#7", false))
|
||||
require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\", Hyperlink("https://example.org/i/7", "#7", true))
|
||||
require.Equal(t, "#7", Hyperlink("", "#7", true))
|
||||
require.Equal(t, "#7", Hyperlink("https://example.org/\x1b[2J", "#7", true))
|
||||
require.Equal(t, "#7", Hyperlink("https://example.org/\u009b2J", "#7", true))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
|
||||
// Draft holds edited text and the private file retained until successful submission.
|
||||
type Draft struct {
|
||||
Path string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Editor edits drafts or existing files using an explicitly selected command.
|
||||
type Editor interface {
|
||||
Edit(context.Context, string, string) (Draft, error)
|
||||
EditPath(context.Context, string, string) error
|
||||
}
|
||||
|
||||
// SystemEditor uses private temporary files and the application's terminal streams.
|
||||
type SystemEditor struct {
|
||||
Directory string
|
||||
Streams Streams
|
||||
}
|
||||
|
||||
// Edit preserves the draft on failure; the caller removes it after submission.
|
||||
func (e SystemEditor) Edit(ctx context.Context, command, body string) (Draft, error) {
|
||||
if _, err := editorArgs(command); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
file, err := os.CreateTemp(e.Directory, "tea-draft-*.md")
|
||||
if err != nil {
|
||||
return Draft{}, errors.New("cannot create a private editor draft")
|
||||
}
|
||||
draft := Draft{Path: file.Name(), Body: body}
|
||||
_, writeErr := file.WriteString(body)
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(writeErr, closeErr); err != nil {
|
||||
return draft, errors.New("cannot write editor draft")
|
||||
}
|
||||
runErr := e.EditPath(ctx, command, draft.Path)
|
||||
info, err := os.Lstat(draft.Path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
// A failed editor can remove its buffer or replace it with a non-file.
|
||||
// Recover the original input separately without following that replacement.
|
||||
recovery, recoveryErr := os.CreateTemp(e.Directory, "tea-draft-recovery-*.md")
|
||||
if recoveryErr != nil {
|
||||
return Draft{Body: body}, errors.Join(ctx.Err(), errors.New("editor draft is unavailable and original input could not be saved"))
|
||||
}
|
||||
draft.Path = recovery.Name()
|
||||
_, writeErr := recovery.WriteString(body)
|
||||
closeErr := recovery.Close()
|
||||
if errors.Join(writeErr, closeErr) != nil {
|
||||
return draft, errors.Join(ctx.Err(), errors.New("cannot save original editor input"))
|
||||
}
|
||||
return draft, errors.Join(ctx.Err(), errors.New("editor draft is unavailable; original input was saved separately"))
|
||||
}
|
||||
// Editors may replace the file rather than writing it in place, even on failure.
|
||||
if err := os.Chmod(draft.Path, 0o600); err != nil {
|
||||
return draft, errors.Join(ctx.Err(), errors.New("cannot restore private draft permissions"))
|
||||
}
|
||||
if runErr != nil {
|
||||
if ctx.Err() != nil {
|
||||
return draft, ctx.Err()
|
||||
}
|
||||
return draft, fmt.Errorf("%w; draft has been preserved", runErr)
|
||||
}
|
||||
data, err := os.ReadFile(draft.Path)
|
||||
if err != nil {
|
||||
return draft, errors.New("cannot read edited draft")
|
||||
}
|
||||
draft.Body = string(data)
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
// EditPath opens an existing file in the configured editor and blocks until it exits.
|
||||
// Like Git, it waits for the editor instead of killing it on cancellation, so the
|
||||
// editor can restore the terminal.
|
||||
func (e SystemEditor) EditPath(ctx context.Context, command, path string) error {
|
||||
args, err := editorArgs(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
process := exec.Command(args[0], append(args[1:], path)...)
|
||||
process.Stdin, process.Stdout, process.Stderr = e.Streams.In, e.Streams.ErrOut, e.Streams.ErrOut
|
||||
if err := process.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("editor failed: %w", err)
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func editorArgs(command string) ([]string, error) {
|
||||
args, ok := commandArgs(command)
|
||||
if !ok {
|
||||
return nil, errors.New("configure an editor command using tea config set editor or EDITOR")
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// commandArgs splits a configured command line without a shell. A value that names an
|
||||
// executable as a whole, such as an unquoted Windows path, is kept intact.
|
||||
func commandArgs(command string) ([]string, bool) {
|
||||
if _, err := exec.LookPath(command); err == nil {
|
||||
return []string{command}, true
|
||||
}
|
||||
args, err := shellquote.Split(command)
|
||||
return args, err == nil && len(args) > 0 && args[0] != ""
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEditorRetainsPrivateDraftAfterSuccessAndFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("editor fixture uses a POSIX executable")
|
||||
}
|
||||
for _, fail := range []bool{false, true} {
|
||||
directory := t.TempDir()
|
||||
program := filepath.Join(directory, "editor with spaces")
|
||||
text := "#!/bin/sh\nprintf 'edited draft' > \"$1\"\nchmod 644 \"$1\"\nprintf 'editor diagnostic'\n"
|
||||
if fail {
|
||||
text += "exit 7\n"
|
||||
}
|
||||
require.NoError(t, os.WriteFile(program, []byte(text), 0o700))
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
draft, err := (SystemEditor{Directory: directory, Streams: Streams{Out: out, ErrOut: diagnostics}}).Edit(context.Background(), shellquote.Join(program), "initial text")
|
||||
if fail {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "edited draft", draft.Body)
|
||||
}
|
||||
data, readErr := os.ReadFile(draft.Path)
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "edited draft", string(data))
|
||||
info, statErr := os.Stat(draft.Path)
|
||||
require.NoError(t, statErr)
|
||||
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
require.Empty(t, out.String())
|
||||
require.Equal(t, "editor diagnostic", diagnostics.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorCanceledBeforeLaunchDoesNotCreateDraft(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
draft, err := (SystemEditor{Directory: directory}).Edit(ctx, "editor", "text")
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
require.Empty(t, draft.Path)
|
||||
files, err := os.ReadDir(directory)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, files)
|
||||
}
|
||||
|
||||
func TestEditorIsNotKilledOnCancellation(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("editor fixture uses a POSIX executable")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
program := filepath.Join(directory, "slow-editor")
|
||||
require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\nsleep 0.3\necho finished > \"$1\"\n"), 0o700))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
time.AfterFunc(50*time.Millisecond, cancel)
|
||||
draft, err := (SystemEditor{Directory: directory}).Edit(ctx, shellquote.Join(program), "text")
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
data, err := os.ReadFile(draft.Path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "finished\n", string(data))
|
||||
}
|
||||
|
||||
func TestEditorRecoversOriginalInputWhenBufferIsRemoved(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("editor fixture uses a POSIX executable")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
program := filepath.Join(directory, "remove-editor")
|
||||
require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\nrm \"$1\"\nexit 1\n"), 0o700))
|
||||
draft, err := (SystemEditor{Directory: directory}).Edit(context.Background(), shellquote.Join(program), "original draft")
|
||||
require.ErrorContains(t, err, "original input was saved separately")
|
||||
data, err := os.ReadFile(draft.Path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "original draft", string(data))
|
||||
info, err := os.Stat(draft.Path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
}
|
||||
|
||||
func TestCommandArgsKeepsUnquotedExecutablePaths(t *testing.T) {
|
||||
name := `editor with spaces\and backslash`
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "editor with spaces.bat"
|
||||
}
|
||||
program := filepath.Join(t.TempDir(), name)
|
||||
require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\n"), 0o700))
|
||||
for _, tc := range []struct {
|
||||
command string
|
||||
want []string
|
||||
}{
|
||||
{program, []string{program}},
|
||||
{shellquote.Join(program) + " --wait", []string{program, "--wait"}},
|
||||
{"code --wait", []string{"code", "--wait"}},
|
||||
} {
|
||||
args, ok := commandArgs(tc.command)
|
||||
require.True(t, ok, tc.command)
|
||||
require.Equal(t, tc.want, args)
|
||||
}
|
||||
for _, command := range []string{"", `"unterminated`} {
|
||||
_, ok := commandArgs(command)
|
||||
require.False(t, ok, command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessFailuresKeepTheirCause(t *testing.T) {
|
||||
const missing = "tea-test-missing-command"
|
||||
streams := Streams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}
|
||||
require.ErrorIs(t, (SystemEditor{Streams: streams}).EditPath(context.Background(), missing, filepath.Join(t.TempDir(), "draft")), exec.ErrNotFound)
|
||||
require.ErrorIs(t, (SystemPager{}).Page(context.Background(), missing, strings.NewReader("text\n"), streams), exec.ErrNotFound)
|
||||
require.ErrorIs(t, (SystemBrowser{ErrOut: streams.ErrOut}).Open(context.Background(), "https://example.org", missing), exec.ErrNotFound)
|
||||
require.ErrorContains(t, (SystemBrowser{}).Open(context.Background(), "https://example.org", "never"), "browser opening is disabled")
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// IssueEditFormChoices supplies current issue values and selectable options for edit.
|
||||
// Current values missing from the options stay selectable.
|
||||
type IssueEditFormChoices struct {
|
||||
Title, Body string
|
||||
Assignees []string
|
||||
Milestone *IssueMilestoneChoice
|
||||
Labels []IssueLabelChoice
|
||||
Deadline *time.Time
|
||||
CanPush bool
|
||||
AvailableAssignees []string
|
||||
Milestones []IssueMilestoneChoice
|
||||
AvailableLabels []IssueLabelChoice
|
||||
EditBody BodyEditor
|
||||
}
|
||||
|
||||
// IssueEditFormValues holds interactively edited issue fields. Deadline is set
|
||||
// only when the due date changed.
|
||||
type IssueEditFormValues struct {
|
||||
Title, Body string
|
||||
Assignees []string
|
||||
Milestone int64
|
||||
ClearMilestone bool
|
||||
Labels []int64
|
||||
Deadline *time.Time
|
||||
ClearDeadline bool
|
||||
Draft Draft
|
||||
}
|
||||
|
||||
// IssueEditForm collects interactive issue edit input.
|
||||
type IssueEditForm interface {
|
||||
EditIssue(context.Context, IssueEditFormChoices) (IssueEditFormValues, error)
|
||||
}
|
||||
|
||||
// EditIssue runs the interactive issue edit form on stderr, seeded with the current values.
|
||||
func (p *FormPrompter) EditIssue(ctx context.Context, choices IssueEditFormChoices) (IssueEditFormValues, error) {
|
||||
deadline := ""
|
||||
if choices.Deadline != nil && !choices.Deadline.IsZero() {
|
||||
// Keep the server's offset: converting to UTC can move the date by a day.
|
||||
deadline = choices.Deadline.Format(time.DateOnly)
|
||||
}
|
||||
fields := issueFields{title: choices.Title, body: choices.Body, deadline: deadline, assignees: choices.Assignees}
|
||||
milestones := choices.Milestones
|
||||
if choices.Milestone != nil {
|
||||
fields.milestone, milestones = choices.Milestone.ID, withCurrent(milestones, *choices.Milestone)
|
||||
}
|
||||
for _, label := range choices.Labels {
|
||||
fields.labels = append(fields.labels, label.ID)
|
||||
}
|
||||
groups := fields.groups(choices.EditBody, choices.CanPush, withCurrent(choices.AvailableAssignees, choices.Assignees...), milestones, withCurrent(choices.AvailableLabels, choices.Labels...))
|
||||
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
|
||||
return IssueEditFormValues{}, err
|
||||
}
|
||||
values := IssueEditFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels}
|
||||
values.ClearMilestone = fields.milestone == 0 && choices.Milestone != nil
|
||||
var err error
|
||||
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
|
||||
return values, err
|
||||
}
|
||||
if strings.TrimSpace(fields.deadline) == deadline {
|
||||
return values, nil
|
||||
}
|
||||
values.Deadline, err = parseDueDate(fields.deadline)
|
||||
values.ClearDeadline = values.Deadline == nil && deadline != ""
|
||||
return values, err
|
||||
}
|
||||
|
||||
// withCurrent appends current values missing from options; selects drop values they do not offer.
|
||||
func withCurrent[T comparable](options []T, current ...T) []T {
|
||||
for _, value := range current {
|
||||
if !slices.Contains(options, value) {
|
||||
options = append(slices.Clip(options), value)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEditIssueEditBodyPreservesDraftOnFailure(t *testing.T) {
|
||||
p, _ := testPrompter(bytes.NewBufferString("New Title\r"))
|
||||
path := filepath.Join(t.TempDir(), "draft.md")
|
||||
values, err := p.EditIssue(context.Background(), IssueEditFormChoices{
|
||||
Title: "", Body: "current",
|
||||
EditBody: func(context.Context, string) (Draft, error) {
|
||||
return Draft{Path: path, Body: "partial"}, errors.New("editor failed")
|
||||
},
|
||||
})
|
||||
require.ErrorContains(t, err, "editor failed")
|
||||
require.Equal(t, path, values.Draft.Path)
|
||||
require.Equal(t, "New Title", values.Title)
|
||||
}
|
||||
|
||||
func TestEditIssueKeepsUnchangedDeadline(t *testing.T) {
|
||||
deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.FixedZone("server", -5*3600))
|
||||
p, _ := testPrompter(bytes.NewBufferString("\r\r\r"))
|
||||
values, err := p.EditIssue(context.Background(), IssueEditFormChoices{Title: "Title", Deadline: &deadline, CanPush: true})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, values.Deadline)
|
||||
require.False(t, values.ClearDeadline)
|
||||
}
|
||||
|
||||
func TestWithCurrentKeepsSeededValuesSelectable(t *testing.T) {
|
||||
options := []IssueLabelChoice{{Name: "bug", ID: 2}}
|
||||
merged := withCurrent(options, IssueLabelChoice{Name: "bug", ID: 2}, IssueLabelChoice{Name: "org", ID: 7})
|
||||
require.Equal(t, []IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "org", ID: 7}}, merged)
|
||||
require.Len(t, options, 1)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// IssueTemplateChoice seeds title/body when selected in an interactive create form.
|
||||
type IssueTemplateChoice struct {
|
||||
Name, About, Title, Body string
|
||||
}
|
||||
|
||||
// IssueMilestoneChoice identifies an open milestone for interactive selection.
|
||||
type IssueMilestoneChoice struct {
|
||||
Title string
|
||||
ID int64
|
||||
}
|
||||
|
||||
// IssueLabelChoice identifies a repository label for interactive selection.
|
||||
type IssueLabelChoice struct {
|
||||
Name string
|
||||
ID int64
|
||||
}
|
||||
|
||||
// BodyEditor collects a markdown body through the shared draft editor instead of
|
||||
// an inline text field. Callers must finish the returned Draft.
|
||||
type BodyEditor func(context.Context, string) (Draft, error)
|
||||
|
||||
// IssueFormChoices supplies options for an interactive issue create form.
|
||||
type IssueFormChoices struct {
|
||||
Templates []IssueTemplateChoice
|
||||
Assignees []string
|
||||
Milestones []IssueMilestoneChoice
|
||||
Labels []IssueLabelChoice
|
||||
CanPush bool
|
||||
EditBody BodyEditor
|
||||
}
|
||||
|
||||
// IssueFormValues holds interactively collected issue create fields.
|
||||
type IssueFormValues struct {
|
||||
Title, Body string
|
||||
Assignees []string
|
||||
Milestone int64
|
||||
Labels []int64
|
||||
Deadline *time.Time
|
||||
Draft Draft
|
||||
}
|
||||
|
||||
// IssueForm collects interactive issue create input.
|
||||
type IssueForm interface {
|
||||
CreateIssue(context.Context, IssueFormChoices) (IssueFormValues, error)
|
||||
}
|
||||
|
||||
// CreateIssue runs the interactive issue create form on stderr.
|
||||
func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices) (IssueFormValues, error) {
|
||||
title, body := "", ""
|
||||
if len(choices.Templates) > 0 {
|
||||
options := []huh.Option[int]{huh.NewOption("[none]", -1)}
|
||||
for i, template := range choices.Templates {
|
||||
label := template.Name
|
||||
switch {
|
||||
case template.Name != "" && template.About != "":
|
||||
label = template.Name + " — " + template.About
|
||||
case label == "":
|
||||
label = template.About
|
||||
}
|
||||
if label == "" {
|
||||
label = "template " + strconv.Itoa(i+1)
|
||||
}
|
||||
options = append(options, huh.NewOption(label, i))
|
||||
}
|
||||
selected := -1
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||
huh.NewSelect[int]().Title("Issue template:").Options(options...).Value(&selected),
|
||||
))); err != nil {
|
||||
return IssueFormValues{}, err
|
||||
}
|
||||
if selected >= 0 {
|
||||
title, body = choices.Templates[selected].Title, choices.Templates[selected].Body
|
||||
}
|
||||
}
|
||||
return p.collectIssueProperties(ctx, title, body, choices)
|
||||
}
|
||||
|
||||
// issueFields holds the shared issue form inputs that create and edit bind to.
|
||||
type issueFields struct {
|
||||
title, body, deadline string
|
||||
assignees []string
|
||||
milestone int64
|
||||
labels []int64
|
||||
}
|
||||
|
||||
// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields.
|
||||
func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group {
|
||||
fields := []huh.Field{huh.NewInput().Title("Issue title (required):").Value(&f.title).Validate(huh.ValidateNotEmpty())}
|
||||
if editBody == nil {
|
||||
fields = append(fields, huh.NewText().Title("Issue description(markdown):").Value(&f.body))
|
||||
}
|
||||
groups := []*huh.Group{huh.NewGroup(fields...)}
|
||||
if !canPush {
|
||||
return groups
|
||||
}
|
||||
if len(assignees) > 0 {
|
||||
groups = append(groups, huh.NewGroup(huh.NewMultiSelect[string]().Title("Assignees:").Options(huh.NewOptions(assignees...)...).Value(&f.assignees)))
|
||||
}
|
||||
if len(milestones) > 0 {
|
||||
options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))}
|
||||
for _, milestone := range milestones {
|
||||
options = append(options, huh.NewOption(milestone.Title, milestone.ID))
|
||||
}
|
||||
groups = append(groups, huh.NewGroup(huh.NewSelect[int64]().Title("Milestone:").Options(options...).Value(&f.milestone)))
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
options := make([]huh.Option[int64], 0, len(labels))
|
||||
for _, label := range labels {
|
||||
options = append(options, huh.NewOption(label.Name, label.ID))
|
||||
}
|
||||
groups = append(groups, huh.NewGroup(huh.NewMultiSelect[int64]().Title("Labels:").Options(options...).Value(&f.labels)))
|
||||
}
|
||||
return append(groups, huh.NewGroup(dueDateInput("Due date:", &f.deadline)))
|
||||
}
|
||||
|
||||
// editBody runs the draft editor after the form, keeping the draft on failure.
|
||||
func editBody(ctx context.Context, edit BodyEditor, body string) (string, Draft, error) {
|
||||
if edit == nil {
|
||||
return body, Draft{}, nil
|
||||
}
|
||||
draft, err := edit(ctx, body)
|
||||
if err != nil {
|
||||
return body, draft, err
|
||||
}
|
||||
return draft.Body, draft, nil
|
||||
}
|
||||
|
||||
func dueDateInput(title string, value *string) *huh.Input {
|
||||
return huh.NewInput().Title(title).Placeholder("YYYY-MM-DD").Value(value).Validate(func(s string) error {
|
||||
_, err := parseDueDate(s)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// parseDueDate returns nil for an empty value.
|
||||
func parseDueDate(value string) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
if err != nil {
|
||||
return nil, errors.New("use YYYY-MM-DD")
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func (p *FormPrompter) collectIssueProperties(ctx context.Context, title, body string, choices IssueFormChoices) (IssueFormValues, error) {
|
||||
fields := issueFields{title: title, body: body}
|
||||
if err := p.runForm(ctx, huh.NewForm(fields.groups(choices.EditBody, choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)...)); err != nil {
|
||||
return IssueFormValues{}, err
|
||||
}
|
||||
values := IssueFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels}
|
||||
var err error
|
||||
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
|
||||
return values, err
|
||||
}
|
||||
values.Deadline, err = parseDueDate(fields.deadline)
|
||||
return values, err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCollectIssuePropertiesUsesEditBodyDraft(t *testing.T) {
|
||||
var initial string
|
||||
p, _ := testPrompter(bytes.NewBufferString("Drafted Title\r"))
|
||||
values, err := p.collectIssueProperties(context.Background(), "", "template body", IssueFormChoices{
|
||||
EditBody: func(_ context.Context, body string) (Draft, error) {
|
||||
initial = body
|
||||
return Draft{Path: filepath.Join(t.TempDir(), "draft.md"), Body: "edited body"}, nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "template body", initial)
|
||||
require.Equal(t, "Drafted Title", values.Title)
|
||||
require.Equal(t, "edited body", values.Body)
|
||||
require.NotEmpty(t, values.Draft.Path)
|
||||
}
|
||||
|
||||
func TestIssueFormMarksTitleRequired(t *testing.T) {
|
||||
p, rendered := accessiblePrompter(t, "Title\n")
|
||||
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rendered.String(), "Issue title (required):")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// MergePullChoice is one selectable open pull request for interactive merge.
|
||||
type MergePullChoice struct {
|
||||
Number int64
|
||||
Title, Head string
|
||||
}
|
||||
|
||||
// MergeForm collects which pull request to merge.
|
||||
type MergeForm interface {
|
||||
SelectPull(context.Context, []MergePullChoice) (int64, error)
|
||||
}
|
||||
|
||||
// SelectPull prompts for a pull request on stderr.
|
||||
func (p *FormPrompter) SelectPull(ctx context.Context, choices []MergePullChoice) (int64, error) {
|
||||
if len(choices) == 0 {
|
||||
return 0, errors.New("no open pull requests match the current branch")
|
||||
}
|
||||
options := make([]huh.Option[int64], 0, len(choices))
|
||||
for _, choice := range choices {
|
||||
options = append(options, huh.NewOption(fmt.Sprintf("#%d: %s (%s)", choice.Number, choice.Title, choice.Head), choice.Number))
|
||||
}
|
||||
selected := choices[0].Number
|
||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||
huh.NewSelect[int64]().Title("Select a PR to merge:").Options(options...).Value(&selected),
|
||||
)))
|
||||
return selected, err
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// MilestoneFormValues holds interactively collected milestone create fields.
|
||||
type MilestoneFormValues struct {
|
||||
Title, Description string
|
||||
Deadline *time.Time
|
||||
}
|
||||
|
||||
// MilestoneForm collects interactive milestone create input.
|
||||
type MilestoneForm interface {
|
||||
CreateMilestone(context.Context) (MilestoneFormValues, error)
|
||||
}
|
||||
|
||||
// CreateMilestone runs the interactive milestone create form on stderr.
|
||||
func (p *FormPrompter) CreateMilestone(ctx context.Context) (MilestoneFormValues, error) {
|
||||
title, description, deadline := "", "", ""
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().Title("Milestone title (required):").Value(&title).Validate(huh.ValidateNotEmpty()),
|
||||
huh.NewText().Title("Milestone description (markdown):").Value(&description),
|
||||
dueDateInput("Milestone deadline:", &deadline),
|
||||
))); err != nil {
|
||||
return MilestoneFormValues{}, err
|
||||
}
|
||||
parsed, err := parseDueDate(deadline)
|
||||
return MilestoneFormValues{Title: strings.TrimSpace(title), Description: description, Deadline: parsed}, err
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMilestoneFormMarksTitleRequired(t *testing.T) {
|
||||
p, rendered := accessiblePrompter(t, "v1\n\n\n")
|
||||
_, err := p.CreateMilestone(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rendered.String(), "Milestone title (required):")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
)
|
||||
|
||||
// WriteJSON projects explicit result fields and applies an in-process jq filter.
|
||||
func WriteJSON(w io.Writer, value any, fields, query string) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var normalized any
|
||||
if err := json.Unmarshal(data, &normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
if names := fieldNames(fields); names != nil {
|
||||
selectFields := func(item any) (any, error) {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("field selection requires resource objects")
|
||||
}
|
||||
selected := map[string]any{}
|
||||
for _, name := range names {
|
||||
v, exists := object[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unknown JSON field %q", name)
|
||||
}
|
||||
selected[name] = v
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
switch v := normalized.(type) {
|
||||
case []any:
|
||||
for i := range v {
|
||||
v[i], err = selectFields(v[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
default:
|
||||
normalized, err = selectFields(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if query == "" {
|
||||
return encoder.Encode(normalized)
|
||||
}
|
||||
parsed, err := parseQuery(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iter := parsed.Run(normalized)
|
||||
for {
|
||||
value, ok := iter.Next()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err, ok := value.(error); ok {
|
||||
return fmt.Errorf("evaluate --jq: %w", err)
|
||||
}
|
||||
if str, ok := value.(string); ok {
|
||||
if _, err := fmt.Fprintln(w, str); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := encoder.Encode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateOutput catches invalid projection/filter syntax before a service mutates data.
|
||||
func ValidateOutput(fields, query string, allowed []string) error {
|
||||
if names := fieldNames(fields); names != nil && allowed != nil {
|
||||
known := map[string]bool{}
|
||||
for _, name := range allowed {
|
||||
known[name] = true
|
||||
}
|
||||
for _, name := range names {
|
||||
if !known[name] {
|
||||
return fmt.Errorf("unknown JSON field %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if query != "" {
|
||||
if _, err := parseQuery(query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fieldNames returns the selected JSON fields, or nil when every field is selected.
|
||||
func fieldNames(fields string) []string {
|
||||
if fields == "" || fields == "*" {
|
||||
return nil
|
||||
}
|
||||
names := strings.Split(fields, ",")
|
||||
for i := range names {
|
||||
names[i] = strings.TrimSpace(names[i])
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func parseQuery(query string) (*gojq.Query, error) {
|
||||
parsed, err := gojq.Parse(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --jq expression: %w", err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJSONFieldSelectionAndJQ(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
value := []map[string]any{{"number": 1, "title": "first"}, {"number": 2, "title": "second"}}
|
||||
require.NoError(t, WriteJSON(&out, value, "title", ".[0].title"))
|
||||
require.Equal(t, "first\n", out.String())
|
||||
out.Reset()
|
||||
require.NoError(t, WriteJSON(&out, []map[string]any{}, "*", ""))
|
||||
require.Equal(t, "[]\n", out.String())
|
||||
require.Error(t, WriteJSON(&out, value, "unknown", ""))
|
||||
require.NoError(t, ValidateOutput("number, title", "", []string{"number", "title"}))
|
||||
require.EqualError(t, ValidateOutput("number, foo", "", []string{"number"}), `unknown JSON field "foo"`)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pager renders multi-line human output through an external command.
|
||||
type Pager interface {
|
||||
Page(context.Context, string, io.Reader, Streams) error
|
||||
}
|
||||
|
||||
// SystemPager runs the pager without a shell and routes diagnostics to stderr.
|
||||
type SystemPager struct{}
|
||||
|
||||
// Page validates the command and feeds content to the pager stdin. The pager
|
||||
// handles interrupts itself; killing it on cancellation would leave the terminal raw.
|
||||
func (SystemPager) Page(ctx context.Context, command string, input io.Reader, streams Streams) error {
|
||||
args, ok := commandArgs(command)
|
||||
if !ok {
|
||||
return errors.New("pager command contains malformed quoted arguments")
|
||||
}
|
||||
process := exec.Command(args[0], args[1:]...)
|
||||
process.Stdin = input
|
||||
process.Stdout = streams.Out
|
||||
process.Stderr = streams.ErrOut
|
||||
env := os.Environ()
|
||||
if os.Getenv("LESS") == "" {
|
||||
env = append(env, "LESS=FRX")
|
||||
}
|
||||
process.Env = env
|
||||
if err := process.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("pager failed; configure pager or PAGER: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteHuman writes human text directly, or through a pager when stdout is a TTY,
|
||||
// a pager command is configured, and the content spans multiple lines.
|
||||
// Prompting policy such as --no-input does not affect paging.
|
||||
func WriteHuman(ctx context.Context, streams Streams, command string, pager Pager, text string) error {
|
||||
if !shouldPage(streams, command, text) {
|
||||
_, err := io.WriteString(streams.Out, text)
|
||||
return err
|
||||
}
|
||||
if pager == nil {
|
||||
pager = SystemPager{}
|
||||
}
|
||||
return pager.Page(ctx, command, strings.NewReader(text), streams)
|
||||
}
|
||||
|
||||
func shouldPage(streams Streams, command, text string) bool {
|
||||
if !streams.OutTTY || command == "" || command == "cat" {
|
||||
return false
|
||||
}
|
||||
trimmed := strings.TrimSuffix(text, "\n")
|
||||
return strings.Contains(trimmed, "\n")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"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 Streams) error {
|
||||
p.calls++
|
||||
p.command = command
|
||||
data, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.input = string(data)
|
||||
_, err = streams.Out.Write(append([]byte("PAGED:"), data...))
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWriteHumanPagesMultiLineTTYOutput(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
pager := &recordingPager{}
|
||||
streams := Streams{Out: out, OutTTY: true}
|
||||
require.NoError(t, WriteHuman(context.Background(), streams, "less -R", pager, "one\ntwo\n"))
|
||||
require.Equal(t, 1, pager.calls)
|
||||
require.Equal(t, "less -R", pager.command)
|
||||
require.Equal(t, "one\ntwo\n", pager.input)
|
||||
require.Equal(t, "PAGED:one\ntwo\n", out.String())
|
||||
}
|
||||
|
||||
func TestWriteHumanSkipsPagerWhenNotNeeded(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
streams Streams
|
||||
command string
|
||||
text string
|
||||
}{
|
||||
{"non-tty", Streams{OutTTY: false}, "less", "one\ntwo\n"},
|
||||
{"empty pager", Streams{OutTTY: true}, "", "one\ntwo\n"},
|
||||
{"cat disables", Streams{OutTTY: true}, "cat", "one\ntwo\n"},
|
||||
{"single line", Streams{OutTTY: true}, "less", "one\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
tc.streams.Out = out
|
||||
pager := &recordingPager{}
|
||||
require.NoError(t, WriteHuman(context.Background(), tc.streams, tc.command, pager, tc.text))
|
||||
require.Zero(t, pager.calls)
|
||||
require.Equal(t, tc.text, out.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemPagerUsesLiteralArguments(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("pager fixture uses a POSIX executable")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
program := filepath.Join(directory, "pager with spaces")
|
||||
capture := filepath.Join(directory, "captured")
|
||||
require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\ncat > \"$1\"\n"), 0o700))
|
||||
out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
command := shellquote.Join(program, capture)
|
||||
require.NoError(t, (SystemPager{}).Page(context.Background(), command, strings.NewReader("paged body\n"), Streams{Out: out, ErrOut: diagnostics}))
|
||||
data, err := os.ReadFile(capture)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "paged body\n", string(data))
|
||||
require.Empty(t, out.String())
|
||||
require.Empty(t, diagnostics.String())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// PullFormChoices supplies defaults and selectable options for interactive PR create.
|
||||
type PullFormChoices struct {
|
||||
BaseOwner, Base, HeadOwner, HeadBranch string
|
||||
// Title and Body seed the form; Title applies only while HeadBranch is kept.
|
||||
Title, Body string
|
||||
AllowMaintainerEdits bool
|
||||
Assignees []string
|
||||
Milestones []IssueMilestoneChoice
|
||||
Labels []IssueLabelChoice
|
||||
CanPush bool
|
||||
EditBody BodyEditor
|
||||
}
|
||||
|
||||
// PullFormValues holds interactively collected pull request create fields.
|
||||
type PullFormValues struct {
|
||||
IssueFormValues
|
||||
Agit bool
|
||||
Base, Head, Topic string
|
||||
AllowMaintainerEdits bool
|
||||
}
|
||||
|
||||
// PullForm collects interactive pull create input.
|
||||
type PullForm interface {
|
||||
CreatePull(context.Context, PullFormChoices) (PullFormValues, error)
|
||||
}
|
||||
|
||||
// CreatePull runs the interactive pull create form on stderr.
|
||||
func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices) (PullFormValues, error) {
|
||||
agit, err := p.Confirm(ctx, "Do you want to create an agit flow pull request?")
|
||||
if err != nil {
|
||||
return PullFormValues{}, err
|
||||
}
|
||||
|
||||
base := choices.Base
|
||||
headOwner := choices.HeadOwner
|
||||
headBranch := choices.HeadBranch
|
||||
topic := headBranch
|
||||
allowMaintainerEdits := choices.AllowMaintainerEdits
|
||||
|
||||
fields := []huh.Field{
|
||||
huh.NewInput().Title("Target branch (required):").Value(&base).Validate(huh.ValidateNotEmpty()),
|
||||
}
|
||||
if agit {
|
||||
fields = append(fields, huh.NewInput().Title("Topic branch (required):").Value(&topic).Validate(huh.ValidateNotEmpty()))
|
||||
} else {
|
||||
headField := huh.NewInput().Title("Source branch:").Value(&headBranch)
|
||||
if strings.TrimSpace(headBranch) == "" {
|
||||
headField = headField.Title("Source branch (required):").Validate(huh.ValidateNotEmpty())
|
||||
}
|
||||
fields = append(fields,
|
||||
huh.NewInput().Title("Source repo owner:").Value(&headOwner),
|
||||
headField,
|
||||
huh.NewConfirm().Title("Allow maintainers to push to the source branch:").Value(&allowMaintainerEdits),
|
||||
)
|
||||
}
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(fields...))); err != nil {
|
||||
return PullFormValues{}, err
|
||||
}
|
||||
|
||||
title := DefaultPullTitle(headBranch)
|
||||
if choices.Title != "" && headBranch == choices.HeadBranch {
|
||||
title = choices.Title
|
||||
}
|
||||
// AGit pushes carry only a title and description.
|
||||
properties, err := p.collectIssueProperties(ctx, title, choices.Body, IssueFormChoices{
|
||||
Assignees: choices.Assignees, Milestones: choices.Milestones, Labels: choices.Labels, CanPush: choices.CanPush && !agit,
|
||||
EditBody: choices.EditBody,
|
||||
})
|
||||
if err != nil {
|
||||
return PullFormValues{IssueFormValues: properties}, err
|
||||
}
|
||||
|
||||
headBranch = strings.TrimSpace(headBranch)
|
||||
headOwner = strings.TrimSpace(headOwner)
|
||||
head := headBranch
|
||||
switch {
|
||||
case agit && head == "":
|
||||
head = "HEAD"
|
||||
case !agit && headOwner != "" && headOwner != choices.BaseOwner:
|
||||
head = headOwner + ":" + headBranch
|
||||
}
|
||||
return PullFormValues{
|
||||
IssueFormValues: properties,
|
||||
Agit: agit,
|
||||
Base: strings.TrimSpace(base),
|
||||
Head: head,
|
||||
Topic: strings.TrimSpace(topic),
|
||||
AllowMaintainerEdits: allowMaintainerEdits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultPullTitle turns a branch-like head into a readable pull title seed.
|
||||
func DefaultPullTitle(head string) string {
|
||||
if i := strings.LastIndex(head, ":"); i != -1 {
|
||||
head = head[i+1:]
|
||||
}
|
||||
parts := strings.Fields(strings.ToLower(strings.NewReplacer("_", " ", "-", " ").Replace(head)))
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i, part := range parts {
|
||||
r, size := utf8.DecodeRuneInString(part)
|
||||
parts[i] = string(unicode.ToUpper(r)) + part[size:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDefaultPullTitle(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"feature", "Feature"},
|
||||
{"fix-my-bug", "Fix My Bug"},
|
||||
{"alice:add_login", "Add Login"},
|
||||
{"über-fix", "Über Fix"},
|
||||
} {
|
||||
require.Equal(t, tc.want, DefaultPullTitle(tc.in), tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullFormMarksRequiredFields(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, input string
|
||||
choices PullFormChoices
|
||||
want, without []string
|
||||
}{
|
||||
{
|
||||
"without head", "n\nmain\nalice\nfeature\ny\nTitle\n",
|
||||
PullFormChoices{},
|
||||
[]string{"Target branch (required):", "Source branch (required):", "Issue title (required):"},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"with head", "n\n\n\n\n\nTitle\n",
|
||||
PullFormChoices{Base: "main", HeadOwner: "alice", HeadBranch: "feature"},
|
||||
[]string{"Source branch:", "Allow maintainers to push to the source branch:"},
|
||||
[]string{"Source branch (required)"},
|
||||
},
|
||||
{"agit", "y\nmain\ntopic\nTitle\n", PullFormChoices{}, []string{"Topic branch (required):"}, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p, rendered := accessiblePrompter(t, tc.input)
|
||||
tc.choices.EditBody = noEdit
|
||||
_, err := p.CreatePull(context.Background(), tc.choices)
|
||||
require.NoError(t, err)
|
||||
for _, want := range tc.want {
|
||||
require.Contains(t, rendered.String(), want)
|
||||
}
|
||||
for _, unwanted := range tc.without {
|
||||
require.NotContains(t, rendered.String(), unwanted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// stagedInput feeds one form's keystrokes per stage. A finished form's canceled read loop can still be inside Read,
|
||||
// and bubbletea drops the byte that Read returns. When a stage runs dry, the finished form's loop blocks first and
|
||||
// the next form's loop second, so the next stage is handed to that second reader and never to the stale one.
|
||||
type stagedInput struct {
|
||||
mu sync.Mutex
|
||||
cur string
|
||||
stages []string
|
||||
idle int
|
||||
}
|
||||
|
||||
func (s *stagedInput) Read(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
for s.cur == "" {
|
||||
s.idle++
|
||||
if s.idle < 2 || len(s.stages) == 0 {
|
||||
s.mu.Unlock()
|
||||
select {} // no more input for this reader; the loop is abandoned with its form
|
||||
}
|
||||
s.cur, s.stages, s.idle = s.stages[0], s.stages[1:], 0
|
||||
}
|
||||
p[0], s.cur = s.cur[0], s.cur[1:]
|
||||
s.mu.Unlock()
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func TestCreatePullPreservesDraftOnEditorFailure(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
path := filepath.Join(t.TempDir(), "draft.md")
|
||||
called := false
|
||||
p, _ := testPrompter(&stagedInput{cur: "n\r", stages: []string{"main\ralice\rfeature\ry\r", "Title\r"}})
|
||||
values, err := p.CreatePull(ctx, PullFormChoices{
|
||||
BaseOwner: "alice", Base: "main", HeadOwner: "alice", HeadBranch: "feature",
|
||||
EditBody: func(context.Context, string) (Draft, error) {
|
||||
called = true
|
||||
return Draft{Path: path, Body: "partial"}, errors.New("editor failed")
|
||||
},
|
||||
})
|
||||
require.True(t, called)
|
||||
require.ErrorContains(t, err, "editor failed")
|
||||
require.Equal(t, path, values.Draft.Path)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
)
|
||||
|
||||
// ReviewCommentDraft is an inline comment collected during interactive review.
|
||||
type ReviewCommentDraft struct {
|
||||
Path, Body string
|
||||
OldLine, NewLine int64
|
||||
}
|
||||
|
||||
// ReviewFormChoices supplies callbacks for interactive pull review.
|
||||
type ReviewFormChoices struct {
|
||||
// ReviewDiff, when non-nil, runs after the user opts into annotating the diff.
|
||||
ReviewDiff func(context.Context) ([]ReviewCommentDraft, error)
|
||||
EditBody BodyEditor
|
||||
}
|
||||
|
||||
// ReviewFormValues holds interactively collected review fields.
|
||||
type ReviewFormValues struct {
|
||||
Event string
|
||||
Body string
|
||||
Comments []ReviewCommentDraft
|
||||
Draft Draft
|
||||
}
|
||||
|
||||
// ReviewForm collects interactive pull review input.
|
||||
type ReviewForm interface {
|
||||
CreateReview(context.Context, ReviewFormChoices) (ReviewFormValues, error)
|
||||
}
|
||||
|
||||
// CreateReview runs the interactive review form on stderr.
|
||||
func (p *FormPrompter) CreateReview(ctx context.Context, choices ReviewFormChoices) (ReviewFormValues, error) {
|
||||
var comments []ReviewCommentDraft
|
||||
if choices.ReviewDiff != nil {
|
||||
reviewDiff := true
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||
huh.NewConfirm().Title("Review / comment the diff?").Value(&reviewDiff),
|
||||
))); err != nil {
|
||||
return ReviewFormValues{}, err
|
||||
}
|
||||
if reviewDiff {
|
||||
parsed, err := choices.ReviewDiff(ctx)
|
||||
if err != nil {
|
||||
return ReviewFormValues{}, fmt.Errorf("diff review failed: %w", err)
|
||||
}
|
||||
comments = append(comments, parsed...)
|
||||
fmt.Fprintf(p.Streams.ErrOut, "Found %d code comments in your review\n", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
event := "COMMENT"
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||
huh.NewSelect[string]().Title("Your assessment:").Options(
|
||||
huh.NewOption("comment", "COMMENT"),
|
||||
huh.NewOption("request changes", "REQUEST_CHANGES"),
|
||||
huh.NewOption("approve", "APPROVED"),
|
||||
).Value(&event),
|
||||
))); err != nil {
|
||||
return ReviewFormValues{}, err
|
||||
}
|
||||
|
||||
body := ""
|
||||
values := ReviewFormValues{Event: event, Comments: comments}
|
||||
required := (event == "COMMENT" && len(comments) == 0) || event == "REQUEST_CHANGES"
|
||||
if choices.EditBody != nil {
|
||||
var err error
|
||||
if body, values.Draft, err = editBody(ctx, choices.EditBody, ""); err != nil {
|
||||
return values, err
|
||||
}
|
||||
} else {
|
||||
field := huh.NewText().Title("Concluding comment(markdown):").Value(&body)
|
||||
if required {
|
||||
field = field.Title("Concluding comment(markdown) (required):").Validate(huh.ValidateNotEmpty())
|
||||
}
|
||||
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(field))); err != nil {
|
||||
return ReviewFormValues{}, err
|
||||
}
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if required && body == "" {
|
||||
return values, fmt.Errorf("concluding comment cannot be empty")
|
||||
}
|
||||
values.Body = body
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateReviewAbortsWhenDiffReviewFails(t *testing.T) {
|
||||
failure := errors.New("editor failed")
|
||||
p, _ := accessiblePrompter(t, "y\n1\n")
|
||||
_, err := p.CreateReview(context.Background(), ReviewFormChoices{
|
||||
ReviewDiff: func(context.Context) ([]ReviewCommentDraft, error) { return nil, failure },
|
||||
})
|
||||
require.ErrorIs(t, err, failure)
|
||||
}
|
||||
|
||||
func TestReviewFormRequiresCommentOnlyWhenRequestingChanges(t *testing.T) {
|
||||
p, rendered := accessiblePrompter(t, "3\n")
|
||||
_, err := p.CreateReview(context.Background(), ReviewFormChoices{})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, rendered.String(), "Concluding comment(markdown):")
|
||||
require.NotContains(t, rendered.String(), "(required)")
|
||||
|
||||
p, rendered = accessiblePrompter(t, "2\n")
|
||||
_, err = p.CreateReview(context.Background(), ReviewFormChoices{})
|
||||
require.ErrorContains(t, err, "concluding comment cannot be empty")
|
||||
require.Contains(t, rendered.String(), "Concluding comment(markdown) (required):")
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// Streams records injected IO and independently detected terminal capabilities.
|
||||
type Streams struct {
|
||||
In io.Reader
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
InTTY bool
|
||||
OutTTY bool
|
||||
ErrTTY bool
|
||||
}
|
||||
|
||||
// System binds streams to the current process and detects each terminal.
|
||||
func System() Streams {
|
||||
return Streams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr, InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd()))}
|
||||
}
|
||||
|
||||
// Policy controls input interaction independently of supplied flag count.
|
||||
type Policy struct{ Enabled, NoInput, Machine, Interactive bool }
|
||||
|
||||
// CanPrompt requires terminal input and diagnostics, without requiring terminal stdout.
|
||||
func (p Policy) CanPrompt(s Streams) bool {
|
||||
return p.Enabled && !p.NoInput && (!p.Machine || p.Interactive) && s.InTTY && s.ErrTTY
|
||||
}
|
||||
|
||||
// Validate rejects an explicit interactive request incompatible with terminal policy.
|
||||
func (p Policy) Validate(s Streams) error {
|
||||
if p.Interactive && !p.CanPrompt(s) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--interactive requires terminal stdin/stderr, enabled prompting, and no --no-input"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prompter abstracts cancellable input collection and confirmation.
|
||||
type Prompter interface {
|
||||
Input(context.Context, string) (string, error)
|
||||
SecretInput(context.Context, string) (string, error)
|
||||
Confirm(context.Context, string) (bool, error)
|
||||
}
|
||||
|
||||
// FormPrompter uses cancellable terminal forms with explicitly injected streams.
|
||||
type FormPrompter struct{ Streams Streams }
|
||||
|
||||
func promptError(ctx context.Context, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return clierr.Wrap(clierr.Canceled, errors.New("input canceled"))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error {
|
||||
return promptError(ctx, form.WithInput(p.Streams.In).WithOutput(p.Streams.ErrOut).RunWithContext(ctx))
|
||||
}
|
||||
|
||||
// Input collects one field using the configured terminal streams.
|
||||
func (p *FormPrompter) Input(ctx context.Context, title string) (string, error) {
|
||||
var value string
|
||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).Value(&value))))
|
||||
return strings.TrimSpace(value), err
|
||||
}
|
||||
|
||||
// SecretInput collects a value without displaying it on the terminal.
|
||||
func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, error) {
|
||||
var value string
|
||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).EchoMode(huh.EchoModePassword).Value(&value))))
|
||||
return value, err
|
||||
}
|
||||
|
||||
// Confirm collects a yes/no choice through cancellable terminal input.
|
||||
func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) {
|
||||
var accepted bool
|
||||
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewConfirm().Title(title).Value(&accepted))))
|
||||
return accepted, err
|
||||
}
|
||||
|
||||
// Require fills missing input or returns an actionable usage error.
|
||||
func Require(ctx context.Context, value *string, flag string, streams Streams, policy Policy, prompt Prompter) (bool, error) {
|
||||
if *value != "" {
|
||||
return false, nil
|
||||
}
|
||||
if !policy.CanPrompt(streams) {
|
||||
return false, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input: supply --%s", flag))
|
||||
}
|
||||
entered, err := prompt.Input(ctx, flag)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if entered == "" {
|
||||
return false, clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag))
|
||||
}
|
||||
*value = entered
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Confirm requires explicit acceptance unless confirmation was supplied by flag.
|
||||
func Confirm(ctx context.Context, description string, yes bool, streams Streams, policy Policy, prompt Prompter) error {
|
||||
if yes {
|
||||
return nil
|
||||
}
|
||||
if !policy.CanPrompt(streams) {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes"))
|
||||
}
|
||||
accepted, err := prompt.Confirm(ctx, description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !accepted {
|
||||
return clierr.Wrap(clierr.Canceled, errors.New("operation canceled"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testPrompter returns a form prompter reading in and the buffer its forms render to.
|
||||
func testPrompter(in io.Reader) (*FormPrompter, *bytes.Buffer) {
|
||||
rendered := &bytes.Buffer{}
|
||||
return &FormPrompter{Streams: Streams{In: in, Out: &bytes.Buffer{}, ErrOut: rendered, InTTY: true, ErrTTY: true}}, rendered
|
||||
}
|
||||
|
||||
// accessiblePrompter is testPrompter in accessible mode, which renders field titles as plain text.
|
||||
func accessiblePrompter(t *testing.T, input string) (*FormPrompter, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("TERM", "dumb")
|
||||
return testPrompter(strings.NewReader(input))
|
||||
}
|
||||
|
||||
func noEdit(context.Context, string) (Draft, error) { return Draft{}, nil }
|
||||
|
||||
type fakePrompt struct{ inputs int }
|
||||
|
||||
func (f *fakePrompt) Input(context.Context, string) (string, error) {
|
||||
f.inputs++
|
||||
return "provided", nil
|
||||
}
|
||||
func (f *fakePrompt) SecretInput(context.Context, string) (string, error) { return "", nil }
|
||||
func (f *fakePrompt) Confirm(context.Context, string) (bool, error) { return false, nil }
|
||||
|
||||
func TestPromptPolicy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
policy Policy
|
||||
streams Streams
|
||||
expected bool
|
||||
}{
|
||||
{Policy{Enabled: true}, Streams{InTTY: true, ErrTTY: true}, true},
|
||||
{Policy{Enabled: true}, Streams{InTTY: true, OutTTY: true}, false},
|
||||
{Policy{Enabled: true, NoInput: true}, Streams{InTTY: true, ErrTTY: true}, false},
|
||||
{Policy{Enabled: true, Machine: true}, Streams{InTTY: true, ErrTTY: true}, false},
|
||||
{Policy{Enabled: true, Machine: true, Interactive: true}, Streams{InTTY: true, ErrTTY: true}, true},
|
||||
} {
|
||||
require.Equal(t, tc.expected, tc.policy.CanPrompt(tc.streams))
|
||||
}
|
||||
prompt := &fakePrompt{}
|
||||
value := "supplied"
|
||||
asked, err := Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt)
|
||||
require.NoError(t, err)
|
||||
require.False(t, asked)
|
||||
require.Zero(t, prompt.inputs)
|
||||
value = ""
|
||||
_, err = Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt)
|
||||
require.ErrorContains(t, err, "--title")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
// Cell is one value in a Table. On color terminals Link, Color (six-digit RGB hex)
|
||||
// and Bold style the text after alignment, so escape sequences never count toward
|
||||
// the column width.
|
||||
type Cell struct {
|
||||
Text, Link, Color string
|
||||
Bold bool
|
||||
}
|
||||
|
||||
// style applies the cell's link, color and emphasis to its already safe text.
|
||||
func (c Cell) style(text string) string {
|
||||
return Hyperlink(c.Link, Emphasis(Colorize(c.Color, text, true), c.Bold), true)
|
||||
}
|
||||
|
||||
// Row returns unstyled cells.
|
||||
func Row(texts ...string) []Cell {
|
||||
cells := make([]Cell, len(texts))
|
||||
for i, text := range texts {
|
||||
cells[i].Text = text
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// Table renders one line per row with columns aligned and separated by two spaces.
|
||||
// Cell text passes through SafeLine, so server data can neither break the layout
|
||||
// nor reach the terminal as escape sequences.
|
||||
func Table(rows [][]Cell, color bool) string {
|
||||
texts := make([][]string, len(rows))
|
||||
var widths []int
|
||||
for r, row := range rows {
|
||||
texts[r] = make([]string, len(row))
|
||||
for i, cell := range row {
|
||||
texts[r][i] = SafeLine(cell.Text)
|
||||
if i == len(widths) {
|
||||
widths = append(widths, 0)
|
||||
}
|
||||
widths[i] = max(widths[i], runewidth.StringWidth(texts[r][i]))
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
for r, row := range rows {
|
||||
var line strings.Builder
|
||||
for i, cell := range row {
|
||||
text := texts[r][i]
|
||||
if color {
|
||||
text = cell.style(text)
|
||||
}
|
||||
line.WriteString(text)
|
||||
if i < len(row)-1 {
|
||||
line.WriteString(strings.Repeat(" ", widths[i]-runewidth.StringWidth(texts[r][i])+2))
|
||||
}
|
||||
}
|
||||
b.WriteString(strings.TrimRight(line.String(), " "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Record is one item for human readers: a heading line of cells separated by spaces,
|
||||
// followed by text lines such as a body and a URL.
|
||||
type Record struct {
|
||||
Heading []Cell
|
||||
Lines []string
|
||||
}
|
||||
|
||||
// Render formats the record. Heading cells pass through SafeLine and are styled like
|
||||
// Table cells; lines pass through SafeText, so they keep their own line breaks.
|
||||
func (r Record) Render(color bool) string {
|
||||
var b strings.Builder
|
||||
for i, cell := range r.Heading {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
text := SafeLine(cell.Text)
|
||||
if color {
|
||||
text = cell.style(text)
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
for _, line := range r.Lines {
|
||||
b.WriteString(SafeText(line))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SafeLine reduces server text to one line: SafeText, with tabs and line breaks as spaces.
|
||||
func SafeLine(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == '\t' || r == '\n' {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, SafeText(s))
|
||||
}
|
||||
|
||||
// SafeText keeps tabs and line breaks in server text, normalizing CRLF to LF, and drops
|
||||
// every other control or format character, such as escape sequences, lone carriage
|
||||
// returns and bidirectional overrides, so the text cannot drive the terminal.
|
||||
func SafeText(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r != '\t' && r != '\n' && (unicode.IsControl(r) || unicode.Is(unicode.Cf, r)) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.ReplaceAll(s, "\r\n", "\n"))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
rows [][]Cell
|
||||
color bool
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, false, ""},
|
||||
{"aligns columns", [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, false, "7 Fix login open\n12 Crash closed\n"},
|
||||
{"counts runes, not bytes", [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, false, "1 äöü x\n2 abcd y\n"},
|
||||
{"counts wide runes twice", [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, false, "1 修复 x\n2 abcd y\n"},
|
||||
{"trims empty trailing cells", [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, false, "1 bug\n22 feature new work\n"},
|
||||
{"pads ragged rows", [][]Cell{Row("1"), Row("2", "two")}, false, "1\n2 two\n"},
|
||||
{
|
||||
"strips hostile title",
|
||||
[][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31mtxt.exe\r\ntail\tend", "open")},
|
||||
false,
|
||||
"1 a]52;c;cHduZWQ=b[2J31mtxt.exe tail end open\n",
|
||||
},
|
||||
{"ignores styles without color", [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, false, "7 bug open\n"},
|
||||
{
|
||||
"styles after alignment",
|
||||
[][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}, Row("12", "feature", "closed")},
|
||||
true,
|
||||
"\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;2;255;0;0mbug\x1b[0m \x1b[1mopen\x1b[0m\n12 feature closed\n",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, Table(tc.rows, tc.color))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecord(t *testing.T) {
|
||||
record := Record{
|
||||
Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob\u202e"}},
|
||||
Lines: []string{"Steps:\r\n\x1b]52;c;aGk=\x07run\n", "https://example.org/7"},
|
||||
}
|
||||
require.Equal(t, "#7 Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(false))
|
||||
require.Equal(t, "\x1b[1m#7\x1b[0m Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(true))
|
||||
}
|
||||
|
||||
func TestSafeText(t *testing.T) {
|
||||
for _, tc := range []struct{ name, in, text, line string }{
|
||||
{"plain", "fix login", "fix login", "fix login"},
|
||||
{"keeps tabs and line breaks", "a\tb\r\nc\nd", "a\tb\nc\nd", "a b c d"},
|
||||
{"drops lone carriage returns", "safe\rFAKE", "safeFAKE", "safeFAKE"},
|
||||
{
|
||||
"strips hostile body",
|
||||
"Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m\u202e\u200bit\x7f\n\x00done",
|
||||
"Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone",
|
||||
"Steps: ]52;c;cHduZWQ=run[2J31mit done",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.text, SafeText(tc.in))
|
||||
require.Equal(t, tc.line, SafeLine(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user