mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11: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/davidmz/go-pageant v1.0.2
|
||||||
github.com/enescakir/emoji v1.0.0
|
github.com/enescakir/emoji v1.0.0
|
||||||
github.com/go-signet/sdk-go v1.1.0
|
github.com/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/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/muesli/termenv v0.16.0
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||||
@@ -60,10 +62,10 @@ require (
|
|||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/go-version v1.9.0 // 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/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.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/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 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
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 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
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=
|
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