Issues API: not[milestone_id]=None returns the issues without a milestone

Summary

The Issues API accepts not[milestone_id] with the values None, Any, Upcoming and Started (issues.rb#L43-44), and the Issues API documentation lists milestone_id among the keys not accepts. Negating None returns the issues that have no milestone, the same issues as milestone_id=None, which is the opposite of what it asks for. Negating Any returns the right issues, but only by accident.

The API passes the value to the finder as the negated milestone_wildcard_id (issues_helpers.rb#L76). IssuableFinder#by_negated_milestone (issuable_finder.rb#L519-530) handles 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 falls through to without_particular_milestones with a nil title (milestoneable.rb#L25), which builds milestones.title NOT IN (NULL) OR milestone_id IS NULL. NOT IN (NULL) is never true, so only the issues without a milestone come back.

This affects GET /issues, GET /groups/:id/issues and GET /projects/:id/issues, and the counts of GET /issues_statistics, GET /groups/:id/issues_statistics and GET /projects/:id/issues_statistics, which take the same filters and build the same finder. Issue boards have the same bug: the GraphQL board list filter not: { milestoneWildcardId } takes MilestoneWildcardId, which includes NONE and ANY (board_issue_input_base_type.rb#L34-36, inherited by NegatedBoardIssueInput), 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, and Boards::Issues::ListService passes it to IssuesFinder (list_service.rb#L42-44). GraphQL's issue and work item filters and the work items REST API accept only Started and Upcoming as negated milestone wildcards, so they are not affected.

Steps to reproduce

  1. Pick a project with open issues in a milestone and open issues in none (on GitLab.com, gitlab-org/gitlab-runner).

  2. List its open issues four times, with milestone_id=None, milestone_id=Any, not[milestone_id]=None and not[milestone_id]=Any:

    curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" \
      --url "https://gitlab.com/api/v4/projects/250833/issues?state=opened&per_page=10&order_by=created_at&not%5Bmilestone_id%5D=None"
  3. Compare the milestone field of the issues each request returns.

  4. Or ask for the counts with the same four filters, which is quicker to compare:

    curl --request GET --header "PRIVATE-TOKEN: <your_access_token>" \
      --url "https://gitlab.com/api/v4/projects/250833/issues_statistics?not%5Bmilestone_id%5D=None"

Example Project

gitlab-org/gitlab-runner on GitLab.com (any project with issues in and out of milestones shows it).

What is the current bug behavior?

not[milestone_id]=None returns the same issues as milestone_id=None: all of them without a milestone, and issues_statistics counts the same issues for both. not[milestone_id]=Any returns them too.

What is the expected correct behavior?

not[milestone_id]=None returns the issues that have a milestone, as milestone_id=Any does, and not[milestone_id]=Any the issues that have none, as milestone_id=None does.

Relevant logs and/or screenshots

On GitLab.com on 2026-10-22, open issues of gitlab-org/gitlab-runner, 10 per page, newest first:

Filter Issues returned With a milestone
milestone_id=None 40170, 40168, 40160, 40158, 40156, 40154, 40153, 40151, 40150, 40149 0
milestone_id=Any 40155, 40144, 40101, 40016, 39955, 39954, 39949, 39935, 39850, 39849 10
not[milestone_id]=None 40170, 40168, 40160, 40158, 40156, 40154, 40153, 40151, 40150, 40149 0
not[milestone_id]=Any 40170, 40168, 40160, 40158, 40156, 40154, 40153, 40151, 40150, 40149 0

On GitLab.com on 2026-10-23, GET /projects/250833/issues_statistics (gitlab-org/gitlab-runner, every state):

Filter all opened closed
none 9495 2570 6925
milestone_id=None 6647 2199 4448
milestone_id=Any 2848 371 2477
not[milestone_id]=None 6647 2199 4448
not[milestone_id]=Any 6647 2199 4448

milestone_id=None and milestone_id=Any add up to the 9495 issues of the project, so not[milestone_id]=None should count the 2848 issues in a milestone, and counts the 6647 without one instead.

Output of checks

This bug happens on GitLab.com. Found while working on gitlab-mcp-server, which lists issues through this API, and checked on master at 392116f2 with specs.

Possible fixes

Handle the two values in by_negated_milestone: negating None keeps the issues with a milestone (the any_milestone scope), and negating Any keeps the issues with none (milestone_id IS NULL). I have a merge request ready that does this, with finder, request and board resolver specs.

Two choices for whoever reviews it:

  • The fix reads only the wildcard parameter. The positive milestone title parameter still treats None and Any as wildcards, a use #336044 plans to remove, so I did not extend it to the negated title: not[milestone]=None keeps its meaning. Matching the title too (Params#filter_by_no_milestone?) would also change what not[milestone]=None returns for issues and merge requests.
  • The other way to remove the wrong answer would be to accept only Started and Upcoming in not[milestone_id] and in the board filter, as GraphQL's issue and work item filters and the work items REST API do, but that turns a request that answers today into a 400 or a GraphQL error, which the REST and GraphQL API guidelines treat as a breaking change.
Edited by 🤖 GitLab Bot 🤖