diff --git a/internal/app/discussion_search.go b/internal/app/discussion_search.go index 728ff32a..62a6803e 100644 --- a/internal/app/discussion_search.go +++ b/internal/app/discussion_search.go @@ -37,6 +37,10 @@ func (a *application) discussionSearchCommand(pulls bool) *cobra.Command { if err != nil { return err } + milestones, err := nameFilter("milestone", opts.Milestones) + if err != nil { + return err + } target, err := a.resolve(cmd.Context(), false) if err != nil { return err @@ -50,13 +54,15 @@ func (a *application) discussionSearchCommand(pulls bool) *cobra.Command { if opts.Team != "" && target.Repo != "" { return clierr.Wrap(clierr.Usage, errors.New("--team requires instance-wide search; omit --repo or TEA_REPO")) } - if len(labelNames) > 0 { - if target.Repo != "" { - if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil { - return err - } + if target.Repo != "" { + if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil { + return err + } + } else if len(labelNames)+len(milestones) > 0 { + opts.Labels, opts.Milestones = strings.Join(labelNames, ","), strings.Join(milestones, ",") + if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "warning: instance-wide search ignores --label and --milestone values that match nothing; pass --repo to check them"); err != nil { + return err } - opts.Labels = strings.Join(labelNames, ",") } opts.Query = strings.Join(args, " ") items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Search(cmd.Context(), opts) diff --git a/internal/app/issue_filters_test.go b/internal/app/issue_filters_test.go index 9798c7cf..ac602801 100644 --- a/internal/app/issue_filters_test.go +++ b/internal/app/issue_filters_test.go @@ -128,3 +128,56 @@ func TestIssueListWithoutRepoPointsToSearch(t *testing.T) { require.ErrorContains(t, err, "tea search issue") require.Empty(t, out.String()) } + +func TestIssueMilestoneFiltersRejectUnknownMilestones(t *testing.T) { + var listed []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/repos/alice/project/milestones": + require.Equal(t, "all", r.URL.Query().Get("state")) + if r.URL.Query().Get("page") == "1" { + _, _ = w.Write([]byte(`[{"id":1,"title":"v1.0"},{"id":2,"title":"2026","state":"closed"}]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + case "/api/v1/repos/alice/project/issues", "/api/v1/repos/issues/search": + listed = append(listed, r.URL.Path+"?milestones="+r.URL.Query().Get("milestones")) + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + deps, out, diagnostics := serverDeps(t, server.URL) + for _, test := range []struct { + args []string + want string + }{ + {[]string{"issue", "list", "--milestone", "nope"}, `milestone "nope" not found in alice/project`}, + {[]string{"issue", "list", "--milestone", "999"}, "milestone 999 not found in alice/project"}, + {[]string{"issue", "list", "--milestone", "v1.0,nope"}, `milestone "nope" not found`}, + {[]string{"search", "issue", "--milestone", "nope"}, `milestone "nope" not found in alice/project`}, + {[]string{"search", "pr", "--milestone", "nope"}, `milestone "nope" not found in alice/project`}, + } { + err := Execute(context.Background(), deps, append(test.args, "-R", "alice/project", "--json")) + require.Equal(t, clierr.Failure, clierr.ExitCode(err), "%v", err) + require.ErrorContains(t, err, test.want) + } + require.Empty(t, listed) + require.Empty(t, out.String()) + for _, args := range [][]string{ + {"issue", "list", "-R", "alice/project", "--milestone", "v1.0,2026"}, + {"issue", "list", "-R", "alice/project", "--milestone", "1"}, + {"search", "issue", "-R", "alice/project", "--milestone", "V1.0"}, + {"search", "issue", "--milestone", "nope"}, + } { + require.NoError(t, Execute(context.Background(), deps, append(args, "--json"))) + } + require.Equal(t, []string{ + "/api/v1/repos/alice/project/issues?milestones=1,2", + "/api/v1/repos/alice/project/issues?milestones=1", + "/api/v1/repos/alice/project/issues?milestones=1", + "/api/v1/repos/issues/search?milestones=nope", + }, listed) + require.Contains(t, diagnostics.String(), "pass --repo to check them") +} diff --git a/internal/app/issues.go b/internal/app/issues.go index 9e2ea0a9..ab9504ae 100644 --- a/internal/app/issues.go +++ b/internal/app/issues.go @@ -114,6 +114,33 @@ func labelChoices(items []services.Label) []terminal.IssueLabelChoice { func issueLabel(n int64) string { return fmt.Sprintf("issue %d", n) } +// nameFilter splits a comma-separated filter of kind, trimming entries and rejecting blanks. +func nameFilter(kind, raw string) ([]string, error) { + if raw == "" { + return nil, nil + } + names := strings.Split(raw, ",") + return names, requireNames(kind, "", names) +} + +// issueFilters checks the label and milestone filters of an issue listing in the target repository +// and returns them as query values. Gitea ignores labels and milestones it cannot find, and lists +// every issue when no filter value is left, so unknown names are errors. +func issueFilters(cmd *cobra.Command, t target, labels, milestones []string) (labelQuery, milestoneQuery string, err error) { + if len(labels) > 0 { + if err := (services.Labels{Client: t.client, Repository: t.Repo}).RequireFilterNames(cmd.Context(), labels); err != nil { + return "", "", err + } + } + var ids []string + if len(milestones) > 0 { + if ids, err = (services.Milestones{Client: t.client, Repository: t.Repo}).FilterIDs(cmd.Context(), milestones); err != nil { + return "", "", err + } + } + return strings.Join(labels, ","), strings.Join(ids, ","), nil +} + // metadataFlags are the label and assignee flags shared by issue and pull request edits. type metadataFlags struct { setLabels, addLabels, removeLabels []string @@ -206,6 +233,10 @@ func (a *application) issueCommand() *cobra.Command { if err != nil { return err } + milestones, err := nameFilter("milestone", opts.Milestones) + if err != nil { + return err + } target, err := a.resolve(cmd.Context(), true) if err != nil { if errors.Is(err, errRepositoryRequired) { @@ -213,11 +244,8 @@ func (a *application) issueCommand() *cobra.Command { } return err } - if len(labelNames) > 0 { - if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil { - return err - } - opts.Labels = strings.Join(labelNames, ",") + if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil { + return err } items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts) if err != nil { diff --git a/internal/services/milestone_selection.go b/internal/services/milestone_selection.go index f091ed2a..b97e2455 100644 --- a/internal/services/milestone_selection.go +++ b/internal/services/milestone_selection.go @@ -6,6 +6,7 @@ package services import ( "context" "fmt" + "strconv" "strings" ) @@ -14,7 +15,7 @@ func (s Milestones) ResolveName(ctx context.Context, name string) (int64, error) if strings.TrimSpace(name) == "" { return 0, fmt.Errorf("milestone name cannot be empty") } - items, err := listAll(func(page int) ([]Milestone, error) { return s.List(ctx, "all", 100, page) }) + items, err := s.All(ctx) if err != nil { return 0, err } @@ -33,3 +34,28 @@ func (s Milestones) ResolveName(ctx context.Context, name string) (int64, error) } return id, nil } + +// All returns the open and closed milestones of the repository. +func (s Milestones) All(ctx context.Context) ([]Milestone, error) { + 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 +// 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.All(ctx) + if err != nil { + 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 }} + ids := make([]string, len(values)) + for i, value := range values { + milestone, err := finder.Find(items, value) + if err != nil { + return nil, err + } + ids[i] = strconv.FormatInt(milestone.ID, 10) + } + return ids, nil +}