Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-06-15 21:10:04 +00:00
parent a8476fe0cd
commit 83d921d51b
62 changed files with 773 additions and 479 deletions

View File

@ -345,7 +345,7 @@ end
group :development do
gem 'lefthook', '~> 0.7.0', require: false
gem 'solargraph', '~> 0.40.4', require: false
gem 'solargraph', '~> 0.42', require: false
gem 'letter_opener_web', '~> 1.4.0'

View File

@ -137,7 +137,7 @@ GEM
net-http-persistent (~> 4.0)
nokogiri (~> 1.11.0.rc2)
babosa (1.0.4)
backport (1.1.2)
backport (1.2.0)
base32 (0.3.2)
batch-loader (2.0.1)
bcrypt (3.1.16)
@ -352,13 +352,24 @@ GEM
factory_bot_rails (6.1.0)
factory_bot (~> 6.1.0)
railties (>= 5.0.0)
faraday (1.0.1)
faraday (1.4.2)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.1)
multipart-post (>= 1.2, < 3)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-http-cache (2.2.0)
faraday (>= 0.8)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.1.0)
faraday_middleware (1.0.0)
faraday (~> 1.0)
faraday_middleware-aws-sigv4 (0.3.0)
@ -1135,7 +1146,7 @@ GEM
nokogiri (>= 1.10.5)
rexml
ruby-statistics (2.1.2)
ruby2_keywords (0.0.2)
ruby2_keywords (0.0.4)
ruby_parser (3.15.0)
sexp_processor (~> 4.9)
rubyntlm (0.6.2)
@ -1205,10 +1216,11 @@ GEM
slack-messenger (2.3.4)
snowplow-tracker (0.6.1)
contracts (~> 0.7, <= 0.11)
solargraph (0.40.4)
backport (~> 1.1)
solargraph (0.42.3)
backport (~> 1.2)
benchmark
bundler (>= 1.17.2)
diff-lcs (~> 1.4)
e2mmap
jaro_winkler (~> 1.5)
kramdown (~> 2.3)
@ -1260,8 +1272,8 @@ GEM
terser (1.0.2)
execjs (>= 0.3.0, < 3)
test-prof (0.12.0)
test_file_finder (0.1.3)
faraday (~> 1.0.1)
test_file_finder (0.1.4)
faraday (~> 1.0)
text (1.3.1)
thin (1.8.0)
daemons (~> 1.0, >= 1.0.9)
@ -1632,7 +1644,7 @@ DEPENDENCIES
simplecov-cobertura (~> 1.3.1)
slack-messenger (~> 2.3.4)
snowplow-tracker (~> 0.6.1)
solargraph (~> 0.40.4)
solargraph (~> 0.42)
spamcheck (~> 0.1.0)
spring (~> 2.1.0)
spring-commands-rspec (~> 1.0.4)

View File

@ -9,18 +9,29 @@ export default {
inject: ['timeTrackingLimitToHours'],
computed: {
...mapGetters(['activeBoardItem']),
initialTimeTracking() {
const {
timeEstimate,
totalTimeSpent,
humanTimeEstimate,
humanTotalTimeSpent,
} = this.activeBoardItem;
return {
timeEstimate,
totalTimeSpent,
humanTimeEstimate,
humanTotalTimeSpent,
};
},
},
};
</script>
<template>
<issuable-time-tracker
:issuable-id="activeBoardItem.id.toString()"
:time-estimate="activeBoardItem.timeEstimate"
:time-spent="activeBoardItem.totalTimeSpent"
:human-time-estimate="activeBoardItem.humanTimeEstimate"
:human-time-spent="activeBoardItem.humanTotalTimeSpent"
:issuable-iid="activeBoardItem.iid.toString()"
:limit-to-hours="timeTrackingLimitToHours"
:initial-time-tracking="initialTimeTracking"
:show-collapsed="false"
/>
</template>

View File

@ -2,5 +2,6 @@ export default class IssueProject {
constructor(obj) {
this.id = obj.id;
this.path = obj.path;
this.fullPath = obj.path_with_namespace;
}
}

View File

