Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-09-02 09:10:23 +00:00
parent 03a5217322
commit 4b9ace6c1f
88 changed files with 1495 additions and 245 deletions

View File

@ -1 +1 @@
15c2f3921c4729e9c4d7ce8592300decfcfdb2e6 12dcff902c9a2178fa6f4992d9d562ad9b422dd2

View File

@ -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: # 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 # https://gitlab.com/gitlab-org/gitlab/issues/31747
gem 'graphiql-rails', '~> 1.4.10' 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] gem 'graphql-docs', '~> 1.6.0', group: [:development, :test]
# Disable strong_params so that Mash does not respond to :permitted? # Disable strong_params so that Mash does not respond to :permitted?

View File

@ -73,7 +73,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.0.1) aes_key_wrap (1.0.1)
akismet (3.0.0) akismet (3.0.0)
apollo_upload_server (2.0.0.beta.3) apollo_upload_server (2.0.2)
graphql (>= 1.8) graphql (>= 1.8)
rails (>= 4.2) rails (>= 4.2)
asana (0.10.0) asana (0.10.0)
@ -1220,7 +1220,7 @@ DEPENDENCIES
acts-as-taggable-on (~> 6.0) acts-as-taggable-on (~> 6.0)
addressable (~> 2.7) addressable (~> 2.7)
akismet (~> 3.0) akismet (~> 3.0)
apollo_upload_server (~> 2.0.0.beta3) apollo_upload_server (~> 2.0.2)
asana (= 0.10.0) asana (= 0.10.0)
asciidoctor (~> 2.0.10) asciidoctor (~> 2.0.10)
asciidoctor-include-ext (~> 0.3.1) asciidoctor-include-ext (~> 0.3.1)

View File

@ -1,16 +1,24 @@
<script> <script>
import { mapActions, mapGetters, mapState } from 'vuex'; import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; import { GlButtonGroup, GlButton, GlDropdown } from '@gitlab/ui';
import { __ } from '~/locale';
export default { export default {
components: { components: {
GlDeprecatedButton, GlButtonGroup,
GlIcon, GlButton,
GlDropdown,
}, },
computed: { computed: {
...mapGetters('diffs', ['isInlineView', 'isParallelView']), ...mapGetters('diffs', ['isInlineView', 'isParallelView']),
...mapState('diffs', ['renderTreeList', 'showWhitespace']), ...mapState('diffs', ['renderTreeList', 'showWhitespace']),
}, },
mounted() {
this.patchAriaLabel();
},
updated() {
this.patchAriaLabel();
},
methods: { methods: {
...mapActions('diffs', [ ...mapActions('diffs', [
'setInlineDiffViewType', 'setInlineDiffViewType',
@ -18,74 +26,69 @@ export default {
'setRenderTreeList', 'setRenderTreeList',
'setShowWhitespace', 'setShowWhitespace',
]), ]),
patchAriaLabel() {
this.$el
.querySelector('.js-show-diff-settings')
.setAttribute('aria-label', __('Diff view settings'));
},
}, },
}; };
</script> </script>
<template> <template>
<div class="dropdown"> <gl-dropdown icon="settings" toggle-class="js-show-diff-settings" right>
<button <div class="gl-px-3">
type="button" <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span>
class="btn btn-default js-show-diff-settings" <gl-button-group class="gl-display-flex">
data-toggle="dropdown" <gl-button
data-display="static" :class="{ selected: !renderTreeList }"
> class="gl-w-half js-list-view"
<gl-icon name="settings" /> <gl-icon name="chevron-down" /> @click="setRenderTreeList(false)"
</button> >
<div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3"> {{ __('List view') }}
<div> </gl-button>
<span class="bold d-block mb-1">{{ __('File browser') }}</span> <gl-button
<div class="btn-group d-flex"> :class="{ selected: renderTreeList }"
<gl-deprecated-button class="gl-w-half js-tree-view"
:class="{ active: !renderTreeList }" @click="setRenderTreeList(true)"
class="w-100 js-list-view" >
@click="setRenderTreeList(false)" {{ __('Tree view') }}
> </gl-button>
{{ __('List view') }} </gl-button-group>
</gl-deprecated-button>
<gl-deprecated-button
:class="{ active: renderTreeList }"
class="w-100 js-tree-view"
@click="setRenderTreeList(true)"
>
{{ __('Tree view') }}
</gl-deprecated-button>
</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
id="inline-diff-btn"
:class="{ active: isInlineView }"
class="w-100 js-inline-diff-button"
data-view-type="inline"
@click="setInlineDiffViewType"
>
{{ __('Inline') }}
</gl-deprecated-button>
<gl-deprecated-button
id="parallel-diff-btn"
:class="{ active: isParallelView }"
class="w-100 js-parallel-diff-button"
data-view-type="parallel"
@click="setParallelDiffViewType"
>
{{ __('Side-by-side') }}
</gl-deprecated-button>
</div>
</div>
<div class="mt-2">
<label class="mb-0">
<input
id="show-whitespace"
type="checkbox"
:checked="showWhitespace"
@change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })"
/>
{{ __('Show whitespace changes') }}
</label>
</div>
</div> </div>
</div> <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="{ selected: isInlineView }"
class="gl-w-half js-inline-diff-button"
data-view-type="inline"
@click="setInlineDiffViewType"
>
{{ __('Inline') }}
</gl-button>
<gl-button
id="parallel-diff-btn"
:class="{ selected: isParallelView }"
class="gl-w-half js-parallel-diff-button"
data-view-type="parallel"
@click="setParallelDiffViewType"
>
{{ __('Side-by-side') }}
</gl-button>
</gl-button-group>
</div>
<div class="gl-mt-3 gl-px-3">
<label class="gl-mb-0">
<input
id="show-whitespace"
type="checkbox"
:checked="showWhitespace"
@change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })"
/>
{{ __('Show whitespace changes') }}
</label>
</div>
</gl-dropdown>
</template> </template>

