fix(milestone): address milestones by title or ID everywhere

milestone view v2.0 failed with "invalid resource number" although its
usage promised <id-or-name>, and issue create --milestone v1.0 failed
with a strconv error. Every milestone argument (view, edit, delete,
close, reopen, milestone issue add|remove|list) and issue create|edit
-m/--milestone now accept a title or an ID: a number is an ID first,
then a title; --by-name matches numeric titles only. --milestone-name
stays as a hidden name of --milestone, and issue edit gains
--remove-milestone (as does --milestone 0).

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:14:30 +02:00
1 parent 0d013746b5
commit 1035ff551b
12 files changed
+220 -147

No files matched your search

+2 -2
View File
@@ -151,8 +151,8 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
} }
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/repos/alice/project/milestones/3": case "/api/v1/repos/alice/project/milestones":
_, _ = w.Write([]byte(`{"id":3,"title":"v1"}`)) writeMilestones(w, r, `[{"id":3,"title":"v1"}]`)
case "/api/v1/repos/alice/project/issues": case "/api/v1/repos/alice/project/issues":
_, _ = w.Write([]byte("[" + issue(1) + "," + issue(2) + "]")) _, _ = w.Write([]byte("[" + issue(1) + "," + issue(2) + "]"))
case "/api/v1/repos/alice/project/issues/1", "/api/v1/repos/alice/project/issues/2": case "/api/v1/repos/alice/project/issues/1", "/api/v1/repos/alice/project/issues/2":
+14 -12
View File
@@ -20,8 +20,8 @@ func (a *application) issueEditCommand() *cobra.Command {
var useEditor bool var useEditor bool
var deadline string var deadline string
var ref string var ref string
var milestone int64 var milestone string
var milestoneName string var removeMilestone bool
var metadata metadataFlags var metadata metadataFlags
cmd := &cobra.Command{Use: "edit <number>...", Short: "Edit issue content, assignees, and milestone", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) { cmd := &cobra.Command{Use: "edit <number>...", Short: "Edit issue content, assignees, and milestone", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) (runErr error) {
var draft terminal.Draft var draft terminal.Draft
@@ -46,10 +46,14 @@ func (a *application) issueEditCommand() *cobra.Command {
if useEditor && len(numbers) != 1 { if useEditor && len(numbers) != 1 {
return clierr.Wrap(clierr.Usage, errors.New("--editor requires one issue")) return clierr.Wrap(clierr.Usage, errors.New("--editor requires one issue"))
} }
if err := validateMilestoneFlags(cmd, milestoneName, milestone); err != nil { milestone, removeMilestone, err := milestoneFlag(cmd, milestone)
if err != nil {
return err return err
} }
opts := services.IssueEdit{} opts := services.IssueEdit{}
if removeMilestone {
opts.Milestone = new(int64)
}
if cmd.Flags().Changed("ref") { if cmd.Flags().Changed("ref") {
opts.Ref = &ref opts.Ref = &ref
} }
@@ -72,25 +76,22 @@ func (a *application) issueEditCommand() *cobra.Command {
} }
opts.Body = &body opts.Body = &body
} }
if cmd.Flags().Changed("milestone") {
opts.Milestone = &milestone
}
if err := metadata.validate(cmd); err != nil { if err := metadata.validate(cmd); err != nil {
return err return err
} }
if opts == (services.IssueEdit{}) && !useEditor && !metadata.changed(cmd) && !cmd.Flags().Changed("milestone-name") { if opts == (services.IssueEdit{}) && !useEditor && !metadata.changed(cmd) && milestone == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea issue edit --help")) return clierr.Wrap(clierr.Usage, errors.New("supply an edit flag; see tea issue edit --help"))
} }
target, err := a.resolve(cmd.Context(), true) target, err := a.resolve(cmd.Context(), true)
if err != nil { if err != nil {
return err return err
} }
if cmd.Flags().Changed("milestone-name") { if milestone != "" {
milestone, err = (services.Milestones{Client: target.client, Repository: target.Repo}).ResolveName(cmd.Context(), milestoneName) id, err := milestoneID(cmd, target, milestone)
if err != nil { if err != nil {
return err return err
} }
opts.Milestone = &milestone opts.Milestone = &id
} }
if opts.Labels, opts.Assignees, opts.Changes, err = metadata.resolve(cmd, target); err != nil { if opts.Labels, opts.Assignees, opts.Changes, err = metadata.resolve(cmd, target); err != nil {
return err return err
@@ -117,8 +118,9 @@ func (a *application) issueEditCommand() *cobra.Command {
cmd.Flags().StringVar(&body, "body", "", "Issue body (empty clears)") cmd.Flags().StringVar(&body, "body", "", "Issue body (empty clears)")
cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin") cmd.Flags().StringVar(&file, "body-file", "", "Read body from file, or - for stdin")
cmd.Flags().BoolVar(&useEditor, "editor", false, "Edit the current issue body in the configured editor") cmd.Flags().BoolVar(&useEditor, "editor", false, "Edit the current issue body in the configured editor")
cmd.Flags().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone") cmd.Flags().StringVarP(&milestone, "milestone", "m", "", "Move the issue to a milestone by `title` or ID")
cmd.Flags().Int64Var(&milestone, "milestone", 0, "Milestone ID; 0 clears") cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the issue from its milestone")
flagAliases(cmd, map[string]string{"milestone-name": "milestone"})
metadata.register(cmd) metadata.register(cmd)
return cmd return cmd
} }
+4
View File
@@ -16,6 +16,10 @@ import (
func TestIssueCreationMetadataAndReferenceClearing(t *testing.T) { func TestIssueCreationMetadataAndReferenceClearing(t *testing.T) {
calls := 0 calls := 0
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/repos/owner/repo/milestones" {
writeMilestones(w, r, `[{"id":4,"title":"Release"}]`)
return
}
calls++ calls++
if calls == 1 { if calls == 1 {
require.Equal(t, http.MethodPost, r.Method) require.Equal(t, http.MethodPost, r.Method)
+34 -10
View File
@@ -80,7 +80,31 @@ func issueTemplate(ctx context.Context, service services.Issues, name string) (s
return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; available: %s", name, strings.Join(names, ", ")) return services.IssueTemplate{}, fmt.Errorf("issue template %q does not exist; available: %s", name, strings.Join(names, ", "))
} }
// validateMilestoneFlags checks --milestone and --milestone-name before any request. // milestoneFlag reads -m/--milestone, a milestone title or ID, before any request: value is
// "" when the flag is absent, and remove reports 0 or --remove-milestone, which clear it.
func milestoneFlag(cmd *cobra.Command, value string) (selected string, remove bool, err error) {
remove, _ = cmd.Flags().GetBool("remove-milestone")
switch {
case !cmd.Flags().Changed("milestone"):
return "", remove, nil
case remove:
return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone and --remove-milestone cannot be combined"))
case strings.TrimSpace(value) == "":
return "", false, clierr.Wrap(clierr.Usage, errors.New("--milestone needs a title or ID; use --remove-milestone to clear it"))
case strings.TrimSpace(value) == "0":
return "", true, nil
}
return strings.TrimSpace(value), false, nil
}
// milestoneID resolves a milestone title or ID in the target repository.
func milestoneID(cmd *cobra.Command, t target, raw string) (int64, error) {
milestone, err := (services.Milestones{Client: t.client, Repository: t.Repo}).FindOne(cmd.Context(), raw, false)
return milestone.ID, err
}
// validateMilestoneFlags checks the --milestone ID and --milestone-name flags of pr create and pr
// edit before any request.
func validateMilestoneFlags(cmd *cobra.Command, name string, id int64) error { func validateMilestoneFlags(cmd *cobra.Command, name string, id int64) error {
if cmd.Flags().Changed("milestone-name") { if cmd.Flags().Changed("milestone-name") {
if cmd.Flags().Changed("milestone") { if cmd.Flags().Changed("milestone") {
@@ -301,15 +325,15 @@ func (a *application) issueCommand() *cobra.Command {
var useEditor bool var useEditor bool
var ref string var ref string
var assignees, labelNames, attachmentPaths []string var assignees, labelNames, attachmentPaths []string
var milestone int64 var milestone string
var milestoneName string
create := &cobra.Command{Use: "create", Short: "Create a repository issue", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) { create := &cobra.Command{Use: "create", Short: "Create a repository issue", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var draft terminal.Draft var draft terminal.Draft
defer finishDraft(&draft, &runErr) defer finishDraft(&draft, &runErr)
if err := validateBodySources(cmd); err != nil { if err := validateBodySources(cmd); err != nil {
return err return err
} }
if err := validateMilestoneFlags(cmd, milestoneName, milestone); err != nil { milestone, _, err := milestoneFlag(cmd, milestone)
if err != nil {
return err return err
} }
if err := requireNames("label", "", labelNames); err != nil { if err := requireNames("label", "", labelNames); err != nil {
@@ -381,9 +405,9 @@ func (a *application) issueCommand() *cobra.Command {
return err return err
} }
} }
if cmd.Flags().Changed("milestone-name") { var milestoneValue int64
milestone, err = (services.Milestones{Client: target.client, Repository: target.Repo}).ResolveName(cmd.Context(), milestoneName) if milestone != "" {
if err != nil { if milestoneValue, err = milestoneID(cmd, target, milestone); err != nil {
return err return err
} }
} }
@@ -397,7 +421,7 @@ func (a *application) issueCommand() *cobra.Command {
} }
} }
service := services.Issues{Client: target.client, Repository: target.Repo} service := services.Issues{Client: target.client, Repository: target.Repo}
item, err := service.Create(cmd.Context(), services.IssueOptions{Title: title, Body: body, Deadline: due, Ref: ref, Assignees: assignees, Milestone: milestone, Labels: labelIDs}) item, err := service.Create(cmd.Context(), services.IssueOptions{Title: title, Body: body, Deadline: due, Ref: ref, Assignees: assignees, Milestone: milestoneValue, Labels: labelIDs})
if err != nil { if err != nil {
return err return err
} }
@@ -416,8 +440,8 @@ func (a *application) issueCommand() *cobra.Command {
create.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag") create.Flags().StringVar(&ref, "ref", "", "Referenced branch or tag")
create.Flags().StringSliceVar(&labelNames, "label", nil, "Apply labels by exact name; use repo: or org: to disambiguate") create.Flags().StringSliceVar(&labelNames, "label", nil, "Apply labels by exact name; use repo: or org: to disambiguate")
create.Flags().StringSliceVar(&assignees, "assignee", nil, "Assign users by username") create.Flags().StringSliceVar(&assignees, "assignee", nil, "Assign users by username")
create.Flags().StringVar(&milestoneName, "milestone-name", "", "Milestone by exact title; conflicts with --milestone") create.Flags().StringVarP(&milestone, "milestone", "m", "", "Add the issue to a milestone by `title` or ID")
create.Flags().Int64Var(&milestone, "milestone", 0, "Milestone ID") flagAliases(create, map[string]string{"milestone-name": "milestone"})
create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp") create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp")
create.Flags().StringVar(&body, "body", "", "Issue body") create.Flags().StringVar(&body, "body", "", "Issue body")
create.Flags().StringVar(&bodyFile, "body-file", "", "Read body from a file, or - for stdin") create.Flags().StringVar(&bodyFile, "body-file", "", "Read body from a file, or - for stdin")
+16 -31
View File
@@ -205,8 +205,8 @@ func (a *application) milestoneCommand() *cobra.Command {
if verb == "create" { if verb == "create" {
command.Args = argsExact(0) command.Args = argsExact(0)
} else { } else {
command.Use += " <id-or-name>" command.Use += " <milestone>"
command.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone title instead of ID") command.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
command.Args = argsExact(1) command.Args = argsExact(1)
} }
if verb == "create" || verb == "edit" { if verb == "create" || verb == "edit" {
@@ -228,12 +228,8 @@ func (a *application) milestoneCommand() *cobra.Command {
} }
} }
opts := services.MilestoneOptions{} opts := services.MilestoneOptions{}
var id int64 if verb != "create" && strings.TrimSpace(args[0]) == "" {
if verb != "create" { return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
var err error
if id, err = milestoneSelector(args[0], byName); err != nil {
return err
}
} }
var interactiveDeadline *time.Time var interactiveDeadline *time.Time
descriptionChanged := false descriptionChanged := false
@@ -297,38 +293,27 @@ func (a *application) milestoneCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
if byName { var current services.Milestone
id, err = s.ResolveName(cmd.Context(), args[0]) if verb != "create" {
if err != nil { if current, err = s.FindOne(cmd.Context(), args[0], byName); err != nil {
return err return err
} }
} }
id := current.ID
if verb == "delete" { if verb == "delete" {
if err := a.confirm(cmd, "Delete milestone "+args[0]); err != nil { if err := a.confirm(cmd, "Delete milestone "+args[0]); err != nil {
return err return err
} }
if a.options.json != "" { if err := s.Delete(cmd.Context(), id); err != nil {
item, err := s.View(cmd.Context(), id) return err
if err != nil {
return err
}
if err := s.Delete(cmd.Context(), id); err != nil {
return err
}
return writeItem(a, cmd, item, milestoneRow)
} }
return s.Delete(cmd.Context(), id) if a.options.json != "" {
return writeItem(a, cmd, current, milestoneRow)
}
return nil
} }
if useEditor { if useEditor {
initial := "" editorDraft, err = a.editDraft(cmd, current.Description)
if verb == "edit" {
current, viewErr := s.View(cmd.Context(), id)
if viewErr != nil {
return viewErr
}
initial = current.Description
}
editorDraft, err = a.editDraft(cmd, initial)
if err != nil { if err != nil {
return err return err
} }
@@ -340,7 +325,7 @@ func (a *application) milestoneCommand() *cobra.Command {
} }
var item services.Milestone var item services.Milestone
if verb == "view" { if verb == "view" {
item, err = s.View(cmd.Context(), id) item = current
} else if verb == "create" { } else if verb == "create" {
item, err = s.Create(cmd.Context(), opts) item, err = s.Create(cmd.Context(), opts)
} else { } else {
+7 -3
View File
@@ -17,9 +17,13 @@ import (
func TestBulkMilestoneStateContinuesAfterFailureAndKeepsJSONClean(t *testing.T) { func TestBulkMilestoneStateContinuesAfterFailureAndKeepsJSONClean(t *testing.T) {
mutations := []string{} mutations := []string{}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/repos/alice/project/milestones" {
writeMilestones(w, r, `[{"id":9,"title":"v9"},{"id":10,"title":"v10"},{"id":11,"title":"v11"}]`)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/project/milestones/") id := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/alice/project/milestones/")
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
_, _ = fmt.Fprintf(w, `{"id":%s,"title":"Milestone %s"}`, id, id) _, _ = fmt.Fprintf(w, `{"id":%s,"title":"v%s"}`, id, id)
return return
} }
mutations = append(mutations, id) mutations = append(mutations, id)
@@ -32,7 +36,7 @@ func TestBulkMilestoneStateContinuesAfterFailureAndKeepsJSONClean(t *testing.T)
defer server.Close() defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL) deps, out, diagnostics := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"milestone", "close", "9", "10", "11", "-R", "alice/project", "--json=id,state"}) err := Execute(context.Background(), deps, []string{"milestone", "close", "9", "10", "11", "-R", "alice/project", "--json=id,state"})
require.ErrorContains(t, err, "milestone 10") require.ErrorContains(t, err, "milestone v10")
require.Equal(t, clierr.Failure, clierr.ExitCode(err)) require.Equal(t, clierr.Failure, clierr.ExitCode(err))
require.Equal(t, []string{"9", "10", "11"}, mutations) require.Equal(t, []string{"9", "10", "11"}, mutations)
require.JSONEq(t, `[{"id":9,"state":"closed"},{"id":11,"state":"closed"}]`, out.String()) require.JSONEq(t, `[{"id":9,"state":"closed"},{"id":11,"state":"closed"}]`, out.String())
@@ -40,7 +44,7 @@ func TestBulkMilestoneStateContinuesAfterFailureAndKeepsJSONClean(t *testing.T)
mutations = nil mutations = nil
out.Reset() out.Reset()
err = Execute(context.Background(), deps, []string{"milestone", "close", "9", "bad", "-R", "alice/project"}) err = Execute(context.Background(), deps, []string{"milestone", "close", "9", "bad", "-R", "alice/project"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err)) require.ErrorContains(t, err, `milestone "bad" not found in alice/project`)
require.Empty(t, mutations) require.Empty(t, mutations)
require.Empty(t, out.String()) require.Empty(t, out.String())
} }
@@ -43,6 +43,8 @@ func TestMilestoneCreateDescriptionFileAndEditor(t *testing.T) {
func TestMilestoneEditEditorSeedsDescription(t *testing.T) { func TestMilestoneEditEditorSeedsDescription(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/milestones":
writeMilestones(w, r, `[{"id":4,"title":"Ship","description":"current notes","state":"open"}]`)
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/milestones/4": case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/alice/project/milestones/4":
_, _ = w.Write([]byte(`{"id":4,"title":"Ship","description":"current notes","state":"open"}`)) _, _ = w.Write([]byte(`{"id":4,"title":"Ship","description":"current notes","state":"open"}`))
case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/alice/project/milestones/4": case r.Method == http.MethodPatch && r.URL.Path == "/api/v1/repos/alice/project/milestones/4":
+13 -33
View File
@@ -4,7 +4,6 @@
package app package app
import ( import (
"context"
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
@@ -14,35 +13,19 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// milestoneSelector validates a milestone ID or, with byName, a title before any request. // findMilestone resolves a milestone argument, a title or an ID, in the target repository.
func milestoneSelector(selector string, byName bool) (int64, error) { func findMilestone(cmd *cobra.Command, t target, raw string, byName bool) (services.Milestone, error) {
if byName { if strings.TrimSpace(raw) == "" {
if strings.TrimSpace(selector) == "" { return services.Milestone{}, clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
return 0, clierr.Wrap(clierr.Usage, errors.New("milestone name cannot be empty"))
}
return 0, nil
} }
return number(selector) return (services.Milestones{Client: t.client, Repository: t.Repo}).FindOne(cmd.Context(), raw, byName)
}
// existingMilestone resolves a title, or confirms that an ID exists.
func existingMilestone(ctx context.Context, milestones services.Milestones, selector string, id int64, byName bool) (int64, error) {
if byName {
return milestones.ResolveName(ctx, selector)
}
_, err := milestones.View(ctx, id)
return id, err
} }
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 milestone issues and pull requests"}
opts := services.ListOptions{} opts := services.ListOptions{}
var byName bool var byName bool
list := &cobra.Command{Use: "list <id-or-name>", 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", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
id, err := milestoneSelector(args[0], byName)
if err != nil {
return err
}
if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil { if err := oneOf("state", opts.State, "open", "closed", "all"); err != nil {
return err return err
} }
@@ -53,11 +36,11 @@ func (a *application) milestoneIssueCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
id, err = existingMilestone(cmd.Context(), services.Milestones{Client: target.client, Repository: target.Repo}, args[0], id, byName) milestone, err := findMilestone(cmd, target, args[0], byName)
if err != nil { if err != nil {
return err return err
} }
opts.Milestones = strconv.FormatInt(id, 10) opts.Milestones = strconv.FormatInt(milestone.ID, 10)
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts) items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
if err != nil { if err != nil {
return err return err
@@ -70,15 +53,11 @@ func (a *application) milestoneIssueCommand() *cobra.Command {
pageFlags(list, &opts.Limit, &opts.Page) pageFlags(list, &opts.Limit, &opts.Page)
list.Flags().StringVar(&opts.State, "state", "open", "open, closed, or all") list.Flags().StringVar(&opts.State, "state", "open", "open, closed, or all")
list.Flags().StringVar(&opts.Kind, "kind", "all", "all, issues, or pulls") list.Flags().StringVar(&opts.Kind, "kind", "all", "all, issues, or pulls")
list.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone title instead of ID") list.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
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 + " <id-or-name> <number>...", Short: titleCase(verb) + " issues or pull requests in a milestone", Args: usageArgs(cobra.MinimumNArgs(2)), RunE: func(cmd *cobra.Command, args []string) error { 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 {
id, err := milestoneSelector(args[0], byName)
if err != nil {
return err
}
numbers, err := resourceNumbers(args[1:]) numbers, err := resourceNumbers(args[1:])
if err != nil { if err != nil {
return err return err
@@ -87,10 +66,11 @@ func (a *application) milestoneIssueCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
id, err = existingMilestone(cmd.Context(), services.Milestones{Client: target.client, Repository: target.Repo}, args[0], id, byName) milestone, err := findMilestone(cmd, target, args[0], byName)
if err != nil { if err != nil {
return err 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) { return batch(a, cmd, numbers, issueLabel, func(n int64) (services.Issue, error) {
if verb == "remove" { if verb == "remove" {
@@ -99,7 +79,7 @@ func (a *application) milestoneIssueCommand() *cobra.Command {
return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &id}) return service.Edit(cmd.Context(), n, services.IssueEdit{Milestone: &id})
}, issueRow) }, issueRow)
}} }}
command.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone title instead of ID") command.Flags().BoolVar(&byName, "by-name", false, "Match a numeric argument as a milestone title rather than an ID")
group.AddCommand(command) group.AddCommand(command)
} }
return group return group
+78 -4
View File
@@ -6,6 +6,7 @@ package app
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"testing" "testing"
@@ -58,12 +59,13 @@ func TestMilestoneDeleteReturnsJSONSnapshotOnlyAfterSuccess(t *testing.T) {
calls := []string{} calls := []string{}
fail := false fail := false
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/milestones/9", r.URL.Path)
calls = append(calls, r.Method) calls = append(calls, r.Method)
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"id":9,"title":"Release one","state":"open"}`)) require.Equal(t, "/api/v1/repos/alice/project/milestones", r.URL.Path)
writeMilestones(w, r, `[{"id":9,"title":"Release one","state":"open"}]`)
return return
} }
require.Equal(t, "/api/v1/repos/alice/project/milestones/9", r.URL.Path)
require.Equal(t, http.MethodDelete, r.Method) require.Equal(t, http.MethodDelete, r.Method)
if fail { if fail {
w.WriteHeader(403) w.WriteHeader(403)
@@ -76,7 +78,7 @@ func TestMilestoneDeleteReturnsJSONSnapshotOnlyAfterSuccess(t *testing.T) {
args := []string{"milestone", "delete", "9", "-R", "alice/project", "--yes", "--json=id,title"} args := []string{"milestone", "delete", "9", "-R", "alice/project", "--yes", "--json=id,title"}
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, args))
require.JSONEq(t, `{"id":9,"title":"Release one"}`, out.String()) require.JSONEq(t, `{"id":9,"title":"Release one"}`, out.String())
require.Equal(t, []string{"GET", "DELETE"}, calls) require.Equal(t, []string{"GET", "GET", "DELETE"}, calls)
fail = true fail = true
out.Reset() out.Reset()
require.Error(t, Execute(context.Background(), deps, args)) require.Error(t, Execute(context.Background(), deps, args))
@@ -113,5 +115,77 @@ func TestMilestoneIssueCommandsSelectByName(t *testing.T) {
require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "issue", "add", "Release one", "3", "--by-name", "-R", "alice/project", "--json=number"})) require.NoError(t, Execute(context.Background(), deps, []string{"milestone", "issue", "add", "Release one", "3", "--by-name", "-R", "alice/project", "--json=number"}))
require.NotContains(t, calls, "GET /api/v1/repos/alice/project/milestones/9") require.NotContains(t, calls, "GET /api/v1/repos/alice/project/milestones/9")
err := Execute(context.Background(), deps, []string{"milestone", "issue", "remove", " ", "3", "--by-name", "-R", "alice/project"}) err := Execute(context.Background(), deps, []string{"milestone", "issue", "remove", " ", "3", "--by-name", "-R", "alice/project"})
require.ErrorContains(t, err, "milestone name cannot be empty") require.ErrorContains(t, err, "milestone title or ID cannot be empty")
}
// writeMilestones answers a milestone listing with page, a JSON array, as its first and only page.
func writeMilestones(w http.ResponseWriter, r *http.Request, page string) {
if r.URL.Query().Get("page") != "1" {
page = `[]`
}
_, _ = w.Write([]byte(page))
}
// payloadField returns the JSON request body's field name as text.
func payloadField(t *testing.T, r *http.Request, name string) string {
t.Helper()
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
return fmt.Sprint(payload[name])
}
func TestMilestonesAcceptTitlesOrIDs(t *testing.T) {
var calls []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":3,"title":"v1.0","state":"open"},{"id":4,"title":"2026","state":"closed"},{"id":5,"title":"v2.0"}]`)
case "GET /api/v1/repos/alice/project/milestones/3", "GET /api/v1/repos/alice/project/milestones/4":
_, _ = w.Write([]byte(`{"id":3,"title":"v1.0","state":"open"}`))
case "PATCH /api/v1/repos/alice/project/milestones/3", "PATCH /api/v1/repos/alice/project/milestones/4":
calls = append(calls, r.Method+" "+r.URL.Path)
_, _ = w.Write([]byte(`{"id":3,"title":"v1.0","state":"closed"}`))
case "POST /api/v1/repos/alice/project/issues":
requirePayload(t, `{"title":"x","milestone":3}`, r.Body)
calls = append(calls, r.Method+" "+r.URL.Path)
_, _ = w.Write([]byte(`{"number":8}`))
case "PATCH /api/v1/repos/alice/project/issues/8":
calls = append(calls, r.Method+" "+r.URL.Path+" milestone="+payloadField(t, r, "milestone"))
_, _ = w.Write([]byte(`{"number":8}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
run := func(args ...string) error {
return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--json", "--yes"))
}
require.NoError(t, run("milestone", "view", "v1.0"))
require.Contains(t, out.String(), `"id":3`)
require.NoError(t, run("milestone", "close", "V1.0", "2026"))
require.NoError(t, run("issue", "create", "--title", "x", "-m", "v1.0"))
require.NoError(t, run("issue", "edit", "8", "--milestone-name", "3"))
require.NoError(t, run("issue", "edit", "8", "--remove-milestone"))
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",
"PATCH /api/v1/repos/alice/project/issues/8 milestone=0",
}, calls)
for _, test := range []struct {
args []string
want string
}{
{[]string{"milestone", "view", "v9"}, `milestone "v9" not found in alice/project`},
{[]string{"milestone", "close", "v1.0", "3"}, "milestone 3 is selected more than once"},
{[]string{"issue", "create", "--title", "x", "--milestone", "nope"}, `milestone "nope" not found in alice/project`},
} {
require.ErrorContains(t, run(test.args...), test.want)
}
require.Len(t, calls, 6)
} }
+15 -36
View File
@@ -5,6 +5,8 @@ package app
import ( import (
"errors" "errors"
"slices"
"strings"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
@@ -13,54 +15,31 @@ import (
func (a *application) milestoneStateCommand(verb string, service func(*cobra.Command) (services.Milestones, error)) *cobra.Command { func (a *application) milestoneStateCommand(verb string, service func(*cobra.Command) (services.Milestones, error)) *cobra.Command {
var byName bool var byName bool
cmd := &cobra.Command{Use: verb + " <id-or-name>...", Short: titleCase(verb) + " one or more milestones", Args: usageArgs(cobra.MinimumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { 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 {
ids := make(map[string]int64, len(args)) if slices.ContainsFunc(args, func(arg string) bool { return strings.TrimSpace(arg) == "" }) {
seenIDs := map[int64]bool{} return clierr.Wrap(clierr.Usage, errors.New("milestone title or ID cannot be empty"))
selectID := func(argument string, id int64) error {
if seenIDs[id] {
return clierr.Wrap(clierr.Usage, errors.New("selectors resolve to the same milestone"))
}
seenIDs[id], ids[argument] = true, id
return nil
}
for _, argument := range args {
if _, seen := ids[argument]; seen {
return clierr.Wrap(clierr.Usage, errors.New("milestone selectors must be unique"))
}
id, err := milestoneSelector(argument, byName)
if err != nil {
return err
}
ids[argument] = 0
if !byName {
if err := selectID(argument, id); err != nil {
return err
}
}
} }
milestones, err := service(cmd) milestones, err := service(cmd)
if err != nil { if err != nil {
return err return err
} }
if byName { selected, err := milestones.Find(cmd.Context(), args, byName)
for _, argument := range args { if err != nil {
id, err := milestones.ResolveName(cmd.Context(), argument) return err
if err != nil { }
return err for i, milestone := range selected {
} if slices.ContainsFunc(selected[:i], func(other services.Milestone) bool { return other.ID == milestone.ID }) {
if err := selectID(argument, id); err != nil { return clierr.Wrap(clierr.Usage, errors.New("milestone "+args[i]+" is selected more than once"))
return err
}
} }
} }
state := "closed" state := "closed"
if verb == "reopen" { if verb == "reopen" {
state = "open" state = "open"
} }
return batch(a, cmd, args, func(argument string) string { return "milestone " + argument }, func(argument string) (services.Milestone, error) { 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(), ids[argument], services.MilestoneOptions{State: &state}) return milestones.Edit(cmd.Context(), milestone.ID, services.MilestoneOptions{State: &state})
}, milestoneRow) }, milestoneRow)
}} }}
cmd.Flags().BoolVar(&byName, "by-name", false, "Select by exact milestone titles instead of IDs") cmd.Flags().BoolVar(&byName, "by-name", false, "Match numeric arguments as milestone titles rather than IDs")
return cmd return cmd
} }
+4 -6
View File
@@ -51,12 +51,12 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"issue", "create", "--no-input"}, "missing required input: supply --title"}, {[]string{"issue", "create", "--no-input"}, "missing required input: supply --title"},
{[]string{"issue", "create", "--title", "safe", "--json=unknown"}, "unknown JSON field \"unknown\""}, {[]string{"issue", "create", "--title", "safe", "--json=unknown"}, "unknown JSON field \"unknown\""},
{[]string{"issue", "create", "--title", "safe", "--json", "--jq", "["}, "invalid --jq expression"}, {[]string{"issue", "create", "--title", "safe", "--json", "--jq", "["}, "invalid --jq expression"},
{[]string{"issue", "create", "--milestone=-1"}, "--milestone must be a positive ID, or 0 for none"}, {[]string{"issue", "create", "--milestone="}, "--milestone needs a title or ID; use --remove-milestone to clear it"},
{[]string{"issue", "create", "--assignee", "alice, "}, "assignee names cannot be empty"}, {[]string{"issue", "create", "--assignee", "alice, "}, "assignee names cannot be empty"},
{[]string{"issue", "create", "--template="}, "--template cannot be empty"}, {[]string{"issue", "create", "--template="}, "--template cannot be empty"},
{[]string{"issue", "create", "--title", "x", "--attachment", "."}, ". is not a regular file"}, {[]string{"issue", "create", "--title", "x", "--attachment", "."}, ". is not a regular file"},
{[]string{"issue", "create", "--milestone-name="}, "--milestone-name cannot be empty"}, {[]string{"issue", "create", "--milestone-name="}, "--milestone needs a title or ID"},
{[]string{"issue", "create", "--milestone-name=Release", "--milestone=1"}, "--milestone and --milestone-name are mutually exclusive"}, {[]string{"issue", "edit", "1", "--milestone", "v1", "--remove-milestone"}, "--milestone and --remove-milestone cannot be combined"},
{[]string{"issue", "edit", "1"}, "supply an edit flag"}, {[]string{"issue", "edit", "1"}, "supply an edit flag"},
{[]string{"issue", "edit", "1", "--editor=false"}, "supply an edit flag"}, {[]string{"issue", "edit", "1", "--editor=false"}, "supply an edit flag"},
{[]string{"issue", "edit", "1", "--add-labels="}, "supply an edit flag"}, {[]string{"issue", "edit", "1", "--add-labels="}, "supply an edit flag"},
@@ -73,9 +73,7 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"}, {[]string{"label", "create", "--name", "bug", "--color", "wrong"}, "--color requires six hexadecimal digits"},
{[]string{"label", "edit", "1"}, "supply label fields to edit"}, {[]string{"label", "edit", "1"}, "supply label fields to edit"},
{[]string{"label", "list", "--org", "team", "-R", "a/b"}, "--org and --repo are mutually exclusive"}, {[]string{"label", "list", "--org", "team", "-R", "a/b"}, "--org and --repo are mutually exclusive"},
{[]string{"milestone", "close", "9", "09"}, "selectors resolve to the same milestone"}, {[]string{"milestone", "close", " ", "--by-name"}, "milestone title or ID cannot be empty"},
{[]string{"milestone", "close", " ", "--by-name"}, "milestone name cannot be empty"},
{[]string{"milestone", "reopen", "v1", "v1", "--by-name"}, "milestone selectors must be unique"},
{[]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"}, "supply milestone fields to edit"},
+31 -10
View File
@@ -40,22 +40,43 @@ func (s Milestones) All(ctx context.Context) ([]Milestone, error) {
return listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) }) return listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) })
} }
// FilterIDs resolves the milestones that filter an issue listing, each an ID or a title, to their // Find returns the milestones that raws address among the open and closed milestones, each by ID
// IDs. Gitea drops filter values that name no milestone and lists every issue when none is left, // or title (D10); byName matches numeric values as titles only.
// so an unknown milestone is an error here. func (s Milestones) Find(ctx context.Context, raws []string, byName bool) ([]Milestone, error) {
func (s Milestones) FilterIDs(ctx context.Context, values []string) ([]string, error) {
items, err := s.All(ctx) items, err := s.All(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
finder := NameOrID[Milestone]{Kind: "milestone", Scope: s.Repository, ID: func(m Milestone) int64 { return m.ID }, Name: func(m Milestone) string { return m.Title }} finder := NameOrID[Milestone]{Kind: "milestone", Scope: s.Repository, ID: func(m Milestone) int64 { return m.ID }, Name: func(m Milestone) string { return m.Title }, ByName: byName}
ids := make([]string, len(values)) result := make([]Milestone, len(raws))
for i, value := range values { for i, raw := range raws {
milestone, err := finder.Find(items, value) if result[i], err = finder.Find(items, raw); err != nil {
if err != nil {
return nil, err return nil, err
} }
ids[i] = strconv.FormatInt(milestone.ID, 10) }
return result, nil
}
// FindOne returns the milestone that raw addresses like Find.
func (s Milestones) FindOne(ctx context.Context, raw string, byName bool) (Milestone, error) {
items, err := s.Find(ctx, []string{raw}, byName)
if err != nil {
return Milestone{}, err
}
return items[0], nil
}
// FilterIDs resolves the milestones that filter an issue listing, each an ID or a title, to their
// IDs. Gitea drops filter values that name no milestone and lists every issue when none is left,
// so an unknown milestone is an error here.
func (s Milestones) FilterIDs(ctx context.Context, values []string) ([]string, error) {
items, err := s.Find(ctx, values, false)
if err != nil {
return nil, err
}
ids := make([]string, len(items))
for i, item := range items {
ids[i] = strconv.FormatInt(item.ID, 10)
} }
return ids, nil return ids, nil
} }