feat(milestone): milestone tables, views, forms and one selection model

milestone view printed the list row, and milestone list hid the due date
and progress. milestone list now shows ID TITLE [STATE] DUE PROGRESS, open
milestones first and by due date; milestone view shows the state, due
date, progress, description and the milestone's web page (also --web).

Every milestone command addresses milestones by title or ID. Mutations
report ✓ lines: milestone create also prints the new milestone's URL and
takes the title as an argument; close and reopen skip milestones already
in that state; delete names the milestone in its confirmation; milestone
issue add and remove report one line such as "✓ Added #1, #2 to
milestone "v1.0" in alice/x". milestone issue list uses the issue table.
milestone edit without flags on a terminal opens the milestone form with
the current values, and every milestone command completes milestone
titles and has help text with examples.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:17:02 +02:00
1 parent 2ba02cf915
commit c161a63eb6
14 files changed
+606 -257

No files matched your search

-12
View File
@@ -424,16 +424,6 @@ const (
issue attachment list
issue attachment upload
issue attachment view
milestone close
milestone create
milestone delete
milestone edit
milestone issue add
milestone issue list
milestone issue remove
milestone list
milestone reopen
milestone view
pr attachment delete
pr attachment download
pr attachment edit
@@ -449,8 +439,6 @@ const (
`
longsToWrite = `
issue attachment
milestone
milestone issue
pr attachment
time
`
+3 -3
View File
@@ -26,14 +26,14 @@ type commentNouns struct {
func commentWords(kind refKind) commentNouns {
if kind == pullRef {
return commentNouns{group: "pr", noun: "pull request", arguments: pullArguments, example: "13"}
return commentNouns{group: "pr", noun: "pull request", arguments: pullNumberArguments, example: "13"}
}
return commentNouns{group: "issue", noun: "issue", arguments: issueArguments, example: "12"}
}
// pullArguments is the ARGUMENTS help of pr comment and pr attachment commands, which take pull
// pullNumberArguments is the ARGUMENTS help of pr comment and pr attachment commands, which take pull
// request numbers like issue commands take issue numbers.
const pullArguments = `A pull request can be supplied as argument in any of the following formats:
const pullNumberArguments = `A pull request can be supplied as argument in any of the following formats:
- by number, e.g. "13" or "#13"
- by URL, e.g. "https://gitea.com/OWNER/REPO/pulls/13"
- by repository and number, e.g. "OWNER/REPO#13"`
+2 -1
View File
@@ -173,7 +173,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
want string
}{
{[]string{"branch", "list"}, "Showing 2 of 2 branches in alice/project\n\nNAME COMMIT PROTECTED UPDATED MESSAGE\nmain (default) abc\ndev def\n"},
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed issue\n2 Issue 2 closed issue\n"},
{[]string{"milestone", "issue", "list", "3"}, "Showing 2 of 2 open issues and pull requests in milestone \"v1\" of alice/project\n\n" +
"ID TITLE LABELS KIND AUTHOR UPDATED\n#1 Issue 1 issue\n#2 Issue 2 issue\n"},
{[]string{"issue", "comment", "list", "5"}, "#1 alice\n one\n\n#2 bob\n two\n"},
} {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
+7 -6
View File
@@ -76,7 +76,7 @@ $ tea issue list --search "login" --web`, Args: argsExact(0), RunE: func(cmd *co
if err != nil {
return err
}
return writeTable(a, cmd, page.Items, issueListSpec(target.Repo, opts.State, opts.Kind, filtered), servicePage(page, opts.Page))
return writeTable(a, cmd, page.Items, issueListSpec("in "+target.Repo, opts.State, opts.Kind, filtered), servicePage(page, opts.Page))
}}
enumFlag(cmd, &opts.State, "state", "s", "open", "Filter by state", "open", "closed", "all")
enumFlag(cmd, &opts.Kind, "kind", "", "issues", "List issues, pull requests or both", "issues", "pulls", "all")
@@ -112,9 +112,10 @@ func issuesURL(t target, opts services.ListOptions) string {
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/" + section + "?" + query.Encode()
}
// issueListSpec describes issue tables: STATE shows on a terminal unless only open issues are
// listed, and KIND when issues and pull requests are; piped tables always have both.
func issueListSpec(repo, state, kind string, filtered bool) listSpec[services.Issue] {
// issueListSpec describes issue tables of the issues where, such as "in alice/x": STATE shows on a
// terminal unless only open issues are listed, and KIND when issues and pull requests are; piped
// tables always have both.
func issueListSpec(where, state, kind string, filtered bool) listSpec[services.Issue] {
what := map[string]string{"issues": "issues", "pulls": "pull requests", "all": "issues and pull requests"}[kind]
if state != "all" {
what = state + " " + what
@@ -134,8 +135,8 @@ func issueListSpec(repo, state, kind string, filtered bool) listSpec[services.Is
{Header: "Updated"},
},
Row: issueRow,
Empty: empty + " in " + repo,
Context: what + " in " + repo,
Empty: empty + " " + where,
Context: what + " " + where,
}
}
+379 -167
View File
@@ -4,7 +4,10 @@
package app
import (
"cmp"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -15,176 +18,385 @@ import (
"github.com/spf13/cobra"
)
func (a *application) milestoneCommand() *cobra.Command {
group := &cobra.Command{Use: "milestone", Short: "Manage repository milestones"}
service := func(cmd *cobra.Command) (services.Milestones, error) {
t, err := a.resolve(cmd.Context(), true)
return services.Milestones{Client: t.client, Repository: t.Repo}, err
}
var limit, page int
var state string
list := &cobra.Command{Use: "list", Short: "List milestones", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
if err := oneOf("state", state, "open", "closed", "all"); err != nil {
return err
}
s, err := service(cmd)
if err != nil {
return err
}
items, err := s.List(cmd.Context(), state, limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, milestoneRow)
}}
pageFlags(list, &limit, &page)
list.Flags().StringVar(&state, "state", "open", "open, closed, all")
group.AddCommand(list)
for _, verb := range []string{"view", "create", "edit", "delete"} {
var title, description, descriptionFile, deadline, createState string
var byName, useEditor bool
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " a milestone"}
if verb == "create" {
command.Args = argsExact(0)
} else {
command.Use += " <milestone>"
command.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
command.Args = argsExact(1)
}
if verb == "create" || verb == "edit" {
command.Flags().StringVar(&title, "title", "", "Milestone title")
command.Flags().StringVar(&description, "description", "", "Milestone description")
command.Flags().StringVar(&descriptionFile, "description-file", "", "Read description from file, or - for stdin")
command.Flags().BoolVar(&useEditor, "editor", false, "Edit the milestone description in the configured editor")
command.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears")
}
if verb == "create" {
command.Flags().StringVar(&createState, "state", "open", "Milestone state: open or closed")
}
command.RunE = func(cmd *cobra.Command, args []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if verb == "create" || verb == "edit" {
if err := validateTextSources(cmd, "description", "description-file"); err != nil {
return err
}
}
opts := services.MilestoneOptions{}
if verb != "create" && strings.TrimSpace(args[0]) == "" {
return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
}
var interactiveDeadline *time.Time
descriptionChanged := false
if verb == "create" {
if err := oneOf("state", createState, "open", "closed"); err != nil {
return err
}
opts.State = &createState
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
policy := a.policy(snapshot)
if !localFlagsChanged(cmd) && policy.CanPrompt(a.deps.Streams) && a.deps.MilestoneForm != nil {
values, err := a.deps.MilestoneForm.CreateMilestone(cmd.Context())
if err != nil {
return err
}
title, description = values.Title, values.Description
interactiveDeadline = values.Deadline
descriptionChanged = true
} else if _, err := a.require(cmd, &title, "title"); err != nil {
return err
}
}
if (verb == "create" || verb == "edit") && !useEditor {
var err error
description, err = a.readTextSource(cmd, description, descriptionFile, "description", "description-file")
if err != nil {
return err
}
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-file") {
descriptionChanged = true
}
}
if cmd.Flags().Changed("title") && strings.TrimSpace(title) == "" {
return clierr.Wrap(clierr.Usage, errors.New("milestone title cannot be empty"))
}
if verb == "create" || cmd.Flags().Changed("title") {
opts.Title = &title
}
if cmd.Flags().Changed("deadline") || interactiveDeadline != nil {
date := time.Time{}
if interactiveDeadline != nil {
date = *interactiveDeadline
} else if deadline != "" {
parsed, _, err := parseDeadline(deadline)
if err != nil {
return err
}
date = *parsed
}
normalized := date.Format(time.RFC3339)
opts.Deadline = &normalized
}
// milestoneArguments is the ARGUMENTS help of commands that take milestones.
const milestoneArguments = `A milestone can be supplied by its title, such as "v1.0", or by its ID. A number is an ID
first, then a title; --by-name matches it as a title only.`
if verb == "edit" && opts.Title == nil && !descriptionChanged && !useEditor && opts.Deadline == nil {
return clierr.Wrap(clierr.Usage, errors.New("supply milestone fields to edit"))
}
s, err := service(cmd)
if err != nil {
return err
}
var current services.Milestone
if verb != "create" {
if current, err = s.FindOne(cmd.Context(), args[0], byName); err != nil {
return err
}
}
id := current.ID
if verb == "delete" {
if err := a.confirm(cmd, "Delete milestone "+args[0]); err != nil {
return err
}
if err := s.Delete(cmd.Context(), id); err != nil {
return err
}
if a.options.json != "" {
return writeItem(a, cmd, current, milestoneRow)
}
return nil
}
if useEditor {
editorDraft, err = a.editDraft(cmd, current.Description)
if err != nil {
return err
}
description = editorDraft.Body
descriptionChanged = true
}
if verb == "create" || descriptionChanged {
opts.Description = &description
}
var item services.Milestone
if verb == "view" {
item = current
} else if verb == "create" {
item, err = s.Create(cmd.Context(), opts)
} else {
item, err = s.Edit(cmd.Context(), id, opts)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, milestoneRow)
}
group.AddCommand(command)
// milestoneService returns the milestones of the repository a milestone command targets.
type milestoneService func(*cobra.Command) (services.Milestones, target, error)
func (a *application) milestoneCommand() *cobra.Command {
group := &cobra.Command{Use: "milestone", Short: "Manage milestones", Long: `Work with the milestones of a repository and the issues and pull requests in them.
Milestones are addressed by title or ID.`}
service := func(cmd *cobra.Command) (services.Milestones, target, error) {
t, err := a.resolve(cmd.Context(), true)
return services.Milestones{Client: t.client, Repository: t.Repo}, t, err
}
group.AddCommand(a.milestoneStateCommand("close", service), a.milestoneStateCommand("reopen", service))
group.AddCommand(a.milestoneIssueCommand())
group.AddCommand(a.milestoneListCommand(service), a.milestoneViewCommand(service), a.milestoneCreateCommand(service), a.milestoneEditCommand(service),
a.milestoneDeleteCommand(service), a.milestoneStateCommand("close", service), a.milestoneStateCommand("reopen", service), a.milestoneIssueCommand())
return group
}
func milestoneRow(item services.Milestone) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}}
func (a *application) milestoneListCommand(service milestoneService) *cobra.Command {
var limit, page int
var state string
cmd := &cobra.Command{Use: "list", Short: "List milestones", Long: "List the milestones of a repository: open ones first, then by due date.", Example: `$ tea milestone list
$ tea milestone list --state all --json title,due_on,open_issues,closed_issues`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
s, t, err := service(cmd)
if err != nil {
return err
}
items, err := s.ListPage(cmd.Context(), state, limit, page)
if err != nil {
return err
}
slices.SortStableFunc(items.Items, compareMilestones)
what := "milestones in " + t.Repo
if state != "all" {
what = state + " " + what
}
return writeTable(a, cmd, items.Items, listSpec[services.Milestone]{
Columns: []terminal.Column{{Header: "ID"}, {Header: "Title", Flex: true}, {Header: "State", PipedOnly: state == "open"}, {Header: "Due"}, {Header: "Progress"}},
Row: milestoneRow, Empty: "no " + what, Context: what,
}, servicePage(items, page))
}}
pageFlags(cmd, &limit, &page)
enumFlag(cmd, &state, "state", "s", "open", "Filter by state", "open", "closed", "all")
return cmd
}
// compareMilestones orders open milestones first, then by due date, those without one last.
func compareMilestones(x, y services.Milestone) int {
if x.State != y.State {
return cmp.Compare(boolOrder(x.State != "open"), boolOrder(y.State != "open"))
}
if x.Deadline == nil || y.Deadline == nil {
return cmp.Compare(boolOrder(x.Deadline == nil), boolOrder(y.Deadline == nil))
}
return terminal.ParseTime(*x.Deadline).Compare(terminal.ParseTime(*y.Deadline))
}
func boolOrder(b bool) int {
if b {
return 1
}
return 0
}
func milestoneRow(item services.Milestone) []terminal.Cell {
due := terminal.Cell{}
if item.Deadline != nil {
due.Time = terminal.ParseTime(*item.Deadline)
}
return []terminal.Cell{
{Text: strconv.FormatInt(item.ID, 10), Color: stateColors[item.State]},
{Text: item.Title},
{Text: item.State, Color: stateColors[item.State]},
due,
{Text: milestoneProgress(item)},
}
}
// milestoneProgress summarizes the closed issues of a milestone: "3/5 (60%)".
func milestoneProgress(item services.Milestone) string {
total := item.OpenIssues + item.ClosedIssues
if total == 0 {
return "0/0"
}
return fmt.Sprintf("%d/%d (%d%%)", item.ClosedIssues, total, item.ClosedIssues*100/total)
}
// milestoneName names a milestone in messages: `milestone "v1.0" in alice/x`.
func milestoneName(t target, item services.Milestone) string {
return fmt.Sprintf("milestone %q in %s", item.Title, t.Repo)
}
// milestoneURL is the web page of a milestone, which the API does not return.
func milestoneURL(t target, item services.Milestone) string {
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/milestone/" + strconv.FormatInt(item.ID, 10)
}
func milestoneView(t target, now time.Time) func(services.Milestone) terminal.View {
return func(item services.Milestone) terminal.View {
total := item.OpenIssues + item.ClosedIssues
meta := []string{fmt.Sprintf("%d of %d closed", item.ClosedIssues, total)}
if total > 0 {
meta[0] += fmt.Sprintf(" (%d%%)", item.ClosedIssues*100/total)
}
due := ""
if item.Deadline != nil {
deadline := terminal.ParseTime(*item.Deadline)
due = terminal.Timestamp(deadline)
meta = append([]string{"due " + deadline.Format("Jan 2, 2006") + " (" + terminal.RelativeTime(now, deadline) + ")"}, meta...)
}
return terminal.View{
Title: item.Title, ID: "#" + strconv.FormatInt(item.ID, 10),
Badges: []terminal.Cell{{Text: titleCase(item.State), Color: stateColors[item.State]}}, Meta: strings.Join(meta, " • "),
Fields: []terminal.Field{
{Label: "State", Value: item.State, PipedOnly: true},
{Label: "Due", Value: due, PipedOnly: true},
{Label: "Progress", Value: milestoneProgress(item), PipedOnly: true},
},
Body: item.Description, Kind: "milestone", URL: milestoneURL(t, item),
}
}
}
func (a *application) milestoneViewCommand(service milestoneService) *cobra.Command {
var byName, web bool
cmd := &cobra.Command{Use: "view <milestone>", Short: "View a milestone", Example: `$ tea milestone view v1.0
$ tea milestone view 3 --web`, Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
s, t, err := service(cmd)
if err != nil {
return err
}
item, err := findMilestone(cmd, s, args[0], byName)
if err != nil {
return err
}
if web {
return a.openBrowser(cmd, milestoneURL(t, item))
}
return writeView(a, cmd, item, milestoneView(t, a.deps.Now()))
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
cmd.Flags().BoolVarP(&web, "web", "w", false, "Open the milestone in the browser")
cmd.ValidArgsFunction = a.completeMilestoneArgs(false)
return cmd
}
// milestoneFields are the fields of milestone create and edit.
type milestoneFields struct {
title, description, descriptionFile, deadline string
useEditor bool
}
func (f *milestoneFields) register(cmd *cobra.Command) {
flags := cmd.Flags()
flags.StringVarP(&f.title, "title", "t", "", "Milestone title")
flags.StringVarP(&f.description, "description", "d", "", "Milestone description")
flags.StringVarP(&f.descriptionFile, "description-file", "F", "", "Read the description from `file`, or - for stdin")
flags.BoolVarP(&f.useEditor, "editor", "e", false, "Write the description in the configured editor")
flags.StringVar(&f.deadline, "deadline", "", "Due `date`: YYYY-MM-DD, RFC3339, or Unix timestamp; empty clears it")
}
// milestoneOptions returns the fields that were passed, reading the description and checking the
// rest before any request. Callers run the editor for --editor.
func (a *application) milestoneOptions(cmd *cobra.Command, f *milestoneFields) (services.MilestoneOptions, error) {
var opts services.MilestoneOptions
if cmd.Flags().Changed("title") {
if strings.TrimSpace(f.title) == "" {
return opts, clierr.Wrap(clierr.Usage, errors.New("milestone title cannot be empty"))
}
opts.Title = &f.title
}
if cmd.Flags().Changed("deadline") {
date := time.Time{}
if f.deadline != "" {
parsed, _, err := parseDeadline(f.deadline)
if err != nil {
return opts, err
}
date = *parsed
}
normalized := date.Format(time.RFC3339)
opts.Deadline = &normalized
}
if cmd.Flags().Changed("description") || cmd.Flags().Changed("description-file") {
description, err := a.readTextSource(cmd, f.description, f.descriptionFile, "description", "description-file")
if err != nil {
return opts, err
}
opts.Description = &description
}
return opts, nil
}
// formOptions turns milestone form values into options.
func formOptions(values terminal.MilestoneFormValues) services.MilestoneOptions {
deadline := time.Time{}
if values.Deadline != nil {
deadline = *values.Deadline
}
normalized := deadline.Format(time.RFC3339)
return services.MilestoneOptions{Title: &values.Title, Description: &values.Description, Deadline: &normalized}
}
func (a *application) milestoneCreateCommand(service milestoneService) *cobra.Command {
var fields milestoneFields
var state string
cmd := &cobra.Command{Use: "create [<title>]", Aliases: []string{"new"}, Short: "Create a milestone", Long: `Create a milestone. Without flags on a terminal, a form asks for the title, description
and due date. The new milestone's URL is printed, so scripts can capture it.`, Example: `$ tea milestone create v1.0 --deadline 2026-12-31 --description "First release"
$ tea milestone create --title v2.0 --editor`, Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
if err := validateTextSources(cmd, "description", "description-file"); err != nil {
return err
}
if len(args) == 1 {
if cmd.Flags().Changed("title") {
return clierr.Wrap(clierr.Usage, errors.New("pass the title as an argument or --title, not both"))
}
if err := cmd.Flags().Set("title", args[0]); err != nil {
return err
}
}
prompt := a.canPrompt()
var opts services.MilestoneOptions
var draft terminal.Draft
var err error
defer finishDraft(&draft, &runErr)
if !localFlagsChanged(cmd) && prompt && a.deps.MilestoneForm != nil {
values, err := a.deps.MilestoneForm.EditMilestone(cmd.Context(), "", terminal.MilestoneFormValues{})
if err != nil {
return err
}
opts = formOptions(values)
} else {
if opts, err = a.milestoneOptions(cmd, &fields); err != nil {
return err
}
if _, err := a.requireInput(cmd, &fields.title, "title", "Milestone title", nil); err != nil {
return err
}
opts.Title = &fields.title
if fields.useEditor {
if draft, err = a.editDraft(cmd, ""); err != nil {
return err
}
opts.Description = &draft.Body
}
}
opts.State = &state
s, t, err := service(cmd)
if err != nil {
return err
}
item, err := s.Create(cmd.Context(), opts)
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
a.done(cmd, "Created %s", milestoneName(t, item))
return a.printURL(cmd, milestoneURL(t, item))
}}
fields.register(cmd)
enumFlag(cmd, &state, "state", "", "open", "State of the new milestone", "open", "closed")
return cmd
}
func (a *application) milestoneEditCommand(service milestoneService) *cobra.Command {
var fields milestoneFields
var byName bool
cmd := &cobra.Command{Use: "edit <milestone>", Short: "Edit a milestone", Long: `Edit the title, description or due date of a milestone. Without flags on a terminal, a form
starts from the current values.`, Example: `$ tea milestone edit v1.0 --title v1.0.0 --deadline 2027-01-31
$ tea milestone edit v1.0 --deadline=
$ tea milestone edit 3 --editor`, Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
if err := validateTextSources(cmd, "description", "description-file"); err != nil {
return err
}
prompt := a.canPrompt()
form := !localFlagsChanged(cmd) && prompt && a.deps.MilestoneForm != nil
if !form && !localFlagsChanged(cmd) {
return errNothingToEdit
}
var opts services.MilestoneOptions
var draft terminal.Draft
var err error
defer finishDraft(&draft, &runErr)
if !form {
if opts, err = a.milestoneOptions(cmd, &fields); err != nil || (opts == (services.MilestoneOptions{}) && !fields.useEditor) {
return cmp.Or(err, errNothingToEdit)
}
}
s, t, err := service(cmd)
if err != nil {
return err
}
current, err := findMilestone(cmd, s, args[0], byName)
if err != nil {
return err
}
switch {
case form:
values := terminal.MilestoneFormValues{Title: current.Title, Description: current.Description}
if current.Deadline != nil {
deadline := terminal.ParseTime(*current.Deadline)
values.Deadline = &deadline
}
if values, err = a.deps.MilestoneForm.EditMilestone(cmd.Context(), "Editing "+milestoneName(t, current), values); err != nil {
return err
}
opts = formOptions(values)
case fields.useEditor:
if draft, err = a.editDraft(cmd, current.Description); err != nil {
return err
}
opts.Description = &draft.Body
}
item, err := s.Edit(cmd.Context(), current.ID, opts)
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
a.done(cmd, "Edited %s", milestoneName(t, item))
return nil
}}
fields.register(cmd)
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
cmd.ValidArgsFunction = a.completeMilestoneArgs(false)
return cmd
}
func (a *application) milestoneDeleteCommand(service milestoneService) *cobra.Command {
var byName bool
cmd := &cobra.Command{Use: "delete <milestone>", Short: "Delete a milestone", Long: "Delete a milestone. Its issues and pull requests stay, without a milestone.", Example: `$ tea milestone delete v0.9
$ tea milestone delete 7 --yes`, Annotations: map[string]string{"tea.confirm": "true", "tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
s, t, err := service(cmd)
if err != nil {
return err
}
item, err := findMilestone(cmd, s, args[0], byName)
if err != nil {
return err
}
name := fmt.Sprintf("milestone %q (ID %d) from %s", item.Title, item.ID, t.Repo)
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + name + "?", NonTTY: "delete " + name}); err != nil {
return err
}
if err := s.Delete(cmd.Context(), item.ID); err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
a.done(cmd, "Deleted milestone %q from %s", item.Title, t.Repo)
return nil
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
cmd.ValidArgsFunction = a.completeMilestoneArgs(false)
return cmd
}
// findMilestone resolves a milestone argument, a title or an ID.
func findMilestone(cmd *cobra.Command, s services.Milestones, raw string, byName bool) (services.Milestone, error) {
if strings.TrimSpace(raw) == "" {
return services.Milestone{}, clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
}
return s.FindOne(cmd.Context(), raw, byName)
}
// completeMilestoneArgs completes milestone titles for the first argument, or for every argument
// when variadic.
func (a *application) completeMilestoneArgs(variadic bool) cobra.CompletionFunc {
complete := a.completeMilestones()
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 && !variadic {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return complete(cmd, args, toComplete)
}
}
+95 -1
View File
@@ -6,6 +6,7 @@ package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
@@ -17,7 +18,7 @@ import (
type milestoneFormFunc func(context.Context) (terminal.MilestoneFormValues, error)
func (f milestoneFormFunc) CreateMilestone(ctx context.Context) (terminal.MilestoneFormValues, error) {
func (f milestoneFormFunc) EditMilestone(ctx context.Context, _ string, _ terminal.MilestoneFormValues) (terminal.MilestoneFormValues, error) {
return f(ctx)
}
@@ -67,3 +68,96 @@ func TestMilestoneCreateState(t *testing.T) {
require.Equal(t, "closed", payload["state"])
require.JSONEq(t, `{"id":9,"state":"closed"}`, out.String())
}
func TestMilestoneTablesViewsAndDoneLines(t *testing.T) {
var changed []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/milestones":
writeMilestones(w, r, `[{"id":5,"title":"later","state":"open"},{"id":4,"title":"v0.9","state":"closed","closed_issues":3},`+
`{"id":3,"title":"v1.0","state":"open","description":"First release","due_on":"2026-12-31T00:00:00Z","open_issues":3,"closed_issues":1}]`)
case "GET /api/v1/repos/alice/project/issues/1", "GET /api/v1/repos/alice/project/issues/2":
_, _ = w.Write([]byte(`{"number":1,"milestone":{"id":4,"title":"v0.9"}}`))
case "PATCH /api/v1/repos/alice/project/issues/1", "PATCH /api/v1/repos/alice/project/issues/2":
changed = append(changed, r.Method+" "+r.URL.Path)
_, _ = fmt.Fprintf(w, `{"number":%s}`, r.URL.Path[len(r.URL.Path)-1:])
default:
changed = append(changed, r.Method+" "+r.URL.Path)
_, _ = w.Write([]byte(`{"id":3,"title":"v1.0","state":"closed"}`))
}
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) }
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw", "TEA_HYPERLINKS": "never"})
run := func(tty bool, args ...string) {
t.Helper()
out.Reset()
diagnostics.Reset()
deps.Streams.OutTTY = tty
require.NoError(t, Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")))
}
run(true, "milestone", "list", "--state", "all")
require.Equal(t, "Showing 3 of 3 milestones in alice/project\n\n"+
"ID TITLE STATE DUE PROGRESS\n"+
"3 v1.0 open in about 2 months 1/4 (25%)\n"+
"5 later open 0/0\n"+
"4 v0.9 closed 3/3 (100%)\n", out.String())
run(false, "milestone", "list", "--state", "all")
require.Equal(t, "3\tv1.0\topen\t2026-12-31T00:00:00Z\t1/4 (25%)\n5\tlater\topen\t\t0/0\n4\tv0.9\tclosed\t\t3/3 (100%)\n", out.String())
run(true, "milestone", "view", "v1.0")
require.Equal(t, "v1.0 #3\nOpen • due Dec 31, 2026 (in about 2 months) • 1 of 4 closed (25%)\n\nFirst release\n\n"+
"View this milestone on Gitea: "+server.URL+"/alice/project/milestone/3\n", out.String())
run(true, "milestone", "close", "v1.0", "v0.9")
require.Equal(t, "✓ Closed milestone \"v1.0\" in alice/project\n! Milestone \"v0.9\" in alice/project is already closed\n", diagnostics.String())
run(true, "milestone", "issue", "add", "v1.0", "1", "#2")
require.Equal(t, "✓ Added #1, #2 to milestone \"v1.0\" in alice/project\n", diagnostics.String())
run(true, "milestone", "issue", "remove", "v0.9", "1")
require.Equal(t, "✓ Removed #1 from milestone \"v0.9\" in alice/project\n", diagnostics.String())
run(false, "milestone", "create", "v2.0")
require.Equal(t, server.URL+"/alice/project/milestone/3\n", out.String())
require.Equal(t, []string{
"GET /api/v1/repos/alice/project/milestones/3",
"PATCH /api/v1/repos/alice/project/milestones/3",
"PATCH /api/v1/repos/alice/project/issues/1",
"PATCH /api/v1/repos/alice/project/issues/2",
"PATCH /api/v1/repos/alice/project/issues/1",
"POST /api/v1/repos/alice/project/milestones",
}, changed)
}
func TestMilestoneEditFormStartsFromTheMilestone(t *testing.T) {
var edited map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.URL.Path {
case "GET /api/v1/repos/alice/project/milestones":
writeMilestones(w, r, `[{"id":3,"title":"v1.0","description":"First","due_on":"2026-12-31T00:00:00Z"}]`)
case "PATCH /api/v1/repos/alice/project/milestones/3":
require.NoError(t, json.NewDecoder(r.Body).Decode(&edited))
_, _ = w.Write([]byte(`{"id":3,"title":"v1.0.0"}`))
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
var heading string
var current terminal.MilestoneFormValues
deps.MilestoneForm = milestoneFormEditor(func(h string, c terminal.MilestoneFormValues) terminal.MilestoneFormValues {
heading, current = h, c
return terminal.MilestoneFormValues{Title: "v1.0.0", Description: c.Description}
})
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "edit", "v1.0", "-R", "alice/project"}))
require.Equal(t, "Editing milestone \"v1.0\" in alice/project", heading)
require.Equal(t, "First", current.Description)
require.Equal(t, "2026-12-31", current.Deadline.Format(time.DateOnly))
require.Equal(t, "v1.0.0", edited["title"])
require.Equal(t, "First", edited["description"])
}
type milestoneFormEditor func(string, terminal.MilestoneFormValues) terminal.MilestoneFormValues
func (f milestoneFormEditor) EditMilestone(_ context.Context, heading string, current terminal.MilestoneFormValues) (terminal.MilestoneFormValues, error) {
return f(heading, current), nil
}
+44 -39
View File
@@ -5,81 +5,86 @@ package app
import (
"errors"
"fmt"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"github.com/spf13/cobra"
)
// findMilestone resolves a milestone argument, a title or an ID, in the target repository.
func findMilestone(cmd *cobra.Command, t target, raw string, byName bool) (services.Milestone, error) {
if strings.TrimSpace(raw) == "" {
return services.Milestone{}, clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
}
return (services.Milestones{Client: t.client, Repository: t.Repo}).FindOne(cmd.Context(), raw, byName)
}
func (a *application) milestoneIssueCommand() *cobra.Command {
group := &cobra.Command{Use: "issue", Short: "Manage milestone issues and pull requests"}
opts := services.ListOptions{}
group := &cobra.Command{Use: "issue", Short: "Manage the issues and pull requests of a milestone", Long: `List the issues and pull requests in a milestone, or add them to it and remove them from
it. Milestones are addressed by title or ID; issues and pull requests by number or URL.`}
opts := services.ListOptions{Page: 1}
var byName bool
list := &cobra.Command{Use: "list <milestone>", Short: "List issues and pull requests in a milestone", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
return err
}
if err := oneOf("kind", opts.Kind, "all", "issues", "pulls"); err != nil {
return err
}
list := &cobra.Command{Use: "list <milestone>", Short: "List issues and pull requests in a milestone", Example: `$ tea milestone issue list v1.0
$ tea milestone issue list v1.0 --state all --kind issues`, Annotations: map[string]string{"tea.arguments": milestoneArguments}, Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
milestone, err := findMilestone(cmd, target, args[0], byName)
milestone, err := findMilestone(cmd, services.Milestones{Client: target.client, Repository: target.Repo}, args[0], byName)
if err != nil {
return err
}
opts.Milestones = strconv.FormatInt(milestone.ID, 10)
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
page, err := (services.Issues{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), opts)
if err != nil {
return err
}
if err := writeList(a, cmd, items, issueRow); err != nil {
return err
}
return a.morePagesHint(cmd, more, opts.Page)
where := fmt.Sprintf("in milestone %q of %s", milestone.Title, target.Repo)
return writeTable(a, cmd, page.Items, issueListSpec(where, opts.State, opts.Kind, false), servicePage(page, opts.Page))
}}
pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.State, "state", "open", "open, closed, or all")
list.Flags().StringVar(&opts.Kind, "kind", "all", "all, issues, or pulls")
enumFlag(list, &opts.State, "state", "s", "open", "Filter by state", "open", "closed", "all")
enumFlag(list, &opts.Kind, "kind", "", "all", "List issues, pull requests or both", "issues", "pulls", "all")
list.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
list.ValidArgsFunction = a.completeMilestoneArgs(false)
group.AddCommand(list)
for _, verb := range []string{"add", "remove"} {
var byName bool
command := &cobra.Command{Use: verb + " <milestone> <number>...", Short: titleCase(verb) + " issues or pull requests in a milestone", Args: usageArgs(cobra.MinimumNArgs(2)), RunE: func(cmd *cobra.Command, args []string) error {
numbers, err := resourceNumbers(args[1:])
short, preposition, past := "Add issues or pull requests to a milestone", "to", "Added"
if verb == "remove" {
short, preposition, past = "Remove issues or pull requests from a milestone", "from", "Removed"
}
command := &cobra.Command{Use: verb + " <milestone> {<number> | <url>}...", Short: short, Example: fmt.Sprintf(`$ tea milestone issue %[1]s v1.0 12
$ tea milestone issue %[1]s v1.0 12 13 '#14'`, verb), Annotations: map[string]string{"tea.arguments": milestoneArguments + "\n\n" + issueArguments}, Args: usageArgs(cobra.MinimumNArgs(2)), RunE: func(cmd *cobra.Command, args []string) error {
target, numbers, err := a.resolveRefs(cmd, args[1:], issueRef)
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
milestone, err := findMilestone(cmd, services.Milestones{Client: target.client, Repository: target.Repo}, args[0], byName)
if err != nil {
return err
}
milestone, err := findMilestone(cmd, target, args[0], byName)
if err != nil {
return err
}
id := milestone.ID
service := services.Issues{Client: target.client, Repository: target.Repo}
return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
results, failures := applyBatch(cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
if verb == "remove" {
return service.RemoveMilestone(cmd.Context(), n, id)
return service.RemoveMilestone(cmd.Context(), n, milestone)
}
return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &id})
}, issueRow)
return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &milestone.ID})
}, func(services.Issue) {})
if len(results) > 0 {
changed := make([]string, len(results))
for i, item := range results {
changed[i] = "#" + strconv.FormatInt(item.Number, 10)
}
a.done(cmd, "%s %s %s %s", past, strings.Join(changed, ", "), preposition, milestoneName(target, milestone))
}
if a.options.json != "" {
failures = append(failures, writeBatchJSON(a, cmd, len(numbers), results))
}
return errors.Join(failures...)
}}
command.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
command.Flags().BoolVar(&byName, "by-name", false, "Match a numeric milestone argument as a title rather than an ID")
milestones, issues := a.completeMilestones(), a.completeIssues("open")
command.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) == 0 {
return milestones(cmd, args, toComplete)
}
return issues(cmd, args, toComplete)
}
group.AddCommand(command)
}
return group
+1 -2
View File
@@ -171,7 +171,6 @@ func TestMilestonesAcceptTitlesOrIDs(t *testing.T) {
require.NoError(t, run("issue", "edit", "8", "--milestone", "0"))
require.Equal(t, []string{
"PATCH /api/v1/repos/alice/project/milestones/3",
"PATCH /api/v1/repos/alice/project/milestones/4",
"POST /api/v1/repos/alice/project/issues",
"PATCH /api/v1/repos/alice/project/issues/8 milestone=3",
"PATCH /api/v1/repos/alice/project/issues/8 milestone=0",
@@ -187,5 +186,5 @@ func TestMilestonesAcceptTitlesOrIDs(t *testing.T) {
} {
require.ErrorContains(t, run(test.args...), test.want)
}
require.Len(t, calls, 6)
require.Len(t, calls, 5)
}
+26 -9
View File
@@ -5,6 +5,7 @@ package app
import (
"errors"
"fmt"
"slices"
"strings"
@@ -13,13 +14,20 @@ import (
"github.com/spf13/cobra"
)
func (a *application) milestoneStateCommand(verb string, service func(*cobra.Command) (services.Milestones, error)) *cobra.Command {
// milestoneStateCommand closes or reopens milestones, reporting those already in that state instead
// of changing them.
func (a *application) milestoneStateCommand(verb string, service milestoneService) *cobra.Command {
var byName bool
cmd := &cobra.Command{Use: verb + " <milestone>...", Short: titleCase(verb) + " one or more milestones", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
state, past := "closed", "Closed"
if verb == "reopen" {
state, past = "open", "Reopened"
}
cmd := &cobra.Command{Use: verb + " <milestone>...", Short: titleCase(verb) + " milestones", 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 {
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"))
}
milestones, err := service(cmd)
milestones, t, err := service(cmd)
if err != nil {
return err
}
@@ -32,14 +40,23 @@ func (a *application) milestoneStateCommand(verb string, service func(*cobra.Com
return clierr.Wrap(clierr.Usage, errors.New("milestone "+args[i]+" is selected more than once"))
}
}
state := "closed"
if verb == "reopen" {
state = "open"
results, failures := applyBatch(cmd, selected, func(milestone services.Milestone) string { return "milestone " + milestone.Title }, func(milestone services.Milestone) (services.Milestone, error) {
if milestone.State == state {
a.warn(cmd, "%s is already %s", titleCase(milestoneName(t, milestone)), state)
return milestone, nil
}
item, err := milestones.Edit(cmd.Context(), milestone.ID, services.MilestoneOptions{State: &state})
if err == nil {
a.done(cmd, "%s %s", past, milestoneName(t, item))
}
return item, err
}, func(services.Milestone) {})
if a.options.json != "" {
failures = append(failures, writeBatchJSON(a, cmd, len(selected), results))
}
return batch(a, cmd, selected, func(milestone services.Milestone) string { return "milestone " + milestone.Title }, func(milestone services.Milestone) (services.Milestone, error) {
return milestones.Edit(cmd.Context(), milestone.ID, services.MilestoneOptions{State: &state})
}, milestoneRow)
return errors.Join(failures...)
}}
cmd.Flags().BoolVar(&byName, "by-name", false, "Match numeric arguments as milestone titles rather than IDs")
cmd.ValidArgsFunction = a.completeMilestoneArgs(true)
return cmd
}
+3 -3
View File
@@ -76,11 +76,11 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"milestone", "close", " ", "--by-name"}, "milestone title or ID cannot be empty"},
{[]string{"milestone", "create", "--title", "v1", "--deadline", "invalid"}, "invalid --deadline"},
{[]string{"milestone", "create", "--title", "x", "--state", "merged", "--no-input"}, "--state must be open or closed"},
{[]string{"milestone", "edit", "1"}, "supply milestone fields to edit"},
{[]string{"milestone", "edit", "1"}, "nothing to edit; pass a flag such as --title (see --help)"},
{[]string{"milestone", "edit", "9", "--title", ""}, "milestone title cannot be empty"},
{[]string{"milestone", "edit", "9", "--title", " \t "}, "milestone title cannot be empty"},
{[]string{"milestone", "issue", "add", "7", "1", "01", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"milestone", "issue", "remove", "7", "1", "1", "--no-input"}, "resource 1 is selected more than once"},
{[]string{"milestone", "issue", "add", "7", "1", "01", "--no-input"}, "#1 is selected more than once"},
{[]string{"milestone", "issue", "remove", "7", "1", "1", "--no-input"}, "#1 is selected more than once"},
{[]string{"milestone", "list", "--limit", "1001"}, `"-L, --limit" flag: must be 1..1000`},
{[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"},
{[]string{"notification", "list", "--status", "invalid"}, "--status must be unread, read, or pinned"},
+2 -2
View File
@@ -57,7 +57,7 @@ func TestRemoveMilestoneRejectsDifferentMembership(t *testing.T) {
_, _ = w.Write([]byte(`{"milestone":{"id":9}}`))
}))
service := Issues{Client: client, Repository: "alice/project"}
_, err := service.RemoveMilestone(context.Background(), 7, 8)
require.ErrorContains(t, err, "not assigned to milestone 8")
_, err := service.RemoveMilestone(context.Background(), 7, Milestone{ID: 8, Title: "v1.0"})
require.EqualError(t, err, `#7 is not in milestone "v1.0"`)
require.Zero(t, mutations)
}
+18 -3
View File
@@ -206,6 +206,21 @@ func (s Milestones) List(ctx context.Context, state string, limit, page int) ([]
return convertAll(milestones, milestoneFrom), nil
}
// ListPage is List with the number of milestones in state, as the server counts them.
func (s Milestones) ListPage(ctx context.Context, state string, limit, page int) (Page[Milestone], error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return Page[Milestone]{}, err
}
milestones, err := listPage(limit, page, func(limit, page int) ([]*gitea.Milestone, http.Header, error) {
milestones, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Milestone, *gitea.Response, error) {
return c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{ListOptions: listOptions(limit, page), State: gitea.StateType(state)})
})
return milestones, headerOf(resp), err
})
return Page[Milestone]{Items: convertAll(milestones.Items, milestoneFrom), More: milestones.More, Total: milestones.Total, Counted: milestones.Counted}, err
}
// Create creates one milestone.
func (s Milestones) Create(ctx context.Context, opts MilestoneOptions) (Milestone, error) {
owner, repo, err := splitRepository(s.Repository)
@@ -388,13 +403,13 @@ func mergeUnique[T any](current, add, remove []T, equal func(T, T) bool) []T {
}
// RemoveMilestone refuses to clear an issue belonging to another milestone.
func (s Issues) RemoveMilestone(ctx context.Context, n, id int64) (Issue, error) {
func (s Issues) RemoveMilestone(ctx context.Context, n int64, milestone Milestone) (Issue, error) {
current, err := s.View(ctx, n)
if err != nil {
return Issue{}, err
}
if current.Milestone == nil || current.Milestone.ID != id {
return Issue{}, fmt.Errorf("issue is not assigned to milestone %d", id)
if current.Milestone == nil || current.Milestone.ID != milestone.ID {
return Issue{}, fmt.Errorf("#%d is not in milestone %q", n, milestone.Title)
}
cleared := int64(0)
return s.Edit(ctx, n, IssueEdit{Milestone: &cleared})
+13 -8
View File
@@ -11,25 +11,30 @@ import (
"charm.land/huh/v2"
)
// MilestoneFormValues holds interactively collected milestone create fields.
// MilestoneFormValues holds interactively collected milestone fields.
type MilestoneFormValues struct {
Title, Description string
Deadline *time.Time
}
// MilestoneForm collects interactive milestone create input.
// MilestoneForm collects interactive milestone input.
type MilestoneForm interface {
CreateMilestone(context.Context) (MilestoneFormValues, error)
// EditMilestone asks for the fields of a milestone, starting from current, under heading, such
// as "Editing milestone v1.0". Empty values create a milestone.
EditMilestone(ctx context.Context, heading string, current MilestoneFormValues) (MilestoneFormValues, error)
}
// CreateMilestone runs the interactive milestone create form on stderr.
func (p *FormPrompter) CreateMilestone(ctx context.Context) (MilestoneFormValues, error) {
title, description, deadline := "", "", ""
// EditMilestone runs the interactive milestone form on stderr.
func (p *FormPrompter) EditMilestone(ctx context.Context, heading string, current MilestoneFormValues) (MilestoneFormValues, error) {
title, description, deadline := current.Title, current.Description, ""
if current.Deadline != nil {
deadline = current.Deadline.Format(time.DateOnly)
}
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),
bodyField("Milestone description (markdown):", &description),
dueDateInput("Milestone deadline:", &deadline),
))); err != nil {
).Title(heading))); err != nil {
return MilestoneFormValues{}, err
}
parsed, err := parseDueDate(deadline)
+13 -1
View File
@@ -5,14 +5,26 @@ package terminal
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestMilestoneFormMarksTitleRequired(t *testing.T) {
p, rendered := accessiblePrompter(t, "v1\n\n\n")
_, err := p.CreateMilestone(context.Background())
_, err := p.EditMilestone(context.Background(), "", MilestoneFormValues{})
require.NoError(t, err)
require.Contains(t, rendered.String(), "Milestone title (required):")
}
func TestMilestoneFormStartsFromCurrentValues(t *testing.T) {
due := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
p, _ := testPrompter(strings.NewReader("\r\r\r"))
values, err := p.EditMilestone(context.Background(), "Editing milestone v1.0", MilestoneFormValues{Title: "v1.0", Description: "First", Deadline: &due})
require.NoError(t, err)
require.Equal(t, "v1.0", values.Title)
require.Equal(t, "First", values.Description)
require.Equal(t, due, *values.Deadline)
}