View File

@ -20,7 +20,6 @@ export default {
components: { components: {
GlIcon, GlIcon,
GlIntersectionObserver, GlIntersectionObserver,
descriptionComponent,
titleComponent, titleComponent,
editedComponent, editedComponent,
formComponent, formComponent,
@ -152,6 +151,18 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
descriptionComponent: {
type: Object,
required: false,
default: () => {
return descriptionComponent;
},
},
showTitleBorder: {
type: Boolean,
required: false,
default: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
@ -209,6 +220,11 @@ export default {
isOpenStatus() { isOpenStatus() {
return this.issuableStatus === IssuableStatus.Open; 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() { statusIcon() {
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close'; return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
}, },
@ -447,9 +463,11 @@ export default {
<pinned-links <pinned-links
:zoom-meeting-url="zoomMeetingUrl" :zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl" :published-incident-url="publishedIncidentUrl"
:class="pinnedLinkClasses"
/> />
<description-component <component
:is="descriptionComponent"
v-if="state.descriptionHtml" v-if="state.descriptionHtml"
:can-update="canUpdate" :can-update="canUpdate"
:description-html="state.descriptionHtml" :description-html="state.descriptionHtml"

View File

@ -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>

View File

@ -45,7 +45,7 @@ export default {
</script> </script>
<template> <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"> <template v-for="(link, i) in pinnedLinks">
<div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }"> <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
<gl-button <gl-button

View 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,
},
});
},
});
}

View File

@ -1,8 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import issuableApp from './components/app.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({ return new Vue({
el: document.getElementById('js-issuable-app'), el: document.getElementById('js-issuable-app'),
components: { components: {
@ -10,7 +9,7 @@ export default function initIssueableApp() {
}, },
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
props: parseIssuableData(), props: issuableData,
}); });
}, },
}); });

View File

@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
import '~/notes/index'; import '~/notes/index';
import { store } from '~/notes/stores'; 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 initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
export default function() { export default function() {
initIssueableApp(); const { issueType, ...issuableData } = parseIssuableData();
if (issueType === 'incident') {
initIncidentApp(issuableData);
} else {
initIssueApp(issuableData);
}
initIssuableHeaderWarning(store); initIssuableHeaderWarning(store);
initSentryErrorStackTraceApp(); initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp(); initRelatedMergeRequestsApp();

View File

@ -1033,3 +1033,9 @@ $mr-widget-min-height: 69px;
.diff-file-row.is-active { .diff-file-row.is-active {
background-color: $gray-50; background-color: $gray-50;
} }
.merge-request-container {
.flash-container {
@include gl-mb-4;
}
}

View File

@ -3,7 +3,6 @@
module MergedAtFilter module MergedAtFilter
private private
# rubocop: disable CodeReuse/ActiveRecord
def by_merged_at(items) def by_merged_at(items)
return items unless merged_after || merged_before 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_after(merged_after) if merged_after.present?
mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present? mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present?
scope = items.joins(:metrics).merge(mr_metrics_scope) items.join_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
end end
# rubocop: enable CodeReuse/ActiveRecord
def merged_after def merged_after
params[:merged_after] params[:merged_after]
@ -24,10 +20,4 @@ module MergedAtFilter
def merged_before def merged_before
params[:merged_before] params[:merged_before]
end 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 end

View File

@ -37,6 +37,10 @@ module Resolvers
argument :milestone_title, GraphQL::STRING_TYPE, argument :milestone_title, GraphQL::STRING_TYPE,
required: false, required: false,
description: 'Title of the milestone' 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 def self.single
::Resolvers::MergeRequestResolver ::Resolvers::MergeRequestResolver

View 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

View File

@ -327,7 +327,8 @@ module ApplicationSettingsHelper
:group_import_limit, :group_import_limit,
:group_export_limit, :group_export_limit,
:group_download_export_limit, :group_download_export_limit,
:wiki_page_max_content_bytes :wiki_page_max_content_bytes,
:container_registry_delete_tags_service_timeout
] ]
end end

