Commit 476af7a9 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'issue_7326' into 'master'

Epics relantioship API

Closes #7326

See merge request gitlab-org/gitlab-ee!9188
parents 164f80b9 97c8886b
......@@ -23,6 +23,7 @@ The following API resources are available:
- [Discussions](discussions.md) (threaded comments)
- [Environments](environments.md)
- [Epic issues](epic_issues.md) **[ULTIMATE]**
- [Epic links](epic_links.md) **[ULTIMATE]**
- [Epics](epics.md) **[ULTIMATE]**
- [Events](events.md)
- [Feature flags](features.md)
......
# Epic Links API **[ULTIMATE]**
>**Note:**
> This endpoint was [introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/9188) in GitLab 11.8.
Manages parent-child [epic relationships](../user/group/epics/index.md#multi-level-child-epics).
Every API call to `epic_links` must be authenticated.
If a user is not a member of a group and the group is private, a `GET` request on that group will result to a `404` status code.
Epics are available only in the [Ultimate/Gold tier](https://about.gitlab.com/pricing/). If the epics feature is not available, a `403` status code will be returned.
## List epics related to a given epic
Gets all child epics of an epic.
```
GET /groups/:id/epics/:epic_iid/epics
```
| 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 |
| `epic_iid` | integer/string | yes | The internal ID of the epic. |
```bash
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/1/epics/5/epics/
```
Example response:
```json
[
{
"id": 29,
"iid": 6,
"group_id": 1,
"parent_id": 5,
"title": "Accusamus iste et ullam ratione voluptatem omnis debitis dolor est.",
"description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.",
"author": {
"id": 10,
"name": "Lu Mayer",
"username": "kam",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/018729e129a6f31c80a6327a30196823?s=80&d=identicon",
"web_url": "http://localhost:3001/kam"
},
"start_date": null,
"start_date_is_fixed": false,
"start_date_fixed": null,
"start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
}
]
```
## Assign a child epic
Creates an association between two epics, designating one as the parent epic and the other as the child epic. A parent epic can have multiple child epics. If the new child epic already belonged to another epic, it is unassigned from that previous parent.
```
POST /groups/:id/epics/:epic_iid/epics
```
| 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 |
| `epic_iid` | integer/string | yes | The internal ID of the epic. |
| `child_epic_id` | integer/string | yes | The global ID of the child epic. Internal ID can't be used because they can conflict with epics from other groups. |
```bash
curl --header POST "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/1/epics/5/epics/6
```
Example response:
```json
{
"id": 6,
"iid": 38,
"group_id": 1,
"parent_id": 5
"title": "Accusamus iste et ullam ratione voluptatem omnis debitis dolor est.",
"description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.",
"author": {
"id": 10,
"name": "Lu Mayer",
"username": "kam",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/018729e129a6f31c80a6327a30196823?s=80&d=identicon",
"web_url": "http://localhost:3001/kam"
},
"start_date": null,
"start_date_is_fixed": false,
"start_date_fixed": null,
"start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
}
```
## Delete an epic parent
Removes an epic - epic association.
```
DELETE /groups/:id/epics/:epic_iid/epics/:child_epic_id
```
| 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. |
| `epic_iid` | integer/string | yes | The internal ID of the epic. |
| `child_epic_id` | integer/string | yes | The global ID of the child epic. Internal ID can't be used because they can conflict with epics from other groups. |
```bash
curl --header DELETE "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/groups/1/epics/4/epics/5
```
Example response:
```json
{
"id": 5,
"iid": 38,
"group_id": 1,
"parent_id": null,
"title": "Accusamus iste et ullam ratione voluptatem omnis debitis dolor est.",
"description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.",
"author": {
"id": 10,
"name": "Lu Mayer",
"username": "kam",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/018729e129a6f31c80a6327a30196823?s=80&d=identicon",
"web_url": "http://localhost:3001/kam"
},
"start_date": null,
"start_date_is_fixed": false,
"start_date_fixed": null,
"start_date_from_milestones": null,
"end_date": "2018-07-31",
"due_date": "2018-07-31",
"due_date_is_fixed": false,
"due_date_fixed": null,
"due_date_from_milestones": "2018-07-31",
"created_at": "2018-07-17T13:36:22.770Z",
"updated_at": "2018-07-18T12:22:05.239Z",
"labels": []
}
```
......@@ -2,6 +2,7 @@
class EpicEntity < IssuableEntity
expose :group_id
expose :group_name do |epic|
epic.group.name
end
......
---
title: Add epic links API endpoints
merge_request: 9188
author:
type: added
......@@ -7,25 +7,9 @@ module API
authorize_epics_feature!
end
helpers do
def authorize_epics_feature!
forbidden! unless user_group.feature_available?(:epics)
end
def authorize_can_read!
authorize!(:read_epic, epic)
end
def authorize_can_admin!
authorize!(:admin_epic, epic)
end
# rubocop: disable CodeReuse/ActiveRecord
def epic
@epic ||= user_group.epics.find_by(iid: params[:epic_iid])
end
# rubocop: enable CodeReuse/ActiveRecord
helpers ::API::Helpers::EpicsHelpers
helpers do
def link
@link ||= epic.epic_issues.find(params[:epic_issue_id])
end
......
# frozen_string_literal: true
module API
class EpicLinks < Grape::API
include ::Gitlab::Utils::StrongMemoize
before do
authenticate!
authorize_epics_feature!
end
helpers ::API::Helpers::EpicsHelpers
helpers do
def child_epic
strong_memoize(:child_epic) do
find_epics(finder_params: { group_id: user_group.id })
.find_by_id(declared_params[:child_epic_id])
end
end
params :child_epic_id do
# Unique ID should be used because epics from other groups can be assigned as child.
requires :child_epic_id, type: Integer, desc: 'The global ID of the epic that will be assigned as child'
end
end
params do
requires :id, type: String, desc: 'The ID of a group'
requires :epic_iid, type: Integer, desc: 'The internal ID of an epic'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get related epics' do
success EE::API::Entities::Epic
end
get ':id/(-/)epics/:epic_iid/epics' do
authorize_can_read!
child_epics = EpicsFinder.new(current_user, parent_id: epic.id, group_id: user_group.id).execute
present child_epics, with: EE::API::Entities::Epic
end
desc 'Relate epics' do
success EE::API::Entities::Epic
end
params do
use :child_epic_id
end
post ':id/(-/)epics/:epic_iid/epics/:child_epic_id' do
authorize_can_admin!
create_params = { target_issuable: child_epic }
result = ::EpicLinks::CreateService.new(epic, current_user, create_params).execute
if result[:status] == :success
present child_epic, with: EE::API::Entities::Epic
else
render_api_error!(result[:message], result[:http_status])
end
end
desc 'Remove epics relation'
params do
use :child_epic_id
end
delete ':id/(-/)epics/:epic_iid/epics/:child_epic_id' do
authorize_can_admin!
updated_epic = ::Epics::UpdateService.new(user_group, current_user, { parent: nil }).execute(child_epic)
present updated_epic, with: EE::API::Entities::Epic
end
end
end
end
......@@ -9,47 +9,9 @@ module API
authorize_epics_feature!
end
helpers ::API::Helpers::EpicsHelpers
helpers ::Gitlab::IssuableMetadata
helpers do
def authorize_epics_feature!
forbidden! unless user_group.feature_available?(:epics)
end
def authorize_can_read!
authorize!(:read_epic, epic)
end
def authorize_can_admin!
authorize!(:admin_epic, epic)
end
def authorize_can_create!
authorize!(:admin_epic, user_group)
end
def authorize_can_destroy!
authorize!(:destroy_epic, epic)
end
# rubocop: disable CodeReuse/ActiveRecord
def epic
@epic ||= user_group.epics.find_by(iid: params[:epic_iid])
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def find_epics(args = {})
args = declared_params.merge(args)
args[:label_name] = args.delete(:labels)
epics = EpicsFinder.new(current_user, args).execute.preload(:labels)
epics.reorder(args[:order_by] => args[:sort])
end
# rubocop: enable CodeReuse/ActiveRecord
end
params do
requires :id, type: String, desc: 'The ID of a group'
end
......@@ -71,7 +33,7 @@ module API
use :pagination
end
get ':id/(-/)epics' do
epics = paginate(find_epics(group_id: user_group.id))
epics = paginate(find_epics(finder_params: { group_id: user_group.id }))
present epics, with: EE::API::Entities::Epic, user: current_user, epics_metadata: issuable_meta_data(epics, 'Epic')
end
......
# frozen_string_literal: true
module API
module Helpers
module EpicsHelpers
def authorize_epics_feature!
forbidden! unless user_group.feature_available?(:epics)
end
def authorize_can_read!
authorize!(:read_epic, epic)
end
def authorize_can_admin!
authorize!(:admin_epic, epic)
end
def authorize_can_create!
authorize!(:admin_epic, user_group)
end
def authorize_can_destroy!
authorize!(:destroy_epic, epic)
end
# rubocop: disable CodeReuse/ActiveRecord
def epic
@epic ||= user_group.epics.find_by(iid: params[:epic_iid])
end
def find_epics(finder_params: {}, preload: nil)
args = declared_params.merge(finder_params)
args[:label_name] = args.delete(:labels)
epics = EpicsFinder.new(current_user, args).execute.preload(preload)
if args[:order_by] && args[:sort]
epics.reorder(args[:order_by] => args[:sort])
else
epics
end
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
......@@ -11,6 +11,7 @@ module EE
mount ::API::Unleash
mount ::API::EpicIssues
mount ::API::EpicLinks
mount ::API::Epics
mount ::API::Geo
mount ::API::GeoNodes
......
......@@ -186,6 +186,7 @@ module EE
expose :id
expose :iid
expose :group_id
expose :parent_id
expose :title
expose :description
expose :author, using: ::API::Entities::UserBasic
......
......@@ -4,6 +4,7 @@
"id": { "type": "integer" },
"iid": { "type": "integer" },
"group_id": { "type": "integer" },
"parent_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"author": {
......
......@@ -5,41 +5,9 @@
"relative_position": { "type": "integer" },
"issue": { "type": "object" },
"epic": {
"type": "object",
"required": [
"id",
"iid",
"title"
],
"properties": {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"title": { "type": "string" },
"group_id": { "type": "integer" },
"description": { "type": ["string", "null"] },
"author": { "type": ["object", "null"] },
"start_date": { "type": ["date", "null"] },
"start_date_fixed": { "type": ["date", "null"] },
"start_date_from_milestones": { "type": ["date", "null"] },
"start_date_is_fixed": { "type": "boolean" },
"end_date": { "type": ["date", "null"] },
"due_date": { "type": ["date", "null"] },
"due_date_fixed": { "type": ["date", "null"] },
"due_date_from_milestones": { "type": ["date", "null"] },
"due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" },
"upvotes": { "type": "integer" },
"downvotes": { "type": "integer" },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] },
"labels": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
"allOf": [
{ "$ref": "../../../../../../../ee/spec/fixtures/api/schemas/public_api/v4/epic.json" }
]
},
"issue": {
"allOf": [
......
# frozen_string_literal: true
require 'spec_helper'
describe API::EpicLinks do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:epic) { create(:epic, group: group) }
shared_examples 'user does not have access' do
it 'returns 403 when epics feature is disabled' do
group.add_developer(user)
get api(url, user)
expect(response).to have_gitlab_http_status(403)
end
it 'returns 401 unauthorized error for non authenticated user' do
get api(url)
expect(response).to have_gitlab_http_status(401)
end
it 'returns 404 not found error for a user without permissions to see the group' do
group.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
get api(url, user)
expect(response).to have_gitlab_http_status(404)
end
end
describe 'GET /groups/:id/epics/:epic_iid/epics' do
let(:url) { "/groups/#{group.path}/epics/#{epic.iid}/epics" }
it_behaves_like 'user does not have access'
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
let!(:child_epic1) { create(:epic, group: group, parent: epic) }
let!(:child_epic2) { create(:epic, group: group, parent: epic) }
it 'returns 200 status' do
get api(url, user)
epics = JSON.parse(response.body)
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/epics', dir: 'ee')
expect(epics.map { |epic| epic["id"] }).to match_array([child_epic1.id, child_epic2.id])
end
end
end
describe 'POST /groups/:id/epics/:epic_iid/epics' do
let(:child_epic) { create(:epic, group: group) }
let(:url) { "/groups/#{group.path}/epics/#{epic.iid}/epics" }
it_behaves_like 'user does not have access'
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when user is guest' do
it 'returns 403' do
group.add_guest(user)
post api("#{url}/#{child_epic.id}", user)
expect(response).to have_gitlab_http_status(403)
end
end
context 'when user is developer' do
it 'returns 201 status' do
group.add_developer(user)
post api("#{url}/#{child_epic.id}", user)
expect(response).to have_gitlab_http_status(201)
expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee')
expect(epic.reload.children).to include(child_epic)
end
end
context 'when target epic cannot be read' do
let(:other_group) { create(:group, :private) }
let(:child_epic) { create(:epic, group: other_group) }
it 'returns 404 status' do
group.add_developer(user)
post api(url, user), params: { child_epic_id: child_epic.id }
expect(response).to have_gitlab_http_status(404)
end
end
end
end
describe 'DELETE /groups/:id/epics/:epic_iid/epics' do
let!(:child_epic) { create(:epic, group: group, parent: epic)}
let(:url) { "/groups/#{group.path}/epics/#{epic.iid}/epics" }
it_behaves_like 'user does not have access'
context 'when epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
context 'when user is guest' do
it 'returns 403' do
group.add_guest(user)
delete api("#{url}/#{child_epic.id}", user)
expect(response).to have_gitlab_http_status(403)
end
end
context 'when user is developer' do
it 'returns 200 status' do
group.add_developer(user)
delete api("#{url}/#{child_epic.id}", user)
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/epic', dir: 'ee')
expect(epic.reload.children).not_to include(child_epic)
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment