Fix negated None and Any milestone wildcards in IssuableFinder

What does this MR do and why?

The Issues API accepts not[milestone_id] with the values None, Any, Upcoming and Started (issues.rb#L43-44), and passes it to the finder as the negated milestone_wildcard_id (issues_helpers.rb#L76). IssuableFinder#by_negated_milestone (issuable_finder.rb#L519-530) handled only Upcoming and Started; when !69179 (merged) added milestone_id to not with all four values, its specs covered only those two. For None and Any it fell through to without_particular_milestones with a nil title, which builds milestones.title NOT IN (NULL) OR milestone_id IS NULL. NOT IN (NULL) is never true, so both values returned only the issues without a milestone: not[milestone_id]=None answered the same issues as milestone_id=None, the opposite of what it asks for, and not[milestone_id]=Any was right only by accident. It reproduces on GitLab.com: in gitlab-org/gitlab-runner, the first ten open issues not[milestone_id]=None returns are the ten milestone_id=None returns, none of them in a milestone, and GET /projects/250833/issues_statistics counts 6647 issues for both filters, where milestone_id=Any counts 2848.

Negating None now returns the issues that have a milestone (the any_milestone scope the positive Any filter uses), and negating Any the issues that have none. The Any branch changes no result: it is there so that the answer no longer rests on NOT IN (NULL), and its spec passes on master too. This applies to GET /issues, GET /groups/:id/issues and GET /projects/:id/issues, and to the counts of the three issues_statistics endpoints, which take the same filters and build the same finder. It also applies to issue board lists in GraphQL, whose not: { milestoneWildcardId } filter accepts NONE and ANY and reaches the same finder through Boards::Issues::ListService; a board list issues resolver spec covers that path.

What API clients will see: not[milestone_id]=None, and not: { milestoneWildcardId: NONE } on a board list, return issues with a milestone instead of issues without one. The status code, the response shape and the accepted values do not change, and not[milestone_id]=Any and the board's negated ANY return the same issues as before. I read this as a bug fix under the API style guide: the parameter is documented (issues.md#L87) and validated with these values, and today it answers the opposite of its definition. Accepting only Started and Upcoming instead, as GraphQL's issue and work item filters and the work items REST API do, would turn a request that answers today into a 400, or into a GraphQL error on a board list.

Two choices you may want to weigh in on:

  • Only the wildcard parameter is read. The positive milestone title parameter still treats None and Any as wildcards, a use #336044 plans to remove, so I did not extend that to the negated title: not[milestone]=None keeps its meaning (every issue not in a milestone titled None). Using Params#filter_by_no_milestone? and #filter_by_any_milestone? here would match the title too, and would change what not[milestone]=None returns for issues and merge requests; I can switch to that if you prefer it.
  • GraphQL's board list filter reaches the new branches too: NegatedBoardIssueInput.milestoneWildcardId takes MilestoneWildcardId (board_issue_input_base_type.rb#L34-36), and the board frontend sends a negated None or Any milestone there (boards_util.js#L272-279), for example from a board URL with not[milestone_title]=None (the filter bar does not suggest None or Any after "is not"), so a board list filtered that way now returns the issues with a milestone. GraphQL's issue and work item filters (NegatedMilestoneWildcardId) and the work items REST API accept only STARTED and UPCOMING (Started and Upcoming), so they are unchanged.
Database: the SQL before and after

No migration and no new scope. The two branches replace a LEFT OUTER JOIN milestones and the NOT IN (NULL) condition with one predicate on issues.milestone_id: IS NOT NULL, which milestone_id=Any already sends, or IS NULL. SQL that IssuesFinder builds for a project with the milestone params the Issues API passes (legacy milestone filtering on), printed by a probe spec on master and on this branch (column list shortened to "issues".*; the ids differ because the two come from separate runs):

not[milestone_id]=None and not[milestone_id]=Any on master (identical):

SELECT "issues".* FROM "issues" LEFT OUTER JOIN "milestones" ON "milestones"."id" = "issues"."milestone_id" WHERE (NOT EXISTS (SELECT 1 FROM "banned_users" WHERE (banned_users.user_id = (issues.author_id + 0)))) AND "issues"."namespace_id" = 70 AND ("issues"."confidential" = FALSE OR "issues"."confidential" = TRUE AND ("issues"."author_id" = 45 OR EXISTS (SELECT "issue_assignees".* FROM "issue_assignees" WHERE "issue_assignees"."user_id" = 45 AND (issue_id = issues.id)) OR (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 45 AND (project_authorizations.project_id = issues.project_id) AND (project_authorizations.access_level >= 15))))) AND "issues"."work_item_type_id" != 8 AND (milestones.title NOT IN (NULL) OR milestone_id IS NULL) /* allow_cross_joins_across_databases */ ORDER BY "issues"."id" DESC