View 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

View File

@ -292,6 +292,7 @@ module IssuablesHelper
{ {
hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0, hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord
} }
@ -301,8 +302,8 @@ module IssuablesHelper
return { groupPath: parent.path } if parent.is_a?(Group) return { groupPath: parent.path } if parent.is_a?(Group)
{ {
projectPath: ref_project.path, projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path projectNamespace: ref_project.namespace.full_path
} }
end end

View File

@ -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 :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| SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end end

View File

@ -163,7 +163,8 @@ module ApplicationSettingImplementation
user_default_external: false, user_default_external: false,
user_default_internal_regex: nil, user_default_internal_regex: nil,
user_show_add_ssh_key_message: true, 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 end

View 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

View File

@ -251,6 +251,15 @@ class MergeRequest < ApplicationRecord
joins(:notes).where(notes: { commit_id: sha }) joins(:notes).where(notes: { commit_id: sha })
end end
scope :join_project, -> { joins(:target_project) } 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 :references_project, -> { references(:target_project) }
scope :with_api_entity_associations, -> { scope :with_api_entity_associations, -> {
preload_routables preload_routables
@ -264,6 +273,14 @@ class MergeRequest < ApplicationRecord
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
end end
scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } 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_source_project, -> { preload(:source_project) }
scope :preload_target_project, -> { preload(:target_project) } scope :preload_target_project, -> { preload(:target_project) }
scope :preload_routables, -> do scope :preload_routables, -> do
@ -320,6 +337,15 @@ class MergeRequest < ApplicationRecord
.pluck(:target_branch) .pluck(:target_branch)
end 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? def rebase_in_progress?
rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid) rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)
end end

View File

@ -351,10 +351,10 @@ class Service < ApplicationRecord
{ success: result.present?, result: result } { success: result.present?, result: result }
end end
# Disable test for instance-level services. # Disable test for instance-level and group-level services.
# https://gitlab.com/gitlab-org/gitlab/-/issues/213138 # https://gitlab.com/gitlab-org/gitlab/-/issues/213138
def can_test? def can_test?
!instance? !instance? && !group_id
end end
# Returns a hash of the properties that have been assigned a new value since last save, # Returns a hash of the properties that have been assigned a new value since last save,

View File

@ -345,6 +345,10 @@ class Snippet < ApplicationRecord
repository.ls_files(ref) repository.ls_files(ref)
end end
def multiple_files?
list_files(repository.root_ref).size > 1
end
class << self class << self
# Searches for snippets with a matching title, description or file name. # Searches for snippets with a matching title, description or file name.
# #

View File

@ -181,6 +181,7 @@ class User < ApplicationRecord
has_one :user_detail has_one :user_detail
has_one :user_highest_role has_one :user_highest_role
has_one :user_canonical_email has_one :user_canonical_email
has_one :atlassian_identity, class_name: 'Atlassian::Identity'
has_many :reviews, foreign_key: :author_id, inverse_of: :author has_many :reviews, foreign_key: :author_id, inverse_of: :author

View File

@ -54,7 +54,7 @@ module Ci
end end
def scan_line!(line) def scan_line!(line)
result = line.scan(/^(.*)=(.*)$/).last result = line.scan(/^(.*?)=(.*)$/).last
raise ParserError, 'Invalid Format' if result.nil? raise ParserError, 'Invalid Format' if result.nil?

View File

@ -5,6 +5,11 @@ module Projects
module Gitlab module Gitlab
class DeleteTagsService class DeleteTagsService
include BaseServiceUtility include BaseServiceUtility
include ::Gitlab::Utils::StrongMemoize
DISABLED_TIMEOUTS = [nil, 0].freeze
TimeoutError = Class.new(StandardError)
def initialize(container_repository, tag_names) def initialize(container_repository, tag_names)
@container_repository = container_repository @container_repository = container_repository
@ -17,12 +22,42 @@ module Projects
def execute def execute
return success(deleted: []) if @tag_names.empty? 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| deleted_tags = @tag_names.select do |name|
raise TimeoutError if timeout?(start_time)
@container_repository.delete_tag_by_name(name) @container_repository.delete_tag_by_name(name)
end end
deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags') deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
end 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 end
end end

View File

@ -15,7 +15,7 @@ module Projects
# This is a hack as the registry doesn't support deleting individual # 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. # 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 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. # don't support fast delete.
# See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
def execute def execute

View File

