Commit 3f6f512f authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'mf-codequality-widget-frontend-backstage' into 'master'

Introduce new vuex store for code quality widget

See merge request gitlab-org/gitlab!36119
parents 5c45af91 069b4d16
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequality_comparison';
export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
export const fetchReports = ({ state, dispatch, commit }) => {
commit(types.REQUEST_REPORTS);
if (!state.basePath) {
return dispatch('receiveReportsError');
}
return Promise.all([axios.get(state.headPath), axios.get(state.basePath)])
.then(results =>
doCodeClimateComparison(
parseCodeclimateMetrics(results[0].data, state.headBlobPath),
parseCodeclimateMetrics(results[1].data, state.baseBlobPath),
),
)
.then(data => dispatch('receiveReportsSuccess', data))
.catch(() => dispatch('receiveReportsError'));
};
export const receiveReportsSuccess = ({ commit }, data) => {
commit(types.RECEIVE_REPORTS_SUCCESS, data);
};
export const receiveReportsError = ({ commit }) => {
commit(types.RECEIVE_REPORTS_ERROR);
};
import { LOADING, ERROR, SUCCESS } from '../../constants';
import { sprintf, __, s__, n__ } from '~/locale';
export const hasCodequalityIssues = state =>
Boolean(state.newIssues?.length || state.resolvedIssues?.length);
export const codequalityStatus = state => {
if (state.isLoading) {
return LOADING;
}
if (state.hasError) {
return ERROR;
}
return SUCCESS;
};
export const codequalityText = state => {
const { newIssues, resolvedIssues } = state;
const text = [];
if (!newIssues.length && !resolvedIssues.length) {
text.push(s__('ciReport|No changes to code quality'));
} else {
text.push(s__('ciReport|Code quality'));
if (resolvedIssues.length) {
text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length));
}
if (newIssues.length && resolvedIssues.length) {
text.push(__(' and'));
}
if (newIssues.length) {
text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length));
}
}
return text.join('');
};
export const codequalityPopover = state => {
if (state.headPath && !state.basePath) {
return {
title: s__('ciReport|Base pipeline codequality artifact not found'),
content: sprintf(
s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'),
{
linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`,
linkEndTag: '<i class="fa fa-external-link" aria-hidden="true"></i></a>',
},
false,
),
};
}
return {};
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export default initialState =>
new Vuex.Store({
actions,
getters,
mutations,
state: state(initialState),
});
export const SET_PATHS = 'SET_PATHS';
export const REQUEST_REPORTS = 'REQUEST_REPORTS';
export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
export const RECEIVE_REPORTS_ERROR = 'RECEIVE_REPORTS_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_PATHS](state, paths) {
state.basePath = paths.basePath;
state.headPath = paths.headPath;
state.baseBlobPath = paths.baseBlobPath;
state.headBlobPath = paths.headBlobPath;
state.helpPath = paths.helpPath;
},
[types.REQUEST_REPORTS](state) {
state.isLoading = true;
},
[types.RECEIVE_REPORTS_SUCCESS](state, data) {
state.hasError = false;
state.isLoading = false;
state.newIssues = data.newIssues;
state.resolvedIssues = data.resolvedIssues;
},
[types.RECEIVE_REPORTS_ERROR](state) {
state.isLoading = false;
state.hasError = true;
},
};
export default () => ({
basePath: null,
headPath: null,
baseBlobPath: null,
headBlobPath: null,
isLoading: false,
hasError: false,
newIssues: [],
resolvedIssues: [],
helpPath: null,
});
import CodeQualityComparisonWorker from '../../workers/codequality_comparison_worker';
export const parseCodeclimateMetrics = (issues = [], path = '') => {
return issues.map(issue => {
const parsedIssue = {
...issue,
name: issue.description,
};
if (issue?.location?.path) {
let parseCodeQualityUrl = `${path}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
if (issue?.location?.lines?.begin) {
parsedIssue.line = issue.location.lines.begin;
parseCodeQualityUrl += `#L${issue.location.lines.begin}`;
} else if (issue?.location?.positions?.begin?.line) {
parsedIssue.line = issue.location.positions.begin.line;
parseCodeQualityUrl += `#L${issue.location.positions.begin.line}`;
}
parsedIssue.urlPath = parseCodeQualityUrl;
}
return parsedIssue;
});
};
export const doCodeClimateComparison = (headIssues, baseIssues) => {
// Do these comparisons in worker threads to avoid blocking the main thread
return new Promise((resolve, reject) => {
const worker = new CodeQualityComparisonWorker();
worker.addEventListener('message', ({ data }) =>
data.newIssues && data.resolvedIssues ? resolve(data) : reject(data),
);
worker.postMessage({
headIssues,
baseIssues,
});
});
};
import { differenceBy } from 'lodash';
const KEY_TO_FILTER_BY = 'fingerprint';
// eslint-disable-next-line no-restricted-globals
self.addEventListener('message', e => {
const { data } = e;
if (data === undefined) {
return null;
}
const { headIssues, baseIssues } = data;
if (!headIssues || !baseIssues) {
// eslint-disable-next-line no-restricted-globals
return self.postMessage({});
}
// eslint-disable-next-line no-restricted-globals
self.postMessage({
newIssues: differenceBy(headIssues, baseIssues, KEY_TO_FILTER_BY),
resolvedIssues: differenceBy(baseIssues, headIssues, KEY_TO_FILTER_BY),
});
// eslint-disable-next-line no-restricted-globals
return self.close();
});
export const headIssues = [
{
check_name: 'Rubocop/Lint/UselessAssignment',
description: 'Insecure Dependency',
location: {
path: 'lib/six.rb',
lines: {
begin: 6,
end: 7,
},
},
fingerprint: 'e879dd9bbc0953cad5037cde7ff0f627',
},
{
categories: ['Security'],
check_name: 'Insecure Dependency',
description: 'Insecure Dependency',
location: {
path: 'Gemfile.lock',
lines: {
begin: 22,
end: 22,
},
},
fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
},
];
export const mockParsedHeadIssues = [
{
...headIssues[1],
name: 'Insecure Dependency',
path: 'lib/six.rb',
urlPath: 'headPath/lib/six.rb#L6',
line: 6,
},
];
export const baseIssues = [
{
categories: ['Security'],
check_name: 'Insecure Dependency',
description: 'Insecure Dependency',
location: {
path: 'Gemfile.lock',
lines: {
begin: 22,
end: 22,
},
},
fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
},
{
categories: ['Security'],
check_name: 'Insecure Dependency',
description: 'Insecure Dependency',
location: {
path: 'Gemfile.lock',
lines: {
begin: 21,
end: 21,
},
},
fingerprint: 'ca2354534dee94ae60ba2f54e3857c50e5',
},
];
export const mockParsedBaseIssues = [
{
...baseIssues[1],
name: 'Insecure Dependency',
path: 'Gemfile.lock',
line: 21,
urlPath: 'basePath/Gemfile.lock#L21',
},
];
export const issueDiff = [
{
categories: ['Security'],
check_name: 'Insecure Dependency',
description: 'Insecure Dependency',
fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5',
line: 6,
location: { lines: { begin: 22, end: 22 }, path: 'Gemfile.lock' },
name: 'Insecure Dependency',
path: 'lib/six.rb',
urlPath: 'headPath/lib/six.rb#L6',
},
];
import axios from '~/lib/utils/axios_utils';
import MockAdapter from 'axios-mock-adapter';
import * as actions from '~/reports/codequality_report/store/actions';
import * as types from '~/reports/codequality_report/store/mutation_types';
import createStore from '~/reports/codequality_report/store';
import { TEST_HOST } from 'spec/test_constants';
import testAction from 'helpers/vuex_action_helper';
import { headIssues, baseIssues, mockParsedHeadIssues, mockParsedBaseIssues } from '../mock_data';
// mock codequality comparison worker
jest.mock('~/reports/codequality_report/workers/codequality_comparison_worker', () =>
jest.fn().mockImplementation(() => {
return {
addEventListener: (eventName, callback) => {
callback({
data: {
newIssues: [mockParsedHeadIssues[0]],
resolvedIssues: [mockParsedBaseIssues[0]],
},
});
},
};
}),
);
describe('Codequality Reports actions', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('setPaths', () => {
it('should commit SET_PATHS mutation', done => {
const paths = {
basePath: 'basePath',
headPath: 'headPath',
baseBlobPath: 'baseBlobPath',
headBlobPath: 'headBlobPath',
helpPath: 'codequalityHelpPath',
};
testAction(
actions.setPaths,
paths,
localState,
[{ type: types.SET_PATHS, payload: paths }],
[],
done,
);
});
});
describe('fetchReports', () => {
let mock;
beforeEach(() => {
localState.headPath = `${TEST_HOST}/head.json`;
localState.basePath = `${TEST_HOST}/base.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', done => {
mock.onGet(`${TEST_HOST}/head.json`).reply(200, headIssues);
mock.onGet(`${TEST_HOST}/base.json`).reply(200, baseIssues);
testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
[
{
payload: {
newIssues: [mockParsedHeadIssues[0]],
resolvedIssues: [mockParsedBaseIssues[0]],
},
type: 'receiveReportsSuccess',
},
],
done,
);
});
});
describe('on error', () => {
it('commits REQUEST_REPORTS and dispatches receiveReportsError', done => {
mock.onGet(`${TEST_HOST}/head.json`).reply(500);
testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
[{ type: 'receiveReportsError' }],
done,
);
});
});
describe('with no base path', () => {
it('commits REQUEST_REPORTS and dispatches receiveReportsError', done => {
localState.basePath = null;
testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
[{ type: 'receiveReportsError' }],
done,
);
});
});
});
describe('receiveReportsSuccess', () => {
it('commits RECEIVE_REPORTS_SUCCESS', done => {
const data = { issues: [] };
testAction(
actions.receiveReportsSuccess,
data,
localState,
[{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }],
[],
done,
);
});
});
describe('receiveReportsError', () => {
it('commits RECEIVE_REPORTS_ERROR', done => {
testAction(
actions.receiveReportsError,
null,
localState,
[{ type: types.RECEIVE_REPORTS_ERROR }],
[],
done,
);
});
});
});
import * as getters from '~/reports/codequality_report/store/getters';
import createStore from '~/reports/codequality_report/store';
import { LOADING, ERROR, SUCCESS } from '~/reports/constants';
describe('Codequality reports store getters', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('hasCodequalityIssues', () => {
describe('when there are issues', () => {
it('returns true', () => {
localState.newIssues = [{ reason: 'repetitive code' }];
localState.resolvedIssues = [];
expect(getters.hasCodequalityIssues(localState)).toEqual(true);
localState.newIssues = [];
localState.resolvedIssues = [{ reason: 'repetitive code' }];
expect(getters.hasCodequalityIssues(localState)).toEqual(true);
});
});
describe('when there are no issues', () => {
it('returns false when there are no issues', () => {
expect(getters.hasCodequalityIssues(localState)).toEqual(false);
});
});
});
describe('codequalityStatus', () => {
describe('when loading', () => {
it('returns loading status', () => {
localState.isLoading = true;
expect(getters.codequalityStatus(localState)).toEqual(LOADING);
});
});
describe('on error', () => {
it('returns error status', () => {
localState.hasError = true;
expect(getters.codequalityStatus(localState)).toEqual(ERROR);
});
});
describe('when successfully loaded', () => {
it('returns error status', () => {
expect(getters.codequalityStatus(localState)).toEqual(SUCCESS);
});
});
});
describe('codequalityText', () => {
it.each`
resolvedIssues | newIssues | expectedText
${0} | ${0} | ${'No changes to code quality'}
${0} | ${1} | ${'Code quality degraded on 1 point'}
${2} | ${0} | ${'Code quality improved on 2 points'}
${1} | ${2} | ${'Code quality improved on 1 point and degraded on 2 points'}
`(
'returns a summary containing $resolvedIssues resolved issues and $newIssues new issues',
({ newIssues, resolvedIssues, expectedText }) => {
localState.newIssues = new Array(newIssues).fill({ reason: 'Repetitive code' });
localState.resolvedIssues = new Array(resolvedIssues).fill({ reason: 'Repetitive code' });
expect(getters.codequalityText(localState)).toEqual(expectedText);
},
);
});
describe('codequalityPopover', () => {
describe('when head report is available but base report is not', () => {
it('returns a popover with a documentation link', () => {
localState.headPath = 'head.json';
localState.basePath = undefined;
localState.helpPath = 'codequality_help.html';
expect(getters.codequalityPopover(localState).title).toEqual(
'Base pipeline codequality artifact not found',
);
expect(getters.codequalityPopover(localState).content).toContain(
'Learn more about codequality reports',
'href="codequality_help.html"',
);
});
});
});
});
import mutations from '~/reports/codequality_report/store/mutations';
import createStore from '~/reports/codequality_report/store';
describe('Codequality Reports mutations', () => {
let localState;
let localStore;
beforeEach(() => {
localStore = createStore();
localState = localStore.state;
});
describe('SET_PATHS', () => {
it('sets paths to given values', () => {
const basePath = 'base.json';
const headPath = 'head.json';
const baseBlobPath = 'base/blob/path/';
const headBlobPath = 'head/blob/path/';
const helpPath = 'help.html';
mutations.SET_PATHS(localState, {
basePath,
headPath,
baseBlobPath,
headBlobPath,
helpPath,
});
expect(localState.basePath).toEqual(basePath);
expect(localState.headPath).toEqual(headPath);
expect(localState.baseBlobPath).toEqual(baseBlobPath);
expect(localState.headBlobPath).toEqual(headBlobPath);
expect(localState.helpPath).toEqual(helpPath);
});
});
describe('REQUEST_REPORTS', () => {
it('sets isLoading to true', () => {
mutations.REQUEST_REPORTS(localState);
expect(localState.isLoading).toEqual(true);
});
});
describe('RECEIVE_REPORTS_SUCCESS', () => {
it('sets isLoading to false', () => {
mutations.RECEIVE_REPORTS_SUCCESS(localState, {});
expect(localState.isLoading).toEqual(false);
});
it('sets hasError to false', () => {
mutations.RECEIVE_REPORTS_SUCCESS(localState, {});
expect(localState.hasError).toEqual(false);
});
it('sets newIssues and resolvedIssues from response data', () => {
const data = { newIssues: [{ id: 1 }], resolvedIssues: [{ id: 2 }] };
mutations.RECEIVE_REPORTS_SUCCESS(localState, data);
expect(localState.newIssues).toEqual(data.newIssues);
expect(localState.resolvedIssues).toEqual(data.resolvedIssues);
});
});
describe('RECEIVE_REPORTS_ERROR', () => {
it('sets isLoading to false', () => {
mutations.RECEIVE_REPORTS_ERROR(localState);
expect(localState.isLoading).toEqual(false);
});
it('sets hasError to true', () => {
mutations.RECEIVE_REPORTS_ERROR(localState);
expect(localState.hasError).toEqual(true);
});
});
});
import {
parseCodeclimateMetrics,
doCodeClimateComparison,
} from '~/reports/codequality_report/store/utils/codequality_comparison';
import { baseIssues, mockParsedHeadIssues, mockParsedBaseIssues } from '../../mock_data';
jest.mock('~/reports/codequality_report/workers/codequality_comparison_worker', () => {
let mockPostMessageCallback;
return jest.fn().mockImplementation(() => {
return {
addEventListener: (_, callback) => {
mockPostMessageCallback = callback;
},
postMessage: data => {
if (!data.headIssues) return mockPostMessageCallback({ data: {} });
if (!data.baseIssues) throw new Error();
const key = 'fingerprint';
return mockPostMessageCallback({
data: {
newIssues: data.headIssues.filter(
item => !data.baseIssues.find(el => el[key] === item[key]),
),
resolvedIssues: data.baseIssues.filter(
item => !data.headIssues.find(el => el[key] === item[key]),
),
},
});
},
};
});
});
describe('Codequality report store utils', () => {
let result;
describe('parseCodeclimateMetrics', () => {
it('should parse the received issues', () => {
[result] = parseCodeclimateMetrics(baseIssues, 'path');
expect(result.name).toEqual(baseIssues[0].check_name);
expect(result.path).toEqual(baseIssues[0].location.path);
expect(result.line).toEqual(baseIssues[0].location.lines.begin);
});
describe('when an issue has no location or path', () => {
const issue = { description: 'Insecure Dependency' };
beforeEach(() => {
[result] = parseCodeclimateMetrics([issue], 'path');
});
it('is parsed', () => {
expect(result.name).toEqual(issue.description);
});
});
describe('when an issue has a path but no line', () => {
const issue = { description: 'Insecure Dependency', location: { path: 'Gemfile.lock' } };
beforeEach(() => {
[result] = parseCodeclimateMetrics([issue], 'path');
});
it('is parsed', () => {
expect(result.name).toEqual(issue.description);
expect(result.path).toEqual(issue.location.path);
expect(result.urlPath).toEqual(`path/${issue.location.path}`);
});
});
describe('when an issue has a line nested in positions', () => {
const issue = {
description: 'Insecure Dependency',
location: {
path: 'Gemfile.lock',
positions: { begin: { line: 84 } },
},
};
beforeEach(() => {
[result] = parseCodeclimateMetrics([issue], 'path');
});
it('is parsed', () => {
expect(result.name).toEqual(issue.description);
expect(result.path).toEqual(issue.location.path);
expect(result.urlPath).toEqual(
`path/${issue.location.path}#L${issue.location.positions.begin.line}`,
);
});
});
describe('with an empty issue array', () => {
beforeEach(() => {
result = parseCodeclimateMetrics([], 'path');
});
it('returns an empty array', () => {
expect(result).toEqual([]);
});
});
});
describe('doCodeClimateComparison', () => {
describe('when the comparison worker finds changed issues', () => {
beforeEach(async () => {
result = await doCodeClimateComparison(mockParsedHeadIssues, mockParsedBaseIssues);
});
it('returns the new and resolved issues', () => {
expect(result.resolvedIssues[0]).toEqual(mockParsedBaseIssues[0]);
expect(result.newIssues[0]).toEqual(mockParsedHeadIssues[0]);
});
});
describe('when the comparison worker finds no changed issues', () => {
beforeEach(async () => {
result = await doCodeClimateComparison([], []);
});
it('returns the empty issue arrays', () => {
expect(result.newIssues).toEqual([]);
expect(result.resolvedIssues).toEqual([]);
});
});
describe('when the comparison worker is given malformed data', () => {
it('rejects the promise', () => {
return expect(doCodeClimateComparison(null)).rejects.toEqual({});
});
});
describe('when the comparison worker encounters an error', () => {
it('rejects the promise and throws an error', () => {
return expect(doCodeClimateComparison([], null)).rejects.toThrow();
});
});
});
});
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