Commit b7ea36e2 authored by Jannik Lehmann's avatar Jannik Lehmann Committed by Phil Hughes

Code quality mr widget extension

parent 64d66902
...@@ -15,3 +15,17 @@ export const SEVERITY_ICONS = { ...@@ -15,3 +15,17 @@ export const SEVERITY_ICONS = {
blocker: 'severity-critical', blocker: 'severity-critical',
unknown: 'severity-unknown', unknown: 'severity-unknown',
}; };
// This is the icons mapping for the code Quality Merge-Request Widget Extension
// once the refactor_mr_widgets_extensions flag is activated the above SEVERITY_ICONS
// need be removed and this variable needs to be rename to SEVERITY_ICONS
// Rollout Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341759
export const SEVERITY_ICONS_EXTENSION = {
info: 'severityInfo',
minor: 'severityLow',
major: 'severityMedium',
critical: 'severityHigh',
blocker: 'severityCritical',
unknown: 'severityUnknown',
};
import { n__, s__, sprintf } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
import { SEVERITY_ICONS_EXTENSION } from '~/reports/codequality_report/constants';
import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export default {
name: 'WidgetCodeQuality',
props: ['codeQuality', 'blobPath'],
i18n: {
label: s__('ciReport|Code Quality'),
loading: s__('ciReport|Code Quality test metrics results are being parsed'),
error: s__('ciReport|Code Quality failed loading results'),
},
expandEvent: 'i_testing_code_quality_widget_total',
computed: {
summary() {
const { newErrors, resolvedErrors, errorSummary } = this.collapsedData;
if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) {
const improvements = sprintf(
n__(
'%{strongOpen}%{errors}%{strongClose} point',
'%{strongOpen}%{errors}%{strongClose} points',
resolvedErrors.length,
),
{
errors: resolvedErrors.length,
strongOpen: '<strong>',
strongClose: '</strong>',
},
false,
);
const degradations = sprintf(
n__(
'%{strongOpen}%{errors}%{strongClose} point',
'%{strongOpen}%{errors}%{strongClose} points',
newErrors.length,
),
{ errors: newErrors.length, strongOpen: '<strong>', strongClose: '</strong>' },
false,
);
return sprintf(
s__(`ciReport|Code Quality improved on ${improvements} and degraded on ${degradations}.`),
);
} else if (errorSummary.resolved >= 1) {
const improvements = n__('%d point', '%d points', resolvedErrors.length);
return sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`));
} else if (errorSummary.errored >= 1) {
const degradations = n__('%d point', '%d points', newErrors.length);
return sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`));
}
return s__(`ciReport|No changes to Code Quality.`);
},
statusIcon() {
if (this.collapsedData.errorSummary?.errored >= 1) {
return EXTENSION_ICONS.warning;
}
return EXTENSION_ICONS.success;
},
},
methods: {
fetchCollapsedData() {
return Promise.all([this.fetchReport(this.codeQuality)]).then((values) => {
return {
resolvedErrors: parseCodeclimateMetrics(
values[0].resolved_errors,
this.blobPath.head_path,
),
newErrors: parseCodeclimateMetrics(values[0].new_errors, this.blobPath.head_path),
existingErrors: parseCodeclimateMetrics(
values[0].existing_errors,
this.blobPath.head_path,
),
errorSummary: values[0].summary,
};
});
},
fetchFullData() {
const fullData = [];
this.collapsedData.newErrors.map((e) => {
return fullData.push({
text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
subtext: sprintf(
s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`),
{
open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`,
close_link: '</a>',
},
false,
),
icon: {
name: SEVERITY_ICONS_EXTENSION[e.severity],
},
});
});
this.collapsedData.resolvedErrors.map((e) => {
return fullData.push({
text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`,
subtext: sprintf(
s__(`ciReport|in %{open_link}${e.file_path}:${e.line}%{close_link}`),
{
open_link: `<a class="gl-text-decoration-underline" href="${e.urlPath}">`,
close_link: '</a>',
},
false,
),
icon: {
name: SEVERITY_ICONS_EXTENSION[e.severity],
},
});
});
return Promise.resolve(fullData);
},
fetchReport(endpoint) {
return axios.get(endpoint).then((res) => res.data);
},
},
};
...@@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab ...@@ -46,6 +46,7 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab
import getStateQuery from './queries/get_state.query.graphql'; import getStateQuery from './queries/get_state.query.graphql';
import terraformExtension from './extensions/terraform'; import terraformExtension from './extensions/terraform';
import accessibilityExtension from './extensions/accessibility'; import accessibilityExtension from './extensions/accessibility';
import codeQualityExtension from './extensions/code_quality';
export default { export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
...@@ -241,6 +242,11 @@ export default { ...@@ -241,6 +242,11 @@ export default {
this.registerTerraformPlans(); this.registerTerraformPlans();
} }
}, },
shouldRenderCodeQuality(newVal) {
if (newVal) {
this.registerCodeQualityExtension();
}
},
shouldShowAccessibilityReport(newVal) { shouldShowAccessibilityReport(newVal) {
if (newVal) { if (newVal) {
this.registerAccessibilityExtension(); this.registerAccessibilityExtension();
...@@ -491,6 +497,11 @@ export default { ...@@ -491,6 +497,11 @@ export default {
registerExtension(accessibilityExtension); registerExtension(accessibilityExtension);
} }
}, },
registerCodeQualityExtension() {
if (this.shouldRenderCodeQuality && this.shouldShowExtension) {
registerExtension(codeQualityExtension);
}
},
}, },
}; };
</script> </script>
...@@ -546,7 +557,7 @@ export default { ...@@ -546,7 +557,7 @@ export default {
</div> </div>
<extensions-container :mr="mr" /> <extensions-container :mr="mr" />
<grouped-codequality-reports-app <grouped-codequality-reports-app
v-if="shouldRenderCodeQuality" v-if="shouldRenderCodeQuality && !shouldShowExtension"
:head-blob-path="mr.headBlobPath" :head-blob-path="mr.headBlobPath"
:base-blob-path="mr.baseBlobPath" :base-blob-path="mr.baseBlobPath"
:codequality-reports-path="mr.codequalityReportsPath" :codequality-reports-path="mr.codequalityReportsPath"
......
...@@ -32,9 +32,15 @@ export default class MergeRequestStore { ...@@ -32,9 +32,15 @@ export default class MergeRequestStore {
this.setPaths(data); this.setPaths(data);
this.setData(data); this.setData(data);
this.initCodeQualityReport(data);
this.setGitpodData(data); this.setGitpodData(data);
} }
initCodeQualityReport(data) {
this.blobPath = data.blob_path;
this.codeQuality = data.codequality_reports_path;
}
setData(data, isRebased) { setData(data, isRebased) {
this.initApprovals(); this.initApprovals();
......
...@@ -352,7 +352,7 @@ export default { ...@@ -352,7 +352,7 @@ export default {
<extensions-container :mr="mr" /> <extensions-container :mr="mr" />
<blocking-merge-requests-report :mr="mr" /> <blocking-merge-requests-report :mr="mr" />
<grouped-codequality-reports-app <grouped-codequality-reports-app
v-if="shouldRenderCodeQuality" v-if="shouldRenderCodeQuality && !shouldShowExtension"
:head-blob-path="mr.headBlobPath" :head-blob-path="mr.headBlobPath"
:base-blob-path="mr.baseBlobPath" :base-blob-path="mr.baseBlobPath"
:codequality-reports-path="mr.codequalityReportsPath" :codequality-reports-path="mr.codequalityReportsPath"
......
...@@ -350,6 +350,11 @@ msgid_plural "%d personal projects will be removed and cannot be restored." ...@@ -350,6 +350,11 @@ msgid_plural "%d personal projects will be removed and cannot be restored."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d point"
msgid_plural "%d points"
msgstr[0] ""
msgstr[1] ""
msgid "%d previously merged commit" msgid "%d previously merged commit"
msgid_plural "%d previously merged commits" msgid_plural "%d previously merged commits"
msgstr[0] "" msgstr[0] ""
...@@ -981,6 +986,11 @@ msgstr "" ...@@ -981,6 +986,11 @@ msgstr ""
msgid "%{start} to %{end}" msgid "%{start} to %{end}"
msgstr "" msgstr ""
msgid "%{strongOpen}%{errors}%{strongClose} point"
msgid_plural "%{strongOpen}%{errors}%{strongClose} points"
msgstr[0] ""
msgstr[1] ""
msgid "%{strongOpen}Warning:%{strongClose} SAML group links can cause GitLab to automatically remove members from groups." msgid "%{strongOpen}Warning:%{strongClose} SAML group links can cause GitLab to automatically remove members from groups."
msgstr "" msgstr ""
...@@ -43479,6 +43489,15 @@ msgstr "" ...@@ -43479,6 +43489,15 @@ msgstr ""
msgid "ciReport|Cluster Image Scanning" msgid "ciReport|Cluster Image Scanning"
msgstr "" msgstr ""
msgid "ciReport|Code Quality"
msgstr ""
msgid "ciReport|Code Quality failed loading results"
msgstr ""
msgid "ciReport|Code Quality test metrics results are being parsed"
msgstr ""
msgid "ciReport|Code quality degraded" msgid "ciReport|Code quality degraded"
msgstr "" msgstr ""
...@@ -43586,6 +43605,9 @@ msgstr "" ...@@ -43586,6 +43605,9 @@ msgstr ""
msgid "ciReport|New" msgid "ciReport|New"
msgstr "" msgstr ""
msgid "ciReport|No changes to Code Quality."
msgstr ""
msgid "ciReport|No changes to code quality" msgid "ciReport|No changes to code quality"
msgstr "" msgstr ""
......
import MockAdapter from 'axios-mock-adapter';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality';
import httpStatusCodes from '~/lib/utils/http_status';
import {
codeQualityResponseNewErrors,
codeQualityResponseResolvedErrors,
codeQualityResponseResolvedAndNewErrors,
codeQualityResponseNoErrors,
} from './mock_data';
describe('Code Quality extension', () => {
let wrapper;
let mock;
registerExtension(codeQualityExtension);
const endpoint = '/root/repo/-/merge_requests/4/accessibility_reports.json';
const mockApi = (statusCode, data) => {
mock.onGet(endpoint).reply(statusCode, data);
};
const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
const createComponent = () => {
wrapper = mountExtended(extensionsContainer, {
propsData: {
mr: {
codeQuality: endpoint,
blobPath: {
head_path: 'example/path',
base_path: 'example/path',
},
},
},
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('summary', () => {
it('displays loading text', () => {
mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors);
createComponent();
expect(wrapper.text()).toBe('Code Quality test metrics results are being parsed');
});
it('displays failed loading text', async () => {
mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Code Quality failed loading results');
});
it('displays quality degradation', async () => {
mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Code Quality degraded on 2 points.');
});
it('displays quality improvement', async () => {
mockApi(httpStatusCodes.OK, codeQualityResponseResolvedErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Code Quality improved on 2 points.');
});
it('displays quality improvement and degradation', async () => {
mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('Code Quality improved on 1 point and degraded on 1 point.');
});
it('displays no detected errors', async () => {
mockApi(httpStatusCodes.OK, codeQualityResponseNoErrors);
createComponent();
await waitForPromises();
expect(wrapper.text()).toBe('No changes to Code Quality.');
});
});
describe('expanded data', () => {
beforeEach(async () => {
mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors);
createComponent();
await waitForPromises();
findToggleCollapsedButton().trigger('click');
await waitForPromises();
});
it('displays all report list items in viewport', async () => {
expect(findAllExtensionListItems()).toHaveLength(2);
});
it('displays report list item formatted', () => {
const text = {
newError: trimText(findAllExtensionListItems().at(0).text().replace(/\s+/g, ' ').trim()),
resolvedError: findAllExtensionListItems().at(1).text().replace(/\s+/g, ' ').trim(),
};
expect(text.newError).toContain(
"Minor - Parsing error: 'return' outside of function in index.js:12",
);
expect(text.resolvedError).toContain(
"Minor - Parsing error: 'return' outside of function in index.js:12",
);
});
});
});
export const codeQualityResponseNewErrors = {
status: 'failed',
new_errors: [
{
description: "Parsing error: 'return' outside of function",
severity: 'minor',
file_path: 'index.js',
line: 12,
},
{
description: 'TODO found',
severity: 'minor',
file_path: '.gitlab-ci.yml',
line: 73,
},
],
resolved_errors: [],
existing_errors: [],
summary: {
total: 2,
resolved: 0,
errored: 2,
},
};
export const codeQualityResponseResolvedErrors = {
status: 'failed',
new_errors: [],
resolved_errors: [
{
description: "Parsing error: 'return' outside of function",
severity: 'minor',
file_path: 'index.js',
line: 12,
},
{
description: 'TODO found',
severity: 'minor',
file_path: '.gitlab-ci.yml',
line: 73,
},
],
existing_errors: [],
summary: {
total: 2,
resolved: 2,
errored: 0,
},
};
export const codeQualityResponseResolvedAndNewErrors = {
status: 'failed',
new_errors: [
{
description: "Parsing error: 'return' outside of function",
severity: 'minor',
file_path: 'index.js',
line: 12,
},
],
resolved_errors: [
{
description: "Parsing error: 'return' outside of function",
severity: 'minor',
file_path: 'index.js',
line: 12,
},
],
existing_errors: [],
summary: {
total: 2,
resolved: 1,
errored: 1,
},
};
export const codeQualityResponseNoErrors = {
status: 'failed',
new_errors: [],
resolved_errors: [],
existing_errors: [],
summary: {
total: 0,
resolved: 0,
errored: 0,
},
};
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