mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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:
14 files changed
+606
-257
No files matched your search
@@ -424,16 +424,6 @@ const (
|
|||||||
issue attachment list
|
issue attachment list
|
||||||
issue attachment upload
|
issue attachment upload
|
||||||
issue attachment view
|
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 delete
|
||||||
pr attachment download
|
pr attachment download
|
||||||
pr attachment edit
|
pr attachment edit
|
||||||
@@ -449,8 +439,6 @@ const (
|
|||||||
`
|
`
|
||||||
longsToWrite = `
|
longsToWrite = `
|
||||||
issue attachment
|
issue attachment
|
||||||
milestone
|
|
||||||
milestone issue
|
|
||||||
pr attachment
|
pr attachment
|
||||||
time
|
time
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ type commentNouns struct {
|
|||||||
|
|
||||||
func commentWords(kind refKind) commentNouns {
|
func commentWords(kind refKind) commentNouns {
|
||||||
if kind == pullRef {
|
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"}
|
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.
|
// 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 number, e.g. "13" or "#13"
|
||||||
- by URL, e.g. "https://gitea.com/OWNER/REPO/pulls/13"
|
- by URL, e.g. "https://gitea.com/OWNER/REPO/pulls/13"
|
||||||
- by repository and number, e.g. "OWNER/REPO#13"`
|
- by repository and number, e.g. "OWNER/REPO#13"`
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
|
|||||||
want string
|
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{"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"},
|
{[]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) {
|
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ $ tea issue list --search "login" --web`, Args: argsExact(0), RunE: func(cmd *co
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.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")
|
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()
|
return strings.TrimRight(t.Host, "/") + "/" + t.Repo + "/" + section + "?" + query.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueListSpec describes issue tables: STATE shows on a terminal unless only open issues are
|
// issueListSpec describes issue tables of the issues where, such as "in alice/x": STATE shows on a
|
||||||
// listed, and KIND when issues and pull requests are; piped tables always have both.
|
// terminal unless only open issues are listed, and KIND when issues and pull requests are; piped
|
||||||
func issueListSpec(repo, state, kind string, filtered bool) listSpec[services.Issue] {
|
// 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]
|
what := map[string]string{"issues": "issues", "pulls": "pull requests", "all": "issues and pull requests"}[kind]
|
||||||
if state != "all" {
|
if state != "all" {
|
||||||
what = state + " " + what
|
what = state + " " + what
|
||||||
@@ -134,8 +135,8 @@ func issueListSpec(repo, state, kind string, filtered bool) listSpec[services.Is
|
|||||||
{Header: "Updated"},
|
{Header: "Updated"},
|
||||||
},
|
},
|
||||||
Row: issueRow,
|
Row: issueRow,
|
||||||
Empty: empty + " in " + repo,
|
Empty: empty + " " + where,
|
||||||
Context: what + " in " + repo,
|
Context: what + " " + where,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+379
-167
@@ -4,7 +4,10 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,176 +18,385 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *application) milestoneCommand() *cobra.Command {
|
// milestoneArguments is the ARGUMENTS help of commands that take milestones.
|
||||||
group := &cobra.Command{Use: "milestone", Short: "Manage repository milestones"}
|
const milestoneArguments = `A milestone can be supplied by its title, such as "v1.0", or by its ID. A number is an ID
|
||||||
service := func(cmd *cobra.Command) (services.Milestones, error) {
|
first, then a title; --by-name matches it as a title only.`
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if verb == "edit" && opts.Title == nil && !descriptionChanged && !useEditor && opts.Deadline == nil {
|
// milestoneService returns the milestones of the repository a milestone command targets.
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("supply milestone fields to edit"))
|
type milestoneService func(*cobra.Command) (services.Milestones, target, error)
|
||||||
}
|
|
||||||
s, err := service(cmd)
|
func (a *application) milestoneCommand() *cobra.Command {
|
||||||
if err != nil {
|
group := &cobra.Command{Use: "milestone", Short: "Manage milestones", Long: `Work with the milestones of a repository and the issues and pull requests in them.
|
||||||
return err
|
Milestones are addressed by title or ID.`}
|
||||||
}
|
service := func(cmd *cobra.Command) (services.Milestones, target, error) {
|
||||||
var current services.Milestone
|
t, err := a.resolve(cmd.Context(), true)
|
||||||
if verb != "create" {
|
return services.Milestones{Client: t.client, Repository: t.Repo}, t, err
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
group.AddCommand(a.milestoneStateCommand("close", service), a.milestoneStateCommand("reopen", service))
|
group.AddCommand(a.milestoneListCommand(service), a.milestoneViewCommand(service), a.milestoneCreateCommand(service), a.milestoneEditCommand(service),
|
||||||
group.AddCommand(a.milestoneIssueCommand())
|
a.milestoneDeleteCommand(service), a.milestoneStateCommand("close", service), a.milestoneStateCommand("reopen", service), a.milestoneIssueCommand())
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
func milestoneRow(item services.Milestone) []terminal.Cell {
|
func (a *application) milestoneListCommand(service milestoneService) *cobra.Command {
|
||||||
return []terminal.Cell{{Text: strconv.FormatInt(item.ID, 10)}, {Text: item.Title}, {Text: item.State, Color: stateColors[item.State]}}
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,7 +18,7 @@ import (
|
|||||||
|
|
||||||
type milestoneFormFunc func(context.Context) (terminal.MilestoneFormValues, error)
|
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)
|
return f(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,3 +68,96 @@ func TestMilestoneCreateState(t *testing.T) {
|
|||||||
require.Equal(t, "closed", payload["state"])
|
require.Equal(t, "closed", payload["state"])
|
||||||
require.JSONEq(t, `{"id":9,"state":"closed"}`, out.String())
|
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
|
||||||
|
}
|
||||||
@@ -5,81 +5,86 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
|
||||||
"gitea.dev/tea/internal/services"
|
"gitea.dev/tea/internal/services"
|
||||||
"github.com/spf13/cobra"
|
"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 {
|
func (a *application) milestoneIssueCommand() *cobra.Command {
|
||||||
group := &cobra.Command{Use: "issue", Short: "Manage milestone issues and pull requests"}
|
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
|
||||||
opts := services.ListOptions{}
|
it. Milestones are addressed by title or ID; issues and pull requests by number or URL.`}
|
||||||
|
opts := services.ListOptions{Page: 1}
|
||||||
var byName bool
|
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 {
|
list := &cobra.Command{Use: "list <milestone>", Short: "List issues and pull requests in a milestone", Example: `$ tea milestone issue list v1.0
|
||||||
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
|
$ 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 {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := oneOf("kind", opts.Kind, "all", "issues", "pulls"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
target, err := a.resolve(cmd.Context(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
opts.Milestones = strconv.FormatInt(milestone.ID, 10)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeList(a, cmd, items, issueRow); err != nil {
|
where := fmt.Sprintf("in milestone %q of %s", milestone.Title, target.Repo)
|
||||||
return err
|
return writeTable(a, cmd, page.Items, issueListSpec(where, opts.State, opts.Kind, false), servicePage(page, opts.Page))
|
||||||
}
|
|
||||||
return a.morePagesHint(cmd, more, opts.Page)
|
|
||||||
}}
|
}}
|
||||||
pageFlags(list, &opts.Limit, &opts.Page)
|
pageFlags(list, &opts.Limit, &opts.Page)
|
||||||
list.Flags().StringVar(&opts.State, "state", "open", "open, closed, or all")
|
enumFlag(list, &opts.State, "state", "s", "open", "Filter by state", "open", "closed", "all")
|
||||||
list.Flags().StringVar(&opts.Kind, "kind", "all", "all, issues, or pulls")
|
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.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)
|
group.AddCommand(list)
|
||||||
for _, verb := range []string{"add", "remove"} {
|
for _, verb := range []string{"add", "remove"} {
|
||||||
var byName bool
|
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 {
|
short, preposition, past := "Add issues or pull requests to a milestone", "to", "Added"
|
||||||
numbers, err := resourceNumbers(args[1:])
|
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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
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}
|
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" {
|
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})
|
return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &milestone.ID})
|
||||||
}, issueRow)
|
}, 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)
|
group.AddCommand(command)
|
||||||
}
|
}
|
||||||
return group
|
return group
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ func TestMilestonesAcceptTitlesOrIDs(t *testing.T) {
|
|||||||
require.NoError(t, run("issue", "edit", "8", "--milestone", "0"))
|
require.NoError(t, run("issue", "edit", "8", "--milestone", "0"))
|
||||||
require.Equal(t, []string{
|
require.Equal(t, []string{
|
||||||
"PATCH /api/v1/repos/alice/project/milestones/3",
|
"PATCH /api/v1/repos/alice/project/milestones/3",
|
||||||
"PATCH /api/v1/repos/alice/project/milestones/4",
|
|
||||||
"POST /api/v1/repos/alice/project/issues",
|
"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=3",
|
||||||
"PATCH /api/v1/repos/alice/project/issues/8 milestone=0",
|
"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.ErrorContains(t, run(test.args...), test.want)
|
||||||
}
|
}
|
||||||
require.Len(t, calls, 6)
|
require.Len(t, calls, 5)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -13,13 +14,20 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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
|
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) == "" }) {
|
if slices.ContainsFunc(args, func(arg string) bool { return strings.TrimSpace(arg) == "" }) {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
|
return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
|
||||||
}
|
}
|
||||||
milestones, err := service(cmd)
|
milestones, t, err := service(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"))
|
return clierr.Wrap(clierr.Usage, errors.New("milestone "+args[i]+" is selected more than once"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state := "closed"
|
results, failures := applyBatch(cmd, selected, func(milestone services.Milestone) string { return "milestone " + milestone.Title }, func(milestone services.Milestone) (services.Milestone, error) {
|
||||||
if verb == "reopen" {
|
if milestone.State == state {
|
||||||
state = "open"
|
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 errors.Join(failures...)
|
||||||
return milestones.Edit(cmd.Context(), milestone.ID, services.MilestoneOptions{State: &state})
|
|
||||||
}, milestoneRow)
|
|
||||||
}}
|
}}
|
||||||
cmd.Flags().BoolVar(&byName, "by-name", false, "Match numeric arguments as milestone titles rather than IDs")
|
cmd.Flags().BoolVar(&byName, "by-name", false, "Match numeric arguments as milestone titles rather than IDs")
|
||||||
|
cmd.ValidArgsFunction = a.completeMilestoneArgs(true)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -76,11 +76,11 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
|
|||||||
{[]string{"milestone", "close", " ", "--by-name"}, "milestone title or ID cannot be empty"},
|
{[]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", "v1", "--deadline", "invalid"}, "invalid --deadline"},
|
||||||
{[]string{"milestone", "create", "--title", "x", "--state", "merged", "--no-input"}, "--state must be open or closed"},
|
{[]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", ""}, "milestone title cannot be empty"},
|
||||||
{[]string{"milestone", "edit", "9", "--title", " \t "}, "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", "add", "7", "1", "01", "--no-input"}, "#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", "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", "--limit", "1001"}, `"-L, --limit" flag: must be 1..1000`},
|
||||||
{[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"},
|
{[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"},
|
||||||
{[]string{"notification", "list", "--status", "invalid"}, "--status must be unread, read, or pinned"},
|
{[]string{"notification", "list", "--status", "invalid"}, "--status must be unread, read, or pinned"},
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestRemoveMilestoneRejectsDifferentMembership(t *testing.T) {
|
|||||||
_, _ = w.Write([]byte(`{"milestone":{"id":9}}`))
|
_, _ = w.Write([]byte(`{"milestone":{"id":9}}`))
|
||||||
}))
|
}))
|
||||||
service := Issues{Client: client, Repository: "alice/project"}
|
service := Issues{Client: client, Repository: "alice/project"}
|
||||||
_, err := service.RemoveMilestone(context.Background(), 7, 8)
|
_, err := service.RemoveMilestone(context.Background(), 7, Milestone{ID: 8, Title: "v1.0"})
|
||||||
require.ErrorContains(t, err, "not assigned to milestone 8")
|
require.EqualError(t, err, `#7 is not in milestone "v1.0"`)
|
||||||
require.Zero(t, mutations)
|
require.Zero(t, mutations)
|
||||||
}
|
}
|
||||||
@@ -206,6 +206,21 @@ func (s Milestones) List(ctx context.Context, state string, limit, page int) ([]
|
|||||||
return convertAll(milestones, milestoneFrom), nil
|
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.
|
// Create creates one milestone.
|
||||||
func (s Milestones) Create(ctx context.Context, opts MilestoneOptions) (Milestone, error) {
|
func (s Milestones) Create(ctx context.Context, opts MilestoneOptions) (Milestone, error) {
|
||||||
owner, repo, err := splitRepository(s.Repository)
|
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.
|
// 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)
|
current, err := s.View(ctx, n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Issue{}, err
|
return Issue{}, err
|
||||||
}
|
}
|
||||||
if current.Milestone == nil || current.Milestone.ID != id {
|
if current.Milestone == nil || current.Milestone.ID != milestone.ID {
|
||||||
return Issue{}, fmt.Errorf("issue is not assigned to milestone %d", id)
|
return Issue{}, fmt.Errorf("#%d is not in milestone %q", n, milestone.Title)
|
||||||
}
|
}
|
||||||
cleared := int64(0)
|
cleared := int64(0)
|
||||||
return s.Edit(ctx, n, IssueEdit{Milestone: &cleared})
|
return s.Edit(ctx, n, IssueEdit{Milestone: &cleared})
|
||||||
|
|||||||
@@ -11,25 +11,30 @@ import (
|
|||||||
"charm.land/huh/v2"
|
"charm.land/huh/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MilestoneFormValues holds interactively collected milestone create fields.
|
// MilestoneFormValues holds interactively collected milestone fields.
|
||||||
type MilestoneFormValues struct {
|
type MilestoneFormValues struct {
|
||||||
Title, Description string
|
Title, Description string
|
||||||
Deadline *time.Time
|
Deadline *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// MilestoneForm collects interactive milestone create input.
|
// MilestoneForm collects interactive milestone input.
|
||||||
type MilestoneForm interface {
|
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.
|
// EditMilestone runs the interactive milestone form on stderr.
|
||||||
func (p *FormPrompter) CreateMilestone(ctx context.Context) (MilestoneFormValues, error) {
|
func (p *FormPrompter) EditMilestone(ctx context.Context, heading string, current MilestoneFormValues) (MilestoneFormValues, error) {
|
||||||
title, description, deadline := "", "", ""
|
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(
|
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
|
||||||
huh.NewInput().Title("Milestone title (required):").Value(&title).Validate(huh.ValidateNotEmpty()),
|
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),
|
dueDateInput("Milestone deadline:", &deadline),
|
||||||
))); err != nil {
|
).Title(heading))); err != nil {
|
||||||
return MilestoneFormValues{}, err
|
return MilestoneFormValues{}, err
|
||||||
}
|
}
|
||||||
parsed, err := parseDueDate(deadline)
|
parsed, err := parseDueDate(deadline)
|
||||||
|
|||||||
@@ -5,14 +5,26 @@ package terminal
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMilestoneFormMarksTitleRequired(t *testing.T) {
|
func TestMilestoneFormMarksTitleRequired(t *testing.T) {
|
||||||
p, rendered := accessiblePrompter(t, "v1\n\n\n")
|
p, rendered := accessiblePrompter(t, "v1\n\n\n")
|
||||||
_, err := p.CreateMilestone(context.Background())
|
_, err := p.EditMilestone(context.Background(), "", MilestoneFormValues{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Contains(t, rendered.String(), "Milestone title (required):")
|
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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user