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

View file

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

View file

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

View file

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

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>
<template>
<div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
<div class="gl-display-flex gl-justify-content-start">
<template v-for="(link, i) in pinnedLinks">
<div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
<gl-button

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@
module MergedAtFilter
private
# rubocop: disable CodeReuse/ActiveRecord
def by_merged_at(items)
return items unless merged_after || merged_before
@ -11,11 +10,8 @@ module MergedAtFilter
mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present?
mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present?
scope = items.joins(:metrics).merge(mr_metrics_scope)
scope = target_project_id_filter_on_metrics(scope) if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true)
scope
items.join_metrics.merge(mr_metrics_scope)
end
# rubocop: enable CodeReuse/ActiveRecord
def merged_after
params[:merged_after]
@ -24,10 +20,4 @@ module MergedAtFilter
def merged_before
params[:merged_before]
end
# rubocop: disable CodeReuse/ActiveRecord
def target_project_id_filter_on_metrics(scope)
scope.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
end
# rubocop: enable CodeReuse/ActiveRecord
end

View file

@ -37,6 +37,10 @@ module Resolvers
argument :milestone_title, GraphQL::STRING_TYPE,
required: false,
description: 'Title of the milestone'
argument :sort, Types::MergeRequestSortEnum,
description: 'Sort merge requests by this criteria',
required: false,
default_value: 'created_desc'
def self.single
::Resolvers::MergeRequestResolver

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_export_limit,
:group_download_export_limit,
:wiki_page_max_content_bytes
:wiki_page_max_content_bytes,
:container_registry_delete_tags_service_timeout
]
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,
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
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)
{
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path
}
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 :container_registry_delete_tags_service_timeout,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end

View file

@ -163,7 +163,8 @@ module ApplicationSettingImplementation
user_default_external: false,
user_default_internal_regex: nil,
user_show_add_ssh_key_message: true,
wiki_page_max_content_bytes: 50.megabytes
wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 100
}
end

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 })
end
scope :join_project, -> { joins(:target_project) }
scope :join_metrics, -> do
query = joins(:metrics)
if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true)
query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id]))
end
query
end
scope :references_project, -> { references(:target_project) }
scope :with_api_entity_associations, -> {
preload_routables
@ -264,6 +273,14 @@ class MergeRequest < ApplicationRecord
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
end
scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) }
scope :order_merged_at, ->(direction) do
query = join_metrics.order(Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction))
# Add `merge_request_metrics.merged_at` to the `SELECT` in order to make the keyset pagination work.
query.select(*query.arel.projections, MergeRequest::Metrics.arel_table[:merged_at].as('"merge_request_metrics.merged_at"'))
end
scope :order_merged_at_asc, -> { order_merged_at('ASC') }
scope :order_merged_at_desc, -> { order_merged_at('DESC') }
scope :preload_source_project, -> { preload(:source_project) }
scope :preload_target_project, -> { preload(:target_project) }
scope :preload_routables, -> do
@ -320,6 +337,15 @@ class MergeRequest < ApplicationRecord
.pluck(:target_branch)
end
def self.sort_by_attribute(method, excluded_labels: [])
case method.to_s
when 'merged_at', 'merged_at_asc' then order_merged_at_asc.with_order_id_desc
when 'merged_at_desc' then order_merged_at_desc.with_order_id_desc
else
super
end
end
def rebase_in_progress?
rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)
end

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,11 @@ module Projects
module Gitlab
class DeleteTagsService
include BaseServiceUtility
include ::Gitlab::Utils::StrongMemoize
DISABLED_TIMEOUTS = [nil, 0].freeze
TimeoutError = Class.new(StandardError)
def initialize(container_repository, tag_names)
@container_repository = container_repository
@ -17,12 +22,42 @@ module Projects
def execute
return success(deleted: []) if @tag_names.empty?
delete_tags
rescue TimeoutError => e
::Gitlab::ErrorTracking.track_exception(e, tags_count: @tag_names&.size, container_repository_id: @container_repository&.id)
error('timeout while deleting tags')
end
private
def delete_tags
start_time = Time.zone.now
deleted_tags = @tag_names.select do |name|
raise TimeoutError if timeout?(start_time)
@container_repository.delete_tag_by_name(name)
end
deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags')
end
def timeout?(start_time)
return false unless throttling_enabled?
return false if service_timeout.in?(DISABLED_TIMEOUTS)
(Time.zone.now - start_time) > service_timeout
end
def throttling_enabled?
strong_memoize(:feature_flag) do
Feature.enabled?(:container_registry_expiration_policies_throttling)
end
end
def service_timeout
::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout
end
end
end
end