@ -14,5 +14,11 @@
.form-text.text-muted .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.") = _("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') = 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" = f.submit 'Save changes', class: "btn btn-success"

View File

@ -1,5 +1,5 @@
- @gfm_form = true - @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) - add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference - breadcrumb_title @merge_request.to_reference
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests") - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests")

View File

@ -21,7 +21,7 @@
.col-sm-12 .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') = 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 %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? - if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") = 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'), = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),

View File

@ -17,13 +17,13 @@
= sprite_icon('pencil') = sprite_icon('pencil')
- elsif current_user - elsif current_user
- if @user.abuse_report - if @user.abuse_report
%button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'), %button{ class: link_classes + 'btn btn-danger', title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } } data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }>
= icon('exclamation-circle') = sprite_icon('error')
- else - else
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn', = 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 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) - 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 = 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') = sprite_icon('rss', css_class: 'qa-rss-icon')

View File

@ -0,0 +1,5 @@
---
title: Add timeout support in the delete tags service for the GitLab Registry
merge_request: 36319
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Replace fa-exclamation-circle and fa-lightbulb-o with GitLab SVG icons
merge_request: 40857
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Bug fix GraphQL file uploads accepting non-file input
merge_request: 39763
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Migrating buttons and classes to match GitLab UI
merge_request: 40409
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Add MergeRequest sort options to GraphQL API
merge_request: 40138
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add Flash spacing on merge request show page
merge_request: 39903
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add Atlassian Identity to store identity/credentials
merge_request: 40176
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Fix RegExp for dotenv report artifact
merge_request: 38562
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add Summary tab for incident issues
merge_request: 39822
author:
type: added

View File

@ -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

View File

@ -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

View 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

View File

@ -0,0 +1 @@
3d49c22b718c5b4af0a7372584fe12ab730e1ffca501c7f582f7d01200708eb1

View File

@ -0,0 +1 @@
d92cdef33a892fdd1761d9491bc8e4c782e9db348d4a6848a1470e99e644fbfd

View File

@ -9266,6 +9266,7 @@ CREATE TABLE public.application_settings (
wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL, wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL,
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL, elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
enforce_namespace_storage_limit boolean DEFAULT false 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_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)), CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 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 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 ( CREATE TABLE public.audit_events (
id integer NOT NULL, id integer NOT NULL,
author_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.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.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); 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 ALTER TABLE ONLY public.ar_internal_metadata
ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); 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 ALTER TABLE ONLY public.audit_events_part_5fc467ac26
ADD CONSTRAINT audit_events_part_5fc467ac26_pkey PRIMARY KEY (id, created_at); 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 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_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); 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 ALTER TABLE ONLY public.design_management_designs
ADD CONSTRAINT fk_rails_bfe283ec3c FOREIGN KEY (issue_id) REFERENCES public.issues(id) ON DELETE CASCADE; 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 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; ADD CONSTRAINT fk_rails_c09009dee1 FOREIGN KEY (pages_domain_id) REFERENCES public.pages_domains(id) ON DELETE CASCADE;

View File

@ -9520,6 +9520,71 @@ type MergeRequestSetWipPayload {
mergeRequest: MergeRequest 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 State of a GitLab merge request
""" """
@ -11741,6 +11806,11 @@ type Project {
""" """
milestoneTitle: String 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. 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 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. 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 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. Array of source branch names. All resolved merge requests will have one of these branches as their source.
""" """

View File

@ -26639,6 +26639,89 @@
"enumValues": null, "enumValues": null,
"possibleTypes": 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", "kind": "ENUM",
"name": "MergeRequestState", "name": "MergeRequestState",
@ -34771,6 +34854,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{ {
"name": "assigneeUsername", "name": "assigneeUsername",
"description": "Username of the assignee", "description": "Username of the assignee",
@ -49461,6 +49554,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{ {
"name": "projectPath", "name": "projectPath",
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.", "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
@ -49646,6 +49749,16 @@
}, },
"defaultValue": null "defaultValue": null
}, },
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{ {
"name": "projectPath", "name": "projectPath",
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.", "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",

View File

@ -533,6 +533,11 @@ The cleanup policy:
1. Excludes from the list any tags matching the `name_regex_keep` value (tags to preserve). 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. 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 ### Create a cleanup policy
You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI. You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI.

View File

@ -537,6 +537,10 @@ module API
) )
end end
def with_api_params(&block)
yield({ api: true, request: request })
end
protected protected
def project_finder_params_visibility_ce def project_finder_params_visibility_ce

View File

@ -27,6 +27,20 @@ module API
exactly_one_of :files, :content exactly_one_of :files, :content
end 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) def content_for(snippet)
if snippet.empty_repo? if snippet.empty_repo?
env['api.format'] = :txt env['api.format'] = :txt
@ -53,10 +67,30 @@ module API
end end
end end
def process_file_args(args) def process_create_params(args)
args[:snippet_actions] = args.delete(:files)&.map do |file| with_api_params do |api_params|
file[:action] = :create args[:snippet_actions] = args.delete(:files)&.map do |file|
file.symbolize_keys 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
end end

View File

@ -64,12 +64,8 @@ module API
end end
post ":id/snippets" do post ":id/snippets" do
authorize! :create_snippet, user_project 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) snippet_params = process_create_params(declared_params(include_missing: false))
end
service_response = ::Snippets::CreateService.new(user_project, current_user, snippet_params).execute service_response = ::Snippets::CreateService.new(user_project, current_user, snippet_params).execute
snippet = service_response.payload[:snippet] snippet = service_response.payload[:snippet]

