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 = { ...@@ -21,6 +21,7 @@ const Api = {
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
usersPath: '/api/:version/users.json', usersPath: '/api/:version/users.json',
commitPath: '/api/:version/projects/:id/repository/commits', commitPath: '/api/:version/projects/:id/repository/commits',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches', createBranchPath: '/api/:version/projects/:id/repository/branches',
pipelinesPath: '/api/:version/projects/:id/pipelines', pipelinesPath: '/api/:version/projects/:id/pipelines',
...@@ -166,6 +167,19 @@ const Api = { ...@@ -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) { branchSingle(id, branch) {
const url = Api.buildUrl(Api.branchSinglePath) const url = Api.buildUrl(Api.branchSinglePath)
.replace(':id', encodeURIComponent(id)) .replace(':id', encodeURIComponent(id))
......
...@@ -123,8 +123,6 @@ export default { ...@@ -123,8 +123,6 @@ export default {
</template> </template>
</div> </div>
</div> </div>
<ide-status-bar <ide-status-bar :file="activeFile"/>
:file="activeFile"
/>
</article> </article>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import timeAgoMixin from '~/vue_shared/mixins/timeago'; 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'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
export default { export default {
components: { components: {
icon, icon,
userAvatarImage, userAvatarImage,
CiIcon,
}, },
directives: { directives: {
tooltip, tooltip,
...@@ -27,8 +29,16 @@ export default { ...@@ -27,8 +29,16 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['currentBranchId', 'currentProjectId']),
...mapGetters(['currentProject', 'lastCommit']), ...mapGetters(['currentProject', 'lastCommit']),
}, },
watch: {
lastCommit() {
if (!this.isPollingInitialized) {
this.initPipelinePolling();
}
},
},
mounted() { mounted() {
this.startTimer(); this.startTimer();
}, },
...@@ -36,13 +46,21 @@ export default { ...@@ -36,13 +46,21 @@ export default {
if (this.intervalId) { if (this.intervalId) {
clearInterval(this.intervalId); clearInterval(this.intervalId);
} }
if (this.isPollingInitialized) {
this.stopPipelinePolling();
}
}, },
methods: { methods: {
...mapActions(['pipelinePoll', 'stopPipelinePolling']),
startTimer() { startTimer() {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.commitAgeUpdate(); this.commitAgeUpdate();
}, 1000); }, 1000);
}, },
initPipelinePolling() {
this.pipelinePoll();
this.isPollingInitialized = true;
},
commitAgeUpdate() { commitAgeUpdate() {
if (this.lastCommit) { if (this.lastCommit) {
this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date); this.lastCommitFormatedAge = this.timeFormated(this.lastCommit.committed_date);
...@@ -61,6 +79,23 @@ export default { ...@@ -61,6 +79,23 @@ export default {
class="ide-status-branch" class="ide-status-branch"
v-if="lastCommit && lastCommitFormatedAge" 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 <icon
name="commit" name="commit"
/> />
......
...@@ -75,4 +75,8 @@ export default { ...@@ -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 flash from '~/flash';
import { __ } from '~/locale';
import service from '../../services'; import service from '../../services';
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import Poll from '../../../lib/utils/poll';
let eTagPoll;
export const getProjectData = ( export const getProjectData = (
{ commit, state, dispatch }, { commit, state, dispatch },
...@@ -21,7 +26,7 @@ export const getProjectData = ( ...@@ -21,7 +26,7 @@ export const getProjectData = (
}) })
.catch(() => { .catch(() => {
flash( flash(
'Error loading project data. Please try again.', __('Error loading project data. Please try again.'),
'alert', 'alert',
document, document,
null, null,
...@@ -59,7 +64,7 @@ export const getBranchData = ( ...@@ -59,7 +64,7 @@ export const getBranchData = (
}) })
.catch(() => { .catch(() => {
flash( flash(
'Error loading branch data. Please try again.', __('Error loading branch data. Please try again.'),
'alert', 'alert',
document, document,
null, null,
...@@ -73,25 +78,74 @@ export const getBranchData = ( ...@@ -73,25 +78,74 @@ export const getBranchData = (
} }
}); });
export const refreshLastCommitData = ( export const refreshLastCommitData = ({ commit, state, dispatch }, { projectId, branchId } = {}) =>
{ commit, state, dispatch }, service
{ projectId, branchId } = {}, .getBranchData(projectId, branchId)
) => service .then(({ data }) => {
.getBranchData(projectId, branchId) commit(types.SET_BRANCH_COMMIT, {
.then(({ data }) => { projectId,
commit(types.SET_BRANCH_COMMIT, { branchId,
projectId, commit: data.commit,
branchId, });
commit: data.commit, })
.catch(() => {
flash(__('Error loading last commit.'), 'alert', document, null, false, true);
}); });
})
.catch(() => { export const pollSuccessCallBack = ({ commit, state, dispatch }, { data }) => {
flash( if (data.pipelines && data.pipelines.length) {
'Error loading last commit.', const lastCommitHash =
'alert', state.projects[state.currentProjectId].branches[state.currentBranchId].commit.id;
document, const lastCommitPipeline = data.pipelines.find(
null, pipeline => pipeline.commit.id === lastCommitHash,
false,
true,
); );
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(
__('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'; ...@@ -23,6 +23,7 @@ export const SET_BRANCH = 'SET_BRANCH';
export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT'; export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT';
export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
export const SET_LAST_COMMIT_PIPELINE = 'SET_LAST_COMMIT_PIPELINE';
// Tree mutation types // Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
......
...@@ -14,6 +14,10 @@ export default { ...@@ -14,6 +14,10 @@ export default {
treeId: `${projectPath}/${branchName}`, treeId: `${projectPath}/${branchName}`,
active: true, active: true,
workingReference: '', workingReference: '',
commit: {
...branch.commit,
pipeline: {},
},
}, },
}, },
}); });
...@@ -28,4 +32,9 @@ export default { ...@@ -28,4 +32,9 @@ export default {
commit, 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; ...@@ -230,7 +230,7 @@ $row-hover: $blue-50;
$row-hover-border: $blue-200; $row-hover-border: $blue-200;
$progress-color: #c0392b; $progress-color: #c0392b;
$header-height: 40px; $header-height: 40px;
$ide-statusbar-height: 27px; $ide-statusbar-height: 25px;
$fixed-layout-width: 1280px; $fixed-layout-width: 1280px;
$limited-layout-width: 990px; $limited-layout-width: 990px;
$limited-layout-width-sm: 790px; $limited-layout-width-sm: 790px;
......
...@@ -22,7 +22,6 @@ ...@@ -22,7 +22,6 @@
height: calc(100vh - #{$header-height}); height: calc(100vh - #{$header-height});
margin-top: 0; margin-top: 0;
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
padding-bottom: $ide-statusbar-height; padding-bottom: $ide-statusbar-height;
&.is-collapsed { &.is-collapsed {
...@@ -380,7 +379,7 @@ ...@@ -380,7 +379,7 @@
.ide-status-bar { .ide-status-bar {
border-top: 1px solid $white-dark; border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding; padding: 2px $gl-padding-8 0;
background: $white-light; background: $white-light;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
...@@ -391,12 +390,19 @@ ...@@ -391,12 +390,19 @@
left: 0; left: 0;
width: 100%; width: 100%;
font-size: 12px;
line-height: 22px;
* {
font-size: inherit;
}
> div + div { > div + div {
padding-left: $gl-padding; padding-left: $gl-padding;
} }
svg { 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', () => { ...@@ -341,4 +341,25 @@ describe('Api', () => {
.catch(done.fail); .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 = [ ...@@ -59,3 +59,37 @@ export const jobs = [
duration: 1, 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 { import Visibility from 'visibilityjs';
refreshLastCommitData, import MockAdapter from 'axios-mock-adapter';
} from '~/ide/stores/actions'; import { refreshLastCommitData, pollSuccessCallBack } from '~/ide/stores/actions';
import store from '~/ide/stores'; import store from '~/ide/stores';
import service from '~/ide/services'; import service from '~/ide/services';
import axios from '~/lib/utils/axios_utils';
import { fullPipelinesResponse } from '../../mock_data';
import { resetStore } from '../../helpers'; import { resetStore } from '../../helpers';
import testAction from '../../../helpers/vuex_action_helper'; import testAction from '../../../helpers/vuex_action_helper';
describe('IDE store project actions', () => { 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(() => { beforeEach(() => {
store.state.projects.abcproject = {}; store.state.projects['abc/def'] = {};
}); });
afterEach(() => { afterEach(() => {
...@@ -17,18 +36,16 @@ describe('IDE store project actions', () => { ...@@ -17,18 +36,16 @@ describe('IDE store project actions', () => {
describe('refreshLastCommitData', () => { describe('refreshLastCommitData', () => {
beforeEach(() => { beforeEach(() => {
store.state.currentProjectId = 'abcproject'; store.state.currentProjectId = 'abc/def';
store.state.currentBranchId = 'master'; store.state.currentBranchId = 'master';
store.state.projects.abcproject = { store.state.projects['abc/def'] = {
id: 4,
branches: { branches: {
master: { master: {
commit: null, commit: null,
}, },
}, },
}; };
});
it('calls the service', done => {
spyOn(service, 'getBranchData').and.returnValue( spyOn(service, 'getBranchData').and.returnValue(
Promise.resolve({ Promise.resolve({
data: { data: {
...@@ -36,14 +53,16 @@ describe('IDE store project actions', () => { ...@@ -36,14 +53,16 @@ describe('IDE store project actions', () => {
}, },
}), }),
); );
});
it('calls the service', done => {
store store
.dispatch('refreshLastCommitData', { .dispatch('refreshLastCommitData', {
projectId: store.state.currentProjectId, projectId: store.state.currentProjectId,
branchId: store.state.currentBranchId, branchId: store.state.currentBranchId,
}) })
.then(() => { .then(() => {
expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'master');
done(); done();
}) })
...@@ -53,16 +72,118 @@ describe('IDE store project actions', () => { ...@@ -53,16 +72,118 @@ describe('IDE store project actions', () => {
it('commits getBranchData', done => { it('commits getBranchData', done => {
testAction( testAction(
refreshLastCommitData, refreshLastCommitData,
{}, {
{}, projectId: store.state.currentProjectId,
[{ branchId: store.state.currentBranchId,
type: 'SET_BRANCH_COMMIT', },
payload: { store.state,
projectId: 'abcproject', [
branchId: 'master', {
commit: { id: '123' }, type: 'SET_BRANCH_COMMIT',
payload: {
projectId: 'abc/def',
branchId: 'master',
commit: { id: '123' },
},
},
], // 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 ], // mutations
[], // action [], // action
done, done,
); );
......
...@@ -37,4 +37,40 @@ describe('Multi-file store branch mutations', () => { ...@@ -37,4 +37,40 @@ describe('Multi-file store branch mutations', () => {
expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit'); 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