Commit 4ff2e634 authored by Kushal Pandya's avatar Kushal Pandya

Add license report information to MR widget

parent 682abe40
...@@ -20,8 +20,10 @@ export default { ...@@ -20,8 +20,10 @@ export default {
return { return {
isLoadingCodequality: false, isLoadingCodequality: false,
isLoadingPerformance: false, isLoadingPerformance: false,
isLoadingLicenseReport: false,
loadingCodequalityFailed: false, loadingCodequalityFailed: false,
loadingPerformanceFailed: false, loadingPerformanceFailed: false,
loadingLicenseReportFailed: false,
}; };
}, },
computed: { computed: {
...@@ -32,6 +34,10 @@ export default { ...@@ -32,6 +34,10 @@ export default {
const { codeclimate } = this.mr; const { codeclimate } = this.mr;
return codeclimate && codeclimate.head_path && codeclimate.base_path; return codeclimate && codeclimate.head_path && codeclimate.base_path;
}, },
shouldRenderLicenseReport() {
const { licenseManagement } = this.mr;
return licenseManagement && licenseManagement.head_path && licenseManagement.base_path;
},
hasCodequalityIssues() { hasCodequalityIssues() {
return ( return (
this.mr.codeclimateMetrics && this.mr.codeclimateMetrics &&
...@@ -49,6 +55,10 @@ export default { ...@@ -49,6 +55,10 @@ export default {
(this.mr.performanceMetrics.neutral && this.mr.performanceMetrics.neutral.length > 0)) (this.mr.performanceMetrics.neutral && this.mr.performanceMetrics.neutral.length > 0))
); );
}, },
hasLicenseReportIssues() {
const { licenseReport } = this.mr;
return licenseReport && licenseReport.length > 0;
},
shouldRenderPerformance() { shouldRenderPerformance() {
const { performance } = this.mr; const { performance } = this.mr;
return performance && performance.head_path && performance.base_path; return performance && performance.head_path && performance.base_path;
...@@ -111,6 +121,18 @@ export default { ...@@ -111,6 +121,18 @@ export default {
return text.join(''); 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() { codequalityStatus() {
return this.checkReportStatus(this.isLoadingCodequality, this.loadingCodequalityFailed); return this.checkReportStatus(this.isLoadingCodequality, this.loadingCodequalityFailed);
}, },
...@@ -118,6 +140,10 @@ export default { ...@@ -118,6 +140,10 @@ export default {
performanceStatus() { performanceStatus() {
return this.checkReportStatus(this.isLoadingPerformance, this.loadingPerformanceFailed); return this.checkReportStatus(this.isLoadingPerformance, this.loadingPerformanceFailed);
}, },
licenseReportStatus() {
return this.checkReportStatus(this.isLoadingLicenseReport, this.loadingLicenseReportFailed);
},
}, },
created() { created() {
if (this.shouldRenderCodeQuality) { if (this.shouldRenderCodeQuality) {
...@@ -127,6 +153,10 @@ export default { ...@@ -127,6 +153,10 @@ export default {
if (this.shouldRenderPerformance) { if (this.shouldRenderPerformance) {
this.fetchPerformance(); this.fetchPerformance();
} }
if (this.shouldRenderLicenseReport) {
this.fetchLicenseReport();
}
}, },
methods: { methods: {
fetchCodeQuality() { fetchCodeQuality() {
...@@ -166,6 +196,22 @@ export default { ...@@ -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) { translateText(type) {
return { return {
error: sprintf(s__('ciReport|Failed to load %{reportName} report'), { error: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
...@@ -243,6 +289,17 @@ export default { ...@@ -243,6 +289,17 @@ export default {
:vulnerability-feedback-help-path="mr.vulnerabilityFeedbackHelpPath" :vulnerability-feedback-help-path="mr.vulnerabilityFeedbackHelpPath"
:pipeline-id="mr.securityReportsPipelineId" :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"> <div class="mr-widget-section">
<component <component
:is="componentName" :is="componentName"
......
...@@ -22,6 +22,7 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -22,6 +22,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.initCodeclimate(data); this.initCodeclimate(data);
this.initPerformanceReport(data); this.initPerformanceReport(data);
this.initLicenseReport(data);
} }
setData(data) { setData(data) {
...@@ -67,6 +68,11 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -67,6 +68,11 @@ export default class MergeRequestStore extends CEMergeRequestStore {
}; };
} }
initLicenseReport(data) {
this.licenseManagement = data.license_management;
this.licenseReport = [];
}
compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) { compareCodeclimateMetrics(headIssues, baseIssues, headBlobPath, baseBlobPath) {
const parsedHeadIssues = MergeRequestStore.parseCodeclimateMetrics(headIssues, headBlobPath); const parsedHeadIssues = MergeRequestStore.parseCodeclimateMetrics(headIssues, headBlobPath);
const parsedBaseIssues = MergeRequestStore.parseCodeclimateMetrics(baseIssues, baseBlobPath); const parsedBaseIssues = MergeRequestStore.parseCodeclimateMetrics(baseIssues, baseBlobPath);
...@@ -127,6 +133,44 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -127,6 +133,44 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.performanceMetrics = { improved, degraded, neutral }; 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 // normalize performance metrics by indexing on performance subject and metric name
static normalizePerformanceMetrics(performanceData) { static normalizePerformanceMetrics(performanceData) {
const indexedSubjects = {}; const indexedSubjects = {};
......
...@@ -44,6 +44,11 @@ export default { ...@@ -44,6 +44,11 @@ export default {
isFullReportVisible: false, isFullReportVisible: false,
}; };
}, },
computed: {
unresolvedIssuesStatus() {
return this.type === 'license' ? 'neutral' : 'failed';
},
},
methods: { methods: {
openFullReport() { openFullReport() {
this.isFullReportVisible = true; this.isFullReportVisible = true;
...@@ -59,7 +64,7 @@ export default { ...@@ -59,7 +64,7 @@ export default {
class="js-mr-code-new-issues" class="js-mr-code-new-issues"
v-if="unresolvedIssues.length" v-if="unresolvedIssues.length"
:type="type" :type="type"
status="failed" :status="unresolvedIssuesStatus"
:issues="unresolvedIssues" :issues="unresolvedIssues"
/> />
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import PerformanceIssue from 'ee/vue_merge_request_widget/components/performance_issue_body.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 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 SastIssue from './sast_issue_body.vue';
import SastContainerIssue from './sast_container_issue_body.vue'; import SastContainerIssue from './sast_container_issue_body.vue';
import DastIssue from './dast_issue_body.vue'; import DastIssue from './dast_issue_body.vue';
...@@ -17,13 +18,14 @@ export default { ...@@ -17,13 +18,14 @@ export default {
DastIssue, DastIssue,
PerformanceIssue, PerformanceIssue,
CodequalityIssue, CodequalityIssue,
LicenseIssue,
}, },
props: { props: {
issues: { issues: {
type: Array, type: Array,
required: true, required: true,
}, },
// security || codequality || performance || docker || dast // security || codequality || performance || docker || dast || license
type: { type: {
type: String, type: String,
required: true, required: true,
...@@ -59,6 +61,9 @@ export default { ...@@ -59,6 +61,9 @@ export default {
isTypePerformance() { isTypePerformance() {
return this.type === 'performance'; return this.type === 'performance';
}, },
isTypeLicense() {
return this.type === 'license';
},
isTypeSast() { isTypeSast() {
return this.type === SAST; return this.type === SAST;
}, },
...@@ -89,6 +94,13 @@ export default { ...@@ -89,6 +94,13 @@ export default {
}" }"
> >
<icon <icon
v-if="isTypeLicense"
name="status_created_borderless"
css-classes="prepend-left-4"
:size="24"
/>
<icon
v-else
:name="iconName" :name="iconName"
:size="32" :size="32"
/> />
...@@ -120,6 +132,11 @@ export default { ...@@ -120,6 +132,11 @@ export default {
v-else-if="isTypePerformance" v-else-if="isTypePerformance"
:issue="issue" :issue="issue"
/> />
<license-issue
v-else-if="isTypeLicense"
:issue="issue"
/>
</li> </li>
</ul> </ul>
</div> </div>
......
...@@ -56,6 +56,19 @@ ...@@ -56,6 +56,19 @@
list-style: none; list-style: none;
padding: 0 1px; padding: 0 1px;
margin: 0; 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 { .report-block-list-icon {
......
...@@ -6,7 +6,14 @@ import MRWidgetService from 'ee/vue_merge_request_widget/services/mr_widget_serv ...@@ -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 MRWidgetStore from 'ee/vue_merge_request_widget/stores/mr_widget_store';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import state from 'ee/vue_shared/security_reports/store/state'; 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 { import {
sastIssues, sastIssues,
...@@ -654,6 +661,106 @@ describe('ee merge request widget options', () => { ...@@ -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('computed', () => {
describe('shouldRenderApprovals', () => { describe('shouldRenderApprovals', () => {
it('should return false when no approvals', () => { it('should return false when no approvals', () => {
......
...@@ -224,8 +224,10 @@ export default { ...@@ -224,8 +224,10 @@ export default {
base_path: 'blob_path', base_path: 'blob_path',
head_path: 'blob_path', head_path: 'blob_path',
}, },
vulnerability_feedback_help_path: '/help/user/project/merge_requests/index#interacting-with-security-reports-ultimate', vulnerability_feedback_help_path:
merge_commit_path: 'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775', '/help/user/project/merge_requests/index#interacting-with-security-reports-ultimate',
merge_commit_path:
'http://localhost:3000/root/acets-app/commit/53027d060246c8f47e4a9310fb332aa52f221775',
}; };
// Codeclimate // Codeclimate
export const headIssues = [ export const headIssues = [
...@@ -396,3 +398,141 @@ export const codequalityParsedIssues = [ ...@@ -396,3 +398,141 @@ export const codequalityParsedIssues = [
urlPath: 'foo/Gemfile.lock', 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, { ...@@ -5,6 +5,8 @@ import mockData, {
baseIssues, baseIssues,
parsedBaseIssues, parsedBaseIssues,
parsedHeadIssues, parsedHeadIssues,
licenseBaseIssues,
licenseHeadIssues,
} from '../mock_data'; } from '../mock_data';
describe('MergeRequestStore', () => { describe('MergeRequestStore', () => {
...@@ -95,6 +97,26 @@ 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', () => { describe('isNothingToMergeState', () => {
it('returns true when nothingToMerge', () => { it('returns true when nothingToMerge', () => {
store.state = stateKey.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