Commit 577b1f16 authored by Ash McKenzie's avatar Ash McKenzie

Merge branch 'add-iterations-rest-api' into 'master'

Add REST API for listing iterations

See merge request gitlab-org/gitlab!44685
parents 02d871d3 85fc31b4
...@@ -38,6 +38,7 @@ The following API resources are available in the project context: ...@@ -38,6 +38,7 @@ The following API resources are available in the project context:
| [Issues Statistics](issues_statistics.md) | `/projects/:id/issues_statistics` (also available for groups and standalone) | | [Issues Statistics](issues_statistics.md) | `/projects/:id/issues_statistics` (also available for groups and standalone) |
| [Issue boards](boards.md) | `/projects/:id/boards` | | [Issue boards](boards.md) | `/projects/:id/boards` |
| [Issue links](issue_links.md) **(STARTER)** | `/projects/:id/issues/.../links` | | [Issue links](issue_links.md) **(STARTER)** | `/projects/:id/issues/.../links` |
| [Iterations](iterations.md) **(STARTER)** | `/projects/:id/iterations` (also available for groups) |
| [Jobs](jobs.md) | `/projects/:id/jobs`, `/projects/:id/pipelines/.../jobs` | | [Jobs](jobs.md) | `/projects/:id/jobs`, `/projects/:id/pipelines/.../jobs` |
| [Labels](labels.md) | `/projects/:id/labels` | | [Labels](labels.md) | `/projects/:id/labels` |
| [Managed licenses](managed_licenses.md) **(ULTIMATE)** | `/projects/:id/managed_licenses` | | [Managed licenses](managed_licenses.md) **(ULTIMATE)** | `/projects/:id/managed_licenses` |
...@@ -97,6 +98,7 @@ The following API resources are available in the group context: ...@@ -97,6 +98,7 @@ The following API resources are available in the group context:
| [Groups](groups.md) | `/groups`, `/groups/.../subgroups` | | [Groups](groups.md) | `/groups`, `/groups/.../subgroups` |
| [Group badges](group_badges.md) | `/groups/:id/badges` | | [Group badges](group_badges.md) | `/groups/:id/badges` |
| [Group issue boards](group_boards.md) | `/groups/:id/boards` | | [Group issue boards](group_boards.md) | `/groups/:id/boards` |
| [Group iterations](group_iterations.md) **(STARTER)** | `/groups/:id/iterations` (also available for projects) |
| [Group labels](group_labels.md) | `/groups/:id/labels` | | [Group labels](group_labels.md) | `/groups/:id/labels` |
| [Group-level variables](group_level_variables.md) | `/groups/:id/variables` | | [Group-level variables](group_level_variables.md) | `/groups/:id/variables` |
| [Group milestones](group_milestones.md) | `/groups/:id/milestones` | | [Group milestones](group_milestones.md) | `/groups/:id/milestones` |
......
---
stage: Plan
group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Group iterations API **(STARTER)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.5.
This page describes the group iterations API.
There's a separate [project iterations API](./iterations.md) page.
## List group iterations
Returns a list of group iterations.
```plaintext
GET /groups/:id/iterations
GET /groups/:id/iterations?state=opened
GET /groups/:id/iterations?state=closed
GET /groups/:id/iterations?title=1.0
GET /groups/:id/iterations?search=version
```
| Attribute | Type | Required | Description |
| ------------------- | ------- | -------- | ----------- |
| `state` | string | no | Return only `opened`, `upcoming`, `started`, `closed`, or `all` iterations. Defaults to `all`. |
| `search` | string | no | Return only iterations with a title matching the provided string. |
| `include_ancestors` | boolean | no | Include iterations from parent group and its ancestors. Defaults to `true`. |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/iterations"
```
Example response:
```json
[
{
"id": 53,
"iid": 13,
"group_id": 5,
"title": "Iteration II",
"description": "Ipsum Lorem ipsum",
"state": 2,
"created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z",
"due_date": "2020-02-01",
"start_date": "2020-02-14"
}
]
```
...@@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w ...@@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/12819) in GitLab 9.5. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/12819) in GitLab 9.5.
This page describes the group milestones API. This page describes the group milestones API.
There's a separate [project milestones API](./group_milestones.md) page. There's a separate [project milestones API](./milestones.md) page.
## List group milestones ## List group milestones
......
---
stage: Plan
group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Project iterations API **(STARTER)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/118742) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.5.
This page describes the project iterations API.
There's a separate [group iterations API](./group_iterations.md) page.
As of GitLab 13.5, we don't have project-level iterations, but you can use this endpoint to fetch the iterations of the project's ancestor groups.
## List project iterations
Returns a list of project iterations.
```plaintext
GET /projects/:id/iterations
GET /projects/:id/iterations?state=opened
GET /projects/:id/iterations?state=closed
GET /projects/:id/iterations?title=1.0
GET /projects/:id/iterations?search=version
```
| Attribute | Type | Required | Description |
| ------------------- | ------- | -------- | ----------- |
| `state` | string | no | Return only `opened`, `upcoming`, `started`, `closed`, or `all` iterations. Defaults to `all`. |
| `search` | string | no | Return only iterations with a title matching the provided string. |
| `include_ancestors` | boolean | no | Include iterations from parent group and its ancestors. Defaults to `true`. |
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/iterations"
```
Example response:
```json
[
{
"id": 53,
"iid": 13,
"group_id": 5,
"title": "Iteration II",
"description": "Ipsum Lorem ipsum",
"state": 2,
"created_at": "2020-01-27T05:07:12.573Z",
"updated_at": "2020-01-27T05:07:12.573Z",
"due_date": "2020-02-01",
"start_date": "2020-02-14"
}
]
```
...@@ -15,6 +15,27 @@ class IterationsFinder ...@@ -15,6 +15,27 @@ class IterationsFinder
attr_reader :params, :current_user attr_reader :params, :current_user
class << self
def params_for_parent(parent, include_ancestors: false)
case parent
when Group
if include_ancestors
{ group_ids: parent.self_and_ancestors.select(:id) }
else
{ group_ids: parent.id }
end
when Project
if include_ancestors && parent.parent_id.present?
{ group_ids: parent.parent.self_and_ancestors.select(:id), project_ids: parent.id }
else
{ project_ids: parent.id }
end
else
raise ArgumentError, 'Invalid parent class. Only Project and Group are supported.'
end
end
end
def initialize(current_user, params = {}) def initialize(current_user, params = {})
@params = params @params = params
@current_user = current_user @current_user = current_user
......
...@@ -41,36 +41,20 @@ module Resolvers ...@@ -41,36 +41,20 @@ module Resolvers
private private
def iterations_finder_params(args) def iterations_finder_params(args)
{ IterationsFinder.params_for_parent(parent, include_ancestors: args[:include_ancestors]).merge!(
id: args[:id], id: args[:id],
iid: args[:iid], iid: args[:iid],
state: args[:state] || 'all', state: args[:state] || 'all',
start_date: args[:start_date], start_date: args[:start_date],
end_date: args[:end_date], end_date: args[:end_date],
search_title: args[:title] search_title: args[:title]
}.merge(parent_id_parameter(args[:include_ancestors])) )
end end
def parent def parent
@parent ||= object.respond_to?(:sync) ? object.sync : object @parent ||= object.respond_to?(:sync) ? object.sync : object
end end
def parent_id_parameter(include_ancestors)
if parent.is_a?(Group)
if include_ancestors
{ group_ids: parent.self_and_ancestors.select(:id) }
else
{ group_ids: parent.id }
end
elsif parent.is_a?(Project)
if include_ancestors && parent.parent_id.present?
{ group_ids: parent.parent.self_and_ancestors.select(:id), project_ids: parent.id }
else
{ project_ids: parent.id }
end
end
end
def authorize! def authorize!
Ability.allowed?(context[:current_user], :read_iteration, parent) || raise_resource_not_available_error! Ability.allowed?(context[:current_user], :read_iteration, parent) || raise_resource_not_available_error!
end end
......
---
title: Add REST API for listing iterations
merge_request: 44685
author:
type: added
# frozen_string_literal: true
module API
class Iterations < ::API::Base
include PaginationParams
helpers do
params :list_params do
optional :state, type: String, values: %w[opened upcoming started closed all], default: 'all',
desc: 'Return "opened", "upcoming", "started", "closed", or "all" milestones'
optional :search, type: String, desc: 'The search criteria for the title of the iteration'
optional :include_ancestors, type: Grape::API::Boolean, default: true,
desc: 'Include iterations from parent and its ancestors'
use :pagination
end
def list_iterations_for(parent)
iterations = IterationsFinder.new(current_user, iterations_finder_params(parent)).execute
present paginate(iterations), with: EE::API::Entities::Iteration
end
def iterations_finder_params(parent)
IterationsFinder.params_for_parent(parent, include_ancestors: params[:include_ancestors]).merge!(
state: params[:state],
search_title: params[:search]
)
end
end
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of project iterations' do
detail 'This feature was introduced in GitLab 13.5'
success EE::API::Entities::Iteration
end
params do
use :list_params
end
get ":id/iterations" do
authorize! :read_iteration, user_project
list_iterations_for(user_project)
end
end
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of group iterations' do
detail 'This feature was introduced in GitLab 13.5'
success EE::API::Entities::Iteration
end
params do
use :list_params
end
get ":id/iterations" do
authorize! :read_iteration, user_group
list_iterations_for(user_group)
end
end
end
end
...@@ -48,6 +48,7 @@ module EE ...@@ -48,6 +48,7 @@ module EE
mount ::API::ProtectedEnvironments mount ::API::ProtectedEnvironments
mount ::API::ResourceWeightEvents mount ::API::ResourceWeightEvents
mount ::API::ResourceIterationEvents mount ::API::ResourceIterationEvents
mount ::API::Iterations
end end
end end
end end
......
...@@ -135,9 +135,9 @@ module EE ...@@ -135,9 +135,9 @@ module EE
end end
def find_iterations(project, params = {}) def find_iterations(project, params = {})
group_ids = project.group.self_and_ancestors.map(&:id) if project.group parent_params = ::IterationsFinder.params_for_parent(project, include_ancestors: true)
::IterationsFinder.new(current_user, params.merge(project_ids: [project.id], group_ids: group_ids)).execute ::IterationsFinder.new(current_user, params.merge(parent_params)).execute
end end
desc _('Publish to status page') desc _('Publish to status page')
......
...@@ -159,5 +159,59 @@ RSpec.describe IterationsFinder do ...@@ -159,5 +159,59 @@ RSpec.describe IterationsFinder do
expect(finder.find_by(iid: iteration_from_project_1.iid)).to eq(iteration_from_project_1) expect(finder.find_by(iid: iteration_from_project_1.iid)).to eq(iteration_from_project_1)
end end
end end
describe '.params_for_parent' do
let_it_be(:parent_group) { create(:group) }
let_it_be(:group) { create(:group, parent: parent_group) }
let_it_be(:project) { create(:project, group: group) }
context 'when parent is a project' do
subject { described_class.params_for_parent(project, include_ancestors: include_ancestors) }
context 'when include_ancestors is true' do
let(:include_ancestors) { true }
it 'returns project and ancestor group ids' do
expect(subject).to match(group_ids: contain_exactly(group, parent_group), project_ids: project.id)
end
end
context 'when include_ancestors is false' do
let(:include_ancestors) { false }
it 'returns project id' do
expect(subject).to eq(project_ids: project.id)
end
end
end
context 'when parent is a group' do
subject { described_class.params_for_parent(group, include_ancestors: include_ancestors) }
context 'when include_ancestors is true' do
let(:include_ancestors) { true }
it 'returns group and ancestor ids' do
expect(subject).to match(group_ids: contain_exactly(group, parent_group))
end
end
context 'when include_ancestors is false' do
let(:include_ancestors) { false }
it 'returns group id' do
expect(subject).to eq(group_ids: group.id)
end
end
end
context 'when parent is invalid' do
subject { described_class.params_for_parent(double(User)) }
it 'raises an ArgumentError' do
expect { subject }.to raise_error(ArgumentError, 'Invalid parent class. Only Project and Group are supported.')
end
end
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Iterations do
let_it_be(:user) { create(:user) }
let_it_be(:parent_group) { create(:group, :private) }
let_it_be(:group) { create(:group, :private, parent: parent_group) }
let_it_be(:iteration) { create(:iteration, group: group, title: 'search_title') }
let_it_be(:closed_iteration) { create(:iteration, :closed, group: group) }
let_it_be(:ancestor_iteration) { create(:iteration, group: parent_group) }
before_all do
parent_group.add_guest(user)
end
shared_examples 'iterations list' do
context 'when user does not have access' do
it 'returns 404' do
get api(api_path, nil)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when user has access' do
it 'returns a list of iterations' do
get api(api_path, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(3)
expect(json_response.map { |i| i['id'] }).to contain_exactly(iteration.id, closed_iteration.id, ancestor_iteration.id)
end
it 'returns iterations filtered by state' do
get api(api_path, user), params: { state: 'closed' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq(closed_iteration.id)
end
it 'returns iterations filtered by title' do
get api(api_path, user), params: { search: 'search_' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(1)
expect(json_response.first['id']).to eq(iteration.id)
end
it 'returns 400 when param is invalid' do
get api(api_path, user), params: { state: 'non-existent-state' }
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
describe 'GET /groups/:id/iterations' do
let(:api_path) { "/groups/#{group.id}/iterations" }
it_behaves_like 'iterations list'
it 'excludes ancestor iterations when include_ancestors is set to false' do
get api(api_path, user), params: { include_ancestors: false }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(2)
expect(json_response.map { |i| i['id'] }).to contain_exactly(iteration.id, closed_iteration.id)
end
end
describe 'GET /projects/:id/iterations' do
let_it_be(:project) { create(:project, :private, group: group) }
let(:api_path) { "/projects/#{project.id}/iterations" }
it_behaves_like 'iterations list'
it 'excludes ancestor iterations when include_ancestors is set to false' do
get api(api_path, user), params: { include_ancestors: false }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(0)
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