View File

@ -76,12 +76,7 @@ module API
post do post do
authorize! :create_snippet authorize! :create_snippet
attrs = declared_params(include_missing: false).tap do |create_args| attrs = process_create_params(declared_params(include_missing: false))
create_args[:request] = request
create_args[:api] = true
process_file_args(create_args)
end
service_response = ::Snippets::CreateService.new(nil, current_user, attrs).execute service_response = ::Snippets::CreateService.new(nil, current_user, attrs).execute
snippet = service_response.payload[:snippet] snippet = service_response.payload[:snippet]
@ -99,16 +94,20 @@ module API
detail 'This feature was introduced in GitLab 8.15.' detail 'This feature was introduced in GitLab 8.15.'
success Entities::PersonalSnippet success Entities::PersonalSnippet
end end
params do params do
requires :id, type: Integer, desc: 'The ID of a snippet' 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 :content, type: String, allow_blank: false, desc: 'The content of a snippet'
optional :description, type: String, desc: 'The description 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, optional :visibility, type: String,
values: Gitlab::VisibilityLevel.string_values, values: Gitlab::VisibilityLevel.string_values,
desc: 'The visibility of the snippet' 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 end
put ':id' do put ':id' do
snippet = snippets_for_current_user.find_by_id(params.delete(:id)) snippet = snippets_for_current_user.find_by_id(params.delete(:id))
@ -116,8 +115,12 @@ module API
authorize! :update_snippet, snippet 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) service_response = ::Snippets::UpdateService.new(nil, current_user, attrs).execute(snippet)
snippet = service_response.payload[:snippet] snippet = service_response.payload[:snippet]
if service_response.success? if service_response.success?

View File

@ -21,6 +21,17 @@ module ContainerRegistry
# Taken from: FaradayMiddleware::FollowRedirects # Taken from: FaradayMiddleware::FollowRedirects
REDIRECT_CODES = Set.new [301, 302, 303, 307] 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 = {}) def initialize(base_uri, options = {})
@base_uri = base_uri @base_uri = base_uri
@options = options @options = options

View File

@ -29,7 +29,7 @@ module Gitlab
def table_condition(order_info, value, operator) def table_condition(order_info, value, operator)
if order_info.named_function if order_info.named_function
target = 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 else
target = arel_table[order_info.attribute_name] target = arel_table[order_info.attribute_name]
end end

View File

@ -71,7 +71,22 @@ module Gitlab
def extract_nulls_last_order(order_value) def extract_nulls_last_order(order_value)
tokens = order_value.downcase.split 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 end
def extract_attribute_values(order_value) def extract_attribute_values(order_value)

View File

@ -2834,6 +2834,9 @@ msgstr ""
msgid "An error occurred while retrieving diff files" msgid "An error occurred while retrieving diff files"
msgstr "" msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving LDAP override status. Please try again." msgid "An error occurred while saving LDAP override status. Please try again."
msgstr "" msgstr ""
@ -5006,6 +5009,9 @@ msgstr ""
msgid "Cleanup policy for tags" msgid "Cleanup policy for tags"
msgstr "" msgstr ""
msgid "Cleanup policy maximum processing time (seconds)"
msgstr ""
msgid "Clear" msgid "Clear"
msgstr "" msgstr ""
@ -8610,6 +8616,9 @@ msgstr ""
msgid "Diff limits" msgid "Diff limits"
msgstr "" msgstr ""
msgid "Diff view settings"
msgstr ""
msgid "Difference between start date and now" msgid "Difference between start date and now"
msgstr "" msgstr ""
@ -24097,6 +24106,9 @@ msgstr ""
msgid "Tags" msgid "Tags"
msgstr "" 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" msgid "Tags feed"
msgstr "" msgstr ""
@ -25968,6 +25980,9 @@ msgstr ""
msgid "To unsubscribe from this issue, please paste the following link into your browser:" msgid "To unsubscribe from this issue, please paste the following link into your browser:"
msgstr "" 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" msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file"
msgstr "" msgstr ""

View File

