Commit 6c1faba7 authored by Shinya Maeda's avatar Shinya Maeda

Merge branch '345534-add-release-api-endpoints-for-groups' into 'master'

Resolve "Add Release API Endpoints for Groups"

See merge request gitlab-org/gitlab!77783
parents 2fddd8d0 1bc89dbd
......@@ -17,8 +17,10 @@ module Groups
def releases
if Feature.enabled?(:group_releases_finder_inoperator)
Releases::GroupReleasesFinder
.new(@group, current_user, { include_subgroups: true, page: params[:page], per: 30 })
.new(@group, current_user)
.execute(preload: false)
.page(params[:page])
.per(30)
else
ReleasesFinder
.new(@group, current_user, { include_subgroups: true })
......
......@@ -15,10 +15,7 @@ module Releases
@current_user = current_user
@params = params
params[:order_by] ||= 'released_at'
params[:sort] ||= 'desc'
params[:page] ||= 0
params[:per] ||= 30
end
def execute(preload: true)
......@@ -26,7 +23,7 @@ module Releases
releases = get_releases
releases.preloaded if preload
paginate_releases(releases)
releases
end
private
......@@ -46,9 +43,6 @@ module Releases
Release.sort_by_attribute("released_at_#{params[:sort]}").order(id: params[:sort])
end
def paginate_releases(releases)
releases.page(params[:page].to_i).per(params[:per])
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
......@@ -114,6 +114,7 @@ The following API resources are available in the group context:
| [Group labels](group_labels.md) | `/groups/:id/labels` |
| [Group-level variables](group_level_variables.md) | `/groups/:id/variables` |
| [Group milestones](group_milestones.md) | `/groups/:id/milestones` |
| [Group releases](group_releases.md) | `/groups/:id/releases`|
| [Group wikis](group_wikis.md) **(PREMIUM)** | `/groups/:id/wikis` |
| [Invitations](invitations.md) | `/groups/:id/invitations` (also available for projects) |
| [Issues](issues.md) | `/groups/:id/issues` (also available for projects and standalone) |
......
---
stage: Release
group: Release
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/#assignments
---
# Group releases API **(FREE)**
Review your groups' [releases](../user/project/releases/index.md) with the REST API.
NOTE:
For information about the project releases API, visit the [Releases API](releases/index.md) page.
FLAG:
On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `group_releases_finder_inoperator`.
## List group releases
Returns a list of group releases.
```plaintext
GET /groups/:id/releases
GET /groups/:id/releases?simple=true
```
Parameters:
| Attribute | Type | Required | Description |
|---------------------|----------------|----------|---------------------------------------------------------------------------------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](index.md#namespaced-path-encoding) owned by the authenticated user. |
| `sort` | string | no | The direction of the order. Either `desc` (default) for descending order or `asc` for ascending order. |
| `simple` | boolean | no | Return only limited fields for each release. |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/releases"
```
Example response:
```json
[
{
"name": "standard release",
"tag_name": "releasetag",
"description": "",
"created_at": "2022-01-10T15:23:15.529Z",
"released_at": "2022-01-10T15:23:15.529Z",
"author": {
"id": 1,
"username": "root",
"name": "Administrator",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "https://gitlab.com/root"
},
"commit": {
"id": "e8cbb845ae5a53a2fef2938cf63cf82efc10d993",
"short_id": "e8cbb845",
"created_at": "2022-01-10T15:20:29.000+00:00",
"parent_ids": [],
"title": "Update test",
"message": "Update test",
"author_name": "Administrator",
"author_email": "admin@example.com",
"authored_date": "2022-01-10T15:20:29.000+00:00",
"committer_name": "Administrator",
"committer_email": "admin@example.com",
"committed_date": "2022-01-10T15:20:29.000+00:00",
"trailers": {},
"web_url": "https://gitlab.com/groups/gitlab-org/-/commit/e8cbb845ae5a53a2fef2938cf63cf82efc10d993"
},
"upcoming_release": false,
"commit_path": "/testgroup/test/-/commit/e8cbb845ae5a53a2fef2938cf63cf82efc10d993",
"tag_path": "/testgroup/test/-/tags/testtag"
}
]
```
......@@ -241,10 +241,10 @@ Get a Release for the given tag.
GET /projects/:id/releases/:tag_name
```
| Attribute | Type | Required | Description |
| ------------- | -------------- | -------- | ----------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../index.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The Git tag the release is associated with. |
| Attribute | Type | Required | Description |
|----------------------------| -------------- | -------- | ----------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../index.md#namespaced-path-encoding). |
| `tag_name` | string | yes | The Git tag the release is associated with. |
| `include_html_description` | boolean | no | If `true`, a response includes HTML rendered Markdown of the release description. |
Example request:
......
# frozen_string_literal: true
module API
module Entities
class BasicReleaseDetails < Grape::Entity
include ::API::Helpers::Presentable
expose :name
expose :tag, as: :tag_name
expose :description
expose :created_at
expose :released_at
expose :upcoming_release?, as: :upcoming_release
end
end
end
......@@ -2,20 +2,14 @@
module API
module Entities
class Release < Grape::Entity
class Release < BasicReleaseDetails
include ::API::Helpers::Presentable
expose :name
expose :tag, as: :tag_name, if: ->(_, _) { can_download_code? }
expose :description
expose :description_html, if: -> (_, options) { options[:include_html_description] } do |entity|
MarkupHelper.markdown_field(entity, :description, current_user: options[:current_user])
end
expose :created_at
expose :released_at
expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? }
expose :commit, using: Entities::Commit, if: ->(_, _) { can_download_code? }
expose :upcoming_release?, as: :upcoming_release
expose :milestones,
using: Entities::MilestoneWithStats,
if: -> (release, _) { release.milestones.present? && can_read_milestone? } do |release, _|
......
......@@ -8,16 +8,48 @@ module API
.merge(tag_name: API::NO_SLASH_URL_PART_REGEX)
RELEASE_CLI_USER_AGENT = 'GitLab-release-cli'
before { authorize_read_releases! }
feature_category :release_orchestration
after { track_release_event }
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before { authorize_read_group_releases! }
feature_category :release_orchestration
desc 'Get a list of releases for projects in this group.' do
success Entities::Release
end
params do
requires :id, type: Integer, desc: 'The ID of the group to get releases for'
optional :sort, type: String, values: %w[asc desc], default: 'desc',
desc: 'Return projects sorted in ascending and descending order by released_at'
optional :simple, type: Boolean, default: false,
desc: 'Return only the ID, URL, name, and path of each project'
use :pagination
end
get ":id/releases" do
not_found! unless Feature.enabled?(:group_releases_finder_inoperator)
finder_options = {
sort: params[:sort]
}
strict_params = declared_params(include_missing: false)
releases = find_group_releases(finder_options)
present_group_releases(strict_params, releases)
end
end
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before { authorize_read_releases! }
after { track_release_event }
desc 'Get a project releases' do
detail 'This feature was introduced in GitLab 11.7.'
named 'get_releases'
......@@ -162,6 +194,10 @@ module API
end
helpers do
def authorize_read_group_releases!
authorize! :read_release, user_group
end
def authorize_create_release!
authorize! :create_release, user_project
end
......@@ -220,6 +256,22 @@ module API
Gitlab::Tracking.event(options[:for].name, options[:route_options][:named],
project: user_project, user: current_user, **event_context)
end
def find_group_releases(finder_options)
::Releases::GroupReleasesFinder
.new(user_group, current_user, finder_options)
.execute(preload: true)
end
def present_group_releases(params, releases)
options = {
with: params[:simple] ? Entities::BasicReleaseDetails : Entities::Release,
current_user: current_user
}
# GroupReleasesFinder has already ordered the data for us
present paginate(releases, skip_default_order: true), options
end
end
end
end
......
......@@ -11,8 +11,8 @@ module Gitlab
@request_context = request_context
end
def paginate(relation, exclude_total_headers: false)
paginate_with_limit_optimization(add_default_order(relation)).tap do |data|
def paginate(relation, exclude_total_headers: false, skip_default_order: false)
paginate_with_limit_optimization(add_default_order(relation, skip_default_order: skip_default_order)).tap do |data|
add_pagination_headers(data, exclude_total_headers)
end
end
......@@ -46,7 +46,9 @@ module Gitlab
false
end
def add_default_order(relation)
def add_default_order(relation, skip_default_order: false)
return relation if skip_default_order
if relation.is_a?(ActiveRecord::Relation) && relation.order_values.empty?
relation = relation.order(:id) # rubocop: disable CodeReuse/ActiveRecord
end
......
......@@ -5,6 +5,7 @@
"name": { "type": "string" },
"description": { "type": "string" },
"description_html": { "type": "string" },
"tag_name": { "type": "string"},
"created_at": { "type": "string", "format": "date-time" },
"released_at": { "type": "string", "format": "date-time" },
"upcoming_release": { "type": "boolean" },
......
......@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe API::Releases do
include UploadHelpers
let(:project) { create(:project, :repository, :private) }
let(:maintainer) { create(:user) }
let(:reporter) { create(:user) }
......@@ -1358,4 +1360,80 @@ RSpec.describe API::Releases do
release_cli: release_cli
)
end
describe 'GET /groups/:id/releases' do
let_it_be(:user1) { create(:user, can_create_group: false) }
let_it_be(:admin) { create(:admin) }
let_it_be(:group1) { create(:group, path: 'some_path', avatar: File.open(uploaded_image_temp_path)) }
let_it_be(:group2) { create(:group, :private) }
let_it_be(:project1) { create(:project, namespace: group1) }
let_it_be(:project2) { create(:project, namespace: group2) }
let_it_be(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
let_it_be(:release1) { create(:release, project: project1) }
let_it_be(:release2) { create(:release, project: project2) }
let_it_be(:release3) { create(:release, project: project3) }
context 'when authenticated as owner' do
it 'gets releases from all projects in the group' do
get api("/groups/#{group1.id}/releases", admin)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.length).to eq(2)
expect(json_response.pluck('name')).to match_array([release1.name, release3.name])
end
it 'respects order by parameters' do
create(:release, project: project1, released_at: DateTime.now + 1.day)
get api("/groups/#{group1.id}/releases", admin), params: { sort: 'desc' }
expect(DateTime.parse(json_response[0]["released_at"]))
.to be > (DateTime.parse(json_response[1]["released_at"]))
end
it 'respects the simple parameter' do
get api("/groups/#{group1.id}/releases", admin), params: { simple: true }
expect(json_response[0].keys).not_to include("assets")
end
it 'denies access to private groups' do
get api("/groups/#{group2.id}/releases", user1), params: { simple: true }
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns not found unless :group_releases_finder_inoperator feature flag enabled' do
stub_feature_flags(group_releases_finder_inoperator: false)
get api("/groups/#{group1.id}/releases", admin)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'performance testing' do
shared_examples 'avoids N+1 queries' do |query_params = {}|
context 'with subgroups' do
let(:group) { create(:group) }
it 'include_subgroups avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true })
end.count
subgroups = create_list(:group, 10, parent: group1)
projects = create_list(:project, 10, namespace: subgroups[0])
create_list(:release, 10, project: projects[0], author: admin)
expect do
get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true })
end.not_to exceed_all_query_limit(control_count)
end
end
end
it_behaves_like 'avoids N+1 queries'
it_behaves_like 'avoids N+1 queries', { simple: true }
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