Commit d6a7a111 authored by Phil Hughes's avatar Phil Hughes

Merge branch '44579-ide-add-pipeline-to-status-bar' into 'master'

Resolve "Show CI pipeline status in Web IDE"

Closes #44579

See merge request gitlab-org/gitlab-ce!19048
parents de12348e c5389054
......@@ -21,6 +21,7 @@ const Api = {
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
usersPath: '/api/:version/users.json',
commitPath: '/api/:version/projects/:id/repository/commits',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
pipelinesPath: '/api/:version/projects/:id/pipelines',
......@@ -166,6 +167,19 @@ const Api = {
});
},
commitPipelines(projectId, sha) {
const encodedProjectId = projectId
.split('/')
.map(fragment => encodeURIComponent(fragment))
.join('/');
const url = Api.buildUrl(Api.commitPipelinesPath)
.replace(':project_id', encodedProjectId)
.replace(':sha', encodeURIComponent(sha));
return axios.get(url);
},
branchSingle(id, branch) {
const url = Api.buildUrl(Api.branchSinglePath)
.replace(':id', encodeURIComponent(id))
......
......@@ -123,8 +123,6 @@ export default {
</template>
</div>
</div>
<ide-status-bar
:file="activeFile"
/>
<ide-status-bar :file="activeFile"/>
</article>
</template>
<script>
import { mapGetters } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
components: {
icon,
userAvatarImage,
CiIcon,
},
directives: {
tooltip,
......@@ -27,8 +29,16 @@ export default {
};
},
computed: {
...mapState(['currentBranchId', 'currentProjectId']),
...mapGetters(['currentProject', 'lastCommit']),
},
watch: {
lastCommit() {
if (!this.isPollingInitialized) {
this.initPipelinePolling();
}
},
},
mounted() {
this.startTimer();
},
......@@ -36,13 +46,21 @@ export default {
if (this.intervalId) {
clearInterval(this.intervalId);
}
if (this.isPollingInitialized) {
this.stopPipelinePolling();
}
},
methods: {
...mapActions(['pipelinePoll', 'stopPipelinePolling']),
startTimer() {
this.intervalId = setInterval(() => {
this.commitAgeUpdate();
}, 1000);
},
initPipelinePolling() {
this.pipelinePoll();
this.isPollingInitialized = true;
},
commitAgeUpdate() {
if (this.lastCommit) {
this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date);
......@@ -61,6 +79,23 @@ export default {
class="ide-status-branch"
v-if="lastCommit && lastCommitFormatedAge"
>
<span
class="ide-status-pipeline"
v-if="lastCommit.pipeline && lastCommit.pipeline.details"
>
<ci-icon
:status="lastCommit.pipeline.details.status"
v-tooltip
:title="lastCommit.pipeline.details.status.text"
/>
Pipeline
<a
class="monospace"
:href="lastCommit.pipeline.details.status.details_path">#{{ lastCommit.pipeline.id }}</a>
{{ lastCommit.pipeline.details.status.text }}
for
</span>
<icon
name="commit"
/>
......
......@@ -75,4 +75,8 @@ export default {
},
});
},
lastCommitPipelines({ getters }) {
const commitSha = getters.lastCommit.id;
return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha);
},
};
import Visibility from 'visibilityjs';
import flash from '~/flash';
import { __ } from '~/locale';
import service from '../../services';
import * as types from '../mutation_types';
import Poll from '../../../lib/utils/poll';
let eTagPoll;
export const getProjectData = (
{ commit, state, dispatch },
......@@ -21,7 +26,7 @@ export const getProjectData = (
})
.catch(() => {
flash(
'Error loading project data. Please try again.',
__('Error loading project data. Please try again.'),
'alert',
document,
null,
......@@ -59,7 +64,7 @@ export const getBranchData = (
})
.catch(() => {
flash(
'Error loading branch data. Please try again.',
__('Error loading branch data. Please try again.'),
'alert',
document,
null,
......@@ -73,10 +78,8 @@ export const getBranchData = (
}
});
export const refreshLastCommitData = (
{ commit, state, dispatch },
{ projectId, branchId } = {},
) => service
export const refreshLastCommitData = ({ commit, state, dispatch }, { projectId, branchId } = {}) =>
service
.getBranchData(projectId, branchId)
.then(({ data }) => {
commit(types.SET_BRANCH_COMMIT, {
......@@ -86,12 +89,63 @@ export const refreshLastCommitData = (
});
})
.catch(() => {
flash(__('Error loading last commit.'), 'alert', document, null, false, true);
});
export const pollSuccessCallBack = ({ commit, state, dispatch }, { data }) => {
if (data.pipelines && data.pipelines.length) {
const lastCommitHash =
state.projects[state.currentProjectId].branches[state.currentBranchId].commit.id;
const lastCommitPipeline = data.pipelines.find(
pipeline => pipeline.commit.id === lastCommitHash,
);
commit(types.SET_LAST_COMMIT_PIPELINE, {
projectId: state.currentProjectId,
branchId: state.currentBranchId,
pipeline: lastCommitPipeline || {},
});
}
return data;
};
export const pipelinePoll = ({ getters, dispatch }) => {
eTagPoll = new Poll({
resource: service,
method: 'lastCommitPipelines',
data: {
getters,
},
successCallback: ({ data }) => dispatch('pollSuccessCallBack', { data }),
errorCallback: () => {
flash(
'Error loading last commit.',
__('Something went wrong while fetching the latest pipeline status.'),
'alert',
document,
null,
false,
true,
);
},
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const stopPipelinePolling = () => {
eTagPoll.stop();
};
export const restartPipelinePolling = () => {
eTagPoll.restart();
};
......@@ -23,6 +23,7 @@ export const SET_BRANCH = 'SET_BRANCH';
export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT';
export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
export const SET_LAST_COMMIT_PIPELINE = 'SET_LAST_COMMIT_PIPELINE';
// Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
......
......@@ -14,6 +14,10 @@ export default {
treeId: `${projectPath}/${branchName}`,
active: true,
workingReference: '',
commit: {
...branch.commit,
pipeline: {},
},
},
},
});
......@@ -28,4 +32,9 @@ export default {
commit,
});
},
[types.SET_LAST_COMMIT_PIPELINE](state, { projectId, branchId, pipeline }) {
Object.assign(state.projects[projectId].branches[branchId].commit, {
pipeline,
});
},
};
......@@ -230,7 +230,7 @@ $row-hover: $blue-50;
$row-hover-border: $blue-200;
$progress-color: #c0392b;
$header-height: 40px;
$ide-statusbar-height: 27px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$limited-layout-width-sm: 790px;
......
......@@ -22,7 +22,6 @@
height: calc(100vh - #{$header-height});
margin-top: 0;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
padding-bottom: $ide-statusbar-height;
&.is-collapsed {
......@@ -380,7 +379,7 @@
.ide-status-bar {
border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding;
padding: 2px $gl-padding-8 0;
background: $white-light;
display: flex;
justify-content: space-between;
......@@ -391,12 +390,19 @@
left: 0;
width: 100%;
font-size: 12px;
line-height: 22px;
* {
font-size: inherit;
}
> div + div {
padding-left: $gl-padding;
}
svg {
vertical-align: middle;
vertical-align: sub;
}
}
......
---
title: Add pipeline status to the status bar of the Web IDE
merge_request:
author:
type: added
......@@ -341,4 +341,25 @@ describe('Api', () => {
.catch(done.fail);
});
});
describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar';
const commitSha = 'abc123def';
const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`;
mock.onGet(expectedUrl).reply(200, [
{
name: 'test',
},
]);
Api.commitPipelines(projectId, commitSha)
.then(({ data }) => {
expect(data.length).toBe(1);
expect(data[0].name).toBe('test');
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -59,3 +59,37 @@ export const jobs = [
duration: 1,
},
];
export const fullPipelinesResponse = {
data: {
count: {
all: 2,
},
pipelines: [
{
id: '51',
commit: {
id: 'xxxxxxxxxxxxxxxxxxxx',
},
details: {
status: {
icon: 'status_failed',
text: 'failed',
},
},
},
{
id: '50',
commit: {
id: 'abc123def456ghi789jkl',
},
details: {
status: {
icon: 'status_passed',
text: 'passed',
},
},
},
],
},
};
import {
refreshLastCommitData,
} from '~/ide/stores/actions';
import Visibility from 'visibilityjs';
import MockAdapter from 'axios-mock-adapter';
import { refreshLastCommitData, pollSuccessCallBack } from '~/ide/stores/actions';
import store from '~/ide/stores';
import service from '~/ide/services';
import axios from '~/lib/utils/axios_utils';
import { fullPipelinesResponse } from '../../mock_data';
import { resetStore } from '../../helpers';
import testAction from '../../../helpers/vuex_action_helper';
describe('IDE store project actions', () => {
const setProjectState = () => {
store.state.currentProjectId = 'abc/def';
store.state.currentBranchId = 'master';
store.state.projects['abc/def'] = {
id: 4,
path_with_namespace: 'abc/def',
branches: {
master: {
commit: {
id: 'abc123def456ghi789jkl',
title: 'example',
},
},
},
};
};
beforeEach(() => {
store.state.projects.abcproject = {};
store.state.projects['abc/def'] = {};
});
afterEach(() => {
......@@ -17,18 +36,16 @@ describe('IDE store project actions', () => {
describe('refreshLastCommitData', () => {
beforeEach(() => {
store.state.currentProjectId = 'abcproject';
store.state.currentProjectId = 'abc/def';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = {
store.state.projects['abc/def'] = {
id: 4,
branches: {
master: {
commit: null,
},
},
};
});
it('calls the service', done => {
spyOn(service, 'getBranchData').and.returnValue(
Promise.resolve({
data: {
......@@ -36,14 +53,16 @@ describe('IDE store project actions', () => {
},
}),
);
});
it('calls the service', done => {
store
.dispatch('refreshLastCommitData', {
projectId: store.state.currentProjectId,
branchId: store.state.currentBranchId,
})
.then(() => {
expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master');
expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'master');
done();
})
......@@ -53,16 +72,118 @@ describe('IDE store project actions', () => {
it('commits getBranchData', done => {
testAction(
refreshLastCommitData,
{},
{},
[{
{
projectId: store.state.currentProjectId,
branchId: store.state.currentBranchId,
},
store.state,
[
{
type: 'SET_BRANCH_COMMIT',
payload: {
projectId: 'abcproject',
projectId: 'abc/def',
branchId: 'master',
commit: { id: '123' },
},
}], // mutations
},
], // mutations
[
{
type: 'getLastCommitPipeline',
payload: {
projectId: 'abc/def',
projectIdNumber: store.state.projects['abc/def'].id,
branchId: 'master',
},
},
], // action
done,
);
});
});
describe('pipelinePoll', () => {
let mock;
beforeEach(() => {
setProjectState();
jasmine.clock().install();
mock = new MockAdapter(axios);
mock
.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines')
.reply(200, { data: { foo: 'bar' } }, { 'poll-interval': '10000' });
});
afterEach(() => {
jasmine.clock().uninstall();
mock.restore();
store.dispatch('stopPipelinePolling');
});
it('calls service periodically', done => {
spyOn(axios, 'get').and.callThrough();
spyOn(Visibility, 'hidden').and.returnValue(false);
store
.dispatch('pipelinePoll')
.then(() => {
jasmine.clock().tick(1000);
expect(axios.get).toHaveBeenCalled();
expect(axios.get.calls.count()).toBe(1);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
jasmine.clock().tick(10000);
expect(axios.get.calls.count()).toBe(2);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
jasmine.clock().tick(10000);
expect(axios.get.calls.count()).toBe(3);
})
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
jasmine.clock().tick(10000);
expect(axios.get.calls.count()).toBe(4);
})
.then(done)
.catch(done.fail);
});
});
describe('pollSuccessCallBack', () => {
beforeEach(() => {
setProjectState();
});
it('commits correct pipeline', done => {
testAction(
pollSuccessCallBack,
fullPipelinesResponse,
store.state,
[
{
type: 'SET_LAST_COMMIT_PIPELINE',
payload: {
projectId: 'abc/def',
branchId: 'master',
pipeline: {
id: '50',
commit: {
id: 'abc123def456ghi789jkl',
},
details: {
status: {
icon: 'status_passed',
text: 'passed',
},
},
},
},
},
], // mutations
[], // action
done,
);
......
......@@ -37,4 +37,40 @@ describe('Multi-file store branch mutations', () => {
expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit');
});
});
describe('SET_LAST_COMMIT_PIPELINE', () => {
it('sets the pipeline for the last commit on current project', () => {
localState.projects = {
Example: {
branches: {
master: {
commit: {},
},
},
},
};
mutations.SET_LAST_COMMIT_PIPELINE(localState, {
projectId: 'Example',
branchId: 'master',
pipeline: {
id: '50',
details: {
status: {
icon: 'status_passed',
text: 'passed',
},
},
},
});
expect(localState.projects.Example.branches.master.commit.pipeline.id).toBe('50');
expect(localState.projects.Example.branches.master.commit.pipeline.details.status.text).toBe(
'passed',
);
expect(localState.projects.Example.branches.master.commit.pipeline.details.status.icon).toBe(
'status_passed',
);
});
});
});
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