Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
03a5217322
commit
4b9ace6c1f
88 changed files with 1495 additions and 245 deletions
|
@ -1 +1 @@
|
|||
15c2f3921c4729e9c4d7ce8592300decfcfdb2e6
|
||||
12dcff902c9a2178fa6f4992d9d562ad9b422dd2
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -93,7 +93,7 @@ gem 'graphql', '~> 1.10.5'
|
|||
# TODO: remove app/views/graphiql/rails/editors/show.html.erb when https://github.com/rmosolgo/graphiql-rails/pull/71 is released:
|
||||
# https://gitlab.com/gitlab-org/gitlab/issues/31747
|
||||
gem 'graphiql-rails', '~> 1.4.10'
|
||||
gem 'apollo_upload_server', '~> 2.0.0.beta3'
|
||||
gem 'apollo_upload_server', '~> 2.0.2'
|
||||
gem 'graphql-docs', '~> 1.6.0', group: [:development, :test]
|
||||
|
||||
# Disable strong_params so that Mash does not respond to :permitted?
|
||||
|
|
|
@ -73,7 +73,7 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 5.0)
|
||||
aes_key_wrap (1.0.1)
|
||||
akismet (3.0.0)
|
||||
apollo_upload_server (2.0.0.beta.3)
|
||||
apollo_upload_server (2.0.2)
|
||||
graphql (>= 1.8)
|
||||
rails (>= 4.2)
|
||||
asana (0.10.0)
|
||||
|
@ -1220,7 +1220,7 @@ DEPENDENCIES
|
|||
acts-as-taggable-on (~> 6.0)
|
||||
addressable (~> 2.7)
|
||||
akismet (~> 3.0)
|
||||
apollo_upload_server (~> 2.0.0.beta3)
|
||||
apollo_upload_server (~> 2.0.2)
|
||||
asana (= 0.10.0)
|
||||
asciidoctor (~> 2.0.10)
|
||||
asciidoctor-include-ext (~> 0.3.1)
|
||||
|
|
|
@ -1,16 +1,24 @@
|
|||
<script>
|
||||
import { mapActions, mapGetters, mapState } from 'vuex';
|
||||
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
|
||||
import { GlButtonGroup, GlButton, GlDropdown } from '@gitlab/ui';
|
||||
import { __ } from '~/locale';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlDeprecatedButton,
|
||||
GlIcon,
|
||||
GlButtonGroup,
|
||||
GlButton,
|
||||
GlDropdown,
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('diffs', ['isInlineView', 'isParallelView']),
|
||||
...mapState('diffs', ['renderTreeList', 'showWhitespace']),
|
||||
},
|
||||
mounted() {
|
||||
this.patchAriaLabel();
|
||||
},
|
||||
updated() {
|
||||
this.patchAriaLabel();
|
||||
},
|
||||
methods: {
|
||||
...mapActions('diffs', [
|
||||
'setInlineDiffViewType',
|
||||
|
@ -18,65 +26,61 @@ export default {
|
|||
'setRenderTreeList',
|
||||
'setShowWhitespace',
|
||||
]),
|
||||
patchAriaLabel() {
|
||||
this.$el
|
||||
.querySelector('.js-show-diff-settings')
|
||||
.setAttribute('aria-label', __('Diff view settings'));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-default js-show-diff-settings"
|
||||
data-toggle="dropdown"
|
||||
data-display="static"
|
||||
>
|
||||
<gl-icon name="settings" /> <gl-icon name="chevron-down" />
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3">
|
||||
<div>
|
||||
<span class="bold d-block mb-1">{{ __('File browser') }}</span>
|
||||
<div class="btn-group d-flex">
|
||||
<gl-deprecated-button
|
||||
:class="{ active: !renderTreeList }"
|
||||
class="w-100 js-list-view"
|
||||
<gl-dropdown icon="settings" toggle-class="js-show-diff-settings" right>
|
||||
<div class="gl-px-3">
|
||||
<span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span>
|
||||
<gl-button-group class="gl-display-flex">
|
||||
<gl-button
|
||||
:class="{ selected: !renderTreeList }"
|
||||
class="gl-w-half js-list-view"
|
||||
@click="setRenderTreeList(false)"
|
||||
>
|
||||
{{ __('List view') }}
|
||||
</gl-deprecated-button>
|
||||
<gl-deprecated-button
|
||||
:class="{ active: renderTreeList }"
|
||||
class="w-100 js-tree-view"
|
||||
</gl-button>
|
||||
<gl-button
|
||||
:class="{ selected: renderTreeList }"
|
||||
class="gl-w-half js-tree-view"
|
||||
@click="setRenderTreeList(true)"
|
||||
>
|
||||
{{ __('Tree view') }}
|
||||
</gl-deprecated-button>
|
||||
</gl-button>
|
||||
</gl-button-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="bold d-block mb-1">{{ __('Compare changes') }}</span>
|
||||
<div class="btn-group d-flex js-diff-view-buttons">
|
||||
<gl-deprecated-button
|
||||
<div class="gl-mt-3 gl-px-3">
|
||||
<span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('Compare changes') }}</span>
|
||||
<gl-button-group class="gl-display-flex js-diff-view-buttons">
|
||||
<gl-button
|
||||
id="inline-diff-btn"
|
||||
:class="{ active: isInlineView }"
|
||||
class="w-100 js-inline-diff-button"
|
||||
:class="{ selected: isInlineView }"
|
||||
class="gl-w-half js-inline-diff-button"
|
||||
data-view-type="inline"
|
||||
@click="setInlineDiffViewType"
|
||||
>
|
||||
{{ __('Inline') }}
|
||||
</gl-deprecated-button>
|
||||
<gl-deprecated-button
|
||||
</gl-button>
|
||||
<gl-button
|
||||
id="parallel-diff-btn"
|
||||
:class="{ active: isParallelView }"
|
||||
class="w-100 js-parallel-diff-button"
|
||||
:class="{ selected: isParallelView }"
|
||||
class="gl-w-half js-parallel-diff-button"
|
||||
data-view-type="parallel"
|
||||
@click="setParallelDiffViewType"
|
||||
>
|
||||
{{ __('Side-by-side') }}
|
||||
</gl-deprecated-button>
|
||||
</gl-button>
|
||||
</gl-button-group>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="mb-0">
|
||||
<div class="gl-mt-3 gl-px-3">
|
||||
<label class="gl-mb-0">
|
||||
<input
|
||||
id="show-whitespace"
|
||||
type="checkbox"
|
||||
|
@ -86,6 +90,5 @@ export default {
|
|||
{{ __('Show whitespace changes') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</gl-dropdown>
|
||||
</template>
|
||||
|
|
|
@ -20,7 +20,6 @@ export default {
|
|||
components: {
|
||||
GlIcon,
|
||||
GlIntersectionObserver,
|
||||
descriptionComponent,
|
||||
titleComponent,
|
||||
editedComponent,
|
||||
formComponent,
|
||||
|
@ -152,6 +151,18 @@ export default {
|
|||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
descriptionComponent: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => {
|
||||
return descriptionComponent;
|
||||
},
|
||||
},
|
||||
showTitleBorder: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const store = new Store({
|
||||
|
@ -209,6 +220,11 @@ export default {
|
|||
isOpenStatus() {
|
||||
return this.issuableStatus === IssuableStatus.Open;
|
||||
},
|
||||
pinnedLinkClasses() {
|
||||
return this.showTitleBorder
|
||||
? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'
|
||||
: '';
|
||||
},
|
||||
statusIcon() {
|
||||
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
|
||||
},
|
||||
|
@ -447,9 +463,11 @@ export default {
|
|||
<pinned-links
|
||||
:zoom-meeting-url="zoomMeetingUrl"
|
||||
:published-incident-url="publishedIncidentUrl"
|
||||
:class="pinnedLinkClasses"
|
||||
/>
|
||||
|
||||
<description-component
|
||||
<component
|
||||
:is="descriptionComponent"
|
||||
v-if="state.descriptionHtml"
|
||||
:can-update="canUpdate"
|
||||
:description-html="state.descriptionHtml"
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<script>
|
||||
import { GlTab, GlTabs } from '@gitlab/ui';
|
||||
import DescriptionComponent from './description.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlTab,
|
||||
GlTabs,
|
||||
DescriptionComponent,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<gl-tabs
|
||||
content-class="gl-reset-line-height gl-mt-3"
|
||||
class="gl-mt-n3"
|
||||
data-testid="incident-tabs"
|
||||
>
|
||||
<gl-tab :title="__('Summary')">
|
||||
<description-component v-bind="$attrs" />
|
||||
</gl-tab>
|
||||
</gl-tabs>
|
||||
</div>
|
||||
</template>
|
|
@ -45,7 +45,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
|
||||
<div class="gl-display-flex gl-justify-content-start">
|
||||
<template v-for="(link, i) in pinnedLinks">
|
||||
<div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
|
||||
<gl-button
|
||||
|
|
21
app/assets/javascripts/issue_show/incident.js
Normal file
21
app/assets/javascripts/issue_show/incident.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import Vue from 'vue';
|
||||
import issuableApp from './components/app.vue';
|
||||
import incidentTabs from './components/incident_tabs.vue';
|
||||
|
||||
export default function initIssuableApp(issuableData = {}) {
|
||||
return new Vue({
|
||||
el: document.getElementById('js-issuable-app'),
|
||||
components: {
|
||||
issuableApp,
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement('issuable-app', {
|
||||
props: {
|
||||
...issuableData,
|
||||
descriptionComponent: incidentTabs,
|
||||
showTitleBorder: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,8 +1,7 @@
|
|||
import Vue from 'vue';
|
||||
import issuableApp from './components/app.vue';
|
||||
import { parseIssuableData } from './utils/parse_data';
|
||||
|
||||
export default function initIssueableApp() {
|
||||
export default function initIssuableApp(issuableData) {
|
||||
return new Vue({
|
||||
el: document.getElementById('js-issuable-app'),
|
||||
components: {
|
||||
|
@ -10,7 +9,7 @@ export default function initIssueableApp() {
|
|||
},
|
||||
render(createElement) {
|
||||
return createElement('issuable-app', {
|
||||
props: parseIssuableData(),
|
||||
props: issuableData,
|
||||
});
|
||||
},
|
||||
});
|
|
@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
|
|||
import ZenMode from '~/zen_mode';
|
||||
import '~/notes/index';
|
||||
import { store } from '~/notes/stores';
|
||||
import initIssueableApp from '~/issue_show';
|
||||
import initIssueApp from '~/issue_show/issue';
|
||||
import initIncidentApp from '~/issue_show/incident';
|
||||
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
|
||||
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
|
||||
import initRelatedMergeRequestsApp from '~/related_merge_requests';
|
||||
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
|
||||
import { parseIssuableData } from '~/issue_show/utils/parse_data';
|
||||
|
||||
export default function() {
|
||||
initIssueableApp();
|
||||
const { issueType, ...issuableData } = parseIssuableData();
|
||||
|
||||
if (issueType === 'incident') {
|
||||
initIncidentApp(issuableData);
|
||||
} else {
|
||||
initIssueApp(issuableData);
|
||||
}
|
||||
|
||||
initIssuableHeaderWarning(store);
|
||||
initSentryErrorStackTraceApp();
|
||||
initRelatedMergeRequestsApp();
|
||||
|
|
|
@ -1033,3 +1033,9 @@ $mr-widget-min-height: 69px;
|
|||
.diff-file-row.is-active {
|
||||
background-color: $gray-50;
|
||||
}
|
||||
|
||||
.merge-request-container {
|
||||
.flash-container {
|
||||
@include gl-mb-4;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
module MergedAtFilter
|
||||
private
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def by_merged_at(items)
|
||||
return items unless merged_after || merged_before
|
||||
|
||||
|
@ -11,11 +10,8 @@ module MergedAtFilter
|
|||
mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present?
|
||||
mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present?
|
||||
|
||||
scope = items.joins(:metrics).merge(mr_metrics_scope)
|
||||
scope = target_project_id_filter_on_metrics(scope) if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true)
|
||||
scope
|
||||
items.join_metrics.merge(mr_metrics_scope)
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
|
||||
def merged_after
|
||||
params[:merged_after]
|
||||
|
@ -24,10 +20,4 @@ module MergedAtFilter
|
|||
def merged_before
|
||||
params[:merged_before]
|
||||
end
|
||||
|
||||
# rubocop: disable CodeReuse/ActiveRecord
|
||||
def target_project_id_filter_on_metrics(scope)
|
||||
scope.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
|
||||
end
|
||||
# rubocop: enable CodeReuse/ActiveRecord
|
||||
end
|
||||
|
|
|
@ -37,6 +37,10 @@ module Resolvers
|
|||
argument :milestone_title, GraphQL::STRING_TYPE,
|
||||
required: false,
|
||||
description: 'Title of the milestone'
|
||||
argument :sort, Types::MergeRequestSortEnum,
|
||||
description: 'Sort merge requests by this criteria',
|
||||
required: false,
|
||||
default_value: 'created_desc'
|
||||
|
||||
def self.single
|
||||
::Resolvers::MergeRequestResolver
|
||||
|
|
11
app/graphql/types/merge_request_sort_enum.rb
Normal file
11
app/graphql/types/merge_request_sort_enum.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class MergeRequestSortEnum < IssuableSortEnum
|
||||
graphql_name 'MergeRequestSort'
|
||||
description 'Values for sorting merge requests'
|
||||
|
||||
value 'MERGED_AT_ASC', 'Merge time by ascending order', value: :merged_at_asc
|
||||
value 'MERGED_AT_DESC', 'Merge time by descending order', value: :merged_at_desc
|
||||
end
|
||||
end
|
|
@ -327,7 +327,8 @@ module ApplicationSettingsHelper
|
|||
:group_import_limit,
|
||||
:group_export_limit,
|
||||
:group_download_export_limit,
|
||||
:wiki_page_max_content_bytes
|
||||
:wiki_page_max_content_bytes,
|
||||
:container_registry_delete_tags_service_timeout
|
||||
]
|
||||
end
|
||||
|
||||
|
|
8
app/helpers/container_registry_helper.rb
Normal file
8
app/helpers/container_registry_helper.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ContainerRegistryHelper
|
||||
def limit_delete_tags_service?
|
||||
Feature.enabled?(:container_registry_expiration_policies_throttling) &&
|
||||
ContainerRegistry::Client.supports_tag_delete?
|
||||
end
|
||||
end
|
|
@ -292,6 +292,7 @@ module IssuablesHelper
|
|||
|
||||
{
|
||||
hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
|
||||
issueType: issuable.issue_type,
|
||||
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
|
||||
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord
|
||||
}
|
||||
|
|
|
@ -282,6 +282,9 @@ class ApplicationSetting < ApplicationRecord
|
|||
|
||||
validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") }
|
||||
|
||||
validates :container_registry_delete_tags_service_timeout,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
|
||||
SUPPORTED_KEY_TYPES.each do |type|
|
||||
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
|
||||
end
|
||||
|
|
|
@ -163,7 +163,8 @@ module ApplicationSettingImplementation
|
|||
user_default_external: false,
|
||||
user_default_internal_regex: nil,
|
||||
user_show_add_ssh_key_message: true,
|
||||
wiki_page_max_content_bytes: 50.megabytes
|
||||
wiki_page_max_content_bytes: 50.megabytes,
|
||||
container_registry_delete_tags_service_timeout: 100
|
||||
}
|
||||
end
|
||||
|
||||
|
|
26
app/models/atlassian/identity.rb
Normal file
26
app/models/atlassian/identity.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Atlassian
|
||||
class Identity < ApplicationRecord
|
||||
self.table_name = 'atlassian_identities'
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :extern_uid, presence: true, uniqueness: true
|
||||
validates :user, presence: true, uniqueness: true
|
||||
|
||||
attr_encrypted :token,
|
||||
mode: :per_attribute_iv,
|
||||
key: Settings.attr_encrypted_db_key_base_truncated,
|
||||
algorithm: 'aes-256-gcm',
|
||||
encode: false,
|
||||
encode_iv: false
|
||||
|
||||
attr_encrypted :refresh_token,
|
||||
mode: :per_attribute_iv,
|
||||
key: Settings.attr_encrypted_db_key_base_truncated,
|
||||
algorithm: 'aes-256-gcm',
|
||||
encode: false,
|
||||
encode_iv: false
|
||||
end
|
||||
end
|
|
@ -251,6 +251,15 @@ class MergeRequest < ApplicationRecord
|
|||
joins(:notes).where(notes: { commit_id: sha })
|
||||
end
|
||||
scope :join_project, -> { joins(:target_project) }
|
||||
scope :join_metrics, -> do
|
||||
query = joins(:metrics)
|
||||
|
||||
if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true)
|
||||
query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
|
||||
end
|
||||
|
||||
query
|
||||
end
|
||||
scope :references_project, -> { references(:target_project) }
|
||||
scope :with_api_entity_associations, -> {
|
||||
preload_routables
|
||||
|
@ -264,6 +273,14 @@ class MergeRequest < ApplicationRecord
|
|||
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
|
||||
end
|
||||
scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) }
|
||||
scope :order_merged_at, ->(direction) do
|
||||
query = join_metrics.order(Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction))
|
||||
|
||||
# Add `merge_request_metrics.merged_at` to the `SELECT` in order to make the keyset pagination work.
|
||||
query.select(*query.arel.projections, MergeRequest::Metrics.arel_table[:merged_at].as('"merge_request_metrics.merged_at"'))
|
||||
end
|
||||
scope :order_merged_at_asc, -> { order_merged_at('ASC') }
|
||||
scope :order_merged_at_desc, -> { order_merged_at('DESC') }
|
||||
scope :preload_source_project, -> { preload(:source_project) }
|
||||
scope :preload_target_project, -> { preload(:target_project) }
|
||||
scope :preload_routables, -> do
|
||||
|
@ -320,6 +337,15 @@ class MergeRequest < ApplicationRecord
|
|||
.pluck(:target_branch)
|
||||
end
|
||||
|
||||
def self.sort_by_attribute(method, excluded_labels: [])
|
||||
case method.to_s
|
||||
when 'merged_at', 'merged_at_asc' then order_merged_at_asc.with_order_id_desc
|
||||
when 'merged_at_desc' then order_merged_at_desc.with_order_id_desc
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def rebase_in_progress?
|
||||
rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)
|
||||
end
|
||||
|
|
|
@ -351,10 +351,10 @@ class Service < ApplicationRecord
|
|||
{ success: result.present?, result: result }
|
||||
end
|
||||
|
||||
# Disable test for instance-level services.
|
||||
# Disable test for instance-level and group-level services.
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/213138
|
||||
def can_test?
|
||||
!instance?
|
||||
!instance? && !group_id
|
||||
end
|
||||
|
||||
# Returns a hash of the properties that have been assigned a new value since last save,
|
||||
|
|
|
@ -345,6 +345,10 @@ class Snippet < ApplicationRecord
|
|||
repository.ls_files(ref)
|
||||
end
|
||||
|
||||
def multiple_files?
|
||||
list_files(repository.root_ref).size > 1
|
||||
end
|
||||
|
||||
class << self
|
||||
# Searches for snippets with a matching title, description or file name.
|
||||
#
|
||||
|
|
|
@ -181,6 +181,7 @@ class User < ApplicationRecord
|
|||
has_one :user_detail
|
||||
has_one :user_highest_role
|
||||
has_one :user_canonical_email
|
||||
has_one :atlassian_identity, class_name: 'Atlassian::Identity'
|
||||
|
||||
has_many :reviews, foreign_key: :author_id, inverse_of: :author
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ module Ci
|
|||
end
|
||||
|
||||
def scan_line!(line)
|
||||
result = line.scan(/^(.*)=(.*)$/).last
|
||||
result = line.scan(/^(.*?)=(.*)$/).last
|
||||
|
||||
raise ParserError, 'Invalid Format' if result.nil?
|
||||
|
||||
|
|
|
@ -5,6 +5,11 @@ module Projects
|
|||
module Gitlab
|
||||
class DeleteTagsService
|
||||
include BaseServiceUtility
|
||||
include ::Gitlab::Utils::StrongMemoize
|
||||
|
||||
DISABLED_TIMEOUTS = [nil, 0].freeze
|
||||
|
||||
TimeoutError = Class.new(StandardError)
|
||||
|
||||
def initialize(container_repository, tag_names)
|
||||
@container_repository = container_repository
|
||||
|
@ -17,12 +22,42 @@ module Projects
|
|||
def execute
|
||||
return success(deleted: []) if @tag_names.empty?
|
||||
|
||||
delete_tags
|
||||
rescue TimeoutError => e
|
||||
::Gitlab::ErrorTracking.track_exception(e, tags_count: @tag_names&.size, container_repository_id: @container_repository&.id)
|
||||
error('timeout while deleting tags')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_tags
|
||||
start_time = Time.zone.now
|
||||
|
||||
deleted_tags = @tag_names.select do |name|
|
||||
raise TimeoutError if timeout?(start_time)
|
||||
|
||||
@container_repository.delete_tag_by_name(name)
|
||||
end
|
||||
|
||||
deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
|
||||
end
|
||||
|
||||
def timeout?(start_time)
|
||||
return false unless throttling_enabled?
|
||||
return false if service_timeout.in?(DISABLED_TIMEOUTS)
|
||||
|
||||
(Time.zone.now - start_time) > service_timeout
|
||||
end
|
||||
|
||||
def throttling_enabled?
|
||||
strong_memoize(:feature_flag) do
|
||||
Feature.enabled?(:container_registry_expiration_policies_throttling)
|
||||
end
|
||||
end
|
||||
|
||||
def service_timeout
|
||||
::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -15,7 +15,7 @@ module Projects
|
|||
# This is a hack as the registry doesn't support deleting individual
|
||||
# tags. This code effectively pushes a dummy image and assigns the tag to it.
|
||||
# This way when the tag is deleted only the dummy image is affected.
|
||||
# This is used to preverse compatibility with third-party registries that
|
||||
# This is used to preserve compatibility with third-party registries that
|
||||
# don't support fast delete.
|
||||
# See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
|
||||
def execute
|
||||
|
|
|
@ -14,5 +14,11 @@
|
|||
.form-text.text-muted
|
||||
= _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
|
||||
= link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries')
|
||||
- if limit_delete_tags_service?
|
||||
.form-group
|
||||
= f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold'
|
||||
= f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control'
|
||||
.form-text.text-muted
|
||||
= _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.")
|
||||
|
||||
= f.submit 'Save changes', class: "btn btn-success"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
- @gfm_form = true
|
||||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
- @content_class = "merge-request-container#{' limit-container-width' unless fluid_layout}"
|
||||
- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
|
||||
- breadcrumb_title @merge_request.to_reference
|
||||
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests")
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
.col-sm-12
|
||||
= f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
|
||||
%span.d-inline-block.mw-100.gl-mt-2
|
||||
= icon('lightbulb-o')
|
||||
= sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1')
|
||||
- if @page.persisted?
|
||||
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
|
||||
= link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),
|
||||
|
|
|
@ -17,13 +17,13 @@
|
|||
= sprite_icon('pencil')
|
||||
- elsif current_user
|
||||
- if @user.abuse_report
|
||||
%button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'),
|
||||
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }
|
||||
= icon('exclamation-circle')
|
||||
%button{ class: link_classes + 'btn btn-danger', title: s_('UserProfile|Already reported for abuse'),
|
||||
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }>
|
||||
= sprite_icon('error')
|
||||
- else
|
||||
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn',
|
||||
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
|
||||
= icon('exclamation-circle')
|
||||
= sprite_icon('error')
|
||||
- if can?(current_user, :read_user_profile, @user)
|
||||
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do
|
||||
= sprite_icon('rss', css_class: 'qa-rss-icon')
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add timeout support in the delete tags service for the GitLab Registry
|
||||
merge_request: 36319
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Replace fa-exclamation-circle and fa-lightbulb-o with GitLab SVG icons
|
||||
merge_request: 40857
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Bug fix GraphQL file uploads accepting non-file input
|
||||
merge_request: 39763
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Migrating buttons and classes to match GitLab UI
|
||||
merge_request: 40409
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add MergeRequest sort options to GraphQL API
|
||||
merge_request: 40138
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add Flash spacing on merge request show page
|
||||
merge_request: 39903
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add Atlassian Identity to store identity/credentials
|
||||
merge_request: 40176
|
||||
author:
|
||||
type: added
|
5
changelogs/unreleased/fix-regexp-dotenv.yml
Normal file
5
changelogs/unreleased/fix-regexp-dotenv.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix RegExp for dotenv report artifact
|
||||
merge_request: 38562
|
||||
author:
|
||||
type: fixed
|
5
changelogs/unreleased/tr-incident-tabs.yml
Normal file
5
changelogs/unreleased/tr-incident-tabs.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add Summary tab for incident issues
|
||||
merge_request: 39822
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: container_registry_expiration_policies_throttling
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36319
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238190
|
||||
group: group::package
|
||||
type: development
|
||||
default_enabled: false
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddContainerRegistryDeleteTagsServiceTimeoutToApplicationSettings < ActiveRecord::Migration[6.0]
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
add_column(
|
||||
:application_settings,
|
||||
:container_registry_delete_tags_service_timeout,
|
||||
:integer,
|
||||
default: 250,
|
||||
null: false
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column(:application_settings, :container_registry_delete_tags_service_timeout)
|
||||
end
|
||||
end
|
39
db/migrate/20200821194920_create_atlassian_identities.rb
Normal file
39
db/migrate/20200821194920_create_atlassian_identities.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateAtlassianIdentities < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
unless table_exists?(:atlassian_identities)
|
||||
with_lock_retries do
|
||||
create_table :atlassian_identities, id: false do |t|
|
||||
t.references :user, index: false, foreign_key: { on_delete: :cascade }, null: false, primary_key: true
|
||||
t.timestamps_with_timezone
|
||||
t.datetime_with_timezone :expires_at
|
||||
t.text :extern_uid, null: false, index: { unique: true }
|
||||
t.binary :encrypted_token
|
||||
t.binary :encrypted_token_iv
|
||||
t.binary :encrypted_refresh_token
|
||||
t.binary :encrypted_refresh_token_iv
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
add_text_limit :atlassian_identities, :extern_uid, 255
|
||||
|
||||
add_check_constraint :atlassian_identities, 'octet_length(encrypted_token) <= 2048', 'atlassian_identities_token_length_constraint'
|
||||
add_check_constraint :atlassian_identities, 'octet_length(encrypted_token_iv) <= 12', 'atlassian_identities_token_iv_length_constraint'
|
||||
add_check_constraint :atlassian_identities, 'octet_length(encrypted_refresh_token) <= 512', 'atlassian_identities_refresh_token_length_constraint'
|
||||
add_check_constraint :atlassian_identities, 'octet_length(encrypted_refresh_token_iv) <= 12', 'atlassian_identities_refresh_token_iv_length_constraint'
|
||||
end
|
||||
|
||||
def down
|
||||
with_lock_retries do
|
||||
drop_table :atlassian_identities
|
||||
end
|
||||
end
|
||||
end
|
1
db/schema_migrations/20200710113437
Normal file
1
db/schema_migrations/20200710113437
Normal file
|
@ -0,0 +1 @@
|
|||
3d49c22b718c5b4af0a7372584fe12ab730e1ffca501c7f582f7d01200708eb1
|
1
db/schema_migrations/20200821194920
Normal file
1
db/schema_migrations/20200821194920
Normal file
|
@ -0,0 +1 @@
|
|||
d92cdef33a892fdd1761d9491bc8e4c782e9db348d4a6848a1470e99e644fbfd
|
|
@ -9266,6 +9266,7 @@ CREATE TABLE public.application_settings (
|
|||
wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL,
|
||||
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
|
||||
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
|
||||
container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL,
|
||||
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
|
||||
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
|
||||
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)),
|
||||
|
@ -9479,6 +9480,32 @@ CREATE TABLE public.ar_internal_metadata (
|
|||
updated_at timestamp(6) without time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.atlassian_identities (
|
||||
user_id bigint NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
expires_at timestamp with time zone,
|
||||
extern_uid text NOT NULL,
|
||||
encrypted_token bytea,
|
||||
encrypted_token_iv bytea,
|
||||
encrypted_refresh_token bytea,
|
||||
encrypted_refresh_token_iv bytea,
|
||||
CONSTRAINT atlassian_identities_refresh_token_iv_length_constraint CHECK ((octet_length(encrypted_refresh_token_iv) <= 12)),
|
||||
CONSTRAINT atlassian_identities_refresh_token_length_constraint CHECK ((octet_length(encrypted_refresh_token) <= 512)),
|
||||
CONSTRAINT atlassian_identities_token_iv_length_constraint CHECK ((octet_length(encrypted_token_iv) <= 12)),
|
||||
CONSTRAINT atlassian_identities_token_length_constraint CHECK ((octet_length(encrypted_token) <= 2048)),
|
||||
CONSTRAINT check_32f5779763 CHECK ((char_length(extern_uid) <= 255))
|
||||
);
|
||||
|
||||
CREATE SEQUENCE public.atlassian_identities_user_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE public.atlassian_identities_user_id_seq OWNED BY public.atlassian_identities.user_id;
|
||||
|
||||
CREATE TABLE public.audit_events (
|
||||
id integer NOT NULL,
|
||||
author_id integer NOT NULL,
|
||||
|
@ -16880,6 +16907,8 @@ ALTER TABLE ONLY public.approver_groups ALTER COLUMN id SET DEFAULT nextval('pub
|
|||
|
||||
ALTER TABLE ONLY public.approvers ALTER COLUMN id SET DEFAULT nextval('public.approvers_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY public.atlassian_identities ALTER COLUMN user_id SET DEFAULT nextval('public.atlassian_identities_user_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY public.audit_events ALTER COLUMN id SET DEFAULT nextval('public.audit_events_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY public.award_emoji ALTER COLUMN id SET DEFAULT nextval('public.award_emoji_id_seq'::regclass);
|
||||
|
@ -17800,6 +17829,9 @@ ALTER TABLE ONLY public.approvers
|
|||
ALTER TABLE ONLY public.ar_internal_metadata
|
||||
ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key);
|
||||
|
||||
ALTER TABLE ONLY public.atlassian_identities
|
||||
ADD CONSTRAINT atlassian_identities_pkey PRIMARY KEY (user_id);
|
||||
|
||||
ALTER TABLE ONLY public.audit_events_part_5fc467ac26
|
||||
ADD CONSTRAINT audit_events_part_5fc467ac26_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
|
@ -19237,6 +19269,8 @@ CREATE INDEX index_approvers_on_target_id_and_target_type ON public.approvers US
|
|||
|
||||
CREATE INDEX index_approvers_on_user_id ON public.approvers USING btree (user_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_atlassian_identities_on_extern_uid ON public.atlassian_identities USING btree (extern_uid);
|
||||
|
||||
CREATE INDEX index_audit_events_on_entity_id_entity_type_id_desc_author_id ON public.audit_events USING btree (entity_id, entity_type, id DESC, author_id);
|
||||
|
||||
CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON public.award_emoji USING btree (awardable_type, awardable_id);
|
||||
|
@ -23097,6 +23131,9 @@ ALTER TABLE ONLY public.resource_weight_events
|
|||
ALTER TABLE ONLY public.design_management_designs
|
||||
ADD CONSTRAINT fk_rails_bfe283ec3c FOREIGN KEY (issue_id) REFERENCES public.issues(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.atlassian_identities
|
||||
ADD CONSTRAINT fk_rails_c02928bc18 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.serverless_domain_cluster
|
||||
ADD CONSTRAINT fk_rails_c09009dee1 FOREIGN KEY (pages_domain_id) REFERENCES public.pages_domains(id) ON DELETE CASCADE;
|
||||
|
||||
|
|
|
@ -9520,6 +9520,71 @@ type MergeRequestSetWipPayload {
|
|||
mergeRequest: MergeRequest
|
||||
}
|
||||
|
||||
"""
|
||||
Values for sorting merge requests
|
||||
"""
|
||||
enum MergeRequestSort {
|
||||
"""
|
||||
Label priority by ascending order
|
||||
"""
|
||||
LABEL_PRIORITY_ASC
|
||||
|
||||
"""
|
||||
Label priority by descending order
|
||||
"""
|
||||
LABEL_PRIORITY_DESC
|
||||
|
||||
"""
|
||||
Merge time by ascending order
|
||||
"""
|
||||
MERGED_AT_ASC
|
||||
|
||||
"""
|
||||
Merge time by descending order
|
||||
"""
|
||||
MERGED_AT_DESC
|
||||
|
||||
"""
|
||||
Milestone due date by ascending order
|
||||
"""
|
||||
MILESTONE_DUE_ASC
|
||||
|
||||
"""
|
||||
Milestone due date by descending order
|
||||
"""
|
||||
MILESTONE_DUE_DESC
|
||||
|
||||
"""
|
||||
Priority by ascending order
|
||||
"""
|
||||
PRIORITY_ASC
|
||||
|
||||
"""
|
||||
Priority by descending order
|
||||
"""
|
||||
PRIORITY_DESC
|
||||
|
||||
"""
|
||||
Created at ascending order
|
||||
"""
|
||||
created_asc
|
||||
|
||||
"""
|
||||
Created at descending order
|
||||
"""
|
||||
created_desc
|
||||
|
||||
"""
|
||||
Updated at ascending order
|
||||
"""
|
||||
updated_asc
|
||||
|
||||
"""
|
||||
Updated at descending order
|
||||
"""
|
||||
updated_desc
|
||||
}
|
||||
|
||||
"""
|
||||
State of a GitLab merge request
|
||||
"""
|
||||
|
@ -11741,6 +11806,11 @@ type Project {
|
|||
"""
|
||||
milestoneTitle: String
|
||||
|
||||
"""
|
||||
Sort merge requests by this criteria
|
||||
"""
|
||||
sort: MergeRequestSort = created_desc
|
||||
|
||||
"""
|
||||
Array of source branch names. All resolved merge requests will have one of these branches as their source.
|
||||
"""
|
||||
|
@ -16808,6 +16878,11 @@ type User {
|
|||
"""
|
||||
projectPath: String
|
||||
|
||||
"""
|
||||
Sort merge requests by this criteria
|
||||
"""
|
||||
sort: MergeRequestSort = created_desc
|
||||
|
||||
"""
|
||||
Array of source branch names. All resolved merge requests will have one of these branches as their source.
|
||||
"""
|
||||
|
@ -16883,6 +16958,11 @@ type User {
|
|||
"""
|
||||
projectPath: String
|
||||
|
||||
"""
|
||||
Sort merge requests by this criteria
|
||||
"""
|
||||
sort: MergeRequestSort = created_desc
|
||||
|
||||
"""
|
||||
Array of source branch names. All resolved merge requests will have one of these branches as their source.
|
||||
"""
|
||||
|
|
|
@ -26639,6 +26639,89 @@
|
|||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "MergeRequestSort",
|
||||
"description": "Values for sorting merge requests",
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "updated_desc",
|
||||
"description": "Updated at descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "updated_asc",
|
||||
"description": "Updated at ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "created_desc",
|
||||
"description": "Created at descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "created_asc",
|
||||
"description": "Created at ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "PRIORITY_ASC",
|
||||
"description": "Priority by ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "PRIORITY_DESC",
|
||||
"description": "Priority by descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "LABEL_PRIORITY_ASC",
|
||||
"description": "Label priority by ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "LABEL_PRIORITY_DESC",
|
||||
"description": "Label priority by descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MILESTONE_DUE_ASC",
|
||||
"description": "Milestone due date by ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MILESTONE_DUE_DESC",
|
||||
"description": "Milestone due date by descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MERGED_AT_ASC",
|
||||
"description": "Merge time by ascending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MERGED_AT_DESC",
|
||||
"description": "Merge time by descending order",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "MergeRequestState",
|
||||
|
@ -34771,6 +34854,16 @@
|
|||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "Sort merge requests by this criteria",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "MergeRequestSort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "created_desc"
|
||||
},
|
||||
{
|
||||
"name": "assigneeUsername",
|
||||
"description": "Username of the assignee",
|
||||
|
@ -49461,6 +49554,16 @@
|
|||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "Sort merge requests by this criteria",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "MergeRequestSort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "created_desc"
|
||||
},
|
||||
{
|
||||
"name": "projectPath",
|
||||
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
|
||||
|
@ -49646,6 +49749,16 @@
|
|||
},
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": "Sort merge requests by this criteria",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "MergeRequestSort",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": "created_desc"
|
||||
},
|
||||
{
|
||||
"name": "projectPath",
|
||||
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
|
||||
|
|
|
@ -533,6 +533,11 @@ The cleanup policy:
|
|||
1. Excludes from the list any tags matching the `name_regex_keep` value (tags to preserve).
|
||||
1. Finally, the remaining tags in the list are deleted from the Container Registry.
|
||||
|
||||
CAUTION: **Warning:**
|
||||
On GitLab.com, the execution time for the cleanup policy is limited, and some of the tags may remain in
|
||||
the Container Registry after the policy runs. The next time the policy runs, the remaining tags are included,
|
||||
so it may take multiple runs for all tags to be deleted.
|
||||
|
||||
### Create a cleanup policy
|
||||
|
||||
You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI.
|
||||
|
|
|
@ -537,6 +537,10 @@ module API
|
|||
)
|
||||
end
|
||||
|
||||
def with_api_params(&block)
|
||||
yield({ api: true, request: request })
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def project_finder_params_visibility_ce
|
||||
|
|
|
@ -27,6 +27,20 @@ module API
|
|||
exactly_one_of :files, :content
|
||||
end
|
||||
|
||||
params :update_file_params do |options|
|
||||
optional :files, type: Array, desc: 'An array of files to update' do
|
||||
requires :action, type: String,
|
||||
values: SnippetInputAction::ACTIONS.map(&:to_s),
|
||||
desc: "The type of action to perform on the file, must be one of: #{SnippetInputAction::ACTIONS.join(", ")}"
|
||||
optional :content, type: String, desc: 'The content of a snippet'
|
||||
optional :file_path, file_path: true, type: String, desc: 'The file path of a snippet file'
|
||||
optional :previous_path, file_path: true, type: String, desc: 'The previous path of a snippet file'
|
||||
end
|
||||
|
||||
mutually_exclusive :files, :content
|
||||
mutually_exclusive :files, :file_name
|
||||
end
|
||||
|
||||
def content_for(snippet)
|
||||
if snippet.empty_repo?
|
||||
env['api.format'] = :txt
|
||||
|
@ -53,11 +67,31 @@ module API
|
|||
end
|
||||
end
|
||||
|
||||
def process_file_args(args)
|
||||
def process_create_params(args)
|
||||
with_api_params do |api_params|
|
||||
args[:snippet_actions] = args.delete(:files)&.map do |file|
|
||||
file[:action] = :create
|
||||
file.symbolize_keys
|
||||
end
|
||||
|
||||
args.merge(api_params)
|
||||
end
|
||||
end
|
||||
|
||||
def process_update_params(args)
|
||||
with_api_params do |api_params|
|
||||
args[:snippet_actions] = args.delete(:files)&.map(&:symbolize_keys)
|
||||
|
||||
args.merge(api_params)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_params_for_multiple_files(snippet)
|
||||
return unless params[:content] || params[:file_name]
|
||||
|
||||
if Feature.enabled?(:snippet_multiple_files, current_user) && snippet.multiple_files?
|
||||
render_api_error!({ error: _('To update Snippets with multiple files, you must use the `files` parameter') }, 400)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -64,12 +64,8 @@ module API
|
|||
end
|
||||
post ":id/snippets" do
|
||||
authorize! :create_snippet, user_project
|
||||
snippet_params = declared_params(include_missing: false).tap do |create_args|
|
||||
create_args[:request] = request
|
||||
create_args[:api] = true
|
||||
|
||||
process_file_args(create_args)
|
||||
end
|
||||
snippet_params = process_create_params(declared_params(include_missing: false))
|
||||
|
||||
service_response = ::Snippets::CreateService.new(user_project, current_user, snippet_params).execute
|
||||
snippet = service_response.payload[:snippet]
|
||||
|
|
|
@ -76,12 +76,7 @@ module API
|
|||
post do
|
||||
authorize! :create_snippet
|
||||
|
||||
attrs = declared_params(include_missing: false).tap do |create_args|
|
||||
create_args[:request] = request
|
||||
create_args[:api] = true
|
||||
|
||||
process_file_args(create_args)
|
||||
end
|
||||
attrs = process_create_params(declared_params(include_missing: false))
|
||||
|
||||
service_response = ::Snippets::CreateService.new(nil, current_user, attrs).execute
|
||||
snippet = service_response.payload[:snippet]
|
||||
|
@ -99,16 +94,20 @@ module API
|
|||
detail 'This feature was introduced in GitLab 8.15.'
|
||||
success Entities::PersonalSnippet
|
||||
end
|
||||
|
||||
params do
|
||||
requires :id, type: Integer, desc: 'The ID of a snippet'
|
||||
optional :title, type: String, allow_blank: false, desc: 'The title of a snippet'
|
||||
optional :file_name, type: String, desc: 'The name of a snippet file'
|
||||
optional :content, type: String, allow_blank: false, desc: 'The content of a snippet'
|
||||
optional :description, type: String, desc: 'The description of a snippet'
|
||||
optional :file_name, type: String, desc: 'The name of a snippet file'
|
||||
optional :title, type: String, allow_blank: false, desc: 'The title of a snippet'
|
||||
optional :visibility, type: String,
|
||||
values: Gitlab::VisibilityLevel.string_values,
|
||||
desc: 'The visibility of the snippet'
|
||||
at_least_one_of :title, :file_name, :content, :visibility
|
||||
|
||||
use :update_file_params
|
||||
|
||||
at_least_one_of :title, :file_name, :content, :files, :visibility
|
||||
end
|
||||
put ':id' do
|
||||
snippet = snippets_for_current_user.find_by_id(params.delete(:id))
|
||||
|
@ -116,8 +115,12 @@ module API
|
|||
|
||||
authorize! :update_snippet, snippet
|
||||
|
||||
attrs = declared_params(include_missing: false).merge(request: request, api: true)
|
||||
validate_params_for_multiple_files(snippet)
|
||||
|
||||
attrs = process_update_params(declared_params(include_missing: false))
|
||||
|
||||
service_response = ::Snippets::UpdateService.new(nil, current_user, attrs).execute(snippet)
|
||||
|
||||
snippet = service_response.payload[:snippet]
|
||||
|
||||
if service_response.success?
|
||||
|
|
|
@ -21,6 +21,17 @@ module ContainerRegistry
|
|||
# Taken from: FaradayMiddleware::FollowRedirects
|
||||
REDIRECT_CODES = Set.new [301, 302, 303, 307]
|
||||
|
||||
def self.supports_tag_delete?
|
||||
registry_config = Gitlab.config.registry
|
||||
return false unless registry_config.enabled && registry_config.api_url.present?
|
||||
|
||||
return true if ::Gitlab.com?
|
||||
|
||||
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
|
||||
client = new(registry_config.api_url, token: token)
|
||||
client.supports_tag_delete?
|
||||
end
|
||||
|
||||
def initialize(base_uri, options = {})
|
||||
@base_uri = base_uri
|
||||
@options = options
|
||||
|
|
|
@ -29,7 +29,7 @@ module Gitlab
|
|||
def table_condition(order_info, value, operator)
|
||||
if order_info.named_function
|
||||
target = order_info.named_function
|
||||
value = value&.downcase if target&.name&.downcase == 'lower'
|
||||
value = value&.downcase if target.respond_to?(:name) && target&.name&.downcase == 'lower'
|
||||
else
|
||||
target = arel_table[order_info.attribute_name]
|
||||
end
|
||||
|
|
|
@ -71,7 +71,22 @@ module Gitlab
|
|||
def extract_nulls_last_order(order_value)
|
||||
tokens = order_value.downcase.split
|
||||
|
||||
[tokens.first, (tokens[1] == 'asc' ? :asc : :desc), nil]
|
||||
column_reference = tokens.first
|
||||
sort_direction = tokens[1] == 'asc' ? :asc : :desc
|
||||
|
||||
# Handles the case when the order value is coming from another table.
|
||||
# Example: table_name.column_name
|
||||
# Query the value using the fully qualified column name: pass table_name.column_name as the named_function
|
||||
if fully_qualified_column_reference?(column_reference)
|
||||
[column_reference, sort_direction, Arel.sql(column_reference)]
|
||||
else
|
||||
[column_reference, sort_direction, nil]
|
||||
end
|
||||
end
|
||||
|
||||
# Example: table_name.column_name
|
||||
def fully_qualified_column_reference?(attribute)
|
||||
attribute.to_s.count('.') == 1
|
||||
end
|
||||
|
||||
def extract_attribute_values(order_value)
|
||||
|
|
|
@ -2834,6 +2834,9 @@ msgstr ""
|
|||
msgid "An error occurred while retrieving diff files"
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred while retrieving projects."
|
||||
msgstr ""
|
||||
|
||||
msgid "An error occurred while saving LDAP override status. Please try again."
|
||||
msgstr ""
|
||||
|
||||
|
@ -5006,6 +5009,9 @@ msgstr ""
|
|||
msgid "Cleanup policy for tags"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cleanup policy maximum processing time (seconds)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear"
|
||||
msgstr ""
|
||||
|
||||
|
@ -8610,6 +8616,9 @@ msgstr ""
|
|||
msgid "Diff limits"
|
||||
msgstr ""
|
||||
|
||||
msgid "Diff view settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Difference between start date and now"
|
||||
msgstr ""
|
||||
|
||||
|
@ -24097,6 +24106,9 @@ msgstr ""
|
|||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0."
|
||||
msgstr ""
|
||||
|
||||
msgid "Tags feed"
|
||||
msgstr ""
|
||||
|
||||
|
@ -25968,6 +25980,9 @@ msgstr ""
|
|||
msgid "To unsubscribe from this issue, please paste the following link into your browser:"
|
||||
msgstr ""
|
||||
|
||||
msgid "To update Snippets with multiple files, you must use the `files` parameter"
|
||||
msgstr ""
|
||||
|
||||
msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -12,19 +12,21 @@ module QA
|
|||
# implementation so that it's not included.
|
||||
end
|
||||
|
||||
def stop(notification)
|
||||
def stop(example_notification)
|
||||
# Based on https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/formatters/json_formatter.rb#L35
|
||||
# But modified to include full details of multiple exceptions
|
||||
@output_hash[:examples] = notification.examples.map do |example|
|
||||
format_example(example).tap do |hash|
|
||||
e = example.exception
|
||||
# But modified to include full details of multiple exceptions and to provide output similar to
|
||||
# https://github.com/sj26/rspec_junit_formatter
|
||||
@output_hash[:examples] = example_notification.notifications.map do |notification|
|
||||
format_example(notification.example).tap do |hash|
|
||||
e = notification.example.exception
|
||||
if e
|
||||
exceptions = e.respond_to?(:all_exceptions) ? e.all_exceptions : [e]
|
||||
hash[:exceptions] = exceptions.map do |exception|
|
||||
{
|
||||
class: exception.class.name,
|
||||
message: exception.message,
|
||||
backtrace: exception.backtrace
|
||||
message_lines: strip_ansi_codes(notification.message_lines),
|
||||
backtrace: notification.formatted_backtrace
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -60,6 +62,12 @@ module QA
|
|||
metadata[:shared_group_inclusion_backtrace].last.formatted_inclusion_location.split(':')
|
||||
end
|
||||
end
|
||||
|
||||
def strip_ansi_codes(strings)
|
||||
# The code below is from https://github.com/piotrmurach/pastel/blob/master/lib/pastel/color.rb
|
||||
modified = Array(strings).map { |string| string.dup.gsub(/\x1b\[{1,2}[0-9;:?]*m/m, '') }
|
||||
modified.size == 1 ? modified[0] : modified
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
11
spec/factories/atlassian_identities.rb
Normal file
11
spec/factories/atlassian_identities.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :atlassian_identity, class: 'Atlassian::Identity' do
|
||||
extern_uid { generate(:username) }
|
||||
user { create(:user) }
|
||||
expires_at { 2.weeks.from_now }
|
||||
token { SecureRandom.alphanumeric(1254) }
|
||||
refresh_token { SecureRandom.alphanumeric(45) }
|
||||
end
|
||||
end
|
|
@ -285,6 +285,55 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
|
|||
expect(current_settings.auto_devops_domain).to eq('domain.com')
|
||||
expect(page).to have_content "Application settings saved successfully"
|
||||
end
|
||||
|
||||
context 'Container Registry' do
|
||||
context 'delete tags service execution timeout' do
|
||||
let(:feature_flag_enabled) { true }
|
||||
let(:client_support) { true }
|
||||
|
||||
before do
|
||||
stub_container_registry_config(enabled: true)
|
||||
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
|
||||
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
|
||||
end
|
||||
|
||||
RSpec.shared_examples 'not having service timeout settings' do
|
||||
it 'lacks the timeout settings' do
|
||||
visit ci_cd_admin_application_settings_path
|
||||
|
||||
expect(page).not_to have_content "Container Registry delete tags service execution timeout"
|
||||
end
|
||||
end
|
||||
|
||||
context 'with feature flag enabled' do
|
||||
context 'with client supporting tag delete' do
|
||||
it 'changes the timeout' do
|
||||
visit ci_cd_admin_application_settings_path
|
||||
|
||||
page.within('.as-registry') do
|
||||
fill_in 'application_setting_container_registry_delete_tags_service_timeout', with: 400
|
||||
click_button 'Save changes'
|
||||
end
|
||||
|
||||
expect(current_settings.container_registry_delete_tags_service_timeout).to eq(400)
|
||||
expect(page).to have_content "Application settings saved successfully"
|
||||
end
|
||||
end
|
||||
|
||||
context 'with client not supporting tag delete' do
|
||||
let(:client_support) { false }
|
||||
|
||||
it_behaves_like 'not having service timeout settings'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with feature flag disabled' do
|
||||
let(:feature_flag_enabled) { false }
|
||||
|
||||
it_behaves_like 'not having service timeout settings'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'Repository page' do
|
||||
|
|
26
spec/features/issues/incident_issue_spec.rb
Normal file
26
spec/features/issues/incident_issue_spec.rb
Normal file
|
@ -0,0 +1,26 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Incident Detail', :js do
|
||||
let(:user) { create(:user) }
|
||||
let(:project) { create(:project, :public) }
|
||||
let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') }
|
||||
|
||||
context 'when user displays the incident' do
|
||||
before do
|
||||
visit project_issue_path(project, incident)
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
it 'shows the incident tabs' do
|
||||
page.within('.issuable-details') do
|
||||
incident_tabs = find('[data-testid="incident-tabs"]')
|
||||
|
||||
expect(find('h2')).to have_content(incident.title)
|
||||
expect(incident_tabs).to have_content('Summary')
|
||||
expect(incident_tabs).to have_content(incident.description)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -7,7 +7,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constant
|
|||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
describe('Diff settiings dropdown component', () => {
|
||||
describe('Diff settings dropdown component', () => {
|
||||
let vm;
|
||||
let actions;
|
||||
|
||||
|
@ -61,50 +61,50 @@ describe('Diff settiings dropdown component', () => {
|
|||
expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined);
|
||||
});
|
||||
|
||||
it('sets list button as active when renderTreeList is false', () => {
|
||||
it('sets list button as selected when renderTreeList is false', () => {
|
||||
createComponent(store => {
|
||||
Object.assign(store.state.diffs, {
|
||||
renderTreeList: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vm.find('.js-list-view').classes('active')).toBe(true);
|
||||
expect(vm.find('.js-tree-view').classes('active')).toBe(false);
|
||||
expect(vm.find('.js-list-view').classes('selected')).toBe(true);
|
||||
expect(vm.find('.js-tree-view').classes('selected')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets tree button as active when renderTreeList is true', () => {
|
||||
it('sets tree button as selected when renderTreeList is true', () => {
|
||||
createComponent(store => {
|
||||
Object.assign(store.state.diffs, {
|
||||
renderTreeList: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vm.find('.js-list-view').classes('active')).toBe(false);
|
||||
expect(vm.find('.js-tree-view').classes('active')).toBe(true);
|
||||
expect(vm.find('.js-list-view').classes('selected')).toBe(false);
|
||||
expect(vm.find('.js-tree-view').classes('selected')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare changes', () => {
|
||||
it('sets inline button as active', () => {
|
||||
it('sets inline button as selected', () => {
|
||||
createComponent(store => {
|
||||
Object.assign(store.state.diffs, {
|
||||
diffViewType: INLINE_DIFF_VIEW_TYPE,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vm.find('.js-inline-diff-button').classes('active')).toBe(true);
|
||||
expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(false);
|
||||
expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true);
|
||||
expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets parallel button as active', () => {
|
||||
it('sets parallel button as selected', () => {
|
||||
createComponent(store => {
|
||||
Object.assign(store.state.diffs, {
|
||||
diffViewType: PARALLEL_DIFF_VIEW_TYPE,
|
||||
});
|
||||
});
|
||||
|
||||
expect(vm.find('.js-inline-diff-button').classes('active')).toBe(false);
|
||||
expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(true);
|
||||
expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false);
|
||||
expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls setInlineDiffViewType when clicking inline button', () => {
|
||||
|
|
|
@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm';
|
|||
import IssuableApp from '~/issue_show/components/app.vue';
|
||||
import eventHub from '~/issue_show/event_hub';
|
||||
import { initialRequest, secondRequest } from '../mock_data';
|
||||
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
|
||||
import DescriptionComponent from '~/issue_show/components/description.vue';
|
||||
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
|
||||
|
||||
function formatText(text) {
|
||||
return text.trim().replace(/\s\s+/g, ' ');
|
||||
|
@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
|
|||
const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
|
||||
const publishedIncidentUrl = 'https://status.com/';
|
||||
|
||||
const defaultProps = {
|
||||
canUpdate: true,
|
||||
canDestroy: true,
|
||||
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
|
||||
updateEndpoint: TEST_HOST,
|
||||
issuableRef: '#1',
|
||||
issuableStatus: 'opened',
|
||||
initialTitleHtml: '',
|
||||
initialTitleText: '',
|
||||
initialDescriptionHtml: 'test',
|
||||
initialDescriptionText: 'test',
|
||||
lockVersion: 1,
|
||||
markdownPreviewPath: '/',
|
||||
markdownDocsPath: '/',
|
||||
projectNamespace: '/',
|
||||
projectPath: '/',
|
||||
issuableTemplateNamesPath: '/issuable-templates-path',
|
||||
zoomMeetingUrl,
|
||||
publishedIncidentUrl,
|
||||
};
|
||||
|
||||
describe('Issuable output', () => {
|
||||
useMockIntersectionObserver();
|
||||
|
||||
|
@ -31,6 +55,12 @@ describe('Issuable output', () => {
|
|||
|
||||
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
|
||||
|
||||
const mountComponent = (props = {}) => {
|
||||
wrapper = mount(IssuableApp, {
|
||||
propsData: { ...defaultProps, ...props },
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setFixtures(`
|
||||
<div>
|
||||
|
@ -57,28 +87,7 @@ describe('Issuable output', () => {
|
|||
return res;
|
||||
});
|
||||
|
||||
wrapper = mount(IssuableApp, {
|
||||
propsData: {
|
||||
canUpdate: true,
|
||||
canDestroy: true,
|
||||
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
|
||||
updateEndpoint: TEST_HOST,
|
||||
issuableRef: '#1',
|
||||
issuableStatus: 'opened',
|
||||
initialTitleHtml: '',
|
||||
initialTitleText: '',
|
||||
initialDescriptionHtml: 'test',
|
||||
initialDescriptionText: 'test',
|
||||
lockVersion: 1,
|
||||
markdownPreviewPath: '/',
|
||||
markdownDocsPath: '/',
|
||||
projectNamespace: '/',
|
||||
projectPath: '/',
|
||||
issuableTemplateNamesPath: '/issuable-templates-path',
|
||||
zoomMeetingUrl,
|
||||
publishedIncidentUrl,
|
||||
},
|
||||
});
|
||||
mountComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -562,4 +571,46 @@ describe('Issuable output', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Composable description component', () => {
|
||||
const findIncidentTabs = () => wrapper.find(IncidentTabs);
|
||||
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
|
||||
const findPinnedLinks = () => wrapper.find(PinnedLinks);
|
||||
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
|
||||
|
||||
describe('when using description component', () => {
|
||||
it('renders the description component', () => {
|
||||
expect(findDescriptionComponent().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render incident tabs', () => {
|
||||
expect(findIncidentTabs().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('adds a border below the header', () => {
|
||||
expect(findPinnedLinks().attributes('class')).toContain(borderClass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using incident tabs description wrapper', () => {
|
||||
beforeEach(() => {
|
||||
mountComponent({
|
||||
descriptionComponent: IncidentTabs,
|
||||
showTitleBorder: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the description component', () => {
|
||||
expect(findDescriptionComponent().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders incident tabs', () => {
|
||||
expect(findIncidentTabs().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not add a border below the header', () => {
|
||||
expect(findPinnedLinks().attributes('class')).not.toContain(borderClass);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper';
|
|||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import Description from '~/issue_show/components/description.vue';
|
||||
import TaskList from '~/task_list';
|
||||
import { descriptionProps as props } from '../mock_data';
|
||||
|
||||
jest.mock('~/task_list');
|
||||
|
||||
describe('Description component', () => {
|
||||
let vm;
|
||||
let DescriptionComponent;
|
||||
const props = {
|
||||
canUpdate: true,
|
||||
descriptionHtml: 'test',
|
||||
descriptionText: 'test',
|
||||
updatedAt: new Date().toString(),
|
||||
taskStatus: '',
|
||||
updateUrl: TEST_HOST,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
DescriptionComponent = Vue.extend(Description);
|
||||
|
|
44
spec/frontend/issue_show/components/incident_tabs_spec.js
Normal file
44
spec/frontend/issue_show/components/incident_tabs_spec.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlTab } from '@gitlab/ui';
|
||||
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
|
||||
import { descriptionProps } from '../mock_data';
|
||||
import DescriptionComponent from '~/issue_show/components/description.vue';
|
||||
|
||||
describe('Incident Tabs component', () => {
|
||||
let wrapper;
|
||||
|
||||
const mountComponent = () => {
|
||||
wrapper = shallowMount(IncidentTabs, {
|
||||
propsData: {
|
||||
...descriptionProps,
|
||||
},
|
||||
stubs: {
|
||||
DescriptionComponent: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mountComponent();
|
||||
});
|
||||
|
||||
const findTabs = () => wrapper.findAll(GlTab);
|
||||
const findSummaryTab = () => findTabs().at(0);
|
||||
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
|
||||
|
||||
describe('default state', () => {
|
||||
it('renders the summary tab', async () => {
|
||||
expect(findTabs()).toHaveLength(1);
|
||||
expect(findSummaryTab().exists()).toBe(true);
|
||||
expect(findSummaryTab().attributes('title')).toBe('Summary');
|
||||
});
|
||||
|
||||
it('renders the description component', () => {
|
||||
expect(findDescriptionComponent().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('passes all props to the description component', () => {
|
||||
expect(findDescriptionComponent().props()).toMatchObject(descriptionProps);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,19 +0,0 @@
|
|||
import initIssueableApp from '~/issue_show';
|
||||
|
||||
describe('Issue show index', () => {
|
||||
describe('initIssueableApp', () => {
|
||||
it('should initialize app with no potential XSS attack', () => {
|
||||
const d = document.createElement('div');
|
||||
d.id = 'js-issuable-app-initial-data';
|
||||
d.innerHTML = JSON.stringify({
|
||||
initialDescriptionHtml: '<img src=x onerror=alert(1)>',
|
||||
});
|
||||
document.body.appendChild(d);
|
||||
|
||||
const alertSpy = jest.spyOn(window, 'alert');
|
||||
initIssueableApp();
|
||||
|
||||
expect(alertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
26
spec/frontend/issue_show/issue_spec.js
Normal file
26
spec/frontend/issue_show/issue_spec.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import initIssuableApp from '~/issue_show/issue';
|
||||
import { parseIssuableData } from '~/issue_show/utils/parse_data';
|
||||
|
||||
describe('Issue show index', () => {
|
||||
describe('initIssueableApp', () => {
|
||||
// Warning: this test is currently faulty.
|
||||
// More details at https://gitlab.com/gitlab-org/gitlab/-/issues/241717
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('should initialize app with no potential XSS attack', () => {
|
||||
const d = document.createElement('div');
|
||||
d.id = 'js-issuable-app-initial-data';
|
||||
|
||||
d.innerHTML = JSON.stringify({
|
||||
initialDescriptionHtml: '<img src=x onerror=alert(1)>',
|
||||
});
|
||||
|
||||
document.body.appendChild(d);
|
||||
|
||||
const alertSpy = jest.spyOn(window, 'alert');
|
||||
const issuableData = parseIssuableData();
|
||||
initIssuableApp(issuableData);
|
||||
|
||||
expect(alertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,3 +1,5 @@
|
|||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
|
||||
export const initialRequest = {
|
||||
title: '<p>this is a title</p>',
|
||||
title_text: 'this is a title',
|
||||
|
@ -21,3 +23,11 @@ export const secondRequest = {
|
|||
updated_by_path: '/other_user',
|
||||
lock_version: 2,
|
||||
};
|
||||
|
||||
export const descriptionProps = {
|
||||
canUpdate: true,
|
||||
descriptionHtml: 'test',
|
||||
descriptionText: 'test',
|
||||
taskStatus: '',
|
||||
updateUrl: TEST_HOST,
|
||||
};
|
||||
|
|
|
@ -206,6 +206,33 @@ RSpec.describe Resolvers::MergeRequestsResolver do
|
|||
expect(result.compact).to contain_exactly(merge_request_4)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sorting' do
|
||||
context 'when sorting by created' do
|
||||
it 'sorts merge requests ascending' do
|
||||
expect(resolve_mr(project, sort: 'created_asc')).to eq [merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone]
|
||||
end
|
||||
|
||||
it 'sorts merge requests descending' do
|
||||
expect(resolve_mr(project, sort: 'created_desc')).to eq [merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_3, merge_request_2, merge_request_1]
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sorting by merged at' do
|
||||
before do
|
||||
merge_request_1.metrics.update!(merged_at: 10.days.ago)
|
||||
merge_request_3.metrics.update!(merged_at: 5.days.ago)
|
||||
end
|
||||
|
||||
it 'sorts merge requests ascending' do
|
||||
expect(resolve_mr(project, sort: :merged_at_asc)).to eq [merge_request_1, merge_request_3, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2]
|
||||
end
|
||||
|
||||
it 'sorts merge requests descending' do
|
||||
expect(resolve_mr(project, sort: :merged_at_desc)).to eq [merge_request_3, merge_request_1, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_mr_single(project, iid)
|
||||
|
|
15
spec/graphql/types/merge_request_sort_enum_spec.rb
Normal file
15
spec/graphql/types/merge_request_sort_enum_spec.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['MergeRequestSort'] do
|
||||
specify { expect(described_class.graphql_name).to eq('MergeRequestSort') }
|
||||
|
||||
it_behaves_like 'common sort values'
|
||||
|
||||
it 'exposes all the existing issue sort values' do
|
||||
expect(described_class.values.keys).to include(
|
||||
*%w[MERGED_AT_ASC MERGED_AT_DESC]
|
||||
)
|
||||
end
|
||||
end
|
|
@ -75,7 +75,8 @@ RSpec.describe GitlabSchema.types['Project'] do
|
|||
:merged_before,
|
||||
:author_username,
|
||||
:assignee_username,
|
||||
:milestone_title
|
||||
:milestone_title,
|
||||
:sort
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
27
spec/helpers/container_registry_helper_spec.rb
Normal file
27
spec/helpers/container_registry_helper_spec.rb
Normal file
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe ContainerRegistryHelper do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
describe '#limit_delete_tags_service?' do
|
||||
subject { helper.limit_delete_tags_service? }
|
||||
|
||||
where(:feature_flag_enabled, :client_support, :expected_result) do
|
||||
true | true | true
|
||||
true | false | false
|
||||
false | true | false
|
||||
false | false | false
|
||||
end
|
||||
|
||||
with_them do
|
||||
before do
|
||||
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
|
||||
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
|
||||
end
|
||||
|
||||
it { is_expected.to eq(expected_result) }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do
|
|||
initialTitleText: issue.title,
|
||||
initialDescriptionHtml: '<p dir="auto">issue text</p>',
|
||||
initialDescriptionText: 'issue text',
|
||||
initialTaskStatus: '0 of 0 tasks completed'
|
||||
initialTaskStatus: '0 of 0 tasks completed',
|
||||
issueType: 'issue'
|
||||
}
|
||||
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
|
||||
end
|
||||
|
|
|
@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.supports_tag_delete?' do
|
||||
let(:registry_enabled) { true }
|
||||
let(:registry_api_url) { 'http://sandbox.local' }
|
||||
let(:registry_tags_support_enabled) { true }
|
||||
let(:is_on_dot_com) { false }
|
||||
|
||||
subject { described_class.supports_tag_delete? }
|
||||
|
||||
before do
|
||||
allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
|
||||
stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
|
||||
stub_registry_tags_support(registry_tags_support_enabled)
|
||||
end
|
||||
|
||||
context 'with the registry enabled' do
|
||||
it { is_expected.to be true }
|
||||
|
||||
context 'without an api url' do
|
||||
let(:registry_api_url) { '' }
|
||||
|
||||
it { is_expected.to be false }
|
||||
end
|
||||
|
||||
context 'on .com' do
|
||||
let(:is_on_dot_com) { true }
|
||||
|
||||
it { is_expected.to be true }
|
||||
end
|
||||
|
||||
context 'when registry server does not support tag deletion' do
|
||||
let(:registry_tags_support_enabled) { false }
|
||||
|
||||
it { is_expected.to be false }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with the registry disabled' do
|
||||
let(:registry_enabled) { false }
|
||||
|
||||
it { is_expected.to be false }
|
||||
end
|
||||
|
||||
def stub_registry_tags_support(supported = true)
|
||||
status_code = supported ? 200 : 404
|
||||
stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
|
||||
.to_return(
|
||||
status: status_code,
|
||||
body: '',
|
||||
headers: { 'Allow' => 'DELETE' }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -71,6 +71,8 @@ RSpec.describe ApplicationSetting do
|
|||
it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) }
|
||||
it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) }
|
||||
|
||||
it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) }
|
||||
|
||||
it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) }
|
||||
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
|
||||
it { is_expected.to validate_presence_of(:max_artifacts_size) }
|
||||
|
|
34
spec/models/atlassian/identity_spec.rb
Normal file
34
spec/models/atlassian/identity_spec.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Atlassian::Identity do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
subject { create(:atlassian_identity) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:extern_uid) }
|
||||
it { is_expected.to validate_uniqueness_of(:extern_uid) }
|
||||
it { is_expected.to validate_presence_of(:user) }
|
||||
it { is_expected.to validate_uniqueness_of(:user) }
|
||||
end
|
||||
|
||||
describe 'encrypted tokens' do
|
||||
let(:token) { SecureRandom.alphanumeric(1254) }
|
||||
let(:refresh_token) { SecureRandom.alphanumeric(45) }
|
||||
let(:identity) { create(:atlassian_identity, token: token, refresh_token: refresh_token) }
|
||||
|
||||
it 'saves the encrypted token, refresh token and corresponding ivs' do
|
||||
expect(identity.encrypted_token).not_to be_nil
|
||||
expect(identity.encrypted_token_iv).not_to be_nil
|
||||
expect(identity.encrypted_refresh_token).not_to be_nil
|
||||
expect(identity.encrypted_refresh_token_iv).not_to be_nil
|
||||
|
||||
expect(identity.token).to eq(token)
|
||||
expect(identity.refresh_token).to eq(refresh_token)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -61,6 +61,24 @@ RSpec.describe MergeRequest, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.order_merged_at_asc' do
|
||||
let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
|
||||
let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
|
||||
|
||||
it 'returns MRs ordered by merged_at ascending' do
|
||||
expect(described_class.order_merged_at_asc).to eq([older_mr, newer_mr])
|
||||
end
|
||||
end
|
||||
|
||||
describe '.order_merged_at_desc' do
|
||||
let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
|
||||
let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
|
||||
|
||||
it 'returns MRs ordered by merged_at descending' do
|
||||
expect(described_class.order_merged_at_desc).to eq([newer_mr, older_mr])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#squash_in_progress?' do
|
||||
let(:repo_path) do
|
||||
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
|
||||
|
@ -431,6 +449,23 @@ RSpec.describe MergeRequest, factory_default: :keep do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.sort_by_attribute' do
|
||||
context 'merged_at' do
|
||||
let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
|
||||
let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
|
||||
|
||||
it 'sorts asc' do
|
||||
merge_requests = described_class.sort_by_attribute(:merged_at_asc)
|
||||
expect(merge_requests).to eq([older_mr, newer_mr])
|
||||
end
|
||||
|
||||
it 'sorts desc' do
|
||||
merge_requests = described_class.sort_by_attribute(:merged_at_desc)
|
||||
expect(merge_requests).to eq([newer_mr, older_mr])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#target_branch_sha' do
|
||||
let(:project) { create(:project, :repository) }
|
||||
|
||||
|
|
|
@ -171,6 +171,16 @@ RSpec.describe Service do
|
|||
it { is_expected.to be_falsey }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when group-level service' do
|
||||
Service.available_services_types.each do |service_type|
|
||||
let(:service) do
|
||||
service_type.constantize.new(group_id: group.id)
|
||||
end
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#test' do
|
||||
|
|
|
@ -787,4 +787,26 @@ RSpec.describe Snippet do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#multiple_files?' do
|
||||
subject { snippet.multiple_files? }
|
||||
|
||||
context 'when snippet has multiple files' do
|
||||
let(:snippet) { create(:snippet, :repository) }
|
||||
|
||||
it { is_expected.to be_truthy }
|
||||
end
|
||||
|
||||
context 'when snippet does not have multiple files' do
|
||||
let(:snippet) { create(:snippet, :empty_repo) }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
|
||||
context 'when the snippet does not have a repository' do
|
||||
let(:snippet) { build(:snippet) }
|
||||
|
||||
it { is_expected.to be_falsey }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -68,6 +68,7 @@ RSpec.describe User do
|
|||
it { is_expected.to have_one(:namespace) }
|
||||
it { is_expected.to have_one(:status) }
|
||||
it { is_expected.to have_one(:user_detail) }
|
||||
it { is_expected.to have_one(:atlassian_identity) }
|
||||
it { is_expected.to have_one(:user_highest_role) }
|
||||
it { is_expected.to have_many(:snippets).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:members) }
|
||||
|
|
|
@ -12,11 +12,11 @@ RSpec.describe "uploading designs" do
|
|||
let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] }
|
||||
let(:variables) { {} }
|
||||
|
||||
let(:mutation) do
|
||||
def mutation
|
||||
input = {
|
||||
project_path: project.full_path,
|
||||
iid: issue.iid,
|
||||
files: files
|
||||
files: files.dup
|
||||
}.merge(variables)
|
||||
graphql_mutation(:design_management_upload, input)
|
||||
end
|
||||
|
@ -30,31 +30,15 @@ RSpec.describe "uploading designs" do
|
|||
end
|
||||
|
||||
it "returns an error if the user is not allowed to upload designs" do
|
||||
post_graphql_mutation(mutation, current_user: create(:user))
|
||||
post_graphql_mutation_with_uploads(mutation, current_user: create(:user))
|
||||
|
||||
expect(graphql_errors).to be_present
|
||||
end
|
||||
|
||||
it "succeeds (backward compatibility)" do
|
||||
post_graphql_mutation(mutation, current_user: current_user)
|
||||
it "succeeds, and responds with the created designs" do
|
||||
post_graphql_mutation_with_uploads(mutation, current_user: current_user)
|
||||
|
||||
expect(graphql_errors).not_to be_present
|
||||
end
|
||||
|
||||
it 'succeeds' do
|
||||
file_path_in_params = ['designManagementUploadInput', 'files', 0]
|
||||
params = mutation_to_apollo_uploads_param(mutation, files: [file_path_in_params])
|
||||
|
||||
workhorse_post_with_file(api('/', current_user, version: 'graphql'),
|
||||
params: params,
|
||||
file_key: '1'
|
||||
)
|
||||
|
||||
expect(graphql_errors).not_to be_present
|
||||
end
|
||||
|
||||
it "responds with the created designs" do
|
||||
post_graphql_mutation(mutation, current_user: current_user)
|
||||
|
||||
expect(mutation_response).to include(
|
||||
"designs" => a_collection_containing_exactly(
|
||||
|
@ -65,7 +49,7 @@ RSpec.describe "uploading designs" do
|
|||
|
||||
it "can respond with skipped designs" do
|
||||
2.times do
|
||||
post_graphql_mutation(mutation, current_user: current_user)
|
||||
post_graphql_mutation_with_uploads(mutation, current_user: current_user)
|
||||
files.each(&:rewind)
|
||||
end
|
||||
|
||||
|
@ -80,7 +64,7 @@ RSpec.describe "uploading designs" do
|
|||
let(:variables) { { iid: "123" } }
|
||||
|
||||
it "returns an error" do
|
||||
post_graphql_mutation(mutation, current_user: create(:user))
|
||||
post_graphql_mutation_with_uploads(mutation, current_user: create(:user))
|
||||
|
||||
expect(graphql_errors).not_to be_empty
|
||||
end
|
||||
|
@ -92,7 +76,7 @@ RSpec.describe "uploading designs" do
|
|||
expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" })
|
||||
end
|
||||
|
||||
post_graphql_mutation(mutation, current_user: current_user)
|
||||
post_graphql_mutation_with_uploads(mutation, current_user: current_user)
|
||||
expect(mutation_response["errors"].first).to eq("Something went wrong")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -210,4 +210,48 @@ RSpec.describe 'getting merge request listings nested in a project' do
|
|||
include_examples 'N+1 query check'
|
||||
end
|
||||
end
|
||||
describe 'sorting and pagination' do
|
||||
let(:data_path) { [:project, :mergeRequests] }
|
||||
|
||||
def pagination_query(params, page_info)
|
||||
graphql_query_for(
|
||||
:project,
|
||||
{ full_path: project.full_path },
|
||||
<<~QUERY
|
||||
mergeRequests(#{params}) {
|
||||
#{page_info} edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
QUERY
|
||||
)
|
||||
end
|
||||
|
||||
def pagination_results_data(data)
|
||||
data.map { |project| project.dig('node', 'id') }
|
||||
end
|
||||
|
||||
context 'when sorting by merged_at DESC' do
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_param) { 'MERGED_AT_DESC' }
|
||||
let(:first_param) { 2 }
|
||||
|
||||
let(:expected_results) do
|
||||
[
|
||||
merge_request_b,
|
||||
merge_request_c,
|
||||
merge_request_d,
|
||||
merge_request_a
|
||||
].map(&:to_gid).map(&:to_s)
|
||||
end
|
||||
|
||||
before do
|
||||
merge_request_c.metrics.update!(merged_at: 5.days.ago)
|
||||
merge_request_b.metrics.update!(merged_at: 1.day.ago)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -391,20 +391,97 @@ RSpec.describe API::Snippets do
|
|||
create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
|
||||
end
|
||||
|
||||
shared_examples 'snippet updates' do
|
||||
it 'updates a snippet' do
|
||||
new_content = 'New content'
|
||||
let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } }
|
||||
let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } }
|
||||
let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } }
|
||||
let(:delete_action) { { action: 'delete', file_path: 'CONTRIBUTING.md' } }
|
||||
let(:bad_file_path) { { action: 'create', file_path: '../../etc/passwd', content: 'bar' } }
|
||||
let(:bad_previous_path) { { action: 'create', previous_path: '../../etc/passwd', file_path: 'CHANGELOG', content: 'bar' } }
|
||||
let(:invalid_move) { { action: 'move', file_path: 'missing_previous_path.txt' } }
|
||||
|
||||
context 'with snippet file changes' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
where(:is_multi_file, :file_name, :content, :files, :status) do
|
||||
true | nil | nil | [create_action] | :success
|
||||
true | nil | nil | [update_action] | :success
|
||||
true | nil | nil | [move_action] | :success
|
||||
true | nil | nil | [delete_action] | :success
|
||||
true | nil | nil | [create_action, update_action] | :success
|
||||
true | 'foo.txt' | 'bar' | [create_action] | :bad_request
|
||||
true | 'foo.txt' | 'bar' | nil | :bad_request
|
||||
true | nil | nil | nil | :bad_request
|
||||
true | 'foo.txt' | nil | [create_action] | :bad_request
|
||||
true | nil | 'bar' | [create_action] | :bad_request
|
||||
true | '' | nil | [create_action] | :bad_request
|
||||
true | nil | '' | [create_action] | :bad_request
|
||||
true | nil | nil | [bad_file_path] | :bad_request
|
||||
true | nil | nil | [bad_previous_path] | :bad_request
|
||||
true | nil | nil | [invalid_move] | :forbidden
|
||||
|
||||
false | 'foo.txt' | 'bar' | nil | :success
|
||||
false | 'foo.txt' | nil | nil | :success
|
||||
false | nil | 'bar' | nil | :success
|
||||
false | 'foo.txt' | 'bar' | [create_action] | :bad_request
|
||||
false | nil | nil | nil | :bad_request
|
||||
false | nil | '' | nil | :bad_request
|
||||
false | nil | nil | [bad_file_path] | :bad_request
|
||||
false | nil | nil | [bad_previous_path] | :bad_request
|
||||
end
|
||||
|
||||
with_them do
|
||||
before do
|
||||
allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(is_multi_file)
|
||||
end
|
||||
|
||||
it 'has the correct response' do
|
||||
update_params = {}.tap do |params|
|
||||
params[:files] = files if files
|
||||
params[:file_name] = file_name if file_name
|
||||
params[:content] = content if content
|
||||
end
|
||||
|
||||
update_snippet(params: update_params)
|
||||
|
||||
expect(response).to have_gitlab_http_status(status)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when save fails due to a repository commit error' do
|
||||
before do
|
||||
allow_next_instance_of(Repository) do |instance|
|
||||
allow(instance).to receive(:multi_action).and_raise(Gitlab::Git::CommitError)
|
||||
end
|
||||
|
||||
update_snippet(params: { files: [create_action] })
|
||||
end
|
||||
|
||||
it 'returns a bad request response' do
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'snippet non-file updates' do
|
||||
it 'updates a snippet non-file attributes' do
|
||||
new_description = 'New description'
|
||||
new_title = 'New title'
|
||||
new_visibility = 'internal'
|
||||
|
||||
update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' })
|
||||
update_snippet(params: { title: new_title, description: new_description, visibility: new_visibility })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
snippet.reload
|
||||
expect(snippet.content).to eq(new_content)
|
||||
|
||||
aggregate_failures do
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(snippet.description).to eq(new_description)
|
||||
expect(snippet.visibility).to eq('internal')
|
||||
expect(snippet.visibility).to eq(new_visibility)
|
||||
expect(snippet.title).to eq(new_title)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'snippet non-file updates'
|
||||
|
||||
context 'with restricted visibility settings' do
|
||||
before do
|
||||
|
@ -413,11 +490,9 @@ RSpec.describe API::Snippets do
|
|||
Gitlab::VisibilityLevel::PRIVATE])
|
||||
end
|
||||
|
||||
it_behaves_like 'snippet updates'
|
||||
it_behaves_like 'snippet non-file updates'
|
||||
end
|
||||
|
||||
it_behaves_like 'snippet updates'
|
||||
|
||||
it 'returns 404 for invalid snippet id' do
|
||||
update_snippet(snippet_id: non_existing_record_id, params: { title: 'Foo' })
|
||||
|
||||
|
@ -438,13 +513,6 @@ RSpec.describe API::Snippets do
|
|||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'returns 400 if content is blank' do
|
||||
update_snippet(params: { content: '' })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
expect(json_response['error']).to eq 'content is empty'
|
||||
end
|
||||
|
||||
it 'returns 400 if title is blank' do
|
||||
update_snippet(params: { title: '' })
|
||||
|
||||
|
|
|
@ -66,12 +66,13 @@ RSpec.describe Ci::ParseDotenvArtifactService do
|
|||
end
|
||||
|
||||
context 'when multiple key/value pairs exist in one line' do
|
||||
let(:blob) { 'KEY1=VAR1KEY2=VAR1' }
|
||||
let(:blob) { 'KEY=VARCONTAINING=EQLS' }
|
||||
|
||||
it 'returns error' do
|
||||
expect(subject[:status]).to eq(:error)
|
||||
expect(subject[:message]).to eq("Validation failed: Key can contain only letters, digits and '_'.")
|
||||
expect(subject[:http_status]).to eq(:bad_request)
|
||||
it 'parses the dotenv data' do
|
||||
subject
|
||||
|
||||
expect(build.job_variables.as_json).to contain_exactly(
|
||||
hash_including('key' => 'KEY', 'value' => 'VARCONTAINING=EQLS'))
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
|
|||
|
||||
subject { service.execute(repository) }
|
||||
|
||||
before do
|
||||
stub_feature_flags(container_registry_expiration_policies_throttling: false)
|
||||
end
|
||||
|
||||
context 'without permissions' do
|
||||
it { is_expected.to include(status: :error) }
|
||||
end
|
||||
|
@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
|
|||
|
||||
it_behaves_like 'logging a success response'
|
||||
end
|
||||
|
||||
context 'with a timeout error' do
|
||||
before do
|
||||
expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
|
||||
expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
|
||||
|
||||
it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
|
||||
end
|
||||
end
|
||||
|
||||
context 'and the feature is disabled' do
|
||||
|
|
|
@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
|
|||
|
||||
subject { service.execute }
|
||||
|
||||
context 'with tags to delete' do
|
||||
before do
|
||||
stub_feature_flags(container_registry_expiration_policies_throttling: false)
|
||||
end
|
||||
|
||||
RSpec.shared_examples 'deleting tags' do
|
||||
it 'deletes the tags by name' do
|
||||
stub_delete_reference_requests(tags)
|
||||
expect_delete_tag_by_names(tags)
|
||||
|
||||
is_expected.to eq(status: :success, deleted: tags)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with tags to delete' do
|
||||
it_behaves_like 'deleting tags'
|
||||
|
||||
it 'succeeds when tag delete returns 404' do
|
||||
stub_delete_reference_requests('A' => 200, 'Ba' => 404)
|
||||
|
@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
|
|||
it { is_expected.to eq(status: :error, message: 'could not delete tags') }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with throttling enabled' do
|
||||
let(:timeout) { 10 }
|
||||
|
||||
before do
|
||||
stub_feature_flags(container_registry_expiration_policies_throttling: true)
|
||||
stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
|
||||
end
|
||||
|
||||
it_behaves_like 'deleting tags'
|
||||
|
||||
context 'with timeout' do
|
||||
context 'set to a valid value' do
|
||||
before do
|
||||
allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
|
||||
stub_delete_reference_requests('A' => 200)
|
||||
end
|
||||
|
||||
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
|
||||
|
||||
it 'tracks the exception' do
|
||||
expect(::Gitlab::ErrorTracking)
|
||||
.to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
context 'set to 0' do
|
||||
let(:timeout) { 0 }
|
||||
|
||||
it_behaves_like 'deleting tags'
|
||||
end
|
||||
|
||||
context 'set to nil' do
|
||||
let(:timeout) { nil }
|
||||
|
||||
it_behaves_like 'deleting tags'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty tags' do
|
||||
|
|
|
@ -241,6 +241,39 @@ module GraphqlHelpers
|
|||
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables)
|
||||
end
|
||||
|
||||
def post_graphql_mutation_with_uploads(mutation, current_user: nil)
|
||||
file_paths = file_paths_in_mutation(mutation)
|
||||
params = mutation_to_apollo_uploads_param(mutation, files: file_paths)
|
||||
|
||||
workhorse_post_with_file(api('/', current_user, version: 'graphql'),
|
||||
params: params,
|
||||
file_key: '1'
|
||||
)
|
||||
end
|
||||
|
||||
def file_paths_in_mutation(mutation)
|
||||
paths = []
|
||||
find_uploads(paths, [], mutation.variables)
|
||||
|
||||
paths
|
||||
end
|
||||
|
||||
# Depth first search for UploadedFile values
|
||||
def find_uploads(paths, path, value)
|
||||
case value
|
||||
when Rack::Test::UploadedFile
|
||||
paths << path
|
||||
when Hash
|
||||
value.each do |k, v|
|
||||
find_uploads(paths, path + [k], v)
|
||||
end
|
||||
when Array
|
||||
value.each_with_index do |v, i|
|
||||
find_uploads(paths, path + [i], v)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# this implements GraphQL multipart request v2
|
||||
# https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2
|
||||
# this is simplified and do not support file deduplication
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
RSpec.shared_examples 'update with repository actions' do
|
||||
context 'when the repository exists' do
|
||||
before do
|
||||
allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(false)
|
||||
end
|
||||
|
||||
it 'commits the changes to the repository' do
|
||||
existing_blob = snippet.blobs.first
|
||||
new_file_name = existing_blob.path + '_new'
|
||||
|
|
Loading…
Reference in a new issue