@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
import { s__, sprintf } from '~/locale';
@ -30,7 +30,6 @@ export default {
GlModal,
GlButton,
GlFormInput,
GlSprintf,
},
props: {
clusterPath: {
@ -135,17 +134,6 @@ export default {
<div v-if="confirmCleanup">
{{ s__('ClusterIntegration|This will permanently delete the following resources:') }}
<ul>
<li>
{{ s__('ClusterIntegration|All installed applications and related resources') }}
</li>
<li>
<gl-sprintf :message="s__('ClusterIntegration|The %{gitlabNamespace} namespace')">
<template #gitlabNamespace>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<code>{{ 'gitlab-managed-apps' }}</code>
</template>
</gl-sprintf>
</li>
<li>{{ s__('ClusterIntegration|Any project namespaces') }}</li>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<li><code>clusterroles</code></li>

View File

@ -53,8 +53,8 @@ export default {
<div
class="diff-stats"
:class="{
'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader,
'd-none d-sm-inline-flex': !isCompareVersionsHeader,
'is-compare-versions-header gl-display-none gl-lg-display-inline-flex': isCompareVersionsHeader,
'gl-display-none gl-sm-display-inline-flex': !isCompareVersionsHeader,
}"
>
<div v-if="notDiffable" :class="fileStats.classes">
@ -66,18 +66,18 @@ export default {
<span class="text-secondary bold">{{ diffFilesCountText }} {{ filesText }}</span>
</div>
<div
class="diff-stats-group cgreen d-flex align-items-center"
class="diff-stats-group gl-text-green-600 gl-display-flex gl-align-items-center"
:class="{ bold: isCompareVersionsHeader }"
>
<span>+</span>
<span class="js-file-addition-line">{{ addedLines }}</span>
<span data-testid="js-file-addition-line">{{ addedLines }}</span>
</div>
<div
class="diff-stats-group cred d-flex align-items-center"
class="diff-stats-group gl-text-red-500 gl-display-flex gl-align-items-center"
:class="{ bold: isCompareVersionsHeader }"
>
<span>-</span>
<span class="js-file-deletion-line">{{ removedLines }}</span>
<span data-testid="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
</div>

View File

@ -105,9 +105,9 @@ export function stats(file) {
valid = true;
if (diff > 0) {
classes = 'cgreen';
classes = 'gl-text-green-600';
} else if (diff < 0) {
classes = 'cred';
classes = 'gl-text-red-500';
}
}

View File

@ -5,8 +5,6 @@ import { intersection } from 'lodash';
import '~/smart_interval';
import eventHub from '../../event_hub';
import Mediator from '../../sidebar_mediator';
import Store from '../../stores/sidebar_store';
import IssuableTimeTracker from './time_tracker.vue';
export default {
@ -14,16 +12,20 @@ export default {
IssuableTimeTracker,
},
props: {
issuableId: {
fullPath: {
type: String,
required: false,
default: '',
},
issuableIid: {
type: String,
required: true,
},
},
data() {
return {
mediator: new Mediator(),
store: new Store(),
};
limitToHours: {
type: Boolean,
required: false,
default: false,
},
},
mounted() {
this.listenForQuickActions();
@ -47,7 +49,7 @@ export default {
changedCommands = [];
}
if (changedCommands && intersection(subscribedCommands, changedCommands).length) {
this.mediator.fetch();
eventHub.$emit('timeTracker:refresh');
}
},
},
@ -57,12 +59,9 @@ export default {
<template>
<div class="block">
<issuable-time-tracker
:issuable-id="issuableId"
:time-estimate="store.timeEstimate"
:time-spent="store.totalTimeSpent"
:human-time-estimate="store.humanTimeEstimate"
:human-time-spent="store.humanTotalTimeSpent"
:limit-to-hours="store.timeTrackingLimitToHours"
:full-path="fullPath"
:issuable-iid="issuableIid"
:limit-to-hours="limitToHours"
/>
</div>
</template>

View File

@ -1,7 +1,9 @@
<script>
import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui';
import { GlIcon, GlLink, GlModal, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
import { IssuableType } from '~/issue_show/constants';
import { s__, __ } from '~/locale';
import { timeTrackingQueries } from '~/sidebar/constants';
import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
@ -19,6 +21,7 @@ export default {
GlIcon,
GlLink,
GlModal,
GlLoadingIcon,
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
TimeTrackingComparisonPane,
@ -30,34 +33,26 @@ export default {
},
inject: ['issuableType'],
props: {
timeEstimate: {
type: Number,
required: true,
},
timeSpent: {
type: Number,
required: true,
},
humanTimeEstimate: {
type: String,
required: false,
default: '',
},
humanTimeSpent: {
type: String,
required: false,
default: '',
},
limitToHours: {
type: Boolean,
default: false,
required: false,
},
issuableId: {
fullPath: {
type: String,
required: false,
default: '',
},
issuableIid: {
type: String,
required: false,
default: '',
},
initialTimeTracking: {
type: Object,
required: false,
default: null,
},
/*
In issue list, "time-tracking-collapsed-state" is always rendered even if the sidebar isn't collapsed.
The actual hiding is controlled with css classes:
@ -77,26 +72,73 @@ export default {
data() {
return {
showHelp: false,
timeTracking: {
...this.initialTimeTracking,
},
};
},
apollo: {
issuableTimeTracking: {
query() {
return timeTrackingQueries[this.issuableType].query;
},
skip() {
// We don't fetch info via GraphQL in following cases
// 1. Time tracking info was provided via prop
// 2. issuableIid and fullPath are not provided.
if (!this.initialTimeTracking) {
return false;
} else if (this.issuableIid && this.fullPath) {
return false;
}
return true;
},
variables() {
return {
iid: this.issuableIid,
fullPath: this.fullPath,
};
},
update(data) {
this.timeTracking = {
...data.workspace?.issuable,
};
},
},
},
computed: {
hasTimeSpent() {
return Boolean(this.timeSpent);
isTimeTrackingInfoLoading() {
return this.$apollo?.queries.issuableTimeTracking.loading ?? false;
},
timeEstimate() {
return this.timeTracking?.timeEstimate || 0;
},
totalTimeSpent() {
return this.timeTracking?.totalTimeSpent || 0;
},
humanTimeEstimate() {
return this.timeTracking?.humanTimeEstimate || '';
},
humanTotalTimeSpent() {
return this.timeTracking?.humanTotalTimeSpent || '';
},
hasTotalTimeSpent() {
return Boolean(this.totalTimeSpent);
},
hasTimeEstimate() {
return Boolean(this.timeEstimate);
},
showComparisonState() {
return this.hasTimeEstimate && this.hasTimeSpent;
return this.hasTimeEstimate && this.hasTotalTimeSpent;
},
showEstimateOnlyState() {
return this.hasTimeEstimate && !this.hasTimeSpent;
return this.hasTimeEstimate && !this.hasTotalTimeSpent;
},
showSpentOnlyState() {
return this.hasTimeSpent && !this.hasTimeEstimate;
return this.hasTotalTimeSpent && !this.hasTimeEstimate;
},
showNoTimeTrackingState() {
return !this.hasTimeEstimate && !this.hasTimeSpent;
return !this.hasTimeEstimate && !this.hasTotalTimeSpent;
},
showHelpState() {
return Boolean(this.showHelp);
@ -104,26 +146,29 @@ export default {
isTimeReportSupported() {
return (
[IssuableType.Issue, IssuableType.MergeRequest].includes(this.issuableType) &&
this.issuableId
this.issuableIid
);
},
},
watch: {
/**
* When `initialTimeTracking` is provided via prop,
* we don't query the same via GraphQl and instead
* monitor it for any updates (eg; Epic Swimlanes)
*/
initialTimeTracking(timeTracking) {
this.timeTracking = timeTracking;
},
},
created() {
eventHub.$on('timeTracker:updateData', this.update);
eventHub.$on('timeTracker:refresh', this.refresh);
},
methods: {
toggleHelpState(show) {
this.showHelp = show;
},
update(data) {
const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = data;
/* eslint-disable vue/no-mutating-props */
this.timeEstimate = timeEstimate;
this.timeSpent = timeSpent;
this.humanTimeEstimate = humanTimeEstimate;
this.humanTimeSpent = humanTimeSpent;
/* eslint-enable vue/no-mutating-props */
refresh() {
this.$apollo.queries.issuableTimeTracking.refetch();
},
},
};
@ -138,11 +183,12 @@ export default {
:show-help-state="showHelpState"
:show-spent-only-state="showSpentOnlyState"
:show-estimate-only-state="showEstimateOnlyState"
:time-spent-human-readable="humanTimeSpent"
:time-spent-human-readable="humanTotalTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
/>
<div class="hide-collapsed gl-line-height-20 gl-text-gray-900">
{{ __('Time tracking') }}
<gl-loading-icon v-if="isTimeTrackingInfoLoading" inline />
<div
v-if="!showHelpState"
data-testid="helpButton"
@ -160,14 +206,14 @@ export default {
<gl-icon name="close" />
</div>
</div>
<div class="hide-collapsed">
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
<div v-if="showEstimateOnlyState" data-testid="estimateOnlyPane">
<span class="gl-font-weight-bold">{{ $options.i18n.estimatedOnlyText }} </span
>{{ humanTimeEstimate }}
</div>
<time-tracking-spent-only-pane
v-if="showSpentOnlyState"
:time-spent-human-readable="humanTimeSpent"
:time-spent-human-readable="humanTotalTimeSpent"
/>
<div v-if="showNoTimeTrackingState" data-testid="noTrackingPane">
<span class="gl-text-gray-500">{{ $options.i18n.noTimeTrackingText }}</span>
@ -175,14 +221,14 @@ export default {
<time-tracking-comparison-pane
v-if="showComparisonState"
:time-estimate="timeEstimate"
:time-spent="timeSpent"
:time-spent-human-readable="humanTimeSpent"
:time-spent="totalTimeSpent"
:time-spent-human-readable="humanTotalTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
:limit-to-hours="limitToHours"
/>
<template v-if="isTimeReportSupported">
<gl-link
v-if="hasTimeSpent"
v-if="hasTotalTimeSpent"
v-gl-modal="'time-tracking-report'"
data-testid="reportLink"
href="#"
@ -194,7 +240,7 @@ export default {
:title="__('Time tracking report')"
:hide-footer="true"
>
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
<time-tracking-report :limit-to-hours="limitToHours" :issuable-iid="issuableIid" />
</gl-modal>
</template>
<transition name="help-state-toggle">

View File

@ -9,8 +9,10 @@ import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.g
import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql';
import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql';
import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
@ -120,6 +122,15 @@ export const subscribedQueries = {
},
};
export const timeTrackingQueries = {
[IssuableType.Issue]: {
query: issueTimeTrackingQuery,
},
[IssuableType.MergeRequest]: {
query: mergeRequestTimeTrackingQuery,
},
};
export const dueDateQueries = {
[IssuableType.Issue]: {
query: issueDueDateQuery,

View File

@ -15,7 +15,7 @@ export default class SidebarMilestone {
humanTimeEstimate,
humanTimeSpent,
limitToHours,
id,
iid,
} = el.dataset;
// eslint-disable-next-line no-new
@ -30,12 +30,14 @@ export default class SidebarMilestone {
render: (createElement) =>
createElement('timeTracker', {
props: {
timeEstimate: parseInt(timeEstimate, 10),
timeSpent: parseInt(timeSpent, 10),
humanTimeEstimate,
humanTimeSpent,
limitToHours: parseBoolean(limitToHours),
issuableId: id.toString(),
issuableIid: iid.toString(),
initialTimeTracking: {
timeEstimate: parseInt(timeEstimate, 10),
totalTimeSpent: parseInt(timeSpent, 10),
humanTimeEstimate,
humanTotalTimeSpent: humanTimeSpent,
},
},
}),
});

View File

@ -391,7 +391,7 @@ function mountSubscriptionsComponent() {
function mountTimeTrackingComponent() {
const el = document.getElementById('issuable-time-tracker');
const { id, issuableType } = getSidebarOptions();
const { iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions();
if (!el) return;
@ -403,7 +403,9 @@ function mountTimeTrackingComponent() {
render: (createElement) =>
createElement(SidebarTimeTracking, {
props: {
issuableId: id.toString(),
fullPath,
issuableIid: iid.toString(),
limitToHours: timeTrackingLimitToHours,
},
}),
});

View File

@ -0,0 +1,13 @@
query issueTimeTracking($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
humanTimeEstimate
humanTotalTimeSpent
timeEstimate
totalTimeSpent
}
}
}

View File

@ -0,0 +1,13 @@
query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: mergeRequest(iid: $iid) {
__typename
id
humanTimeEstimate
humanTotalTimeSpent
timeEstimate
totalTimeSpent
}
}
}

View File

@ -51,9 +51,8 @@ class GitlabSchema < GraphQL::Schema
end
def get_type(type_name)
# This is a backwards compatibility hack to work around an accidentally
# released argument typed as EEIterationID
type_name = type_name.gsub(/^EE/, '') if type_name.end_with?('ID')
type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name)
super(type_name)
end
@ -162,10 +161,9 @@ class GitlabSchema < GraphQL::Schema
end
end
# This is a backwards compatibility hack to work around an accidentally
# released argument typed as EE{Type}ID
def get_type(type_name)
type_name = type_name.gsub(/^EE/, '') if type_name.end_with?('ID')
type_name = Gitlab::GlobalId::Deprecations.apply_to_graphql_name(type_name)
super(type_name)
end
end

View File

@ -52,11 +52,20 @@ module Types
@id_types ||= {}
@id_types[model_class] ||= Class.new(self) do
graphql_name "#{model_class.name.gsub(/::/, '')}ID"
description <<~MD
model_name = model_class.name
graphql_name model_name_to_graphql_name(model_name)
description <<~MD.strip
A `#{graphql_name}` is a global ID. It is encoded as a string.
An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_class.name, id: 1)}"`.
An example `#{graphql_name}` is: `"#{::Gitlab::GlobalId.build(model_name: model_name, id: 1)}"`.
#{
if deprecation = Gitlab::GlobalId::Deprecations.deprecation_by(model_name)
'The older format `"' +
::Gitlab::GlobalId.build(model_name: deprecation.old_model_name, id: 1).to_s +
'"` was deprecated in ' + deprecation.milestone + '.'
end}
MD
define_singleton_method(:to_s) do
@ -69,7 +78,7 @@ module Types
define_singleton_method(:as) do |new_name|
if @renamed && graphql_name != new_name
raise "Conflicting names for ID of #{model_class.name}: " \
raise "Conflicting names for ID of #{model_name}: " \
"#{graphql_name} and #{new_name}"
end
@ -79,11 +88,11 @@ module Types
end
define_singleton_method(:coerce_result) do |gid, ctx|
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name)
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_name)
next global_id.to_s if suitable?(global_id)
raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}"
raise GraphQL::CoercionError, "Expected a #{model_name} ID, got #{global_id}"
end
define_singleton_method(:suitable?) do |gid|
@ -97,9 +106,13 @@ module Types
gid = super(string, ctx)
next gid if suitable?(gid)
raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}"
raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_name}"
end
end
end
def self.model_name_to_graphql_name(model_name)
"#{model_name.gsub(/::/, '')}ID"
end
end
end

View File

@ -168,18 +168,16 @@ module Clusters
state_machine :cleanup_status, initial: :cleanup_not_started do
state :cleanup_not_started, value: 1
state :cleanup_uninstalling_applications, value: 2
state :cleanup_removing_project_namespaces, value: 3
state :cleanup_removing_service_account, value: 4
state :cleanup_errored, value: 5
event :start_cleanup do |cluster|
transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications
transition [:cleanup_not_started, :cleanup_errored] => :cleanup_removing_project_namespaces
end
event :continue_cleanup do
transition(
cleanup_uninstalling_applications: :cleanup_removing_project_namespaces,
cleanup_removing_project_namespaces: :cleanup_removing_service_account)
end
@ -192,13 +190,7 @@ module Clusters
cluster.cleanup_status_reason = status_reason if status_reason
end
after_transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications do |cluster|
cluster.run_after_commit do
Clusters::Cleanup::AppWorker.perform_async(cluster.id)
end
end
after_transition cleanup_uninstalling_applications: :cleanup_removing_project_namespaces do |cluster|
after_transition [:cleanup_not_started, :cleanup_errored] => :cleanup_removing_project_namespaces do |cluster|
cluster.run_after_commit do
Clusters::Cleanup::ProjectNamespaceWorker.perform_async(cluster.id)
end

View File

@ -17,7 +17,7 @@ class IssueBoardEntity < Grape::Entity
end
expose :project do |issue|
API::Entities::Project.represent issue.project, only: [:id, :path]
API::Entities::Project.represent issue.project, only: [:id, :path, :path_with_namespace]
end
expose :milestone, if: -> (issue) { issue.milestone } do |issue|

View File

@ -1,33 +0,0 @@
# frozen_string_literal: true
module Clusters
module Cleanup
class AppService < Clusters::Cleanup::BaseService
def execute
persisted_applications = @cluster.persisted_applications
persisted_applications.each do |app|
next unless app.available?
next unless app.can_uninstall?
log_event(:uninstalling_app, application: app.class.application_name)
uninstall_app_async(app)
end
# Keep calling the worker untill all dependencies are uninstalled
return schedule_next_execution(Clusters::Cleanup::AppWorker) if persisted_applications.any?
log_event(:schedule_remove_project_namespaces)
cluster.continue_cleanup!
end
private
def uninstall_app_async(application)
application.make_scheduled!
Clusters::Applications::UninstallWorker.perform_async(application.name, application.id)
end
end
end
end

View File

@ -2,7 +2,7 @@
module Clusters
module Cleanup
class ProjectNamespaceService < BaseService
class ProjectNamespaceService < ::Clusters::Cleanup::BaseService
KUBERNETES_NAMESPACE_BATCH_SIZE = 100
def execute

View File

@ -2,7 +2,7 @@
module Clusters
module Cleanup
class ServiceAccountService < BaseService
class ServiceAccountService < ::Clusters::Cleanup::BaseService
def execute
delete_gitlab_service_account

View File

@ -1,8 +1,5 @@
.block.time-tracking
%time-tracker{ ":time-estimate" => "issue.timeEstimate || 0",
":time-spent" => "issue.timeSpent || 0",
":human-time-estimate" => "issue.humanTimeEstimate",
":human-time-spent" => "issue.humanTimeSpent",
":limit-to-hours" => "timeTrackingLimitToHours",
":issuable-id" => "issue.id ? issue.id.toString() : ''",
%time-tracker{ ":limit-to-hours" => "timeTrackingLimitToHours",
":issuable-iid" => "issue.iid ? issue.iid.toString() : ''",
":full-path" => "issue.project ? issue.project.fullPath : ''",
"root-path" => "#{root_url}" }

View File

@ -98,7 +98,7 @@
time_spent: @milestone.total_time_spent,
human_time_estimate: @milestone.human_total_time_estimate,
human_time_spent: @milestone.human_total_time_spent,
id: @milestone.id,
iid: @milestone.iid,
limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } }
= render_if_exists 'shared/milestones/weight', milestone: milestone

View File

@ -808,15 +808,6 @@
:weight: 1
:idempotent:
:tags: []
- :name: gcp_cluster:clusters_cleanup_app
:worker_name: Clusters::Cleanup::AppWorker
:feature_category: :kubernetes_management
:has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
- :name: gcp_cluster:clusters_cleanup_project_namespace
:worker_name: Clusters::Cleanup::ProjectNamespaceWorker
:feature_category: :kubernetes_management

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
module Clusters
module Cleanup
class AppWorker # rubocop:disable Scalability/IdempotentWorker
include ClusterCleanupMethods
def perform(cluster_id, execution_count = 0)
Clusters::Cluster.with_persisted_applications.find_by_id(cluster_id).try do |cluster|
break unless cluster.cleanup_uninstalling_applications?
break exceeded_execution_limit(cluster) if exceeded_execution_limit?(execution_count)
::Clusters::Cleanup::AppService.new(cluster, execution_count).execute
end
end
end
end
end

View File

@ -4,6 +4,6 @@ if Gitlab::Utils.to_boolean(ENV['ENABLE_ACTIVERECORD_EMPTY_PING'], default: true
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(Gitlab::Database::PostgresqlAdapter::EmptyQueryPing)
end
if Gitlab::Utils.to_boolean(ENV['ENABLE_ACTIVERECORD_TYPEMAP_CACHE'], default: false)
if Gitlab::Utils.to_boolean(ENV['ENABLE_ACTIVERECORD_TYPEMAP_CACHE'], default: true)
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(Gitlab::Database::PostgresqlAdapter::TypeMapCache)
end

View File

@ -0,0 +1,3 @@
# frozen_string_literal: true
GlobalID.prepend(Gitlab::Patch::GlobalID)

View File

@ -8,18 +8,10 @@ class AddIndexForCadenceIterationsAutomation < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def up
return if index_exists_by_name?(:iterations_cadences, INDEX_NAME)
execute(
<<-SQL
CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON iterations_cadences
USING BTREE(automatic, duration_in_weeks, (DATE ((COALESCE("iterations_cadences"."last_run_date", DATE('01-01-1970')) + "iterations_cadences"."duration_in_weeks" * INTERVAL '1 week'))))
WHERE duration_in_weeks IS NOT NULL
SQL
)
# no-op
end
def down
remove_concurrent_index_by_name :iterations_cadences, INDEX_NAME
# no-op
end
end

View File

@ -8,7 +8,7 @@ class EnableEnforceSshKeyExpiration < ActiveRecord::Migration[6.0]
def up
ApplicationSetting.reset_column_information
ApplicationSetting.where.not(enforce_ssh_key_expiration: false).each do |application_setting|
ApplicationSetting.where.not(enforce_ssh_key_expiration: true).each do |application_setting|
application_setting.update!(enforce_ssh_key_expiration: true)
end
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
class RebuildIndexForCadenceIterationsAutomation < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
INDEX_NAME = 'cadence_create_iterations_automation'
disable_ddl_transaction!
def up
return if index_exists_and_is_valid?
remove_concurrent_index_by_name :iterations_cadences, INDEX_NAME
disable_statement_timeout do
execute(
<<-SQL
CREATE INDEX CONCURRENTLY #{INDEX_NAME} ON iterations_cadences
USING BTREE(automatic, duration_in_weeks, (DATE ((COALESCE("iterations_cadences"."last_run_date", DATE('01-01-1970')) + "iterations_cadences"."duration_in_weeks" * INTERVAL '1 week'))))
WHERE duration_in_weeks IS NOT NULL
SQL
)
end
end
def down
remove_concurrent_index_by_name :iterations_cadences, INDEX_NAME
end
def index_exists_and_is_valid?
execute(
<<-SQL
SELECT identifier
FROM postgres_indexes
WHERE identifier LIKE '%#{INDEX_NAME}' AND valid_index=TRUE
SQL
).any?
end
end

View File

@ -0,0 +1 @@
9429a8adca0bc85167f64e76d8d72b45d09d4303a01bd9c4ca39560bb4d89799

View File

@ -15378,6 +15378,7 @@ An example `IssueID` is: `"gid://gitlab/Issue/1"`.
A `IterationID` is a global ID. It is encoded as a string.
An example `IterationID` is: `"gid://gitlab/Iteration/1"`.
The older format `"gid://gitlab/EEIteration/1"` was deprecated in 13.3.
### `IterationsCadenceID`

View File

@ -65,7 +65,7 @@ Complementary reads:
- [Security process for developers](https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#security-releases-critical-non-critical-as-a-developer)
- [Guidelines for implementing Enterprise Edition features](ee_features.md)
- [Danger bot](dangerbot.md)
- [Generate a changelog entry with `bin/changelog`](changelog.md)
- [Guidelines for changelogs](changelog.md)
- [Requesting access to ChatOps on GitLab.com](chatops_on_gitlabcom.md#requesting-access) (for GitLab team members)
- [Patch release process for developers](https://gitlab.com/gitlab-org/release/docs/blob/master/general/patch/process.md#process-for-developers)
- [Adding a new service component to GitLab](adding_service_component.md)

View File

@ -46,7 +46,7 @@ request is as follows:
your personal namespace (or group) on GitLab.com.
1. Create a feature branch in your fork (don't work off `master`).
1. Write [tests](../rake_tasks.md#run-tests) and code.
1. [Generate a changelog entry with `bin/changelog`](../changelog.md)
1. [Ensure a changelog is created](../changelog.md).
1. If you are writing documentation, make sure to follow the
[documentation guidelines](../documentation/index.md).
1. Follow the [commit messages guidelines](#commit-messages-guidelines).

View File

@ -58,7 +58,8 @@ The DevOps Adoption tab shows you which groups within your organization are usin
- Pipelines
- Deployments
Buttons to manage your groups appear in the DevOps Adoption section of the page.
When managing groups in the UI, you can add your groups with the **Add group to table**
button, in the top right hand section the page.
DevOps Adoption allows you to:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -36,7 +36,7 @@ Group DevOps Adoption shows you how individual groups and sub-groups within your
- Pipelines
- Deployments
When managing groups in the UI, you can manage your sub-groups with the **Add/Remove sub-groups**
When managing groups in the UI, you can add your sub-groups with the **Add sub-group to table**
button, in the top right hand section of your Groups pages.
With DevOps Adoption you can:

View File

@ -147,7 +147,7 @@ module Gitlab
is required for this version of GitLab.
<% if Rails.env.development? || Rails.env.test? %>
If using gitlab-development-kit, please find the relevant steps here:
https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/master/doc/howto/postgresql.md#upgrade-postgresql
https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/howto/postgresql.md#upgrade-postgresql
<% end %>
Please upgrade your environment to a supported PostgreSQL version, see
https://docs.gitlab.com/ee/install/requirements.html#database for details.

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
module Gitlab
module GlobalId
module Deprecations
Deprecation = Struct.new(:old_model_name, :new_model_name, :milestone, keyword_init: true)
# Contains the deprecations in place.
# Example:
#
# DEPRECATIONS = [
# Deprecation.new(old_model_name: 'PrometheusService', new_model_name: 'Integrations::Prometheus', milestone: '14.0')
# ].freeze
DEPRECATIONS = [
# This works around an accidentally released argument named as `"EEIterationID"` in 7000489db.
Deprecation.new(old_model_name: 'EEIteration', new_model_name: 'Iteration', milestone: '13.3')
].freeze
# Maps of the DEPRECATIONS Hash for quick access.
OLD_NAME_MAP = DEPRECATIONS.index_by(&:old_model_name).freeze
NEW_NAME_MAP = DEPRECATIONS.index_by(&:new_model_name).freeze
OLD_GRAPHQL_NAME_MAP = DEPRECATIONS.index_by do |d|
Types::GlobalIDType.model_name_to_graphql_name(d.old_model_name)
end.freeze
def self.deprecated?(old_model_name)
OLD_NAME_MAP.key?(old_model_name)
end
def self.deprecation_for(old_model_name)
OLD_NAME_MAP[old_model_name]
end
def self.deprecation_by(new_model_name)
NEW_NAME_MAP[new_model_name]
end
# Returns the new `graphql_name` (Type#graphql_name) of a deprecated GID,
# or the `graphql_name` argument given if no deprecation applies.
def self.apply_to_graphql_name(graphql_name)
return graphql_name unless deprecation = OLD_GRAPHQL_NAME_MAP[graphql_name]
Types::GlobalIDType.model_name_to_graphql_name(deprecation.new_model_name)
end
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
# To support GlobalID arguments that present a model with its old "deprecated" name
# we alter GlobalID so it will correctly find the record with its new model name.
module Gitlab
module Patch
module GlobalID
def initialize(gid, options = {})
super
if deprecation = Gitlab::GlobalId::Deprecations.deprecation_for(model_name)
@new_model_name = deprecation.new_model_name
end
end
def model_name
new_model_name || super
end
private
attr_reader :new_model_name
end
end
end

View File

@ -6979,9 +6979,6 @@ msgstr ""
msgid "ClusterIntegration|All data will be deleted and cannot be restored."
msgstr ""
msgid "ClusterIntegration|All installed applications and related resources"
msgstr ""
msgid "ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{linkStart}More information%{linkEnd}"
msgstr ""
@ -7687,9 +7684,6 @@ msgstr ""
msgid "ClusterIntegration|Subnets"
msgstr ""
msgid "ClusterIntegration|The %{gitlabNamespace} namespace"
msgstr ""
msgid "ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}"
msgstr ""
@ -11261,6 +11255,18 @@ msgstr ""
msgid "DevopsAdoption|Add a group to get started"
msgstr ""
msgid "DevopsAdoption|Add group"
msgstr ""
msgid "DevopsAdoption|Add group to table"
msgstr ""
msgid "DevopsAdoption|Add sub-group"
msgstr ""
msgid "DevopsAdoption|Add sub-group to table"
msgstr ""
msgid "DevopsAdoption|Add/remove groups"
msgstr ""
@ -11336,6 +11342,9 @@ msgstr ""
msgid "DevopsAdoption|No filter results."
msgstr ""
msgid "DevopsAdoption|No results…"
msgstr ""
msgid "DevopsAdoption|Not adopted"
msgstr ""
@ -11375,6 +11384,9 @@ msgstr ""
msgid "DevopsAdoption|There was an error fetching Groups. Please refresh the page."
msgstr ""
msgid "DevopsAdoption|This group has no sub-groups"
msgstr ""
msgid "DevopsAdoption|You cannot remove the group you are currently in."
msgstr ""

View File

@ -56,7 +56,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/svgs": "1.199.0",
"@gitlab/tributejs": "1.0.0",
"@gitlab/ui": "29.34.1",
"@gitlab/ui": "29.35.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "6.1.3-2",
"@rails/ujs": "6.1.3-2",

View File

@ -138,10 +138,6 @@ FactoryBot.define do
cleanup_status { 1 }
end
trait :cleanup_uninstalling_applications do
cleanup_status { 2 }
end
trait :cleanup_removing_project_namespaces do
cleanup_status { 3 }
end

View File

@ -129,8 +129,8 @@ RSpec.describe 'Merge request > User sees versions', :js do
)
expect(page).to have_content '4 files'
additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-addition-line').text
deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-deletion-line').text
additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group [data-testid="js-file-addition-line"]').text
deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group [data-testid="js-file-deletion-line"]').text
expect(additions_content).to eq '15'
expect(deletions_content).to eq '6'
@ -152,8 +152,8 @@ RSpec.describe 'Merge request > User sees versions', :js do
end
it 'show diff between new and old version' do
additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-addition-line').text
deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group .js-file-deletion-line').text
additions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group [data-testid="js-file-addition-line"]').text
deletions_content = page.find('.diff-stats.is-compare-versions-header .diff-stats-group [data-testid="js-file-deletion-line"]').text
expect(page).to have_content '4 files'
expect(additions_content).to eq '15'

View File

@ -18,7 +18,8 @@
"type": "object",
"properties": {
"id": { "type": "integer" },
"path": { "type": "string" }
"path": { "type": "string" },
"path_with_namespace": { "type": "string" }
}
},
"milestone": {

View File

@ -26,7 +26,7 @@ describe('BoardSidebarTimeTracker', () => {
store = createStore();
store.state.boardItems = {
1: {
id: 1,
iid: 1,
timeEstimate: 3600,
totalTimeSpent: 1800,
humanTimeEstimate: '1h',
@ -47,13 +47,16 @@ describe('BoardSidebarTimeTracker', () => {
createComponent({ provide: { timeTrackingLimitToHours } });
expect(wrapper.find(IssuableTimeTracker).props()).toEqual({
timeEstimate: 3600,
timeSpent: 1800,
humanTimeEstimate: '1h',
humanTimeSpent: '30min',
limitToHours: timeTrackingLimitToHours,
showCollapsed: false,
issuableId: '1',
issuableIid: '1',
fullPath: '',
initialTimeTracking: {
timeEstimate: 3600,
totalTimeSpent: 1800,
humanTimeEstimate: '1h',
humanTotalTimeSpent: '30min',
},
});
},
);

View File

@ -1,5 +1,7 @@
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DiffStats from '~/diffs/components/diff_stats.vue';
import mockDiffFile from '../mock_data/diff_file';
@ -12,13 +14,15 @@ describe('diff_stats', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(DiffStats, {
propsData: {
addedLines: TEST_ADDED_LINES,
removedLines: TEST_REMOVED_LINES,
...props,
},
});
wrapper = extendedWrapper(
shallowMount(DiffStats, {
propsData: {
addedLines: TEST_ADDED_LINES,
removedLines: TEST_REMOVED_LINES,
...props,
},
}),
);
};
describe('diff stats group', () => {
@ -58,24 +62,24 @@ describe('diff_stats', () => {
it("renders the bytes changes instead of line changes when the file isn't diffable", () => {
const content = getBytesContainer();
expect(content.classes('cgreen')).toBe(true);
expect(content.classes('gl-text-green-600')).toBe(true);
expect(content.text()).toBe('+1.00 KiB (+100%)');
});
});
describe('line changes', () => {
const findFileLine = (name) => wrapper.find(name);
const findFileLine = (name) => wrapper.findByTestId(name);
beforeEach(() => {
createComponent();
});
it('shows the amount of lines added', () => {
expect(findFileLine('.js-file-addition-line').text()).toBe(TEST_ADDED_LINES.toString());
expect(findFileLine('js-file-addition-line').text()).toBe(TEST_ADDED_LINES.toString());
});
it('shows the amount of lines removed', () => {
expect(findFileLine('.js-file-deletion-line').text()).toBe(TEST_REMOVED_LINES.toString());
expect(findFileLine('js-file-deletion-line').text()).toBe(TEST_REMOVED_LINES.toString());
});
});

View File

@ -181,7 +181,7 @@ describe('diff_file utilities', () => {
{
changed: 1024,
percent: 100,
classes: 'cgreen',
classes: 'gl-text-green-600',
sign: '+',
text: '+1.00 KiB (+100%)',
valid: true,
@ -197,7 +197,7 @@ describe('diff_file utilities', () => {
{
changed: -1024,
percent: -100,
classes: 'cred',
classes: 'gl-text-red-500',
sign: '',
text: '-1.00 KiB (-100%)',
valid: true,

View File

@ -1,7 +1,11 @@
import { mount } from '@vue/test-utils';
import { stubTransition } from 'helpers/stub_transition';
import { createMockDirective } from 'helpers/vue_mock_directive';
import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import SidebarEventHub from '~/sidebar/event_hub';
import { issuableTimeTrackingResponse } from '../../mock_data';
describe('Issuable Time Tracker', () => {
let wrapper;
@ -13,16 +17,18 @@ describe('Issuable Time Tracker', () => {
const findReportLink = () => findByTestId('reportLink');
const defaultProps = {
timeEstimate: 10_000, // 2h 46m
timeSpent: 5_000, // 1h 23m
humanTimeEstimate: '2h 46m',
humanTimeSpent: '1h 23m',
limitToHours: false,
issuableId: '1',
fullPath: 'gitlab-org/gitlab-test',
issuableIid: '1',
initialTimeTracking: {
...issuableTimeTrackingResponse.data.workspace.issuable,
},
};
const mountComponent = ({ props = {}, issuableType = 'issue' } = {}) =>
mount(TimeTracker, {
const issuableTimeTrackingRefetchSpy = jest.fn();
const mountComponent = ({ props = {}, issuableType = 'issue', loading = false } = {}) => {
return mount(TimeTracker, {
propsData: { ...defaultProps, ...props },
directives: { GlTooltip: createMockDirective() },
stubs: {
@ -31,7 +37,19 @@ describe('Issuable Time Tracker', () => {
provide: {
issuableType,
},
mocks: {
$apollo: {
queries: {
issuableTimeTracking: {
loading,
refetch: issuableTimeTrackingRefetchSpy,
query: jest.fn().mockResolvedValue(issuableTimeTrackingResponse),
},
},
},
},
});
};
afterEach(() => {
wrapper.destroy();
@ -48,13 +66,13 @@ describe('Issuable Time Tracker', () => {
it('should correctly render timeEstimate', () => {
expect(findByTestId('timeTrackingComparisonPane').html()).toContain(
defaultProps.humanTimeEstimate,
defaultProps.initialTimeTracking.humanTimeEstimate,
);
});
it('should correctly render time_spent', () => {
it('should correctly render totalTimeSpent', () => {
expect(findByTestId('timeTrackingComparisonPane').html()).toContain(
defaultProps.humanTimeSpent,
defaultProps.initialTimeTracking.humanTotalTimeSpent,
);
});
});
@ -82,10 +100,12 @@ describe('Issuable Time Tracker', () => {
beforeEach(() => {
wrapper = mountComponent({
props: {
timeEstimate: 100_000, // 1d 3h
timeSpent: 5_000, // 1h 23m
humanTimeEstimate: '1d 3h',
humanTimeSpent: '1h 23m',
initialTimeTracking: {
timeEstimate: 100_000, // 1d 3h
totalTimeSpent: 5_000, // 1h 23m
humanTimeEstimate: '1d 3h',
humanTotalTimeSpent: '1h 23m',
},
},
});
});
@ -112,8 +132,11 @@ describe('Issuable Time Tracker', () => {
it('should display the remaining meter with the correct background color when over estimate', () => {
wrapper = mountComponent({
props: {
timeEstimate: 10_000, // 2h 46m
timeSpent: 20_000_000, // 231 days
initialTimeTracking: {
...defaultProps.initialTimeTracking,
timeEstimate: 10_000, // 2h 46m
totalTimeSpent: 20_000_000, // 231 days
},
},
});
@ -126,8 +149,11 @@ describe('Issuable Time Tracker', () => {
beforeEach(async () => {
wrapper = mountComponent({
props: {
timeEstimate: 100_000, // 1d 3h
limitToHours: true,
initialTimeTracking: {
...defaultProps.initialTimeTracking,
timeEstimate: 100_000, // 1d 3h
},
},
});
});
@ -144,10 +170,12 @@ describe('Issuable Time Tracker', () => {
beforeEach(async () => {
wrapper = mountComponent({
props: {
timeEstimate: 10_000, // 2h 46m
timeSpent: 0,
timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '',
initialTimeTracking: {
timeEstimate: 10_000, // 2h 46m
totalTimeSpent: 0,
humanTimeEstimate: '2h 46m',
humanTotalTimeSpent: '',
},
},
});
await wrapper.vm.$nextTick();
@ -163,10 +191,12 @@ describe('Issuable Time Tracker', () => {
beforeEach(() => {
wrapper = mountComponent({
props: {
timeEstimate: 0,
timeSpent: 5_000, // 1h 23m
timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '1h 23m',
initialTimeTracking: {
timeEstimate: 0,
totalTimeSpent: 5_000, // 1h 23m
humanTimeEstimate: '2h 46m',
humanTotalTimeSpent: '1h 23m',
},
},
});
});
@ -181,10 +211,12 @@ describe('Issuable Time Tracker', () => {
beforeEach(() => {
wrapper = mountComponent({
props: {
timeEstimate: 0,
timeSpent: 0,
timeEstimateHumanReadable: '',
timeSpentHumanReadable: '',
initialTimeTracking: {
timeEstimate: 0,
totalTimeSpent: 0,
humanTimeEstimate: '',
humanTotalTimeSpent: '',
},
},
});
});
@ -202,8 +234,11 @@ describe('Issuable Time Tracker', () => {
beforeEach(() => {
wrapper = mountComponent({
props: {
timeSpent: 0,
timeSpentHumanReadable: '',
initialTimeTracking: {
...defaultProps.initialTimeTracking,
totalTimeSpent: 0,
humanTotalTimeSpent: '',
},
},
});
});
@ -236,7 +271,16 @@ describe('Issuable Time Tracker', () => {
const findCloseHelpButton = () => findByTestId('closeHelpButton');
beforeEach(async () => {
wrapper = mountComponent({ props: { timeEstimate: 0, timeSpent: 0 } });
wrapper = mountComponent({
props: {
initialTimeTracking: {
timeEstimate: 0,
totalTimeSpent: 0,
humanTimeEstimate: '',
humanTotalTimeSpent: '',
},
},
});
await wrapper.vm.$nextTick();
});
@ -265,4 +309,14 @@ describe('Issuable Time Tracker', () => {
});
});
});
describe('Event listeners', () => {
it('refetches issuableTimeTracking query when eventHub emits `timeTracker:refresh` event', async () => {
SidebarEventHub.$emit('timeTracker:refresh');
await wrapper.vm.$nextTick();
expect(issuableTimeTrackingRefetchSpy).toHaveBeenCalled();
});
});
});

View File

@ -592,4 +592,21 @@ export const emptyProjectMilestonesResponse = {
},
};
export const issuableTimeTrackingResponse = {
data: {
workspace: {
__typename: 'Project',
issuable: {
__typename: 'Issue',
id: 'gid://gitlab/Issue/1',
title: 'Commodi incidunt eos eos libero dicta dolores sed.',
timeEstimate: 10_000, // 2h 46m
totalTimeSpent: 5_000, // 1h 23m
humanTimeEstimate: '2h 46m',
humanTotalTimeSpent: '1h 23m',
},
},
},
};
export default mockData;

View File

@ -3,6 +3,10 @@
require 'spec_helper'
RSpec.describe Types::GlobalIDType do
include ::Gitlab::Graphql::Laziness
include GraphqlHelpers
include GlobalIDDeprecationHelpers
let_it_be(:project) { create(:project) }
let(:gid) { project.to_global_id }
@ -97,6 +101,142 @@ RSpec.describe Types::GlobalIDType do
expect { type.coerce_isolated_input(invalid_gid) }
.to raise_error(GraphQL::CoercionError, /does not represent an instance of Project/)
end
context 'with a deprecation' do
around(:all) do |example|
# Unset all previously memoized GlobalIDTypes to allow us to define one
# that will use the constants stubbed in the `before` block.
previous_id_types = Types::GlobalIDType.instance_variable_get(:@id_types)
Types::GlobalIDType.instance_variable_set(:@id_types, {})
example.run
ensure
Types::GlobalIDType.instance_variable_set(:@id_types, previous_id_types)
end
before do
deprecation = Gitlab::GlobalId::Deprecations::Deprecation.new(old_model_name: 'OldIssue', new_model_name: 'Issue', milestone: '10.0')
stub_global_id_deprecations(deprecation)
end
let_it_be(:issue) { create(:issue) }
let!(:type) { ::Types::GlobalIDType[::Issue] }
let(:deprecated_gid) { Gitlab::GlobalId.build(model_name: 'OldIssue', id: issue.id) }
let(:deprecating_gid) { Gitlab::GlobalId.build(model_name: 'Issue', id: issue.id) }
it 'appends the description with a deprecation notice for the old Global ID' do
expect(type.to_graphql.description).to include('The older format `"gid://gitlab/OldIssue/1"` was deprecated in 10.0')
end
describe 'coercing input against the type (parsing the Global ID string when supplied as an argument)' do
subject(:result) { type.coerce_isolated_input(gid.to_s) }
context 'when passed the deprecated Global ID' do
let(:gid) { deprecated_gid }
it 'changes the model_name to the new model name' do
expect(result.model_name).to eq('Issue')
end
it 'changes the model_class to the new model class' do
expect(result.model_class).to eq(Issue)
end
it 'can find the correct resource' do
expect(result.find).to eq(issue)
end
it 'can find the correct resource loaded through GitlabSchema' do
expect(force(GitlabSchema.object_from_id(result, expected_class: Issue))).to eq(issue)
end
end
context 'when passed the Global ID that is deprecating another' do
let(:gid) { deprecating_gid }
it 'works as normal' do
expect(result).to have_attributes(
model_class: Issue,
model_name: 'Issue',
find: issue,
to_s: gid.to_s
)
end
end
end
describe 'coercing the result against the type (producing the Global ID string when used in a field)' do
context 'when passed the deprecated Global ID' do
let(:gid) { deprecated_gid }
it 'works, but does not result in matching the new Global ID', :aggregate_failures do
# Note, this would normally never happen in real life as the object being parsed
# by the field would not produce the GlobalID of the deprecated model. This test
# proves that it is technically possible for the deprecated GlobalID to be
# considered parsable for the type, as opposed to raising a `GraphQL::CoercionError`.
expect(type.coerce_isolated_result(gid)).not_to eq(issue.to_global_id.to_s)
expect(type.coerce_isolated_result(gid)).to eq(gid.to_s)
end
end
context 'when passed the Global ID that is deprecating another' do
let(:gid) { deprecating_gid }
it 'works as normal' do
expect(type.coerce_isolated_result(gid)).to eq(issue.to_global_id.to_s)
end
end
end
describe 'executing against the schema' do
let(:query_result) do
context = { current_user: issue.project.owner }
variables = { 'id' => gid }
run_with_clean_state(query, context: context, variables: variables).to_h
end
shared_examples 'a query that works with old and new GIDs' do
let(:query) do
<<-GQL
query($id: #{argument_name}!) {
issue(id: $id) {
id
}
}
GQL
end
subject { query_result.dig('data', 'issue', 'id') }
context 'when the argument value is the new GID' do
let(:gid) { Gitlab::GlobalId.build(model_name: 'Issue', id: issue.id) }
it { is_expected.to be_present }
end
context 'when the argument value is the old GID' do
let(:gid) { Gitlab::GlobalId.build(model_name: 'OldIssue', id: issue.id) }
it { is_expected.to be_present }
end
end
context 'when the query signature includes the old type name' do
let(:argument_name) { 'OldIssueID' }
it_behaves_like 'a query that works with old and new GIDs'
end
context 'when the query signature includes the new type name' do
let(:argument_name) { 'IssueID' }
it_behaves_like 'a query that works with old and new GIDs'
end
end
end
end
describe 'a parameterized type with a namespace' do
@ -231,4 +371,10 @@ RSpec.describe Types::GlobalIDType do
end
end
end
describe '.model_name_to_graphql_name' do
it 'returns a graphql name for the given model name' do
expect(described_class.model_name_to_graphql_name('DesignManagement::Design')).to eq('DesignManagementDesignID')
end
end
end

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'global_id' do
it 'prepends `Gitlab::Patch::GlobalID`' do
expect(GlobalID.ancestors).to include(Gitlab::Patch::GlobalID)
end
it 'patches GlobalID to find aliased models when a deprecation exists' do
allow(Gitlab::GlobalId::Deprecations).to receive(:deprecation_for).and_call_original
allow(Gitlab::GlobalId::Deprecations).to receive(:deprecation_for).with('Issue').and_return(double(new_model_name: 'Project'))
project = create(:project)
gid_string = Gitlab::GlobalId.build(model_name: Issue.name, id: project.id).to_s
expect(GlobalID.new(gid_string)).to have_attributes(
to_s: gid_string,
model_name: 'Project',
model_class: Project,
find: project
)
end
it 'works as normal when no deprecation exists' do
issue = create(:issue)
gid_string = Gitlab::GlobalId.build(model_name: Issue.name, id: issue.id).to_s
expect(GlobalID.new(gid_string)).to have_attributes(
to_s: gid_string,
model_name: 'Issue',
model_class: Issue,
find: issue
)
end
end

View File

@ -4,20 +4,17 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::PostgresqlAdapter::TypeMapCache do
let(:db_config) { ActiveRecord::Base.configurations.configs_for(env_name: 'test', name: 'primary').configuration_hash }
let(:adapter_class) do
Class.new(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
let(:adapter_class) { ActiveRecord::ConnectionAdapters::PostgreSQLAdapter }
before do
adapter_class.type_map_cache.clear
end
describe '#initialize_type_map' do
it 'caches loading of types in memory' do
initialize_connection.disconnect!
recorder_without_cache = ActiveRecord::QueryRecorder.new(skip_schema_queries: false) { initialize_connection.disconnect! }
expect(recorder_without_cache.log).to include(a_string_matching(/FROM pg_type/)).twice
adapter_class.prepend(described_class)
initialize_connection.disconnect!
recorder_with_cache = ActiveRecord::QueryRecorder.new(skip_schema_queries: false) { initialize_connection.disconnect! }
expect(recorder_with_cache.count).to be < recorder_without_cache.count
@ -29,8 +26,6 @@ RSpec.describe Gitlab::Database::PostgresqlAdapter::TypeMapCache do
end
it 'only reuses the cache if the connection parameters are exactly the same' do
adapter_class.prepend(described_class)
initialize_connection.disconnect!
other_config = db_config.dup
@ -44,8 +39,6 @@ RSpec.describe Gitlab::Database::PostgresqlAdapter::TypeMapCache do
describe '#reload_type_map' do
it 'clears the cache and executes the type map query again' do
adapter_class.prepend(described_class)
initialize_connection.disconnect!
connection = initialize_connection

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::GlobalId::Deprecations do
include GlobalIDDeprecationHelpers
let_it_be(:deprecation_1) { described_class::Deprecation.new(old_model_name: 'Foo::Model', new_model_name: 'Bar', milestone: '9.0') }
let_it_be(:deprecation_2) { described_class::Deprecation.new(old_model_name: 'Baz', new_model_name: 'Qux::Model', milestone: '10.0') }
before do
stub_global_id_deprecations(deprecation_1, deprecation_2)
end
describe '.deprecated?' do
it 'returns a boolean to signal if model name has a deprecation', :aggregate_failures do
expect(described_class.deprecated?('Foo::Model')).to eq(true)
expect(described_class.deprecated?('Qux::Model')).to eq(false)
end
end
describe '.deprecation_for' do
it 'returns the deprecation for the model if it exists', :aggregate_failures do
expect(described_class.deprecation_for('Foo::Model')).to eq(deprecation_1)
expect(described_class.deprecation_for('Qux::Model')).to be_nil
end
end
describe '.deprecation_by' do
it 'returns the deprecation by the model if it exists', :aggregate_failures do
expect(described_class.deprecation_by('Foo::Model')).to be_nil
expect(described_class.deprecation_by('Qux::Model')).to eq(deprecation_2)
end
end
describe '.apply_to_graphql_name' do
it 'returns the corresponding graphql_name of the GID for the new model', :aggregate_failures do
expect(described_class.apply_to_graphql_name('FooModelID')).to eq('BarID')
expect(described_class.apply_to_graphql_name('BazID')).to eq('QuxModelID')
end
it 'returns the same value if there is no deprecation' do
expect(described_class.apply_to_graphql_name('ProjectID')).to eq('ProjectID')
end
end
end

View File

@ -1021,7 +1021,6 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
where(:status_name, :cleanup_status) do
provider_status | :cleanup_not_started
:cleanup_ongoing | :cleanup_uninstalling_applications
:cleanup_ongoing | :cleanup_removing_project_namespaces
:cleanup_ongoing | :cleanup_removing_service_account
:cleanup_errored | :cleanup_errored
@ -1077,8 +1076,8 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
end
describe '#start_cleanup!' do
let(:expected_worker_class) { Clusters::Cleanup::AppWorker }
let(:to_state) { :cleanup_uninstalling_applications }
let(:expected_worker_class) { Clusters::Cleanup::ProjectNamespaceWorker }
let(:to_state) { :cleanup_removing_project_namespaces }
subject { cluster.start_cleanup! }
@ -1116,25 +1115,13 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
end
describe '#continue_cleanup!' do
context 'when cleanup_status is cleanup_uninstalling_applications' do
let(:expected_worker_class) { Clusters::Cleanup::ProjectNamespaceWorker }
let(:from_state) { :cleanup_uninstalling_applications }
let(:to_state) { :cleanup_removing_project_namespaces }
let(:expected_worker_class) { Clusters::Cleanup::ServiceAccountWorker }
let(:from_state) { :cleanup_removing_project_namespaces }
let(:to_state) { :cleanup_removing_service_account }
subject { cluster.continue_cleanup! }
subject { cluster.continue_cleanup! }
it_behaves_like 'cleanup_status transition'
end
context 'when cleanup_status is cleanup_removing_project_namespaces' do
let(:expected_worker_class) { Clusters::Cleanup::ServiceAccountWorker }
let(:from_state) { :cleanup_removing_project_namespaces }
let(:to_state) { :cleanup_removing_service_account }
subject { cluster.continue_cleanup! }
it_behaves_like 'cleanup_status transition'
end
it_behaves_like 'cleanup_status transition'
end
end

View File

@ -15,7 +15,7 @@ RSpec.describe IssueBoardEntity do
it 'has basic attributes' do
expect(subject).to include(:id, :iid, :title, :confidential, :due_date, :project_id, :relative_position,
:labels, :assignees, project: hash_including(:id, :path))
:labels, :assignees, project: hash_including(:id, :path, :path_with_namespace))
end
it 'has path and endpoints' do

View File

@ -1,118 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::Cleanup::AppService do
describe '#execute' do
let!(:cluster) { create(:cluster, :project, :cleanup_uninstalling_applications, provider_type: :gcp) }
let(:service) { described_class.new(cluster) }
let(:logger) { service.send(:logger) }
let(:log_meta) do
{
service: described_class.name,
cluster_id: cluster.id,
execution_count: 0
}
end
subject { service.execute }
shared_examples 'does not reschedule itself' do
it 'does not reschedule itself' do
expect(Clusters::Cleanup::AppWorker).not_to receive(:perform_in)
end
end
context 'when cluster has no applications available or transitioning applications' do
it_behaves_like 'does not reschedule itself'
it 'transitions cluster to cleanup_removing_project_namespaces' do
expect { subject }
.to change { cluster.reload.cleanup_status_name }
.from(:cleanup_uninstalling_applications)
.to(:cleanup_removing_project_namespaces)
end
it 'schedules Clusters::Cleanup::ProjectNamespaceWorker' do
expect(Clusters::Cleanup::ProjectNamespaceWorker).to receive(:perform_async).with(cluster.id)
subject
end
it 'logs all events' do
expect(logger).to receive(:info)
.with(log_meta.merge(event: :schedule_remove_project_namespaces))
subject
end
end
context 'when cluster has uninstallable applications' do
shared_examples 'reschedules itself' do
it 'reschedules itself' do
expect(Clusters::Cleanup::AppWorker)
.to receive(:perform_in)
.with(1.minute, cluster.id, 1)
subject
end
end
context 'has applications with dependencies' do
let!(:helm) { create(:clusters_applications_helm, :installed, cluster: cluster) }
let!(:ingress) { create(:clusters_applications_ingress, :installed, cluster: cluster) }
let!(:cert_manager) { create(:clusters_applications_cert_manager, :installed, cluster: cluster) }
let!(:jupyter) { create(:clusters_applications_jupyter, :installed, cluster: cluster) }
it_behaves_like 'reschedules itself'
it 'only uninstalls apps that are not dependencies for other installed apps' do
expect(Clusters::Applications::UninstallWorker)
.to receive(:perform_async).with(helm.name, helm.id)
.and_call_original
expect(Clusters::Applications::UninstallWorker)
.not_to receive(:perform_async).with(ingress.name, ingress.id)
expect(Clusters::Applications::UninstallWorker)
.to receive(:perform_async).with(cert_manager.name, cert_manager.id)
.and_call_original
expect(Clusters::Applications::UninstallWorker)
.to receive(:perform_async).with(jupyter.name, jupyter.id)
.and_call_original
subject
end
it 'logs application uninstalls and next execution' do
expect(logger).to receive(:info)
.with(log_meta.merge(event: :uninstalling_app, application: kind_of(String))).exactly(3).times
expect(logger).to receive(:info)
.with(log_meta.merge(event: :scheduling_execution, next_execution: 1))
subject
end
context 'cluster is not cleanup_uninstalling_applications' do
let!(:cluster) { create(:cluster, :project, provider_type: :gcp) }
it_behaves_like 'does not reschedule itself'
end
end
context 'when applications are still uninstalling/scheduled/depending on others' do
let!(:helm) { create(:clusters_applications_helm, :installed, cluster: cluster) }
let!(:ingress) { create(:clusters_applications_ingress, :scheduled, cluster: cluster) }
let!(:runner) { create(:clusters_applications_runner, :uninstalling, cluster: cluster) }
it_behaves_like 'reschedules itself'
it 'does not call the uninstallation service' do
expect(Clusters::Applications::UninstallWorker).not_to receive(:new)
subject
end
end
end
end
end

View File

@ -37,7 +37,7 @@ RSpec.describe Clusters::DestroyService do
let(:params) { { cleanup: 'true' } }
before do
allow(Clusters::Cleanup::AppWorker).to receive(:perform_async)
allow(Clusters::Cleanup::ProjectNamespaceWorker).to receive(:perform_async)
end
it 'does not destroy cluster' do
@ -45,10 +45,10 @@ RSpec.describe Clusters::DestroyService do
expect(Clusters::Cluster.where(id: cluster.id).exists?).not_to be_falsey
end
it 'transition cluster#cleanup_status from cleanup_not_started to cleanup_uninstalling_applications' do
it 'transition cluster#cleanup_status from cleanup_not_started to cleanup_removing_project_namespaces' do
expect { subject }.to change { cluster.cleanup_status_name }
.from(:cleanup_not_started)
.to(:cleanup_uninstalling_applications)
.to(:cleanup_removing_project_namespaces)
end
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module GlobalIDDeprecationHelpers
def stub_global_id_deprecations(*deprecations)
old_name_map = deprecations.index_by(&:old_model_name)
new_name_map = deprecations.index_by(&:new_model_name)
old_graphql_name_map = deprecations.index_by { |d| Types::GlobalIDType.model_name_to_graphql_name(d.old_model_name) }
stub_const('Gitlab::GlobalId::Deprecations::OLD_NAME_MAP', old_name_map)
stub_const('Gitlab::GlobalId::Deprecations::NEW_NAME_MAP', new_name_map)
stub_const('Gitlab::GlobalId::Deprecations::OLD_GRAPHQL_NAME_MAP', old_graphql_name_map)
end
end

View File

@ -1,41 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::Cleanup::AppWorker do
describe '#perform' do
subject { worker_instance.perform(cluster.id) }
let!(:worker_instance) { described_class.new }
let!(:cluster) { create(:cluster, :project, :cleanup_uninstalling_applications, provider_type: :gcp) }
let!(:logger) { worker_instance.send(:logger) }
it_behaves_like 'cluster cleanup worker base specs'
context 'when exceeded the execution limit' do
subject { worker_instance.perform(cluster.id, worker_instance.send(:execution_limit)) }
let(:worker_instance) { described_class.new }
let(:logger) { worker_instance.send(:logger) }
let!(:helm) { create(:clusters_applications_helm, :installed, cluster: cluster) }
let!(:ingress) { create(:clusters_applications_ingress, :scheduled, cluster: cluster) }
it 'logs the error' do
expect(logger).to receive(:error)
.with(
hash_including(
exception: 'ClusterCleanupMethods::ExceededExecutionLimitError',
cluster_id: kind_of(Integer),
class_name: described_class.name,
applications: "helm:installed,ingress:scheduled",
cleanup_status: cluster.cleanup_status_name,
event: :failed_to_remove_cluster_and_resources,
message: "exceeded execution limit of 10 tries"
)
)
subject
end
end
end
end

View File

@ -908,10 +908,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/tributejs/-/tributejs-1.0.0.tgz#672befa222aeffc83e7d799b0500a7a4418e59b8"
integrity sha512-nmKw1+hB6MHvlmPz63yPwVs1qQkycHwsKgxpEbzmky16Y6mL4EJMk3w1b8QlOAF/AIAzjCERPhe/R4MJiohbZw==
"@gitlab/ui@29.34.1":
version "29.34.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-29.34.1.tgz#1dfd6864c0c7b325745f28ec708d1f187df3acd8"
integrity sha512-uNS3KNAzDFELq5SkfN7Yg9F8Pc98kcPVaXVhZGvw8JcjyVdLF4AORUbzFBsAtJXOwkWPH8yfBQY7+RVC9wkjGg==
"@gitlab/ui@29.35.0":
version "29.35.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-29.35.0.tgz#bb04d1e4f8796134bc406adaa869c1b5b1fdcaf2"
integrity sha512-Fso++QXqxZSfIgSmPGlfQC3mdFI6oh/AOL/9cGn4t/3kfxwHd1GCMjUNAFeHsgyIwKIr1hwksipapwuuOIFSCw==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"