diff options
author | Rémy Coutable <remy@rymai.me> | 2019-05-20 09:49:23 +0000 |
---|---|---|
committer | Rémy Coutable <remy@rymai.me> | 2019-05-20 09:49:23 +0000 |
commit | 23660f3ae424824db803ce4794240f718157b045 (patch) | |
tree | 5bfe7c6f326163a46abed6ee479e846579f2f1ae | |
parent | f14565948f8d7759f6c0e7d6cc77445b2211e8f6 (diff) | |
parent | 9ff6edf690423a284f4d0ad924ff2a9a4285eb50 (diff) | |
download | gitlab-ce-23660f3ae424824db803ce4794240f718157b045.tar.gz |
Merge branch 'ce-57402-add-issues-statistics-api-endpoints' into 'master'
Add issues_statistics api endpoints
See merge request gitlab-org/gitlab-ce!27366
-rw-r--r-- | changelogs/unreleased/ce-57402-add-issues-statistics-api-endpoints.yml | 5 | ||||
-rw-r--r-- | doc/api/issues.md | 66 | ||||
-rw-r--r-- | doc/api/issues_statistics.md | 177 | ||||
-rw-r--r-- | lib/api/entities.rb | 19 | ||||
-rw-r--r-- | lib/api/helpers/issues_helpers.rb | 33 | ||||
-rw-r--r-- | lib/api/issues.rb | 101 | ||||
-rw-r--r-- | lib/api/validations/check_assignees_count.rb | 32 | ||||
-rw-r--r-- | spec/fixtures/api/schemas/public_api/v4/label_basic.json | 24 | ||||
-rw-r--r-- | spec/requests/api/issues/get_group_issues_spec.rb | 652 | ||||
-rw-r--r-- | spec/requests/api/issues/get_project_issues_spec.rb | 805 | ||||
-rw-r--r-- | spec/requests/api/issues/issues_spec.rb | 796 | ||||
-rw-r--r-- | spec/requests/api/issues/post_projects_issues_spec.rb | 549 | ||||
-rw-r--r-- | spec/requests/api/issues/put_projects_issues_spec.rb | 392 | ||||
-rw-r--r-- | spec/requests/api/issues_spec.rb | 2265 | ||||
-rw-r--r-- | spec/support/shared_examples/requests/api/issues_shared_example_spec.rb | 44 |
15 files changed, 3642 insertions, 2318 deletions
diff --git a/changelogs/unreleased/ce-57402-add-issues-statistics-api-endpoints.yml b/changelogs/unreleased/ce-57402-add-issues-statistics-api-endpoints.yml new file mode 100644 index 00000000000..a626193dc27 --- /dev/null +++ b/changelogs/unreleased/ce-57402-add-issues-statistics-api-endpoints.yml @@ -0,0 +1,5 @@ +--- +title: Add issues_statistics api endpoints and extend issues search api +merge_request: 27366 +author: +type: added diff --git a/doc/api/issues.md b/doc/api/issues.md index cb5789e76b7..4fb3626f637 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -37,23 +37,26 @@ GET /issues?confidential=true | Attribute | Type | Required | Description | | ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| `state` | string | no | Return all issues or just those that are `opened` or `closed` | +| `state` | string | no | Return `all` issues or just those that are `opened` or `closed` | | `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. | +| `with_labels_details`| Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:text_color`. Default is `false`. | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `created_by_me`<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ | -| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned_to_me`. _([Introduced][ce-13004] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Return issues assigned to the given user `id`. `None` returns unassigned issues. `Any` returns issues with an assignee. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `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`. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `author_username` | string | no | Return issues created by the given `username`. Simillar to `author_id` and mutually exclusive with `author_id`. | +| `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. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `assignee_username` | Array[String] | no | Return issues assigned to the given `username`. Simillar to `assignee_id` and mutually exclusive with `assignee_id`. In CE version `assignee_username` array should only contain a single value or an invalid param error will be returned otherwise. | | `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. _([Introduced][ce-14016] in GitLab 10.0)_ | | `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search issues against their `title` and `description` | -| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` | +| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` | | `created_after` | datetime | no | Return issues created on or after the given time | | `created_before` | datetime | no | Return issues created on or before the given time | | `updated_after` | datetime | no | Return issues updated on or after the given time | | `updated_before` | datetime | no | Return issues updated on or before the given time | -| `confidential ` | Boolean | no | Filter confidential or public issues. | +| `confidential ` | Boolean | no | Filter confidential or public issues. | ```bash curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/issues @@ -109,7 +112,7 @@ Example response: "title" : "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.", "created_at" : "2016-01-04T15:31:51.081Z", "iid" : 6, - "labels" : [], + "labels" : ["foo", "bar"], "upvotes": 4, "downvotes": 0, "merge_requests_count": 0, @@ -122,8 +125,17 @@ Example response: "human_time_estimate": null, "human_total_time_spent": null }, + "has_tasks": true, + "task_status": "10 of 15 tasks completed", "confidential": false, - "discussion_locked": false + "discussion_locked": false, + "_links":{ + "self":"http://example.com/api/v4/projects/1/issues/76", + "notes":"`http://example.com/`api/v4/projects/1/issues/76/notes", + "award_emoji":"http://example.com/api/v4/projects/1/issues/76/award_emoji", + "project":"http://example.com/api/v4/projects/1" + }, + "subscribed": false } ] ``` @@ -158,11 +170,14 @@ GET /groups/:id/issues?confidential=true | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `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. | +| `with_labels_details`| Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:text_color`. Default is `false`. | | `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`.<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ | -| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Return issues assigned to the given user `id`. `None` returns unassigned issues. `Any` returns issues with an assignee. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `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`. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `author_username` | string | no | Return issues created by the given `username`. Simillar to `author_id` and mutually exclusive with `author_id`. | +| `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. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `assignee_username` | Array[String] | no | Return issues assigned to the given `username`. Simillar to `assignee_id` and mutually exclusive with `assignee_id`. In CE version `assignee_username` array should only contain a single value or an invalid param error will be returned otherwise. | | `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. _([Introduced][ce-14016] in GitLab 10.0)_ | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | @@ -221,7 +236,7 @@ Example response: "id" : 9, "name" : "Dr. Luella Kovacek" }, - "labels" : [], + "labels" : ["foo", "bar"], "upvotes": 4, "downvotes": 0, "merge_requests_count": 0, @@ -240,8 +255,17 @@ Example response: "human_time_estimate": null, "human_total_time_spent": null }, + "has_tasks": true, + "task_status": "10 of 15 tasks completed", "confidential": false, - "discussion_locked": false + "discussion_locked": false, + "_links":{ + "self":"http://example.com/api/v4/projects/4/issues/41", + "notes":"`http://example.com/`api/v4/projects/4/issues/41/notes", + "award_emoji":"http://example.com/api/v4/projects/4/issues/41/award_emoji", + "project":"http://example.com/api/v4/projects/4" + }, + "subscribed": false } ] ``` @@ -277,10 +301,13 @@ GET /projects/:id/issues?confidential=true | `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` | | `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `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. | +| `with_labels_details`| Boolean | no | If `true`, response will return more details for each label in labels field: `:name`, `:color`, `:description`, `:text_color`. Default is `false`. | | `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | | `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`.<br> For versions before 11.0, use the now deprecated `created-by-me` or `assigned-to-me` scopes instead.<br> _([Introduced][ce-13004] in GitLab 9.5. [Changed to snake_case][ce-18935] in GitLab 11.0)_ | -| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Return issues assigned to the given user `id`. `None` returns unassigned issues. `Any` returns issues with an assignee. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `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`. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `author_username` | string | no | Return issues created by the given `username`. Simillar to `author_id` and mutually exclusive with `author_id`. | +| `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. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `assignee_username` | Array[String] | no | Return issues assigned to the given `username`. Simillar to `assignee_id` and mutually exclusive with `assignee_id`. In CE version `assignee_username` array should only contain a single value or an invalid param error will be returned otherwise. | | `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. _([Introduced][ce-14016] in GitLab 10.0)_ | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | @@ -340,7 +367,7 @@ Example response: "id" : 9, "name" : "Dr. Luella Kovacek" }, - "labels" : [], + "labels" : ["foo", "bar"], "upvotes": 4, "downvotes": 0, "merge_requests_count": 0, @@ -366,8 +393,17 @@ Example response: "human_time_estimate": null, "human_total_time_spent": null }, + "has_tasks": true, + "task_status": "10 of 15 tasks completed", "confidential": false, - "discussion_locked": false + "discussion_locked": false, + "_links":{ + "self":"http://example.com/api/v4/projects/4/issues/41", + "notes":"`http://example.com/`api/v4/projects/4/issues/41/notes", + "award_emoji":"http://example.com/api/v4/projects/4/issues/41/award_emoji", + "project":"http://example.com/api/v4/projects/4" + }, + "subscribed": false } ] ``` diff --git a/doc/api/issues_statistics.md b/doc/api/issues_statistics.md new file mode 100644 index 00000000000..82bc9c142cc --- /dev/null +++ b/doc/api/issues_statistics.md @@ -0,0 +1,177 @@ +# Issues Statistics API + +Every API call to issues_statistics must be authenticated. + +If a user is not a member of a project and the project is private, a `GET` +request on that project will result to a `404` status code. + +## Get issues statistics + +Gets issues count statistics on all issues the authenticated user has access to. By default it +returns only issues created by the current user. To get all issues, +use parameter `scope=all`. + +``` +GET /issues_statistics +GET /issues_statistics?labels=foo +GET /issues_statistics?labels=foo,bar +GET /issues_statistics?labels=foo,bar&state=opened +GET /issues_statistics?milestone=1.0.0 +GET /issues_statistics?milestone=1.0.0&state=opened +GET /issues_statistics?iids[]=42&iids[]=43 +GET /issues_statistics?author_id=5 +GET /issues_statistics?assignee_id=5 +GET /issues_statistics?my_reaction_emoji=star +GET /issues_statistics?search=foo&in=title +GET /issues_statistics?confidential=true +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | +| `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. Defaults to `created_by_me` | +| `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`. | +| `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` | Array[String] | no | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In CE version `assignee_username` array should only contain a single value or an invalid param error will be returned otherwise. | +| `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. | +| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | +| `search` | string | no | Search issues against their `title` and `description` | +| `in` | string | no | Modify the scope of the `search` attribute. `title`, `description`, or a string joining them with comma. Default is `title,description` | +| `created_after` | datetime | no | Return issues created on or after the given time | +| `created_before` | datetime | no | Return issues created on or before the given time | +| `updated_after` | datetime | no | Return issues updated on or after the given time | +| `updated_before` | datetime | no | Return issues updated on or before the given time | +| `confidential ` | Boolean | no | Filter confidential or public issues. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/issues_statistics +``` + +Example response: + +```json +{ + "statistics": { + "counts": { + "all": 20, + "closed": 5, + "opened": 15 + } + } +} +``` + +## Get group issues statistics + +Gets issues count statistics for given group. + +``` +GET /groups/:id/issues_statistics +GET /groups/:id/issues_statistics?labels=foo +GET /groups/:id/issues_statistics?labels=foo,bar +GET /groups/:id/issues_statistics?labels=foo,bar&state=opened +GET /groups/:id/issues_statistics?milestone=1.0.0 +GET /groups/:id/issues_statistics?milestone=1.0.0&state=opened +GET /groups/:id/issues_statistics?iids[]=42&iids[]=43 +GET /groups/:id/issues_statistics?search=issue+title+or+description +GET /groups/:id/issues_statistics?author_id=5 +GET /groups/:id/issues_statistics?assignee_id=5 +GET /groups/:id/issues_statistics?my_reaction_emoji=star +GET /groups/:id/issues_statistics?confidential=true +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `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. | +| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | +| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | +| `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. | +| `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`. | +| `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` | Array[String] | no | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In CE version `assignee_username` array should only contain a single value or an invalid param error will be returned otherwise. | +| `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. | +| `search` | string | no | Search group issues against their `title` and `description` | +| `created_after` | datetime | no | Return issues created on or after the given time | +| `created_before` | datetime | no | Return issues created on or before the given time | +| `updated_after` | datetime | no | Return issues updated on or after the given time | +| `updated_before` | datetime | no | Return issues updated on or before the given time | +| `confidential ` | Boolean | no | Filter confidential or public issues. | + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/4/issues_statistics +``` + +Example response: + +```json +{ + "statistics": { + "counts": { + "all": 20, + "closed": 5, + "opened": 15 + } + } +} +``` + +## Get project issues statistics + +Gets issues count statistics for given project. + +``` +GET /projects/:id/issues_statistics +GET /projects/:id/issues_statistics?labels=foo +GET /projects/:id/issues_statistics?labels=foo,bar +GET /projects/:id/issues_statistics?labels=foo,bar&state=opened +GET /projects/:id/issues_statistics?milestone=1.0.0 +GET /projects/:id/issues_statistics?milestone=1.0.0&state=opened +GET /projects/:id/issues_statistics?iids[]=42&iids[]=43 +GET /projects/:id/issues_statistics?search=issue+title+or+description +GET /projects/:id/issues_statistics?author_id=5 +GET /projects/:id/issues_statistics?assignee_id=5 +GET /projects/:id/issues_statistics?my_reaction_emoji=star +GET /projects/:id/issues_statistics?confidential=true +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` | +| `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. | +| `milestone` | string | no | The milestone title. `None` lists all issues with no milestone. `Any` lists all issues that have an assigned milestone. | +| `scope` | string | no | Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`. | +| `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`. | +| `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` | Array[String] | no | Return issues assigned to the given `username`. Similar to `assignee_id` and mutually exclusive with `assignee_id`. In CE version `assignee_username` array should only contain a single value or an invalid param error will be returned otherwise. | +| `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. | +| `search` | string | no | Search project issues against their `title` and `description` | +| `created_after` | datetime | no | Return issues created on or after the given time | +| `created_before` | datetime | no | Return issues created on or before the given time | +| `updated_after` | datetime | no | Return issues updated on or after the given time | +| `updated_before` | datetime | no | Return issues updated on or before the given time | +| `confidential ` | Boolean | no | Filter confidential or public issues. | + + +```bash +curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/4/issues_statistics +``` + +Example response: + +```json +{ + "statistics": { + "counts": { + "all": 20, + "closed": 5, + "opened": 15 + } + } +} +``` diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 296688ba25b..625fada4f08 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -542,10 +542,15 @@ module API class IssueBasic < ProjectEntity expose :closed_at expose :closed_by, using: Entities::UserBasic - expose :labels do |issue| - # Avoids an N+1 query since labels are preloaded - issue.labels.map(&:title).sort + + expose :labels do |issue, options| + if options[:with_labels_details] + ::API::Entities::LabelBasic.represent(issue.labels.sort_by(&:title)) + else + issue.labels.map(&:title).sort + end end + expose :milestone, using: Entities::Milestone expose :assignees, :author, using: Entities::UserBasic @@ -573,6 +578,14 @@ module API class Issue < IssueBasic include ::API::Helpers::RelatedResourcesHelpers + expose(:has_tasks) do |issue, _| + !issue.task_list_items.empty? + end + + expose :task_status, if: -> (issue, _) do + !issue.task_list_items.empty? + end + expose :_links do expose :self do |issue| expose_url(api_v4_project_issue_path(id: issue.project_id, issue_iid: issue.iid)) diff --git a/lib/api/helpers/issues_helpers.rb b/lib/api/helpers/issues_helpers.rb index f6762910b0c..fc66cec5341 100644 --- a/lib/api/helpers/issues_helpers.rb +++ b/lib/api/helpers/issues_helpers.rb @@ -18,6 +18,39 @@ module API :title ] end + + def issue_finder(args = {}) + args = declared_params.merge(args) + + args.delete(:id) + args[:milestone_title] ||= args.delete(:milestone) + args[:label_name] ||= args.delete(:labels) + args[:scope] = args[:scope].underscore if args[:scope] + + IssuesFinder.new(current_user, args) + end + + def find_issues(args = {}) + finder = issue_finder(args) + issues = finder.execute.with_api_entity_associations + + issues.reorder(order_options_with_tie_breaker) # rubocop: disable CodeReuse/ActiveRecord + end + + def issues_statistics(args = {}) + finder = issue_finder(args) + counter = Gitlab::IssuablesCountForState.new(finder) + + { + statistics: { + counts: { + all: counter[:all], + closed: counter[:closed], + opened: counter[:opened] + } + } + } + end end end end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index d0a93b77951..0b4da01f3c8 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -3,27 +3,12 @@ module API class Issues < Grape::API include PaginationParams + helpers Helpers::IssuesHelpers + helpers ::Gitlab::IssuableMetadata before { authenticate_non_get! } - helpers ::Gitlab::IssuableMetadata - helpers do - # rubocop: disable CodeReuse/ActiveRecord - def find_issues(args = {}) - args = declared_params.merge(args) - - args.delete(:id) - args[:milestone_title] = args.delete(:milestone) - args[:label_name] = args.delete(:labels) - args[:scope] = args[:scope].underscore if args[:scope] - - issues = IssuesFinder.new(current_user, args).execute - .with_api_entity_associations - issues.reorder(order_options_with_tie_breaker) - end - # rubocop: enable CodeReuse/ActiveRecord - if Gitlab.ee? params :issues_params_ee do optional :weight, types: [Integer, String], integer_none_any: true, desc: 'The weight of the issue' @@ -34,13 +19,9 @@ module API end end - params :issues_params do + params :issues_stats_params do optional :labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names' optional :milestone, type: String, desc: 'Milestone title' - optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', - desc: 'Return issues ordered by `created_at` or `updated_at` fields.' - optional :sort, type: String, values: %w[asc desc], default: 'desc', - desc: 'Return issues sorted in `asc` or `desc` order.' optional :milestone, type: String, desc: 'Return issues for a specific milestone' optional :iids, type: Array[Integer], desc: 'The IID array of issues' optional :search, type: String, desc: 'Search issues for text present in the title, description, or any combination of these' @@ -49,18 +30,39 @@ module API optional :created_before, type: DateTime, desc: 'Return issues created before the specified time' optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time' optional :updated_before, type: DateTime, desc: 'Return issues updated before the specified time' + optional :author_id, type: Integer, desc: 'Return issues which are authored by the user with the given ID' + optional :author_username, type: String, desc: 'Return issues which are authored by the user with the given username' + mutually_exclusive :author_id, :author_username + optional :assignee_id, types: [Integer, String], integer_none_any: true, desc: 'Return issues which are assigned to the user with the given ID' + optional :assignee_username, type: Array[String], check_assignees_count: true, + coerce_with: Validations::CheckAssigneesCount.coerce, + desc: 'Return issues which are assigned to the user with the given username' + mutually_exclusive :assignee_id, :assignee_username + optional :scope, type: String, values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], desc: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`' optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' optional :confidential, type: Boolean, desc: 'Filter confidential or public issues' - use :pagination use :issues_params_ee if Gitlab.ee? end + params :issues_params do + optional :with_labels_details, type: Boolean, desc: 'Return more label data than just lable title', default: false + optional :state, type: String, values: %w[opened closed all], default: 'all', + desc: 'Return opened, closed, or all issues' + optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', + desc: 'Return issues ordered by `created_at` or `updated_at` fields.' + optional :sort, type: String, values: %w[asc desc], default: 'desc', + desc: 'Return issues sorted in `asc` or `desc` order.' + + use :issues_stats_params + use :pagination + end + params :issue_params do optional :description, type: String, desc: 'The description of an issue' optional :assignee_ids, type: Array[Integer], desc: 'The array of user IDs to assign issue' @@ -75,13 +77,23 @@ module API end end + desc "Get currently authenticated user's issues statistics" + params do + use :issues_stats_params + optional :scope, type: String, values: %w[created_by_me assigned_to_me all], default: 'created_by_me', + desc: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`' + end + get '/issues_statistics' do + authenticate! unless params[:scope] == 'all' + + present issues_statistics, with: Grape::Presenters::Presenter + end + resource :issues do desc "Get currently authenticated user's issues" do - success Entities::IssueBasic + success Entities::Issue end params do - optional :state, type: String, values: %w[opened closed all], default: 'all', - desc: 'Return opened, closed, or all issues' use :issues_params optional :scope, type: String, values: %w[created-by-me assigned-to-me created_by_me assigned_to_me all], default: 'created_by_me', desc: 'Return issues for the given scope: `created_by_me`, `assigned_to_me` or `all`' @@ -91,7 +103,8 @@ module API issues = paginate(find_issues) options = { - with: Entities::IssueBasic, + with: Entities::Issue, + with_labels_details: declared_params[:with_labels_details], current_user: current_user, issuable_metadata: issuable_meta_data(issues, 'Issue') } @@ -105,11 +118,9 @@ module API end resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do desc 'Get a list of group issues' do - success Entities::IssueBasic + success Entities::Issue end params do - optional :state, type: String, values: %w[opened closed all], default: 'all', - desc: 'Return opened, closed, or all issues' use :issues_params end get ":id/issues" do @@ -118,13 +129,24 @@ module API issues = paginate(find_issues(group_id: group.id, include_subgroups: true)) options = { - with: Entities::IssueBasic, + with: Entities::Issue, + with_labels_details: declared_params[:with_labels_details], current_user: current_user, issuable_metadata: issuable_meta_data(issues, 'Issue') } present issues, options end + + desc 'Get statistics for the list of group issues' + params do + use :issues_stats_params + end + get ":id/issues_statistics" do + group = find_group!(params[:id]) + + present issues_statistics(group_id: group.id, include_subgroups: true), with: Grape::Presenters::Presenter + end end params do @@ -134,11 +156,9 @@ module API include TimeTrackingEndpoints desc 'Get a list of project issues' do - success Entities::IssueBasic + success Entities::Issue end params do - optional :state, type: String, values: %w[opened closed all], default: 'all', - desc: 'Return opened, closed, or all issues' use :issues_params end get ":id/issues" do @@ -147,7 +167,8 @@ module API issues = paginate(find_issues(project_id: project.id)) options = { - with: Entities::IssueBasic, + with: Entities::Issue, + with_labels_details: declared_params[:with_labels_details], current_user: current_user, project: user_project, issuable_metadata: issuable_meta_data(issues, 'Issue') @@ -156,6 +177,16 @@ module API present issues, options end + desc 'Get statistics for the list of project issues' + params do + use :issues_stats_params + end + get ":id/issues_statistics" do + project = find_project!(params[:id]) + + present issues_statistics(project_id: project.id), with: Grape::Presenters::Presenter + end + desc 'Get a single project issue' do success Entities::Issue end diff --git a/lib/api/validations/check_assignees_count.rb b/lib/api/validations/check_assignees_count.rb new file mode 100644 index 00000000000..836ec936b31 --- /dev/null +++ b/lib/api/validations/check_assignees_count.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module API + module Validations + class CheckAssigneesCount < Grape::Validations::Base + def self.coerce + lambda do |value| + case value + when String, Array + Array.wrap(value) + else + [] + end + end + end + + def validate_param!(attr_name, params) + return if param_allowed?(attr_name, params) + + raise Grape::Exceptions::Validation, + params: [@scope.full_name(attr_name)], + message: "allows one value, but found #{params[attr_name].size}: #{params[attr_name].join(", ")}" + end + + private + + def param_allowed?(attr_name, params) + params[attr_name].size <= 1 + end + end + end +end diff --git a/spec/fixtures/api/schemas/public_api/v4/label_basic.json b/spec/fixtures/api/schemas/public_api/v4/label_basic.json new file mode 100644 index 00000000000..37bbdcb14fe --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/label_basic.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required": [ + "id", + "name", + "color", + "description", + "text_color" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + }, + "description": { "type": ["string", "null"] }, + "text_color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}$" + } + }, + "additionalProperties": false +} diff --git a/spec/requests/api/issues/get_group_issues_spec.rb b/spec/requests/api/issues/get_group_issues_spec.rb new file mode 100644 index 00000000000..8b02cf56e9f --- /dev/null +++ b/spec/requests/api/issues/get_group_issues_spec.rb @@ -0,0 +1,652 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Issues do + set(:user) { create(:user) } + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + set(:guest) { create(:user) } + set(:author) { create(:author) } + set(:assignee) { create(:assignee) } + let(:admin) { create(:user, :admin) } + + let(:issue_title) { 'foo' } + let(:issue_description) { 'closed' } + + let(:no_milestone_title) { 'None' } + let(:any_milestone_title) { 'Any' } + + before do + stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + end + + describe 'GET /groups/:id/issues' do + let!(:group) { create(:group) } + let!(:group_project) { create(:project, :public, creator_id: user.id, namespace: group) } + let!(:group_closed_issue) do + create :closed_issue, + author: user, + assignees: [user], + project: group_project, + state: :closed, + milestone: group_milestone, + updated_at: 3.hours.ago, + created_at: 1.day.ago + end + let!(:group_confidential_issue) do + create :issue, + :confidential, + project: group_project, + author: author, + assignees: [assignee], + updated_at: 2.hours.ago, + created_at: 2.days.ago + end + let!(:group_issue) do + create :issue, + author: user, + assignees: [user], + project: group_project, + milestone: group_milestone, + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description, + created_at: 5.days.ago + end + let!(:group_label) do + create(:label, title: 'group_lbl', color: '#FFAABB', project: group_project) + end + let!(:group_label_link) { create(:label_link, label: group_label, target: group_issue) } + let!(:group_milestone) { create(:milestone, title: '3.0.0', project: group_project) } + let!(:group_empty_milestone) do + create(:milestone, title: '4.0.0', project: group_project) + end + let!(:group_note) { create(:note_on_issue, author: user, project: group_project, noteable: group_issue) } + + let(:base_url) { "/groups/#{group.id}/issues" } + + shared_examples 'group issues statistics' do + it 'returns issues statistics' do + get api("/groups/#{group.id}/issues_statistics", user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(json_response['statistics']).not_to be_nil + expect(json_response['statistics']['counts']['all']).to eq counts[:all] + expect(json_response['statistics']['counts']['closed']).to eq counts[:closed] + expect(json_response['statistics']['counts']['opened']).to eq counts[:opened] + end + end + + context 'when group has subgroups', :nested_groups do + let(:subgroup_1) { create(:group, parent: group) } + let(:subgroup_2) { create(:group, parent: subgroup_1) } + + let(:subgroup_1_project) { create(:project, :public, namespace: subgroup_1) } + let(:subgroup_2_project) { create(:project, namespace: subgroup_2) } + + let!(:issue_1) { create(:issue, project: subgroup_1_project) } + let!(:issue_2) { create(:issue, project: subgroup_2_project) } + + context 'when user is unauthenticated' do + it 'also returns subgroups public projects issues' do + get api(base_url) + + expect_paginated_array_response([issue_1.id, group_closed_issue.id, group_issue.id]) + end + + it 'also returns subgroups public projects issues filtered by milestone' do + get api(base_url), params: { milestone: group_milestone.title } + + expect_paginated_array_response([group_closed_issue.id, group_issue.id]) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: group_milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: group_milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + end + end + + context 'when user is a group member' do + before do + group.add_developer(user) + end + + it 'also returns subgroups projects issues' do + get api(base_url, user) + + expect_paginated_array_response([issue_2.id, issue_1.id, group_closed_issue.id, group_confidential_issue.id, group_issue.id]) + end + + it 'also returns subgroups public projects issues filtered by milestone' do + get api(base_url, user), params: { milestone: group_milestone.title } + + expect_paginated_array_response([group_closed_issue.id, group_issue.id]) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 5, closed: 1, opened: 4 } } + + it_behaves_like 'group issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 5, closed: 1, opened: 4 } } + + it_behaves_like 'group issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 5, closed: 1, opened: 4 } } + + it_behaves_like 'group issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 5, closed: 1, opened: 4 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: group_milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: group_milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + end + end + end + + context 'when user is unauthenticated' do + it 'lists all issues in public projects' do + get api(base_url) + + expect_paginated_array_response([group_closed_issue.id, group_issue.id]) + end + + it 'also returns subgroups public projects issues filtered by milestone' do + get api(base_url), params: { milestone: group_milestone.title } + + expect_paginated_array_response([group_closed_issue.id, group_issue.id]) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: group_milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: group_milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + end + end + + context 'when user is a group member' do + before do + group_project.add_reporter(user) + end + + it 'returns all group issues (including opened and closed)' do + get api(base_url, admin) + + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) + end + + it 'returns group issues without confidential issues for non project members' do + get api(base_url, non_member), params: { state: :opened } + + expect_paginated_array_response(group_issue.id) + end + + it 'returns group confidential issues for author' do + get api(base_url, author), params: { state: :opened } + + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) + end + + it 'returns group confidential issues for assignee' do + get api(base_url, assignee), params: { state: :opened } + + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) + end + + it 'returns group issues with confidential issues for project members' do + get api(base_url, user), params: { state: :opened } + + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) + end + + it 'returns group confidential issues for admin' do + get api(base_url, admin), params: { state: :opened } + + expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) + end + + it 'returns only confidential issues' do + get api(base_url, user), params: { confidential: true } + + expect_paginated_array_response(group_confidential_issue.id) + end + + it 'returns only public issues' do + get api(base_url, user), params: { confidential: false } + + expect_paginated_array_response([group_closed_issue.id, group_issue.id]) + end + + it 'returns an array of labeled group issues' do + get api(base_url, user), params: { labels: group_label.title } + + expect_paginated_array_response(group_issue.id) + expect(json_response.first['labels']).to eq([group_label.title]) + end + + it 'returns an array of labeled group issues with labels param as array' do + get api(base_url, user), params: { labels: [group_label.title] } + + expect_paginated_array_response(group_issue.id) + expect(json_response.first['labels']).to eq([group_label.title]) + end + + it 'returns an array of labeled group issues where all labels match' do + get api(base_url, user), params: { labels: "#{group_label.title},foo,bar" } + + expect_paginated_array_response([]) + end + + it 'returns an array of labeled group issues where all labels match with labels param as array' do + get api(base_url, user), params: { labels: [group_label.title, 'foo', 'bar'] } + + expect_paginated_array_response([]) + end + + it 'returns issues matching given search string for title' do + get api(base_url, user), params: { search: group_issue.title } + + expect_paginated_array_response(group_issue.id) + end + + it 'returns issues matching given search string for description' do + get api(base_url, user), params: { search: group_issue.description } + + expect_paginated_array_response(group_issue.id) + end + + context 'with labeled issues' do + let(:label_b) { create(:label, title: 'foo', project: group_project) } + let(:label_c) { create(:label, title: 'bar', project: group_project) } + + before do + create(:label_link, label: label_b, target: group_issue) + create(:label_link, label: label_c, target: group_issue) + + get api(base_url, user), params: params + end + + let(:issue) { group_issue } + let(:label) { group_label } + + it_behaves_like 'labeled issues with labels and label_name params' + end + + it 'returns an array of issues found by iids' do + get api(base_url, user), params: { iids: [group_issue.iid] } + + expect_paginated_array_response(group_issue.id) + expect(json_response.first['id']).to eq(group_issue.id) + end + + it 'returns an empty array if iid does not exist' do + get api(base_url, user), params: { iids: [0] } + + expect_paginated_array_response([]) + end + + it 'returns an empty array if no group issue matches labels' do + get api(base_url, user), params: { labels: 'foo,bar' } + + expect_paginated_array_response([]) + end + + it 'returns an array of group issues with any label' do + get api(base_url, user), params: { labels: IssuesFinder::FILTER_ANY } + + expect_paginated_array_response(group_issue.id) + expect(json_response.first['id']).to eq(group_issue.id) + end + + it 'returns an array of group issues with any label with labels param as array' do + get api(base_url, user), params: { labels: [IssuesFinder::FILTER_ANY] } + + expect_paginated_array_response(group_issue.id) + expect(json_response.first['id']).to eq(group_issue.id) + end + + it 'returns an array of group issues with no label' do + get api(base_url, user), params: { labels: IssuesFinder::FILTER_NONE } + + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id]) + end + + it 'returns an array of group issues with no label with labels param as array' do + get api(base_url, user), params: { labels: [IssuesFinder::FILTER_NONE] } + + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id]) + end + + it 'returns an empty array if no issue matches milestone' do + get api(base_url, user), params: { milestone: group_empty_milestone.title } + + expect_paginated_array_response([]) + end + + it 'returns an empty array if milestone does not exist' do + get api(base_url, user), params: { milestone: 'foo' } + + expect_paginated_array_response([]) + end + + it 'returns an array of issues in given milestone' do + get api(base_url, user), params: { state: :opened, milestone: group_milestone.title } + + expect_paginated_array_response(group_issue.id) + end + + it 'returns an array of issues matching state in milestone' do + get api(base_url, user), params: { milestone: group_milestone.title, state: :closed } + + expect_paginated_array_response(group_closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get api(base_url, user), params: { milestone: no_milestone_title } + + expect(response).to have_gitlab_http_status(200) + + expect_paginated_array_response(group_confidential_issue.id) + end + + context 'without sort params' do + it 'sorts by created_at descending by default' do + get api(base_url, user) + + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) + end + + context 'with 2 issues with same created_at' do + let!(:group_issue2) do + create :issue, + author: user, + assignees: [user], + project: group_project, + milestone: group_milestone, + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description, + created_at: group_issue.created_at + end + + it 'page breaks first page correctly' do + get api("#{base_url}?per_page=3", user) + + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue2.id]) + end + + it 'page breaks second page correctly' do + get api("#{base_url}?per_page=3&page=2", user) + + expect_paginated_array_response([group_issue.id]) + end + end + end + + it 'sorts ascending when requested' do + get api("#{base_url}?sort=asc", user) + + expect_paginated_array_response([group_issue.id, group_confidential_issue.id, group_closed_issue.id]) + end + + it 'sorts by updated_at descending when requested' do + get api("#{base_url}?order_by=updated_at", user) + + group_issue.touch(:updated_at) + + expect_paginated_array_response([group_issue.id, group_confidential_issue.id, group_closed_issue.id]) + end + + it 'sorts by updated_at ascending when requested' do + get api(base_url, user), params: { order_by: :updated_at, sort: :asc } + + expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: group_milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: group_milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: group_milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'group issues statistics' + end + + context 'sort does not affect statistics ' do + let(:params) { { state: :opened, order_by: 'updated_at' } } + let(:counts) { { all: 3, closed: 1, opened: 2 } } + + it_behaves_like 'group issues statistics' + end + end + + context 'filtering by assignee_username' do + let(:another_assignee) { create(:assignee) } + let!(:issue1) { create(:issue, author: user2, project: group_project, created_at: 3.days.ago) } + let!(:issue2) { create(:issue, author: user2, project: group_project, created_at: 2.days.ago) } + let!(:issue3) { create(:issue, author: user2, assignees: [assignee, another_assignee], project: group_project, created_at: 1.day.ago) } + + it 'returns issues with by assignee_username' do + get api(base_url, user), params: { assignee_username: [assignee.username], scope: 'all' } + + expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id]) + expect_paginated_array_response([issue3.id, group_confidential_issue.id]) + end + + it 'returns issues by assignee_username as string' do + get api(base_url, user), params: { assignee_username: assignee.username, scope: 'all' } + + expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id]) + expect_paginated_array_response([issue3.id, group_confidential_issue.id]) + end + + it 'returns error when multiple assignees are passed' do + get api(base_url, user), params: { assignee_username: [assignee.username, another_assignee.username], scope: 'all' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to include("allows one value, but found 2") + end + + it 'returns error when assignee_username and assignee_id are passed together' do + get api(base_url, user), params: { assignee_username: [assignee.username], assignee_id: another_assignee.id, scope: 'all' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to include("mutually exclusive") + end + end + end + end +end diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb new file mode 100644 index 00000000000..a07d7673345 --- /dev/null +++ b/spec/requests/api/issues/get_project_issues_spec.rb @@ -0,0 +1,805 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Issues do + set(:user) { create(:user) } + set(:project) do + create(:project, :public, creator_id: user.id, namespace: user.namespace) + end + + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + set(:guest) { create(:user) } + set(:author) { create(:author) } + set(:assignee) { create(:assignee) } + let(:admin) { create(:user, :admin) } + let(:issue_title) { 'foo' } + let(:issue_description) { 'closed' } + let!(:closed_issue) do + create :closed_issue, + author: user, + assignees: [user], + project: project, + state: :closed, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 3.hours.ago, + closed_at: 1.hour.ago + end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignees: [assignee], + created_at: generate(:past_time), + updated_at: 2.hours.ago + end + let!(:issue) do + create :issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + end + set(:label) do + create(:label, title: 'label', color: '#FFAABB', project: project) + end + let!(:label_link) { create(:label_link, label: label, target: issue) } + let(:milestone) { create(:milestone, title: '1.0.0', project: project) } + set(:empty_milestone) do + create(:milestone, title: '2.0.0', project: project) + end + let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + + let(:no_milestone_title) { 'None' } + let(:any_milestone_title) { 'Any' } + + before(:all) do + project.add_reporter(user) + project.add_guest(guest) + end + + before do + stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + end + + shared_examples 'project issues statistics' do + it 'returns project issues statistics' do + get api("/issues_statistics", user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(json_response['statistics']).not_to be_nil + expect(json_response['statistics']['counts']['all']).to eq counts[:all] + expect(json_response['statistics']['counts']['closed']).to eq counts[:closed] + expect(json_response['statistics']['counts']['opened']).to eq counts[:opened] + end + end + + describe "GET /projects/:id/issues" do + let(:base_url) { "/projects/#{project.id}" } + + context 'when unauthenticated' do + it 'returns public project issues' do + get api("/projects/#{project.id}/issues") + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'sort does not affect statistics ' do + let(:params) { { state: :opened, order_by: 'updated_at' } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + end + end + + it 'avoids N+1 queries' do + get api("/projects/#{project.id}/issues", user) + + create_list(:issue, 3, project: project) + + control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get api("/projects/#{project.id}/issues", user) + end.count + + expect do + get api("/projects/#{project.id}/issues", user) + end.not_to exceed_all_query_limit(control_count) + end + + it 'returns 404 when project does not exist' do + get api('/projects/1000/issues', non_member) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 on private projects for other users' do + private_project = create(:project, :private) + create(:issue, project: private_project) + + get api("/projects/#{private_project.id}/issues", non_member) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns no issues when user has access to project but not issues' do + restricted_project = create(:project, :public, :issues_private) + create(:issue, project: restricted_project) + + get api("/projects/#{restricted_project.id}/issues", non_member) + + expect_paginated_array_response([]) + end + + it 'returns project issues without confidential issues for non project members' do + get api("#{base_url}/issues", non_member) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns project issues without confidential issues for project members with guest role' do + get api("#{base_url}/issues", guest) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns project confidential issues for author' do + get api("#{base_url}/issues", author) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + it 'returns only confidential issues' do + get api("#{base_url}/issues", author), params: { confidential: true } + + expect_paginated_array_response(confidential_issue.id) + end + + it 'returns only public issues' do + get api("#{base_url}/issues", author), params: { confidential: false } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns project confidential issues for assignee' do + get api("#{base_url}/issues", assignee) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + it 'returns project issues with confidential issues for project members' do + get api("#{base_url}/issues", user) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + it 'returns project confidential issues for admin' do + get api("#{base_url}/issues", admin) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + it 'returns an array of labeled project issues' do + get api("#{base_url}/issues", user), params: { labels: label.title } + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of labeled project issues with labels param as array' do + get api("#{base_url}/issues", user), params: { labels: [label.title] } + + expect_paginated_array_response(issue.id) + end + + context 'with labeled issues' do + let(:label_b) { create(:label, title: 'foo', project: project) } + let(:label_c) { create(:label, title: 'bar', project: project) } + + before do + create(:label_link, label: label_b, target: issue) + create(:label_link, label: label_c, target: issue) + + get api('/issues', user), params: params + end + + it_behaves_like 'labeled issues with labels and label_name params' + end + + it 'returns issues matching given search string for title' do + get api("#{base_url}/issues?search=#{issue.title}", user) + + expect_paginated_array_response(issue.id) + end + + it 'returns issues matching given search string for description' do + get api("#{base_url}/issues?search=#{issue.description}", user) + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of issues found by iids' do + get api("#{base_url}/issues", user), params: { iids: [issue.iid] } + + expect_paginated_array_response(issue.id) + end + + it 'returns an empty array if iid does not exist' do + get api("#{base_url}/issues", user), params: { iids: [0] } + + expect_paginated_array_response([]) + end + + it 'returns an empty array if not all labels matches' do + get api("#{base_url}/issues?labels=#{label.title},foo", user) + + expect_paginated_array_response([]) + end + + it 'returns an array of project issues with any label' do + get api("#{base_url}/issues", user), params: { labels: IssuesFinder::FILTER_ANY } + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of project issues with any label with labels param as array' do + get api("#{base_url}/issues", user), params: { labels: [IssuesFinder::FILTER_ANY] } + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of project issues with no label' do + get api("#{base_url}/issues", user), params: { labels: IssuesFinder::FILTER_NONE } + + expect_paginated_array_response([confidential_issue.id, closed_issue.id]) + end + + it 'returns an array of project issues with no label with labels param as array' do + get api("#{base_url}/issues", user), params: { labels: [IssuesFinder::FILTER_NONE] } + + expect_paginated_array_response([confidential_issue.id, closed_issue.id]) + end + + it 'returns an empty array if no project issue matches labels' do + get api("#{base_url}/issues", user), params: { labels: 'foo,bar' } + + expect_paginated_array_response([]) + end + + it 'returns an empty array if no issue matches milestone' do + get api("#{base_url}/issues", user), params: { milestone: empty_milestone.title } + + expect_paginated_array_response([]) + end + + it 'returns an empty array if milestone does not exist' do + get api("#{base_url}/issues", user), params: { milestone: :foo } + + expect_paginated_array_response([]) + end + + it 'returns an array of issues in given milestone' do + get api("#{base_url}/issues", user), params: { milestone: milestone.title } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns an array of issues matching state in milestone' do + get api("#{base_url}/issues", user), params: { milestone: milestone.title, state: :closed } + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get api("#{base_url}/issues", user), params: { milestone: no_milestone_title } + + expect_paginated_array_response(confidential_issue.id) + end + + it 'returns an array of issues with any milestone' do + get api("#{base_url}/issues", user), params: { milestone: any_milestone_title } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + context 'without sort params' do + it 'sorts by created_at descending by default' do + get api("#{base_url}/issues", user) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + context 'with 2 issues with same created_at' do + let!(:closed_issue2) do + create :closed_issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: closed_issue.created_at, + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + end + + it 'page breaks first page correctly' do + get api("#{base_url}/issues?per_page=3", user) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue2.id]) + end + + it 'page breaks second page correctly' do + get api("#{base_url}/issues?per_page=3&page=2", user) + + expect_paginated_array_response([closed_issue.id]) + end + end + end + + it 'sorts ascending when requested' do + get api("#{base_url}/issues", user), params: { sort: :asc } + + expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id]) + end + + it 'sorts by updated_at descending when requested' do + get api("#{base_url}/issues", user), params: { order_by: :updated_at } + + issue.touch(:updated_at) + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + it 'sorts by updated_at ascending when requested' do + get api("#{base_url}/issues", user), params: { order_by: :updated_at, sort: :asc } + + expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id]) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + + context 'sort does not affect statistics ' do + let(:params) { { state: :opened, order_by: 'updated_at' } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'project issues statistics' + end + end + + context 'filtering by assignee_username' do + let(:another_assignee) { create(:assignee) } + let!(:issue1) { create(:issue, author: user2, project: project, created_at: 3.days.ago) } + let!(:issue2) { create(:issue, author: user2, project: project, created_at: 2.days.ago) } + let!(:issue3) { create(:issue, author: user2, assignees: [assignee, another_assignee], project: project, created_at: 1.day.ago) } + + it 'returns issues by assignee_username' do + get api("/issues", user), params: { assignee_username: [assignee.username], scope: 'all' } + + expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id]) + expect_paginated_array_response([confidential_issue.id, issue3.id]) + end + + it 'returns issues by assignee_username as string' do + get api("/issues", user), params: { assignee_username: assignee.username, scope: 'all' } + + expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id]) + expect_paginated_array_response([confidential_issue.id, issue3.id]) + end + + it 'returns error when multiple assignees are passed' do + get api("/issues", user), params: { assignee_username: [assignee.username, another_assignee.username], scope: 'all' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to include("allows one value, but found 2") + end + + it 'returns error when assignee_username and assignee_id are passed together' do + get api("/issues", user), params: { assignee_username: [assignee.username], assignee_id: another_assignee.id, scope: 'all' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to include("mutually exclusive") + end + end + end + + describe 'GET /projects/:id/issues/:issue_iid' do + context 'when unauthenticated' do + it 'returns public issues' do + get api("/projects/#{project.id}/issues/#{issue.iid}") + + expect(response).to have_gitlab_http_status(200) + end + end + + it 'exposes known attributes' do + get api("/projects/#{project.id}/issues/#{issue.iid}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['id']).to eq(issue.id) + expect(json_response['iid']).to eq(issue.iid) + expect(json_response['project_id']).to eq(issue.project.id) + expect(json_response['title']).to eq(issue.title) + expect(json_response['description']).to eq(issue.description) + expect(json_response['state']).to eq(issue.state) + expect(json_response['closed_at']).to be_falsy + expect(json_response['created_at']).to be_present + expect(json_response['updated_at']).to be_present + expect(json_response['labels']).to eq(issue.label_names) + expect(json_response['milestone']).to be_a Hash + expect(json_response['assignees']).to be_a Array + expect(json_response['assignee']).to be_a Hash + expect(json_response['author']).to be_a Hash + expect(json_response['confidential']).to be_falsy + end + + it 'exposes the closed_at attribute' do + get api("/projects/#{project.id}/issues/#{closed_issue.iid}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['closed_at']).to be_present + end + + context 'links exposure' do + it 'exposes related resources full URIs' do + get api("/projects/#{project.id}/issues/#{issue.iid}", user) + + links = json_response['_links'] + + expect(links['self']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}") + expect(links['notes']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}/notes") + expect(links['award_emoji']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}/award_emoji") + expect(links['project']).to end_with("/api/v4/projects/#{project.id}") + end + end + + it 'returns a project issue by internal id' do + get api("/projects/#{project.id}/issues/#{issue.iid}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq(issue.title) + expect(json_response['iid']).to eq(issue.iid) + end + + it 'returns 404 if issue id not found' do + get api("/projects/#{project.id}/issues/54321", user) + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if the issue ID is used' do + get api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_gitlab_http_status(404) + end + + context 'confidential issues' do + it 'returns 404 for non project members' do + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 for project members with guest role' do + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns confidential issue for project members' do + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it 'returns confidential issue for author' do + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it 'returns confidential issue for assignee' do + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", assignee) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it 'returns confidential issue for admin' do + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + end + end + + describe 'GET :id/issues/:issue_iid/closed_by' do + let(:merge_request) do + create(:merge_request, + :simple, + author: user, + source_project: project, + target_project: project, + description: "closes #{issue.to_reference}") + end + + before do + create(:merge_requests_closing_issues, issue: issue, merge_request: merge_request) + end + + context 'when unauthenticated' do + it 'return public project issues' do + get api("/projects/#{project.id}/issues/#{issue.iid}/closed_by") + + expect_paginated_array_response(merge_request.id) + end + end + + it 'returns merge requests that will close issue on merge' do + get api("/projects/#{project.id}/issues/#{issue.iid}/closed_by", user) + + expect_paginated_array_response(merge_request.id) + end + + context 'when no merge requests will close issue' do + it 'returns empty array' do + get api("/projects/#{project.id}/issues/#{closed_issue.iid}/closed_by", user) + + expect_paginated_array_response([]) + end + end + + it "returns 404 when issue doesn't exists" do + get api("/projects/#{project.id}/issues/0/closed_by", user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe 'GET :id/issues/:issue_iid/related_merge_requests' do + def get_related_merge_requests(project_id, issue_iid, user = nil) + get api("/projects/#{project_id}/issues/#{issue_iid}/related_merge_requests", user) + end + + def create_referencing_mr(user, project, issue) + attributes = { + author: user, + source_project: project, + target_project: project, + source_branch: 'master', + target_branch: 'test', + description: "See #{issue.to_reference}" + } + create(:merge_request, attributes).tap do |merge_request| + create(:note, :system, project: issue.project, noteable: issue, author: user, note: merge_request.to_reference(full: true)) + end + end + + let!(:related_mr) { create_referencing_mr(user, project, issue) } + + context 'when unauthenticated' do + it 'return list of referenced merge requests from issue' do + get_related_merge_requests(project.id, issue.iid) + + expect_paginated_array_response(related_mr.id) + end + + it 'renders 404 if project is not visible' do + private_project = create(:project, :private) + private_issue = create(:issue, project: private_project) + create_referencing_mr(user, private_project, private_issue) + + get_related_merge_requests(private_project.id, private_issue.iid) + + expect(response).to have_gitlab_http_status(404) + end + end + + it 'returns merge requests that mentioned a issue' do + create(:merge_request, + :simple, + author: user, + source_project: project, + target_project: project, + description: 'Some description') + + get_related_merge_requests(project.id, issue.iid, user) + + expect_paginated_array_response(related_mr.id) + end + + it 'returns merge requests cross-project wide' do + project2 = create(:project, :public, creator_id: user.id, namespace: user.namespace) + merge_request = create_referencing_mr(user, project2, issue) + + get_related_merge_requests(project.id, issue.iid, user) + + expect_paginated_array_response([related_mr.id, merge_request.id]) + end + + it 'does not generate references to projects with no access' do + private_project = create(:project, :private) + create_referencing_mr(private_project.creator, private_project, issue) + + get_related_merge_requests(project.id, issue.iid, user) + + expect_paginated_array_response(related_mr.id) + end + + context 'no merge request mentioned a issue' do + it 'returns empty array' do + get_related_merge_requests(project.id, closed_issue.iid, user) + + expect_paginated_array_response([]) + end + end + + it "returns 404 when issue doesn't exists" do + get_related_merge_requests(project.id, 0, user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe 'GET /projects/:id/issues/:issue_iid/user_agent_detail' do + let!(:user_agent_detail) { create(:user_agent_detail, subject: issue) } + + context 'when unauthenticated' do + it 'returns unauthorized' do + get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail") + + expect(response).to have_gitlab_http_status(401) + end + end + + it 'exposes known attributes' do + get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", admin) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['user_agent']).to eq(user_agent_detail.user_agent) + expect(json_response['ip_address']).to eq(user_agent_detail.ip_address) + expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) + end + + it 'returns unauthorized for non-admin users' do + get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", user) + + expect(response).to have_gitlab_http_status(403) + end + end + + describe 'GET projects/:id/issues/:issue_iid/participants' do + it_behaves_like 'issuable participants endpoint' do + let(:entity) { issue } + end + + it 'returns 404 if the issue is confidential' do + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/participants", non_member) + + expect(response).to have_gitlab_http_status(404) + end + end +end diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb new file mode 100644 index 00000000000..9b9cc778fb3 --- /dev/null +++ b/spec/requests/api/issues/issues_spec.rb @@ -0,0 +1,796 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Issues do + set(:user) { create(:user) } + set(:project) do + create(:project, :public, creator_id: user.id, namespace: user.namespace) + end + + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + set(:guest) { create(:user) } + set(:author) { create(:author) } + set(:assignee) { create(:assignee) } + let(:admin) { create(:user, :admin) } + let(:issue_title) { 'foo' } + let(:issue_description) { 'closed' } + let!(:closed_issue) do + create :closed_issue, + author: user, + assignees: [user], + project: project, + state: :closed, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 3.hours.ago, + closed_at: 1.hour.ago + end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignees: [assignee], + created_at: generate(:past_time), + updated_at: 2.hours.ago + end + let!(:issue) do + create :issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + end + set(:label) do + create(:label, title: 'label', color: '#FFAABB', project: project) + end + let!(:label_link) { create(:label_link, label: label, target: issue) } + let(:milestone) { create(:milestone, title: '1.0.0', project: project) } + set(:empty_milestone) do + create(:milestone, title: '2.0.0', project: project) + end + let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + + let(:no_milestone_title) { 'None' } + let(:any_milestone_title) { 'Any' } + + before(:all) do + project.add_reporter(user) + project.add_guest(guest) + end + + before do + stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + end + + shared_examples 'issues statistics' do + it 'returns issues statistics' do + get api("/issues_statistics", user), params: params + + expect(response).to have_gitlab_http_status(200) + expect(json_response['statistics']).not_to be_nil + expect(json_response['statistics']['counts']['all']).to eq counts[:all] + expect(json_response['statistics']['counts']['closed']).to eq counts[:closed] + expect(json_response['statistics']['counts']['opened']).to eq counts[:opened] + end + end + + describe 'GET /issues' do + context 'when unauthenticated' do + it 'returns an array of all issues' do + get api('/issues'), params: { scope: 'all' } + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + end + + it 'returns authentication error without any scope' do + get api('/issues') + + expect(response).to have_http_status(401) + end + + it 'returns authentication error when scope is assigned-to-me' do + get api('/issues'), params: { scope: 'assigned-to-me' } + + expect(response).to have_http_status(401) + end + + it 'returns authentication error when scope is created-by-me' do + get api('/issues'), params: { scope: 'created-by-me' } + + expect(response).to have_http_status(401) + end + + it 'returns an array of issues matching state in milestone' do + get api('/issues'), params: { milestone: 'foo', scope: 'all' } + + expect(response).to have_http_status(200) + expect_paginated_array_response([]) + end + + it 'returns an array of issues matching state in milestone' do + get api('/issues'), params: { milestone: milestone.title, scope: 'all' } + + expect(response).to have_http_status(200) + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + context 'issues_statistics' do + it 'returns authentication error without any scope' do + get api('/issues_statistics') + + expect(response).to have_http_status(401) + end + + it 'returns authentication error when scope is assigned_to_me' do + get api('/issues_statistics'), params: { scope: 'assigned_to_me' } + + expect(response).to have_http_status(401) + end + + it 'returns authentication error when scope is created_by_me' do + get api('/issues_statistics'), params: { scope: 'created_by_me' } + + expect(response).to have_http_status(401) + end + + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'sort does not affect statistics ' do + let(:params) { { state: :opened, order_by: 'updated_at' } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + end + end + + context 'when authenticated' do + it 'returns an array of issues' do + get api('/issues', user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + expect(json_response.first['title']).to eq(issue.title) + expect(json_response.last).to have_key('web_url') + end + + it 'returns an array of closed issues' do + get api('/issues', user), params: { state: :closed } + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an array of opened issues' do + get api('/issues', user), params: { state: :opened } + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of all issues' do + get api('/issues', user), params: { state: :all } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns issues assigned to me' do + issue2 = create(:issue, assignees: [user2], project: project) + + get api('/issues', user2), params: { scope: 'assigned_to_me' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues assigned to me (kebab-case)' do + issue2 = create(:issue, assignees: [user2], project: project) + + get api('/issues', user2), params: { scope: 'assigned-to-me' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues authored by the given author id' do + issue2 = create(:issue, author: user2, project: project) + + get api('/issues', user), params: { author_id: user2.id, scope: 'all' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues assigned to the given assignee id' do + issue2 = create(:issue, assignees: [user2], project: project) + + get api('/issues', user), params: { assignee_id: user2.id, scope: 'all' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues authored by the given author id and assigned to the given assignee id' do + issue2 = create(:issue, author: user2, assignees: [user2], project: project) + + get api('/issues', user), params: { author_id: user2.id, assignee_id: user2.id, scope: 'all' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues with no assignee' do + issue2 = create(:issue, author: user2, project: project) + + get api('/issues', user), params: { assignee_id: 0, scope: 'all' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues with no assignee' do + issue2 = create(:issue, author: user2, project: project) + + get api('/issues', user), params: { assignee_id: 'None', scope: 'all' } + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues with any assignee' do + # This issue without assignee should not be returned + create(:issue, author: user2, project: project) + + get api('/issues', user), params: { assignee_id: 'Any', scope: 'all' } + + expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) + end + + it 'returns only confidential issues' do + get api('/issues', user), params: { confidential: true, scope: 'all' } + + expect_paginated_array_response(confidential_issue.id) + end + + it 'returns only public issues' do + get api('/issues', user), params: { confidential: false } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns issues reacted by the authenticated user' do + issue2 = create(:issue, project: project, author: user, assignees: [user]) + create(:award_emoji, awardable: issue2, user: user2, name: 'star') + create(:award_emoji, awardable: issue, user: user2, name: 'thumbsup') + + get api('/issues', user2), params: { my_reaction_emoji: 'Any', scope: 'all' } + + expect_paginated_array_response([issue2.id, issue.id]) + end + + it 'returns issues not reacted by the authenticated user' do + issue2 = create(:issue, project: project, author: user, assignees: [user]) + create(:award_emoji, awardable: issue2, user: user2, name: 'star') + + get api('/issues', user2), params: { my_reaction_emoji: 'None', scope: 'all' } + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns issues matching given search string for title' do + get api('/issues', user), params: { search: issue.title } + + expect_paginated_array_response(issue.id) + end + + it 'returns issues matching given search string for title and scoped in title' do + get api('/issues', user), params: { search: issue.title, in: 'title' } + + expect_paginated_array_response(issue.id) + end + + it 'returns an empty array if no issue matches given search string for title and scoped in description' do + get api('/issues', user), params: { search: issue.title, in: 'description' } + + expect_paginated_array_response([]) + end + + it 'returns issues matching given search string for description' do + get api('/issues', user), params: { search: issue.description } + + expect_paginated_array_response(issue.id) + end + + context 'filtering before a specific date' do + let!(:issue2) { create(:issue, project: project, author: user, created_at: Date.new(2000, 1, 1), updated_at: Date.new(2000, 1, 1)) } + + it 'returns issues created before a specific date' do + get api('/issues?created_before=2000-01-02T00:00:00.060Z', user) + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues updated before a specific date' do + get api('/issues?updated_before=2000-01-02T00:00:00.060Z', user) + + expect_paginated_array_response(issue2.id) + end + end + + context 'filtering after a specific date' do + let!(:issue2) { create(:issue, project: project, author: user, created_at: 1.week.from_now, updated_at: 1.week.from_now) } + + it 'returns issues created after a specific date' do + get api("/issues?created_after=#{issue2.created_at}", user) + + expect_paginated_array_response(issue2.id) + end + + it 'returns issues updated after a specific date' do + get api("/issues?updated_after=#{issue2.updated_at}", user) + + expect_paginated_array_response(issue2.id) + end + end + + context 'filter by labels or label_name param' do + context 'N+1' do + let(:label_b) { create(:label, title: 'foo', project: project) } + let(:label_c) { create(:label, title: 'bar', project: project) } + + before do + create(:label_link, label: label_b, target: issue) + create(:label_link, label: label_c, target: issue) + end + it 'tests N+1' do + control = ActiveRecord::QueryRecorder.new do + get api('/issues', user), params: { labels: [label.title, label_b.title, label_c.title] } + end + + label_d = create(:label, title: 'dar', project: project) + label_e = create(:label, title: 'ear', project: project) + create(:label_link, label: label_d, target: issue) + create(:label_link, label: label_e, target: issue) + + expect do + get api('/issues', user), params: { labels: [label.title, label_b.title, label_c.title] } + end.not_to exceed_query_limit(control) + expect(issue.labels.count).to eq(5) + end + end + + it 'returns an array of labeled issues' do + get api('/issues', user), params: { labels: label.title } + + expect_paginated_array_response(issue.id) + expect(json_response.first['labels']).to eq([label.title]) + end + + it 'returns an array of labeled issues with labels param as array' do + get api('/issues', user), params: { labels: [label.title] } + + expect_paginated_array_response(issue.id) + expect(json_response.first['labels']).to eq([label.title]) + end + + context 'with labeled issues' do + let(:label_b) { create(:label, title: 'foo', project: project) } + let(:label_c) { create(:label, title: 'bar', project: project) } + + before do + create(:label_link, label: label_b, target: issue) + create(:label_link, label: label_c, target: issue) + + get api('/issues', user), params: params + end + + it_behaves_like 'labeled issues with labels and label_name params' + end + + it 'returns an empty array if no issue matches labels' do + get api('/issues', user), params: { labels: 'foo,bar' } + + expect_paginated_array_response([]) + end + + it 'returns an empty array if no issue matches labels with labels param as array' do + get api('/issues', user), params: { labels: %w(foo bar) } + + expect_paginated_array_response([]) + end + + it 'returns an array of labeled issues matching given state' do + get api('/issues', user), params: { labels: label.title, state: :opened } + + expect_paginated_array_response(issue.id) + expect(json_response.first['labels']).to eq([label.title]) + expect(json_response.first['state']).to eq('opened') + end + + it 'returns an array of labeled issues matching given state with labels param as array' do + get api('/issues', user), params: { labels: [label.title], state: :opened } + + expect_paginated_array_response(issue.id) + expect(json_response.first['labels']).to eq([label.title]) + expect(json_response.first['state']).to eq('opened') + end + + it 'returns an empty array if no issue matches labels and state filters' do + get api('/issues', user), params: { labels: label.title, state: :closed } + + expect_paginated_array_response([]) + end + + it 'returns an array of issues with any label' do + get api('/issues', user), params: { labels: IssuesFinder::FILTER_ANY } + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of issues with any label with labels param as array' do + get api('/issues', user), params: { labels: [IssuesFinder::FILTER_ANY] } + + expect_paginated_array_response(issue.id) + end + + it 'returns an array of issues with no label' do + get api('/issues', user), params: { labels: IssuesFinder::FILTER_NONE } + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an array of issues with no label with labels param as array' do + get api('/issues', user), params: { labels: [IssuesFinder::FILTER_NONE] } + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an array of issues with no label when using the legacy No+Label filter' do + get api('/issues', user), params: { labels: 'No Label' } + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an array of issues with no label when using the legacy No+Label filter with labels param as array' do + get api('/issues', user), params: { labels: ['No Label'] } + + expect_paginated_array_response(closed_issue.id) + end + end + + it 'returns an empty array if no issue matches milestone' do + get api("/issues?milestone=#{empty_milestone.title}", user) + + expect_paginated_array_response([]) + end + + it 'returns an empty array if milestone does not exist' do + get api('/issues?milestone=foo', user) + + expect_paginated_array_response([]) + end + + it 'returns an array of issues in given milestone' do + get api("/issues?milestone=#{milestone.title}", user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns an array of issues in given milestone_title param' do + get api("/issues?milestone_title=#{milestone.title}", user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'returns an array of issues matching state in milestone' do + get api("/issues?milestone=#{milestone.title}&state=closed", user) + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get api("/issues?milestone=#{no_milestone_title}", author) + + expect_paginated_array_response(confidential_issue.id) + end + + it 'returns an array of issues with no milestone using milestone_title param' do + get api("/issues?milestone_title=#{no_milestone_title}", author) + + expect_paginated_array_response(confidential_issue.id) + end + + it 'returns an array of issues found by iids' do + get api('/issues', user), params: { iids: [closed_issue.iid] } + + expect_paginated_array_response(closed_issue.id) + end + + it 'returns an empty array if iid does not exist' do + get api('/issues', user), params: { iids: [0] } + + expect_paginated_array_response([]) + end + + context 'without sort params' do + it 'sorts by created_at descending by default' do + get api('/issues', user) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + context 'with 2 issues with same created_at' do + let!(:closed_issue2) do + create :closed_issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: closed_issue.created_at, + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + end + + it 'page breaks first page correctly' do + get api('/issues?per_page=2', user) + + expect_paginated_array_response([issue.id, closed_issue2.id]) + end + + it 'page breaks second page correctly' do + get api('/issues?per_page=2&page=2', user) + + expect_paginated_array_response([closed_issue.id]) + end + end + end + + it 'sorts ascending when requested' do + get api('/issues?sort=asc', user) + + expect_paginated_array_response([closed_issue.id, issue.id]) + end + + it 'sorts by updated_at descending when requested' do + get api('/issues?order_by=updated_at', user) + + issue.touch(:updated_at) + + expect_paginated_array_response([issue.id, closed_issue.id]) + end + + it 'sorts by updated_at ascending when requested' do + get api('/issues?order_by=updated_at&sort=asc', user) + + issue.touch(:updated_at) + + expect_paginated_array_response([closed_issue.id, issue.id]) + end + + it 'matches V4 response schema' do + get api('/issues', user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/issues') + end + + it 'returns a related merge request count of 0 if there are no related merge requests' do + get api('/issues', user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/issues') + expect(json_response.first).to include('merge_requests_count' => 0) + end + + it 'returns a related merge request count > 0 if there are related merge requests' do + create(:merge_requests_closing_issues, issue: issue) + + get api('/issues', user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/issues') + expect(json_response.first).to include('merge_requests_count' => 1) + end + + context 'issues_statistics' do + context 'no state is treated as all state' do + let(:params) { {} } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'statistics when all state is passed' do + let(:params) { { state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'closed state is treated as all state' do + let(:params) { { state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'opened state is treated as all state' do + let(:params) { { state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and no state treated as all state' do + let(:params) { { milestone: milestone.title } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and all state' do + let(:params) { { milestone: milestone.title, state: :all } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and closed state treated as all state' do + let(:params) { { milestone: milestone.title, state: :closed } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'when filtering by milestone and opened state treated as all state' do + let(:params) { { milestone: milestone.title, state: :opened } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + + context 'sort does not affect statistics ' do + let(:params) { { state: :opened, order_by: 'updated_at' } } + let(:counts) { { all: 2, closed: 1, opened: 1 } } + + it_behaves_like 'issues statistics' + end + end + + context 'filtering by assignee_username' do + let(:another_assignee) { create(:assignee) } + let!(:issue1) { create(:issue, author: user2, project: project, created_at: 3.days.ago) } + let!(:issue2) { create(:issue, author: user2, project: project, created_at: 2.days.ago) } + let!(:issue3) { create(:issue, author: user2, assignees: [assignee, another_assignee], project: project, created_at: 1.day.ago) } + + it 'returns issues with by assignee_username' do + get api("/issues", user), params: { assignee_username: [assignee.username], scope: 'all' } + + expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id]) + expect_paginated_array_response([confidential_issue.id, issue3.id]) + end + + it 'returns issues by assignee_username as string' do + get api("/issues", user), params: { assignee_username: assignee.username, scope: 'all' } + + expect(issue3.reload.assignees.pluck(:id)).to match_array([assignee.id, another_assignee.id]) + expect_paginated_array_response([confidential_issue.id, issue3.id]) + end + + it 'returns error when multiple assignees are passed' do + get api("/issues", user), params: { assignee_username: [assignee.username, another_assignee.username], scope: 'all' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to include("allows one value, but found 2") + end + + it 'returns error when assignee_username and assignee_id are passed together' do + get api("/issues", user), params: { assignee_username: [assignee.username], assignee_id: another_assignee.id, scope: 'all' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response["error"]).to include("mutually exclusive") + end + end + end + end + + describe 'DELETE /projects/:id/issues/:issue_iid' do + it 'rejects a non member from deleting an issue' do + delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member) + expect(response).to have_gitlab_http_status(403) + end + + it 'rejects a developer from deleting an issue' do + delete api("/projects/#{project.id}/issues/#{issue.iid}", author) + expect(response).to have_gitlab_http_status(403) + end + + context 'when the user is project owner' do + let(:owner) { create(:user) } + let(:project) { create(:project, namespace: owner.namespace) } + + it 'deletes the issue if an admin requests it' do + delete api("/projects/#{project.id}/issues/#{issue.iid}", owner) + + expect(response).to have_gitlab_http_status(204) + end + + it_behaves_like '412 response' do + let(:request) { api("/projects/#{project.id}/issues/#{issue.iid}", owner) } + end + end + + context 'when issue does not exist' do + it 'returns 404 when trying to move an issue' do + delete api("/projects/#{project.id}/issues/123", user) + + expect(response).to have_gitlab_http_status(404) + end + end + + it 'returns 404 when using the issue ID instead of IID' do + delete api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe 'time tracking endpoints' do + let(:issuable) { issue } + + include_examples 'time tracking endpoints', 'issue' + end +end diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb new file mode 100644 index 00000000000..b74e8867310 --- /dev/null +++ b/spec/requests/api/issues/post_projects_issues_spec.rb @@ -0,0 +1,549 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Issues do + set(:user) { create(:user) } + set(:project) do + create(:project, :public, creator_id: user.id, namespace: user.namespace) + end + + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + set(:guest) { create(:user) } + set(:author) { create(:author) } + set(:assignee) { create(:assignee) } + let(:admin) { create(:user, :admin) } + let(:issue_title) { 'foo' } + let(:issue_description) { 'closed' } + let!(:closed_issue) do + create :closed_issue, + author: user, + assignees: [user], + project: project, + state: :closed, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 3.hours.ago, + closed_at: 1.hour.ago + end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignees: [assignee], + created_at: generate(:past_time), + updated_at: 2.hours.ago + end + let!(:issue) do + create :issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + end + set(:label) do + create(:label, title: 'label', color: '#FFAABB', project: project) + end + let!(:label_link) { create(:label_link, label: label, target: issue) } + let(:milestone) { create(:milestone, title: '1.0.0', project: project) } + set(:empty_milestone) do + create(:milestone, title: '2.0.0', project: project) + end + let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + + let(:no_milestone_title) { 'None' } + let(:any_milestone_title) { 'Any' } + + before(:all) do + project.add_reporter(user) + project.add_guest(guest) + end + + before do + stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + end + + describe 'POST /projects/:id/issues' do + context 'support for deprecated assignee_id' do + it 'creates a new project issue' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', assignee_id: user2.id } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + + it 'creates a new project issue when assignee_id is empty' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', assignee_id: '' } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignee']).to be_nil + end + end + + context 'single assignee restrictions' do + it 'creates a new project issue with no more than one assignee' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', assignee_ids: [user2.id, guest.id] } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['assignees'].count).to eq(1) + end + end + + context 'user does not have permissions to create issue' do + let(:not_member) { create(:user) } + + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE) + end + + it 'renders 403' do + post api("/projects/#{project.id}/issues", not_member), params: { title: 'new issue' } + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'an internal ID is provided' do + context 'by an admin' do + it 'sets the internal ID on the new issue' do + post api("/projects/#{project.id}/issues", admin), + params: { title: 'new issue', iid: 9001 } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['iid']).to eq 9001 + end + end + + context 'by an owner' do + it 'sets the internal ID on the new issue' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', iid: 9001 } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['iid']).to eq 9001 + end + end + + context 'by a group owner' do + let(:group) { create(:group) } + let(:group_project) { create(:project, :public, namespace: group) } + + it 'sets the internal ID on the new issue' do + group.add_owner(user2) + post api("/projects/#{group_project.id}/issues", user2), + params: { title: 'new issue', iid: 9001 } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['iid']).to eq 9001 + end + end + + context 'by another user' do + it 'ignores the given internal ID' do + post api("/projects/#{project.id}/issues", user2), + params: { title: 'new issue', iid: 9001 } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['iid']).not_to eq 9001 + end + end + end + + it 'creates a new project issue' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', labels: 'label, label2', weight: 3, assignee_ids: [user2.id] } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['description']).to be_nil + expect(json_response['labels']).to eq(%w(label label2)) + expect(json_response['confidential']).to be_falsy + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + + it 'creates a new project issue with labels param as array' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', labels: %w(label label2), weight: 3, assignee_ids: [user2.id] } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['description']).to be_nil + expect(json_response['labels']).to eq(%w(label label2)) + expect(json_response['confidential']).to be_falsy + expect(json_response['assignee']['name']).to eq(user2.name) + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + + it 'creates a new confidential project issue' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', confidential: true } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_truthy + end + + it 'creates a new confidential project issue with a different param' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', confidential: 'y' } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_truthy + end + + it 'creates a public issue when confidential param is false' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', confidential: false } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['confidential']).to be_falsy + end + + it 'creates a public issue when confidential param is invalid' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', confidential: 'foo' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq('confidential is invalid') + end + + it 'returns a 400 bad request if title not given' do + post api("/projects/#{project.id}/issues", user), params: { labels: 'label, label2' } + expect(response).to have_gitlab_http_status(400) + end + + it 'allows special label names' do + post api("/projects/#{project.id}/issues", user), + params: { + title: 'new issue', + labels: 'label, label?, label&foo, ?, &' + } + expect(response.status).to eq(201) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'allows special label names with labels param as array' do + post api("/projects/#{project.id}/issues", user), + params: { + title: 'new issue', + labels: ['label', 'label?', 'label&foo, ?, &'] + } + expect(response.status).to eq(201) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'returns 400 if title is too long' do + post api("/projects/#{project.id}/issues", user), + params: { title: 'g' * 256 } + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['title']).to eq([ + 'is too long (maximum is 255 characters)' + ]) + end + + context 'resolving discussions' do + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } + let(:merge_request) { discussion.noteable } + let(:project) { merge_request.source_project } + + before do + project.add_maintainer(user) + end + + context 'resolving all discussions in a merge request' do + before do + post api("/projects/#{project.id}/issues", user), + params: { + title: 'New Issue', + merge_request_to_resolve_discussions_of: merge_request.iid + } + end + + it_behaves_like 'creating an issue resolving discussions through the API' + end + + context 'resolving a single discussion' do + before do + post api("/projects/#{project.id}/issues", user), + params: { + title: 'New Issue', + merge_request_to_resolve_discussions_of: merge_request.iid, + discussion_to_resolve: discussion.id + } + end + + it_behaves_like 'creating an issue resolving discussions through the API' + end + end + + context 'with due date' do + it 'creates a new project issue' do + due_date = 2.weeks.from_now.strftime('%Y-%m-%d') + + post api("/projects/#{project.id}/issues", user), + params: { title: 'new issue', due_date: due_date } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['title']).to eq('new issue') + expect(json_response['description']).to be_nil + expect(json_response['due_date']).to eq(due_date) + end + end + + context 'setting created_at' do + let(:creation_time) { 2.weeks.ago } + let(:params) { { title: 'new issue', labels: 'label, label2', created_at: creation_time } } + + context 'by an admin' do + it 'sets the creation time on the new issue' do + post api("/projects/#{project.id}/issues", admin), params: params + + expect(response).to have_gitlab_http_status(201) + expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) + end + end + + context 'by a project owner' do + it 'sets the creation time on the new issue' do + post api("/projects/#{project.id}/issues", user), params: params + + expect(response).to have_gitlab_http_status(201) + expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) + end + end + + context 'by a group owner' do + it 'sets the creation time on the new issue' do + group = create(:group) + group_project = create(:project, :public, namespace: group) + group.add_owner(user2) + post api("/projects/#{group_project.id}/issues", user2), params: params + + expect(response).to have_gitlab_http_status(201) + expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) + end + end + + context 'by another user' do + it 'ignores the given creation time' do + post api("/projects/#{project.id}/issues", user2), params: params + + expect(response).to have_gitlab_http_status(201) + expect(Time.parse(json_response['created_at'])).not_to be_like_time(creation_time) + end + end + end + + context 'the user can only read the issue' do + it 'cannot create new labels' do + expect do + post api("/projects/#{project.id}/issues", non_member), params: { title: 'new issue', labels: 'label, label2' } + end.not_to change { project.labels.count } + end + + it 'cannot create new labels with labels param as array' do + expect do + post api("/projects/#{project.id}/issues", non_member), params: { title: 'new issue', labels: %w(label label2) } + end.not_to change { project.labels.count } + end + end + end + + describe 'POST /projects/:id/issues with spam filtering' do + before do + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive_messages(spam?: true) + end + + let(:params) do + { + title: 'new issue', + description: 'content here', + labels: 'label, label2' + } + end + + it 'does not create a new project issue' do + expect { post api("/projects/#{project.id}/issues", user), params: params }.not_to change(Issue, :count) + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq({ 'error' => 'Spam detected' }) + + spam_logs = SpamLog.all + expect(spam_logs.count).to eq(1) + expect(spam_logs[0].title).to eq('new issue') + expect(spam_logs[0].description).to eq('content here') + expect(spam_logs[0].user).to eq(user) + expect(spam_logs[0].noteable_type).to eq('Issue') + end + end + + describe '/projects/:id/issues/:issue_iid/move' do + let!(:target_project) { create(:project, creator_id: user.id, namespace: user.namespace ) } + let!(:target_project2) { create(:project, creator_id: non_member.id, namespace: non_member.namespace ) } + + it 'moves an issue' do + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), + params: { to_project_id: target_project.id } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['project_id']).to eq(target_project.id) + end + + context 'when source and target projects are the same' do + it 'returns 400 when trying to move an issue' do + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), + params: { to_project_id: project.id } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq('Cannot move issue to project it originates from!') + end + end + + context 'when the user does not have the permission to move issues' do + it 'returns 400 when trying to move an issue' do + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), + params: { to_project_id: target_project2.id } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq('Cannot move issue due to insufficient permissions!') + end + end + + it 'moves the issue to another namespace if I am admin' do + post api("/projects/#{project.id}/issues/#{issue.iid}/move", admin), + params: { to_project_id: target_project2.id } + + expect(response).to have_gitlab_http_status(201) + expect(json_response['project_id']).to eq(target_project2.id) + end + + context 'when using the issue ID instead of iid' do + it 'returns 404 when trying to move an issue' do + post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + params: { to_project_id: target_project.id } + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when issue does not exist' do + it 'returns 404 when trying to move an issue' do + post api("/projects/#{project.id}/issues/123/move", user), + params: { to_project_id: target_project.id } + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + + context 'when source project does not exist' do + it 'returns 404 when trying to move an issue' do + post api("/projects/0/issues/#{issue.iid}/move", user), + params: { to_project_id: target_project.id } + + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 Project Not Found') + end + end + + context 'when target project does not exist' do + it 'returns 404 when trying to move an issue' do + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), + params: { to_project_id: 0 } + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe 'POST :id/issues/:issue_iid/subscribe' do + it 'subscribes to an issue' do + post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user2) + + expect(response).to have_gitlab_http_status(201) + expect(json_response['subscribed']).to eq(true) + end + + it 'returns 304 if already subscribed' do + post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user) + + expect(response).to have_gitlab_http_status(304) + end + + it 'returns 404 if the issue is not found' do + post api("/projects/#{project.id}/issues/123/subscribe", user) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if the issue ID is used instead of the iid' do + post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if the issue is confidential' do + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/subscribe", non_member) + + expect(response).to have_gitlab_http_status(404) + end + end + + describe 'POST :id/issues/:issue_id/unsubscribe' do + it 'unsubscribes from an issue' do + post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user) + + expect(response).to have_gitlab_http_status(201) + expect(json_response['subscribed']).to eq(false) + end + + it 'returns 304 if not subscribed' do + post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user2) + + expect(response).to have_gitlab_http_status(304) + end + + it 'returns 404 if the issue is not found' do + post api("/projects/#{project.id}/issues/123/unsubscribe", user) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if using the issue ID instead of iid' do + post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) + + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 if the issue is confidential' do + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/unsubscribe", non_member) + + expect(response).to have_gitlab_http_status(404) + end + end +end diff --git a/spec/requests/api/issues/put_projects_issues_spec.rb b/spec/requests/api/issues/put_projects_issues_spec.rb new file mode 100644 index 00000000000..267cba93713 --- /dev/null +++ b/spec/requests/api/issues/put_projects_issues_spec.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Issues do + set(:user) { create(:user) } + set(:project) do + create(:project, :public, creator_id: user.id, namespace: user.namespace) + end + + let(:user2) { create(:user) } + let(:non_member) { create(:user) } + set(:guest) { create(:user) } + set(:author) { create(:author) } + set(:assignee) { create(:assignee) } + let(:admin) { create(:user, :admin) } + let(:issue_title) { 'foo' } + let(:issue_description) { 'closed' } + let!(:closed_issue) do + create :closed_issue, + author: user, + assignees: [user], + project: project, + state: :closed, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 3.hours.ago, + closed_at: 1.hour.ago + end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignees: [assignee], + created_at: generate(:past_time), + updated_at: 2.hours.ago + end + let!(:issue) do + create :issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + end + set(:label) do + create(:label, title: 'label', color: '#FFAABB', project: project) + end + let!(:label_link) { create(:label_link, label: label, target: issue) } + let(:milestone) { create(:milestone, title: '1.0.0', project: project) } + set(:empty_milestone) do + create(:milestone, title: '2.0.0', project: project) + end + let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + + let(:no_milestone_title) { 'None' } + let(:any_milestone_title) { 'Any' } + + before(:all) do + project.add_reporter(user) + project.add_guest(guest) + end + + before do + stub_licensed_features(multiple_issue_assignees: false, issue_weights: false) + end + + describe 'PUT /projects/:id/issues/:issue_iid to update only title' do + it 'updates a project issue' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(200) + + expect(json_response['title']).to eq('updated title') + end + + it 'returns 404 error if issue iid not found' do + put api("/projects/#{project.id}/issues/44444", user), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(404) + end + + it 'returns 404 error if issue id is used instead of the iid' do + put api("/projects/#{project.id}/issues/#{issue.id}", user), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(404) + end + + it 'allows special label names' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { + title: 'updated title', + labels: 'label, label?, label&foo, ?, &' + } + + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'allows special label names with labels param as array' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { + title: 'updated title', + labels: ['label', 'label?', 'label&foo, ?, &'] + } + + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label' + expect(json_response['labels']).to include 'label?' + expect(json_response['labels']).to include 'label&foo' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + context 'confidential issues' do + it 'returns 403 for non project members' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(403) + end + + it 'returns 403 for project members with guest role' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(403) + end + + it 'updates a confidential issue for project members' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it 'updates a confidential issue for author' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it 'updates a confidential issue for admin' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it 'sets an issue to confidential' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { confidential: true } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['confidential']).to be_truthy + end + + it 'makes a confidential issue public' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), + params: { confidential: false } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['confidential']).to be_falsy + end + + it 'does not update a confidential issue with wrong confidential flag' do + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), + params: { confidential: 'foo' } + + expect(response).to have_gitlab_http_status(400) + expect(json_response['error']).to eq('confidential is invalid') + end + end + end + + describe 'PUT /projects/:id/issues/:issue_iid with spam filtering' do + let(:params) do + { + title: 'updated title', + description: 'content here', + labels: 'label, label2' + } + end + + it 'does not create a new project issue' do + allow_any_instance_of(SpamService).to receive_messages(check_for_spam?: true) + allow_any_instance_of(AkismetService).to receive_messages(spam?: true) + + put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: params + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq({ 'error' => 'Spam detected' }) + + spam_logs = SpamLog.all + expect(spam_logs.count).to eq(1) + expect(spam_logs[0].title).to eq('updated title') + expect(spam_logs[0].description).to eq('content here') + expect(spam_logs[0].user).to eq(user) + expect(spam_logs[0].noteable_type).to eq('Issue') + end + end + + describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do + context 'support for deprecated assignee_id' do + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { assignee_id: 0 } + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['assignee']).to be_nil + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { assignee_id: user2.id } + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['assignee']['name']).to eq(user2.name) + end + end + + it 'removes assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { assignee_ids: [0] } + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['assignees']).to be_empty + end + + it 'updates an issue with new assignee' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { assignee_ids: [user2.id] } + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['assignees'].first['name']).to eq(user2.name) + end + + context 'single assignee restrictions' do + it 'updates an issue with several assignees but only one has been applied' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { assignee_ids: [user2.id, guest.id] } + + expect(response).to have_gitlab_http_status(200) + + expect(json_response['assignees'].size).to eq(1) + end + end + end + + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do + let!(:label) { create(:label, title: 'dummy', project: project) } + let!(:label_link) { create(:label_link, label: label, target: issue) } + + it 'does not update labels if not present' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { title: 'updated title' } + expect(response).to have_gitlab_http_status(200) + expect(json_response['labels']).to eq([label.title]) + end + + it 'removes all labels and touches the record' do + Timecop.travel(1.minute.from_now) do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { labels: '' } + end + + expect(response).to have_gitlab_http_status(200) + expect(json_response['labels']).to eq([]) + expect(json_response['updated_at']).to be > Time.now + end + + it 'removes all labels and touches the record with labels param as array' do + Timecop.travel(1.minute.from_now) do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { labels: [''] } + end + + expect(response).to have_gitlab_http_status(200) + expect(json_response['labels']).to eq([]) + expect(json_response['updated_at']).to be > Time.now + end + + it 'updates labels and touches the record' do + Timecop.travel(1.minute.from_now) do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { labels: 'foo,bar' } + end + expect(response).to have_gitlab_http_status(200) + expect(json_response['labels']).to include 'foo' + expect(json_response['labels']).to include 'bar' + expect(json_response['updated_at']).to be > Time.now + end + + it 'updates labels and touches the record with labels param as array' do + Timecop.travel(1.minute.from_now) do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { labels: %w(foo bar) } + end + expect(response).to have_gitlab_http_status(200) + expect(json_response['labels']).to include 'foo' + expect(json_response['labels']).to include 'bar' + expect(json_response['updated_at']).to be > Time.now + end + + it 'allows special label names' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' } + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label:foo' + expect(json_response['labels']).to include 'label-bar' + expect(json_response['labels']).to include 'label_bar' + expect(json_response['labels']).to include 'label/bar' + expect(json_response['labels']).to include 'label?bar' + expect(json_response['labels']).to include 'label&bar' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'allows special label names with labels param as array' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { labels: ['label:foo', 'label-bar', 'label_bar', 'label/bar,label?bar,label&bar,?,&'] } + expect(response.status).to eq(200) + expect(json_response['labels']).to include 'label:foo' + expect(json_response['labels']).to include 'label-bar' + expect(json_response['labels']).to include 'label_bar' + expect(json_response['labels']).to include 'label/bar' + expect(json_response['labels']).to include 'label?bar' + expect(json_response['labels']).to include 'label&bar' + expect(json_response['labels']).to include '?' + expect(json_response['labels']).to include '&' + end + + it 'returns 400 if title is too long' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { title: 'g' * 256 } + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']['title']).to eq([ + 'is too long (maximum is 255 characters)' + ]) + end + end + + describe 'PUT /projects/:id/issues/:issue_iid to update state and label' do + it 'updates a project issue' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { labels: 'label2', state_event: 'close' } + expect(response).to have_gitlab_http_status(200) + + expect(json_response['labels']).to include 'label2' + expect(json_response['state']).to eq 'closed' + end + + it 'reopens a project isssue' do + put api("/projects/#{project.id}/issues/#{closed_issue.iid}", user), params: { state_event: 'reopen' } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['state']).to eq 'opened' + end + + context 'when an admin or owner makes the request' do + it 'accepts the update date to be set' do + update_time = 2.weeks.ago + put api("/projects/#{project.id}/issues/#{issue.iid}", user), + params: { labels: 'label3', state_event: 'close', updated_at: update_time } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['labels']).to include 'label3' + expect(Time.parse(json_response['updated_at'])).to be_like_time(update_time) + end + end + end + + describe 'PUT /projects/:id/issues/:issue_iid to update due date' do + it 'creates a new project issue' do + due_date = 2.weeks.from_now.strftime('%Y-%m-%d') + + put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { due_date: due_date } + + expect(response).to have_gitlab_http_status(200) + expect(json_response['due_date']).to eq(due_date) + end + end +end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb deleted file mode 100644 index 0fa34688371..00000000000 --- a/spec/requests/api/issues_spec.rb +++ /dev/null @@ -1,2265 +0,0 @@ -require 'spec_helper' - -describe API::Issues do - set(:user) { create(:user) } - set(:project) do - create(:project, :public, creator_id: user.id, namespace: user.namespace) - end - - let(:user2) { create(:user) } - let(:non_member) { create(:user) } - set(:guest) { create(:user) } - set(:author) { create(:author) } - set(:assignee) { create(:assignee) } - let(:admin) { create(:user, :admin) } - let(:issue_title) { 'foo' } - let(:issue_description) { 'closed' } - let!(:closed_issue) do - create :closed_issue, - author: user, - assignees: [user], - project: project, - state: :closed, - milestone: milestone, - created_at: generate(:past_time), - updated_at: 3.hours.ago, - closed_at: 1.hour.ago - end - let!(:confidential_issue) do - create :issue, - :confidential, - project: project, - author: author, - assignees: [assignee], - created_at: generate(:past_time), - updated_at: 2.hours.ago - end - let!(:issue) do - create :issue, - author: user, - assignees: [user], - project: project, - milestone: milestone, - created_at: generate(:past_time), - updated_at: 1.hour.ago, - title: issue_title, - description: issue_description - end - set(:label) do - create(:label, title: 'label', color: '#FFAABB', project: project) - end - let!(:label_link) { create(:label_link, label: label, target: issue) } - let(:milestone) { create(:milestone, title: '1.0.0', project: project) } - set(:empty_milestone) do - create(:milestone, title: '2.0.0', project: project) - end - let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } - - let(:no_milestone_title) { "None" } - let(:any_milestone_title) { "Any" } - - before(:all) do - project.add_reporter(user) - project.add_guest(guest) - end - - describe "GET /issues" do - context "when unauthenticated" do - it "returns an array of all issues" do - get api("/issues"), params: { scope: 'all' } - - expect(response).to have_http_status(200) - expect(json_response).to be_an Array - end - - it "returns authentication error without any scope" do - get api("/issues") - - expect(response).to have_http_status(401) - end - - it "returns authentication error when scope is assigned-to-me" do - get api("/issues"), params: { scope: 'assigned-to-me' } - - expect(response).to have_http_status(401) - end - - it "returns authentication error when scope is created-by-me" do - get api("/issues"), params: { scope: 'created-by-me' } - - expect(response).to have_http_status(401) - end - end - - context "when authenticated" do - it "returns an array of issues" do - get api("/issues", user) - - expect_paginated_array_response([issue.id, closed_issue.id]) - expect(json_response.first['title']).to eq(issue.title) - expect(json_response.last).to have_key('web_url') - end - - it 'returns an array of closed issues' do - get api('/issues', user), params: { state: :closed } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an array of opened issues' do - get api('/issues', user), params: { state: :opened } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of all issues' do - get api('/issues', user), params: { state: :all } - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns issues assigned to me' do - issue2 = create(:issue, assignees: [user2], project: project) - - get api('/issues', user2), params: { scope: 'assigned_to_me' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues assigned to me (kebab-case)' do - issue2 = create(:issue, assignees: [user2], project: project) - - get api('/issues', user2), params: { scope: 'assigned-to-me' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues authored by the given author id' do - issue2 = create(:issue, author: user2, project: project) - - get api('/issues', user), params: { author_id: user2.id, scope: 'all' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues assigned to the given assignee id' do - issue2 = create(:issue, assignees: [user2], project: project) - - get api('/issues', user), params: { assignee_id: user2.id, scope: 'all' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues authored by the given author id and assigned to the given assignee id' do - issue2 = create(:issue, author: user2, assignees: [user2], project: project) - - get api('/issues', user), params: { author_id: user2.id, assignee_id: user2.id, scope: 'all' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues with no assignee' do - issue2 = create(:issue, author: user2, project: project) - - get api('/issues', user), params: { assignee_id: 0, scope: 'all' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues with no assignee' do - issue2 = create(:issue, author: user2, project: project) - - get api('/issues', user), params: { assignee_id: 'None', scope: 'all' } - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues with any assignee' do - # This issue without assignee should not be returned - create(:issue, author: user2, project: project) - - get api('/issues', user), params: { assignee_id: 'Any', scope: 'all' } - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - it 'returns only confidential issues' do - get api('/issues', user), params: { confidential: true, scope: 'all' } - - expect_paginated_array_response(confidential_issue.id) - end - - it 'returns only public issues' do - get api('/issues', user), params: { confidential: false } - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns issues reacted by the authenticated user' do - issue2 = create(:issue, project: project, author: user, assignees: [user]) - create(:award_emoji, awardable: issue2, user: user2, name: 'star') - create(:award_emoji, awardable: issue, user: user2, name: 'thumbsup') - - get api('/issues', user2), params: { my_reaction_emoji: 'Any', scope: 'all' } - - expect_paginated_array_response([issue2.id, issue.id]) - end - - it 'returns issues not reacted by the authenticated user' do - issue2 = create(:issue, project: project, author: user, assignees: [user]) - create(:award_emoji, awardable: issue2, user: user2, name: 'star') - - get api('/issues', user2), params: { my_reaction_emoji: 'None', scope: 'all' } - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns issues matching given search string for title' do - get api("/issues", user), params: { search: issue.title } - - expect_paginated_array_response(issue.id) - end - - it 'returns issues matching given search string for title and scoped in title' do - get api("/issues", user), params: { search: issue.title, in: 'title' } - - expect_paginated_array_response(issue.id) - end - - it 'returns an empty array if no issue matches given search string for title and scoped in description' do - get api("/issues", user), params: { search: issue.title, in: 'description' } - - expect_paginated_array_response([]) - end - - it 'returns issues matching given search string for description' do - get api("/issues", user), params: { search: issue.description } - - expect_paginated_array_response(issue.id) - end - - context 'filtering before a specific date' do - let!(:issue2) { create(:issue, project: project, author: user, created_at: Date.new(2000, 1, 1), updated_at: Date.new(2000, 1, 1)) } - - it 'returns issues created before a specific date' do - get api('/issues?created_before=2000-01-02T00:00:00.060Z', user) - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues updated before a specific date' do - get api('/issues?updated_before=2000-01-02T00:00:00.060Z', user) - - expect_paginated_array_response(issue2.id) - end - end - - context 'filtering after a specific date' do - let!(:issue2) { create(:issue, project: project, author: user, created_at: 1.week.from_now, updated_at: 1.week.from_now) } - - it 'returns issues created after a specific date' do - get api("/issues?created_after=#{issue2.created_at}", user) - - expect_paginated_array_response(issue2.id) - end - - it 'returns issues updated after a specific date' do - get api("/issues?updated_after=#{issue2.updated_at}", user) - - expect_paginated_array_response(issue2.id) - end - end - - it 'returns an array of labeled issues' do - get api('/issues', user), params: { labels: label.title } - - expect_paginated_array_response(issue.id) - expect(json_response.first['labels']).to eq([label.title]) - end - - it 'returns an array of labeled issues with labels param as array' do - get api('/issues', user), params: { labels: [label.title] } - - expect_paginated_array_response(issue.id) - expect(json_response.first['labels']).to eq([label.title]) - end - - it 'returns an array of labeled issues when all labels matches' do - label_b = create(:label, title: 'foo', project: project) - label_c = create(:label, title: 'bar', project: project) - - create(:label_link, label: label_b, target: issue) - create(:label_link, label: label_c, target: issue) - - get api('/issues', user), params: { labels: "#{label.title},#{label_b.title},#{label_c.title}" } - - expect_paginated_array_response(issue.id) - expect(json_response.first['labels']).to eq([label_c.title, label_b.title, label.title]) - end - - it 'returns an array of labeled issues when all labels matches with labels param as array' do - label_b = create(:label, title: 'foo', project: project) - label_c = create(:label, title: 'bar', project: project) - - create(:label_link, label: label_b, target: issue) - create(:label_link, label: label_c, target: issue) - - get api('/issues', user), params: { labels: [label.title, label_b.title, label_c.title] } - - expect_paginated_array_response(issue.id) - expect(json_response.first['labels']).to eq([label_c.title, label_b.title, label.title]) - end - - it 'returns an empty array if no issue matches labels' do - get api('/issues', user), params: { labels: 'foo,bar' } - - expect_paginated_array_response([]) - end - - it 'returns an empty array if no issue matches labels with labels param as array' do - get api('/issues', user), params: { labels: %w(foo bar) } - - expect_paginated_array_response([]) - end - - it 'returns an array of labeled issues matching given state' do - get api('/issues', user), params: { labels: label.title, state: :opened } - - expect_paginated_array_response(issue.id) - expect(json_response.first['labels']).to eq([label.title]) - expect(json_response.first['state']).to eq('opened') - end - - it 'returns an array of labeled issues matching given state with labels param as array' do - get api('/issues', user), params: { labels: [label.title], state: :opened } - - expect_paginated_array_response(issue.id) - expect(json_response.first['labels']).to eq([label.title]) - expect(json_response.first['state']).to eq('opened') - end - - it 'returns an empty array if no issue matches labels and state filters' do - get api('/issues', user), params: { labels: label.title, state: :closed } - - expect_paginated_array_response([]) - end - - it 'returns an array of issues with any label' do - get api('/issues', user), params: { labels: IssuesFinder::FILTER_ANY } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of issues with any label with labels param as array' do - get api('/issues', user), params: { labels: [IssuesFinder::FILTER_ANY] } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of issues with no label' do - get api('/issues', user), params: { labels: IssuesFinder::FILTER_NONE } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an array of issues with no label with labels param as array' do - get api('/issues', user), params: { labels: [IssuesFinder::FILTER_NONE] } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an array of issues with no label when using the legacy No+Label filter' do - get api('/issues', user), params: { labels: 'No Label' } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an array of issues with no label when using the legacy No+Label filter with labels param as array' do - get api('/issues', user), params: { labels: ['No Label'] } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an empty array if no issue matches milestone' do - get api("/issues?milestone=#{empty_milestone.title}", user) - - expect_paginated_array_response([]) - end - - it 'returns an empty array if milestone does not exist' do - get api("/issues?milestone=foo", user) - - expect_paginated_array_response([]) - end - - it 'returns an array of issues in given milestone' do - get api("/issues?milestone=#{milestone.title}", user) - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns an array of issues matching state in milestone' do - get api("/issues?milestone=#{milestone.title}"\ - '&state=closed', user) - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an array of issues with no milestone' do - get api("/issues?milestone=#{no_milestone_title}", author) - - expect_paginated_array_response(confidential_issue.id) - end - - it 'returns an array of issues found by iids' do - get api('/issues', user), params: { iids: [closed_issue.iid] } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an empty array if iid does not exist' do - get api("/issues", user), params: { iids: [0] } - - expect_paginated_array_response([]) - end - - context 'without sort params' do - it 'sorts by created_at descending by default' do - get api('/issues', user) - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - context 'with 2 issues with same created_at' do - let!(:closed_issue2) do - create :closed_issue, - author: user, - assignees: [user], - project: project, - milestone: milestone, - created_at: closed_issue.created_at, - updated_at: 1.hour.ago, - title: issue_title, - description: issue_description - end - - it 'page breaks first page correctly' do - get api('/issues?per_page=2', user) - - expect_paginated_array_response([issue.id, closed_issue2.id]) - end - - it 'page breaks second page correctly' do - get api('/issues?per_page=2&page=2', user) - - expect_paginated_array_response([closed_issue.id]) - end - end - end - - it 'sorts ascending when requested' do - get api('/issues?sort=asc', user) - - expect_paginated_array_response([closed_issue.id, issue.id]) - end - - it 'sorts by updated_at descending when requested' do - get api('/issues?order_by=updated_at', user) - - issue.touch(:updated_at) - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'sorts by updated_at ascending when requested' do - get api('/issues?order_by=updated_at&sort=asc', user) - - issue.touch(:updated_at) - - expect_paginated_array_response([closed_issue.id, issue.id]) - end - - it 'matches V4 response schema' do - get api('/issues', user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to match_response_schema('public_api/v4/issues') - end - - it 'returns a related merge request count of 0 if there are no related merge requests' do - get api('/issues', user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to match_response_schema('public_api/v4/issues') - expect(json_response.first).to include('merge_requests_count' => 0) - end - - it 'returns a related merge request count > 0 if there are related merge requests' do - create(:merge_requests_closing_issues, issue: issue) - - get api('/issues', user) - - expect(response).to have_gitlab_http_status(200) - expect(response).to match_response_schema('public_api/v4/issues') - expect(json_response.first).to include('merge_requests_count' => 1) - end - end - end - - describe "GET /groups/:id/issues" do - let!(:group) { create(:group) } - let!(:group_project) { create(:project, :public, creator_id: user.id, namespace: group) } - let!(:group_closed_issue) do - create :closed_issue, - author: user, - assignees: [user], - project: group_project, - state: :closed, - milestone: group_milestone, - updated_at: 3.hours.ago, - created_at: 1.day.ago - end - let!(:group_confidential_issue) do - create :issue, - :confidential, - project: group_project, - author: author, - assignees: [assignee], - updated_at: 2.hours.ago, - created_at: 2.days.ago - end - let!(:group_issue) do - create :issue, - author: user, - assignees: [user], - project: group_project, - milestone: group_milestone, - updated_at: 1.hour.ago, - title: issue_title, - description: issue_description, - created_at: 5.days.ago - end - let!(:group_label) do - create(:label, title: 'group_lbl', color: '#FFAABB', project: group_project) - end - let!(:group_label_link) { create(:label_link, label: group_label, target: group_issue) } - let!(:group_milestone) { create(:milestone, title: '3.0.0', project: group_project) } - let!(:group_empty_milestone) do - create(:milestone, title: '4.0.0', project: group_project) - end - let!(:group_note) { create(:note_on_issue, author: user, project: group_project, noteable: group_issue) } - - let(:base_url) { "/groups/#{group.id}/issues" } - - context 'when group has subgroups', :nested_groups do - let(:subgroup_1) { create(:group, parent: group) } - let(:subgroup_2) { create(:group, parent: subgroup_1) } - - let(:subgroup_1_project) { create(:project, namespace: subgroup_1) } - let(:subgroup_2_project) { create(:project, namespace: subgroup_2) } - - let!(:issue_1) { create(:issue, project: subgroup_1_project) } - let!(:issue_2) { create(:issue, project: subgroup_2_project) } - - before do - group.add_developer(user) - end - - it 'also returns subgroups projects issues' do - get api(base_url, user) - - expect_paginated_array_response([issue_2.id, issue_1.id, group_closed_issue.id, group_confidential_issue.id, group_issue.id]) - end - end - - context 'when user is unauthenticated' do - it 'lists all issues in public projects' do - get api(base_url) - - expect_paginated_array_response([group_closed_issue.id, group_issue.id]) - end - end - - context 'when user is a group member' do - before do - group_project.add_reporter(user) - end - - it 'returns all group issues (including opened and closed)' do - get api(base_url, admin) - - expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) - end - - it 'returns group issues without confidential issues for non project members' do - get api(base_url, non_member), params: { state: :opened } - - expect_paginated_array_response(group_issue.id) - end - - it 'returns group confidential issues for author' do - get api(base_url, author), params: { state: :opened } - - expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) - end - - it 'returns group confidential issues for assignee' do - get api(base_url, assignee), params: { state: :opened } - - expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) - end - - it 'returns group issues with confidential issues for project members' do - get api(base_url, user), params: { state: :opened } - - expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) - end - - it 'returns group confidential issues for admin' do - get api(base_url, admin), params: { state: :opened } - - expect_paginated_array_response([group_confidential_issue.id, group_issue.id]) - end - - it 'returns only confidential issues' do - get api(base_url, user), params: { confidential: true } - - expect_paginated_array_response(group_confidential_issue.id) - end - - it 'returns only public issues' do - get api(base_url, user), params: { confidential: false } - - expect_paginated_array_response([group_closed_issue.id, group_issue.id]) - end - - it 'returns an array of labeled group issues' do - get api(base_url, user), params: { labels: group_label.title } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['labels']).to eq([group_label.title]) - end - - it 'returns an array of labeled group issues with labels param as array' do - get api(base_url, user), params: { labels: [group_label.title] } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['labels']).to eq([group_label.title]) - end - - it 'returns an array of labeled group issues where all labels match' do - get api(base_url, user), params: { labels: "#{group_label.title},foo,bar" } - - expect_paginated_array_response([]) - end - - it 'returns an array of labeled group issues where all labels match with labels param as array' do - get api(base_url, user), params: { labels: [group_label.title, 'foo', 'bar'] } - - expect_paginated_array_response([]) - end - - it 'returns issues matching given search string for title' do - get api(base_url, user), params: { search: group_issue.title } - - expect_paginated_array_response(group_issue.id) - end - - it 'returns issues matching given search string for description' do - get api(base_url, user), params: { search: group_issue.description } - - expect_paginated_array_response(group_issue.id) - end - - it 'returns an array of labeled issues when all labels matches' do - label_b = create(:label, title: 'foo', project: group_project) - label_c = create(:label, title: 'bar', project: group_project) - - create(:label_link, label: label_b, target: group_issue) - create(:label_link, label: label_c, target: group_issue) - - get api(base_url, user), params: { labels: "#{group_label.title},#{label_b.title},#{label_c.title}" } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['labels']).to eq([label_c.title, label_b.title, group_label.title]) - end - - it 'returns an array of labeled issues when all labels matches with labels param as array' do - label_b = create(:label, title: 'foo', project: group_project) - label_c = create(:label, title: 'bar', project: group_project) - - create(:label_link, label: label_b, target: group_issue) - create(:label_link, label: label_c, target: group_issue) - - get api(base_url, user), params: { labels: [group_label.title, label_b.title, label_c.title] } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['labels']).to eq([label_c.title, label_b.title, group_label.title]) - end - - it 'returns an array of issues found by iids' do - get api(base_url, user), params: { iids: [group_issue.iid] } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['id']).to eq(group_issue.id) - end - - it 'returns an empty array if iid does not exist' do - get api(base_url, user), params: { iids: [0] } - - expect_paginated_array_response([]) - end - - it 'returns an empty array if no group issue matches labels' do - get api(base_url, user), params: { labels: 'foo,bar' } - - expect_paginated_array_response([]) - end - - it 'returns an array of group issues with any label' do - get api(base_url, user), params: { labels: IssuesFinder::FILTER_ANY } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['id']).to eq(group_issue.id) - end - - it 'returns an array of group issues with any label with labels param as array' do - get api(base_url, user), params: { labels: [IssuesFinder::FILTER_ANY] } - - expect_paginated_array_response(group_issue.id) - expect(json_response.first['id']).to eq(group_issue.id) - end - - it 'returns an array of group issues with no label' do - get api(base_url, user), params: { labels: IssuesFinder::FILTER_NONE } - - expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id]) - end - - it 'returns an array of group issues with no label with labels param as array' do - get api(base_url, user), params: { labels: [IssuesFinder::FILTER_NONE] } - - expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id]) - end - - it 'returns an empty array if no issue matches milestone' do - get api(base_url, user), params: { milestone: group_empty_milestone.title } - - expect_paginated_array_response([]) - end - - it 'returns an empty array if milestone does not exist' do - get api(base_url, user), params: { milestone: 'foo' } - - expect_paginated_array_response([]) - end - - it 'returns an array of issues in given milestone' do - get api(base_url, user), params: { state: :opened, milestone: group_milestone.title } - - expect_paginated_array_response(group_issue.id) - end - - it 'returns an array of issues matching state in milestone' do - get api(base_url, user), params: { milestone: group_milestone.title, state: :closed } - - expect_paginated_array_response(group_closed_issue.id) - end - - it 'returns an array of issues with no milestone' do - get api(base_url, user), params: { milestone: no_milestone_title } - - expect(response).to have_gitlab_http_status(200) - - expect_paginated_array_response(group_confidential_issue.id) - end - - context 'without sort params' do - it 'sorts by created_at descending by default' do - get api(base_url, user) - - expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) - end - - context 'with 2 issues with same created_at' do - let!(:group_issue2) do - create :issue, - author: user, - assignees: [user], - project: group_project, - milestone: group_milestone, - updated_at: 1.hour.ago, - title: issue_title, - description: issue_description, - created_at: group_issue.created_at - end - - it 'page breaks first page correctly' do - get api("#{base_url}?per_page=3", user) - - expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue2.id]) - end - - it 'page breaks second page correctly' do - get api("#{base_url}?per_page=3&page=2", user) - - expect_paginated_array_response([group_issue.id]) - end - end - end - - it 'sorts ascending when requested' do - get api("#{base_url}?sort=asc", user) - - expect_paginated_array_response([group_issue.id, group_confidential_issue.id, group_closed_issue.id]) - end - - it 'sorts by updated_at descending when requested' do - get api("#{base_url}?order_by=updated_at", user) - - group_issue.touch(:updated_at) - - expect_paginated_array_response([group_issue.id, group_confidential_issue.id, group_closed_issue.id]) - end - - it 'sorts by updated_at ascending when requested' do - get api(base_url, user), params: { order_by: :updated_at, sort: :asc } - - expect_paginated_array_response([group_closed_issue.id, group_confidential_issue.id, group_issue.id]) - end - end - end - - describe "GET /projects/:id/issues" do - let(:base_url) { "/projects/#{project.id}" } - - context 'when unauthenticated' do - it 'returns public project issues' do - get api("/projects/#{project.id}/issues") - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - end - - it 'avoids N+1 queries' do - get api("/projects/#{project.id}/issues", user) - - control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do - get api("/projects/#{project.id}/issues", user) - end.count - - create_list(:issue, 3, project: project) - - expect do - get api("/projects/#{project.id}/issues", user) - end.not_to exceed_all_query_limit(control_count) - end - - it 'returns 404 when project does not exist' do - get api('/projects/1000/issues', non_member) - - expect(response).to have_gitlab_http_status(404) - end - - it "returns 404 on private projects for other users" do - private_project = create(:project, :private) - create(:issue, project: private_project) - - get api("/projects/#{private_project.id}/issues", non_member) - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns no issues when user has access to project but not issues' do - restricted_project = create(:project, :public, :issues_private) - create(:issue, project: restricted_project) - - get api("/projects/#{restricted_project.id}/issues", non_member) - - expect_paginated_array_response([]) - end - - it 'returns project issues without confidential issues for non project members' do - get api("#{base_url}/issues", non_member) - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns project issues without confidential issues for project members with guest role' do - get api("#{base_url}/issues", guest) - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns project confidential issues for author' do - get api("#{base_url}/issues", author) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - it 'returns only confidential issues' do - get api("#{base_url}/issues", author), params: { confidential: true } - - expect_paginated_array_response(confidential_issue.id) - end - - it 'returns only public issues' do - get api("#{base_url}/issues", author), params: { confidential: false } - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns project confidential issues for assignee' do - get api("#{base_url}/issues", assignee) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - it 'returns project issues with confidential issues for project members' do - get api("#{base_url}/issues", user) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - it 'returns project confidential issues for admin' do - get api("#{base_url}/issues", admin) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - it 'returns an array of labeled project issues' do - get api("#{base_url}/issues", user), params: { labels: label.title } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of labeled project issues with labels param as array' do - get api("#{base_url}/issues", user), params: { labels: [label.title] } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of labeled issues when all labels matches' do - label_b = create(:label, title: 'foo', project: project) - label_c = create(:label, title: 'bar', project: project) - - create(:label_link, label: label_b, target: issue) - create(:label_link, label: label_c, target: issue) - - get api("#{base_url}/issues", user), params: { labels: "#{label.title},#{label_b.title},#{label_c.title}" } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of labeled issues when all labels matches with labels param as array' do - label_b = create(:label, title: 'foo', project: project) - label_c = create(:label, title: 'bar', project: project) - - create(:label_link, label: label_b, target: issue) - create(:label_link, label: label_c, target: issue) - - get api("#{base_url}/issues", user), params: { labels: [label.title, label_b.title, label_c.title] } - - expect_paginated_array_response(issue.id) - end - - it 'returns issues matching given search string for title' do - get api("#{base_url}/issues?search=#{issue.title}", user) - - expect_paginated_array_response(issue.id) - end - - it 'returns issues matching given search string for description' do - get api("#{base_url}/issues?search=#{issue.description}", user) - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of issues found by iids' do - get api("#{base_url}/issues", user), params: { iids: [issue.iid] } - - expect_paginated_array_response(issue.id) - end - - it 'returns an empty array if iid does not exist' do - get api("#{base_url}/issues", user), params: { iids: [0] } - - expect_paginated_array_response([]) - end - - it 'returns an empty array if not all labels matches' do - get api("#{base_url}/issues?labels=#{label.title},foo", user) - - expect_paginated_array_response([]) - end - - it 'returns an array of project issues with any label' do - get api("#{base_url}/issues", user), params: { labels: IssuesFinder::FILTER_ANY } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of project issues with any label with labels param as array' do - get api("#{base_url}/issues", user), params: { labels: [IssuesFinder::FILTER_ANY] } - - expect_paginated_array_response(issue.id) - end - - it 'returns an array of project issues with no label' do - get api("#{base_url}/issues", user), params: { labels: IssuesFinder::FILTER_NONE } - - expect_paginated_array_response([confidential_issue.id, closed_issue.id]) - end - - it 'returns an array of project issues with no label with labels param as array' do - get api("#{base_url}/issues", user), params: { labels: [IssuesFinder::FILTER_NONE] } - - expect_paginated_array_response([confidential_issue.id, closed_issue.id]) - end - - it 'returns an empty array if no project issue matches labels' do - get api("#{base_url}/issues", user), params: { labels: 'foo,bar' } - - expect_paginated_array_response([]) - end - - it 'returns an empty array if no issue matches milestone' do - get api("#{base_url}/issues", user), params: { milestone: empty_milestone.title } - - expect_paginated_array_response([]) - end - - it 'returns an empty array if milestone does not exist' do - get api("#{base_url}/issues", user), params: { milestone: :foo } - - expect_paginated_array_response([]) - end - - it 'returns an array of issues in given milestone' do - get api("#{base_url}/issues", user), params: { milestone: milestone.title } - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - it 'returns an array of issues matching state in milestone' do - get api("#{base_url}/issues", user), params: { milestone: milestone.title, state: :closed } - - expect_paginated_array_response(closed_issue.id) - end - - it 'returns an array of issues with no milestone' do - get api("#{base_url}/issues", user), params: { milestone: no_milestone_title } - - expect_paginated_array_response(confidential_issue.id) - end - - it 'returns an array of issues with any milestone' do - get api("#{base_url}/issues", user), params: { milestone: any_milestone_title } - - expect_paginated_array_response([issue.id, closed_issue.id]) - end - - context 'without sort params' do - it 'sorts by created_at descending by default' do - get api("#{base_url}/issues", user) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - context 'with 2 issues with same created_at' do - let!(:closed_issue2) do - create :closed_issue, - author: user, - assignees: [user], - project: project, - milestone: milestone, - created_at: closed_issue.created_at, - updated_at: 1.hour.ago, - title: issue_title, - description: issue_description - end - - it 'page breaks first page correctly' do - get api("#{base_url}/issues?per_page=3", user) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue2.id]) - end - - it 'page breaks second page correctly' do - get api("#{base_url}/issues?per_page=3&page=2", user) - - expect_paginated_array_response([closed_issue.id]) - end - end - end - - it 'sorts ascending when requested' do - get api("#{base_url}/issues", user), params: { sort: :asc } - - expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id]) - end - - it 'sorts by updated_at descending when requested' do - get api("#{base_url}/issues", user), params: { order_by: :updated_at } - - issue.touch(:updated_at) - - expect_paginated_array_response([issue.id, confidential_issue.id, closed_issue.id]) - end - - it 'sorts by updated_at ascending when requested' do - get api("#{base_url}/issues", user), params: { order_by: :updated_at, sort: :asc } - - expect_paginated_array_response([closed_issue.id, confidential_issue.id, issue.id]) - end - end - - describe "GET /projects/:id/issues/:issue_iid" do - context 'when unauthenticated' do - it 'returns public issues' do - get api("/projects/#{project.id}/issues/#{issue.iid}") - - expect(response).to have_gitlab_http_status(200) - end - end - - it 'exposes known attributes' do - get api("/projects/#{project.id}/issues/#{issue.iid}", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['id']).to eq(issue.id) - expect(json_response['iid']).to eq(issue.iid) - expect(json_response['project_id']).to eq(issue.project.id) - expect(json_response['title']).to eq(issue.title) - expect(json_response['description']).to eq(issue.description) - expect(json_response['state']).to eq(issue.state) - expect(json_response['closed_at']).to be_falsy - expect(json_response['created_at']).to be_present - expect(json_response['updated_at']).to be_present - expect(json_response['labels']).to eq(issue.label_names) - expect(json_response['milestone']).to be_a Hash - expect(json_response['assignees']).to be_a Array - expect(json_response['assignee']).to be_a Hash - expect(json_response['author']).to be_a Hash - expect(json_response['confidential']).to be_falsy - end - - it "exposes the 'closed_at' attribute" do - get api("/projects/#{project.id}/issues/#{closed_issue.iid}", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['closed_at']).to be_present - end - - context 'links exposure' do - it 'exposes related resources full URIs' do - get api("/projects/#{project.id}/issues/#{issue.iid}", user) - - links = json_response['_links'] - - expect(links['self']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}") - expect(links['notes']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}/notes") - expect(links['award_emoji']).to end_with("/api/v4/projects/#{project.id}/issues/#{issue.iid}/award_emoji") - expect(links['project']).to end_with("/api/v4/projects/#{project.id}") - end - end - - it "returns a project issue by internal id" do - get api("/projects/#{project.id}/issues/#{issue.iid}", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq(issue.title) - expect(json_response['iid']).to eq(issue.iid) - end - - it "returns 404 if issue id not found" do - get api("/projects/#{project.id}/issues/54321", user) - expect(response).to have_gitlab_http_status(404) - end - - it "returns 404 if the issue ID is used" do - get api("/projects/#{project.id}/issues/#{issue.id}", user) - - expect(response).to have_gitlab_http_status(404) - end - - context 'confidential issues' do - it "returns 404 for non project members" do - get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member) - - expect(response).to have_gitlab_http_status(404) - end - - it "returns 404 for project members with guest role" do - get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest) - - expect(response).to have_gitlab_http_status(404) - end - - it "returns confidential issue for project members" do - get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq(confidential_issue.title) - expect(json_response['iid']).to eq(confidential_issue.iid) - end - - it "returns confidential issue for author" do - get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq(confidential_issue.title) - expect(json_response['iid']).to eq(confidential_issue.iid) - end - - it "returns confidential issue for assignee" do - get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", assignee) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq(confidential_issue.title) - expect(json_response['iid']).to eq(confidential_issue.iid) - end - - it "returns confidential issue for admin" do - get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq(confidential_issue.title) - expect(json_response['iid']).to eq(confidential_issue.iid) - end - end - end - - describe "POST /projects/:id/issues" do - context 'support for deprecated assignee_id' do - it 'creates a new project issue' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', assignee_id: user2.id } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['assignee']['name']).to eq(user2.name) - expect(json_response['assignees'].first['name']).to eq(user2.name) - end - - it 'creates a new project issue when assignee_id is empty' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', assignee_id: '' } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['assignee']).to be_nil - end - end - - context 'single assignee restrictions' do - it 'creates a new project issue with no more than one assignee' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', assignee_ids: [user2.id, guest.id] } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['assignees'].count).to eq(1) - end - end - - context 'user does not have permissions to create issue' do - let(:not_member) { create(:user) } - - before do - project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE) - end - - it 'renders 403' do - post api("/projects/#{project.id}/issues", not_member), params: { title: 'new issue' } - - expect(response).to have_gitlab_http_status(403) - end - end - - context 'an internal ID is provided' do - context 'by an admin' do - it 'sets the internal ID on the new issue' do - post api("/projects/#{project.id}/issues", admin), - params: { title: 'new issue', iid: 9001 } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['iid']).to eq 9001 - end - end - - context 'by an owner' do - it 'sets the internal ID on the new issue' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', iid: 9001 } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['iid']).to eq 9001 - end - end - - context 'by a group owner' do - let(:group) { create(:group) } - let(:group_project) { create(:project, :public, namespace: group) } - - it 'sets the internal ID on the new issue' do - group.add_owner(user2) - post api("/projects/#{group_project.id}/issues", user2), - params: { title: 'new issue', iid: 9001 } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['iid']).to eq 9001 - end - end - - context 'by another user' do - it 'ignores the given internal ID' do - post api("/projects/#{project.id}/issues", user2), - params: { title: 'new issue', iid: 9001 } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['iid']).not_to eq 9001 - end - end - end - - it 'creates a new project issue' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', labels: 'label, label2', weight: 3, assignee_ids: [user2.id] } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['description']).to be_nil - expect(json_response['labels']).to eq(%w(label label2)) - expect(json_response['confidential']).to be_falsy - expect(json_response['assignee']['name']).to eq(user2.name) - expect(json_response['assignees'].first['name']).to eq(user2.name) - end - - it 'creates a new project issue with labels param as array' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', labels: %w(label label2), weight: 3, assignee_ids: [user2.id] } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['description']).to be_nil - expect(json_response['labels']).to eq(%w(label label2)) - expect(json_response['confidential']).to be_falsy - expect(json_response['assignee']['name']).to eq(user2.name) - expect(json_response['assignees'].first['name']).to eq(user2.name) - end - - it 'creates a new confidential project issue' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', confidential: true } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['confidential']).to be_truthy - end - - it 'creates a new confidential project issue with a different param' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', confidential: 'y' } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['confidential']).to be_truthy - end - - it 'creates a public issue when confidential param is false' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', confidential: false } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['confidential']).to be_falsy - end - - it 'creates a public issue when confidential param is invalid' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', confidential: 'foo' } - - expect(response).to have_gitlab_http_status(400) - expect(json_response['error']).to eq('confidential is invalid') - end - - it "returns a 400 bad request if title not given" do - post api("/projects/#{project.id}/issues", user), params: { labels: 'label, label2' } - expect(response).to have_gitlab_http_status(400) - end - - it 'allows special label names' do - post api("/projects/#{project.id}/issues", user), - params: { - title: 'new issue', - labels: 'label, label?, label&foo, ?, &' - } - expect(response.status).to eq(201) - expect(json_response['labels']).to include 'label' - expect(json_response['labels']).to include 'label?' - expect(json_response['labels']).to include 'label&foo' - expect(json_response['labels']).to include '?' - expect(json_response['labels']).to include '&' - end - - it 'allows special label names with labels param as array' do - post api("/projects/#{project.id}/issues", user), - params: { - title: 'new issue', - labels: ['label', 'label?', 'label&foo, ?, &'] - } - expect(response.status).to eq(201) - expect(json_response['labels']).to include 'label' - expect(json_response['labels']).to include 'label?' - expect(json_response['labels']).to include 'label&foo' - expect(json_response['labels']).to include '?' - expect(json_response['labels']).to include '&' - end - - it 'returns 400 if title is too long' do - post api("/projects/#{project.id}/issues", user), - params: { title: 'g' * 256 } - expect(response).to have_gitlab_http_status(400) - expect(json_response['message']['title']).to eq([ - 'is too long (maximum is 255 characters)' - ]) - end - - context 'resolving discussions' do - let(:discussion) { create(:diff_note_on_merge_request).to_discussion } - let(:merge_request) { discussion.noteable } - let(:project) { merge_request.source_project } - - before do - project.add_maintainer(user) - end - - context 'resolving all discussions in a merge request' do - before do - post api("/projects/#{project.id}/issues", user), - params: { - title: 'New Issue', - merge_request_to_resolve_discussions_of: merge_request.iid - } - end - - it_behaves_like 'creating an issue resolving discussions through the API' - end - - context 'resolving a single discussion' do - before do - post api("/projects/#{project.id}/issues", user), - params: { - title: 'New Issue', - merge_request_to_resolve_discussions_of: merge_request.iid, - discussion_to_resolve: discussion.id - } - end - - it_behaves_like 'creating an issue resolving discussions through the API' - end - end - - context 'with due date' do - it 'creates a new project issue' do - due_date = 2.weeks.from_now.strftime('%Y-%m-%d') - - post api("/projects/#{project.id}/issues", user), - params: { title: 'new issue', due_date: due_date } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['title']).to eq('new issue') - expect(json_response['description']).to be_nil - expect(json_response['due_date']).to eq(due_date) - end - end - - context 'setting created_at' do - let(:creation_time) { 2.weeks.ago } - let(:params) { { title: 'new issue', labels: 'label, label2', created_at: creation_time } } - - context 'by an admin' do - before do - post api("/projects/#{project.id}/issues", admin), params: params - end - - it 'sets the creation time on the new issue' do - expect(response).to have_gitlab_http_status(201) - expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) - end - - it 'sets the system notes timestamp based on creation time' do - issue = Issue.find(json_response['id']) - - expect(issue.resource_label_events.last.created_at).to be_like_time(creation_time) - end - end - - context 'by a project owner' do - it 'sets the creation time on the new issue' do - post api("/projects/#{project.id}/issues", user), params: params - - expect(response).to have_gitlab_http_status(201) - expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) - end - end - - context 'by a group owner' do - it 'sets the creation time on the new issue' do - group = create(:group) - group_project = create(:project, :public, namespace: group) - group.add_owner(user2) - post api("/projects/#{group_project.id}/issues", user2), params: params - - expect(response).to have_gitlab_http_status(201) - expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) - end - end - - context 'by another user' do - it 'ignores the given creation time' do - post api("/projects/#{project.id}/issues", user2), params: params - - expect(response).to have_gitlab_http_status(201) - expect(Time.parse(json_response['created_at'])).not_to be_like_time(creation_time) - end - end - end - - context 'the user can only read the issue' do - it 'cannot create new labels' do - expect do - post api("/projects/#{project.id}/issues", non_member), params: { title: 'new issue', labels: 'label, label2' } - end.not_to change { project.labels.count } - end - - it 'cannot create new labels with labels param as array' do - expect do - post api("/projects/#{project.id}/issues", non_member), params: { title: 'new issue', labels: %w(label label2) } - end.not_to change { project.labels.count } - end - end - end - - describe 'POST /projects/:id/issues with spam filtering' do - before do - allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) - allow_any_instance_of(AkismetService).to receive_messages(spam?: true) - end - - let(:params) do - { - title: 'new issue', - description: 'content here', - labels: 'label, label2' - } - end - - it "does not create a new project issue" do - expect { post api("/projects/#{project.id}/issues", user), params: params }.not_to change(Issue, :count) - expect(response).to have_gitlab_http_status(400) - expect(json_response['message']).to eq({ "error" => "Spam detected" }) - - spam_logs = SpamLog.all - expect(spam_logs.count).to eq(1) - expect(spam_logs[0].title).to eq('new issue') - expect(spam_logs[0].description).to eq('content here') - expect(spam_logs[0].user).to eq(user) - expect(spam_logs[0].noteable_type).to eq('Issue') - end - end - - describe "PUT /projects/:id/issues/:issue_iid to update only title" do - it "updates a project issue" do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(200) - - expect(json_response['title']).to eq('updated title') - end - - it "returns 404 error if issue iid not found" do - put api("/projects/#{project.id}/issues/44444", user), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(404) - end - - it "returns 404 error if issue id is used instead of the iid" do - put api("/projects/#{project.id}/issues/#{issue.id}", user), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(404) - end - - it 'allows special label names' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { - title: 'updated title', - labels: 'label, label?, label&foo, ?, &' - } - - expect(response.status).to eq(200) - expect(json_response['labels']).to include 'label' - expect(json_response['labels']).to include 'label?' - expect(json_response['labels']).to include 'label&foo' - expect(json_response['labels']).to include '?' - expect(json_response['labels']).to include '&' - end - - it 'allows special label names with labels param as array' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { - title: 'updated title', - labels: ['label', 'label?', 'label&foo, ?, &'] - } - - expect(response.status).to eq(200) - expect(json_response['labels']).to include 'label' - expect(json_response['labels']).to include 'label?' - expect(json_response['labels']).to include 'label&foo' - expect(json_response['labels']).to include '?' - expect(json_response['labels']).to include '&' - end - - context 'confidential issues' do - it "returns 403 for non project members" do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(403) - end - - it "returns 403 for project members with guest role" do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(403) - end - - it "updates a confidential issue for project members" do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq('updated title') - end - - it "updates a confidential issue for author" do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq('updated title') - end - - it "updates a confidential issue for admin" do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(200) - expect(json_response['title']).to eq('updated title') - end - - it 'sets an issue to confidential' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { confidential: true } - - expect(response).to have_gitlab_http_status(200) - expect(json_response['confidential']).to be_truthy - end - - it 'makes a confidential issue public' do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), - params: { confidential: false } - - expect(response).to have_gitlab_http_status(200) - expect(json_response['confidential']).to be_falsy - end - - it 'does not update a confidential issue with wrong confidential flag' do - put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), - params: { confidential: 'foo' } - - expect(response).to have_gitlab_http_status(400) - expect(json_response['error']).to eq('confidential is invalid') - end - end - end - - describe 'PUT /projects/:id/issues/:issue_iid with spam filtering' do - let(:params) do - { - title: 'updated title', - description: 'content here', - labels: 'label, label2' - } - end - - it "does not create a new project issue" do - allow_any_instance_of(SpamService).to receive_messages(check_for_spam?: true) - allow_any_instance_of(AkismetService).to receive_messages(spam?: true) - - put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: params - - expect(response).to have_gitlab_http_status(400) - expect(json_response['message']).to eq({ "error" => "Spam detected" }) - - spam_logs = SpamLog.all - expect(spam_logs.count).to eq(1) - expect(spam_logs[0].title).to eq('updated title') - expect(spam_logs[0].description).to eq('content here') - expect(spam_logs[0].user).to eq(user) - expect(spam_logs[0].noteable_type).to eq('Issue') - end - end - - describe 'PUT /projects/:id/issues/:issue_iid to update assignee' do - context 'support for deprecated assignee_id' do - it 'removes assignee' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { assignee_id: 0 } - - expect(response).to have_gitlab_http_status(200) - - expect(json_response['assignee']).to be_nil - end - - it 'updates an issue with new assignee' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { assignee_id: user2.id } - - expect(response).to have_gitlab_http_status(200) - - expect(json_response['assignee']['name']).to eq(user2.name) - end - end - - it 'removes assignee' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { assignee_ids: [0] } - - expect(response).to have_gitlab_http_status(200) - - expect(json_response['assignees']).to be_empty - end - - it 'updates an issue with new assignee' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { assignee_ids: [user2.id] } - - expect(response).to have_gitlab_http_status(200) - - expect(json_response['assignees'].first['name']).to eq(user2.name) - end - - context 'single assignee restrictions' do - it 'updates an issue with several assignees but only one has been applied' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { assignee_ids: [user2.id, guest.id] } - - expect(response).to have_gitlab_http_status(200) - - expect(json_response['assignees'].size).to eq(1) - end - end - end - - describe 'PUT /projects/:id/issues/:issue_iid to update labels' do - let!(:label) { create(:label, title: 'dummy', project: project) } - let!(:label_link) { create(:label_link, label: label, target: issue) } - - it 'does not update labels if not present' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { title: 'updated title' } - expect(response).to have_gitlab_http_status(200) - expect(json_response['labels']).to eq([label.title]) - end - - it 'removes all labels and touches the record' do - Timecop.travel(1.minute.from_now) do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { labels: '' } - end - - expect(response).to have_gitlab_http_status(200) - expect(json_response['labels']).to eq([]) - expect(json_response['updated_at']).to be > Time.now - end - - it 'removes all labels and touches the record with labels param as array' do - Timecop.travel(1.minute.from_now) do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { labels: [''] } - end - - expect(response).to have_gitlab_http_status(200) - expect(json_response['labels']).to eq([]) - expect(json_response['updated_at']).to be > Time.now - end - - it 'updates labels and touches the record' do - Timecop.travel(1.minute.from_now) do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { labels: 'foo,bar' } - end - expect(response).to have_gitlab_http_status(200) - expect(json_response['labels']).to include 'foo' - expect(json_response['labels']).to include 'bar' - expect(json_response['updated_at']).to be > Time.now - end - - it 'updates labels and touches the record with labels param as array' do - Timecop.travel(1.minute.from_now) do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { labels: %w(foo bar) } - end - expect(response).to have_gitlab_http_status(200) - expect(json_response['labels']).to include 'foo' - expect(json_response['labels']).to include 'bar' - expect(json_response['updated_at']).to be > Time.now - end - - it 'allows special label names' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' } - expect(response.status).to eq(200) - expect(json_response['labels']).to include 'label:foo' - expect(json_response['labels']).to include 'label-bar' - expect(json_response['labels']).to include 'label_bar' - expect(json_response['labels']).to include 'label/bar' - expect(json_response['labels']).to include 'label?bar' - expect(json_response['labels']).to include 'label&bar' - expect(json_response['labels']).to include '?' - expect(json_response['labels']).to include '&' - end - - it 'allows special label names with labels param as array' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { labels: ['label:foo', 'label-bar', 'label_bar', 'label/bar,label?bar,label&bar,?,&'] } - expect(response.status).to eq(200) - expect(json_response['labels']).to include 'label:foo' - expect(json_response['labels']).to include 'label-bar' - expect(json_response['labels']).to include 'label_bar' - expect(json_response['labels']).to include 'label/bar' - expect(json_response['labels']).to include 'label?bar' - expect(json_response['labels']).to include 'label&bar' - expect(json_response['labels']).to include '?' - expect(json_response['labels']).to include '&' - end - - it 'returns 400 if title is too long' do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { title: 'g' * 256 } - expect(response).to have_gitlab_http_status(400) - expect(json_response['message']['title']).to eq([ - 'is too long (maximum is 255 characters)' - ]) - end - end - - describe "PUT /projects/:id/issues/:issue_iid to update state and label" do - it "updates a project issue" do - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { labels: 'label2', state_event: "close" } - expect(response).to have_gitlab_http_status(200) - - expect(json_response['labels']).to include 'label2' - expect(json_response['state']).to eq "closed" - end - - it 'reopens a project isssue' do - put api("/projects/#{project.id}/issues/#{closed_issue.iid}", user), params: { state_event: 'reopen' } - - expect(response).to have_gitlab_http_status(200) - expect(json_response['state']).to eq 'opened' - end - - context 'when an admin or owner makes the request' do - it 'accepts the update date to be set' do - update_time = 2.weeks.ago - put api("/projects/#{project.id}/issues/#{issue.iid}", user), - params: { labels: 'label3', state_event: 'close', updated_at: update_time } - - expect(response).to have_gitlab_http_status(200) - expect(json_response['labels']).to include 'label3' - expect(Time.parse(json_response['updated_at'])).to be_like_time(update_time) - end - end - end - - describe 'PUT /projects/:id/issues/:issue_iid to update due date' do - it 'creates a new project issue' do - due_date = 2.weeks.from_now.strftime('%Y-%m-%d') - - put api("/projects/#{project.id}/issues/#{issue.iid}", user), params: { due_date: due_date } - - expect(response).to have_gitlab_http_status(200) - expect(json_response['due_date']).to eq(due_date) - end - end - - describe "DELETE /projects/:id/issues/:issue_iid" do - it "rejects a non member from deleting an issue" do - delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member) - expect(response).to have_gitlab_http_status(403) - end - - it "rejects a developer from deleting an issue" do - delete api("/projects/#{project.id}/issues/#{issue.iid}", author) - expect(response).to have_gitlab_http_status(403) - end - - context "when the user is project owner" do - let(:owner) { create(:user) } - let(:project) { create(:project, namespace: owner.namespace) } - - it "deletes the issue if an admin requests it" do - delete api("/projects/#{project.id}/issues/#{issue.iid}", owner) - - expect(response).to have_gitlab_http_status(204) - end - - it_behaves_like '412 response' do - let(:request) { api("/projects/#{project.id}/issues/#{issue.iid}", owner) } - end - end - - context 'when issue does not exist' do - it 'returns 404 when trying to move an issue' do - delete api("/projects/#{project.id}/issues/123", user) - - expect(response).to have_gitlab_http_status(404) - end - end - - it 'returns 404 when using the issue ID instead of IID' do - delete api("/projects/#{project.id}/issues/#{issue.id}", user) - - expect(response).to have_gitlab_http_status(404) - end - end - - describe '/projects/:id/issues/:issue_iid/move' do - let!(:target_project) { create(:project, creator_id: user.id, namespace: user.namespace ) } - let!(:target_project2) { create(:project, creator_id: non_member.id, namespace: non_member.namespace ) } - - it 'moves an issue' do - post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), - params: { to_project_id: target_project.id } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['project_id']).to eq(target_project.id) - end - - context 'when source and target projects are the same' do - it 'returns 400 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), - params: { to_project_id: project.id } - - expect(response).to have_gitlab_http_status(400) - expect(json_response['message']).to eq('Cannot move issue to project it originates from!') - end - end - - context 'when the user does not have the permission to move issues' do - it 'returns 400 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), - params: { to_project_id: target_project2.id } - - expect(response).to have_gitlab_http_status(400) - expect(json_response['message']).to eq('Cannot move issue due to insufficient permissions!') - end - end - - it 'moves the issue to another namespace if I am admin' do - post api("/projects/#{project.id}/issues/#{issue.iid}/move", admin), - params: { to_project_id: target_project2.id } - - expect(response).to have_gitlab_http_status(201) - expect(json_response['project_id']).to eq(target_project2.id) - end - - context 'when using the issue ID instead of iid' do - it 'returns 404 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), - params: { to_project_id: target_project.id } - - expect(response).to have_gitlab_http_status(404) - expect(json_response['message']).to eq('404 Issue Not Found') - end - end - - context 'when issue does not exist' do - it 'returns 404 when trying to move an issue' do - post api("/projects/#{project.id}/issues/123/move", user), - params: { to_project_id: target_project.id } - - expect(response).to have_gitlab_http_status(404) - expect(json_response['message']).to eq('404 Issue Not Found') - end - end - - context 'when source project does not exist' do - it 'returns 404 when trying to move an issue' do - post api("/projects/0/issues/#{issue.iid}/move", user), - params: { to_project_id: target_project.id } - - expect(response).to have_gitlab_http_status(404) - expect(json_response['message']).to eq('404 Project Not Found') - end - end - - context 'when target project does not exist' do - it 'returns 404 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), - params: { to_project_id: 0 } - - expect(response).to have_gitlab_http_status(404) - end - end - end - - describe 'POST :id/issues/:issue_iid/subscribe' do - it 'subscribes to an issue' do - post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user2) - - expect(response).to have_gitlab_http_status(201) - expect(json_response['subscribed']).to eq(true) - end - - it 'returns 304 if already subscribed' do - post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user) - - expect(response).to have_gitlab_http_status(304) - end - - it 'returns 404 if the issue is not found' do - post api("/projects/#{project.id}/issues/123/subscribe", user) - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns 404 if the issue ID is used instead of the iid' do - post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/subscribe", non_member) - - expect(response).to have_gitlab_http_status(404) - end - end - - describe 'POST :id/issues/:issue_id/unsubscribe' do - it 'unsubscribes from an issue' do - post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user) - - expect(response).to have_gitlab_http_status(201) - expect(json_response['subscribed']).to eq(false) - end - - it 'returns 304 if not subscribed' do - post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user2) - - expect(response).to have_gitlab_http_status(304) - end - - it 'returns 404 if the issue is not found' do - post api("/projects/#{project.id}/issues/123/unsubscribe", user) - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns 404 if using the issue ID instead of iid' do - post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) - - expect(response).to have_gitlab_http_status(404) - end - - it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/unsubscribe", non_member) - - expect(response).to have_gitlab_http_status(404) - end - end - - describe 'time tracking endpoints' do - let(:issuable) { issue } - - include_examples 'time tracking endpoints', 'issue' - end - - describe 'GET :id/issues/:issue_iid/closed_by' do - let(:merge_request) do - create(:merge_request, - :simple, - author: user, - source_project: project, - target_project: project, - description: "closes #{issue.to_reference}") - end - - before do - create(:merge_requests_closing_issues, issue: issue, merge_request: merge_request) - end - - context 'when unauthenticated' do - it 'return public project issues' do - get api("/projects/#{project.id}/issues/#{issue.iid}/closed_by") - - expect_paginated_array_response(merge_request.id) - end - end - - it 'returns merge requests that will close issue on merge' do - get api("/projects/#{project.id}/issues/#{issue.iid}/closed_by", user) - - expect_paginated_array_response(merge_request.id) - end - - context 'when no merge requests will close issue' do - it 'returns empty array' do - get api("/projects/#{project.id}/issues/#{closed_issue.iid}/closed_by", user) - - expect_paginated_array_response([]) - end - end - - it "returns 404 when issue doesn't exists" do - get api("/projects/#{project.id}/issues/0/closed_by", user) - - expect(response).to have_gitlab_http_status(404) - end - end - - describe 'GET :id/issues/:issue_iid/related_merge_requests' do - def get_related_merge_requests(project_id, issue_iid, user = nil) - get api("/projects/#{project_id}/issues/#{issue_iid}/related_merge_requests", user) - end - - def create_referencing_mr(user, project, issue) - attributes = { - author: user, - source_project: project, - target_project: project, - source_branch: "master", - target_branch: "test", - description: "See #{issue.to_reference}" - } - create(:merge_request, attributes).tap do |merge_request| - create(:note, :system, project: issue.project, noteable: issue, author: user, note: merge_request.to_reference(full: true)) - end - end - - let!(:related_mr) { create_referencing_mr(user, project, issue) } - - context 'when unauthenticated' do - it 'return list of referenced merge requests from issue' do - get_related_merge_requests(project.id, issue.iid) - - expect_paginated_array_response(related_mr.id) - end - - it 'renders 404 if project is not visible' do - private_project = create(:project, :private) - private_issue = create(:issue, project: private_project) - create_referencing_mr(user, private_project, private_issue) - - get_related_merge_requests(private_project.id, private_issue.iid) - - expect(response).to have_gitlab_http_status(404) - end - end - - it 'returns merge requests that mentioned a issue' do - create(:merge_request, - :simple, - author: user, - source_project: project, - target_project: project, - description: "Some description") - - get_related_merge_requests(project.id, issue.iid, user) - - expect_paginated_array_response(related_mr.id) - end - - it 'returns merge requests cross-project wide' do - project2 = create(:project, :public, creator_id: user.id, namespace: user.namespace) - merge_request = create_referencing_mr(user, project2, issue) - - get_related_merge_requests(project.id, issue.iid, user) - - expect_paginated_array_response([related_mr.id, merge_request.id]) - end - - it 'does not generate references to projects with no access' do - private_project = create(:project, :private) - create_referencing_mr(private_project.creator, private_project, issue) - - get_related_merge_requests(project.id, issue.iid, user) - - expect_paginated_array_response(related_mr.id) - end - - context 'merge request closes an issue' do - let!(:closing_issue_mr_rel) do - create(:merge_requests_closing_issues, issue: issue, merge_request: related_mr) - end - - it 'returns closing MR only once' do - get_related_merge_requests(project.id, issue.iid, user) - - expect_paginated_array_response([related_mr.id]) - end - end - - context 'no merge request mentioned a issue' do - it 'returns empty array' do - get_related_merge_requests(project.id, closed_issue.iid, user) - - expect_paginated_array_response([]) - end - end - - it "returns 404 when issue doesn't exists" do - get_related_merge_requests(project.id, 0, user) - - expect(response).to have_gitlab_http_status(404) - end - end - - describe "GET /projects/:id/issues/:issue_iid/user_agent_detail" do - let!(:user_agent_detail) { create(:user_agent_detail, subject: issue) } - - context 'when unauthenticated' do - it "returns unauthorized" do - get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail") - - expect(response).to have_gitlab_http_status(401) - end - end - - it 'exposes known attributes' do - get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", admin) - - expect(response).to have_gitlab_http_status(200) - expect(json_response['user_agent']).to eq(user_agent_detail.user_agent) - expect(json_response['ip_address']).to eq(user_agent_detail.ip_address) - expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) - end - - it "returns unauthorized for non-admin users" do - get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", user) - - expect(response).to have_gitlab_http_status(403) - end - end - - describe 'GET projects/:id/issues/:issue_iid/participants' do - it_behaves_like 'issuable participants endpoint' do - let(:entity) { issue } - end - - it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/participants", non_member) - - expect(response).to have_gitlab_http_status(404) - end - end -end diff --git a/spec/support/shared_examples/requests/api/issues_shared_example_spec.rb b/spec/support/shared_examples/requests/api/issues_shared_example_spec.rb new file mode 100644 index 00000000000..1133e95e44e --- /dev/null +++ b/spec/support/shared_examples/requests/api/issues_shared_example_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +shared_examples 'labeled issues with labels and label_name params' do + shared_examples 'returns label names' do + it 'returns label names' do + expect_paginated_array_response(issue.id) + expect(json_response.first['labels']).to eq([label_c.title, label_b.title, label.title]) + end + end + + shared_examples 'returns basic label entity' do + it 'returns basic label entity' do + expect_paginated_array_response(issue.id) + expect(json_response.first['labels'].pluck('name')).to eq([label_c.title, label_b.title, label.title]) + expect(json_response.first['labels'].first).to match_schema('/public_api/v4/label_basic') + end + end + + context 'array of labeled issues when all labels match' do + let(:params) { { labels: "#{label.title},#{label_b.title},#{label_c.title}" } } + + it_behaves_like 'returns label names' + end + + context 'array of labeled issues when all labels match with labels param as array' do + let(:params) { { labels: [label.title, label_b.title, label_c.title] } } + + it_behaves_like 'returns label names' + end + + context 'when with_labels_details provided' do + context 'array of labeled issues when all labels match' do + let(:params) { { labels: "#{label.title},#{label_b.title},#{label_c.title}", with_labels_details: true } } + + it_behaves_like 'returns basic label entity' + end + + context 'array of labeled issues when all labels match with labels param as array' do + let(:params) { { labels: [label.title, label_b.title, label_c.title], with_labels_details: true } } + + it_behaves_like 'returns basic label entity' + end + end +end |