diff --git a/app/models/issue.rb b/app/models/issue.rb index af402adc696c59f3463d65a3bcf9cc6dee9e578c..dc3af29d0c646f5781c7b28e6d33c592088f1a19 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -541,6 +541,17 @@ 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], + relative_position: [: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/doc/api/issues.md b/doc/api/issues.md index 05ad1ee5807d6716ca5e3c80c3dcdff7a1211261..0aca341b3de045e1a8edc16e7f183fba238d2bb9 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. @@ -513,35 +519,41 @@ 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. | + +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: diff --git a/doc/api/rest/_index.md b/doc/api/rest/_index.md index 148621b94afe3a80aa26def70b8d8a35798165c7..d2c3d1a207a86b559517cdadaf7826eb02eb1b9d 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. [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 e422a93340031ffe6a02100d0d4264909f5e8d88..34b915b8b0df93d4d5250eeda9f98c3f92919bda 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 ae7e10ed86d29214c8087b6241073cd26c520727..795568585d8fb1c5edb18c6acb51e842dbc87b2c 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/issues.rb b/lib/api/issues.rb index 8667d24486d5a39b0f7b8c98b24a0bd2578feba6..6ed402647ebc8d8565ac82a02454bf4a4e4eb105 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(', ')}" - use :issues_stats_params use :pagination end @@ -214,10 +213,17 @@ 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? - issues = paginate(find_issues(project_id: user_project.id)) + + if declared_params[:order_by] && Issue.supported_keyset_orderings.keys + .exclude?(declared_params[:order_by].to_sym) + params.delete("pagination") + end + + issues = find_issues(project_id: user_project.id) options = { with: Entities::Issue, @@ -227,7 +233,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' diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb index 516d135147f804b65ed0ea86b0b7643f588b1a75..73343fc67009504082c753b6ecba29825ac03904 100644 --- a/spec/requests/api/issues/issues_spec.rb +++ b/spec/requests/api/issues/issues_spec.rb @@ -1123,6 +1123,110 @@ 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 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 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 + + 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(response.headers["Link"]).not_to include("cursor") + end + + it 'orders by popularity' do + params = { pagination: 'keyset', per_page: 1, order_by: 'popularity' } + 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 + + 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 + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers["Link"]).not_to include("cursor") + end + + 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(response.headers["Link"]).not_to include("cursor") + end + end + end end describe 'GET /projects/:id/issues/:issue_iid' do