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:
bircni committed 2026-10-11 17:12:23 +00:00
1 parent 88b91faa54
commit faea86cd94
27 files changed
+1941 -1

No files matched your search

+59
View File
@@ -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
}
+37
View File
@@ -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)
}
+62
View File
@@ -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\\"
}
+42
View File
@@ -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))
}
+125
View File
@@ -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] != ""
}
+129
View File
@@ -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")
}
+89
View File
@@ -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
}
+45
View File
@@ -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)
}
+175
View File
@@ -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
}
+36
View File
@@ -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):")
}
+39
View File
@@ -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
}
+37
View File
@@ -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
}
+18
View File
@@ -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):")
}
+124
View File
@@ -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
}
+24
View File
@@ -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"`)
}
+69
View File
@@ -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")
}
+88
View File
@@ -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())
}
+120
View File
@@ -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, " ")
}
+105
View File
@@ -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)
}
+94
View File
@@ -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
}
+34
View File
@@ -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):")
}
+130
View File
@@ -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
}
+63
View File
@@ -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")
}
+119
View File
@@ -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"))
}
+71
View File
@@ -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\u009b31m‮txt.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))
})
}
}