Commit af03cb06 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '65940-run-pipeline' into 'master'

Resolve "Run pipeline button for "Pipelines for merge requests""

Closes #65940

See merge request gitlab-org/gitlab-ce!31722
parents bfaa96d5 48b98b58
......@@ -36,6 +36,7 @@ const Api = {
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/projects/:id/releases',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: 'api/:version/application/statistics',
group(groupId, callback) {
......@@ -371,6 +372,14 @@ const Api = {
});
},
postMergeRequestPipeline(id, { mergeRequestId }) {
const url = Api.buildUrl(this.mergeRequestsPipeline)
.replace(':id', encodeURIComponent(id))
.replace(':merge_request_iid', mergeRequestId);
return axios.post(url);
},
releases(id) {
const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id));
......
<script>
import PipelinesService from '../../pipelines/services/pipelines_service';
import PipelineStore from '../../pipelines/stores/pipelines_store';
import pipelinesMixin from '../../pipelines/mixins/pipelines';
import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import pipelinesMixin from '~/pipelines/mixins/pipelines';
import eventHub from '~/pipelines/event_hub';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { getParameterByName } from '~/lib/utils/common_utils';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
import bp from '~/breakpoints';
export default {
components: {
TablePagination,
GlButton,
GlLoadingIcon,
},
mixins: [pipelinesMixin, CIPaginationMixin],
props: {
......@@ -33,6 +38,21 @@ export default {
required: false,
default: 'child',
},
canRunPipeline: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: String,
required: false,
default: '',
},
mergeRequestId: {
type: Number,
required: false,
default: 0,
},
},
data() {
......@@ -53,6 +73,41 @@ export default {
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
/**
* The Run Pipeline button can only be rendered when:
* - In MR view - we use `canRunPipeline` for that purpose
* - If the latest pipeline has the `detached_merge_request_pipeline` flag
*
* @returns {Boolean}
*/
canRenderPipelineButton() {
return this.canRunPipeline && this.latestPipelineDetachedFlag;
},
/**
* Checks if either `detached_merge_request_pipeline` or
* `merge_request_pipeline` are tru in the first
* object in the pipelines array.
*
* @returns {Boolean}
*/
latestPipelineDetachedFlag() {
const latest = this.state.pipelines[0];
return (
latest &&
latest.flags &&
(latest.flags.detached_merge_request_pipeline || latest.flags.merge_request_pipeline)
);
},
/**
* When we are on Desktop and the button is visible
* we need to add a negative margin to the table
* to make it inline with the button
*
* @returns {Boolean}
*/
shouldAddNegativeMargin() {
return this.canRenderPipelineButton && bp.isDesktop();
},
},
created() {
this.service = new PipelinesService(this.endpoint);
......@@ -77,6 +132,22 @@ export default {
this.$el.parentElement.dispatchEvent(updatePipelinesEvent);
}
},
/**
* When the user clicks on the Run Pipeline button
* we need to make a post request and
* to update the table content once the request is finished.
*
* We are emitting an event through the eventHub using the old pattern
* to make use of the code in mixins/pipelines.js that handles all the
* table events
*
*/
onClickRunPipeline() {
eventHub.$emit('runMergeRequestPipeline', {
projectId: this.projectId,
mergeRequestId: this.mergeRequestId,
});
},
},
};
</script>
......@@ -99,11 +170,25 @@ export default {
/>
<div v-else-if="shouldRenderTable" class="table-holder">
<div v-if="canRenderPipelineButton" class="nav justify-content-end">
<gl-button
v-if="canRenderPipelineButton"
variant="success"
class="js-run-mr-pipeline prepend-top-10 btn-wide-on-xs"
:disabled="state.isRunningMergeRequestPipeline"
@click="onClickRunPipeline"
>
<gl-loading-icon v-if="state.isRunningMergeRequestPipeline" inline />
{{ s__('Pipelines|Run Pipeline') }}
</gl-button>
</div>
<pipelines-table-component
:pipelines="state.pipelines"
:update-graph-dropdown="updateGraphDropdown"
:auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
:class="{ 'negative-margin-top': shouldAddNegativeMargin }"
/>
</div>
......
......@@ -333,7 +333,8 @@ export default class MergeRequestTabs {
mountPipelinesView() {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
const { CommitPipelinesTable } = gl;
const { CommitPipelinesTable, mrWidgetData } = gl;
this.commitPipelinesTable = new CommitPipelinesTable({
propsData: {
endpoint: pipelineTableViewEl.dataset.endpoint,
......@@ -341,6 +342,9 @@ export default class MergeRequestTabs {
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
canRunPipeline: true,
projectId: pipelineTableViewEl.dataset.projectId,
mergeRequestId: mrWidgetData ? mrWidgetData.iid : null,
},
}).$mount();
......
import Visibility from 'visibilityjs';
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '../../locale';
import Flash from '../../flash';
import createFlash from '../../flash';
import Poll from '../../lib/utils/poll';
import EmptyState from '../components/empty_state.vue';
import SvgBlankState from '../components/blank_state.vue';
......@@ -62,6 +62,7 @@ export default {
eventHub.$on('clickedDropdown', this.updateTable);
eventHub.$on('updateTable', this.updateTable);
eventHub.$on('refreshPipelinesTable', this.fetchPipelines);
eventHub.$on('runMergeRequestPipeline', this.runMergeRequestPipeline);
},
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
......@@ -69,6 +70,7 @@ export default {
eventHub.$off('clickedDropdown', this.updateTable);
eventHub.$off('updateTable', this.updateTable);
eventHub.$off('refreshPipelinesTable', this.fetchPipelines);
eventHub.$off('runMergeRequestPipeline', this.runMergeRequestPipeline);
},
destroyed() {
this.poll.stop();
......@@ -110,7 +112,7 @@ export default {
// Stop polling
this.poll.stop();
// Restarting the poll also makes an initial request
this.poll.restart();
return this.poll.restart();
},
fetchPipelines() {
if (!this.isMakingRequest) {
......@@ -156,7 +158,31 @@ export default {
this.service
.postAction(endpoint)
.then(() => this.updateTable())
.catch(() => Flash(__('An error occurred while making the request.')));
.catch(() => createFlash(__('An error occurred while making the request.')));
},
/**
* When the user clicks on the run pipeline button
* we toggle the state of the button to be disabled
*
* Once the post request has finished, we fetch the
* pipelines again to show the most recent data
*
* Once the pipeline has been updated, we toggle back the
* loading state and re-enable the run pipeline button
*/
runMergeRequestPipeline(options) {
this.store.toggleIsRunningPipeline(true);
this.service
.runMRPipeline(options)
.then(() => this.updateTable())
.catch(() => {
createFlash(
__('An error occurred while trying to run a new pipeline for this Merge Request.'),
);
})
.finally(() => this.store.toggleIsRunningPipeline(false));
},
},
};
import axios from '../../lib/utils/axios_utils';
import Api from '~/api';
export default class PipelinesService {
/**
......@@ -39,4 +40,9 @@ export default class PipelinesService {
postAction(endpoint) {
return axios.post(`${endpoint}.json`);
}
// eslint-disable-next-line class-methods-use-this
runMRPipeline({ projectId, mergeRequestId }) {
return Api.postMergeRequestPipeline(projectId, { mergeRequestId });
}
}
......@@ -7,6 +7,9 @@ export default class PipelinesStore {
this.state.pipelines = [];
this.state.count = {};
this.state.pageInfo = {};
// Used in MR Pipelines tab
this.state.isRunningMergeRequestPipeline = false;
}
storePipelines(pipelines = []) {
......@@ -29,4 +32,13 @@ export default class PipelinesStore {
this.state.pageInfo = paginationInfo;
}
/**
* Toggles the isRunningPipeline flag
*
* @param {Boolean} value
*/
toggleIsRunningPipeline(value = false) {
this.state.isRunningMergeRequestPipeline = value;
}
}
......@@ -726,6 +726,7 @@ $pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
$ci-action-dropdown-button-size: 24px;
$ci-action-dropdown-svg-size: 12px;
$pipelines-table-header-height: 40px;
/*
CI variable lists
......
......@@ -26,6 +26,10 @@
}
.pipelines {
.negative-margin-top {
margin-top: -$pipelines-table-header-height;
}
.stage {
max-width: 90px;
width: 90px;
......
......@@ -5,4 +5,5 @@
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
"project-id": @project.id,
} }
---
title: Run Pipeline button & API for MR Pipelines
merge_request: 31722
author:
type: added
......@@ -821,6 +821,66 @@ Parameters:
]
```
## Create MR Pipeline
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/31722) in Gitlab 12.3.
Create a new [pipeline for a merge request](../ci/merge_request_pipelines/index.md). A pipeline created via this endpoint will not run a regular branch/tag pipeline, it requires `.gitlab-ci.yml` to be configured with `only: [merge_requests]` to create jobs.
The new pipeline can be:
- A detached merge request pipeline.
- A [pipeline for merged results](../ci/merge_request_pipelines/pipelines_for_merged_results/index.md)
if the [project setting is enabled](../ci/merge_request_pipelines/pipelines_for_merged_results/index.md#enabling-pipelines-for-merged-results).
```
POST /projects/:id/merge_requests/:merge_request_iid/pipelines
```
Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding)
- `merge_request_iid` (required) - The internal ID of the merge request
```json
{
"id": 2,
"sha": "b83d6e391c22777fca1ed3012fce84f633d7fed0",
"ref": "refs/merge-requests/1/head",
"status": "pending",
"web_url": "http://localhost/user1/project1/pipelines/2",
"before_sha": "0000000000000000000000000000000000000000",
"tag": false,
"yaml_errors": null,
"user": {
"id": 1,
"name": "John Doe1",
"username": "user1",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/c922747a93b40d1ea88262bf1aebee62?s=80&d=identicon",
"web_url": "http://example.com"
},
"created_at": "2019-09-04T19:20:18.267Z",
"updated_at": "2019-09-04T19:20:18.459Z",
"started_at": null,
"finished_at": null,
"committed_at": null,
"duration": null,
"coverage": null,
"detailed_status": {
"icon": "status_pending",
"text": "pending",
"label": "pending",
"group": "pending",
"tooltip": "pending",
"has_details": false,
"details_path": "/user1/project1/pipelines/2",
"illustration": null,
"favicon": "/assets/ci_favicons/favicon_status_pending-5bdf338420e5221ca24353b6bff1c9367189588750632e9a871b7af09ff6a2ae.png"
}
}
```
## Create MR
Creates a new merge request.
......
......@@ -317,6 +317,26 @@ module API
present paginate(pipelines), with: Entities::PipelineBasic
end
desc 'Create a pipeline for merge request' do
success Entities::Pipeline
end
post ':id/merge_requests/:merge_request_iid/pipelines' do
authorize! :create_pipeline, user_project
pipeline = ::MergeRequests::CreatePipelineService
.new(user_project, current_user, allow_duplicate: true)
.execute(find_merge_request_with_access(params[:merge_request_iid]))
if pipeline.nil?
not_allowed!
elsif pipeline.persisted?
status :ok
present pipeline, with: Entities::Pipeline
else
render_validation_error!(pipeline)
end
end
desc 'Update a merge request' do
success Entities::MergeRequest
end
......
......@@ -1146,6 +1146,9 @@ msgstr ""
msgid "An error occurred while triggering the job."
msgstr ""
msgid "An error occurred while trying to run a new pipeline for this Merge Request."
msgstr ""
msgid "An error occurred while validating username"
msgstr ""
......
......@@ -45,6 +45,38 @@ describe 'Merge request > User sees pipelines', :js do
expect(page.find('.ci-widget')).to have_text("Could not retrieve the pipeline status. For troubleshooting steps, read the documentation.")
end
context 'with a detached merge request pipeline' do
let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) }
it 'displays the Run Pipeline button' do
visit project_merge_request_path(project, merge_request)
page.within('.merge-request-tabs') do
click_link('Pipelines')
end
wait_for_requests
expect(page.find('.js-run-mr-pipeline')).to have_text('Run Pipeline')
end
end
context 'with a merged results pipeline' do
let(:merge_request) { create(:merge_request, :with_merge_request_pipeline) }
it 'displays the Run Pipeline button' do
visit project_merge_request_path(project, merge_request)
page.within('.merge-request-tabs') do
click_link('Pipelines')
end
wait_for_requests
expect(page.find('.js-run-mr-pipeline')).to have_text('Run Pipeline')
end
end
end
context 'without pipelines' do
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Api from '~/api';
import pipelinesTable from '~/commit/pipelines/pipelines_table.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
......@@ -10,6 +11,13 @@ describe('Pipelines table in Commits and Merge requests', function() {
let PipelinesTable;
let mock;
let vm;
const props = {
endpoint: 'endpoint.json',
helpPagePath: 'foo',
emptyStateSvgPath: 'foo',
errorStateSvgPath: 'foo',
autoDevopsHelpPath: 'foo',
};
preloadFixtures(jsonFixtureName);
......@@ -32,13 +40,7 @@ describe('Pipelines table in Commits and Merge requests', function() {
beforeEach(function() {
mock.onGet('endpoint.json').reply(200, []);
vm = mountComponent(PipelinesTable, {
endpoint: 'endpoint.json',
helpPagePath: 'foo',
emptyStateSvgPath: 'foo',
errorStateSvgPath: 'foo',
autoDevopsHelpPath: 'foo',
});
vm = mountComponent(PipelinesTable, props);
});
it('should render the empty state', function(done) {
......@@ -54,13 +56,7 @@ describe('Pipelines table in Commits and Merge requests', function() {
describe('with pipelines', () => {
beforeEach(() => {
mock.onGet('endpoint.json').reply(200, [pipeline]);
vm = mountComponent(PipelinesTable, {
endpoint: 'endpoint.json',
helpPagePath: 'foo',
emptyStateSvgPath: 'foo',
errorStateSvgPath: 'foo',
autoDevopsHelpPath: 'foo',
});
vm = mountComponent(PipelinesTable, props);
});
it('should render a table with the received pipelines', done => {
......@@ -111,30 +107,145 @@ describe('Pipelines table in Commits and Merge requests', function() {
done();
});
vm = mountComponent(PipelinesTable, {
endpoint: 'endpoint.json',
helpPagePath: 'foo',
emptyStateSvgPath: 'foo',
errorStateSvgPath: 'foo',
autoDevopsHelpPath: 'foo',
});
vm = mountComponent(PipelinesTable, props);
element.appendChild(vm.$el);
});
});
});
describe('run pipeline button', () => {
let pipelineCopy;
beforeEach(() => {
pipelineCopy = Object.assign({}, pipeline);
});
describe('when latest pipeline has detached flag and canRunPipeline is true', () => {
it('renders the run pipeline button', done => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
pipelineCopy.flags.merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
vm = mountComponent(
PipelinesTable,
Object.assign({}, props, {
canRunPipeline: true,
}),
);
setTimeout(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull();
done();
});
});
});
describe('when latest pipeline has detached flag and canRunPipeline is false', () => {
it('does not render the run pipeline button', done => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
pipelineCopy.flags.merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
vm = mountComponent(
PipelinesTable,
Object.assign({}, props, {
canRunPipeline: false,
}),
);
setTimeout(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
done();
});
});
});
describe('when latest pipeline does not have detached flag and canRunPipeline is true', () => {
it('does not render the run pipeline button', done => {
pipelineCopy.flags.detached_merge_request_pipeline = false;
pipelineCopy.flags.merge_request_pipeline = false;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
vm = mountComponent(
PipelinesTable,
Object.assign({}, props, {
canRunPipeline: true,
}),
);
setTimeout(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
done();
});
});
});
describe('when latest pipeline does not have detached flag and merge_request_pipeline is true', () => {
it('does not render the run pipeline button', done => {
pipelineCopy.flags.detached_merge_request_pipeline = false;
pipelineCopy.flags.merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
vm = mountComponent(
PipelinesTable,
Object.assign({}, props, {
canRunPipeline: false,
}),
);
setTimeout(() => {
expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull();
done();
});
});
});
describe('on click', () => {
beforeEach(() => {
pipelineCopy.flags.detached_merge_request_pipeline = true;
mock.onGet('endpoint.json').reply(200, [pipelineCopy]);
vm = mountComponent(
PipelinesTable,
Object.assign({}, props, {
canRunPipeline: true,
projectId: '5',
mergeRequestId: 3,
}),
);
});
it('updates the loading state', done => {
spyOn(Api, 'postMergeRequestPipeline').and.returnValue(Promise.resolve());
setTimeout(() => {
vm.$el.querySelector('.js-run-mr-pipeline').click();
vm.$nextTick(() => {
expect(vm.state.isRunningMergeRequestPipeline).toBe(true);
setTimeout(() => {
expect(vm.state.isRunningMergeRequestPipeline).toBe(false);
done();
});
});
});
});
});
});
describe('unsuccessfull request', () => {
beforeEach(() => {
mock.onGet('endpoint.json').reply(500, []);
vm = mountComponent(PipelinesTable, {
endpoint: 'endpoint.json',
helpPagePath: 'foo',
emptyStateSvgPath: 'foo',
errorStateSvgPath: 'foo',
autoDevopsHelpPath: 'foo',
});
vm = mountComponent(PipelinesTable, props);
});
it('should render error state', function(done) {
......
......@@ -579,14 +579,22 @@ describe Ci::Pipeline, :mailer do
end
describe 'Validations for merge request pipelines' do
let(:pipeline) { build(:ci_pipeline, source: source, merge_request: merge_request) }
let(:pipeline) do
build(:ci_pipeline, source: source, merge_request: merge_request)
end
let(:merge_request) do
create(:merge_request,
source_project: project,
source_branch: 'feature',
target_project: project,
target_branch: 'master')
end
context 'when source is merge request' do
let(:source) { :merge_request_event }
context 'when merge request is specified' do
let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_project: project, target_branch: 'master') }
it { expect(pipeline).to be_valid }
end
......@@ -601,8 +609,6 @@ describe Ci::Pipeline, :mailer do
let(:source) { :web }
context 'when merge request is specified' do
let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_project: project, target_branch: 'master') }
it { expect(pipeline).not_to be_valid }
end
......
......@@ -1033,6 +1033,70 @@ describe API::MergeRequests do
end
end
describe 'POST /projects/:id/merge_requests/:merge_request_iid/pipelines' do
before do
allow_any_instance_of(Ci::Pipeline)
.to receive(:ci_yaml_file)
.and_return(YAML.dump({
rspec: {
script: 'ls',
only: ['merge_requests']
}
}))
end
let(:project) do
create(:project, :private, :repository,
creator: user,
namespace: user.namespace,
only_allow_merge_if_pipeline_succeeds: false)
end
let(:merge_request) do
create(:merge_request, :with_detached_merge_request_pipeline,
milestone: milestone1,
author: user,
assignees: [user],
source_project: project,
target_project: project,
title: 'Test',
created_at: base_time)
end
let(:merge_request_iid) { merge_request.iid }
let(:authenticated_user) { user }
let(:request) do
post api("/projects/#{project.id}/merge_requests/#{merge_request_iid}/pipelines", authenticated_user)
end
context 'when authorized' do
it 'creates and returns the new Pipeline' do
expect { request }.to change(Ci::Pipeline, :count).by(1)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_a Hash
end
end
context 'when unauthorized' do
let(:authenticated_user) { create(:user) }
it 'responds with a blank 404' do
expect { request }.not_to change(Ci::Pipeline, :count)
expect(response).to have_gitlab_http_status(404)
end
end
context 'when the merge request does not exist' do
let(:merge_request_iid) { 777 }
it 'responds with a blank 404' do
expect { request }.not_to change(Ci::Pipeline, :count)
expect(response).to have_gitlab_http_status(404)
end
end
end
describe 'POST /projects/:id/merge_requests' do
context 'support for deprecated assignee_id' do
let(:params) do
......
......@@ -38,6 +38,10 @@ describe MergeRequests::CreatePipelineService do
expect(subject).to be_detached_merge_request_pipeline
end
it 'defaults to merge_request_event' do
expect(subject.source).to eq('merge_request_event')
end
context 'when service is called multiple times' do
it 'creates a pipeline once' do
expect do
......
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