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
-
Pick a project with open issues in a milestone and open issues in none (on GitLab.com,
gitlab-org/gitlab-runner). -
List its open issues four times, with
milestone_id=None,milestone_id=Any,not[milestone_id]=Noneandnot[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¬%5Bmilestone_id%5D=None" -
Compare the
milestonefield of the issues each request returns. -
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
milestonetitle parameter still treatsNoneandAnyas wildcards, a use #336044 plans to remove, so I did not extend it to the negated title:not[milestone]=Nonekeeps its meaning. Matching the title too (Params#filter_by_no_milestone?) would also change whatnot[milestone]=Nonereturns for issues and merge requests. - The other way to remove the wrong answer would be to accept only
StartedandUpcominginnot[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 a400or a GraphQL error, which the REST and GraphQL API guidelines treat as a breaking change.