mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
4 files changed
+125
-12
No files matched your search
@@ -37,6 +37,10 @@ func (a *application) discussionSearchCommand(pulls bool) *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
milestones, err := nameFilter("milestone", opts.Milestones)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
target, err := a.resolve(cmd.Context(), false)
|
target, err := a.resolve(cmd.Context(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -50,13 +54,15 @@ func (a *application) discussionSearchCommand(pulls bool) *cobra.Command {
|
|||||||
if opts.Team != "" && target.Repo != "" {
|
if opts.Team != "" && target.Repo != "" {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--team requires instance-wide search; omit --repo or TEA_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 target.Repo != "" {
|
if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil {
|
||||||
if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil {
|
return err
|
||||||
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, " ")
|
opts.Query = strings.Join(args, " ")
|
||||||
items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Search(cmd.Context(), opts)
|
items, err := (services.Issues{Client: target.client, Repository: target.Repo}).Search(cmd.Context(), opts)
|
||||||
|
|||||||
@@ -128,3 +128,56 @@ func TestIssueListWithoutRepoPointsToSearch(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, "tea search issue")
|
require.ErrorContains(t, err, "tea search issue")
|
||||||
require.Empty(t, out.String())
|
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")
|
||||||
|
}
|
||||||
+33
-5
@@ -114,6 +114,33 @@ func labelChoices(items []services.Label) []terminal.IssueLabelChoice {
|
|||||||
|
|
||||||
func issueLabel(n int64) string { return fmt.Sprintf("issue %d", n) }
|
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.
|
// metadataFlags are the label and assignee flags shared by issue and pull request edits.
|
||||||
type metadataFlags struct {
|
type metadataFlags struct {
|
||||||
setLabels, addLabels, removeLabels []string
|
setLabels, addLabels, removeLabels []string
|
||||||
@@ -206,6 +233,10 @@ func (a *application) issueCommand() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
milestones, err := nameFilter("milestone", opts.Milestones)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
target, err := a.resolve(cmd.Context(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errRepositoryRequired) {
|
if errors.Is(err, errRepositoryRequired) {
|
||||||
@@ -213,11 +244,8 @@ func (a *application) issueCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(labelNames) > 0 {
|
if opts.Labels, opts.Milestones, err = issueFilters(cmd, target, labelNames, milestones); err != nil {
|
||||||
if err := (services.Labels{Client: target.client, Repository: target.Repo}).RequireFilterNames(cmd.Context(), labelNames); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
opts.Labels = strings.Join(labelNames, ",")
|
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ func (s Milestones) ResolveName(ctx context.Context, name string) (int64, error)
|
|||||||
if strings.TrimSpace(name) == "" {
|
if strings.TrimSpace(name) == "" {
|
||||||
return 0, fmt.Errorf("milestone name cannot be empty")
|
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 {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -33,3 +34,28 @@ func (s Milestones) ResolveName(ctx context.Context, name string) (int64, error)
|
|||||||
}
|
}
|
||||||
return id, nil
|
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user