From 640ac20bb1ffa7e6150ecea93f922901d9726cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Wed, 30 Jul 2025 17:58:45 +0200 Subject: [PATCH 01/12] Add keyset pagination for project/issues endpoint Changelog: changed --- app/models/issue.rb | 16 ++++++++++++++++ lib/api/issues.rb | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index af402adc696c59..dc88c555c190d1 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -541,6 +541,22 @@ def self.to_branch_name(id, title, project: nil) branch_name end + def self.supported_keyset_orderings + { + id: [:asc, :desc], + title: [:asc, :desc], + created_at: [:asc, :desc], + updated_at: [:asc, :desc], + due_date: [:asc, :desc], + label_priority: [:asc, :desc], + milestone_due: [:asc, :desc], + popularity: [:asc, :desc], + priority: [:asc, :desc], + relative_position: [:asc, :desc], + weight: [:asc, :desc] + } + end + # Temporary disable moving null elements because of performance problems # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 def check_repositioning_allowed! diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 8667d24486d5a3..df586c362eed51 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -91,7 +91,7 @@ class Issues < ::API::Base optional :due_date, type: String, values: %w[0 any today tomorrow overdue week month next_month_and_previous_two_weeks] << '', desc: 'Return issues that have no due date (`0`), or whose due date is this week, this month, between two weeks ago and next month, or which are overdue. Accepts: `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`, `0`' optional :issue_type, type: String, values: WorkItems::Type.allowed_types_for_issues, desc: "The type of the issue. Accepts: #{WorkItems::Type.allowed_types_for_issues.join(', ')}" - + optional :cursor, type: String, desc: 'Cursor for obtaining the next set of records' use :issues_stats_params use :pagination end @@ -217,7 +217,7 @@ class Issues < ::API::Base end get ":id/issues" do validate_search_rate_limit! if declared_params[:search].present? - issues = paginate(find_issues(project_id: user_project.id)) + issues = find_issues(project_id: user_project.id) options = { with: Entities::Issue, @@ -227,7 +227,7 @@ class Issues < ::API::Base include_subscribed: false } - present issues, options + present paginate_with_strategies(issues), options end desc 'Get statistics for the list of project issues' -- GitLab From c0d4bac8095c1bd803b960a97b063ddcf3dd6669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Fri, 1 Aug 2025 11:59:52 +0200 Subject: [PATCH 02/12] Add specs for new keyset pagination for different order options --- lib/api/issues.rb | 2 +- spec/requests/api/issues/issues_spec.rb | 131 ++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index df586c362eed51..879797833e66a7 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -91,7 +91,6 @@ class Issues < ::API::Base optional :due_date, type: String, values: %w[0 any today tomorrow overdue week month next_month_and_previous_two_weeks] << '', desc: 'Return issues that have no due date (`0`), or whose due date is this week, this month, between two weeks ago and next month, or which are overdue. Accepts: `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`, `0`' optional :issue_type, type: String, values: WorkItems::Type.allowed_types_for_issues, desc: "The type of the issue. Accepts: #{WorkItems::Type.allowed_types_for_issues.join(', ')}" - optional :cursor, type: String, desc: 'Cursor for obtaining the next set of records' use :issues_stats_params use :pagination end @@ -214,6 +213,7 @@ class Issues < ::API::Base end params do use :issues_params + optional :cursor, type: String, desc: 'Cursor for obtaining the next set of records' end get ":id/issues" do validate_search_rate_limit! if declared_params[:search].present? diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index 516d135147f804..ad19dad96b0ac3 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -1123,6 +1123,137 @@ expect(response).to have_gitlab_http_status(:ok) end end + + context 'when using keyset pagination' do + it 'returns first page with cursor to next page' do + params = { pagination: 'keyset', per_page: 2, order_by: 'updated_at', sort: 'asc' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.size).to eq(2) + expect(json_response.pluck('id')).to match_array([confidential_issue.id, closed_issue.id]) + expect(response.headers["Link"]).to include("cursor") + next_cursor = response.headers["Link"].match("(?cursor=.*?)&")["cursor_data"] + + get api("/projects/#{project.id}/issues", user), params: params.merge(Rack::Utils.parse_query(next_cursor)) + + expect(json_response.size).to eq(1) + expect(json_response.pluck('id')).to match_array([issue.id]) + expect(response.headers).not_to include("Link") + end + + it 'respects state filters' do + params = { pagination: 'keyset', per_page: 2, state: 'opened' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.size).to eq(2) + expect(json_response.pluck('id')).to match_array([issue.id, confidential_issue.id]) + expect(response.headers).not_to include("Link") + end + + it 'orders by created_at' do + issue.update!(created_at: 1.second.ago) + params = { pagination: 'keyset', per_page: 2, order_by: 'created_at' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by title' do + issue.update!(title: 'aaaaa') + + params = { pagination: 'keyset', per_page: 2, order_by: 'title', sort: 'asc' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by weight' do + issue.update!(weight: 10) + + params = { pagination: 'keyset', per_page: 2, order_by: 'weight' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by due date' do + issue.update!(due_date: 1.day.from_now) + confidential_issue.update!(due_date: 2.days.ago) + + params = { pagination: 'keyset', per_page: 2, order_by: 'due_date' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by label priority' do + create(:label, title: 'label with priority', color: '#FFAABB', project: project, priority: 2) + create(:label_link, label: label, target: issue) + create(:label, title: 'label with higher priority', color: '#FFAABB', project: project, priority: 1) + create(:label_link, label: label, target: confidential_issue) + + params = { pagination: 'keyset', per_page: 2, order_by: 'label_priority' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by popularity' do + create(:award_emoji, awardable: confidential_issue, user: user, name: 'star') + create(:award_emoji, awardable: confidential_issue, user: user, name: AwardEmoji::THUMBS_UP) + + create(:award_emoji, awardable: issue, user: create(:user), name: AwardEmoji::THUMBS_UP) + create(:award_emoji, awardable: issue, user: create(:user), name: AwardEmoji::THUMBS_UP) + + params = { pagination: 'keyset', per_page: 2, order_by: 'popularity' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by milestone due' do + milestone.update!(due_date: 1.day.from_now) + new_milestone = create(:milestone, title: 'temporal', project: project, due_date: 1.day.ago) + confidential_issue.update!(milestone: new_milestone) + + params = { pagination: 'keyset', per_page: 2, order_by: 'milestone_due' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by priority' do + milestone.update!(due_date: 1.day.from_now) + new_milestone = create(:milestone, title: 'temporal', project: project, due_date: 1.day.ago) + confidential_issue.update!(milestone: new_milestone) + + params = { pagination: 'keyset', per_page: 2, order_by: 'priority' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'orders by relative_position' do + issue.update!(relative_position: 10) + confidential_issue.update!(relative_position: 9) + + params = { pagination: 'keyset', per_page: 2, order_by: 'relative_position' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue.id) + end + end end describe 'GET /projects/:id/issues/:issue_iid' do -- GitLab From 7c7bc32ff1f5b0ebef115d90670554e2354fd7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Fri, 1 Aug 2025 15:18:38 +0200 Subject: [PATCH 03/12] Add milestone due sorting option for keyset --- app/models/issue.rb | 61 +++++++++++++++++++++++++++++---------- spec/models/issue_spec.rb | 15 ++++++++++ 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index dc88c555c190d1..9e45819f28f496 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -56,6 +56,29 @@ class Issue < ApplicationRecord # This default came from the enum `issue_type` column. Defined as default in the DB DEFAULT_ISSUE_TYPE = :issue + SORT_METHODS = { + 'closest_future_date' => -> { order_closest_future_date }, + 'closest_future_date_asc' => -> { order_closest_future_date }, + 'due_date' => -> { order_due_date_asc.with_order_id_desc }, + 'due_date_asc' => -> { order_due_date_asc.with_order_id_desc }, + 'due_date_desc' => -> { order_due_date_desc.with_order_id_desc }, + 'start_date' => -> { order_start_date_asc.with_order_id_desc }, + 'start_date_asc' => -> { order_start_date_asc.with_order_id_desc }, + 'start_date_desc' => -> { order_start_date_desc.with_order_id_desc }, + 'milestone' => -> { order_milestone_due_keyset_asc }, + 'milestone_due_asc' => -> { order_milestone_due_keyset_asc }, + 'milestone_due_desc' => -> { order_milestone_due_keyset_desc }, + 'relative_position' => -> { order_by_relative_position }, + 'relative_position_asc' => -> { order_by_relative_position }, + 'severity_asc' => -> { order_severity_asc }, + 'severity_desc' => -> { order_severity_desc }, + 'escalation_status_asc' => -> { order_escalation_status_asc }, + 'escalation_status_desc' => -> { order_escalation_status_desc }, + 'closed_at' => -> { order_closed_at_asc }, + 'closed_at_asc' => -> { order_closed_at_asc }, + 'closed_at_desc' => -> { order_closed_at_desc } + }.freeze + # prevent caching this column by rails, as we want to easily remove it after the backfilling ignore_column :tmp_epic_id, remove_with: '16.11', remove_after: '2024-03-31' @@ -200,6 +223,24 @@ def most_recent scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_closed_at_asc, -> { reorder(arel_table[:closed_at].asc.nulls_last) } scope :order_closed_at_desc, -> { reorder(arel_table[:closed_at].desc.nulls_last) } + scope :order_milestone_due_keyset_asc, -> do + build_keyset_order_on_joined_column( + scope: left_joins_milestones, + attribute_name: 'milestones_due_date', + column: Milestone.arel_table[:due_date], + direction: :asc, + nullable: :nulls_last + ) + end + scope :order_milestone_due_keyset_desc, -> do + build_keyset_order_on_joined_column( + scope: left_joins_milestones, + attribute_name: 'milestones_due_date', + column: Milestone.arel_table[:due_date], + direction: :desc, + nullable: :nulls_last + ) + end scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } scope :with_web_entity_associations, -> do @@ -477,22 +518,10 @@ def self.simple_sorts end def self.sort_by_attribute(method, excluded_labels: []) - case method.to_s - when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date - when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc - when 'due_date_desc' then order_due_date_desc.with_order_id_desc - when 'start_date', 'start_date_asc' then order_start_date_asc.with_order_id_desc - when 'start_date_desc' then order_start_date_desc.with_order_id_desc - when 'relative_position', 'relative_position_asc' then order_by_relative_position - when 'severity_asc' then order_severity_asc - when 'severity_desc' then order_severity_desc - when 'escalation_status_asc' then order_escalation_status_asc - when 'escalation_status_desc' then order_escalation_status_desc - when 'closed_at', 'closed_at_asc' then order_closed_at_asc - when 'closed_at_desc' then order_closed_at_desc - else - super - end + sort_method = SORT_METHODS[method.to_s] + return super unless sort_method + + sort_method.call end def self.order_by_relative_position diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 0b76aaa0b6c11e..fc5f8dea0ee9cb 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -1976,4 +1976,19 @@ let(:field) { :description } end end + + describe 'keyset pagination with milestone due date sorting' do + it 'creates keyset-aware scopes for milestone due date ordering' do + expect(Gitlab::Pagination::Keyset::Order.keyset_aware?(described_class.order_milestone_due_keyset_asc)).to be_truthy + expect(Gitlab::Pagination::Keyset::Order.keyset_aware?(described_class.order_milestone_due_keyset_desc)).to be_truthy + end + + it 'extracts keyset order objects from milestone due date scopes' do + asc_order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(described_class.order_milestone_due_keyset_asc) + desc_order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(described_class.order_milestone_due_keyset_desc) + + expect(asc_order.column_definitions.map(&:attribute_name)).to include('milestones_due_date', 'id') + expect(desc_order.column_definitions.map(&:attribute_name)).to include('milestones_due_date', 'id') + end + end end -- GitLab From 00debb1d1e8104821ccb0abd5e80d77ae86e42b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Mon, 4 Aug 2025 15:53:28 +0200 Subject: [PATCH 04/12] Remove non-stable cursor params --- app/models/issue.rb | 65 ++++++--------------- lib/api/helpers/issues_helpers.rb | 4 ++ lib/api/issues.rb | 3 + spec/requests/api/issues/issues_spec.rb | 75 ++++++++++--------------- 4 files changed, 52 insertions(+), 95 deletions(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index 9e45819f28f496..be13c58f60ebfe 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -56,29 +56,6 @@ class Issue < ApplicationRecord # This default came from the enum `issue_type` column. Defined as default in the DB DEFAULT_ISSUE_TYPE = :issue - SORT_METHODS = { - 'closest_future_date' => -> { order_closest_future_date }, - 'closest_future_date_asc' => -> { order_closest_future_date }, - 'due_date' => -> { order_due_date_asc.with_order_id_desc }, - 'due_date_asc' => -> { order_due_date_asc.with_order_id_desc }, - 'due_date_desc' => -> { order_due_date_desc.with_order_id_desc }, - 'start_date' => -> { order_start_date_asc.with_order_id_desc }, - 'start_date_asc' => -> { order_start_date_asc.with_order_id_desc }, - 'start_date_desc' => -> { order_start_date_desc.with_order_id_desc }, - 'milestone' => -> { order_milestone_due_keyset_asc }, - 'milestone_due_asc' => -> { order_milestone_due_keyset_asc }, - 'milestone_due_desc' => -> { order_milestone_due_keyset_desc }, - 'relative_position' => -> { order_by_relative_position }, - 'relative_position_asc' => -> { order_by_relative_position }, - 'severity_asc' => -> { order_severity_asc }, - 'severity_desc' => -> { order_severity_desc }, - 'escalation_status_asc' => -> { order_escalation_status_asc }, - 'escalation_status_desc' => -> { order_escalation_status_desc }, - 'closed_at' => -> { order_closed_at_asc }, - 'closed_at_asc' => -> { order_closed_at_asc }, - 'closed_at_desc' => -> { order_closed_at_desc } - }.freeze - # prevent caching this column by rails, as we want to easily remove it after the backfilling ignore_column :tmp_epic_id, remove_with: '16.11', remove_after: '2024-03-31' @@ -223,24 +200,6 @@ def most_recent scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_closed_at_asc, -> { reorder(arel_table[:closed_at].asc.nulls_last) } scope :order_closed_at_desc, -> { reorder(arel_table[:closed_at].desc.nulls_last) } - scope :order_milestone_due_keyset_asc, -> do - build_keyset_order_on_joined_column( - scope: left_joins_milestones, - attribute_name: 'milestones_due_date', - column: Milestone.arel_table[:due_date], - direction: :asc, - nullable: :nulls_last - ) - end - scope :order_milestone_due_keyset_desc, -> do - build_keyset_order_on_joined_column( - scope: left_joins_milestones, - attribute_name: 'milestones_due_date', - column: Milestone.arel_table[:due_date], - direction: :desc, - nullable: :nulls_last - ) - end scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } scope :with_web_entity_associations, -> do @@ -518,10 +477,22 @@ def self.simple_sorts end def self.sort_by_attribute(method, excluded_labels: []) - sort_method = SORT_METHODS[method.to_s] - return super unless sort_method - - sort_method.call + case method.to_s + when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date + when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc + when 'due_date_desc' then order_due_date_desc.with_order_id_desc + when 'start_date', 'start_date_asc' then order_start_date_asc.with_order_id_desc + when 'start_date_desc' then order_start_date_desc.with_order_id_desc + when 'relative_position', 'relative_position_asc' then order_by_relative_position + when 'severity_asc' then order_severity_asc + when 'severity_desc' then order_severity_desc + when 'escalation_status_asc' then order_escalation_status_asc + when 'escalation_status_desc' then order_escalation_status_desc + when 'closed_at', 'closed_at_asc' then order_closed_at_asc + when 'closed_at_desc' then order_closed_at_desc + else + super + end end def self.order_by_relative_position @@ -577,10 +548,6 @@ def self.supported_keyset_orderings created_at: [:asc, :desc], updated_at: [:asc, :desc], due_date: [:asc, :desc], - label_priority: [:asc, :desc], - milestone_due: [:asc, :desc], - popularity: [:asc, :desc], - priority: [:asc, :desc], relative_position: [:asc, :desc], weight: [:asc, :desc] } diff --git a/lib/api/helpers/issues_helpers.rb b/lib/api/helpers/issues_helpers.rb index 27b1b0a38f8f09..98241335c45b71 100644 --- a/lib/api/helpers/issues_helpers.rb +++ b/lib/api/helpers/issues_helpers.rb @@ -47,6 +47,10 @@ def self.sort_options ] end + def non_stable_cursor_options + %w[label_priority popularity milestone_due priority] + end + def issue_finder(args = {}) args = declared_params.merge(args) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 879797833e66a7..f47aff22eb8342 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -217,6 +217,9 @@ class Issues < ::API::Base end get ":id/issues" do validate_search_rate_limit! if declared_params[:search].present? + + params.delete("pagination") if non_stable_cursor_options.include?(declared_params[:order_by]) + issues = find_issues(project_id: user_project.id) options = { diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index ad19dad96b0ac3..409f953c0ceac1 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -1192,66 +1192,49 @@ expect(json_response.first['id']).to eq(issue.id) end - it 'orders by label priority' do - create(:label, title: 'label with priority', color: '#FFAABB', project: project, priority: 2) - create(:label_link, label: label, target: issue) - create(:label, title: 'label with higher priority', color: '#FFAABB', project: project, priority: 1) - create(:label_link, label: label, target: confidential_issue) - - params = { pagination: 'keyset', per_page: 2, order_by: 'label_priority' } - get api("/projects/#{project.id}/issues", user), params: params - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.first['id']).to eq(issue.id) - end - - it 'orders by popularity' do - create(:award_emoji, awardable: confidential_issue, user: user, name: 'star') - create(:award_emoji, awardable: confidential_issue, user: user, name: AwardEmoji::THUMBS_UP) - - create(:award_emoji, awardable: issue, user: create(:user), name: AwardEmoji::THUMBS_UP) - create(:award_emoji, awardable: issue, user: create(:user), name: AwardEmoji::THUMBS_UP) + it 'orders by relative_position' do + issue.update!(relative_position: 10) + confidential_issue.update!(relative_position: 9) - params = { pagination: 'keyset', per_page: 2, order_by: 'popularity' } + params = { pagination: 'keyset', per_page: 2, order_by: 'relative_position' } get api("/projects/#{project.id}/issues", user), params: params expect(response).to have_gitlab_http_status(:ok) expect(json_response.first['id']).to eq(issue.id) end - it 'orders by milestone due' do - milestone.update!(due_date: 1.day.from_now) - new_milestone = create(:milestone, title: 'temporal', project: project, due_date: 1.day.ago) - confidential_issue.update!(milestone: new_milestone) - - params = { pagination: 'keyset', per_page: 2, order_by: 'milestone_due' } - get api("/projects/#{project.id}/issues", user), params: params + context 'when ordering by not-keyset-supported param is called' do + it 'orders by label priority' do + params = { pagination: 'keyset', per_page: 1, order_by: 'label_priority' } + get api("/projects/#{project.id}/issues", user), params: params - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.first['id']).to eq(issue.id) - end + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Link"]).not_to include("cursor") + end - it 'orders by priority' do - milestone.update!(due_date: 1.day.from_now) - new_milestone = create(:milestone, title: 'temporal', project: project, due_date: 1.day.ago) - confidential_issue.update!(milestone: new_milestone) + it 'orders by popularity' do + params = { pagination: 'keyset', per_page: 1, order_by: 'popularity' } + get api("/projects/#{project.id}/issues", user), params: params - params = { pagination: 'keyset', per_page: 2, order_by: 'priority' } - get api("/projects/#{project.id}/issues", user), params: params + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Link"]).not_to include("cursor") + end - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.first['id']).to eq(issue.id) - end + it 'orders by milestone due' do + params = { pagination: 'keyset', per_page: 1, order_by: 'milestone_due' } + get api("/projects/#{project.id}/issues", user), params: params - it 'orders by relative_position' do - issue.update!(relative_position: 10) - confidential_issue.update!(relative_position: 9) + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Link"]).not_to include("cursor") + end - params = { pagination: 'keyset', per_page: 2, order_by: 'relative_position' } - get api("/projects/#{project.id}/issues", user), params: params + it 'orders by priority' do + params = { pagination: 'keyset', per_page: 1, order_by: 'priority' } + get api("/projects/#{project.id}/issues", user), params: params - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.first['id']).to eq(issue.id) + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Link"]).not_to include("cursor") + end end end end -- GitLab From 5344ab2ac1ccd1b0f0729c6123aa8fa2270e5b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Mon, 4 Aug 2025 16:22:29 +0200 Subject: [PATCH 05/12] Remove obsolete changes --- spec/models/issue_spec.rb | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index fc5f8dea0ee9cb..0b76aaa0b6c11e 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -1976,19 +1976,4 @@ let(:field) { :description } end end - - describe 'keyset pagination with milestone due date sorting' do - it 'creates keyset-aware scopes for milestone due date ordering' do - expect(Gitlab::Pagination::Keyset::Order.keyset_aware?(described_class.order_milestone_due_keyset_asc)).to be_truthy - expect(Gitlab::Pagination::Keyset::Order.keyset_aware?(described_class.order_milestone_due_keyset_desc)).to be_truthy - end - - it 'extracts keyset order objects from milestone due date scopes' do - asc_order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(described_class.order_milestone_due_keyset_asc) - desc_order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(described_class.order_milestone_due_keyset_desc) - - expect(asc_order.column_definitions.map(&:attribute_name)).to include('milestones_due_date', 'id') - expect(desc_order.column_definitions.map(&:attribute_name)).to include('milestones_due_date', 'id') - end - end end -- GitLab From 3974d174a146d8b63908eb27649b0c4d02e09c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Mon, 4 Aug 2025 17:04:25 +0200 Subject: [PATCH 06/12] Add documentation changes about the new pagination option --- doc/api/issues.md | 59 +++++++++++++++++++++--------------------- doc/api/rest/_index.md | 25 +++++++++--------- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/doc/api/issues.md b/doc/api/issues.md index 05ad1ee5807d67..372f6ec21c1d2b 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -513,35 +513,36 @@ GET /projects/:id/issues?state=opened Supported attributes: -| Attribute | Type | Required | Description | -| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `id` | integer/string | Yes | The global ID or [URL-encoded path of the project](rest/_index.md#namespaced-paths). | -| `assignee_id` | integer | No | Return issues assigned to the given user `id`. Mutually exclusive with `assignee_username`. `None` returns unassigned issues. `Any` returns issues with an assignee. | -| `assignee_username` | string array | No | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In GitLab CE, the `assignee_username` array should only contain a single value. Otherwise, an invalid parameter error is returned. | -| `author_id` | integer | No | Return issues created by the given user `id`. Mutually exclusive with `author_username`. Combine with `scope=all` or `scope=assigned_to_me`. | -| `author_username` | string | No | Return issues created by the given `username`. Similar to `author_id` and mutually exclusive with `author_id`. | -| `confidential` | boolean | No | Filter confidential or public issues. | -| `created_after` | datetime | No | Return issues created on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `created_before` | datetime | No | Return issues created on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `due_date` | string | No | Return issues that have no due date, are overdue, or whose due date is this week, this month, or between two weeks ago and next month. Accepts: `0` (no due date), `any`, `today`, `tomorrow`, `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`. | -| `epic_id` | integer | No | Return issues associated with the given epic ID. `None` returns issues that are not associated with an epic. `Any` returns issues that are associated with an epic. Premium and Ultimate only. | -| `iids[]` | integer array | No | Return only the issues having the given `iid`. | -| `issue_type` | string | No | Filter to a given type of issue. One of `issue`, `incident`, `test_case` or `task`. | -| `iteration_id` | integer | No | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. Premium and Ultimate only. | -| `iteration_title` | string | No | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. Premium and Ultimate only. | -| `labels` | string | No | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | -| `milestone` | string | No | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | -| `my_reaction_emoji` | string | No | Return issues reacted by the authenticated user by the given `emoji`. `None` returns issues not given a reaction. `Any` returns issues given at least one reaction. | -| `not` | Hash | No | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in`. | -| `order_by` | string | No | Return issues ordered by `created_at`, `updated_at`, `priority`, `due_date`, `relative_position`, `label_priority`, `milestone_due`, `popularity`, `weight` fields. Default is `created_at`. | -| `scope` | string | No | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `all`. | -| `search` | string | No | Search project issues against their `title` and `description`. | -| `sort` | string | No | Return issues sorted in `asc` or `desc` order. Default is `desc`. | -| `state` | string | No | Return all issues or just those that are `opened` or `closed`. | -| `updated_after` | datetime | No | Return issues updated on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `updated_before` | datetime | No | Return issues updated on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `weight` | integer | No | Return issues with the specified `weight`. `None` returns issues with no weight assigned. `Any` returns issues with a weight assigned. Premium and Ultimate only. | -| `with_labels_details` | boolean | No | If `true`, the response returns more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. | +| Attribute | Type | Required | Description | +|-----------------------|----------------| ---------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | integer/string | Yes | The global ID or [URL-encoded path of the project](rest/_index.md#namespaced-paths). | +| `assignee_id` | integer | No | Return issues assigned to the given user `id`. Mutually exclusive with `assignee_username`. `None` returns unassigned issues. `Any` returns issues with an assignee. | +| `assignee_username` | string array | No | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In GitLab CE, the `assignee_username` array should only contain a single value. Otherwise, an invalid parameter error is returned. | +| `author_id` | integer | No | Return issues created by the given user `id`. Mutually exclusive with `author_username`. Combine with `scope=all` or `scope=assigned_to_me`. | +| `author_username` | string | No | Return issues created by the given `username`. Similar to `author_id` and mutually exclusive with `author_id`. | +| `confidential` | boolean | No | Filter confidential or public issues. | +| `created_after` | datetime | No | Return issues created on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `created_before` | datetime | No | Return issues created on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `due_date` | string | No | Return issues that have no due date, are overdue, or whose due date is this week, this month, or between two weeks ago and next month. Accepts: `0` (no due date), `any`, `today`, `tomorrow`, `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`. | +| `epic_id` | integer | No | Return issues associated with the given epic ID. `None` returns issues that are not associated with an epic. `Any` returns issues that are associated with an epic. Premium and Ultimate only. | +| `iids[]` | integer array | No | Return only the issues having the given `iid`. | +| `issue_type` | string | No | Filter to a given type of issue. One of `issue`, `incident`, `test_case` or `task`. | +| `iteration_id` | integer | No | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. Premium and Ultimate only. | +| `iteration_title` | string | No | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. Premium and Ultimate only. | +| `labels` | string | No | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | +| `milestone` | string | No | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | +| `my_reaction_emoji` | string | No | Return issues reacted by the authenticated user by the given `emoji`. `None` returns issues not given a reaction. `Any` returns issues given at least one reaction. | +| `not` | Hash | No | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in`. | +| `order_by` | string | No | Return issues ordered by `created_at`, `updated_at`, `priority`, `due_date`, `relative_position`, `label_priority`, `milestone_due`, `popularity`, `weight` fields. Default is `created_at`. | +| `scope` | string | No | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `all`. | +| `search` | string | No | Search project issues against their `title` and `description`. | +| `sort` | string | No | Return issues sorted in `asc` or `desc` order. Default is `desc`. | +| `state` | string | No | Return all issues or just those that are `opened` or `closed`. | +| `updated_after` | datetime | No | Return issues updated on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `updated_before` | datetime | No | Return issues updated on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `weight` | integer | No | Return issues with the specified `weight`. `None` returns issues with no weight assigned. `Any` returns issues with a weight assigned. Premium and Ultimate only. | +| `with_labels_details` | boolean | No | If `true`, the response returns more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. | +| `cursor` | string | No | Parameter used in keyset pagination. | Example request: diff --git a/doc/api/rest/_index.md b/doc/api/rest/_index.md index 148621b94afe3a..7a953efdd0516c 100644 --- a/doc/api/rest/_index.md +++ b/doc/api/rest/_index.md @@ -446,18 +446,19 @@ pagination headers. Keyset-based pagination is supported only for selected resources and ordering options: -| Resource | Options | Availability | -|:-------------------------------------------------------------------------------|:----------------------------------------------------|:-------------| -| [Group audit events](../audit_events.md#retrieve-all-group-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Groups](../groups.md#list-groups) | `order_by=name`, `sort=asc` only | Unauthenticated users only. | -| [Instance audit events](../audit_events.md#retrieve-all-instance-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Package pipelines](../packages.md#list-package-pipelines) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Project jobs](../jobs.md#list-project-jobs) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Project audit events](../audit_events.md#retrieve-all-project-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Projects](../projects.md) | `order_by=id` only | Authenticated and unauthenticated users. | -| [Users](../users.md) | `order_by=id`, `order_by=name`, `order_by=username`, `order_by=created_at`, or `order_by=updated_at`. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/419556) in GitLab 16.5. | -| [Registry Repository Tags](../container_registry.md) | `order_by=name`, `sort=asc`, or `sort=desc` only. | Authenticated users only. | -| [List repository tree](../repositories.md#list-repository-tree) | N/A | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/154897) in GitLab 17.1. | +| Resource | Options | Availability | +|:-------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [Group audit events](../audit_events.md#retrieve-all-group-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Groups](../groups.md#list-groups) | `order_by=name`, `sort=asc` only | Unauthenticated users only. | +| [Instance audit events](../audit_events.md#retrieve-all-instance-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Package pipelines](../packages.md#list-package-pipelines) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Project jobs](../jobs.md#list-project-jobs) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Project audit events](../audit_events.md#retrieve-all-project-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Projects](../projects.md) | `order_by=id` only | Authenticated and unauthenticated users. | +| [Users](../users.md) | `order_by=id`, `order_by=name`, `order_by=username`, `order_by=created_at`, or `order_by=updated_at`. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/419556) in GitLab 16.5. | +| [Registry Repository Tags](../container_registry.md) | `order_by=name`, `sort=asc`, or `sort=desc` only. | Authenticated users only. | +| [List repository tree](../repositories.md#list-repository-tree) | N/A | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/154897) in GitLab 17.1. | +| [Project issues](../issues.md#list-project-issues) | `order_by=created_at`, `order_by=updated_at`, `order_by=title`, `order_by=id`, `order by=weight`, `order_by=due_date`, `order_by=relative_position`, `sort=asc`, or `sort=desc` only. | Authenticated and unauthenticated users. Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199887/) in GitLab 18.3. | ### Pagination response headers -- GitLab From 8d06885cc77385fdb894e84dd943016911b63828 Mon Sep 17 00:00:00 2001 From: Gosia Ksionek Date: Tue, 5 Aug 2025 11:55:41 +0200 Subject: [PATCH 07/12] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: GitLab Duo --- lib/api/issues.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index f47aff22eb8342..f1ac4b7cff5190 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -219,7 +219,7 @@ class Issues < ::API::Base validate_search_rate_limit! if declared_params[:search].present? params.delete("pagination") if non_stable_cursor_options.include?(declared_params[:order_by]) - + params.delete("pagination") if declared_params[:order_by] && non_stable_cursor_options.include?(declared_params[:order_by]) issues = find_issues(project_id: user_project.id) options = { -- GitLab From 2b09b0332e1593fa8d102e8b2b4c28aaca70e93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Tue, 5 Aug 2025 16:19:03 +0200 Subject: [PATCH 08/12] Fix rubocop offence --- lib/api/issues.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index f1ac4b7cff5190..adfe8fc1eecca8 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -219,7 +219,11 @@ class Issues < ::API::Base validate_search_rate_limit! if declared_params[:search].present? params.delete("pagination") if non_stable_cursor_options.include?(declared_params[:order_by]) - params.delete("pagination") if declared_params[:order_by] && non_stable_cursor_options.include?(declared_params[:order_by]) + + if declared_params[:order_by] && non_stable_cursor_options.include?(declared_params[:order_by]) + params.delete("pagination") + end + issues = find_issues(project_id: user_project.id) options = { -- GitLab From a2f5af3164c58c3777d8f9fc8f9e290fcdfc89d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Wed, 6 Aug 2025 11:23:48 +0200 Subject: [PATCH 09/12] Clean up code --- app/models/issue.rb | 3 +-- doc/api/rest/_index.md | 2 +- ee/app/models/ee/issue.rb | 5 +++++ ee/spec/requests/api/issues_spec.rb | 13 +++++++++++++ lib/api/helpers/issues_helpers.rb | 4 ++-- lib/api/issues.rb | 6 ++---- spec/requests/api/issues/issues_spec.rb | 10 ---------- 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index be13c58f60ebfe..dc3af29d0c646f 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -548,8 +548,7 @@ def self.supported_keyset_orderings created_at: [:asc, :desc], updated_at: [:asc, :desc], due_date: [:asc, :desc], - relative_position: [:asc, :desc], - weight: [:asc, :desc] + relative_position: [:asc, :desc] } end diff --git a/doc/api/rest/_index.md b/doc/api/rest/_index.md index 7a953efdd0516c..b06ada6ff422aa 100644 --- a/doc/api/rest/_index.md +++ b/doc/api/rest/_index.md @@ -458,7 +458,7 @@ options: | [Users](../users.md) | `order_by=id`, `order_by=name`, `order_by=username`, `order_by=created_at`, or `order_by=updated_at`. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/419556) in GitLab 16.5. | | [Registry Repository Tags](../container_registry.md) | `order_by=name`, `sort=asc`, or `sort=desc` only. | Authenticated users only. | | [List repository tree](../repositories.md#list-repository-tree) | N/A | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/154897) in GitLab 17.1. | -| [Project issues](../issues.md#list-project-issues) | `order_by=created_at`, `order_by=updated_at`, `order_by=title`, `order_by=id`, `order by=weight`, `order_by=due_date`, `order_by=relative_position`, `sort=asc`, or `sort=desc` only. | Authenticated and unauthenticated users. Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199887/) in GitLab 18.3. | +| [Project issues](../issues.md#list-project-issues) | `order_by=created_at`, `order_by=updated_at`, `order_by=title`, `order_by=id`, `order_by=weight`, `order_by=due_date`, `order_by=relative_position`, `sort=asc`, or `sort=desc` only. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199887/) in GitLab 18.3. | ### Pagination response headers diff --git a/ee/app/models/ee/issue.rb b/ee/app/models/ee/issue.rb index e422a93340031f..34b915b8b0df93 100644 --- a/ee/app/models/ee/issue.rb +++ b/ee/app/models/ee/issue.rb @@ -175,6 +175,11 @@ def with_api_entity_associations def use_separate_indices? true end + + # override + def supported_keyset_orderings + super.merge(weight: [:asc, :desc]) + end end # override diff --git a/ee/spec/requests/api/issues_spec.rb b/ee/spec/requests/api/issues_spec.rb index ae7e10ed86d292..795568585d8fb1 100644 --- a/ee/spec/requests/api/issues_spec.rb +++ b/ee/spec/requests/api/issues_spec.rb @@ -418,6 +418,19 @@ let(:endpoint) { "/projects/#{group_project.id}/issues" } end + context 'when using keyset pagination' do + let!(:issue1) { create(:issue, author: user2, project: project, weight: 1, created_at: 3.days.ago) } + let!(:issue2) { create(:issue, author: user2, project: project, weight: 5, created_at: 2.days.ago) } + + it 'orders by weight' do + params = { pagination: 'keyset', per_page: 2, order_by: 'weight' } + get api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response.first['id']).to eq(issue2.id) + end + end + context 'on personal project' do let!(:issue_with_epic) { create(:issue, project: project, epic: epic) } diff --git a/lib/api/helpers/issues_helpers.rb b/lib/api/helpers/issues_helpers.rb index 98241335c45b71..577864f438a381 100644 --- a/lib/api/helpers/issues_helpers.rb +++ b/lib/api/helpers/issues_helpers.rb @@ -47,8 +47,8 @@ def self.sort_options ] end - def non_stable_cursor_options - %w[label_priority popularity milestone_due priority] + def stable_cursor_options + Issue.supported_keyset_orderings.keys end def issue_finder(args = {}) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index adfe8fc1eecca8..8e125a03ece107 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -217,10 +217,8 @@ class Issues < ::API::Base end get ":id/issues" do validate_search_rate_limit! if declared_params[:search].present? - - params.delete("pagination") if non_stable_cursor_options.include?(declared_params[:order_by]) - - if declared_params[:order_by] && non_stable_cursor_options.include?(declared_params[:order_by]) + # binding.pry + if declared_params[:order_by] && stable_cursor_options.exclude?(declared_params[:order_by].to_sym) params.delete("pagination") end diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index 409f953c0ceac1..73343fc6700950 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -1171,16 +1171,6 @@ expect(json_response.first['id']).to eq(issue.id) end - it 'orders by weight' do - issue.update!(weight: 10) - - params = { pagination: 'keyset', per_page: 2, order_by: 'weight' } - get api("/projects/#{project.id}/issues", user), params: params - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response.first['id']).to eq(issue.id) - end - it 'orders by due date' do issue.update!(due_date: 1.day.from_now) confidential_issue.update!(due_date: 2.days.ago) -- GitLab From a9abbac37521ac3bcad6b9d9939b5a7623be5660 Mon Sep 17 00:00:00 2001 From: Marcin Sedlak-Jakubowski Date: Wed, 6 Aug 2025 14:53:22 +0200 Subject: [PATCH 10/12] Format tables in doc --- doc/api/issues.md | 60 +++++++++++++++++++++--------------------- doc/api/rest/_index.md | 26 +++++++++--------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/doc/api/issues.md b/doc/api/issues.md index 372f6ec21c1d2b..8bb7bf4d86704d 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -513,36 +513,36 @@ GET /projects/:id/issues?state=opened Supported attributes: -| Attribute | Type | Required | Description | -|-----------------------|----------------| ---------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | integer/string | Yes | The global ID or [URL-encoded path of the project](rest/_index.md#namespaced-paths). | -| `assignee_id` | integer | No | Return issues assigned to the given user `id`. Mutually exclusive with `assignee_username`. `None` returns unassigned issues. `Any` returns issues with an assignee. | -| `assignee_username` | string array | No | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In GitLab CE, the `assignee_username` array should only contain a single value. Otherwise, an invalid parameter error is returned. | -| `author_id` | integer | No | Return issues created by the given user `id`. Mutually exclusive with `author_username`. Combine with `scope=all` or `scope=assigned_to_me`. | -| `author_username` | string | No | Return issues created by the given `username`. Similar to `author_id` and mutually exclusive with `author_id`. | -| `confidential` | boolean | No | Filter confidential or public issues. | -| `created_after` | datetime | No | Return issues created on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `created_before` | datetime | No | Return issues created on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `due_date` | string | No | Return issues that have no due date, are overdue, or whose due date is this week, this month, or between two weeks ago and next month. Accepts: `0` (no due date), `any`, `today`, `tomorrow`, `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`. | -| `epic_id` | integer | No | Return issues associated with the given epic ID. `None` returns issues that are not associated with an epic. `Any` returns issues that are associated with an epic. Premium and Ultimate only. | -| `iids[]` | integer array | No | Return only the issues having the given `iid`. | -| `issue_type` | string | No | Filter to a given type of issue. One of `issue`, `incident`, `test_case` or `task`. | -| `iteration_id` | integer | No | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. Premium and Ultimate only. | -| `iteration_title` | string | No | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. Premium and Ultimate only. | -| `labels` | string | No | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | -| `milestone` | string | No | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | -| `my_reaction_emoji` | string | No | Return issues reacted by the authenticated user by the given `emoji`. `None` returns issues not given a reaction. `Any` returns issues given at least one reaction. | -| `not` | Hash | No | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in`. | -| `order_by` | string | No | Return issues ordered by `created_at`, `updated_at`, `priority`, `due_date`, `relative_position`, `label_priority`, `milestone_due`, `popularity`, `weight` fields. Default is `created_at`. | -| `scope` | string | No | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `all`. | -| `search` | string | No | Search project issues against their `title` and `description`. | -| `sort` | string | No | Return issues sorted in `asc` or `desc` order. Default is `desc`. | -| `state` | string | No | Return all issues or just those that are `opened` or `closed`. | -| `updated_after` | datetime | No | Return issues updated on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `updated_before` | datetime | No | Return issues updated on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | -| `weight` | integer | No | Return issues with the specified `weight`. `None` returns issues with no weight assigned. `Any` returns issues with a weight assigned. Premium and Ultimate only. | -| `with_labels_details` | boolean | No | If `true`, the response returns more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. | -| `cursor` | string | No | Parameter used in keyset pagination. | +| Attribute | Type | Required | Description | +| --------------------- | -------------- | -------- | ----------- | +| `id` | integer/string | Yes | The global ID or [URL-encoded path of the project](rest/_index.md#namespaced-paths). | +| `assignee_id` | integer | No | Return issues assigned to the given user `id`. Mutually exclusive with `assignee_username`. `None` returns unassigned issues. `Any` returns issues with an assignee. | +| `assignee_username` | string array | No | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In GitLab CE, the `assignee_username` array should only contain a single value. Otherwise, an invalid parameter error is returned. | +| `author_id` | integer | No | Return issues created by the given user `id`. Mutually exclusive with `author_username`. Combine with `scope=all` or `scope=assigned_to_me`. | +| `author_username` | string | No | Return issues created by the given `username`. Similar to `author_id` and mutually exclusive with `author_id`. | +| `confidential` | boolean | No | Filter confidential or public issues. | +| `created_after` | datetime | No | Return issues created on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `created_before` | datetime | No | Return issues created on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `due_date` | string | No | Return issues that have no due date, are overdue, or whose due date is this week, this month, or between two weeks ago and next month. Accepts: `0` (no due date), `any`, `today`, `tomorrow`, `overdue`, `week`, `month`, `next_month_and_previous_two_weeks`. | +| `epic_id` | integer | No | Return issues associated with the given epic ID. `None` returns issues that are not associated with an epic. `Any` returns issues that are associated with an epic. Premium and Ultimate only. | +| `iids[]` | integer array | No | Return only the issues having the given `iid`. | +| `issue_type` | string | No | Filter to a given type of issue. One of `issue`, `incident`, `test_case` or `task`. | +| `iteration_id` | integer | No | Return issues assigned to the given iteration ID. `None` returns issues that do not belong to an iteration. `Any` returns issues that belong to an iteration. Mutually exclusive with `iteration_title`. Premium and Ultimate only. | +| `iteration_title` | string | No | Return issues assigned to the iteration with the given title. Similar to `iteration_id` and mutually exclusive with `iteration_id`. Premium and Ultimate only. | +| `labels` | string | No | Comma-separated list of label names, issues must have all labels to be returned. `None` lists all issues with no labels. `Any` lists all issues with at least one label. `No+Label` (Deprecated) lists all issues with no labels. Predefined names are case-insensitive. | +| `milestone` | string | No | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | +| `my_reaction_emoji` | string | No | Return issues reacted by the authenticated user by the given `emoji`. `None` returns issues not given a reaction. `Any` returns issues given at least one reaction. | +| `not` | Hash | No | Return issues that do not match the parameters supplied. Accepts: `labels`, `milestone`, `author_id`, `author_username`, `assignee_id`, `assignee_username`, `my_reaction_emoji`, `search`, `in`. | +| `order_by` | string | No | Return issues ordered by `created_at`, `updated_at`, `priority`, `due_date`, `relative_position`, `label_priority`, `milestone_due`, `popularity`, `weight` fields. Default is `created_at`. | +| `scope` | string | No | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `all`. | +| `search` | string | No | Search project issues against their `title` and `description`. | +| `sort` | string | No | Return issues sorted in `asc` or `desc` order. Default is `desc`. | +| `state` | string | No | Return all issues or just those that are `opened` or `closed`. | +| `updated_after` | datetime | No | Return issues updated on or after the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `updated_before` | datetime | No | Return issues updated on or before the given time. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`). | +| `weight` | integer | No | Return issues with the specified `weight`. `None` returns issues with no weight assigned. `Any` returns issues with a weight assigned. Premium and Ultimate only. | +| `with_labels_details` | boolean | No | If `true`, the response returns more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. | +| `cursor` | string | No | Parameter used in keyset pagination. | Example request: diff --git a/doc/api/rest/_index.md b/doc/api/rest/_index.md index b06ada6ff422aa..d2c3d1a207a86b 100644 --- a/doc/api/rest/_index.md +++ b/doc/api/rest/_index.md @@ -446,19 +446,19 @@ pagination headers. Keyset-based pagination is supported only for selected resources and ordering options: -| Resource | Options | Availability | -|:-------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [Group audit events](../audit_events.md#retrieve-all-group-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Groups](../groups.md#list-groups) | `order_by=name`, `sort=asc` only | Unauthenticated users only. | -| [Instance audit events](../audit_events.md#retrieve-all-instance-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Package pipelines](../packages.md#list-package-pipelines) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Project jobs](../jobs.md#list-project-jobs) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Project audit events](../audit_events.md#retrieve-all-project-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | -| [Projects](../projects.md) | `order_by=id` only | Authenticated and unauthenticated users. | -| [Users](../users.md) | `order_by=id`, `order_by=name`, `order_by=username`, `order_by=created_at`, or `order_by=updated_at`. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/419556) in GitLab 16.5. | -| [Registry Repository Tags](../container_registry.md) | `order_by=name`, `sort=asc`, or `sort=desc` only. | Authenticated users only. | -| [List repository tree](../repositories.md#list-repository-tree) | N/A | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/154897) in GitLab 17.1. | -| [Project issues](../issues.md#list-project-issues) | `order_by=created_at`, `order_by=updated_at`, `order_by=title`, `order_by=id`, `order_by=weight`, `order_by=due_date`, `order_by=relative_position`, `sort=asc`, or `sort=desc` only. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199887/) in GitLab 18.3. | +| Resource | Options | Availability | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| [Group audit events](../audit_events.md#retrieve-all-group-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Groups](../groups.md#list-groups) | `order_by=name`, `sort=asc` only | Unauthenticated users only. | +| [Instance audit events](../audit_events.md#retrieve-all-instance-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Package pipelines](../packages.md#list-package-pipelines) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Project jobs](../jobs.md#list-project-jobs) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Project audit events](../audit_events.md#retrieve-all-project-audit-events) | `order_by=id`, `sort=desc` only | Authenticated users only. | +| [Projects](../projects.md) | `order_by=id` only | Authenticated and unauthenticated users. | +| [Users](../users.md) | `order_by=id`, `order_by=name`, `order_by=username`, `order_by=created_at`, or `order_by=updated_at`. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/419556) in GitLab 16.5. | +| [Registry Repository Tags](../container_registry.md) | `order_by=name`, `sort=asc`, or `sort=desc` only. | Authenticated users only. | +| [List repository tree](../repositories.md#list-repository-tree) | N/A | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/154897) in GitLab 17.1. | +| [Project issues](../issues.md#list-project-issues) | `order_by=created_at`, `order_by=updated_at`, `order_by=title`, `order_by=id`, `order_by=weight`, `order_by=due_date`, `order_by=relative_position`, `sort=asc`, or `sort=desc` only. | Authenticated and unauthenticated users. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199887/) in GitLab 18.3. | ### Pagination response headers -- GitLab From 308bea4ac6c74701d0d1ebf9b3376a6c47159b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Thu, 7 Aug 2025 09:57:09 +0200 Subject: [PATCH 11/12] Refer to model method directly --- lib/api/helpers/issues_helpers.rb | 4 ---- lib/api/issues.rb | 5 +++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/api/helpers/issues_helpers.rb b/lib/api/helpers/issues_helpers.rb index 577864f438a381..27b1b0a38f8f09 100644 --- a/lib/api/helpers/issues_helpers.rb +++ b/lib/api/helpers/issues_helpers.rb @@ -47,10 +47,6 @@ def self.sort_options ] end - def stable_cursor_options - Issue.supported_keyset_orderings.keys - end - def issue_finder(args = {}) args = declared_params.merge(args) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 8e125a03ece107..6ed402647ebc8d 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -217,8 +217,9 @@ class Issues < ::API::Base end get ":id/issues" do validate_search_rate_limit! if declared_params[:search].present? - # binding.pry - if declared_params[:order_by] && stable_cursor_options.exclude?(declared_params[:order_by].to_sym) + + if declared_params[:order_by] && Issue.supported_keyset_orderings.keys + .exclude?(declared_params[:order_by].to_sym) params.delete("pagination") end -- GitLab From 66e10d70274954bd1e74dbd5f81dc876a7f1b797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C5=82gorzata=20Ksionek?= Date: Fri, 8 Aug 2025 09:28:09 +0200 Subject: [PATCH 12/12] Add information about documentation --- doc/api/issues.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/api/issues.md b/doc/api/issues.md index 8bb7bf4d86704d..0aca341b3de045 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -489,6 +489,12 @@ Use `iid` of the `epic` attribute instead. ## List project issues +{{< history >}} + +- Support for keyset pagination [introduced](https://gitlab.com/gitlab-com/gl-infra/production-engineering/-/issues/26555) in GitLab 18.3. + +{{< /history >}} + Get a list of a project's issues. If the project is private, you need to provide credentials to authorize. @@ -544,6 +550,11 @@ Supported attributes: | `with_labels_details` | boolean | No | If `true`, the response returns more details for each label in labels field: `:name`, `:color`, `:description`, `:description_html`, `:text_color`. Default is `false`. | | `cursor` | string | No | Parameter used in keyset pagination. | +This endpoint supports both offset-based and [keyset-based](rest/_index.md#keyset-based-pagination) pagination. You should use keyset-based +pagination when requesting consecutive pages of results. + +Read more on [pagination](rest/_index.md#pagination). + Example request: ```shell -- GitLab