@ -12,19 +12,21 @@ module QA
# implementation so that it's not included. # implementation so that it's not included.
end 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 # 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 # But modified to include full details of multiple exceptions and to provide output similar to
@output_hash[:examples] = notification.examples.map do |example| # https://github.com/sj26/rspec_junit_formatter
format_example(example).tap do |hash| @output_hash[:examples] = example_notification.notifications.map do |notification|
e = example.exception format_example(notification.example).tap do |hash|
e = notification.example.exception
if e if e
exceptions = e.respond_to?(:all_exceptions) ? e.all_exceptions : [e] exceptions = e.respond_to?(:all_exceptions) ? e.all_exceptions : [e]
hash[:exceptions] = exceptions.map do |exception| hash[:exceptions] = exceptions.map do |exception|
{ {
class: exception.class.name, class: exception.class.name,
message: exception.message, message: exception.message,
backtrace: exception.backtrace message_lines: strip_ansi_codes(notification.message_lines),
backtrace: notification.formatted_backtrace
} }
end end
end end
@ -60,6 +62,12 @@ module QA
metadata[:shared_group_inclusion_backtrace].last.formatted_inclusion_location.split(':') metadata[:shared_group_inclusion_backtrace].last.formatted_inclusion_location.split(':')
end end
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 end
end end

View 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

View File

@ -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(current_settings.auto_devops_domain).to eq('domain.com')
expect(page).to have_content "Application settings saved successfully" expect(page).to have_content "Application settings saved successfully"
end 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 end
context 'Repository page' do context 'Repository page' do

View 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

View File

@ -7,7 +7,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constant
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
describe('Diff settiings dropdown component', () => { describe('Diff settings dropdown component', () => {
let vm; let vm;
let actions; let actions;
@ -61,50 +61,50 @@ describe('Diff settiings dropdown component', () => {
expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined); 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 => { createComponent(store => {
Object.assign(store.state.diffs, { Object.assign(store.state.diffs, {
renderTreeList: false, renderTreeList: false,
}); });
}); });
expect(vm.find('.js-list-view').classes('active')).toBe(true); expect(vm.find('.js-list-view').classes('selected')).toBe(true);
expect(vm.find('.js-tree-view').classes('active')).toBe(false); 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 => { createComponent(store => {
Object.assign(store.state.diffs, { Object.assign(store.state.diffs, {
renderTreeList: true, renderTreeList: true,
}); });
}); });
expect(vm.find('.js-list-view').classes('active')).toBe(false); expect(vm.find('.js-list-view').classes('selected')).toBe(false);
expect(vm.find('.js-tree-view').classes('active')).toBe(true); expect(vm.find('.js-tree-view').classes('selected')).toBe(true);
}); });
}); });
describe('compare changes', () => { describe('compare changes', () => {
it('sets inline button as active', () => { it('sets inline button as selected', () => {
createComponent(store => { createComponent(store => {
Object.assign(store.state.diffs, { Object.assign(store.state.diffs, {
diffViewType: INLINE_DIFF_VIEW_TYPE, diffViewType: INLINE_DIFF_VIEW_TYPE,
}); });
}); });
expect(vm.find('.js-inline-diff-button').classes('active')).toBe(true); expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true);
expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(false); 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 => { createComponent(store => {
Object.assign(store.state.diffs, { Object.assign(store.state.diffs, {
diffViewType: PARALLEL_DIFF_VIEW_TYPE, diffViewType: PARALLEL_DIFF_VIEW_TYPE,
}); });
}); });
expect(vm.find('.js-inline-diff-button').classes('active')).toBe(false); expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false);
expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(true); expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true);
}); });
it('calls setInlineDiffViewType when clicking inline button', () => { it('calls setInlineDiffViewType when clicking inline button', () => {

View File

@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue'; import IssuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub'; import eventHub from '~/issue_show/event_hub';
import { initialRequest, secondRequest } from '../mock_data'; 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) { function formatText(text) {
return text.trim().replace(/\s\s+/g, ' '); 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 zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
const publishedIncidentUrl = 'https://status.com/'; 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', () => { describe('Issuable output', () => {
useMockIntersectionObserver(); useMockIntersectionObserver();
@ -31,6 +55,12 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const mountComponent = (props = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...defaultProps, ...props },
});
};
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div> <div>
@ -57,28 +87,7 @@ describe('Issuable output', () => {
return res; return res;
}); });
wrapper = mount(IssuableApp, { mountComponent();
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,
},
});
}); });
afterEach(() => { 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);
});
});
});
}); });

View File

@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import Description from '~/issue_show/components/description.vue'; import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list'; import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data';
jest.mock('~/task_list'); jest.mock('~/task_list');
describe('Description component', () => { describe('Description component', () => {
let vm; let vm;
let DescriptionComponent; let DescriptionComponent;
const props = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
updatedAt: new Date().toString(),
taskStatus: '',
updateUrl: TEST_HOST,
};
beforeEach(() => { beforeEach(() => {
DescriptionComponent = Vue.extend(Description); DescriptionComponent = Vue.extend(Description);

View 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);
});
});
});

