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 { ...@@ -54,7 +54,7 @@ export default {
placement: 'top', placement: 'top',
content: sprintf( 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. and wrap the body at %{bodyLength} so they are readable in git.
`), `),
{ titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH }, { titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH },
......
...@@ -177,6 +177,9 @@ table { ...@@ -177,6 +177,9 @@ table {
} }
.nav-tabs { .nav-tabs {
// Override bootstrap's default border
border-bottom: 0;
.nav-link { .nav-link {
border: 0; border: 0;
} }
......
...@@ -5,13 +5,12 @@ class Groups::GroupMembersController < Groups::ApplicationController ...@@ -5,13 +5,12 @@ class Groups::GroupMembersController < Groups::ApplicationController
include MembersPresentation include MembersPresentation
include SortingHelper include SortingHelper
# Authorize def self.admin_not_required_endpoints
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access, :update, :override] %i[index leave request_access]
before_action :authorize_update_group_member!, only: [:update, :override] end
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access, # Authorize
:approve_access_request, :leave, :resend_invite, before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
:override
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access, skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite, :approve_access_request, :leave, :resend_invite,
......
class Groups::MilestonesController < Groups::ApplicationController class Groups::MilestonesController < Groups::ApplicationController
prepend EE::Groups::MilestonesController
include MilestoneActions include MilestoneActions
before_action :group_projects before_action :group_projects
...@@ -77,17 +79,14 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -77,17 +79,14 @@ class Groups::MilestonesController < Groups::ApplicationController
def milestones def milestones
milestones = MilestonesFinder.new(search_params).execute 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' @sort = params[:sort] || 'due_date_asc'
MilestoneArray.sort(milestones + legacy_milestones, @sort) MilestoneArray.sort(milestones + legacy_milestones, @sort)
end end
def legacy_milestones
GroupMilestone.build_collection(group, group_projects, params)
end
def milestone def milestone
@milestone = @milestone =
if params[:title] if params[:title]
......
...@@ -29,6 +29,10 @@ module Ci ...@@ -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_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_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 :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, 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 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 { ...@@ -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 {
......
...@@ -3,6 +3,19 @@ module EE ...@@ -3,6 +3,19 @@ module EE
module GroupMembersController module GroupMembersController
extend ActiveSupport::Concern 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 # rubocop:disable Gitlab/ModuleWithInstanceVariables
def override def override
member = @group.members.find_by!(id: params[:id]) 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 ...@@ -332,14 +332,7 @@ class GeoNodeStatus < ActiveRecord::Base
end end
def shards_match?(first, second) def shards_match?(first, second)
# Developers may want to run Geo locally using different paths names_match?(first, second)
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'] }
end end
def names_match?(first, second) def names_match?(first, second)
......
...@@ -4,26 +4,25 @@ ...@@ -4,26 +4,25 @@
class StorageShard class StorageShard
include ActiveModel::Model include ActiveModel::Model
attr_accessor :name, :path attr_accessor :name
validates :name, presence: true validates :name, presence: true
validates :path, presence: true
# Generates an array of StorageShard objects from the currrent storage # Generates an array of StorageShard objects from the currrent storage
# configuration using the gitlab.yml array of key/value pairs: # 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. # The key is the shard name, and the values are the parameters for that shard.
def self.all def self.all
Settings.repositories.storages.map do |name, params| 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) config.slice!(*allowed_params)
StorageShard.new(config) StorageShard.new(config)
end end
end end
def self.allowed_params def self.allowed_params
%i(name path).freeze %i(name).freeze
end end
end end
class StorageShardEntity < Grape::Entity class StorageShardEntity < Grape::Entity
expose :name, :path expose :name
end 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 ...@@ -124,7 +124,6 @@ describe EE::API::Entities::GeoNodeStatus, :postgresql do
expect(subject[:storage_shards].count).to eq(shards.count) 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[:name]).to eq(shards.first.name)
expect(subject[:storage_shards].first[:path]).to eq(shards.first.path)
end end
end end
......
...@@ -867,17 +867,5 @@ describe GeoNodeStatus, :geo do ...@@ -867,17 +867,5 @@ describe GeoNodeStatus, :geo do
expect(result.storage_shards_match?).to be true expect(result.storage_shards_match?).to be true
end 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
end end
...@@ -7,7 +7,6 @@ describe StorageShard do ...@@ -7,7 +7,6 @@ describe StorageShard do
expect(shards.count).to eq(Settings.repositories.storages.count) expect(shards.count).to eq(Settings.repositories.storages.count)
expect(shards.map(&:name)).to match_array(Settings.repositories.storages.keys) 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 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 ...@@ -40,7 +40,7 @@ module API
merge_requests = MergeRequestsFinder.new(current_user, args).execute merge_requests = MergeRequestsFinder.new(current_user, args).execute
.reorder(args[:order_by] => args[:sort]) .reorder(args[:order_by] => args[:sort])
merge_requests = paginate(merge_requests) merge_requests = paginate(merge_requests)
.preload(:target_project) .preload(:source_project, :target_project)
return merge_requests if args[:view] == 'simple' return merge_requests if args[:view] == 'simple'
......
...@@ -143,8 +143,13 @@ module Gitlab ...@@ -143,8 +143,13 @@ module Gitlab
.order(arel_table[column_sym]) .order(arel_table[column_sym])
).as('row_id') ).as('row_id')
count = arel_table.from(arel_table.alias) arel_from = if Gitlab.rails5?
.project('COUNT(*)') 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])) .where(arel_table[partition_column].eq(arel_table.alias[partition_column]))
.as('ct') .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 ...@@ -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;
......
...@@ -3,7 +3,12 @@ require 'spec_helper' ...@@ -3,7 +3,12 @@ require 'spec_helper'
describe Gitlab::CycleAnalytics::UsageData do describe Gitlab::CycleAnalytics::UsageData do
describe '#to_json' do describe '#to_json' do
before 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) user = create(:user, :admin)
projects = create_list(:project, 2, :repository) projects = create_list(:project, 2, :repository)
...@@ -37,13 +42,7 @@ describe Gitlab::CycleAnalytics::UsageData do ...@@ -37,13 +42,7 @@ describe Gitlab::CycleAnalytics::UsageData do
expected_values.each_pair do |op, value| expected_values.each_pair do |op, value|
expect(stage_values).to have_key(op) expect(stage_values).to have_key(op)
expect(stage_values[op]).to eq(value)
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
end end
end end
end end
...@@ -58,8 +57,8 @@ describe Gitlab::CycleAnalytics::UsageData do ...@@ -58,8 +57,8 @@ describe Gitlab::CycleAnalytics::UsageData do
missing: 0 missing: 0
}, },
plan: { plan: {
average: 2, average: 1,
sd: 2, sd: 0,
missing: 0 missing: 0
}, },
code: { code: {
......
...@@ -4,12 +4,12 @@ module CycleAnalyticsHelpers ...@@ -4,12 +4,12 @@ module CycleAnalyticsHelpers
create_commit("Commit for ##{issue.iid}", issue.project, user, branch_name) create_commit("Commit for ##{issue.iid}", issue.project, user, branch_name)
end 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 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) 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 end
commit_shas = Array.new(count) do |index| commit_shas = Array.new(count) do |index|
...@@ -19,6 +19,8 @@ module CycleAnalyticsHelpers ...@@ -19,6 +19,8 @@ module CycleAnalyticsHelpers
commit_sha commit_sha
end end
return if skip_push_handler
GitPushService.new(project, GitPushService.new(project,
user, user,
oldrev: oldrev, oldrev: oldrev,
...@@ -44,13 +46,11 @@ module CycleAnalyticsHelpers ...@@ -44,13 +46,11 @@ module CycleAnalyticsHelpers
project.repository.add_branch(user, source_branch, 'master') project.repository.add_branch(user, source_branch, 'master')
end end
sha = project.repository.create_file( # Cycle analytic specs often test with frozen times, which causes metrics to be
user, # pinned to the current time. For example, in the plan stage, we assume that an issue
generate(:branch), # milestone has been created before any code has been written. We add a second
'content', # to ensure that the plan time is positive.
message: commit_message, create_commit(commit_message, project, user, source_branch, commit_time: Time.now + 1.second, skip_push_handler: true)
branch_name: source_branch)
project.repository.commit(sha)
opts = { opts = {
title: 'Awesome merge_request', title: 'Awesome merge_request',
...@@ -116,9 +116,9 @@ module CycleAnalyticsHelpers ...@@ -116,9 +116,9 @@ module CycleAnalyticsHelpers
protected: false) protected: false)
end 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| 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) branch_update = m.call(*args)
if branch_update.newrev 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