Commit b0a1008e authored by Stan Hu's avatar Stan Hu

Merge branch 'public-api-for-merge-trains' into 'master'

Allow users to get Merge Trains entries via Public API

See merge request gitlab-org/gitlab!25229
parents 63e78bb7 7d21a853
......@@ -173,8 +173,10 @@ module Ci
scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
scope :order_id_desc, -> { order('ci_builds.id DESC') }
PROJECT_ROUTE_AND_NAMESPACE_ROUTE = { project: [:project_feature, :route, { namespace: :route }] }.freeze
scope :preload_project_and_pipeline_project, -> { preload(PROJECT_ROUTE_AND_NAMESPACE_ROUTE, pipeline: PROJECT_ROUTE_AND_NAMESPACE_ROUTE) }
scope :preload_project_and_pipeline_project, -> do
preload(Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE,
pipeline: Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE)
end
acts_as_taggable
......
......@@ -16,6 +16,10 @@ module Ci
include FromUnion
include UpdatedAtFilterable
PROJECT_ROUTE_AND_NAMESPACE_ROUTE = {
project: [:project_feature, :route, { namespace: :route }]
}.freeze
BridgeStatusError = Class.new(StandardError)
sha_attribute :source_sha
......
......@@ -235,12 +235,17 @@ class MergeRequest < ApplicationRecord
end
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
PROJECT_ROUTE_AND_NAMESPACE_ROUTE = [
target_project: [:route, { namespace: :route }],
source_project: [:route, { namespace: :route }]
].freeze
scope :with_api_entity_associations, -> {
preload(:assignees, :author, :unresolved_notes, :labels, :milestone,
:timelogs, :latest_merge_request_diff,
metrics: [:latest_closed_by, :merged_by],
target_project: [:route, { namespace: :route }],
source_project: [:route, { namespace: :route }])
*PROJECT_ROUTE_AND_NAMESPACE_ROUTE,
metrics: [:latest_closed_by, :merged_by])
}
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
......
---
title: Allow users to get Merge Trains entries via Public API
merge_request: 25229
author:
type: added
# Merge Trains API **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/36146) in GitLab 12.9.
> - Using this API you can consume GitLab's [Merge Train](../ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md) entries.
Every API call to merge trains must be authenticated with Developer or higher [permissions](link-to-permissions-doc).
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.
If Merge Trains is not available for the project, a `403` status code will return.
## Merge Trains API pagination
By default, `GET` requests return 20 results at a time because the API results
are paginated.
Read more on [pagination](README.md#pagination).
## List Merge Trains for a project
Get all Merge Trains of the requested project:
```txt
GET /projects/:id/merge_trains
GET /projects/:id/merge_trains?scope=complete
```
| Attribute | Type | Required | Description |
| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding). |
| `scope` | string | no | Return Merge Trains filtered by the given scope. Available scopes are `active` (to be merged) and `complete` (have been merged). |
| `sort` | string | no | Return Merge Trains sorted in `asc` or `desc` order. Default is `desc`. |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" https://gitlab.example.com/api/v4/projects/1/merge_trains
```
Example response:
```json
[
{
"id": 110,
"merge_request": {
"id": 126,
"iid": 59,
"project_id": 20,
"title": "Test MR 1580978354",
"description": "",
"state": "merged",
"created_at": "2020-02-06T08:39:14.883Z",
"updated_at": "2020-02-06T08:40:57.038Z",
"web_url": "http://local.gitlab.test:8181/root/merge-train-race-condition/-/merge_requests/59"
},
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "http://local.gitlab.test:8181/root"
},
"pipeline": {
"id": 246,
"sha": "bcc17a8ffd51be1afe45605e714085df28b80b13",
"ref": "refs/merge-requests/59/train",
"status": "success",
"created_at": "2020-02-06T08:40:42.410Z",
"updated_at": "2020-02-06T08:40:46.912Z",
"web_url": "http://local.gitlab.test:8181/root/merge-train-race-condition/pipelines/246"
},
"created_at": "2020-02-06T08:39:47.217Z",
"updated_at": "2020-02-06T08:40:57.720Z",
"target_branch": "feature-1580973432",
"status": "merged",
"merged_at": "2020-02-06T08:40:57.719Z",
"duration": 70
}
]
```
# frozen_string_literal: true
class MergeTrainsFinder
attr_reader :project, :merge_trains, :params, :current_user
def initialize(project, current_user, params = {})
@project = project
@current_user = current_user
@merge_trains = project.merge_trains
@params = params
end
def execute
unless Ability.allowed?(current_user, :read_merge_train, project)
return MergeTrain.none
end
items = merge_trains
items = by_scope(items)
sort(items)
end
private
def by_scope(items)
case params[:scope]
when 'active'
items.active
when 'complete'
items.complete
else
items
end
end
def sort(items)
return items unless %w[asc desc].include?(params[:sort])
items.by_id(params[:sort].to_sym)
end
end
......@@ -68,7 +68,12 @@ class MergeTrain < ApplicationRecord
scope :active, -> { with_status(*ACTIVE_STATUSES) }
scope :complete, -> { with_status(*COMPLETE_STATUSES) }
scope :for_target, -> (project_id, branch) { where(target_project_id: project_id, target_branch: branch) }
scope :by_id, -> { order('merge_trains.id ASC') }
scope :by_id, -> (sort = :asc) { order(id: sort) }
scope :preload_api_entities, -> do
preload(:user, merge_request: MergeRequest::PROJECT_ROUTE_AND_NAMESPACE_ROUTE,
pipeline: Ci::Pipeline::PROJECT_ROUTE_AND_NAMESPACE_ROUTE)
end
class << self
def all_active_mrs_in_train(target_project_id, target_branch)
......
......@@ -187,6 +187,8 @@ module EE
rule { security_dashboard_enabled & can?(:developer_access) }.enable :read_vulnerability
rule { can?(:read_merge_request) & can?(:read_pipeline) }.enable :read_merge_train
rule { can?(:read_vulnerability) }.policy do
enable :read_project_security_dashboard
enable :create_vulnerability
......
# frozen_string_literal: true
module API
class MergeTrains < ::Grape::API
include PaginationParams
before do
service_unavailable! unless Feature.enabled?(:merge_trains_api, user_project, default_enabled: true)
authorize_read_merge_trains!
end
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
resource :merge_trains do
desc 'Get all merge trains of a project' do
detail 'This feature was introduced in GitLab 12.9'
success EE::API::Entities::MergeTrain
end
params do
optional :scope, type: String, desc: 'The scope of merge trains',
values: %w[active complete]
optional :sort, type: String, desc: 'Sort by asc (ascending) or desc (descending)',
values: %w[asc desc],
default: 'desc'
use :pagination
end
get do
merge_trains = ::MergeTrainsFinder
.new(user_project, current_user, declared_params(include_missing: false))
.execute
.preload_api_entities
present paginate(merge_trains), with: EE::API::Entities::MergeTrain
end
end
end
helpers do
def authorize_read_merge_trains!
authorize! :read_merge_train, user_project
end
end
end
end
......@@ -35,6 +35,7 @@ module EE
mount ::API::ConanPackages
mount ::API::MavenPackages
mount ::API::NpmPackages
mount ::API::MergeTrains
mount ::API::ProjectPackages
mount ::API::GroupPackages
mount ::API::GroupHooks
......
# frozen_string_literal: true
module EE
module API
module Entities
class MergeTrain < Grape::Entity
expose :id
expose :merge_request, using: ::API::Entities::MergeRequestSimple
expose :user, using: ::API::Entities::UserBasic
expose :pipeline, using: ::API::Entities::PipelineBasic
expose :created_at
expose :updated_at
expose :target_branch
expose :status_name, as: :status
expose :merged_at
expose :duration
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe MergeTrainsFinder do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:finder) { described_class.new(project, user, params) }
let(:user) { developer }
let(:params) { {} }
before_all do
project.add_developer(developer)
project.add_guest(guest)
end
describe '#execute' do
subject { finder.execute }
let!(:merge_train_1) { create(:merge_train, target_project: project) }
let!(:merge_train_2) { create(:merge_train, target_project: project) }
it 'returns merge trains ordered by id' do
is_expected.to eq([merge_train_1, merge_train_2])
end
context 'when sort is asc' do
let(:params) { { sort: 'asc' } }
it 'returns merge trains in ascending order' do
is_expected.to eq([merge_train_1, merge_train_2])
end
end
context 'when sort is asc' do
let(:params) { { sort: 'desc' } }
it 'returns merge trains in descending order' do
is_expected.to eq([merge_train_2, merge_train_1])
end
end
context 'when user is a guest' do
let(:user) { guest }
it 'returns an empty list' do
is_expected.to be_empty
end
end
context 'when scope is given' do
let!(:merge_train_1) { create(:merge_train, :idle, target_project: project) }
let!(:merge_train_2) { create(:merge_train, :merged, target_project: project) }
context 'when scope is active' do
let(:params) { { scope: 'active' } }
it 'returns active merge train' do
is_expected.to eq([merge_train_1])
end
end
context 'when scope is complete' do
let(:params) { { scope: 'complete' } }
it 'returns complete merge train' do
is_expected.to eq([merge_train_2])
end
end
end
end
end
{
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "integer" },
"merge_request": { "$ref": "../../../../../../../spec/fixtures/api/schemas/public_api/v4/merge_request_simple.json" },
"user": { "$ref": "../../../../../../../spec/fixtures/api/schemas/public_api/v4/user/basic.json" },
"pipeline": { "$ref": "./pipeline.json" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"target_branch": { "type": "string" },
"status": { "type": "string" },
"merged_at": { "type": ["date", "null"] },
"duration": { "type": ["integer", "null"] }
},
"additionalProperties": false
}
{
"type": "array",
"items": { "$ref": "./merge_train.json" }
}
......@@ -37,7 +37,7 @@ describe ProjectPolicy do
%i[
admin_vulnerability_feedback read_project_security_dashboard read_feature_flag
read_vulnerability create_vulnerability admin_vulnerability
admin_vulnerability_issue_link
admin_vulnerability_issue_link read_merge_train
]
end
let(:additional_maintainer_permissions) { %i[push_code_to_protected_branches admin_feature_flags_client] }
......@@ -52,7 +52,7 @@ describe ProjectPolicy do
create_merge_request_in award_emoji
read_project_security_dashboard read_vulnerability
read_vulnerability_feedback read_security_findings read_software_license_policy
read_threat_monitoring
read_threat_monitoring read_merge_train
]
end
......
# frozen_string_literal: true
require 'spec_helper'
describe API::MergeTrains do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
let(:user) { developer }
before_all do
project.add_developer(developer)
project.add_guest(guest)
end
describe 'GET /projects/:id/merge_trains' do
subject { get api("/projects/#{project.id}/merge_trains", user), params: params }
let(:params) { {} }
context 'when there are two merge trains' do
let_it_be(:merge_train_1) { create(:merge_train, :merged, target_project: project) }
let_it_be(:merge_train_2) { create(:merge_train, :idle, target_project: project) }
it 'returns merge trains sorted by id in descending order' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/merge_trains', dir: 'ee')
expect(json_response.count).to eq(2)
expect(json_response.first['id']).to eq(merge_train_2.id)
expect(json_response.second['id']).to eq(merge_train_1.id)
end
it 'does not have N+1 problem' do
control_count = ActiveRecord::QueryRecorder.new { subject }
create_list(:merge_train, 3, target_project: project)
expect { get api("/projects/#{project.id}/merge_trains", user) }
.not_to exceed_query_limit(control_count)
end
context 'when sort is specified' do
let(:params) { { sort: 'asc' } }
it 'returns merge trains sorted by id in ascending order' do
subject
expect(json_response.first['id']).to eq(merge_train_1.id)
expect(json_response.second['id']).to eq(merge_train_2.id)
end
end
context 'when scope is specified' do
context 'when scope is active' do
let(:params) { { scope: 'active' } }
it 'returns active merge trains' do
subject
expect(json_response.count).to eq(1)
expect(json_response.first['id']).to eq(merge_train_2.id)
end
end
context 'when scope is complete' do
let(:params) { { scope: 'complete' } }
it 'returns complete merge trains' do
subject
expect(json_response.count).to eq(1)
expect(json_response.first['id']).to eq(merge_train_1.id)
end
end
end
context 'when user is guest' do
let(:user) { guest }
it 'forbids the request' do
subject
expect(response).to have_gitlab_http_status(:forbidden)
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(merge_trains_api: false)
end
it 'forbids the request' do
subject
expect(response).to have_gitlab_http_status(:service_unavailable)
end
end
end
end
end
......@@ -359,6 +359,10 @@ module API
render_api_error!('405 Method Not Allowed', 405)
end
def service_unavailable!
render_api_error!('503 Service Unavailable', 503)
end
def conflict!(message = nil)
render_api_error!(message || '409 Conflict', 409)
end
......
{
"type": "object",
"properties" : {
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"project_id": { "type": "integer" },
"title": { "type": "string" },
"description": { "type": ["string", "null"] },
"state": { "type": "string" },
"created_at": { "type": "date" },
"updated_at": { "type": "date" },
"web_url": { "type": "uri" }
},
"required": [
"id", "iid", "project_id", "title", "description",
"state", "created_at", "updated_at", "web_url"
],
"head_pipeline": {
"oneOf": [
{ "type": "null" },
{ "$ref": "pipeline/detail.json" }
]
}
}
}
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