View File

@ -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: '&lt;img src=x onerror=alert(1)&gt;',
});
document.body.appendChild(d);
const alertSpy = jest.spyOn(window, 'alert');
initIssueableApp();
expect(alertSpy).not.toHaveBeenCalled();
});
});
});

View 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: '&lt;img src=x onerror=alert(1)&gt;',
});
document.body.appendChild(d);
const alertSpy = jest.spyOn(window, 'alert');
const issuableData = parseIssuableData();
initIssuableApp(issuableData);
expect(alertSpy).not.toHaveBeenCalled();
});
});
});

View File

@ -1,3 +1,5 @@
import { TEST_HOST } from 'helpers/test_constants';
export const initialRequest = { export const initialRequest = {
title: '<p>this is a title</p>', title: '<p>this is a title</p>',
title_text: 'this is a title', title_text: 'this is a title',
@ -21,3 +23,11 @@ export const secondRequest = {
updated_by_path: '/other_user', updated_by_path: '/other_user',
lock_version: 2, lock_version: 2,
}; };
export const descriptionProps = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
taskStatus: '',
updateUrl: TEST_HOST,
};

View File

@ -206,6 +206,33 @@ RSpec.describe Resolvers::MergeRequestsResolver do
expect(result.compact).to contain_exactly(merge_request_4) expect(result.compact).to contain_exactly(merge_request_4)
end end
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 end
def resolve_mr_single(project, iid) def resolve_mr_single(project, iid)

View 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

View File

@ -75,7 +75,8 @@ RSpec.describe GitlabSchema.types['Project'] do
:merged_before, :merged_before,
:author_username, :author_username,
:assignee_username, :assignee_username,
:milestone_title :milestone_title,
:sort
) )
end end
end end

View 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

View File

@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do
initialTitleText: issue.title, initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>', initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text', 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)) expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end end

View File

@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do
end end
end 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 end

View File

@ -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('three').for(:push_event_activities_limit) }
it { is_expected.not_to allow_value(nil).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(: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_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) } it { is_expected.to validate_presence_of(:max_artifacts_size) }

View 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

View File

@ -61,6 +61,24 @@ RSpec.describe MergeRequest, factory_default: :keep do
end end
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 describe '#squash_in_progress?' do
let(:repo_path) do let(:repo_path) do
Gitlab::GitalyClient::StorageSettings.allow_disk_access do Gitlab::GitalyClient::StorageSettings.allow_disk_access do
@ -431,6 +449,23 @@ RSpec.describe MergeRequest, factory_default: :keep do
end end
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 describe '#target_branch_sha' do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }

View File

@ -171,6 +171,16 @@ RSpec.describe Service do
it { is_expected.to be_falsey } it { is_expected.to be_falsey }
end end
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 end
describe '#test' do describe '#test' do

View File

@ -787,4 +787,26 @@ RSpec.describe Snippet do
end end
end 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 end

View File

@ -68,6 +68,7 @@ RSpec.describe User do
it { is_expected.to have_one(:namespace) } it { is_expected.to have_one(:namespace) }
it { is_expected.to have_one(:status) } it { is_expected.to have_one(:status) }
it { is_expected.to have_one(:user_detail) } 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_one(:user_highest_role) }
it { is_expected.to have_many(:snippets).dependent(:destroy) } it { is_expected.to have_many(:snippets).dependent(:destroy) }
it { is_expected.to have_many(:members) } it { is_expected.to have_many(:members) }

View File