View file

@ -15,7 +15,7 @@ module Projects
# This is a hack as the registry doesn't support deleting individual
# tags. This code effectively pushes a dummy image and assigns the tag to it.
# This way when the tag is deleted only the dummy image is affected.
# This is used to preverse compatibility with third-party registries that
# This is used to preserve compatibility with third-party registries that
# don't support fast delete.
# See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion
def execute

View file

@ -14,5 +14,11 @@
.form-text.text-muted
= _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.")
= link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries')
- if limit_delete_tags_service?
.form-group
= f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold'
= f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control'
.form-text.text-muted
= _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.")
= f.submit 'Save changes', class: "btn btn-success"

View file

@ -1,5 +1,5 @@
- @gfm_form = true
- @content_class = "limit-container-width" unless fluid_layout
- @content_class = "merge-request-container#{' limit-container-width' unless fluid_layout}"
- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
- breadcrumb_title @merge_request.to_reference
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests")

View file

@ -21,7 +21,7 @@
.col-sm-12
= f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title')
%span.d-inline-block.mw-100.gl-mt-2
= icon('lightbulb-o')
= sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1')
- if @page.persisted?
= s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.")
= link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'),

View file

@ -17,13 +17,13 @@
= sprite_icon('pencil')
- elsif current_user
- if @user.abuse_report
%button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }
= icon('exclamation-circle')
%button{ class: link_classes + 'btn btn-danger', title: s_('UserProfile|Already reported for abuse'),
data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }>
= sprite_icon('error')
- else
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('exclamation-circle')
= sprite_icon('error')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do
= sprite_icon('rss', css_class: 'qa-rss-icon')

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,
elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL,
enforce_namespace_storage_limit boolean DEFAULT false NOT NULL,
container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL,
CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)),
CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)),
CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)),
@ -9479,6 +9480,32 @@ CREATE TABLE public.ar_internal_metadata (
updated_at timestamp(6) without time zone NOT NULL
);
CREATE TABLE public.atlassian_identities (
user_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone,
extern_uid text NOT NULL,
encrypted_token bytea,
encrypted_token_iv bytea,
encrypted_refresh_token bytea,
encrypted_refresh_token_iv bytea,
CONSTRAINT atlassian_identities_refresh_token_iv_length_constraint CHECK ((octet_length(encrypted_refresh_token_iv) <= 12)),
CONSTRAINT atlassian_identities_refresh_token_length_constraint CHECK ((octet_length(encrypted_refresh_token) <= 512)),
CONSTRAINT atlassian_identities_token_iv_length_constraint CHECK ((octet_length(encrypted_token_iv) <= 12)),
CONSTRAINT atlassian_identities_token_length_constraint CHECK ((octet_length(encrypted_token) <= 2048)),
CONSTRAINT check_32f5779763 CHECK ((char_length(extern_uid) <= 255))
);
CREATE SEQUENCE public.atlassian_identities_user_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.atlassian_identities_user_id_seq OWNED BY public.atlassian_identities.user_id;
CREATE TABLE public.audit_events (
id integer NOT NULL,
author_id integer NOT NULL,
@ -16880,6 +16907,8 @@ ALTER TABLE ONLY public.approver_groups ALTER COLUMN id SET DEFAULT nextval('pub
ALTER TABLE ONLY public.approvers ALTER COLUMN id SET DEFAULT nextval('public.approvers_id_seq'::regclass);
ALTER TABLE ONLY public.atlassian_identities ALTER COLUMN user_id SET DEFAULT nextval('public.atlassian_identities_user_id_seq'::regclass);
ALTER TABLE ONLY public.audit_events ALTER COLUMN id SET DEFAULT nextval('public.audit_events_id_seq'::regclass);
ALTER TABLE ONLY public.award_emoji ALTER COLUMN id SET DEFAULT nextval('public.award_emoji_id_seq'::regclass);
@ -17800,6 +17829,9 @@ ALTER TABLE ONLY public.approvers
ALTER TABLE ONLY public.ar_internal_metadata
ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key);
ALTER TABLE ONLY public.atlassian_identities
ADD CONSTRAINT atlassian_identities_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY public.audit_events_part_5fc467ac26
ADD CONSTRAINT audit_events_part_5fc467ac26_pkey PRIMARY KEY (id, created_at);
@ -19237,6 +19269,8 @@ CREATE INDEX index_approvers_on_target_id_and_target_type ON public.approvers US
CREATE INDEX index_approvers_on_user_id ON public.approvers USING btree (user_id);
CREATE UNIQUE INDEX index_atlassian_identities_on_extern_uid ON public.atlassian_identities USING btree (extern_uid);
CREATE INDEX index_audit_events_on_entity_id_entity_type_id_desc_author_id ON public.audit_events USING btree (entity_id, entity_type, id DESC, author_id);
CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON public.award_emoji USING btree (awardable_type, awardable_id);
@ -23097,6 +23131,9 @@ ALTER TABLE ONLY public.resource_weight_events
ALTER TABLE ONLY public.design_management_designs
ADD CONSTRAINT fk_rails_bfe283ec3c FOREIGN KEY (issue_id) REFERENCES public.issues(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.atlassian_identities
ADD CONSTRAINT fk_rails_c02928bc18 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.serverless_domain_cluster
ADD CONSTRAINT fk_rails_c09009dee1 FOREIGN KEY (pages_domain_id) REFERENCES public.pages_domains(id) ON DELETE CASCADE;

View file

@ -9520,6 +9520,71 @@ type MergeRequestSetWipPayload {
mergeRequest: MergeRequest
}
"""
Values for sorting merge requests
"""
enum MergeRequestSort {
"""
Label priority by ascending order
"""
LABEL_PRIORITY_ASC
"""
Label priority by descending order
"""
LABEL_PRIORITY_DESC
"""
Merge time by ascending order
"""
MERGED_AT_ASC
"""
Merge time by descending order
"""
MERGED_AT_DESC
"""
Milestone due date by ascending order
"""
MILESTONE_DUE_ASC
"""
Milestone due date by descending order
"""
MILESTONE_DUE_DESC
"""
Priority by ascending order
"""
PRIORITY_ASC
"""
Priority by descending order
"""
PRIORITY_DESC
"""
Created at ascending order
"""
created_asc
"""
Created at descending order
"""
created_desc
"""
Updated at ascending order
"""
updated_asc
"""
Updated at descending order
"""
updated_desc
}
"""
State of a GitLab merge request
"""
@ -11741,6 +11806,11 @@ type Project {
"""
milestoneTitle: String
"""
Sort merge requests by this criteria
"""
sort: MergeRequestSort = created_desc
"""
Array of source branch names. All resolved merge requests will have one of these branches as their source.
"""
@ -16808,6 +16878,11 @@ type User {
"""
projectPath: String
"""
Sort merge requests by this criteria
"""
sort: MergeRequestSort = created_desc
"""
Array of source branch names. All resolved merge requests will have one of these branches as their source.
"""
@ -16883,6 +16958,11 @@ type User {
"""
projectPath: String
"""
Sort merge requests by this criteria
"""
sort: MergeRequestSort = created_desc
"""
Array of source branch names. All resolved merge requests will have one of these branches as their source.
"""

View file

@ -26639,6 +26639,89 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "MergeRequestSort",
"description": "Values for sorting merge requests",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "updated_desc",
"description": "Updated at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updated_asc",
"description": "Updated at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "created_desc",
"description": "Created at descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "created_asc",
"description": "Created at ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "PRIORITY_ASC",
"description": "Priority by ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "PRIORITY_DESC",
"description": "Priority by descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LABEL_PRIORITY_ASC",
"description": "Label priority by ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LABEL_PRIORITY_DESC",
"description": "Label priority by descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MILESTONE_DUE_ASC",
"description": "Milestone due date by ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MILESTONE_DUE_DESC",
"description": "Milestone due date by descending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MERGED_AT_ASC",
"description": "Merge time by ascending order",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "MERGED_AT_DESC",
"description": "Merge time by descending order",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "MergeRequestState",
@ -34771,6 +34854,16 @@
},
"defaultValue": null
},
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{
"name": "assigneeUsername",
"description": "Username of the assignee",
@ -49461,6 +49554,16 @@
},
"defaultValue": null
},
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{
"name": "projectPath",
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",
@ -49646,6 +49749,16 @@
},
"defaultValue": null
},
{
"name": "sort",
"description": "Sort merge requests by this criteria",
"type": {
"kind": "ENUM",
"name": "MergeRequestSort",
"ofType": null
},
"defaultValue": "created_desc"
},
{
"name": "projectPath",
"description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.",

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. Finally, the remaining tags in the list are deleted from the Container Registry.
CAUTION: **Warning:**
On GitLab.com, the execution time for the cleanup policy is limited, and some of the tags may remain in
the Container Registry after the policy runs. The next time the policy runs, the remaining tags are included,
so it may take multiple runs for all tags to be deleted.
### Create a cleanup policy
You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI.

View file

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

View file

@ -27,6 +27,20 @@ module API
exactly_one_of :files, :content
end
params :update_file_params do |options|
optional :files, type: Array, desc: 'An array of files to update' do
requires :action, type: String,
values: SnippetInputAction::ACTIONS.map(&:to_s),
desc: "The type of action to perform on the file, must be one of: #{SnippetInputAction::ACTIONS.join(", ")}"
optional :content, type: String, desc: 'The content of a snippet'
optional :file_path, file_path: true, type: String, desc: 'The file path of a snippet file'
optional :previous_path, file_path: true, type: String, desc: 'The previous path of a snippet file'
end
mutually_exclusive :files, :content
mutually_exclusive :files, :file_name
end
def content_for(snippet)
if snippet.empty_repo?
env['api.format'] = :txt
@ -53,10 +67,30 @@ module API
end
end
def process_file_args(args)
args[:snippet_actions] = args.delete(:files)&.map do |file|
file[:action] = :create
file.symbolize_keys
def process_create_params(args)
with_api_params do |api_params|
args[:snippet_actions] = args.delete(:files)&.map do |file|
file[:action] = :create
file.symbolize_keys
end
args.merge(api_params)
end
end
def process_update_params(args)
with_api_params do |api_params|
args[:snippet_actions] = args.delete(:files)&.map(&:symbolize_keys)
args.merge(api_params)
end
end
def validate_params_for_multiple_files(snippet)
return unless params[:content] || params[:file_name]
if Feature.enabled?(:snippet_multiple_files, current_user) && snippet.multiple_files?
render_api_error!({ error: _('To update Snippets with multiple files, you must use the `files` parameter') }, 400)
end
end
end

View file

@ -64,12 +64,8 @@ module API
end
post ":id/snippets" do
authorize! :create_snippet, user_project
snippet_params = declared_params(include_missing: false).tap do |create_args|
create_args[:request] = request
create_args[:api] = true
process_file_args(create_args)
end
snippet_params = process_create_params(declared_params(include_missing: false))
service_response = ::Snippets::CreateService.new(user_project, current_user, snippet_params).execute
snippet = service_response.payload[:snippet]

View file

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

View file

@ -21,6 +21,17 @@ module ContainerRegistry
# Taken from: FaradayMiddleware::FollowRedirects
REDIRECT_CODES = Set.new [301, 302, 303, 307]
def self.supports_tag_delete?
registry_config = Gitlab.config.registry
return false unless registry_config.enabled && registry_config.api_url.present?
return true if ::Gitlab.com?
token = Auth::ContainerRegistryAuthenticationService.access_token([], [])
client = new(registry_config.api_url, token: token)
client.supports_tag_delete?
end
def initialize(base_uri, options = {})
@base_uri = base_uri
@options = options

View file

@ -29,7 +29,7 @@ module Gitlab
def table_condition(order_info, value, operator)
if order_info.named_function
target = order_info.named_function
value = value&.downcase if target&.name&.downcase == 'lower'
value = value&.downcase if target.respond_to?(:name) && target&.name&.downcase == 'lower'
else
target = arel_table[order_info.attribute_name]
end

View file

@ -71,7 +71,22 @@ module Gitlab
def extract_nulls_last_order(order_value)
tokens = order_value.downcase.split
[tokens.first, (tokens[1] == 'asc' ? :asc : :desc), nil]
column_reference = tokens.first
sort_direction = tokens[1] == 'asc' ? :asc : :desc
# Handles the case when the order value is coming from another table.
# Example: table_name.column_name
# Query the value using the fully qualified column name: pass table_name.column_name as the named_function
if fully_qualified_column_reference?(column_reference)
[column_reference, sort_direction, Arel.sql(column_reference)]
else
[column_reference, sort_direction, nil]
end
end
# Example: table_name.column_name
def fully_qualified_column_reference?(attribute)
attribute.to_s.count('.') == 1
end
def extract_attribute_values(order_value)

View file

@ -2834,6 +2834,9 @@ msgstr ""
msgid "An error occurred while retrieving diff files"
msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving LDAP override status. Please try again."
msgstr ""
@ -5006,6 +5009,9 @@ msgstr ""
msgid "Cleanup policy for tags"
msgstr ""
msgid "Cleanup policy maximum processing time (seconds)"
msgstr ""
msgid "Clear"
msgstr ""
@ -8610,6 +8616,9 @@ msgstr ""
msgid "Diff limits"
msgstr ""
msgid "Diff view settings"
msgstr ""
msgid "Difference between start date and now"
msgstr ""
@ -24097,6 +24106,9 @@ msgstr ""
msgid "Tags"
msgstr ""
msgid "Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0."
msgstr ""
msgid "Tags feed"
msgstr ""
@ -25968,6 +25980,9 @@ msgstr ""
msgid "To unsubscribe from this issue, please paste the following link into your browser:"
msgstr ""
msgid "To update Snippets with multiple files, you must use the `files` parameter"
msgstr ""
msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file"
msgstr ""

View file

@ -12,19 +12,21 @@ module QA
# implementation so that it's not included.
end
def stop(notification)
def stop(example_notification)
# Based on https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/formatters/json_formatter.rb#L35
# But modified to include full details of multiple exceptions
@output_hash[:examples] = notification.examples.map do |example|
format_example(example).tap do |hash|
e = example.exception
# But modified to include full details of multiple exceptions and to provide output similar to
# https://github.com/sj26/rspec_junit_formatter
@output_hash[:examples] = example_notification.notifications.map do |notification|
format_example(notification.example).tap do |hash|
e = notification.example.exception
if e
exceptions = e.respond_to?(:all_exceptions) ? e.all_exceptions : [e]
hash[:exceptions] = exceptions.map do |exception|
{
class: exception.class.name,
message: exception.message,
backtrace: exception.backtrace
message_lines: strip_ansi_codes(notification.message_lines),
backtrace: notification.formatted_backtrace
}
end
end
@ -60,6 +62,12 @@ module QA
metadata[:shared_group_inclusion_backtrace].last.formatted_inclusion_location.split(':')
end
end
def strip_ansi_codes(strings)
# The code below is from https://github.com/piotrmurach/pastel/blob/master/lib/pastel/color.rb
modified = Array(strings).map { |string| string.dup.gsub(/\x1b\[{1,2}[0-9;:?]*m/m, '') }
modified.size == 1 ? modified[0] : modified
end
end
end
end

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(page).to have_content "Application settings saved successfully"
end
context 'Container Registry' do
context 'delete tags service execution timeout' do
let(:feature_flag_enabled) { true }
let(:client_support) { true }
before do
stub_container_registry_config(enabled: true)
stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled)
allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support)
end
RSpec.shared_examples 'not having service timeout settings' do
it 'lacks the timeout settings' do
visit ci_cd_admin_application_settings_path
expect(page).not_to have_content "Container Registry delete tags service execution timeout"
end
end
context 'with feature flag enabled' do
context 'with client supporting tag delete' do
it 'changes the timeout' do
visit ci_cd_admin_application_settings_path
page.within('.as-registry') do
fill_in 'application_setting_container_registry_delete_tags_service_timeout', with: 400
click_button 'Save changes'
end
expect(current_settings.container_registry_delete_tags_service_timeout).to eq(400)
expect(page).to have_content "Application settings saved successfully"
end
end
context 'with client not supporting tag delete' do
let(:client_support) { false }
it_behaves_like 'not having service timeout settings'
end
end
context 'with feature flag disabled' do
let(:feature_flag_enabled) { false }
it_behaves_like 'not having service timeout settings'
end
end
end
end
context 'Repository page' do

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

View file

@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
import { initialRequest, secondRequest } from '../mock_data';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
const publishedIncidentUrl = 'https://status.com/';
const defaultProps = {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
};
describe('Issuable output', () => {
useMockIntersectionObserver();
@ -31,6 +55,12 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const mountComponent = (props = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...defaultProps, ...props },
});
};
beforeEach(() => {
setFixtures(`
<div>
@ -57,28 +87,7 @@ describe('Issuable output', () => {
return res;
});
wrapper = mount(IssuableApp, {
propsData: {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
},
});
mountComponent();
});
afterEach(() => {
@ -562,4 +571,46 @@ describe('Issuable output', () => {
});
});
});
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('does not render incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(false);
});
it('adds a border below the header', () => {
expect(findPinnedLinks().attributes('class')).toContain(borderClass);
});
});
describe('when using incident tabs description wrapper', () => {
beforeEach(() => {
mountComponent({
descriptionComponent: IncidentTabs,
showTitleBorder: false,
});
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('renders incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(true);
});
it('does not add a border below the header', () => {
expect(findPinnedLinks().attributes('class')).not.toContain(borderClass);
});
});
});
});

View file

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

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 = {
title: '<p>this is a title</p>',
title_text: 'this is a title',
@ -21,3 +23,11 @@ export const secondRequest = {
updated_by_path: '/other_user',
lock_version: 2,
};
export const descriptionProps = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
taskStatus: '',
updateUrl: TEST_HOST,
};

View file

@ -206,6 +206,33 @@ RSpec.describe Resolvers::MergeRequestsResolver do
expect(result.compact).to contain_exactly(merge_request_4)
end
end
describe 'sorting' do
context 'when sorting by created' do
it 'sorts merge requests ascending' do
expect(resolve_mr(project, sort: 'created_asc')).to eq [merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone]
end
it 'sorts merge requests descending' do
expect(resolve_mr(project, sort: 'created_desc')).to eq [merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_3, merge_request_2, merge_request_1]
end
end
context 'when sorting by merged at' do
before do
merge_request_1.metrics.update!(merged_at: 10.days.ago)
merge_request_3.metrics.update!(merged_at: 5.days.ago)
end
it 'sorts merge requests ascending' do
expect(resolve_mr(project, sort: :merged_at_asc)).to eq [merge_request_1, merge_request_3, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2]
end
it 'sorts merge requests descending' do
expect(resolve_mr(project, sort: :merged_at_desc)).to eq [merge_request_3, merge_request_1, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2]
end
end
end
end
def resolve_mr_single(project, iid)

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,
:author_username,
:assignee_username,
:milestone_title
:milestone_title,
:sort
)
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,
initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed'
initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue'
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
end

View file

@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do
end
end
end
describe '.supports_tag_delete?' do
let(:registry_enabled) { true }
let(:registry_api_url) { 'http://sandbox.local' }
let(:registry_tags_support_enabled) { true }
let(:is_on_dot_com) { false }
subject { described_class.supports_tag_delete? }
before do
allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
stub_registry_tags_support(registry_tags_support_enabled)
end
context 'with the registry enabled' do
it { is_expected.to be true }
context 'without an api url' do
let(:registry_api_url) { '' }
it { is_expected.to be false }
end
context 'on .com' do
let(:is_on_dot_com) { true }
it { is_expected.to be true }
end
context 'when registry server does not support tag deletion' do
let(:registry_tags_support_enabled) { false }
it { is_expected.to be false }
end
end
context 'with the registry disabled' do
let(:registry_enabled) { false }
it { is_expected.to be false }
end
def stub_registry_tags_support(supported = true)
status_code = supported ? 200 : 404
stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
.to_return(
status: status_code,
body: '',
headers: { 'Allow' => 'DELETE' }
)
end
end
end

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(nil).for(:push_event_activities_limit) }
it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) }
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
it { is_expected.to validate_presence_of(:max_artifacts_size) }

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
describe '.order_merged_at_asc' do
let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
it 'returns MRs ordered by merged_at ascending' do
expect(described_class.order_merged_at_asc).to eq([older_mr, newer_mr])
end
end
describe '.order_merged_at_desc' do
let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
it 'returns MRs ordered by merged_at descending' do
expect(described_class.order_merged_at_desc).to eq([newer_mr, older_mr])
end
end
describe '#squash_in_progress?' do
let(:repo_path) do
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
@ -431,6 +449,23 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
describe '.sort_by_attribute' do
context 'merged_at' do
let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) }
let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) }
it 'sorts asc' do
merge_requests = described_class.sort_by_attribute(:merged_at_asc)
expect(merge_requests).to eq([older_mr, newer_mr])
end
it 'sorts desc' do
merge_requests = described_class.sort_by_attribute(:merged_at_desc)
expect(merge_requests).to eq([newer_mr, older_mr])
end
end
end
describe '#target_branch_sha' do
let(:project) { create(:project, :repository) }

View file

@ -171,6 +171,16 @@ RSpec.describe Service do
it { is_expected.to be_falsey }
end
end
context 'when group-level service' do
Service.available_services_types.each do |service_type|
let(:service) do
service_type.constantize.new(group_id: group.id)
end
it { is_expected.to be_falsey }
end
end
end
describe '#test' do

View file

@ -787,4 +787,26 @@ RSpec.describe Snippet do
end
end
end
describe '#multiple_files?' do
subject { snippet.multiple_files? }
context 'when snippet has multiple files' do
let(:snippet) { create(:snippet, :repository) }
it { is_expected.to be_truthy }
end
context 'when snippet does not have multiple files' do
let(:snippet) { create(:snippet, :empty_repo) }
it { is_expected.to be_falsey }
end
context 'when the snippet does not have a repository' do
let(:snippet) { build(:snippet) }
it { is_expected.to be_falsey }
end
end
end

View file

@ -68,6 +68,7 @@ RSpec.describe User do
it { is_expected.to have_one(:namespace) }
it { is_expected.to have_one(:status) }
it { is_expected.to have_one(:user_detail) }
it { is_expected.to have_one(:atlassian_identity) }
it { is_expected.to have_one(:user_highest_role) }
it { is_expected.to have_many(:snippets).dependent(:destroy) }
it { is_expected.to have_many(:members) }

View file

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

View file

@ -210,4 +210,48 @@ RSpec.describe 'getting merge request listings nested in a project' do
include_examples 'N+1 query check'
end
end
describe 'sorting and pagination' do
let(:data_path) { [:project, :mergeRequests] }
def pagination_query(params, page_info)
graphql_query_for(
:project,
{ full_path: project.full_path },
<<~QUERY
mergeRequests(#{params}) {
#{page_info} edges {
node {
id
}
}
}
QUERY
)
end
def pagination_results_data(data)
data.map { |project| project.dig('node', 'id') }
end
context 'when sorting by merged_at DESC' do
it_behaves_like 'sorted paginated query' do
let(:sort_param) { 'MERGED_AT_DESC' }
let(:first_param) { 2 }
let(:expected_results) do
[
merge_request_b,
merge_request_c,
merge_request_d,
merge_request_a
].map(&:to_gid).map(&:to_s)
end
before do
merge_request_c.metrics.update!(merged_at: 5.days.ago)
merge_request_b.metrics.update!(merged_at: 1.day.ago)
end
end
end
end
end

View file

@ -391,21 +391,98 @@ RSpec.describe API::Snippets do
create(:personal_snippet, :repository, author: user, visibility_level: visibility_level)
end
shared_examples 'snippet updates' do
it 'updates a snippet' do
new_content = 'New content'
new_description = 'New description'
let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } }
let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } }
let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } }
let(:delete_action) { { action: 'delete', file_path: 'CONTRIBUTING.md' } }
let(:bad_file_path) { { action: 'create', file_path: '../../etc/passwd', content: 'bar' } }
let(:bad_previous_path) { { action: 'create', previous_path: '../../etc/passwd', file_path: 'CHANGELOG', content: 'bar' } }
let(:invalid_move) { { action: 'move', file_path: 'missing_previous_path.txt' } }
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)
snippet.reload
expect(snippet.content).to eq(new_content)
expect(snippet.description).to eq(new_description)
expect(snippet.visibility).to eq('internal')
where(:is_multi_file, :file_name, :content, :files, :status) do
true | nil | nil | [create_action] | :success
true | nil | nil | [update_action] | :success
true | nil | nil | [move_action] | :success
true | nil | nil | [delete_action] | :success
true | nil | nil | [create_action, update_action] | :success
true | 'foo.txt' | 'bar' | [create_action] | :bad_request
true | 'foo.txt' | 'bar' | nil | :bad_request
true | nil | nil | nil | :bad_request
true | 'foo.txt' | nil | [create_action] | :bad_request
true | nil | 'bar' | [create_action] | :bad_request
true | '' | nil | [create_action] | :bad_request
true | nil | '' | [create_action] | :bad_request
true | nil | nil | [bad_file_path] | :bad_request
true | nil | nil | [bad_previous_path] | :bad_request
true | nil | nil | [invalid_move] | :forbidden
false | 'foo.txt' | 'bar' | nil | :success
false | 'foo.txt' | nil | nil | :success
false | nil | 'bar' | nil | :success
false | 'foo.txt' | 'bar' | [create_action] | :bad_request
false | nil | nil | nil | :bad_request
false | nil | '' | nil | :bad_request
false | nil | nil | [bad_file_path] | :bad_request
false | nil | nil | [bad_previous_path] | :bad_request
end
with_them do
before do
allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(is_multi_file)
end
it 'has the correct response' do
update_params = {}.tap do |params|
params[:files] = files if files
params[:file_name] = file_name if file_name
params[:content] = content if content
end
update_snippet(params: update_params)
expect(response).to have_gitlab_http_status(status)
end
end
context 'when save fails due to a repository commit error' do
before do
allow_next_instance_of(Repository) do |instance|
allow(instance).to receive(:multi_action).and_raise(Gitlab::Git::CommitError)
end
update_snippet(params: { files: [create_action] })
end
it 'returns a bad request response' do
expect(response).to have_gitlab_http_status(:bad_request)
end
end
end
shared_examples 'snippet non-file updates' do
it 'updates a snippet non-file attributes' do
new_description = 'New description'
new_title = 'New title'
new_visibility = 'internal'
update_snippet(params: { 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
before do
stub_application_setting(restricted_visibility_levels:
@ -413,11 +490,9 @@ RSpec.describe API::Snippets do
Gitlab::VisibilityLevel::PRIVATE])
end
it_behaves_like 'snippet updates'
it_behaves_like 'snippet non-file updates'
end
it_behaves_like 'snippet updates'
it 'returns 404 for invalid snippet id' do
update_snippet(snippet_id: non_existing_record_id, params: { title: 'Foo' })
@ -438,13 +513,6 @@ RSpec.describe API::Snippets do
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'returns 400 if content is blank' do
update_snippet(params: { content: '' })
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq 'content is empty'
end
it 'returns 400 if title is blank' do
update_snippet(params: { title: '' })

View file

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

View file

@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
subject { service.execute(repository) }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
context 'without permissions' do
it { is_expected.to include(status: :error) }
end
@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it_behaves_like 'logging a success response'
end
context 'with a timeout error' do
before do
expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service|
expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError)
end
end
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
end
end
context 'and the feature is disabled' do

View file

@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
subject { service.execute }
context 'with tags to delete' do
before do
stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
RSpec.shared_examples 'deleting tags' do
it 'deletes the tags by name' do
stub_delete_reference_requests(tags)
expect_delete_tag_by_names(tags)
is_expected.to eq(status: :success, deleted: tags)
end
end
context 'with tags to delete' do
it_behaves_like 'deleting tags'
it 'succeeds when tag delete returns 404' do
stub_delete_reference_requests('A' => 200, 'Ba' => 404)
@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
it { is_expected.to eq(status: :error, message: 'could not delete tags') }
end
end
context 'with throttling enabled' do
let(:timeout) { 10 }
before do
stub_feature_flags(container_registry_expiration_policies_throttling: true)
stub_application_setting(container_registry_delete_tags_service_timeout: timeout)
end
it_behaves_like 'deleting tags'
context 'with timeout' do
context 'set to a valid value' do
before do
allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout
stub_delete_reference_requests('A' => 200)
end
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
it 'tracks the exception' do
expect(::Gitlab::ErrorTracking)
.to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id)
subject
end
end
context 'set to 0' do
let(:timeout) { 0 }
it_behaves_like 'deleting tags'
end
context 'set to nil' do
let(:timeout) { nil }
it_behaves_like 'deleting tags'
end
end
end
end
context 'with empty tags' do

View file

@ -241,6 +241,39 @@ module GraphqlHelpers
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables)
end
def post_graphql_mutation_with_uploads(mutation, current_user: nil)
file_paths = file_paths_in_mutation(mutation)
params = mutation_to_apollo_uploads_param(mutation, files: file_paths)
workhorse_post_with_file(api('/', current_user, version: 'graphql'),
params: params,
file_key: '1'
)
end
def file_paths_in_mutation(mutation)
paths = []
find_uploads(paths, [], mutation.variables)
paths
end
# Depth first search for UploadedFile values
def find_uploads(paths, path, value)
case value
when Rack::Test::UploadedFile
paths << path
when Hash
value.each do |k, v|
find_uploads(paths, path + [k], v)
end
when Array
value.each_with_index do |v, i|
find_uploads(paths, path + [i], v)
end
end
end
# this implements GraphQL multipart request v2
# https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2
# this is simplified and do not support file deduplication

View file

@ -2,6 +2,10 @@
RSpec.shared_examples 'update with repository actions' do
context 'when the repository exists' do
before do
allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(false)
end
it 'commits the changes to the repository' do
existing_blob = snippet.blobs.first
new_file_name = existing_blob.path + '_new'