summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality.vue175
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/dependencies.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js27
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss39
-rw-r--r--app/models/ci/build.rb1
-rw-r--r--app/models/ci/pipeline.rb6
-rw-r--r--app/models/merge_request.rb17
-rw-r--r--app/serializers/merge_request_entity.rb16
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request.json6
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_issues_spec.js99
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_spec.js178
-rw-r--r--spec/javascripts/vue_mr_widget/mock_data.js65
-rw-r--r--spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js53
-rw-r--r--spec/models/ci/pipeline_spec.rb20
-rw-r--r--spec/models/merge_request_spec.rb6
18 files changed, 760 insertions, 14 deletions
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality.vue
new file mode 100644
index 00000000000..b27e89ca300
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality.vue
@@ -0,0 +1,175 @@
+<script>
+import successIcon from 'icons/_icon_status_success.svg';
+import errorIcon from 'icons/_icon_status_failed.svg';
+import issuesBlock from './mr_widget_code_quality_issues.vue';
+import loadingIcon from '../../vue_shared/components/loading_icon.vue';
+import '../../lib/utils/text_utility';
+
+export default {
+ name: 'MRWidgetCodeQuality',
+
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ service: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ components: {
+ issuesBlock,
+ loadingIcon,
+ },
+
+ data() {
+ return {
+ collapseText: 'Expand',
+ isCollapsed: true,
+ isLoading: false,
+ loadingFailed: false,
+ };
+ },
+
+ computed: {
+ stateIcon() {
+ return this.mr.codeclimateMetrics.newIssues.length ? errorIcon : successIcon;
+ },
+
+ hasNoneIssues() {
+ const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
+ return !newIssues.length && !resolvedIssues.length;
+ },
+
+ hasIssues() {
+ const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
+ return newIssues.length || resolvedIssues.length;
+ },
+
+ codeText() {
+ const { newIssues, resolvedIssues } = this.mr.codeclimateMetrics;
+ let newIssuesText = '';
+ let resolvedIssuesText = '';
+ let text = '';
+
+ if (this.hasNoneIssues) {
+ text = 'No changes to code quality so far.';
+ } else if (this.hasIssues) {
+ if (newIssues.length) {
+ newIssuesText = `degraded on ${newIssues.length} ${this.pointsText(newIssues)}`;
+ }
+
+ if (resolvedIssues.length) {
+ resolvedIssuesText = `improved on ${resolvedIssues.length} ${this.pointsText(resolvedIssues)}`;
+ }
+
+ const connector = this.hasIssues ? 'and' : '';
+
+ text = `Code quality ${resolvedIssuesText} ${connector} ${newIssuesText}.`;
+ }
+
+ return text;
+ },
+ },
+
+ methods: {
+ pointsText(issues) {
+ return gl.text.pluralize('point', issues.length);
+ },
+
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+
+ const text = this.isCollapsed ? 'Expand' : 'Collapse';
+ this.collapseText = text;
+ },
+
+ handleError() {
+ this.isLoading = false;
+ this.loadingFailed = true;
+ },
+ },
+
+ created() {
+ const { head_path, base_path } = this.mr.codeclimate;
+
+ this.isLoading = true;
+
+ this.service.fetchCodeclimate(head_path)
+ .then(resp => resp.json())
+ .then((data) => {
+ this.mr.setCodeclimateHeadMetrics(data);
+ this.service.fetchCodeclimate(base_path)
+ .then(response => response.json())
+ .then(baseData => this.mr.setCodeclimateBaseMetrics(baseData))
+ .then(() => this.mr.compareCodeclimateMetrics())
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch(() => this.handleError());
+ })
+ .catch(() => this.handleError());
+ },
+};
+</script>
+<template>
+ <section class="mr-widget-code-quality">
+ <div
+ v-if="isLoading"
+ class="padding-left">
+ <i
+ class="fa fa-spinner fa-spin"
+ aria-hidden="true">
+ </i>
+ Loading codeclimate report.
+ </div>
+
+ <div v-else-if="!isLoading && !loadingFailed">
+ <span
+ class="padding-left ci-status-icon"
+ :class="{
+ 'ci-status-icon-failed': mr.codeclimateMetrics.newIssues.length,
+ 'ci-status-icon-passed': mr.codeclimateMetrics.newIssues.length === 0
+ }"
+ v-html="stateIcon">
+ </span>
+ <span>
+ {{codeText}}
+ </span>
+
+ <button
+ type="button"
+ class="btn-link btn-blank"
+ v-if="hasIssues"
+ @click="toggleCollapsed">
+ {{collapseText}}
+ </button>
+
+ <div
+ class="code-quality-container"
+ v-if="hasIssues"
+ v-show="!isCollapsed">
+ <issues-block
+ class="js-mr-code-new-issues"
+ v-if="mr.codeclimateMetrics.newIssues.length"
+ type="failed"
+ :issues="mr.codeclimateMetrics.newIssues"
+ />
+
+ <issues-block
+ class="js-mr-code-resolved-issues"
+ v-if="mr.codeclimateMetrics.resolvedIssues.length"
+ type="success"
+ :issues="mr.codeclimateMetrics.resolvedIssues"
+ />
+ </div>
+ </div>
+ <div
+ v-else-if="loadingFailed"
+ class="padding-left">
+ Failed to load codeclimate report.
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue
new file mode 100644
index 00000000000..fe5c8c58e71
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue
@@ -0,0 +1,41 @@
+<script>
+export default {
+ name: 'MRWidgetCodeQualityIssues',
+ props: {
+ issues: {
+ type: Array,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <ul class="mr-widget-code-quality-list">
+ <li
+ class="commit-sha"
+ :class="{
+ failed: type === 'failed',
+ success: type === 'success'
+ }
+ "v-for="issue in issues">
+ <i
+ class="fa"
+ :class="{
+ 'fa-minus': type === 'failed',
+ 'fa-plus': type === 'success'
+ }"
+ aria-hidden="true">
+ </i>
+ <span>
+ {{issue.check_name}}
+ {{issue.location.path}}
+ {{issue.location.positions}}
+ {{issue.location.lines}}
+ </span>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
index bfe30ee4c08..435108c2f8a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js
+++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js
@@ -16,6 +16,7 @@ export { default as WidgetMergeHelp } from './components/mr_widget_merge_help';
export { default as WidgetPipeline } from './components/mr_widget_pipeline';
export { default as WidgetDeployment } from './components/mr_widget_deployment';
export { default as WidgetRelatedLinks } from './components/mr_widget_related_links';
+export { default as WidgetCodeQuality } from './components/mr_widget_code_quality.vue';
export { default as MergedState } from './components/states/mr_widget_merged';
export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge';
export { default as ClosedState } from './components/states/mr_widget_closed';
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
index 99600b6664e..adfc0f5e5b3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js
@@ -6,6 +6,7 @@ import {
WidgetPipeline,
WidgetDeployment,
WidgetRelatedLinks,
+ WidgetCodeQuality,
MergedState,
ClosedState,
LockedState,
@@ -58,6 +59,10 @@ export default {
shouldRenderDeployments() {
return this.mr.deployments.length;
},
+ shouldRenderCodeQuality() {
+ const { codeclimate } = this.mr;
+ return codeclimate && codeclimate.head_path && codeclimate.base_path;
+ },
},
methods: {
createService(store) {
@@ -79,13 +84,12 @@ export default {
.then((res) => {
this.mr.setData(res);
this.setFavicon();
+
if (cb) {
cb.call(null, res);
}
})
- .catch(() => {
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
+ .catch(() => new Flash('Something went wrong. Please try again.'));
},
initPolling() {
this.pollingInterval = new gl.SmartInterval({
@@ -132,9 +136,7 @@ export default {
document.body.appendChild(el);
}
})
- .catch(() => {
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
- });
+ .catch(() => new Flash('Something went wrong. Please try again.'));
},
resumePolling() {
this.pollingInterval.resume();
@@ -211,6 +213,7 @@ export default {
'mr-widget-pipeline-failed': PipelineFailedState,
'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
'mr-widget-auto-merge-failed': AutoMergeFailed,
+ 'mr-widget-code-quality': WidgetCodeQuality,
},
template: `
<div class="mr-state-widget prepend-top-default">
@@ -222,6 +225,11 @@ export default {
v-if="shouldRenderDeployments"
:mr="mr"
:service="service" />
+ <mr-widget-code-quality
+ v-if="shouldRenderCodeQuality"
+ :mr="mr"
+ :service="service"
+ />
<component
:is="componentName"
:mr="mr"
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index 79c3d335679..e24e5403772 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -47,6 +47,10 @@ export default class MRWidgetService {
return this.mergeActionsContentResource.get();
}
+ fetchCodeclimate(endpoint) { // eslint-disable-line
+ return Vue.http.get(endpoint);
+ }
+
static stopEnvironment(url) {
return Vue.http.post(url);
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index c07bd25e6fd..f520589fcad 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -5,6 +5,14 @@ export default class MergeRequestStore {
constructor(data) {
this.sha = data.diff_head_sha;
+ this.codeclimate = data.codeclimate;
+ this.codeclimateMetrics = {
+ headIssues: [],
+ baseIssues: [],
+ newIssues: [],
+ resolvedIssues: [],
+ };
+
this.setData(data);
}
@@ -110,6 +118,25 @@ export default class MergeRequestStore {
}
}
+ setCodeclimateHeadMetrics(data) {
+ this.codeclimateMetrics.headIssues = data;
+ }
+
+ setCodeclimateBaseMetrics(data) {
+ this.codeclimateMetrics.baseIssues = data;
+ }
+
+ compareCodeclimateMetrics() {
+ const { headIssues, baseIssues } = this.codeclimateMetrics;
+
+ this.codeclimateMetrics.newIssues = this.filterByFingerprint(headIssues, baseIssues);
+ this.codeclimateMetrics.resolvedIssues = this.filterByFingerprint(baseIssues, headIssues);
+ }
+
+ filterByFingerprint(firstArray, secondArray) { // eslint-disable-line
+ return firstArray.filter(item => !secondArray.find(el => el.fingerprint === item.fingerprint));
+ }
+
static getAuthorObject(event) {
if (!event) {
return {};
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 183be86f650..25c706e374c 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -812,3 +812,42 @@
}
}
}
+
+.mr-widget-code-quality {
+ padding-top: $gl-padding-top;
+
+ .padding-left {
+ padding-left: $gl-padding;
+ }
+
+ .ci-status-icon {
+ vertical-align: sub;
+
+ svg {
+ width: 22px;
+ height: 22px;
+ margin-right: 4px;
+ }
+ }
+
+ .code-quality-container {
+ border-top: 1px solid $gray-darker;
+ border-bottom: 1px solid $gray-darker;
+ padding: $gl-padding-top;
+ background-color: $gray-light;
+
+ .mr-widget-code-quality-list {
+ list-style: none;
+ padding: 0 36px;
+ margin: 0;
+
+ li.success {
+ color: $green-500;
+ }
+
+ li.failed {
+ color: $red-500;
+ }
+ }
+ }
+}
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 760ec8e5919..f1343dfb4fc 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -34,6 +34,7 @@ module Ci
scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual).relevant }
+ scope :codeclimate, ->() { where(name: 'codeclimate') }
mount_uploader :artifacts_file, ArtifactUploader
mount_uploader :artifacts_metadata, ArtifactUploader
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 81c30b0e077..523d006c1e9 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -396,6 +396,12 @@ module Ci
.fabricate!
end
+ def codeclimate_artifact
+ artifacts.codeclimate.find do |artifact|
+ artifact.options[:artifacts][:paths] == ['codeclimate.json']
+ end
+ end
+
private
def pipeline_data
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 2eec013fa9d..f53f9e2f267 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -924,4 +924,21 @@ class MergeRequest < ActiveRecord::Base
true
end
+
+ def base_pipeline
+ @base_pipeline ||= project.pipelines.find_by(sha: merge_request_diff&.base_commit_sha)
+ end
+
+ def codeclimate_artifact
+ @codeclimate_artifact ||= head_pipeline&.codeclimate_artifact
+ end
+
+ def base_codeclimate_artifact
+ @base_codeclimate_artifact ||= base_pipeline&.codeclimate_artifact
+ end
+
+ def has_codeclimate_data?
+ codeclimate_artifact&.success? &&
+ base_codeclimate_artifact&.success?
+ end
end
diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb
index b3247ae36dd..615b6a74029 100644
--- a/app/serializers/merge_request_entity.rb
+++ b/app/serializers/merge_request_entity.rb
@@ -177,6 +177,22 @@ class MergeRequestEntity < IssuableEntity
merge_request)
end
+ expose :codeclimate, if: lambda { |mr, _| mr.has_codeclimate_data? } do
+ expose :head_path do |merge_request|
+ raw_namespace_project_build_artifacts_url(merge_request.project.namespace,
+ merge_request.project,
+ merge_request.codeclimate_artifact,
+ path: 'codeclimate.json')
+ end
+
+ expose :base_path do |merge_request|
+ raw_namespace_project_build_artifacts_url(merge_request.project.namespace,
+ merge_request.project,
+ merge_request.base_codeclimate_artifact,
+ path: 'codeclimate.json')
+ end
+ end
+
private
delegate :current_user, to: :request
diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json
index 4afbb87453e..36df6ffe927 100644
--- a/spec/fixtures/api/schemas/entities/merge_request.json
+++ b/spec/fixtures/api/schemas/entities/merge_request.json
@@ -92,7 +92,11 @@
"diverged_commits_count": { "type": "integer" },
"commit_change_content_path": { "type": "string" },
"remove_wip_path": { "type": "string" },
- "commits_count": { "type": "integer" }
+ "commits_count": { "type": "integer" },
+ "codeclimate": {
+ "head_path": { "type": "string" },
+ "base_path": { "type": "string" }
+ }
},
"additionalProperties": false
}
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_issues_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_issues_spec.js
new file mode 100644
index 00000000000..0a8bab36af5
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_issues_spec.js
@@ -0,0 +1,99 @@
+import Vue from 'vue';
+import mrWidgetCodeQualityIssues from '~/vue_merge_request_widget/components/mr_widget_code_quality_issues.vue';
+
+describe('Merge Request Code Quality Issues', () => {
+ let vm;
+ let MRWidgetCodeQualityIssues;
+ let mountComponent;
+
+ beforeEach(() => {
+ MRWidgetCodeQualityIssues = Vue.extend(mrWidgetCodeQualityIssues);
+ mountComponent = props => new MRWidgetCodeQualityIssues({ propsData: props }).$mount();
+ });
+
+ describe('Renders provided list of issues', () => {
+ describe('with positions and lines', () => {
+ beforeEach(() => {
+ vm = mountComponent({
+ type: 'success',
+ issues: [{
+ check_name: 'foo',
+ location: {
+ path: 'bar',
+ positions: '81',
+ lines: '21',
+ },
+ }],
+ });
+ });
+
+ it('should render issue', () => {
+ expect(
+ vm.$el.querySelector('li span').textContent.trim().replace(/\s+/g, ''),
+ ).toEqual('foobar8121');
+ });
+ });
+
+ describe('without positions and lines', () => {
+ beforeEach(() => {
+ vm = mountComponent({
+ type: 'success',
+ issues: [{
+ check_name: 'foo',
+ location: {
+ path: 'bar',
+ },
+ }],
+ });
+ });
+
+ it('should render issue without position and lines', () => {
+ expect(
+ vm.$el.querySelector('li span').textContent.trim().replace(/\s+/g, ''),
+ ).toEqual('foobar');
+ });
+ });
+
+ describe('for type failed', () => {
+ beforeEach(() => {
+ vm = mountComponent({
+ type: 'failed',
+ issues: [{
+ check_name: 'foo',
+ location: {
+ path: 'bar',
+ positions: '81',
+ lines: '21',
+ },
+ }],
+ });
+ });
+
+ it('should render failed minus icon', () => {
+ expect(vm.$el.querySelector('li').classList.contains('failed')).toEqual(true);
+ expect(vm.$el.querySelector('li i').classList.contains('fa-minus')).toEqual(true);
+ });
+ });
+
+ describe('for type success', () => {
+ beforeEach(() => {
+ vm = mountComponent({
+ type: 'success',
+ issues: [{
+ check_name: 'foo',
+ location: {
+ path: 'bar',
+ positions: '81',
+ lines: '21',
+ },
+ }],
+ });
+ });
+
+ it('should render success plus icon', () => {
+ expect(vm.$el.querySelector('li').classList.contains('success')).toEqual(true);
+ expect(vm.$el.querySelector('li i').classList.contains('fa-plus')).toEqual(true);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_spec.js
new file mode 100644
index 00000000000..14ebaeee15e
--- /dev/null
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_code_quality_spec.js
@@ -0,0 +1,178 @@
+import Vue from 'vue';
+import mrWidgetCodeQuality from '~/vue_merge_request_widget/components/mr_widget_code_quality.vue';
+import Store from '~/vue_merge_request_widget/stores/mr_widget_store';
+import Service from '~/vue_merge_request_widget/services/mr_widget_service';
+import mockData, { baseIssues, headIssues } from '../mock_data';
+
+describe('Merge Request Code Quality', () => {
+ let vm;
+ let MRWidgetCodeQuality;
+ let store;
+ let mountComponent;
+ let service;
+
+ beforeEach(() => {
+ MRWidgetCodeQuality = Vue.extend(mrWidgetCodeQuality);
+ store = new Store(mockData);
+ service = new Service('');
+ mountComponent = props => new MRWidgetCodeQuality({ propsData: props }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('when it is loading', () => {
+ beforeEach(() => {
+ vm = mountComponent({
+ mr: store,
+ service,
+ });
+ });
+
+ it('should render loading indicator', () => {
+ expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report.');
+ });
+ });
+
+ describe('with successfull request', () => {
+ const interceptor = (request, next) => {
+ if (request.url === 'head.json') {
+ next(request.respondWith(JSON.stringify(headIssues), {
+ status: 200,
+ }));
+ }
+
+ if (request.url === 'base.json') {
+ next(request.respondWith(JSON.stringify(baseIssues), {
+ status: 200,
+ }));
+ }
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(interceptor);
+
+ vm = mountComponent({
+ mr: store,
+ service,
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ });
+
+ it('should render provided data', (done) => {
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('span:nth-child(2)').textContent.trim(),
+ ).toEqual('Code quality improved on 1 point and degraded on 1 point.');
+ done();
+ }, 0);
+ });
+
+ describe('toggleCollapsed', () => {
+ it('toggles issues', (done) => {
+ setTimeout(() => {
+ vm.$el.querySelector('button').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.code-quality-container').geAttribute('style'),
+ ).toEqual(null);
+ expect(
+ vm.$el.querySelector('button').textContent.trim(),
+ ).toEqual('Collapse');
+
+ vm.$el.querySelector('button').click();
+
+ Vue.nextTick(() => {
+ expect(
+ vm.$el.querySelector('.code-quality-container').geAttribute('style'),
+ ).toEqual('display: none;');
+ expect(
+ vm.$el.querySelector('button').textContent.trim(),
+ ).toEqual('Expand');
+ });
+ });
+ done();
+ }, 0);
+ });
+ });
+ });
+
+ describe('with empty successfull request', () => {
+ const emptyInterceptor = (request, next) => {
+ if (request.url === 'head.json') {
+ next(request.respondWith(JSON.stringify([]), {
+ status: 200,
+ }));
+ }
+
+ if (request.url === 'base.json') {
+ next(request.respondWith(JSON.stringify([]), {
+ status: 200,
+ }));
+ }
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(emptyInterceptor);
+
+ vm = mountComponent({
+ mr: store,
+ service,
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, emptyInterceptor);
+ });
+
+ it('should render provided data', (done) => {
+ setTimeout(() => {
+ expect(
+ vm.$el.querySelector('span:nth-child(2)').textContent.trim(),
+ ).toEqual('No changes to code quality so far.');
+ done();
+ }, 0);
+ });
+ });
+
+ describe('with failed request', () => {
+ const errorInterceptor = (request, next) => {
+ if (request.url === 'head.json') {
+ next(request.respondWith(JSON.stringify([]), {
+ status: 500,
+ }));
+ }
+
+ if (request.url === 'base.json') {
+ next(request.respondWith(JSON.stringify([]), {
+ status: 500,
+ }));
+ }
+ };
+
+ beforeEach(() => {
+ Vue.http.interceptors.push(errorInterceptor);
+
+ vm = mountComponent({
+ mr: store,
+ service,
+ });
+ });
+
+ afterEach(() => {
+ Vue.http.interceptors = _.without(Vue.http.interceptors, errorInterceptor);
+ });
+
+ it('should render error indicator', (done) => {
+ setTimeout(() => {
+ expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report.');
+ done();
+ }, 0);
+ });
+ });
+});
diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js
index e6f96d5588b..8deeb8f8ece 100644
--- a/spec/javascripts/vue_mr_widget/mock_data.js
+++ b/spec/javascripts/vue_mr_widget/mock_data.js
@@ -210,5 +210,68 @@ export default {
"merge_commit_message_with_description": "Merge branch 'daaaa' into 'master'\n\nUpdate README.md\n\nSee merge request !22",
"diverged_commits_count": 0,
"only_allow_merge_if_pipeline_succeeds": false,
- "commit_change_content_path": "/root/acets-app/merge_requests/22/commit_change_content"
+ "commit_change_content_path": "/root/acets-app/merge_requests/22/commit_change_content",
+ "codeclimate": {
+ "head_path": "head.json",
+ "base_path": "base.json"
+ }
}
+
+export const headIssues = [
+ {
+ "check_name": "Rubocop/Lint/UselessAssignment",
+ "location": {
+ "path": "lib/six.rb",
+ "positions": {
+ "begin": {
+ "column": 6,
+ "line": 59
+ },
+ "end": {
+ "column": 7,
+ "line": 59
+ }
+ }
+ },
+ "fingerprint": "e879dd9bbc0953cad5037cde7ff0f627",
+ },
+ {
+ "categories": ["Security"],
+ "check_name": "Insecure Dependency",
+ "location": {
+ "path": "Gemfile.lock",
+ "lines": {
+ "begin": 22,
+ "end": 22
+ }
+ },
+ "fingerprint": "ca2e59451e98ae60ba2f54e3857c50e5",
+ }
+];
+
+export const baseIssues = [
+ {
+ "categories": ["Security"],
+ "check_name": "Insecure Dependency",
+ "location": {
+ "path": "Gemfile.lock",
+ "lines": {
+ "begin": 22,
+ "end": 22
+ }
+ },
+ "fingerprint": "ca2e59451e98ae60ba2f54e3857c50e5",
+ },
+ {
+ "categories": ["Security"],
+ "check_name": "Insecure Dependency",
+ "location": {
+ "path": "Gemfile.lock",
+ "lines": {
+ "begin": 21,
+ "end": 21
+ }
+ },
+ "fingerprint": "ca2354534dee94ae60ba2f54e3857c50e5",
+ }
+]
diff --git a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
index 56dd0198ae2..0d1139f3662 100644
--- a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
+++ b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js
@@ -1,14 +1,14 @@
import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
-import mockData from '../mock_data';
+import mockData, { headIssues, baseIssues } from '../mock_data';
describe('MergeRequestStore', () => {
- describe('setData', () => {
- let store;
+ let store;
- beforeEach(() => {
- store = new MergeRequestStore(mockData);
- });
+ beforeEach(() => {
+ store = new MergeRequestStore(mockData);
+ });
+ describe('setData', () => {
it('should set hasSHAChanged when the diff SHA changes', () => {
store.setData({ ...mockData, diff_head_sha: 'a-different-string' });
expect(store.hasSHAChanged).toBe(true);
@@ -19,4 +19,45 @@ describe('MergeRequestStore', () => {
expect(store.hasSHAChanged).toBe(false);
});
});
+
+ describe('setCodeclimateHeadMetrics', () => {
+ it('should set defaults', () => {
+ expect(store.codeclimate).toEqual(mockData.codeclimate);
+ expect(store.codeclimateMetrics).toEqual({
+ headIssues: [],
+ baseIssues: [],
+ newIssues: [],
+ resolvedIssues: [],
+ });
+ });
+
+ it('should set the provided head metrics', () => {
+ store.setCodeclimateHeadMetrics(headIssues);
+ expect(store.codeclimateMetrics.headIssues).toEqual(headIssues);
+ });
+ });
+
+ describe('setCodeclimateBaseMetrics', () => {
+ it('should set the provided base metrics', () => {
+ store.setCodeclimateBaseMetrics(baseIssues);
+
+ expect(store.codeclimateMetrics.baseIssues).toEqual(baseIssues);
+ });
+ });
+
+ describe('compareCodeclimateMetrics', () => {
+ beforeEach(() => {
+ store.setCodeclimateHeadMetrics(headIssues);
+ store.setCodeclimateBaseMetrics(baseIssues);
+ store.compareCodeclimateMetrics();
+ });
+
+ it('should return the new issues', () => {
+ expect(store.codeclimateMetrics.newIssues[0]).toEqual(headIssues[0]);
+ });
+
+ it('should return the resolved issues', () => {
+ expect(store.codeclimateMetrics.resolvedIssues[0]).toEqual(baseIssues[1]);
+ });
+ });
});
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index c8023dc13b1..00e3b8e5689 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1218,4 +1218,24 @@ describe Ci::Pipeline, models: true do
it_behaves_like 'not sending any notification'
end
end
+
+ describe '#codeclimate_artifact' do
+ let!(:build) do
+ create(
+ :ci_build,
+ :artifacts,
+ name: 'codeclimate',
+ pipeline: pipeline,
+ options: {
+ artifacts: {
+ paths: ['codeclimate.json']
+ }
+ }
+ )
+ end
+
+ it do
+ expect(pipeline.codeclimate_artifact).to eq(build)
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index da915c49d3c..3645964b09c 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1535,6 +1535,12 @@ describe MergeRequest, models: true do
end
end
+ describe '#base_pipeline' do
+ let!(:pipeline) { create(:ci_empty_pipeline, project: subject.project, sha: subject.diff_base_sha) }
+
+ it { expect(subject.base_pipeline).to eq(pipeline) }
+ end
+
describe '#version_params_for' do
subject { create(:merge_request, importing: true) }
let(:project) { subject.project }