Commit 54b12e93 authored by Stan Hu's avatar Stan Hu

Merge branch 'mfluharty-full-codequality-report' into 'master'

Add code quality tab with full report to pipeline view

See merge request gitlab-org/gitlab!21527
parents 0e4be207 7a776100
...@@ -1090,6 +1090,20 @@ button.mini-pipeline-graph-dropdown-toggle { ...@@ -1090,6 +1090,20 @@ button.mini-pipeline-graph-dropdown-toggle {
} }
} }
.codequality-report {
.media {
padding: $gl-padding;
}
.media-body {
flex-direction: row;
}
.report-block-container {
height: auto !important;
}
}
.progress-bar.bg-primary { .progress-bar.bg-primary {
background-color: $blue-500 !important; background-color: $blue-500 !important;
} }
......
...@@ -260,11 +260,16 @@ GitLab. ...@@ -260,11 +260,16 @@ GitLab.
## Code Quality reports ## Code Quality reports
Once the Code Quality job has completed, GitLab: Once the Code Quality job has completed:
- Checks the generated report. - The full list of code quality violations generated by a pipeline is available in the
- Compares the metrics between the source and target branches. Code Quality tab of the Pipeline Details page.
- Shows the information right on the merge request. - Potential changes to code quality are shown directly in the merge request.
The Code Quality widget in the merge request compares the reports from the base and head of the branch,
then lists any violations that will be resolved or created when the branch is merged.
- The full JSON report is available as a
[downloadable artifact](../../project/pipelines/job_artifacts.html#downloading-artifacts)
for the `code_quality` job.
If multiple jobs in a pipeline generate a code quality artifact, only the artifact from If multiple jobs in a pipeline generate a code quality artifact, only the artifact from
the last created job (the job with the largest job ID) is used. To avoid confusion, the last created job (the job with the largest job ID) is used. To avoid confusion,
...@@ -276,6 +281,10 @@ Code Quality job in your `.gitlab-ci.yml` for the very first time. ...@@ -276,6 +281,10 @@ Code Quality job in your `.gitlab-ci.yml` for the very first time.
Consecutive merge requests will have something to compare to and the Code Quality Consecutive merge requests will have something to compare to and the Code Quality
report will be shown properly. report will be shown properly.
These reports will only be available as long as the Code Quality artifact(s) required to generate
them are also available. See
[`artifacts:expire_in`](../../../ci/yaml/README.md#artifactsexpire_in) for more details.
<!-- ## Troubleshooting <!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues Include any troubleshooting steps that you can foresee. If you know beforehand what issues
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import { componentNames } from 'ee/reports/components/issue_body';
import ReportSection from '~/reports/components/report_section.vue';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { n__, s__, sprintf } from '~/locale';
export default {
components: {
ReportSection,
PaginationLinks,
},
mixins: [reportsMixin],
componentNames,
computed: {
...mapState(['isLoadingCodequality', 'loadingCodequalityFailed', 'pageInfo']),
...mapGetters(['codequalityIssues', 'codequalityIssueTotal']),
hasCodequalityIssues() {
return this.codequalityIssueTotal > 0;
},
codequalityText() {
const text = [];
const { codequalityIssueTotal } = this;
if (codequalityIssueTotal === 0) {
return s__('ciReport|No code quality issues found');
} else if (codequalityIssueTotal > 0) {
return sprintf(s__('ciReport|Found %{issuesWithCount}'), {
issuesWithCount: n__(
'%d code quality issue',
'%d code quality issues',
codequalityIssueTotal,
),
});
}
return text.join('');
},
codequalityStatus() {
return this.checkReportStatus(this.isLoadingCodequality, this.loadingCodequalityFailed);
},
},
methods: {
...mapActions(['setPage']),
translateText(type) {
return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
reportName: type,
}),
loading: sprintf(s__('ciReport|Loading %{reportName} report'), {
reportName: type,
}),
};
},
},
};
</script>
<template>
<div>
<report-section
always-open
:status="codequalityStatus"
:loading-text="translateText('codeclimate').loading"
:error-text="translateText('codeclimate').error"
:success-text="codequalityText"
:unresolved-issues="codequalityIssues"
:resolved-issues="[]"
:has-issues="hasCodequalityIssues"
:component="$options.componentNames.CodequalityIssueBody"
class="codequality-report"
/>
<pagination-links
:change="setPage"
:page-info="pageInfo"
class="d-flex justify-content-center prepend-top-default"
/>
</div>
</template>
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import MergeRequestStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page);
export const requestReport = ({ commit }) => commit(types.REQUEST_REPORT);
export const receiveReportSuccess = ({ state, commit }, data) => {
const parsedIssues = MergeRequestStore.parseCodeclimateMetrics(data, state.blobPath);
commit(types.RECEIVE_REPORT_SUCCESS, parsedIssues);
};
export const receiveReportError = ({ commit }, error) => commit(types.RECEIVE_REPORT_ERROR, error);
export const fetchReport = ({ state, dispatch }) => {
dispatch('requestReport');
axios
.get(state.endpoint)
.then(({ data }) => {
if (!state.blobPath) throw new Error();
dispatch('receiveReportSuccess', data);
})
.catch(error => {
dispatch('receiveReportError', error);
createFlash(s__('ciReport|There was an error fetching the codequality report.'));
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const codequalityIssues = state => {
const { page, perPage } = state.pageInfo;
const start = (page - 1) * perPage;
return state.allCodequalityIssues.slice(start, start + perPage);
};
export const codequalityIssueTotal = state => state.allCodequalityIssues.length;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default (initialState = {}) =>
new Vuex.Store({
actions,
getters,
mutations,
state: {
...state(),
...initialState,
},
});
export const SET_PAGE = 'SET_PAGE';
export const REQUEST_REPORT = 'REQUEST_REPORT';
export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_PAGE](state, page) {
Object.assign(state, {
pageInfo: Object.assign(state.pageInfo, {
page,
}),
});
},
[types.REQUEST_REPORT](state) {
Object.assign(state, { isLoadingCodequality: true });
},
[types.RECEIVE_REPORT_SUCCESS](state, allCodequalityIssues) {
Object.assign(state, {
isLoadingCodequality: false,
allCodequalityIssues: Object.freeze(allCodequalityIssues),
pageInfo: Object.assign(state.pageInfo, {
total: allCodequalityIssues.length,
}),
});
},
[types.RECEIVE_REPORT_ERROR](state, codeQualityError) {
Object.assign(state, {
isLoadingCodequality: false,
allCodequalityIssues: [],
loadingCodequalityFailed: true,
codeQualityError,
pageInfo: Object.assign(state.pageInfo, {
total: 0,
}),
});
},
};
export default () => ({
endpoint: '',
allCodequalityIssues: [],
isLoadingCodequality: false,
loadingCodequalityFailed: false,
codeQualityError: null,
pageInfo: {
page: 1,
perPage: 25,
total: 0,
},
});
// /codequality_report is an alias for show
import '../show/index';
import Vue from 'vue';
import CodequalityReportApp from 'ee/codequality_report/codequality_report.vue';
import Translate from '~/vue_shared/translate';
import createStore from 'ee/codequality_report/store';
Vue.use(Translate);
export default () => {
const tabsElement = document.querySelector('.pipelines-tabs');
const codequalityTab = document.getElementById('js-pipeline-codequality-report');
if (tabsElement && codequalityTab) {
const fetchReportAction = 'fetchReport';
const { codequalityReportDownloadPath, blobPath } = codequalityTab.dataset;
const store = createStore({ endpoint: codequalityReportDownloadPath, blobPath });
const isCodequalityTabActive = Boolean(
document.querySelector('.pipelines-tabs > li > a.codequality-tab.active'),
);
if (isCodequalityTabActive) {
store.dispatch(fetchReportAction);
} else {
const tabClickHandler = e => {
if (e.target.className === 'codequality-tab') {
store.dispatch(fetchReportAction);
tabsElement.removeEventListener('click', tabClickHandler);
}
};
tabsElement.addEventListener('click', tabClickHandler);
}
// eslint-disable-next-line no-new
new Vue({
el: codequalityTab,
components: {
CodequalityReportApp,
},
store,
render: createElement => createElement('codequality-report-app'),
});
}
};
...@@ -2,10 +2,12 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; ...@@ -2,10 +2,12 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle';
import initPipelines from '~/pages/projects/pipelines/init_pipelines'; import initPipelines from '~/pages/projects/pipelines/init_pipelines';
import initSecurityReport from './security_report'; import initSecurityReport from './security_report';
import initLicenseReport from './license_report'; import initLicenseReport from './license_report';
import initCodequalityReport from './codequality_report';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initPipelines(); initPipelines();
initPipelineDetails(); initPipelineDetails();
initSecurityReport(); initSecurityReport();
initLicenseReport(); initLicenseReport();
initCodequalityReport();
}); });
...@@ -30,6 +30,10 @@ module EE ...@@ -30,6 +30,10 @@ module EE
end end
end end
end end
def codequality_report
render_show
end
end end
end end
end end
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
- license_management_settings_path = can?(current_user, :admin_software_license_policy, project) ? license_management_settings_path(project) : nil - license_management_settings_path = can?(current_user, :admin_software_license_policy, project) ? license_management_settings_path(project) : nil
- licenses_api_path = licenses_project_pipeline_path(project, pipeline) if project.feature_available?(:license_management) - licenses_api_path = licenses_project_pipeline_path(project, pipeline) if project.feature_available?(:license_management)
- vulnerabilities_endpoint_path = expose_path(api_v4_projects_vulnerability_findings_path(id: project.id, params: { pipeline_id: pipeline.id, scope: 'dismissed' })) - vulnerabilities_endpoint_path = expose_path(api_v4_projects_vulnerability_findings_path(id: project.id, params: { pipeline_id: pipeline.id, scope: 'dismissed' }))
- codequality_report_download_path = pipeline.downloadable_path_for_report_type(:codequality)
- if pipeline.expose_security_dashboard? - if pipeline.expose_security_dashboard?
#js-tab-security.build-security.tab-pane #js-tab-security.build-security.tab-pane
...@@ -23,3 +24,8 @@ ...@@ -23,3 +24,8 @@
license_management_settings_path: license_management_settings_path, license_management_settings_path: license_management_settings_path,
licenses_api_path: licenses_api_path, licenses_api_path: licenses_api_path,
can_manage_licenses: can?(current_user, :admin_software_license_policy, project).to_s } } can_manage_licenses: can?(current_user, :admin_software_license_policy, project).to_s } }
- if codequality_report_download_path
#js-tab-codequality.tab-pane
#js-pipeline-codequality-report{ data: { codequality_report_download_path: codequality_report_download_path,
blob_path: project_blob_path(project, pipeline.ref) } }
...@@ -11,3 +11,8 @@ ...@@ -11,3 +11,8 @@
= link_to licenses_project_pipeline_path(project, pipeline), data: { target: '#js-tab-licenses', action: 'licenses', toggle: 'tab', qa_selector: 'licenses_tab' }, class: 'licenses-tab' do = link_to licenses_project_pipeline_path(project, pipeline), data: { target: '#js-tab-licenses', action: 'licenses', toggle: 'tab', qa_selector: 'licenses_tab' }, class: 'licenses-tab' do
= _("Licenses") = _("Licenses")
%span.badge.badge-pill.js-licenses-counter.hidden{ data: { qa_selector: 'licenses_counter' } } %span.badge.badge-pill.js-licenses-counter.hidden{ data: { qa_selector: 'licenses_counter' } }
- if pipeline.downloadable_path_for_report_type(:codequality)
%li.js-codequality-tab-link
= link_to codequality_report_namespace_project_pipeline_path(project.namespace, project, pipeline), data: { target: '#js-tab-codequality', action: 'codequality_report', toggle: 'tab'}, class: 'codequality-tab' do
= _('Code Quality')
---
title: Add code quality tab to pipeline view
merge_request: 21527
author:
type: added
...@@ -154,6 +154,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -154,6 +154,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
member do member do
get :security get :security
get :licenses get :licenses
get :codequality_report
end end
end end
......
import Vuex from 'vuex';
import CodequalityReportApp from 'ee/codequality_report/codequality_report.vue';
import { parsedIssues } from './mock_data';
import { mount, createLocalVue } from '@vue/test-utils';
jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Codequality report app', () => {
let wrapper;
let store;
const createComponent = (state = {}, issues = []) => {
store = new Vuex.Store({
state: {
pageInfo: {},
...state,
},
getters: {
codequalityIssues: () => issues,
codequalityIssueTotal: () => issues.length,
},
});
wrapper = mount(CodequalityReportApp, {
localVue,
store,
});
};
const findStatus = () => wrapper.find('.js-code-text');
const findSuccessIcon = () => wrapper.find('.js-ci-status-icon-success');
const findWarningIcon = () => wrapper.find('.js-ci-status-icon-warning');
afterEach(() => {
wrapper.destroy();
});
describe('when loading', () => {
beforeEach(() => {
createComponent({ isLoadingCodequality: true });
});
it('shows a loading state', () => {
expect(findStatus().text()).toBe('Loading codeclimate report');
});
});
describe('on error', () => {
beforeEach(() => {
createComponent({ loadingCodequalityFailed: true });
});
it('shows a warning icon and error message', () => {
expect(findWarningIcon().exists()).toBe(true);
expect(findStatus().text()).toBe('Failed to load codeclimate report');
});
});
describe('when there are codequality issues', () => {
beforeEach(() => {
createComponent({}, parsedIssues);
});
it('renders the codequality issues', () => {
const expectedIssueTotal = parsedIssues.length;
expect(findWarningIcon().exists()).toBe(true);
expect(findStatus().text()).toBe(`Found ${expectedIssueTotal} code quality issues`);
expect(wrapper.findAll('.report-block-list-issue').length).toBe(expectedIssueTotal);
});
});
describe('when there are no codequality issues', () => {
beforeEach(() => {
createComponent({}, []);
});
it('shows a message that no codequality issues were found', () => {
expect(findSuccessIcon().exists()).toBe(true);
expect(findStatus().text()).toBe('No code quality issues found');
expect(wrapper.findAll('.report-block-list-issue').length).toBe(0);
});
});
});
This diff is collapsed.
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import * as actions from 'ee/codequality_report/store/actions';
import * as types from 'ee/codequality_report/store/mutation_types';
import { TEST_HOST } from 'helpers/test_constants';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import { unparsedIssues, parsedIssues } from '../mock_data';
jest.mock('~/flash');
describe('Codequality report actions', () => {
let mock;
let state;
const endpoint = `${TEST_HOST}/codequality_report.json`;
const defaultState = {
endpoint,
};
beforeEach(() => {
mock = new MockAdapter(axios);
state = defaultState;
});
afterEach(() => {
mock.restore();
});
describe('setPage', () => {
it('sets the page number', done => {
testAction(actions.setPage, 12, state, [{ type: types.SET_PAGE, payload: 12 }], [], done);
});
});
describe('requestReport', () => {
it('sets the loading flag', done => {
testAction(actions.requestReport, null, state, [{ type: types.REQUEST_REPORT }], [], done);
});
});
describe('receiveReportSuccess', () => {
it('parses the list of issues from the report', done => {
testAction(
actions.receiveReportSuccess,
unparsedIssues,
{ blobPath: '/root/test-codequality/blob/feature-branch', ...state },
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: parsedIssues }],
[],
done,
);
});
});
describe('receiveReportError', () => {
it('accepts a report error', done => {
testAction(
actions.receiveReportError,
'error',
state,
[{ type: types.RECEIVE_REPORT_ERROR, payload: 'error' }],
[],
done,
);
});
});
describe('fetchReport', () => {
beforeEach(() => {
mock.onGet(endpoint).replyOnce(200, unparsedIssues);
});
it('fetches the report', done => {
testAction(
actions.fetchReport,
null,
{ blobPath: 'blah', ...state },
[],
[{ type: 'requestReport' }, { type: 'receiveReportSuccess', payload: unparsedIssues }],
done,
);
});
it('shows a flash message when there is an error', done => {
testAction(
actions.fetchReport,
'error',
state,
[],
[{ type: 'requestReport' }, { type: 'receiveReportError', payload: new Error() }],
() => {
expect(createFlash).toHaveBeenCalledWith(
'There was an error fetching the codequality report.',
);
done();
},
);
});
it('shows an error when blob path is missing', done => {
testAction(
actions.fetchReport,
null,
state,
[],
[{ type: 'requestReport' }, { type: 'receiveReportError', payload: new Error() }],
() => {
expect(createFlash).toHaveBeenCalledWith(
'There was an error fetching the codequality report.',
);
done();
},
);
});
});
});
import * as getters from 'ee/codequality_report/store/getters';
import { parsedIssues } from '../mock_data';
describe('Codequality report getters', () => {
let state;
const defaultState = {
allCodequalityIssues: parsedIssues,
};
beforeEach(() => {
state = defaultState;
});
describe('codequalityIssues', () => {
it('gets the current page of issues', () => {
expect(
getters.codequalityIssues({ pageInfo: { page: 1, perPage: 2, total: 3 }, ...state }),
).toEqual(parsedIssues.slice(0, 2));
expect(
getters.codequalityIssues({ pageInfo: { page: 2, perPage: 2, total: 3 }, ...state }),
).toEqual(parsedIssues.slice(2, 3));
});
});
describe('codequalityIssueTotal', () => {
it('gets the total number of codequality issues', () => {
expect(getters.codequalityIssueTotal(state)).toBe(parsedIssues.length);
});
});
});
import * as types from 'ee/codequality_report/store/mutation_types';
import mutations from 'ee/codequality_report/store/mutations';
import { parsedIssues } from '../mock_data';
describe('Codequality report mutations', () => {
let state;
const defaultState = {
pageInfo: {},
};
beforeEach(() => {
state = defaultState;
});
describe('set page', () => {
it('should set page', () => {
mutations[types.SET_PAGE](state, 4);
expect(state.pageInfo.page).toBe(4);
});
});
describe('request report', () => {
it('should set the loading flag', () => {
mutations[types.REQUEST_REPORT](state);
expect(state.isLoadingCodequality).toBe(true);
});
});
describe('receive report success', () => {
it('should set issue info and clear the loading flag', () => {
mutations[types.RECEIVE_REPORT_SUCCESS](state, parsedIssues);
expect(state.isLoadingCodequality).toBe(false);
expect(state.allCodequalityIssues).toBe(parsedIssues);
expect(state.pageInfo.total).toBe(parsedIssues.length);
});
});
describe('receive report error', () => {
it('should set error info and clear the loading flag', () => {
mutations[types.RECEIVE_REPORT_ERROR](state, new Error());
expect(state.isLoadingCodequality).toBe(false);
expect(state.loadingCodequalityFailed).toBe(true);
expect(state.allCodequalityIssues).toEqual([]);
expect(state.codeQualityError).toEqual(new Error());
expect(state.pageInfo.total).toBe(0);
});
});
});
...@@ -68,6 +68,11 @@ msgstr "" ...@@ -68,6 +68,11 @@ msgstr ""
msgid "\"%{path}\" did not exist on \"%{ref}\"" msgid "\"%{path}\" did not exist on \"%{ref}\""
msgstr "" msgstr ""
msgid "%d code quality issue"
msgid_plural "%d code quality issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d comment" msgid "%d comment"
msgid_plural "%d comments" msgid_plural "%d comments"
msgstr[0] "" msgstr[0] ""
...@@ -4853,6 +4858,9 @@ msgstr "" ...@@ -4853,6 +4858,9 @@ msgstr ""
msgid "Code Owners to the merge request changes." msgid "Code Owners to the merge request changes."
msgstr "" msgstr ""
msgid "Code Quality"
msgstr ""
msgid "Code Review" msgid "Code Review"
msgstr "" msgstr ""
...@@ -23188,6 +23196,9 @@ msgstr "" ...@@ -23188,6 +23196,9 @@ msgstr ""
msgid "ciReport|Fixed:" msgid "ciReport|Fixed:"
msgstr "" msgstr ""
msgid "ciReport|Found %{issuesWithCount}"
msgstr ""
msgid "ciReport|Investigate this vulnerability by creating an issue" msgid "ciReport|Investigate this vulnerability by creating an issue"
msgstr "" msgstr ""
...@@ -23206,6 +23217,9 @@ msgstr "" ...@@ -23206,6 +23217,9 @@ msgstr ""
msgid "ciReport|No changes to performance metrics" msgid "ciReport|No changes to performance metrics"
msgstr "" msgstr ""
msgid "ciReport|No code quality issues found"
msgstr ""
msgid "ciReport|Performance metrics" msgid "ciReport|Performance metrics"
msgstr "" msgstr ""
...@@ -23236,6 +23250,9 @@ msgstr "" ...@@ -23236,6 +23250,9 @@ msgstr ""
msgid "ciReport|There was an error dismissing the vulnerability. Please try again." msgid "ciReport|There was an error dismissing the vulnerability. Please try again."
msgstr "" msgstr ""
msgid "ciReport|There was an error fetching the codequality report."
msgstr ""
msgid "ciReport|There was an error reverting the dismissal. Please try again." msgid "ciReport|There was an error reverting the dismissal. Please try again."
msgstr "" msgstr ""
......
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