Commit cf43b255 authored by Mike Greiling's avatar Mike Greiling

Merge branch '5487-fe-license-info-mr-widget' into 'master'

Add License Management results in the MR widget

Closes #5487

See merge request gitlab-org/gitlab-ee!5521
parents fbd676b0 7b21b424
<script>
import { s__, sprintf } from '~/locale';
export default {
props: {
issue: {
type: Object,
required: true,
},
},
data() {
return {
displayPackageCount: 3,
showAllPackages: false,
};
},
computed: {
packages() {
return this.getPackagesString(!this.showAllPackages);
},
remainingPackages() {
const { packages } = this.issue;
if (packages.length > this.displayPackageCount) {
return sprintf(s__('ciReport|%{remainingPackagesCount} more'), {
remainingPackagesCount: packages.length - this.displayPackageCount,
});
}
return '';
},
},
methods: {
getPackagesString(truncate) {
const { packages } = this.issue;
// When there is only 1 package name to show.
if (packages.length === 1) {
return packages[0].name;
}
// When packages count is higher than displayPackageCount
// and truncate is true.
if (truncate && packages.length > this.displayPackageCount) {
return sprintf(s__('ciReport|%{packagesString} and '), {
packagesString: packages
.slice(0, this.displayPackageCount)
.map(packageItem => packageItem.name)
.join(', '),
});
}
// Return all package names separated by comma with proper grammer
return sprintf(s__('ciReport|%{packagesString} and %{lastPackage}'), {
packagesString: packages
.slice(0, packages.length - 1)
.map(packageItem => packageItem.name)
.join(', '),
lastPackage: packages[packages.length - 1].name,
});
},
handleShowPackages() {
this.showAllPackages = true;
},
},
};
</script>
<template>
<p
class="prepend-left-4 append-bottom-0 report-block-info license-item"
>
<a
target="_blank"
rel="noopener noreferrer nofollow"
:href="issue.url"
>{{ issue.name }}</a>
<span
class="license-dependencies"
>
&nbsp;{{ packages }}
</span>
<button
v-if="!showAllPackages"
type="button"
class="btn btn-link btn-show-all-packages"
@click="handleShowPackages"
>
{{ remainingPackages }}
</button>
</p>
</template>
......@@ -20,8 +20,10 @@ export default {
return {
isLoadingCodequality: false,
isLoadingPerformance: false,
isLoadingLicenseReport: false,
loadingCodequalityFailed: false,
loadingPerformanceFailed: false,
loadingLicenseReportFailed: false,
};
},
computed: {
......@@ -32,6 +34,10 @@ export default {
const { codeclimate } = this.mr;
return codeclimate && codeclimate.head_path && codeclimate.base_path;
},
shouldRenderLicenseReport() {
const { licenseManagement } = this.mr;
return licenseManagement && licenseManagement.head_path && licenseManagement.base_path;
},
hasCodequalityIssues() {
return (
this.mr.codeclimateMetrics &&
......@@ -49,6 +55,10 @@ export default {
(this.mr.performanceMetrics.neutral && this.mr.performanceMetrics.neutral.length > 0))
);
},
hasLicenseReportIssues() {
const { licenseReport } = this.mr;
return licenseReport && licenseReport.length > 0;
},
shouldRenderPerformance() {
const { performance } = this.mr;
return performance && performance.head_path && performance.base_path;
......@@ -111,6 +121,18 @@ export default {
return text.join('');
},
licenseReportText() {
const { licenseReport } = this.mr;
if (licenseReport.length > 0) {
return sprintf(s__('ciReport|License management detected %{licenseInfo}'), {
licenseInfo: n__('%d new license', '%d new licenses', licenseReport.length),
});
}
return s__('ciReport|License management detected no new licenses');
},
codequalityStatus() {
return this.checkReportStatus(this.isLoadingCodequality, this.loadingCodequalityFailed);
},
......@@ -118,6 +140,10 @@ export default {
performanceStatus() {
return this.checkReportStatus(this.isLoadingPerformance, this.loadingPerformanceFailed);
},
licenseReportStatus() {
return this.checkReportStatus(this.isLoadingLicenseReport, this.loadingLicenseReportFailed);
},
},
created() {
if (this.shouldRenderCodeQuality) {
......@@ -127,6 +153,10 @@ export default {
if (this.shouldRenderPerformance) {
this.fetchPerformance();
}
if (this.shouldRenderLicenseReport) {
this.fetchLicenseReport();
}
},
methods: {
fetchCodeQuality() {
......@@ -166,6 +196,22 @@ export default {
});
},
fetchLicenseReport() {
const { head_path, base_path } = this.mr.licenseManagement;
this.isLoadingLicenseReport = true;
Promise.all([this.service.fetchReport(head_path), this.service.fetchReport(base_path)])
.then(values => {
this.mr.parseLicenseReportMetrics(values[0], values[1]);
this.isLoadingLicenseReport = false;
})
.catch(() => {
this.isLoadingLicenseReport = false;
this.loadingLicenseReportFailed = true;
});
},
translateText(type) {
return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
......@@ -243,6 +289,17 @@ export default {
:vulnerability-feedback-help-path="mr.vulnerabilityFeedbackHelpPath"
:pipeline-id="mr.securityReportsPipelineId"
/>
<report-section
class="js-license-report-widget mr-widget-border-top"
v-if="shouldRenderLicenseReport"
type="license"
:status="licenseReportStatus"
:loading-text="translateText('license management').loading"
:error-text="translateText('license management').error"
:success-text="licenseReportText"
:unresolved-issues="mr.licenseReport"
:has-issues="hasLicenseReportIssues"
/>
<div class="mr-widget-section">
<component
:is="componentName"
......
......@@ -22,6 +22,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.initCodeclimate(data);
this.initPerformanceReport(data);
this.initLicenseReport(data);
}
setData(data) {
......@@ -67,6 +68,11 @@ export default class MergeRequestStore extends CEMergeRequestStore {
};
}
initLicenseReport(data) {
this.licenseManagement = data.license_management;
this.licenseReport = [];
}
compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) {
const parsedHeadIssues = MergeRequestStore.parseCodeclimateMetrics(headIssues, headBlobPath);
const parsedBaseIssues = MergeRequestStore.parseCodeclimateMetrics(baseIssues, baseBlobPath);
......@@ -127,6 +133,44 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.performanceMetrics = { improved, degraded, neutral };
}
parseLicenseReportMetrics(headMetrics, baseMetrics) {
const headLicenses = headMetrics.licenses;
const headDependencies = headMetrics.dependencies;
const baseLicenses = baseMetrics.licenses;
if (headLicenses.length > 0 && headDependencies.length > 0) {
const report = {};
const knownLicenses = baseLicenses.map(license => license.name);
const newLicenses = [];
headLicenses.forEach(license => {
if (knownLicenses.indexOf(license.name) === -1) {
report[license.name] = {
name: license.name,
count: license.count,
url: '',
packages: [],
};
newLicenses.push(license.name);
}
});
headDependencies.forEach(dependencyItem => {
const licenseName = dependencyItem.license.name;
if (newLicenses.indexOf(licenseName) > -1) {
if (!report[licenseName].url) {
report[licenseName].url = dependencyItem.license.url;
}
report[licenseName].packages.push(dependencyItem.dependency);
}
});
this.licenseReport = newLicenses.map(licenseName => report[licenseName]);
}
}
// normalize performance metrics by indexing on performance subject and metric name
static normalizePerformanceMetrics(performanceData) {
const indexedSubjects = {};
......
......@@ -44,6 +44,11 @@ export default {
isFullReportVisible: false,
};
},
computed: {
unresolvedIssuesStatus() {
return this.type === 'license' ? 'neutral' : 'failed';
},
},
methods: {
openFullReport() {
this.isFullReportVisible = true;
......@@ -59,7 +64,7 @@ export default {
class="js-mr-code-new-issues"
v-if="unresolvedIssues.length"
:type="type"
status="failed"
:status="unresolvedIssuesStatus"
:issues="unresolvedIssues"
/>
......
......@@ -2,6 +2,7 @@
import Icon from '~/vue_shared/components/icon.vue';
import PerformanceIssue from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import CodequalityIssue from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue';
import LicenseIssue from 'ee/vue_merge_request_widget/components/license_issue_body.vue';
import SastIssue from './sast_issue_body.vue';
import SastContainerIssue from './sast_container_issue_body.vue';
import DastIssue from './dast_issue_body.vue';
......@@ -17,13 +18,14 @@ export default {
DastIssue,
PerformanceIssue,
CodequalityIssue,
LicenseIssue,
},
props: {
issues: {
type: Array,
required: true,
},
// security || codequality || performance || docker || dast
// security || codequality || performance || docker || dast || license
type: {
type: String,
required: true,
......@@ -59,6 +61,9 @@ export default {
isTypePerformance() {
return this.type === 'performance';
},
isTypeLicense() {
return this.type === 'license';
},
isTypeSast() {
return this.type === SAST;
},
......@@ -89,6 +94,13 @@ export default {
}"
>
<icon
v-if="isTypeLicense"
name="status_created_borderless"
css-classes="prepend-left-4"
:size="24"
/>
<icon
v-else
:name="iconName"
:size="32"
/>
......@@ -120,6 +132,11 @@ export default {
v-else-if="isTypePerformance"
:issue="issue"
/>
<license-issue
v-else-if="isTypeLicense"
:issue="issue"
/>
</li>
</ul>
</div>
......
......@@ -56,6 +56,19 @@
list-style: none;
padding: 0 1px;
margin: 0;
.license-item {
line-height: $gl-padding-24;
.license-dependencies {
color: $gl-text-color-tertiary;
}
.btn-show-all-packages {
line-height: $gl-btn-line-height;
margin-bottom: 2px;
}
}
}
.report-block-list-icon {
......
---
title: Add License Management results in the MR widget
merge_request:
author:
type: added
import Vue from 'vue';
import LicenseIssueBody from 'ee/vue_merge_request_widget/components/license_issue_body.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { licenseReport } from '../mock_data';
const licenseReportIssue = licenseReport[0];
const createComponent = (issue = licenseReportIssue) => {
const Component = Vue.extend(LicenseIssueBody);
return mountComponent(Component, { issue });
};
describe('LicenseIssueBody', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('remainingPackages', () => {
it('returns string with count of issue.packages when it exceeds `displayPackageCount` prop', () => {
expect(vm.remainingPackages).toBe('2 more');
});
it('returns empty string when count of issue.packages does not exceed `displayPackageCount` prop', (done) => {
vm.displayPackageCount = licenseReportIssue.packages.length + 1;
Vue.nextTick()
.then(() => {
expect(vm.remainingPackages).toBe('');
})
.then(done)
.catch(done.fail);
});
});
});
describe('methods', () => {
describe('getPackagesString', () => {
it('returns string containing name of package when issue.packages contains only one item', (done) => {
vm.issue = Object.assign({}, licenseReportIssue, {
// We need only 3 elements as it is same as
// default value of `displayPackageCount`
// which is 3.
packages: licenseReportIssue.packages.slice(0, 1),
});
Vue.nextTick()
.then(() => {
expect(vm.getPackagesString(true)).toBe('pg');
})
.then(done)
.catch(done.fail);
});
it('returns string with comma separated names of packages up to 3 when `truncate` param is true and issue.packages count exceeds `displayPackageCount`', () => {
expect(vm.getPackagesString(true)).toBe('pg, puma, foo and ');
});
it('returns string with comma separated names of all the packages when `truncate` param is true and issue.packages count does NOT exceed `displayPackageCount`', (done) => {
vm.issue = Object.assign({}, licenseReportIssue, {
// We need only 3 elements as it is same as
// default value of `displayPackageCount`
// which is 3.
packages: licenseReportIssue.packages.slice(0, 3),
});
Vue.nextTick()
.then(() => {
expect(vm.getPackagesString(true)).toBe('pg, puma and foo');
})
.then(done)
.catch(done.fail);
});
it('returns string with comma separated names of all the packages when `truncate` param is false irrespective of issue.packages count', () => {
expect(vm.getPackagesString(false)).toBe('pg, puma, foo, bar and baz');
});
});
describe('handleShowPackages', () => {
it('sets value of `showAllPackages` prop to true', () => {
vm.showAllPackages = false;
vm.handleShowPackages();
expect(vm.showAllPackages).toBe(true);
});
});
});
describe('template', () => {
it('renders component container element with class `license-item`', () => {
expect(vm.$el.classList.contains('license-item')).toBe(true);
});
it('renders license link element', () => {
const linkEl = vm.$el.querySelector('a');
expect(linkEl).not.toBeNull();
expect(linkEl.getAttribute('href')).toBe(licenseReportIssue.url);
expect(linkEl.innerText.trim()).toBe(licenseReportIssue.name);
});
it('renders packages list for a particular license', () => {
const packagesEl = vm.$el.querySelector('.license-dependencies');
expect(packagesEl).not.toBeNull();
expect(packagesEl.innerText.trim()).toBe('pg, puma, foo and');
});
it('renders more packages button element', () => {
const buttonEl = vm.$el.querySelector('.btn-show-all-packages');
expect(buttonEl).not.toBeNull();
expect(buttonEl.innerText.trim()).toBe('2 more');
});
});
});
......@@ -6,7 +6,14 @@ import MRWidgetService from 'ee/vue_merge_request_widget/services/mr_widget_serv
import MRWidgetStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import state from 'ee/vue_shared/security_reports/store/state';
import mockData, { baseIssues, headIssues, basePerformance, headPerformance } from './mock_data';
import mockData, {
baseIssues,
headIssues,
basePerformance,
headPerformance,
licenseBaseIssues,
licenseHeadIssues,
} from './mock_data';
import {
sastIssues,
......@@ -654,6 +661,106 @@ describe('ee merge request widget options', () => {
});
});
describe('license management report', () => {
beforeEach(() => {
gl.mrWidgetData = {
...mockData,
license_management: {
head_path: 'head.json',
base_path: 'base.json',
},
};
Component.mr = new MRWidgetStore(gl.mrWidgetData);
Component.service = new MRWidgetService({});
});
describe('when it is loading', () => {
it('should render loading indicator', () => {
mock.onGet('head.json').reply(200, licenseHeadIssues);
mock.onGet('base.json').reply(200, licenseBaseIssues);
vm = mountComponent(Component);
expect(
removeBreakLine(vm.$el.querySelector('.js-license-report-widget').textContent),
).toContain('Loading license management report');
});
});
describe('with successful request', () => {
beforeEach(() => {
mock.onGet('head.json').reply(200, licenseHeadIssues);
mock.onGet('base.json').reply(200, licenseBaseIssues);
vm = mountComponent(Component);
});
it('should render report overview', done => {
setTimeout(() => {
expect(
removeBreakLine(
vm.$el.querySelector('.js-license-report-widget .js-code-text').textContent,
),
).toEqual('License management detected 1 new license');
done();
}, 0);
});
it('should render report issues list in section body', done => {
setTimeout(() => {
const sectionBodyEl = vm.$el.querySelector(
'.js-license-report-widget .js-report-section-container',
);
expect(sectionBodyEl).not.toBeNull();
expect(sectionBodyEl.querySelectorAll('li.report-block-list-issue').length).toBe(
licenseHeadIssues.licenses.length - 1,
);
done();
}, 0);
});
});
describe('with empty successful request', () => {
beforeEach(() => {
mock.onGet('head.json').reply(200, licenseBaseIssues);
mock.onGet('base.json').reply(200, licenseBaseIssues);
vm = mountComponent(Component);
});
afterEach(() => {
mock.restore();
});
it('should render report overview', done => {
setTimeout(() => {
expect(
removeBreakLine(
vm.$el.querySelector('.js-license-report-widget .js-code-text').textContent,
),
).toEqual('License management detected no new licenses');
done();
}, 0);
});
});
describe('with failed request', () => {
beforeEach(() => {
mock.onGet('head.json').reply(500, {});
mock.onGet('base.json').reply(500, {});
vm = mountComponent(Component);
});
it('should render error indicator', done => {
setTimeout(() => {
expect(
removeBreakLine(
vm.$el.querySelector('.js-license-report-widget .js-code-text').textContent,
),
).toContain('Failed to load license management report');
done();
}, 0);
});
});
});
describe('computed', () => {
describe('shouldRenderApprovals', () => {
it('should return false when no approvals', () => {
......
......@@ -224,8 +224,10 @@ export default {
base_path: 'blob_path',
head_path: 'blob_path',
},
vulnerability_feedback_help_path: '/help/user/project/merge_requests/index#interacting-with-security-reports-ultimate',
merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775',
vulnerability_feedback_help_path:
'/help/user/project/merge_requests/index#interacting-with-security-reports-ultimate',
merge_commit_path:
'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775',
};
// Codeclimate
export const headIssues = [
......@@ -396,3 +398,141 @@ export const codequalityParsedIssues = [
urlPath: 'foo/Gemfile.lock',
},
];
export const licenseBaseIssues = {
licenses: [
{
count: 1,
name: 'MIT',
},
],
dependencies: [
{
license: {
name: 'MIT',
url: 'http://opensource.org/licenses/mit-license',
},
dependency: {
name: 'bundler',
url: 'http://bundler.io',
description: 'The best way to manage your application\'s dependencies',
pathes: [
'.',
],
},
},
],
};
export const licenseHeadIssues = {
licenses: [
{
count: 3,
name: 'New BSD',
},
{
count: 1,
name: 'MIT',
},
],
dependencies: [
{
license: {
name: 'New BSD',
url: 'http://opensource.org/licenses/BSD-3-Clause',
},
dependency: {
name: 'pg',
url: 'https://bitbucket.org/ged/ruby-pg',
description:
'Pg is the Ruby interface to the {PostgreSQL RDBMS}[http://www.postgresql.org/]',
pathes: ['.'],
},
},
{
license: {
name: 'New BSD',
url: 'http://opensource.org/licenses/BSD-3-Clause',
},
dependency: {
name: 'puma',
url: 'http://puma.io',
description:
'Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications',
pathes: ['.'],
},
},
{
license: {
name: 'New BSD',
url: 'http://opensource.org/licenses/BSD-3-Clause',
},
dependency: {
name: 'foo',
url: 'http://foo.io',
description:
'Foo is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications',
pathes: ['.'],
},
},
{
license: {
name: 'MIT',
url: 'http://opensource.org/licenses/mit-license',
},
dependency: {
name: 'execjs',
url: 'https://github.com/rails/execjs',
description: 'Run JavaScript code from Ruby',
pathes: [
'.',
],
},
},
],
};
export const licenseReport = [
{
name: 'New BSD',
count: 5,
url: 'http://opensource.org/licenses/BSD-3-Clause',
packages: [
{
name: 'pg',
url: 'https://bitbucket.org/ged/ruby-pg',
description:
'Pg is the Ruby interface to the {PostgreSQL RDBMS}[http://www.postgresql.org/]',
pathes: ['.'],
},
{
name: 'puma',
url: 'http://puma.io',
description:
'Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications',
pathes: ['.'],
},
{
name: 'foo',
url: 'https://bitbucket.org/ged/ruby-pg',
description:
'Pg is the Ruby interface to the {PostgreSQL RDBMS}[http://www.postgresql.org/]',
pathes: ['.'],
},
{
name: 'bar',
url: 'http://puma.io',
description:
'Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications',
pathes: ['.'],
},
{
name: 'baz',
url: 'https://bitbucket.org/ged/ruby-pg',
description:
'Pg is the Ruby interface to the {PostgreSQL RDBMS}[http://www.postgresql.org/]',
pathes: ['.'],
},
],
},
];
......@@ -5,6 +5,8 @@ import mockData, {
baseIssues,
parsedBaseIssues,
parsedHeadIssues,
licenseBaseIssues,
licenseHeadIssues,
} from '../mock_data';
describe('MergeRequestStore', () => {
......@@ -95,6 +97,26 @@ describe('MergeRequestStore', () => {
});
});
describe('parseLicenseReportMetrics', () => {
it('should parse the received issues', () => {
store.parseLicenseReportMetrics(licenseHeadIssues, licenseBaseIssues);
expect(store.licenseReport[0].name).toBe(licenseHeadIssues.licenses[0].name);
expect(store.licenseReport[0].url).toBe(licenseHeadIssues.dependencies[0].license.url);
});
it('should ommit issues from base report', () => {
const knownLicenseName = licenseBaseIssues.licenses[0].name;
store.parseLicenseReportMetrics(licenseHeadIssues, licenseBaseIssues);
expect(store.licenseReport.length).toBe(licenseHeadIssues.licenses.length - 1);
expect(store.licenseReport[0].packages.length).toBe(
licenseHeadIssues.dependencies.length - 1,
);
store.licenseReport.forEach(license => {
expect(license.name).not.toBe(knownLicenseName);
});
});
});
describe('isNothingToMergeState', () => {
it('returns true when nothingToMerge', () => {
store.state = stateKey.nothingToMerge;
......
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