@ -12,11 +12,11 @@ RSpec.describe "uploading designs" do
let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] } let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] }
let(:variables) { {} } let(:variables) { {} }
let(:mutation) do def mutation
input = { input = {
project_path: project.full_path, project_path: project.full_path,
iid: issue.iid, iid: issue.iid,
files: files files: files.dup
}.merge(variables) }.merge(variables)
graphql_mutation(:design_management_upload, input) graphql_mutation(:design_management_upload, input)
end end
@ -30,31 +30,15 @@ RSpec.describe "uploading designs" do
end end
it "returns an error if the user is not allowed to upload designs" do 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 expect(graphql_errors).to be_present
end end
it "succeeds (backward compatibility)" do it "succeeds, and responds with the created designs" do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation_with_uploads(mutation, current_user: current_user)
expect(graphql_errors).not_to be_present 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( expect(mutation_response).to include(
"designs" => a_collection_containing_exactly( "designs" => a_collection_containing_exactly(
@ -65,7 +49,7 @@ RSpec.describe "uploading designs" do
it "can respond with skipped designs" do it "can respond with skipped designs" do
2.times 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) files.each(&:rewind)
end end
@ -80,7 +64,7 @@ RSpec.describe "uploading designs" do
let(:variables) { { iid: "123" } } let(:variables) { { iid: "123" } }
it "returns an error" do 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 expect(graphql_errors).not_to be_empty
end end
@ -92,7 +76,7 @@ RSpec.describe "uploading designs" do
expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" }) expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" })
end 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") expect(mutation_response["errors"].first).to eq("Something went wrong")
end end
end end

View File

@ -210,4 +210,48 @@ RSpec.describe 'getting merge request listings nested in a project' do
include_examples 'N+1 query check' include_examples 'N+1 query check'
end end
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 end

View File

@ -391,21 +391,98 @@ RSpec.describe API::Snippets do
create(:personal_snippet, :repository, author: user, visibility_level: visibility_level) create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
end end
shared_examples 'snippet updates' do let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } }
it 'updates a snippet' do let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } }
new_content = 'New content' let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } }
new_description = 'New description' 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' } }
update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' }) context 'with snippet file changes' do
using RSpec::Parameterized::TableSyntax
expect(response).to have_gitlab_http_status(:ok) where(:is_multi_file, :file_name, :content, :files, :status) do
snippet.reload true | nil | nil | [create_action] | :success
expect(snippet.content).to eq(new_content) true | nil | nil | [update_action] | :success
expect(snippet.description).to eq(new_description) true | nil | nil | [move_action] | :success
expect(snippet.visibility).to eq('internal') 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
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: { title: new_title, description: new_description, visibility: new_visibility })
snippet.reload
aggregate_failures do
expect(response).to have_gitlab_http_status(:ok)
expect(snippet.description).to eq(new_description)
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 context 'with restricted visibility settings' do
before do before do
stub_application_setting(restricted_visibility_levels: stub_application_setting(restricted_visibility_levels:
@ -413,11 +490,9 @@ RSpec.describe API::Snippets do
Gitlab::VisibilityLevel::PRIVATE]) Gitlab::VisibilityLevel::PRIVATE])
end end
it_behaves_like 'snippet updates' it_behaves_like 'snippet non-file updates'
end end
it_behaves_like 'snippet updates'
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
update_snippet(snippet_id: non_existing_record_id, params: { title: 'Foo' }) 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) expect(response).to have_gitlab_http_status(:bad_request)
end 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 it 'returns 400 if title is blank' do
update_snippet(params: { title: '' }) update_snippet(params: { title: '' })

View File

@ -66,12 +66,13 @@ RSpec.describe Ci::ParseDotenvArtifactService do
end end
context 'when multiple key/value pairs exist in one line' do 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 it 'parses the dotenv data' do
expect(subject[:status]).to eq(:error) subject
expect(subject[:message]).to eq("Validation failed: Key can contain only letters, digits and '_'.")
expect(subject[:http_status]).to eq(:bad_request) expect(build.job_variables.as_json).to contain_exactly(
hash_including('key' => 'KEY', 'value' => 'VARCONTAINING=EQLS'))
end end
end end

View File

@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
subject { service.execute(repository) } subject { service.execute(repository) }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
context 'without permissions' do context 'without permissions' do
it { is_expected.to include(status: :error) } it { is_expected.to include(status: :error) }
end end
@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it_behaves_like 'logging a success response' it_behaves_like 'logging a success response'
end 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 end
context 'and the feature is disabled' do context 'and the feature is disabled' do

View File

@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
subject { service.execute } 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 it 'deletes the tags by name' do
stub_delete_reference_requests(tags) stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags) expect_delete_tag_by_names(tags)
is_expected.to eq(status: :success, deleted: tags) is_expected.to eq(status: :success, deleted: tags)
end end
end
context 'with tags to delete' do
it_behaves_like 'deleting tags'
it 'succeeds when tag delete returns 404' do it 'succeeds when tag delete returns 404' do
stub_delete_reference_requests('A' => 200, 'Ba' => 404) 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') } it { is_expected.to eq(status: :error, message: 'could not delete tags') }
end end
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 end
context 'with empty tags' do context 'with empty tags' do

View File

@ -241,6 +241,39 @@ module GraphqlHelpers
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables) post_graphql(mutation.query, current_user: current_user, variables: mutation.variables)
end 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 # this implements GraphQL multipart request v2
# https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2 # https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2
# this is simplified and do not support file deduplication # this is simplified and do not support file deduplication

View File

@ -2,6 +2,10 @@
RSpec.shared_examples 'update with repository actions' do RSpec.shared_examples 'update with repository actions' do
context 'when the repository exists' 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 it 'commits the changes to the repository' do
existing_blob = snippet.blobs.first existing_blob = snippet.blobs.first
new_file_name = existing_blob.path + '_new' new_file_name = existing_blob.path + '_new'