not[milestone_id]=None on this branch:

SELECT "issues".* FROM "issues" WHERE (NOT EXISTS (SELECT 1 FROM "banned_users" WHERE (banned_users.user_id = (issues.author_id + 0)))) AND "issues"."namespace_id" = 105 AND ("issues"."confidential" = FALSE OR "issues"."confidential" = TRUE AND ("issues"."author_id" = 68 OR EXISTS (SELECT "issue_assignees".* FROM "issue_assignees" WHERE "issue_assignees"."user_id" = 68 AND (issue_id = issues.id)) OR (EXISTS (SELECT 1 FROM "project_authorizations" WHERE "project_authorizations"."user_id" = 68 AND (project_authorizations.project_id = issues.project_id) AND (project_authorizations.access_level >= 15))))) AND "issues"."work_item_type_id" != 8 AND "issues"."milestone_id" IS NOT NULL /* allow_cross_joins_across_databases */ ORDER BY "issues"."id" DESC

not[milestone_id]=Any on this branch: the same statement with "issues"."milestone_id" IS NULL.

I have no access to Database Lab, so I have no plans on production-sized data; the plans on my local test database, with a handful of rows, would say nothing useful.

Specs: red on master, green on this branch

What I ran, with Ruby 3.3.11 and the project bundle, on this branch (based on 392116f2; the four files it changes are the same on master at 8d21ced7, 2026-10-24):

  • bundle exec rspec spec/finders/issues_finder_spec.rb spec/finders/work_items/work_items_finder_spec.rb spec/requests/api/issues/issues_spec.rb spec/graphql/resolvers/board_list_issues_resolver_spec.rb -e 'filtering by not milestone wildcard' -e "returns correct issues when filtering with negated 'milestone_id'" -e 'negating the none milestone wildcard' with master's IssuableFinder#by_negated_milestone: 10 examples, 5 failures, the none example in both finder specs, the wildcard_none row of the request spec and the board resolver example for project and group boards. The any examples pass, as the bug predicts.
  • The same command on this branch: 10 examples, 0 failures.
  • The specs around the change on this branch (spec/finders/issues_finder_spec.rb, spec/finders/work_items/work_items_finder_spec.rb, ee/spec/finders/issues_finder_spec.rb, spec/finders/merge_requests_finder_spec.rb, issues_spec.rb, get_project_issues_spec.rb and get_group_issues_spec.rb under spec/requests/api/issues/, and the project and group issues resolver specs): 1424 examples, 0 failures, 22 pending.
  • The board specs on this branch (board_list_issues_resolver_spec.rb under spec/graphql/resolvers/ and ee/spec/graphql/ee/resolvers/, list_service_spec.rb under spec/services/boards/issues/ and ee/spec/services/ee/boards/issues/, and spec/requests/api/graphql/boards/board_list_issues_query_spec.rb): 199 examples, 0 failures.
  • bundle exec rubocop on the four changed files: no offenses. scripts/lint/commit_linter.rb: no findings.
