Add default reference to custom status mappings
What does this MR do and why?
Fixes status filtering for work items without current_status records by tracking default status roles in mappings.
Problem: Items without current_status
records (pre-18.2 work items) display the correct status but are not picked up by filters when status mappings are involved. The filtering logic can't determine whether these items should be included because it doesn't know if the mapped old status was a default status.
Root cause: When filtering work items by status, the system needs to know if items without status records should match a filter. This requires knowing whether the old status in a mapping was a default status (open/closed/duplicate), but this information isn't currently stored.
Solution: Add old_status_role
column to track whether the old status in a mapping was a default lifecycle status, enabling the filter logic to correctly include items without status records.
This MR additionally resolves an issue when we filter by a custom status that also is a default status (open/closed/duplicate) and we have items that don't have a current_status record. Before these weren't picked up by the filter and now they're included. See Agnes thread below for details.
References
Query and query plan
Include items without current_status via mapping
The net addition is this condition below which applies a mapping that also includes items that don't have a current status assigned. The mapping is added when the user selects a new lifecycle for a work item type and provides a mapping from a default status.
This way we ensure those items are included in filtered lists.
OR "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 31
AND "work_item_current_statuses"."updated_at" <= '2025-10-07 19:25:18.905498'
OR "issues"."created_at" <= '2025-10-07 19:25:18.905498'
OR "work_item_current_statuses"."id" IS NULL)
Click to expand
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_id" LEFT OUTER
JOIN "work_item_current_statuses"
ON "work_item_current_statuses"."work_item_id" = "issues"."id"
LEFT JOIN project_features
ON projects.id = project_features.project_id
WHERE (EXISTS
(SELECT 1
FROM "project_authorizations"
WHERE "project_authorizations"."user_id" = 12977570
AND (project_authorizations.project_id = projects.id))
OR projects.visibility_level IN (0,10,20))
AND ("project_features"."issues_access_level" > 0
OR "project_features"."issues_access_level" IS NULL)
AND "projects"."id" = 278964
AND "issues"."state_id" = 1
AND "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 197
OR "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 198
OR "work_item_current_statuses"."system_defined_status_id" IS NOT NULL
AND "work_item_current_statuses"."system_defined_status_id" = 1)
OR "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 31
AND "work_item_current_statuses"."updated_at" <= '2025-10-07 19:25:18.905498'
OR "issues"."created_at" <= '2025-10-07 19:25:18.905498'
OR "work_item_current_statuses"."id" IS NULL))
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 21
Group level
Query plan for issues and tasks where the mapping also includes all items without a current status (which is a lot for the gitlab-org group).
Click to expand
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_id" LEFT OUTER
JOIN "work_item_current_statuses"
ON "work_item_current_statuses"."work_item_id" = "issues"."id"
LEFT JOIN project_features
ON projects.id = project_features.project_id
WHERE "projects"."namespace_id" IN
(SELECT "namespaces"."id"
FROM UNNEST( COALESCE(
(SELECT ids
FROM
(SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
FROM "namespace_descendants"
WHERE "namespace_descendants"."outdated_at" IS NULL
AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids
FROM
(SELECT ARRAY_AGG("namespaces"."id") AS ids
FROM
(SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids,
1)] AS id
FROM "namespaces"
WHERE "namespaces"."type" = 'Group'
AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query)) ) AS namespaces(id) )
AND (EXISTS
(SELECT 1
FROM "project_authorizations"
WHERE "project_authorizations"."user_id" = 12977570
AND (project_authorizations.project_id = projects.id))
OR projects.visibility_level IN (0,10,20))
AND ("project_features"."issues_access_level" > 0
OR "project_features"."issues_access_level" IS NULL)
AND "issues"."state_id" = 1
AND ("issues"."project_id" IS NULL
OR "projects"."archived" = FALSE)
-- only issues and tasks
AND "issues"."work_item_type_id" IN (1, 5)
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 245
OR "issues"."work_item_type_id" = 1
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 239
OR "work_item_current_statuses"."system_defined_status_id" IS NOT NULL
AND "work_item_current_statuses"."system_defined_status_id" = 1)
-- this is the mapping we're interested in
OR "issues"."work_item_type_id" = 1
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 31
AND "work_item_current_statuses"."updated_at" <= '2025-10-08 15:15:35.714756'
-- no current status and created before valid until of the mapping
-- this obviously selects a lot of elements in this case but
-- there's no better way to showcase how the query will work
OR "work_item_current_statuses"."id" IS NULL
AND "issues"."created_at" <= '2025-10-08 15:15:35.714756'))
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 21
Query plan for tasks only (less items) with the same logic.
Click to expand
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_id" LEFT OUTER
JOIN "work_item_current_statuses"
ON "work_item_current_statuses"."work_item_id" = "issues"."id"
LEFT JOIN project_features
ON projects.id = project_features.project_id
WHERE "projects"."namespace_id" IN
(SELECT "namespaces"."id"
FROM UNNEST( COALESCE(
(SELECT ids
FROM
(SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
FROM "namespace_descendants"
WHERE "namespace_descendants"."outdated_at" IS NULL
AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids
FROM
(SELECT ARRAY_AGG("namespaces"."id") AS ids
FROM
(SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids,
1)] AS id
FROM "namespaces"
WHERE "namespaces"."type" = 'Group'
AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query)) ) AS namespaces(id) )
AND (EXISTS
(SELECT 1
FROM "project_authorizations"
WHERE "project_authorizations"."user_id" = 12977570
AND (project_authorizations.project_id = projects.id))
OR projects.visibility_level IN (0,10,20))
AND ("project_features"."issues_access_level" > 0
OR "project_features"."issues_access_level" IS NULL)
AND "issues"."state_id" = 1
AND ("issues"."project_id" IS NULL
OR "projects"."archived" = FALSE)
-- only tasks
AND "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 245
OR "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 239
OR "work_item_current_statuses"."system_defined_status_id" IS NOT NULL
AND "work_item_current_statuses"."system_defined_status_id" = 1)
-- this is the mapping we're interested in
OR "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 31
AND "work_item_current_statuses"."updated_at" <= '2025-10-08 15:15:35.714756'
-- no current status and created before valid until of the mapping
-- this obviously selects a lot of elements in this case but
-- there's no better way to showcase how the query will work
OR "work_item_current_statuses"."id" IS NULL
AND "issues"."created_at" <= '2025-10-08 15:15:35.714756'))
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 21
Include items without current_status when list is filtered by status that is default
Query plan when we filter by the open default status for issues and tasks. Keep in mind that this is kind of a worst case scenario because the majority of items on the gitlab-org
namespace match this criteria.
As a comparison see this query plan where we only do the direct match of the "Start" custom status. Keep in mind though that we don't pick up items without a current status record which is currently wrong behavior.
The net addition is this condition for each included work item type:
"issues"."state_id" = 1
AND "work_item_current_statuses"."id" IS NULL
AND "issues"."work_item_type_id" = 1
Below you can find an example query with issues and tasks and two mappings below the added condition:
Click to expand
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_id" LEFT OUTER
JOIN "work_item_current_statuses"
ON "work_item_current_statuses"."work_item_id" = "issues"."id"
LEFT JOIN project_features
ON projects.id = project_features.project_id
WHERE (EXISTS
(SELECT 1
FROM "project_authorizations"
WHERE "project_authorizations"."user_id" = 1
AND (project_authorizations.project_id = projects.id))
OR projects.visibility_level IN (0,10,20))
AND ("project_features"."issues_access_level" > 0
OR "project_features"."issues_access_level" IS NULL)
AND "projects"."id" = 38
-- regular finder condition for open items
AND "issues"."state_id" = 1
AND "issues"."work_item_type_id" IN (1, 2, 5, 8, 7, 6, 3)
-- this is the direct match with the requested status
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 224
-- state_id = 1 is above, this is the added condition for issues
OR "issues"."work_item_type_id" = 1
AND "work_item_current_statuses"."id" IS NULL
-- this is the added condition for tasks
OR "issues"."work_item_type_id" = 5
AND "work_item_current_statuses"."id" IS NULL
-- the below are existing mappings that are included to resolve to the requested status
OR "issues"."work_item_type_id" = 1
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 219
OR "work_item_current_statuses"."system_defined_status_id" IS NOT NULL
AND "work_item_current_statuses"."system_defined_status_id" = 1)
OR "issues"."work_item_type_id" = 5
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 219
OR "work_item_current_statuses"."system_defined_status_id" IS NOT NULL
AND "work_item_current_statuses"."system_defined_status_id" = 1))
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 21
Group level
Here's a query plan for filtering by "Start" status which includes items without a current status for issues and tasks (so a lot of items are selected here).
Click to expand for query
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_id" LEFT OUTER
JOIN "work_item_current_statuses"
ON "work_item_current_statuses"."work_item_id" = "issues"."id"
LEFT JOIN project_features
ON projects.id = project_features.project_id
WHERE "projects"."namespace_id" IN
(SELECT "namespaces"."id"
FROM UNNEST( COALESCE(
(SELECT ids
FROM
(SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
FROM "namespace_descendants"
WHERE "namespace_descendants"."outdated_at" IS NULL
AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids
FROM
(SELECT ARRAY_AGG("namespaces"."id") AS ids
FROM
(SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids,
1)] AS id
FROM "namespaces"
WHERE "namespaces"."type" = 'Group'
AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query)) ) AS namespaces(id) )
AND (EXISTS
(SELECT 1
FROM "project_authorizations"
WHERE "project_authorizations"."user_id" = 12977570
AND (project_authorizations.project_id = projects.id))
OR projects.visibility_level IN (0,10,20))
AND ("project_features"."issues_access_level" > 0
OR "project_features"."issues_access_level" IS NULL)
-- state open is over here :wave:
AND "issues"."state_id" = 1
AND ("issues"."project_id" IS NULL
OR "projects"."archived" = FALSE)
-- limit to issues and tasks
AND "issues"."work_item_type_id" IN (1, 5)
-- filter status condition (Start)
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 30
-- include issues without current status
OR "issues"."work_item_type_id" = 1
AND "work_item_current_statuses"."id" IS NULL
-- include tasks without current status
OR "issues"."work_item_type_id" = 5
AND "work_item_current_statuses"."id" IS NULL
)
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 21
Here's a query plan for tasks only filtering by "Start" which includes items without current status.
Click to expand for query
SELECT "issues"."id",
"issues"."title",
"issues"."author_id",
"issues"."project_id",
"issues"."created_at",
"issues"."updated_at",
"issues"."description",
"issues"."milestone_id",
"issues"."iid",
"issues"."updated_by_id",
"issues"."weight",
"issues"."confidential",
"issues"."due_date",
"issues"."moved_to_id",
"issues"."lock_version",
"issues"."title_html",
"issues"."description_html",
"issues"."time_estimate",
"issues"."relative_position",
"issues"."service_desk_reply_to",
"issues"."cached_markdown_version",
"issues"."last_edited_at",
"issues"."last_edited_by_id",
"issues"."discussion_locked",
"issues"."closed_at",
"issues"."closed_by_id",
"issues"."state_id",
"issues"."duplicated_to_id",
"issues"."promoted_to_epic_id",
"issues"."health_status",
"issues"."sprint_id",
"issues"."blocking_issues_count",
"issues"."upvotes_count",
"issues"."work_item_type_id",
"issues"."namespace_id",
"issues"."start_date",
"issues"."imported_from",
"issues"."namespace_traversal_ids"
FROM "issues"
INNER JOIN "projects"
ON "projects"."id" = "issues"."project_id" LEFT OUTER
JOIN "work_item_current_statuses"
ON "work_item_current_statuses"."work_item_id" = "issues"."id"
LEFT JOIN project_features
ON projects.id = project_features.project_id
WHERE "projects"."namespace_id" IN
(SELECT "namespaces"."id"
FROM UNNEST( COALESCE(
(SELECT ids
FROM
(SELECT "namespace_descendants"."self_and_descendant_group_ids" AS ids
FROM "namespace_descendants"
WHERE "namespace_descendants"."outdated_at" IS NULL
AND "namespace_descendants"."namespace_id" = 9970) cached_query),
(SELECT ids
FROM
(SELECT ARRAY_AGG("namespaces"."id") AS ids
FROM
(SELECT namespaces.traversal_ids[array_length(namespaces.traversal_ids,
1)] AS id
FROM "namespaces"
WHERE "namespaces"."type" = 'Group'
AND (traversal_ids @> ('{9970}'))) namespaces) consistent_query)) ) AS namespaces(id) )
AND (EXISTS
(SELECT 1
FROM "project_authorizations"
WHERE "project_authorizations"."user_id" = 12977570
AND (project_authorizations.project_id = projects.id))
OR projects.visibility_level IN (0,10,20))
AND ("project_features"."issues_access_level" > 0
OR "project_features"."issues_access_level" IS NULL)
-- state open is over here :wave:
AND "issues"."state_id" = 1
AND ("issues"."project_id" IS NULL
OR "projects"."archived" = FALSE)
-- limit to tasks
AND "issues"."work_item_type_id" = 5
-- filter status condition (Start)
AND ("work_item_current_statuses"."custom_status_id" IS NOT NULL
AND "work_item_current_statuses"."custom_status_id" = 30
-- new condition to also include all without current status
OR "issues"."work_item_type_id" = 5
AND "work_item_current_statuses"."id" IS NULL
)
ORDER BY "issues"."created_at" DESC, "issues"."id" DESC LIMIT 21
Screenshots or screen recordings
Screen_Recording_2025-10-07_at_21.47.28
How to set up and validate locally
-
Enable
work_item_status_mvc2
feature flag -
Create new group and project
-
Create a new issue.
-
In the console remove the
current_status
record.WorkItem.last.current_status.delete
-
Go to status settings page
-
Add new status "Old default" and make it the default status
-
Remove "To do" (so it doesn't use that to pre-populate) and add mapping to "Old default"
-
Use a list page and see the work item has the default status of the lifecycle.
-
Create a new lifecycle
-
Add new status "Not default"
-
Now attach the issue work item type to the new lifecycle
-
When asked for a mapping for "Old default", select "Not default" and save
-
Verify a mapping with
old_status_role == "open"
was created:
WorkItems::Statuses::Custom::Mapping.last
- Reload the list page and see the work item has the "Not default" status.
- Filter the list by the "Not default" status and see it still shows the item with "Not default" status.
- Create a new item with the correct default status of the new lifecycle and see it gets the correct status.
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.