feat(label): edit a label in a form without flags

label edit without flags on a terminal now opens a form with the label's
name, color, description and flags, like milestone edit and issue edit,
instead of failing; without a terminal it reports the shared "nothing to
edit" error. The help of issue and milestone close and reopen names its
commands without generating the text from the verb.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:17:08 +02:00
1 parent f6a0d18b8a
commit 5d2f2d83eb
6 files changed
+143 -8

No files matched your search

+3 -3
View File
@@ -414,11 +414,11 @@ func issueTemplate(ctx context.Context, service services.Issues, name string) (s
// changing them. // changing them.
func (a *application) issueStateCommand(verb string) *cobra.Command { func (a *application) issueStateCommand(verb string) *cobra.Command {
var comment string var comment string
state, past, completeState := "closed", "Closed", "open" state, past, completeState, short := "closed", "Closed", "open", "Close issues"
if verb == "reopen" { if verb == "reopen" {
state, past, completeState = "open", "Reopened", "closed" state, past, completeState, short = "open", "Reopened", "closed", "Reopen issues"
} }
cmd := &cobra.Command{Use: verb + " {<number> | <url>}...", Short: titleCase(verb) + " issues", Example: fmt.Sprintf(`$ tea issue %[1]s 12 cmd := &cobra.Command{Use: verb + " {<number> | <url>}...", Short: short, Example: fmt.Sprintf(`$ tea issue %[1]s 12
$ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string]string{"tea.arguments": issueArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { $ tea issue %[1]s 12 13 --comment "Done in #14"`, verb), Annotations: map[string]string{"tea.arguments": issueArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
target, numbers, err := a.resolveRefs(cmd, args, issueRef) target, numbers, err := a.resolveRefs(cmd, args, issueRef)
if err != nil { if err != nil {
+43
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.dev/tea/internal/terminal"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -193,3 +194,45 @@ func TestLabelCreatePicksAColorAndPrompts(t *testing.T) {
require.Equal(t, "From a prompt", created[1]["description"]) require.Equal(t, "From a prompt", created[1]["description"])
require.Equal(t, []string{"Label name", "Color (hex, e.g. d73a4a; empty = random):", "Description (optional):"}, prompt.asked) require.Equal(t, []string{"Label name", "Color (hex, e.g. d73a4a; empty = random):", "Description (optional):"}, prompt.asked)
} }
// labelFormPrompter answers the label form with edit applied to the current values.
type labelFormPrompter struct {
scriptedPrompter
heading string
edit func(*terminal.LabelFormValues)
}
func (p *labelFormPrompter) EditLabel(_ context.Context, heading string, current terminal.LabelFormValues) (terminal.LabelFormValues, error) {
p.heading = heading
p.edit(&current)
return current, nil
}
func TestLabelEditFormStartsFromTheLabel(t *testing.T) {
var edited string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/project/labels":
if r.URL.Query().Get("page") != "1" {
_, _ = w.Write([]byte(`[]`))
return
}
_, _ = w.Write([]byte(`[{"id":11,"name":"bug","color":"ee0701","description":"Broken"}]`))
case "PATCH /api/v1/repos/alice/project/labels/11":
edited = requestFields(t, r, "name", "color", "description", "exclusive")
_, _ = w.Write([]byte(`{"id":11,"name":"defect"}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
prompt := &labelFormPrompter{edit: func(v *terminal.LabelFormValues) { v.Name, v.Exclusive = "defect", true }}
deps.Prompter = prompt
require.NoError(t, Execute(context.Background(), deps, []string{"label", "edit", "bug", "-R", "alice/project"}))
require.Equal(t, `Editing label "bug" in alice/project`, prompt.heading)
require.Equal(t, "name=defect color=ee0701 description=Broken exclusive=true", edited)
deps.Streams.InTTY, deps.Streams.ErrTTY = false, false
require.ErrorIs(t, Execute(context.Background(), deps, []string{"label", "edit", "bug", "-R", "alice/project"}), errNothingToEdit)
}
+17 -2
View File
@@ -240,14 +240,19 @@ func (a *application) reportLabel(cmd *cobra.Command, s services.Labels, item se
func (a *application) labelEditCommand(scope labelScope, complete cobra.CompletionFunc) *cobra.Command { func (a *application) labelEditCommand(scope labelScope, complete cobra.CompletionFunc) *cobra.Command {
var fields labelFields var fields labelFields
var byName bool var byName bool
cmd := &cobra.Command{Use: "edit <label>", Short: "Edit a label", Example: `$ tea label edit bug --name defect --color b60205 cmd := &cobra.Command{Use: "edit <label>", Short: "Edit a label", Long: "Edit a label. Without flags on a terminal, a form starts from the current values.", Example: `$ tea label edit bug --name defect --color b60205
$ tea label edit "kind/question" --description "Further information is requested" $ tea label edit "kind/question" --description "Further information is requested"
$ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.arguments": labelArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { $ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.arguments": labelArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
opts, err := fields.options(cmd) opts, err := fields.options(cmd)
if err != nil { if err != nil {
return err return err
} }
if opts == (services.LabelOptions{}) { prompt, err := a.canPrompt()
if err != nil {
return err
}
form, hasForm := a.deps.Prompter.(terminal.LabelForm)
if opts == (services.LabelOptions{}) && (!prompt || !hasForm) {
return errNothingToEdit return errNothingToEdit
} }
s, err := scope(cmd) s, err := scope(cmd)
@@ -258,6 +263,16 @@ $ tea label edit old-stuff --archived`, Annotations: map[string]string{"tea.argu
if err != nil { if err != nil {
return err return err
} }
if opts == (services.LabelOptions{}) {
current := found[0]
values, err := form.EditLabel(cmd.Context(), "Editing "+labelName(s, current), terminal.LabelFormValues{
Name: current.Name, Color: current.Color, Description: current.Description, Exclusive: current.Exclusive, Archived: current.Archived,
})
if err != nil {
return err
}
opts = services.LabelOptions{Name: &values.Name, Color: &values.Color, Description: &values.Description, Exclusive: &values.Exclusive, Archived: &values.Archived}
}
item, err := s.Edit(cmd.Context(), found[0].ID, opts) item, err := s.Edit(cmd.Context(), found[0].ID, opts)
if err != nil { if err != nil {
return err return err
+3 -3
View File
@@ -18,11 +18,11 @@ import (
// of changing them. // of changing them.
func (a *application) milestoneStateCommand(verb string, service milestoneService) *cobra.Command { func (a *application) milestoneStateCommand(verb string, service milestoneService) *cobra.Command {
var byName bool var byName bool
state, past := "closed", "Closed" state, past, short := "closed", "Closed", "Close milestones"
if verb == "reopen" { if verb == "reopen" {
state, past = "open", "Reopened" state, past, short = "open", "Reopened", "Reopen milestones"
} }
cmd := &cobra.Command{Use: verb + " <milestone>...", Short: titleCase(verb) + " milestones", Example: fmt.Sprintf(`$ tea milestone %[1]s v1.0 cmd := &cobra.Command{Use: verb + " <milestone>...", Short: short, Example: fmt.Sprintf(`$ tea milestone %[1]s v1.0
$ tea milestone %[1]s v1.0 v1.1`, verb), Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { $ tea milestone %[1]s v1.0 v1.1`, verb), Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if slices.ContainsFunc(args, func(arg string) bool { return strings.TrimSpace(arg) == "" }) { if slices.ContainsFunc(args, func(arg string) bool { return strings.TrimSpace(arg) == "" }) {
return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty")) return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"errors"
"regexp"
"strings"
"charm.land/huh/v2"
)
// LabelFormValues holds interactively edited label fields.
type LabelFormValues struct {
Name, Color, Description string
Exclusive, Archived bool
}
// LabelForm collects interactive label input.
type LabelForm interface {
// EditLabel asks for the fields of a label, starting from current, under heading, such as
// `Editing label "bug" in alice/x`.
EditLabel(ctx context.Context, heading string, current LabelFormValues) (LabelFormValues, error)
}
var hexColor = regexp.MustCompile(`^#?[0-9a-fA-F]{6}$`)
// EditLabel runs the interactive label form on stderr.
func (p *FormPrompter) EditLabel(ctx context.Context, heading string, current LabelFormValues) (LabelFormValues, error) {
values := current
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
huh.NewInput().Title("Label name (required):").Value(&values.Name).Validate(huh.ValidateNotEmpty()),
huh.NewInput().Title("Color (hex, e.g. d73a4a):").Value(&values.Color).Validate(func(s string) error {
if !hexColor.MatchString(strings.TrimSpace(s)) {
return errors.New("use six hexadecimal digits")
}
return nil
}),
huh.NewInput().Title("Description (optional):").Value(&values.Description),
huh.NewConfirm().Title("Exclusive (one label of its scope per issue):").Value(&values.Exclusive),
huh.NewConfirm().Title("Archived:").Value(&values.Archived),
).Title(heading))); err != nil {
return LabelFormValues{}, err
}
values.Name, values.Color = strings.TrimSpace(values.Name), strings.TrimPrefix(strings.TrimSpace(values.Color), "#")
return values, nil
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestLabelFormStartsFromCurrentValues(t *testing.T) {
p, _ := testPrompter(strings.NewReader("\r\r\r\r\r"))
current := LabelFormValues{Name: "bug", Color: "#EE0701", Description: "Broken", Exclusive: true}
values, err := p.EditLabel(context.Background(), `Editing label "bug" in alice/x`, current)
require.NoError(t, err)
require.Equal(t, LabelFormValues{Name: "bug", Color: "EE0701", Description: "Broken", Exclusive: true}, values)
}
func TestLabelFormMarksNameRequired(t *testing.T) {
p, rendered := accessiblePrompter(t, "bug\nee0701\n\nn\nn\n")
values, err := p.EditLabel(context.Background(), "", LabelFormValues{})
require.NoError(t, err)
require.Contains(t, rendered.String(), "Label name (required):")
require.Equal(t, "bug", values.Name)
}