Commit 8fdf3d3d authored by Grzegorz Bizon's avatar Grzegorz Bizon

Merge branch 'master' into 'backstage/gb/use-persisted-stages-to-improve-pipelines-table-ee'

 Conflicts:
   app/models/ci/pipeline.rb
parents 062b9209 99ca7ab4
......@@ -54,7 +54,7 @@ export default {
placement: 'top',
content: sprintf(
__(`
The character highligher helps you keep the subject line to %{titleLength} characters
The character highlighter helps you keep the subject line to %{titleLength} characters
and wrap the body at %{bodyLength} so they are readable in git.
`),
{ titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH },
......
......@@ -177,6 +177,9 @@ table {
}
.nav-tabs {
// Override bootstrap's default border
border-bottom: 0;
.nav-link {
border: 0;
}
......
......@@ -5,13 +5,12 @@ class Groups::GroupMembersController < Groups::ApplicationController
include MembersPresentation
include SortingHelper
# Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access, :update, :override]
before_action :authorize_update_group_member!, only: [:update, :override]
def self.admin_not_required_endpoints
%i[index leave request_access]
end
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
:override
# Authorize
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
......
class Groups::MilestonesController < Groups::ApplicationController
prepend EE::Groups::MilestonesController
include MilestoneActions
before_action :group_projects
......@@ -77,17 +79,14 @@ class Groups::MilestonesController < Groups::ApplicationController
def milestones
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones =
if params[:only_group_milestones]
[]
else
GroupMilestone.build_collection(group, group_projects, params)
end
@sort = params[:sort] || 'due_date_asc'
MilestoneArray.sort(milestones + legacy_milestones, @sort)
end
def legacy_milestones
GroupMilestone.build_collection(group, group_projects, params)
end
def milestone
@milestone =
if params[:title]
......
......@@ -29,6 +29,10 @@ module Ci
has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
has_internal_id :iid, scope: :project, presence: false, init: ->(s) do
s&.project&.pipelines&.maximum(:iid) || s&.project&.pipelines&.count
end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
......
---
title: Include username in output when testing SSH to GitLab
merge_request: 19358
author:
type: other
---
title: Rails5 fix arel from
merge_request: 19340
author: Jasper Maes
type: fixed
---
title: Fix N+1 with source_projects in merge requests API
merge_request:
author:
type: performance
<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 {
......
......@@ -3,6 +3,19 @@ module EE
module GroupMembersController
extend ActiveSupport::Concern
class_methods do
extend ::Gitlab::Utils::Override
override :admin_not_required_endpoints
def admin_not_required_endpoints
super.concat(%i[update override])
end
end
included do
before_action :authorize_update_group_member!, only: [:update, :override]
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def override
member = @group.members.find_by!(id: params[:id])
......
module EE
module Groups
module MilestonesController
extend ::Gitlab::Utils::Override
override :legacy_milestones
def legacy_milestones
params[:only_group_milestones] ? [] : super
end
end
end
end
......@@ -332,14 +332,7 @@ class GeoNodeStatus < ActiveRecord::Base
end
def shards_match?(first, second)
# Developers may want to run Geo locally using different paths
return names_match?(first, second) if Rails.env.development?
sort_by_name(first) == sort_by_name(second)
end
def sort_by_name(shards)
shards.sort_by { |shard| shard['name'] }
names_match?(first, second)
end
def names_match?(first, second)
......
......@@ -4,26 +4,25 @@
class StorageShard
include ActiveModel::Model
attr_accessor :name, :path
attr_accessor :name
validates :name, presence: true
validates :path, presence: true
# Generates an array of StorageShard objects from the currrent storage
# configuration using the gitlab.yml array of key/value pairs:
#
# {"default"=>{"path"=>"/home/git/repositories", ...}
# {"default"=>{"gitaly_address"=>"/home/gitaly/gitaly.socket", ...}
#
# The key is the shard name, and the values are the parameters for that shard.
def self.all
Settings.repositories.storages.map do |name, params|
config = params.symbolize_keys.merge(name: name, path: params.legacy_disk_path)
config = params.symbolize_keys.merge(name: name)
config.slice!(*allowed_params)
StorageShard.new(config)
end
end
def self.allowed_params
%i(name path).freeze
%i(name).freeze
end
end
class StorageShardEntity < Grape::Entity
expose :name, :path
expose :name
end
---
title: Add License Management results in the MR widget
merge_request:
author:
type: added
......@@ -124,7 +124,6 @@ describe EE::API::Entities::GeoNodeStatus, :postgresql do
expect(subject[:storage_shards].count).to eq(shards.count)
expect(subject[:storage_shards].first[:name]).to eq(shards.first.name)
expect(subject[:storage_shards].first[:path]).to eq(shards.first.path)
end
end
......
......@@ -867,17 +867,5 @@ describe GeoNodeStatus, :geo do
expect(result.storage_shards_match?).to be true
end
context 'in development mode' do
before do
allow(Rails.env).to receive(:development?).and_return(true)
end
it 'returns true if keys are same but paths are different' do
data['storage_shards'].first['path'] = '/tmp/different-path'
expect(result.storage_shards_match?).to be_truthy
end
end
end
end
......@@ -7,7 +7,6 @@ describe StorageShard do
expect(shards.count).to eq(Settings.repositories.storages.count)
expect(shards.map(&:name)).to match_array(Settings.repositories.storages.keys)
expect(shards.map(&:path)).to match_array(Settings.repositories.storages.values.map(&:legacy_disk_path))
end
end
end
require 'spec_helper'
describe StorageShardEntity, :postgresql do
let(:entity) { described_class.new(StorageShard.new, request: double) }
subject { entity.as_json }
it { is_expected.to have_key(:name) }
end
......@@ -40,7 +40,7 @@ module API
merge_requests = MergeRequestsFinder.new(current_user, args).execute
.reorder(args[:order_by] => args[:sort])
merge_requests = paginate(merge_requests)
.preload(:target_project)
.preload(:source_project, :target_project)
return merge_requests if args[:view] == 'simple'
......
......@@ -143,8 +143,13 @@ module Gitlab
.order(arel_table[column_sym])
).as('row_id')
count = arel_table.from(arel_table.alias)
.project('COUNT(*)')
arel_from = if Gitlab.rails5?
arel_table.from.from(arel_table.alias)
else
arel_table.from(arel_table.alias)
end
count = arel_from.project('COUNT(*)')
.where(arel_table[partition_column].eq(arel_table.alias[partition_column]))
.as('ct')
......
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;
......
......@@ -3,7 +3,12 @@ require 'spec_helper'
describe Gitlab::CycleAnalytics::UsageData do
describe '#to_json' do
before do
Timecop.freeze do
# Since git commits only have second precision, round up to the
# nearest second to ensure we have accurate median and standard
# deviation calculations.
current_time = Time.at(Time.now.to_i)
Timecop.freeze(current_time) do
user = create(:user, :admin)
projects = create_list(:project, 2, :repository)
......@@ -37,13 +42,7 @@ describe Gitlab::CycleAnalytics::UsageData do
expected_values.each_pair do |op, value|
expect(stage_values).to have_key(op)
if op == :missing
expect(stage_values[op]).to eq(value)
else
# delta is used because of git timings that Timecop does not stub
expect(stage_values[op].to_i).to be_within(5).of(value.to_i)
end
expect(stage_values[op]).to eq(value)
end
end
end
......@@ -58,8 +57,8 @@ describe Gitlab::CycleAnalytics::UsageData do
missing: 0
},
plan: {
average: 2,
sd: 2,
average: 1,
sd: 0,
missing: 0
},
code: {
......
......@@ -4,12 +4,12 @@ module CycleAnalyticsHelpers
create_commit("Commit for ##{issue.iid}", issue.project, user, branch_name)
end
def create_commit(message, project, user, branch_name, count: 1)
def create_commit(message, project, user, branch_name, count: 1, commit_time: nil, skip_push_handler: false)
repository = project.repository
oldrev = repository.commit(branch_name).sha
oldrev = repository.commit(branch_name)&.sha || Gitlab::Git::BLANK_SHA
if Timecop.frozen? && Gitlab::GitalyClient.feature_enabled?(:operation_user_commit_files)
mock_gitaly_multi_action_dates(repository.raw)
mock_gitaly_multi_action_dates(repository.raw, commit_time)
end
commit_shas = Array.new(count) do |index|
......@@ -19,6 +19,8 @@ module CycleAnalyticsHelpers
commit_sha
end
return if skip_push_handler
GitPushService.new(project,
user,
oldrev: oldrev,
......@@ -44,13 +46,11 @@ module CycleAnalyticsHelpers
project.repository.add_branch(user, source_branch, 'master')
end
sha = project.repository.create_file(
user,
generate(:branch),
'content',
message: commit_message,
branch_name: source_branch)
project.repository.commit(sha)
# Cycle analytic specs often test with frozen times, which causes metrics to be
# pinned to the current time. For example, in the plan stage, we assume that an issue
# milestone has been created before any code has been written. We add a second
# to ensure that the plan time is positive.
create_commit(commit_message, project, user, source_branch, commit_time: Time.now + 1.second, skip_push_handler: true)
opts = {
title: 'Awesome merge_request',
......@@ -116,9 +116,9 @@ module CycleAnalyticsHelpers
protected: false)
end
def mock_gitaly_multi_action_dates(raw_repository)
def mock_gitaly_multi_action_dates(raw_repository, commit_time)
allow(raw_repository).to receive(:multi_action).and_wrap_original do |m, *args|
new_date = Time.now
new_date = commit_time || Time.now
branch_update = m.call(*args)
if branch_update.newrev
......
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