diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d4ed9d19..46cf1fc0 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 ` diff --git a/internal/app/comments.go b/internal/app/comments.go index 982fc9f7..3732288b 100644 --- a/internal/app/comments.go +++ b/internal/app/comments.go @@ -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"` diff --git a/internal/app/human_output_test.go b/internal/app/human_output_test.go index 1f48d486..20cb98b8 100644 --- a/internal/app/human_output_test.go +++ b/internal/app/human_output_test.go @@ -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) { diff --git a/internal/app/issues.go b/internal/app/issues.go index 4f3443b0..870351c8 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -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, } } diff --git a/internal/app/metadata.go b/internal/app/metadata.go index 6c7756d1..df26599f 100644 --- a/internal/app/metadata.go +++ b/internal/app/metadata.go @@ -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 += " " - 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 ", 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 []", 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) + } } diff --git a/internal/app/milestone_create_form_test.go b/internal/app/milestone_create_form_test.go index 802553f3..4fcbd6c0 100644 --- a/internal/app/milestone_create_form_test.go +++ b/internal/app/milestone_create_form_test.go @@ -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 +} diff --git a/internal/app/milestone_issues.go b/internal/app/milestone_issues.go index cd705fab..8e27e377 100644 --- a/internal/app/milestone_issues.go +++ b/internal/app/milestone_issues.go @@ -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 diff --git a/internal/app/milestone_names_test.go b/internal/app/milestone_names_test.go index 09561890..a43bec2e 100644 --- a/internal/app/milestone_names_test.go +++ b/internal/app/milestone_names_test.go @@ -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) } diff --git a/internal/app/milestone_state.go b/internal/app/milestone_state.go index b1a18fa1..45777e84 100644 --- a/internal/app/milestone_state.go +++ b/internal/app/milestone_state.go @@ -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 } diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 27051904..b4e1420e 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -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"}, diff --git a/internal/services/comments_test.go b/internal/services/comments_test.go index d4b3aef2..7c9d93eb 100644 --- a/internal/services/comments_test.go +++ b/internal/services/comments_test.go @@ -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) } diff --git a/internal/services/metadata.go b/internal/services/metadata.go index af7eb7c0..a130b39b 100644 --- a/internal/services/metadata.go +++ b/internal/services/metadata.go @@ -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}) diff --git a/internal/terminal/milestone_form.go b/internal/terminal/milestone_form.go index 4933b63d..4d95a362 100644 --- a/internal/terminal/milestone_form.go +++ b/internal/terminal/milestone_form.go @@ -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) diff --git a/internal/terminal/milestone_form_test.go b/internal/terminal/milestone_form_test.go index b2b22249..367209f7 100644 --- a/internal/terminal/milestone_form_test.go +++ b/internal/terminal/milestone_form_test.go @@ -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) +}