$ bundle exec rspec spec/finders/issues_finder_spec.rb spec/finders/work_items/work_items_finder_spec.rb spec/requests/api/issues/issues_spec.rb spec/graphql/resolvers/board_list_issues_resolver_spec.rb -e 'filtering by not milestone wildcard' -e "returns correct issues when filtering with negated 'milestone_id'" -e 'negating the none milestone wildcard'   # master's by_negated_milestone
  1) IssuesFinder behaves like issues or work items finder #execute scope: all filtering by not milestone wildcard none returns items with a milestone
     Failure/Error: expect(items).to contain_exactly(item1)
       expected collection contained:  [#<Issue id:1239 group1/project-1#1>]
       actual collection contained:    [#<Issue id:1240 namespace1/project-2#1>, #<Issue id:1241 namespace1/project-2#2>, #<Issue id:1242 group1/group2/project-3#1>, #<Issue id:1243 group1/project-1#2>]
  2) WorkItems::WorkItemsFinder behaves like issues or work items finder #execute scope: all filtering by not milestone wildcard none returns items with a milestone
     Failure/Error: expect(items).to contain_exactly(item1)
  3) API::Issues GET /issues when authenticated filtering by milestone_id negated filtering not_milestone_id: wildcard_none, ... returns correct issues when filtering with negated 'milestone_id'
     Failure/Error: expect(json_response.map { |item| item['id'] }).to match_array(items.flatten)
       expected collection contained:  [1253, 1255, 1256, 1257, 1258]
       actual collection contained:    [1259]
  4) Resolvers::BoardListIssuesResolver#resolve when project boards behaves like group and project board list issues resolver when authorized finds only issues with a milestone when negating the none milestone wildcard
     Failure/Error: expect(result).to match_array([issue1, issue2, issue3])
       expected collection contained:  [#<Issue id:1260 user20/project-9#1>, #<Issue id:1261 user20/project-9#2>, #<Issue id:1262 user20/project-9#3>]
       actual collection contained:    [#<Issue id:1263 user20/project-9#4>]
  5) Resolvers::BoardListIssuesResolver#resolve when group boards behaves like group and project board list issues resolver when authorized finds only issues with a milestone when negating the none milestone wildcard
     Failure/Error: expect(result).to match_array([issue1, issue2, issue3])
10 examples, 5 failures

$ (same command, this branch)
10 examples, 0 failures

References

Closes #636229 (closed)

  • !69179 (merged) added milestone_id to the REST API, in not with all four values, and tested the negation of Upcoming and Started only.
  • #336044 plans the removal of None and Any as milestone titles.
  • I found this while working on gitlab-mcp-server, which lists issues through this API.

Screenshots or screen recordings

No UI code changes. An issue board filtered with a negated None milestone (not[milestone_title]=None in the board URL) lists the issues with a milestone after this change, as the API does. I checked this through the board list GraphQL query, not in a browser.

How to set up and validate locally

  1. In a project, put two issues in a milestone and leave one without a milestone.

  2. Call GET /api/v4/projects/:id/issues?not[milestone_id]=None. On master it returns the issue without a milestone; on this branch, the two in the milestone.

  3. Call GET /api/v4/projects/:id/issues_statistics?not[milestone_id]=None. statistics.counts.all is 1 on master and 2 on this branch.

  4. Call GET /api/v4/projects/:id/issues?not[milestone_id]=Any. Both return the issue without a milestone.

  5. Open the project's issue board once, so it has its Open and Closed lists, and run this query in GraphiQL (/-/graphql-explorer). On master the backlog (Open) list returns the issue without a milestone, count 1; on this branch, the two in the milestone, count 2. With ANY instead of NONE, both return the issue without a milestone.

    query {
      project(fullPath: "<project path>") {
        boards(first: 1) {
          nodes {
            lists {
              nodes {
                listType
                issues(filters: { not: { milestoneWildcardId: NONE } }) { count nodes { iid } }
              }
            }
          }
        }
      }
    }

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Merge request reports

Loading
Loading