Adds work_item_type_ids filter to GraphQL

Add workItemTypeIds filter to GraphQL API

Problem

The current types filter on work item and issue queries uses the IssueTypeEnum enum, which is a static set of system-defined type names. This cannot support custom work item types because enum values can't be dynamically generated per-namespace. See #582561.

Solution

Add a new workItemTypeIds argument that accepts an array of WorkItems::Type GlobalIDs. This is additive — the existing types enum filter remains fully functional. The two filters are mutually exclusive, enforced via validates mutually_exclusive:.

What this MR does

Model — Two new scopes on Issue: with_work_item_type_ids and without_work_item_type_ids for direct integer-based filtering on work_item_type_id.

FinderIssues::IssueTypesFilter gains by_work_item_type_ids. We don't pass type ids through the provider for authorization/validation because the DB query will kind of do this anyway for us. It will either match items of existing types or not. This way we keep the filteirng logic super slim.

Only downside is when you filter by a single type which is not part of your namespace, you'll get 0 results.

EE Finder — The EE override for by_work_item_type_ids gates access to the epic work item type based on license availability, matching the existing behaviour of by_issue_types. When parent is nil, epic type is stripped since license status cannot be verified.

GraphQLworkItemTypeIds argument added to:

  • WorkItems::SharedFilterArguments (work item list queries)
  • Issues::BaseResolver (issue queries)
  • Boards::BoardIssueInputBaseType (board list issues)
  • WorkItems::NegatedWorkItemFilterInputType (negated filtering)

Each argument uses a prepare lambda to extract model_id from the GID before the finder receives it. Mutual exclusivity with types is validated at the GraphQL layer.

Scopes

This MR adds two new scopes which work with ids exclusively. They produce the same query in the end.

See scopes here as an overview

  # Existing
  scope :with_issue_type, ->(types) {
    type_ids = WorkItems::TypesFramework::Provider.new.ids_by_base_types(types)

    where(work_item_type_id: type_ids)
  }
  scope :without_issue_type, ->(types) {
    type_ids = WorkItems::TypesFramework::Provider.new.ids_by_base_types(types)

    where.not(work_item_type_id: type_ids)
  }

  # New
  scope :with_work_item_type_ids, ->(ids) { where(work_item_type_id: ids) }
  scope :without_work_item_type_ids, ->(ids) { where.not(work_item_type_id: ids) }

DB queries

