Commit 4faccf96 authored by Filipa Lacerda's avatar Filipa Lacerda Committed by Kushal Pandya

Enables Run Pipeline button to be rendered

In the Merge Request view, under pipelines tab
the user can see a run pipeline button

Adds axios post request to button click

Adds the logic to handle the user click,
refresh the table and disable the button while thee
request is being made

Updates UI for desktop and mobile

Adds specs
Regenerates potfile

Follow-up after review

Uses .finally to avoid code repetition
parent 0de02f82
......@@ -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.
......
......@@ -319,6 +319,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
......
......@@ -1498,6 +1498,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 unsubscribing to notifications."
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