diff --git a/app/graphql/resolvers/issues/base_resolver.rb b/app/graphql/resolvers/issues/base_resolver.rb
index 88579b09482fdd3d2ce18d09459c1153c2d135c3..9a2c4572abbf18e9ccee74ef985da6f9887cf4b2 100644
--- a/app/graphql/resolvers/issues/base_resolver.rb
+++ b/app/graphql/resolvers/issues/base_resolver.rb
@@ -97,7 +97,7 @@ def accept_release_tag
       end
 
       def ready?(**args)
-        if args[:or].present? && ::Feature.disabled?(:or_issuable_queries, resource_parent)
+        if args[:or].present? && or_issuable_queries_disabled?
           raise ::Gitlab::Graphql::Errors::ArgumentError,
             "'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled."
         end
@@ -115,6 +115,14 @@ def ready?(**args)
 
       private
 
+      def or_issuable_queries_disabled?
+        if respond_to?(:resource_parent, true)
+          ::Feature.disabled?(:or_issuable_queries, resource_parent)
+        else
+          ::Feature.disabled?(:or_issuable_queries)
+        end
+      end
+
       def prepare_finder_params(args)
         params = super(args)
         params[:not] = params[:not].to_h if params[:not]
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e3102a7d32a46cb86bd7fe9b0f6275a45ed0ef22
--- /dev/null
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Resolvers
+  class IssuesResolver < Issues::BaseResolver
+    prepend ::Issues::LookAheadPreloads
+    include ::Issues::SortArguments
+
+    argument :state, Types::IssuableStateEnum,
+              required: false,
+              description: 'Current state of this issue.'
+
+    # see app/graphql/types/issue_connection.rb
+    type 'Types::IssueConnection', null: true
+
+    def resolve_with_lookahead(**args)
+      return unless Feature.enabled?(:root_level_issues_query)
+
+      issues = apply_lookahead(
+        IssuesFinder.new(current_user, prepare_finder_params(args)).execute
+      )
+
+      if non_stable_cursor_sort?(args[:sort])
+        # Certain complex sorts are not supported by the stable cursor pagination yet.
+        # In these cases, we use offset pagination, so we return the correct connection.
+        offset_pagination(issues)
+      else
+        issues
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 8fe6d364118ba9b5f23635bcb1f3d47e3b4b9571..21cb3f9e06cd6ae65b01e92fd75696d86c216023 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -82,6 +82,13 @@ class QueryType < ::Types::BaseObject
 
     field :echo, resolver: Resolvers::EchoResolver
 
+    field :issues,
+          null: true,
+          alpha: { milestone: '15.6' },
+          resolver: Resolvers::IssuesResolver,
+          description: 'Issues visible by the current user.' \
+                       ' Returns null if the `root_level_issues_query` feature flag is disabled.'
+
     field :issue, Types::IssueType,
           null: true,
           description: 'Find an issue.' do