The new scopes and existing scopes produce the same queries. They use work_item_type_id which is indexed (composite index using general access patterns across namespace, state_id, work_item_type_id`). So we're actually not reviewing the performance of this query because it's the same than today. The DB review is mostly about the finder per se and the scope 🙂

This is a query that was generated when filtering for open issues (using GitLab project id)

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 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" = 278964
        AND "issues"."state_id" = 1
        AND "issues"."work_item_type_id" = 1
ORDER BY  "issues"."created_at" DESC, "issues"."id" DESC LIMIT 101

Postgres.ai link

https://explain-depesz.postgres.ai/s/U8

 Limit  (cost=2.84..121.52 rows=101 width=999) (actual time=95.859..219.140 rows=101 loops=1)
   Buffers: shared hit=17 read=160 dirtied=26
   WAL: records=26 fpi=26 bytes=171514
   ->  Incremental Sort  (cost=2.84..142141.17 rows=120965 width=999) (actual time=95.858..219.128 rows=101 loops=1)
         Sort Key: issues.created_at DESC, issues.id DESC
         Buffers: shared hit=17 read=160 dirtied=26
         WAL: records=26 fpi=26 bytes=171514
         ->  Nested Loop  (cost=1.70..136697.74 rows=120965 width=999) (actual time=29.396..218.623 rows=102 loops=1)
               Buffers: shared hit=8 read=160 dirtied=26
               WAL: records=26 fpi=26 bytes=171514
               ->  Index Scan Backward using index_issues_on_work_item_type_id_project_id_created_at_state on public.issues  (cost=0.57..135148.46 rows=123080 width=999) (actual time=7.503..196.394 rows=102 loops=1)
                     Index Cond: ((issues.work_item_type_id = 1) AND (issues.project_id = 278964) AND (issues.state_id = 1))
                     Buffers: shared hit=7 read=143 dirtied=25
                     WAL: records=25 fpi=25 bytes=166977
               ->  Materialize  (cost=1.13..10.79 rows=1 width=4) (actual time=0.215..0.216 rows=1 loops=102)
                     Buffers: shared hit=1 read=17 dirtied=1
                     WAL: records=1 fpi=1 bytes=4537
                     ->  Nested Loop Left Join  (cost=1.13..10.78 rows=1 width=4) (actual time=21.881..21.908 rows=1 loops=1)
                           Filter: ((project_features.issues_access_level > 0) OR (project_features.issues_access_level IS NULL))
                           Rows Removed by Filter: 0
                           Buffers: shared hit=1 read=17 dirtied=1
                           WAL: records=1 fpi=1 bytes=4537
                           ->  Index Scan using idx_projects_on_repository_storage_last_repository_updated_at on public.projects  (cost=0.56..7.18 rows=1 width=4) (actual time=16.609..16.634 rows=1 loops=1)
                                 Index Cond: (projects.id = 278964)
                                 Filter: (EXISTS(SubPlan 1) OR (projects.visibility_level = ANY ('{0,10,20}'::integer[])))
                                 Rows Removed by Filter: 0
                                 Buffers: shared hit=1 read=12 dirtied=1
                                 WAL: records=1 fpi=1 bytes=4537
                                 SubPlan 1
                                   ->  Index Only Scan using index_project_authorizations_on_project_user_access_level on public.project_authorizations  (cost=0.58..3.60 rows=1 width=0) (actual time=6.357..6.357 rows=1 loops=1)
                                         Index Cond: ((project_authorizations.project_id = projects.id) AND (project_authorizations.user_id = 1))
                                         Heap Fetches: 0
                                         Buffers: shared read=5
                           ->  Index Scan using index_project_features_on_project_id on public.project_features  (cost=0.57..3.58 rows=1 width=8) (actual time=5.267..5.267 rows=1 loops=1)
                                 Index Cond: (project_features.project_id = 278964)
                                 Buffers: shared read=5
Settings: work_mem = '230MB', seq_page_cost = '4', effective_cache_size = '472585MB', jit = 'off', random_page_cost = '1.5'

How to verify

Filter by work item type GIDs

Screenshot_2026-03-04_at_17.05.15

query {
  project(fullPath: "my/project") {
    workItems(workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"]) {
      nodes { id title 
        workItemType {
          id
        }
      }
    }
  }
}

Negated filtering

Screenshot_2026-03-04_at_17.04.59

query {
  project(fullPath: "my/project") {
    workItems(not: { workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"] }) {
      nodes { id title 
        workItemType {
          id
        }
      }
    }
  }
}

Multiple types (OR)

image

query {
  project(fullPath: "gitlab-org/gitlab") {
    workItems(workItemTypeIds: 
      ["gid://gitlab/WorkItems::Type/9", "gid://gitlab/WorkItems::Type/5"]
    ) {
      nodes { id title 
        workItemType {
          id
        }
      }
    }
  }
}

Also works for issues endpoint

Also single, multiple and negated.

image

query {
  project(fullPath: "gitlab-org/gitlab") {
    id
    issues(workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"]) {
      nodes {
        id
        title
        type
      }
    }
  }
}

Also works for board lists endpoint

Example uses negated types, but single type and array work. Same usage as work item and issue.

screenshot

query {
  project(fullPath: "gitlab-org/gitlab") {
    board(id: "gid://gitlab/Board/3") {
      lists {
        nodes {
          issues(filters: { not: { workItemTypeIds: ["gid://gitlab/WorkItems::Type/9"] } }) {
            nodes {
              id
              title
              type
            }
          }
        }
      }
    }
  }
}
  • Part 1 of 3: This MR (Finder + Model + GraphQL)
  • Part 2: REST API (adds work_item_type_ids param to Issues and Work Items REST endpoints)
  • Part 3: Elasticsearch (adds work_item_type_ids to ALLOWED_ES_FILTERS)
  • Closes #582561 (after all 3 MRs)

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.

Edited by Marc Saleiko

Merge request reports

Loading