fix(issue): reject unknown milestones in issue list and search filters

Gitea drops milestone filter values it cannot resolve and lists every
issue when none is left, so tea issue list --milestone nope printed all
issues and exited 0, and a pipeline closing the result would act on all
of them. issue list and search issue|pr -R now resolve each --milestone
value by ID or title and fail with "milestone "nope" not found in
alice/project". Instance-wide search cannot check the names and says so
on stderr.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:14:17 +02:00
1 parent 81b0c1b99e
commit 883264512c
4 files changed
+121 -8

No files matched your search

+9 -3
View File
@@ -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 {
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)
+53
View File
@@ -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")
}
+32 -4
View File
@@ -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,12 +244,9 @@ 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 {
if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil {
return err
}
opts.Labels = strings.Join(labelNames, ",")
}
items, more, err := (services.Issues{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts)
if err != nil {
return err
+27 -1
View File
@@ -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
}