diff --git a/config/feature_flags/development/root_level_issues_query.yml b/config/feature_flags/development/root_level_issues_query.yml
new file mode 100644
index 0000000000000000000000000000000000000000..308f916816705e1b4e58419573de8c94a73a91f5
--- /dev/null
+++ b/config/feature_flags/development/root_level_issues_query.yml
@@ -0,0 +1,8 @@
+---
+name: root_level_issues_query
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102348
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/382250
+milestone: '15.6'
+type: development
+group: group::project management
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 721b15db3d946e7250615836062386d831663d29..8c98f0660099fcfd7856752ff91878cc8582d805 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -214,6 +214,57 @@ Returns [`Issue`](#issue).
 | ---- | ---- | ----------- |
 | <a id="queryissueid"></a>`id` | [`IssueID!`](#issueid) | Global ID of the issue. |
 
+### `Query.issues`
+
+Issues visible by the current user. Returns null if the `root_level_issues_query` feature flag is disabled.
+
+WARNING:
+**Introduced** in 15.6.
+This feature is in Alpha. It can be changed or removed at any time.
+
+Returns [`IssueConnection`](#issueconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#connection-pagination-arguments):
+`before: String`, `after: String`, `first: Int`, `last: Int`.
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="queryissuesassigneeid"></a>`assigneeId` | [`String`](#string) | ID of a user assigned to the issues. Wildcard values "NONE" and "ANY" are supported. |
+| <a id="queryissuesassigneeusername"></a>`assigneeUsername` **{warning-solid}** | [`String`](#string) | **Deprecated** in 13.11. Use `assigneeUsernames`. |
+| <a id="queryissuesassigneeusernames"></a>`assigneeUsernames` | [`[String!]`](#string) | Usernames of users assigned to the issue. |
+| <a id="queryissuesauthorusername"></a>`authorUsername` | [`String`](#string) | Username of the author of the issue. |
+| <a id="queryissuesclosedafter"></a>`closedAfter` | [`Time`](#time) | Issues closed after this date. |
+| <a id="queryissuesclosedbefore"></a>`closedBefore` | [`Time`](#time) | Issues closed before this date. |
+| <a id="queryissuesconfidential"></a>`confidential` | [`Boolean`](#boolean) | Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues. |
+| <a id="queryissuescreatedafter"></a>`createdAfter` | [`Time`](#time) | Issues created after this date. |
+| <a id="queryissuescreatedbefore"></a>`createdBefore` | [`Time`](#time) | Issues created before this date. |
+| <a id="queryissuescrmcontactid"></a>`crmContactId` | [`String`](#string) | ID of a contact assigned to the issues. |
+| <a id="queryissuescrmorganizationid"></a>`crmOrganizationId` | [`String`](#string) | ID of an organization assigned to the issues. |
+| <a id="queryissuesepicid"></a>`epicId` | [`String`](#string) | ID of an epic associated with the issues, "none" and "any" values are supported. |
+| <a id="queryissueshealthstatusfilter"></a>`healthStatusFilter` | [`HealthStatusFilter`](#healthstatusfilter) | Health status of the issue, "none" and "any" values are supported. |
+| <a id="queryissuesiid"></a>`iid` | [`String`](#string) | IID of the issue. For example, "1". |
+| <a id="queryissuesiids"></a>`iids` | [`[String!]`](#string) | List of IIDs of issues. For example, `["1", "2"]`. |
+| <a id="queryissuesin"></a>`in` | [`[IssuableSearchableField!]`](#issuablesearchablefield) | Specify the fields to perform the search in. Defaults to `[TITLE, DESCRIPTION]`. Requires the `search` argument.'. |
+| <a id="queryissuesincludesubepics"></a>`includeSubepics` | [`Boolean`](#boolean) | Whether to include subepics when filtering issues by epicId. |
+| <a id="queryissuesiterationid"></a>`iterationId` | [`[ID]`](#id) | List of iteration Global IDs applied to the issue. |
+| <a id="queryissuesiterationwildcardid"></a>`iterationWildcardId` | [`IterationWildcardId`](#iterationwildcardid) | Filter by iteration ID wildcard. |
+| <a id="queryissueslabelname"></a>`labelName` | [`[String]`](#string) | Labels applied to this issue. |
+| <a id="queryissuesmilestonetitle"></a>`milestoneTitle` | [`[String]`](#string) | Milestone applied to this issue. |
+| <a id="queryissuesmilestonewildcardid"></a>`milestoneWildcardId` | [`MilestoneWildcardId`](#milestonewildcardid) | Filter issues by milestone ID wildcard. |
+| <a id="queryissuesmyreactionemoji"></a>`myReactionEmoji` | [`String`](#string) | Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported. |
+| <a id="queryissuesnot"></a>`not` | [`NegatedIssueFilterInput`](#negatedissuefilterinput) | Negated arguments. |
+| <a id="queryissuesor"></a>`or` | [`UnionedIssueFilterInput`](#unionedissuefilterinput) | List of arguments with inclusive OR. |
+| <a id="queryissuessearch"></a>`search` | [`String`](#string) | Search query for title or description. |
+| <a id="queryissuessort"></a>`sort` | [`IssueSort`](#issuesort) | Sort issues by this criteria. |
+| <a id="queryissuesstate"></a>`state` | [`IssuableState`](#issuablestate) | Current state of this issue. |
+| <a id="queryissuestypes"></a>`types` | [`[IssueType!]`](#issuetype) | Filter issues by the given issue types. |
+| <a id="queryissuesupdatedafter"></a>`updatedAfter` | [`Time`](#time) | Issues updated after this date. |
+| <a id="queryissuesupdatedbefore"></a>`updatedBefore` | [`Time`](#time) | Issues updated before this date. |
+| <a id="queryissuesweight"></a>`weight` | [`String`](#string) | Weight applied to the issue, "none" and "any" values are supported. |
+
 ### `Query.iteration`
 
 Find an iteration.
diff --git a/spec/requests/api/graphql/issues_spec.rb b/spec/requests/api/graphql/issues_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8838ad78f72cd78885934bbccde444212bb0823a
--- /dev/null
+++ b/spec/requests/api/graphql/issues_spec.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting an issue list at root level' do
+  include GraphqlHelpers
+
+  let_it_be(:developer) { create(:user) }
+  let_it_be(:reporter) { create(:user) }
+  let_it_be(:group1) { create(:group).tap { |group| group.add_developer(developer) } }
+  let_it_be(:group2) { create(:group).tap { |group| group.add_developer(developer) } }
+  let_it_be(:project_a) { create(:project, :repository, :public, group: group1) }
+  let_it_be(:project_b) { create(:project, :repository, :private, group: group1) }
+  let_it_be(:project_c) { create(:project, :repository, :public, group: group2) }
+  let_it_be(:project_d) { create(:project, :repository, :private, group: group2) }
+  let_it_be(:early_milestone) { create(:milestone, project: project_d, due_date: 10.days.from_now) }
+  let_it_be(:late_milestone) { create(:milestone, project: project_c, due_date: 30.days.from_now) }
+  let_it_be(:priority1) { create(:label, project: project_c, priority: 1) }
+  let_it_be(:priority2) { create(:label, project: project_d, priority: 5) }
+  let_it_be(:priority3) { create(:label, project: project_a, priority: 10) }
+
+  let_it_be(:issue_a) { create(:issue, project: project_a, labels: [priority3]) }
+  let_it_be(:issue_b) { create(:issue, :with_alert, project: project_b, discussion_locked: true) }
+  let_it_be(:issue_c) do
+    create(
+      :issue,
+      project: project_c,
+      title: 'title matching issue plus',
+      labels: [priority1],
+      milestone: late_milestone
+    )
+  end
+
+  let_it_be(:issue_d) { create(:issue, :with_alert, project: project_d, discussion_locked: true, labels: [priority2]) }
+  let_it_be(:issue_e) { create(:issue, project: project_d, milestone: early_milestone) }
+
+  let(:issue_filter_params) { {} }
+
+  let(:fields) do
+    <<~QUERY
+      nodes {
+        #{all_graphql_fields_for('issues'.classify)}
+      }
+    QUERY
+  end
+
+  before_all do
+    group2.add_reporter(reporter)
+  end
+
+  context 'when the root_level_issues_query feature flag is disabled' do
+    before do
+      stub_feature_flags(root_level_issues_query: false)
+    end
+
+    it 'the field returns null' do
+      post_graphql(query, current_user: developer)
+
+      expect(graphql_data).to eq('issues' => nil)
+    end
+  end
+
+  it_behaves_like 'graphql issue list request spec' do
+    subject(:post_query) { post_graphql(query, current_user: current_user) }
+
+    let(:current_user) { developer }
+    let(:another_user) { reporter }
+    let(:issues_data) { graphql_data['issues']['nodes'] }
+    let(:issue_ids) { graphql_dig_at(issues_data, :id) }
+
+    # filters
+    let(:expected_negated_assignee_issues) { [issue_b, issue_c, issue_d, issue_e] }
+    let(:expected_unioned_assignee_issues) { [issue_a, issue_c] }
+    let(:voted_issues) { [issue_a, issue_c] }
+    let(:no_award_issues) { [issue_b, issue_d, issue_e] }
+    let(:locked_discussion_issues) { [issue_b, issue_d] }
+    let(:unlocked_discussion_issues) { [issue_a, issue_c, issue_e] }
+    let(:search_title_term) { 'matching issue' }
+    let(:title_search_issue) { issue_c }
+
+    # sorting
+    let(:data_path) { [:issues] }
+    let(:expected_severity_sorted_asc) { [issue_c, issue_a, issue_b, issue_e, issue_d] }
+    let(:expected_priority_sorted_asc) { [issue_e, issue_c, issue_d, issue_a, issue_b] }
+    let(:expected_priority_sorted_desc) { [issue_c, issue_e, issue_a, issue_d, issue_b] }
+
+    before_all do
+      issue_a.assignee_ids = developer.id
+      issue_c.assignee_ids = reporter.id
+
+      create(:award_emoji, :upvote, user: developer, awardable: issue_a)
+      create(:award_emoji, :upvote, user: developer, awardable: issue_c)
+
+      # severity sorting
+      create(:issuable_severity, issue: issue_a, severity: :unknown)
+      create(:issuable_severity, issue: issue_b, severity: :low)
+      create(:issuable_severity, issue: issue_d, severity: :critical)
+      create(:issuable_severity, issue: issue_e, severity: :high)
+    end
+
+    def pagination_query(params)
+      graphql_query_for(
+        :issues,
+        params,
+        "#{page_info} nodes { id }"
+      )
+    end
+  end
+
+  def query(params = issue_filter_params)
+    graphql_query_for(
+      :issues,
+      params,
+      fields
+    )
+  end
+end
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index 3d09206fa96f0c483892852dd238733b8494c8f5..214165cb1716727ec0bf0044d4978f75f17d22ad 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -8,140 +8,74 @@
   let_it_be(:group) { create(:group) }
   let_it_be(:project) { create(:project, :repository, :public, group: group) }
   let_it_be(:current_user) { create(:user) }
-  let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true) }
-  let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project) }
-  let_it_be(:issues, reload: true) { [issue_a, issue_b] }
+  let_it_be(:another_user) { create(:user).tap { |u| group.add_reporter(u) } }
+  let_it_be(:early_milestone) { create(:milestone, project: project, due_date: 10.days.from_now) }
+  let_it_be(:late_milestone) { create(:milestone, project: project, due_date: 30.days.from_now) }
+  let_it_be(:priority1) { create(:label, project: project, priority: 1) }
+  let_it_be(:priority2) { create(:label, project: project, priority: 5) }
+  let_it_be(:priority3) { create(:label, project: project, priority: 10) }
+
+  let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true, labels: [priority3]) }
+  let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project, title: 'title matching issue i') }
+  let_it_be(:issue_c) { create(:issue, project: project, labels: [priority1], milestone: late_milestone) }
+  let_it_be(:issue_d) { create(:issue, project: project, labels: [priority2]) }
+  let_it_be(:issue_e) { create(:issue, project: project, milestone: early_milestone) }
+  let_it_be(:issues, reload: true) { [issue_a, issue_b, issue_c, issue_d, issue_e] }
 
   let(:issue_a_gid) { issue_a.to_global_id.to_s }
   let(:issue_b_gid) { issue_b.to_global_id.to_s }
-  let(:issues_data) { graphql_data['project']['issues']['edges'] }
+  let(:issues_data) { graphql_data['project']['issues']['nodes'] }
   let(:issue_filter_params) { {} }
 
   let(:fields) do
     <<~QUERY
-    edges {
-      node {
-        #{all_graphql_fields_for('issues'.classify)}
-      }
+    nodes {
+      #{all_graphql_fields_for('issues'.classify)}
     }
     QUERY
   end
 
-  it_behaves_like 'a working graphql query' do
-    before do
-      post_graphql(query, current_user: current_user)
-    end
-  end
-
-  it 'includes a web_url' do
-    post_graphql(query, current_user: current_user)
-
-    expect(issues_data[0]['node']['webUrl']).to be_present
-  end
-
-  it 'includes discussion locked' do
-    post_graphql(query, current_user: current_user)
-
-    expect(issues_data[0]['node']['discussionLocked']).to eq(false)
-    expect(issues_data[1]['node']['discussionLocked']).to eq(true)
-  end
-
-  context 'when both assignee_username filters are provided' do
-    let(:issue_filter_params) { { assignee_username: current_user.username, assignee_usernames: [current_user.username] } }
-
-    it 'returns a mutually exclusive param error' do
-      post_graphql(query, current_user: current_user)
-
-      expect_graphql_errors_to_include('only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.')
-    end
-  end
-
-  context 'when filtering by a negated argument' do
-    let(:issue_filter_params) { { not: { assignee_usernames: current_user.username } } }
-
-    it 'returns correctly filtered issues' do
-      issue_a.assignee_ids = current_user.id
-
-      post_graphql(query, current_user: current_user)
-
-      expect(issues_ids).to contain_exactly(issue_b_gid)
-    end
-
-    context 'when argument is blank' do
-      let(:issue_filter_params) { { not: {} } }
-
-      it 'does not raise an error' do
-        post_graphql(query, current_user: current_user)
-
-        expect_graphql_errors_to_be_empty
-      end
-    end
-  end
-
-  context 'when filtering by a unioned argument' do
-    let(:another_user) { create(:user) }
-    let(:issue_filter_params) { { or: { assignee_usernames: [current_user.username, another_user.username] } } }
-
-    it 'returns correctly filtered issues' do
+  # All new specs should be added to the shared example if the change also
+  # affects the `issues` query at the root level of the API.
+  # Shared example also used in spec/requests/api/graphql/issues_spec.rb
+  it_behaves_like 'graphql issue list request spec' do
+    subject(:post_query) { post_graphql(query, current_user: current_user) }
+
+    # filters
+    let(:expected_negated_assignee_issues) { [issue_b, issue_c, issue_d, issue_e] }
+    let(:expected_unioned_assignee_issues) { [issue_a, issue_b] }
+    let(:voted_issues) { [issue_a] }
+    let(:no_award_issues) { [issue_b, issue_c, issue_d, issue_e] }
+    let(:locked_discussion_issues) { [issue_a] }
+    let(:unlocked_discussion_issues) { [issue_b, issue_c, issue_d, issue_e] }
+    let(:search_title_term) { 'matching issue' }
+    let(:title_search_issue) { issue_b }
+
+    # sorting
+    let(:data_path) { [:project, :issues] }
+    let(:expected_severity_sorted_asc) { [issue_c, issue_a, issue_b, issue_e, issue_d] }
+    let(:expected_priority_sorted_asc) { [issue_e, issue_c, issue_d, issue_a, issue_b] }
+    let(:expected_priority_sorted_desc) { [issue_c, issue_e, issue_a, issue_d, issue_b] }
+
+    before_all do
       issue_a.assignee_ids = current_user.id
       issue_b.assignee_ids = another_user.id
 
-      post_graphql(query, current_user: current_user)
-
-      expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid)
-    end
-
-    context 'when argument is blank' do
-      let(:issue_filter_params) { { or: {} } }
-
-      it 'does not raise an error' do
-        post_graphql(query, current_user: current_user)
-
-        expect_graphql_errors_to_be_empty
-      end
-    end
-
-    context 'when feature flag is disabled' do
-      it 'returns an error' do
-        stub_feature_flags(or_issuable_queries: false)
+      create(:award_emoji, :upvote, user: current_user, awardable: issue_a)
 
-        post_graphql(query, current_user: current_user)
-
-        expect_graphql_errors_to_include("'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled.")
-      end
+      # severity sorting
+      create(:issuable_severity, issue: issue_a, severity: :unknown)
+      create(:issuable_severity, issue: issue_b, severity: :low)
+      create(:issuable_severity, issue: issue_d, severity: :critical)
+      create(:issuable_severity, issue: issue_e, severity: :high)
     end
-  end
-
-  context 'filtering by my_reaction_emoji' do
-    using RSpec::Parameterized::TableSyntax
-
-    let_it_be(:upvote_award) { create(:award_emoji, :upvote, user: current_user, awardable: issue_a) }
-
-    where(:value, :gids) do
-      'thumbsup'   | lazy { [issue_a_gid] }
-      'ANY'        | lazy { [issue_a_gid] }
-      'any'        | lazy { [issue_a_gid] }
-      'AnY'        | lazy { [issue_a_gid] }
-      'NONE'       | lazy { [issue_b_gid] }
-      'thumbsdown' | lazy { [] }
-    end
-
-    with_them do
-      let(:issue_filter_params) { { my_reaction_emoji: value } }
 
-      it 'returns correctly filtered issues' do
-        post_graphql(query, current_user: current_user)
-
-        expect(issues_ids).to eq(gids)
-      end
-    end
-  end
-
-  context 'when filtering by search' do
-    it_behaves_like 'query with a search term' do
-      let(:issuable_data) { issues_data }
-      let(:user) { current_user }
-      let_it_be(:issuable) { create(:issue, project: project, description: 'bar') }
+    def pagination_query(params)
+      graphql_query_for(
+        :project,
+        { full_path: project.full_path },
+        query_graphql_field(:issues, params, "#{page_info} nodes { id }")
+      )
     end
   end
 
@@ -211,10 +145,10 @@
       it 'returns issues without confidential issues' do
         post_graphql(query, current_user: current_user)
 
-        expect(issues_data.size).to eq(2)
+        expect(issues_data.size).to eq(5)
 
         issues_data.each do |issue|
-          expect(issue.dig('node', 'confidential')).to eq(false)
+          expect(issue['confidential']).to eq(false)
         end
       end
 
@@ -234,7 +168,7 @@
         it 'returns correctly filtered issues' do
           post_graphql(query, current_user: current_user)
 
-          expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid)
+          expect(issue_ids).to match_array(issues.map { |i| i.to_gid.to_s })
         end
       end
     end
@@ -247,13 +181,13 @@
       it 'returns issues with confidential issues' do
         post_graphql(query, current_user: current_user)
 
-        expect(issues_data.size).to eq(3)
+        expect(issues_data.size).to eq(6)
 
         confidentials = issues_data.map do |issue|
-          issue.dig('node', 'confidential')
+          issue['confidential']
         end
 
-        expect(confidentials).to eq([true, false, false])
+        expect(confidentials).to contain_exactly(true, false, false, false, false, false)
       end
 
       context 'filtering for confidential issues' do
@@ -262,7 +196,7 @@
         it 'returns correctly filtered issues' do
           post_graphql(query, current_user: current_user)
 
-          expect(issues_ids).to contain_exactly(confidential_issue_gid)
+          expect(issue_ids).to contain_exactly(confidential_issue_gid)
         end
       end
 
@@ -272,7 +206,7 @@
         it 'returns correctly filtered issues' do
           post_graphql(query, current_user: current_user)
 
-          expect(issues_ids).to contain_exactly(issue_a_gid, issue_b_gid)
+          expect(issue_ids).to match_array([issue_a, issue_b, issue_c, issue_d, issue_e].map { |i| i.to_gid.to_s })
         end
       end
     end
@@ -294,37 +228,7 @@ def pagination_results_data(data)
       data.map { |issue| issue['iid'].to_i }
     end
 
-    context 'when sorting by severity' do
-      let_it_be(:severty_issue1) { create(:issue, project: sort_project) }
-      let_it_be(:severty_issue2) { create(:issue, project: sort_project) }
-      let_it_be(:severty_issue3) { create(:issue, project: sort_project) }
-      let_it_be(:severty_issue4) { create(:issue, project: sort_project) }
-      let_it_be(:severty_issue5) { create(:issue, project: sort_project) }
-
-      before(:all) do
-        create(:issuable_severity, issue: severty_issue1, severity: :unknown)
-        create(:issuable_severity, issue: severty_issue2, severity: :low)
-        create(:issuable_severity, issue: severty_issue4, severity: :critical)
-        create(:issuable_severity, issue: severty_issue5, severity: :high)
-      end
-
-      context 'when ascending' do
-        it_behaves_like 'sorted paginated query' do
-          let(:sort_param)       { :SEVERITY_ASC }
-          let(:first_param)      { 2 }
-          let(:all_records) { [severty_issue3.iid, severty_issue1.iid, severty_issue2.iid, severty_issue5.iid, severty_issue4.iid] }
-        end
-      end
-
-      context 'when descending' do
-        it_behaves_like 'sorted paginated query' do
-          let(:sort_param)       { :SEVERITY_DESC }
-          let(:first_param)      { 2 }
-          let(:all_records) { [severty_issue4.iid, severty_issue5.iid, severty_issue2.iid, severty_issue1.iid, severty_issue3.iid] }
-        end
-      end
-    end
-
+    # rubocop:disable RSpec/MultipleMemoizedHelpers
     context 'when sorting by due date' do
       let_it_be(:due_issue1) { create(:issue, project: sort_project, due_date: 3.days.from_now) }
       let_it_be(:due_issue2) { create(:issue, project: sort_project, due_date: nil) }
@@ -370,41 +274,6 @@ def pagination_results_data(data)
       end
     end
 
-    context 'when sorting by priority' do
-      let_it_be(:on_project) { { project: sort_project } }
-      let_it_be(:early_milestone) { create(:milestone, **on_project, due_date: 10.days.from_now) }
-      let_it_be(:late_milestone) { create(:milestone, **on_project, due_date: 30.days.from_now) }
-      let_it_be(:priority_1) { create(:label, **on_project, priority: 1) }
-      let_it_be(:priority_2) { create(:label, **on_project, priority: 5) }
-      let_it_be(:priority_issue1) { create(:issue, **on_project, labels: [priority_1], milestone: late_milestone) }
-      let_it_be(:priority_issue2) { create(:issue, **on_project, labels: [priority_2]) }
-      let_it_be(:priority_issue3) { create(:issue, **on_project, milestone: early_milestone) }
-      let_it_be(:priority_issue4) { create(:issue, **on_project) }
-
-      context 'when ascending' do
-        it_behaves_like 'sorted paginated query' do
-          let(:sort_param)       { :PRIORITY_ASC }
-          let(:first_param)      { 2 }
-          let(:all_records) do
-            [
-              priority_issue3.iid, priority_issue1.iid,
-              priority_issue2.iid, priority_issue4.iid
-            ]
-          end
-        end
-      end
-
-      context 'when descending' do
-        it_behaves_like 'sorted paginated query' do
-          let(:sort_param)       { :PRIORITY_DESC }
-          let(:first_param)      { 2 }
-          let(:all_records) do
-            [priority_issue1.iid, priority_issue3.iid, priority_issue2.iid, priority_issue4.iid]
-          end
-        end
-      end
-    end
-
     context 'when sorting by label priority' do
       let_it_be(:label1) { create(:label, project: sort_project, priority: 1) }
       let_it_be(:label2) { create(:label, project: sort_project, priority: 5) }
@@ -430,6 +299,7 @@ def pagination_results_data(data)
         end
       end
     end
+    # rubocop:enable RSpec/MultipleMemoizedHelpers
 
     context 'when sorting by milestone due date' do
       let_it_be(:early_milestone)  { create(:milestone, project: sort_project, due_date: 10.days.from_now) }
@@ -459,8 +329,7 @@ def pagination_results_data(data)
   context 'when fetching alert management alert' do
     let(:fields) do
       <<~QUERY
-      edges {
-        node {
+        nodes {
           iid
           alertManagementAlert {
             title
@@ -471,7 +340,6 @@ def pagination_results_data(data)
             }
           }
         }
-      }
       QUERY
     end
 
@@ -491,7 +359,7 @@ def pagination_results_data(data)
     it 'returns the alert data' do
       post_graphql(query, current_user: current_user)
 
-      alert_titles = issues_data.map { |issue| issue.dig('node', 'alertManagementAlert', 'title') }
+      alert_titles = issues_data.map { |issue| issue.dig('alertManagementAlert', 'title') }
       expected_titles = issues.map { |issue| issue.alert_management_alert&.title }
 
       expect(alert_titles).to contain_exactly(*expected_titles)
@@ -500,7 +368,7 @@ def pagination_results_data(data)
     it 'returns the alerts data' do
       post_graphql(query, current_user: current_user)
 
-      alert_titles = issues_data.map { |issue| issue.dig('node', 'alertManagementAlerts', 'nodes') }
+      alert_titles = issues_data.map { |issue| issue.dig('alertManagementAlerts', 'nodes') }
       expected_titles = issues.map do |issue|
         issue.alert_management_alerts.map { |alert| { 'title' => alert.title } }
       end
@@ -541,13 +409,11 @@ def clean_state_query
   context 'when fetching labels' do
     let(:fields) do
       <<~QUERY
-        edges {
-          node {
-            id
-            labels {
-              nodes {
-                id
-              }
+        nodes {
+          id
+          labels {
+            nodes {
+              id
             }
           }
         }
@@ -563,8 +429,8 @@ def clean_state_query
     end
 
     def response_label_ids(response_data)
-      response_data.map do |edge|
-        edge['node']['labels']['nodes'].map { |u| u['id'] }
+      response_data.map do |node|
+        node['labels']['nodes'].map { |u| u['id'] }
       end.flatten
     end
 
@@ -574,7 +440,7 @@ def labels_as_global_ids(issues)
 
     it 'avoids N+1 queries', :aggregate_failures do
       control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
-      expect(issues_data.count).to eq(2)
+      expect(issues_data.count).to eq(5)
       expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(issues))
 
       new_issues = issues + [create(:issue, project: project, labels: [create(:label, project: project)])]
@@ -582,8 +448,8 @@ def labels_as_global_ids(issues)
       expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
       # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb)
       # so we have to parse the body ourselves the second time
-      issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges']
-      expect(issues_data.count).to eq(3)
+      issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['nodes']
+      expect(issues_data.count).to eq(6)
       expect(response_label_ids(issues_data)).to match_array(labels_as_global_ids(new_issues))
     end
   end
@@ -591,13 +457,11 @@ def labels_as_global_ids(issues)
   context 'when fetching assignees' do
     let(:fields) do
       <<~QUERY
-        edges {
-          node {
-            id
-            assignees {
-              nodes {
-                id
-              }
+        nodes {
+          id
+          assignees {
+            nodes {
+              id
             }
           }
         }
@@ -613,8 +477,8 @@ def labels_as_global_ids(issues)
     end
 
     def response_assignee_ids(response_data)
-      response_data.map do |edge|
-        edge['node']['assignees']['nodes'].map { |node| node['id'] }
+      response_data.map do |node|
+        node['assignees']['nodes'].map { |node| node['id'] }
       end.flatten
     end
 
@@ -624,7 +488,7 @@ def assignees_as_global_ids(issues)
 
     it 'avoids N+1 queries', :aggregate_failures do
       control = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }
-      expect(issues_data.count).to eq(2)
+      expect(issues_data.count).to eq(5)
       expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(issues))
 
       new_issues = issues + [create(:issue, project: project, assignees: [create(:user)])]
@@ -632,8 +496,8 @@ def assignees_as_global_ids(issues)
       expect { post_graphql(query, current_user: current_user) }.not_to exceed_query_limit(control)
       # graphql_data is memoized (see spec/support/helpers/graphql_helpers.rb)
       # so we have to parse the body ourselves the second time
-      issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['edges']
-      expect(issues_data.count).to eq(3)
+      issues_data = Gitlab::Json.parse(response.body)['data']['project']['issues']['nodes']
+      expect(issues_data.count).to eq(6)
       expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(new_issues))
     end
   end
@@ -644,11 +508,9 @@ def assignees_as_global_ids(issues)
     let(:statuses) { issue_data.to_h { |issue| [issue['iid'], issue['escalationStatus']] } }
     let(:fields) do
       <<~QUERY
-        edges {
-          node {
-            id
-            escalationStatus
-          }
+        nodes {
+          id
+          escalationStatus
         }
       QUERY
     end
@@ -660,9 +522,9 @@ def assignees_as_global_ids(issues)
     it 'returns the escalation status values' do
       post_graphql(query, current_user: current_user)
 
-      statuses = issues_data.map { |issue| issue.dig('node', 'escalationStatus') }
+      statuses = issues_data.map { |issue| issue['escalationStatus'] }
 
-      expect(statuses).to contain_exactly(escalation_status.status_name.upcase.to_s, nil)
+      expect(statuses).to contain_exactly(escalation_status.status_name.upcase.to_s, nil, nil, nil, nil)
     end
 
     it 'avoids N+1 queries', :aggregate_failures do
@@ -798,8 +660,8 @@ def execute_query
     end
   end
 
-  def issues_ids
-    graphql_dig_at(issues_data, :node, :id)
+  def issue_ids
+    graphql_dig_at(issues_data, :id)
   end
 
   def query(params = issue_filter_params)
diff --git a/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb
index 22805cf7aedf4b15f4e9d4eec6048905f6926dab..bb492425fd768b14d3d27db7caa77022f53a4f31 100644
--- a/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/issuable_search_shared_examples.rb
@@ -1,13 +1,15 @@
 # frozen_string_literal: true
 
 # Requires `query(params)` , `user`, `issuable_data` and `issuable` bindings
-RSpec.shared_examples 'query with a search term' do
+RSpec.shared_examples 'query with a search term' do |fields = [:DESCRIPTION]|
+  let(:search_term) { 'bar' }
+  let(:ids) { graphql_dig_at(issuable_data, :node, :id) }
+
   it 'returns only matching issuables' do
-    filter_params = { search: 'bar', in: [:DESCRIPTION] }
+    filter_params = { search: search_term, in: fields }
     graphql_query = query(filter_params)
 
     post_graphql(graphql_query, current_user: user)
-    ids = graphql_dig_at(issuable_data, :node, :id)
 
     expect(ids).to contain_exactly(issuable.to_global_id.to_s)
   end
diff --git a/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5469fd80a4f144c3d74a3066c2413f04e4b501e6
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/graphql/issue_list_shared_examples.rb
@@ -0,0 +1,170 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'graphql issue list request spec' do
+  it_behaves_like 'a working graphql query' do
+    before do
+      post_query
+    end
+  end
+
+  describe 'filters' do
+    context 'when filtering by assignees' do
+      context 'when both assignee_username filters are provided' do
+        let(:issue_filter_params) do
+          { assignee_username: current_user.username, assignee_usernames: [current_user.username] }
+        end
+
+        it 'returns a mutually exclusive param error' do
+          post_query
+
+          expect_graphql_errors_to_include(
+            'only one of [assigneeUsernames, assigneeUsername] arguments is allowed at the same time.'
+          )
+        end
+      end
+
+      context 'when filtering by a negated argument' do
+        let(:issue_filter_params) { { not: { assignee_usernames: [current_user.username] } } }
+
+        it 'returns correctly filtered issues' do
+          post_query
+
+          expect(issue_ids).to match_array(expected_negated_assignee_issues.map { |i| i.to_gid.to_s })
+        end
+      end
+    end
+
+    context 'when filtering by unioned arguments' do
+      let(:issue_filter_params) { { or: { assignee_usernames: [current_user.username, another_user.username] } } }
+
+      it 'returns correctly filtered issues' do
+        post_query
+
+        expect(issue_ids).to match_array(expected_unioned_assignee_issues.map { |i| i.to_gid.to_s })
+      end
+
+      context 'when argument is blank' do
+        let(:issue_filter_params) { { or: {} } }
+
+        it 'does not raise an error' do
+          post_query
+
+          expect_graphql_errors_to_be_empty
+        end
+      end
+
+      context 'when feature flag is disabled' do
+        it 'returns an error' do
+          stub_feature_flags(or_issuable_queries: false)
+
+          post_query
+
+          expect_graphql_errors_to_include(
+            "'or' arguments are only allowed when the `or_issuable_queries` feature flag is enabled."
+          )
+        end
+      end
+    end
+
+    context 'when filtering by a blank negated argument' do
+      let(:issue_filter_params) { { not: {} } }
+
+      it 'does not raise an error' do
+        post_query
+
+        expect_graphql_errors_to_be_empty
+      end
+    end
+
+    context 'when filtering by reaction emoji' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:value, :issue_list) do
+        'thumbsup'   | lazy { voted_issues }
+        'ANY'        | lazy { voted_issues }
+        'any'        | lazy { voted_issues }
+        'AnY'        | lazy { voted_issues }
+        'NONE'       | lazy { no_award_issues }
+        'thumbsdown' | lazy { [] }
+      end
+
+      with_them do
+        let(:issue_filter_params) { { my_reaction_emoji: value } }
+        let(:gids) { to_gid_list(issue_list) }
+
+        it 'returns correctly filtered issues' do
+          post_query
+
+          expect(issue_ids).to match_array(gids)
+        end
+      end
+    end
+
+    context 'when filtering by search' do
+      it_behaves_like 'query with a search term', [:TITLE] do
+        let(:search_term) { search_title_term }
+        let(:issuable_data) { issues_data }
+        let(:user) { current_user }
+        let(:issuable) { title_search_issue }
+        let(:ids) { issue_ids }
+      end
+    end
+  end
+
+  describe 'sorting and pagination' do
+    context 'when sorting by severity' do
+      context 'when ascending' do
+        it_behaves_like 'sorted paginated query' do
+          let(:sort_param)  { :SEVERITY_ASC }
+          let(:first_param) { 2 }
+          let(:all_records) { to_gid_list(expected_severity_sorted_asc) }
+        end
+      end
+
+      context 'when descending' do
+        it_behaves_like 'sorted paginated query' do
+          let(:sort_param)  { :SEVERITY_DESC }
+          let(:first_param) { 2 }
+          let(:all_records) { to_gid_list(expected_severity_sorted_asc.reverse) }
+        end
+      end
+    end
+
+    context 'when sorting by priority' do
+      context 'when ascending' do
+        it_behaves_like 'sorted paginated query' do
+          let(:sort_param)  { :PRIORITY_ASC }
+          let(:first_param) { 2 }
+          let(:all_records) { to_gid_list(expected_priority_sorted_asc) }
+        end
+      end
+
+      context 'when descending' do
+        it_behaves_like 'sorted paginated query' do
+          let(:sort_param)  { :PRIORITY_DESC }
+          let(:first_param) { 2 }
+          let(:all_records) { to_gid_list(expected_priority_sorted_desc) }
+        end
+      end
+    end
+  end
+
+  it 'includes a web_url' do
+    post_query
+
+    expect(issues_data[0]['webUrl']).to be_present
+  end
+
+  it 'includes discussion locked' do
+    post_query
+
+    expect(issues_data).to contain_exactly(
+      *locked_discussion_issues.map { |i| hash_including('id' => i.to_gid.to_s, 'discussionLocked' => true) },
+      *unlocked_discussion_issues.map { |i| hash_including('id' => i.to_gid.to_s, 'discussionLocked' => false) }
+    )
+  end
+
+  def to_gid_list(instance_list)
+    instance_list.map { |instance| instance.to_gid.to_s }
+  end
+end