summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue137
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue22
-rw-r--r--app/assets/javascripts/issue_show/components/incident_tabs.vue26
-rw-r--r--app/assets/javascripts/issue_show/components/pinned_links.vue2
-rw-r--r--app/assets/javascripts/issue_show/incident.js21
-rw-r--r--app/assets/javascripts/issue_show/issue.js (renamed from app/assets/javascripts/issue_show/index.js)5
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js13
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss6
-rw-r--r--app/finders/concerns/merged_at_filter.rb12
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb4
-rw-r--r--app/graphql/types/merge_request_sort_enum.rb11
-rw-r--r--app/helpers/application_settings_helper.rb3
-rw-r--r--app/helpers/container_registry_helper.rb8
-rw-r--r--app/helpers/issuables_helper.rb5
-rw-r--r--app/models/application_setting.rb3
-rw-r--r--app/models/application_setting_implementation.rb3
-rw-r--r--app/models/atlassian/identity.rb26
-rw-r--r--app/models/merge_request.rb26
-rw-r--r--app/models/service.rb4
-rw-r--r--app/models/snippet.rb4
-rw-r--r--app/models/user.rb1
-rw-r--r--app/services/ci/parse_dotenv_artifact_service.rb2
-rw-r--r--app/services/projects/container_repository/gitlab/delete_tags_service.rb35
-rw-r--r--app/services/projects/container_repository/third_party/delete_tags_service.rb2
-rw-r--r--app/views/admin/application_settings/_registry.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml2
-rw-r--r--app/views/shared/wikis/_form.html.haml2
-rw-r--r--app/views/users/show.html.haml8
-rw-r--r--changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml5
-rw-r--r--changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml5
-rw-r--r--changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml5
-rw-r--r--changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml5
-rw-r--r--changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml5
-rw-r--r--changelogs/unreleased/add-flash-spacing-on-merge-request.yml5
-rw-r--r--changelogs/unreleased/dblessing-atlassian-integration.yml5
-rw-r--r--changelogs/unreleased/fix-regexp-dotenv.yml5
-rw-r--r--changelogs/unreleased/tr-incident-tabs.yml5
-rw-r--r--config/feature_flags/development/container_registry_expiration_policies_throttling.yml7
-rw-r--r--db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb19
-rw-r--r--db/migrate/20200821194920_create_atlassian_identities.rb39
-rw-r--r--db/schema_migrations/202007101134371
-rw-r--r--db/schema_migrations/202008211949201
-rw-r--r--db/structure.sql37
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql80
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json113
-rw-r--r--doc/user/packages/container_registry/index.md5
-rw-r--r--lib/api/helpers.rb4
-rw-r--r--lib/api/helpers/snippets_helpers.rb42
-rw-r--r--lib/api/project_snippets.rb6
-rw-r--r--lib/api/snippets.rb23
-rw-r--r--lib/container_registry/client.rb11
-rw-r--r--lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb2
-rw-r--r--lib/gitlab/graphql/pagination/keyset/order_info.rb17
-rw-r--r--locale/gitlab.pot15
-rw-r--r--qa/qa/support/json_formatter.rb20
-rw-r--r--spec/factories/atlassian_identities.rb11
-rw-r--r--spec/features/admin/admin_settings_spec.rb49
-rw-r--r--spec/features/issues/incident_issue_spec.rb26
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js26
-rw-r--r--spec/frontend/issue_show/components/app_spec.js95
-rw-r--r--spec/frontend/issue_show/components/description_spec.js9
-rw-r--r--spec/frontend/issue_show/components/incident_tabs_spec.js44
-rw-r--r--spec/frontend/issue_show/index_spec.js19
-rw-r--r--spec/frontend/issue_show/issue_spec.js26
-rw-r--r--spec/frontend/issue_show/mock_data.js10
-rw-r--r--spec/graphql/resolvers/merge_requests_resolver_spec.rb27
-rw-r--r--spec/graphql/types/merge_request_sort_enum_spec.rb15
-rw-r--r--spec/graphql/types/project_type_spec.rb3
-rw-r--r--spec/helpers/container_registry_helper_spec.rb27
-rw-r--r--spec/helpers/issuables_helper_spec.rb3
-rw-r--r--spec/lib/container_registry/client_spec.rb53
-rw-r--r--spec/models/application_setting_spec.rb2
-rw-r--r--spec/models/atlassian/identity_spec.rb34
-rw-r--r--spec/models/merge_request_spec.rb35
-rw-r--r--spec/models/service_spec.rb10
-rw-r--r--spec/models/snippet_spec.rb22
-rw-r--r--spec/models/user_spec.rb1
-rw-r--r--spec/requests/api/graphql/mutations/design_management/upload_spec.rb32
-rw-r--r--spec/requests/api/graphql/project/merge_requests_spec.rb44
-rw-r--r--spec/requests/api/snippets_spec.rb104
-rw-r--r--spec/services/ci/parse_dotenv_artifact_service_spec.rb11
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb16
-rw-r--r--spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb51
-rw-r--r--spec/support/helpers/graphql_helpers.rb33
-rw-r--r--spec/support/shared_examples/requests/snippet_shared_examples.rb4
88 files changed, 1493 insertions, 243 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index efde50ac13a..0d9079f6165 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-15c2f3921c4729e9c4d7ce8592300decfcfdb2e6
+12dcff902c9a2178fa6f4992d9d562ad9b422dd2
diff --git a/Gemfile b/Gemfile
index a9f9912969f..2495a5ca48a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -93,7 +93,7 @@ gem 'graphql', '~> 1.10.5'
# TODO: remove app/views/graphiql/rails/editors/show.html.erb when https://github.com/rmosolgo/graphiql-rails/pull/71 is released:
# https://gitlab.com/gitlab-org/gitlab/issues/31747
gem 'graphiql-rails', '~> 1.4.10'
-gem 'apollo_upload_server', '~> 2.0.0.beta3'
+gem 'apollo_upload_server', '~> 2.0.2'
gem 'graphql-docs', '~> 1.6.0', group: [:development, :test]
# Disable strong_params so that Mash does not respond to :permitted?
diff --git a/Gemfile.lock b/Gemfile.lock
index 0f5cfcdc056..ae678d2321b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 80b44f7bb13..78647065c8e 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -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 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>
- </div>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 992d87a969f..1cc04003aa6 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -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"
diff --git a/app/assets/javascripts/issue_show/components/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incident_tabs.vue
new file mode 100644
index 00000000000..f6e82cfaa74
--- /dev/null
+++ b/app/assets/javascripts/issue_show/components/incident_tabs.vue
@@ -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>
diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue
index a877aa2ac96..36375ca743b 100644
--- a/app/assets/javascripts/issue_show/components/pinned_links.vue
+++ b/app/assets/javascripts/issue_show/components/pinned_links.vue
@@ -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
diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js
new file mode 100644
index 00000000000..82b862a2195
--- /dev/null
+++ b/app/assets/javascripts/issue_show/incident.js
@@ -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,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/issue.js
index e170d338408..f9f61d5aa64 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/issue.js
@@ -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,
});
},
});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 5ac6c17e09d..a577d2e1ecd 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -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();
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 77170f7de5e..8aaeb92eb7a 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -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;
+ }
+}
diff --git a/app/finders/concerns/merged_at_filter.rb b/app/finders/concerns/merged_at_filter.rb
index e92bee3934c..581bcca3c25 100644
--- a/app/finders/concerns/merged_at_filter.rb
+++ b/app/finders/concerns/merged_at_filter.rb
@@ -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
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index e428e9f115f..677f84e5795 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -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
diff --git a/app/graphql/types/merge_request_sort_enum.rb b/app/graphql/types/merge_request_sort_enum.rb
new file mode 100644
index 00000000000..c64ae367a76
--- /dev/null
+++ b/app/graphql/types/merge_request_sort_enum.rb
@@ -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
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index d68ae1a56ca..7b73af4bd09 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -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
diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb
new file mode 100644
index 00000000000..9a5d84a90dd
--- /dev/null
+++ b/app/helpers/container_registry_helper.rb
@@ -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
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 0b859a39c4f..398e76b6697 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -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
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 6666a04e71d..83576edb866 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -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
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 5a5fc02c112..82304671e4e 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -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
diff --git a/app/models/atlassian/identity.rb b/app/models/atlassian/identity.rb
new file mode 100644
index 00000000000..906f2be0fbf
--- /dev/null
+++ b/app/models/atlassian/identity.rb
@@ -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
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index fd73b0d1e04..618fa06745e 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -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
diff --git a/app/models/service.rb b/app/models/service.rb
index 148c554119f..262806cd0f0 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -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,
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index eb3960ff12b..0179176ba9e 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -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.
#
diff --git a/app/models/user.rb b/app/models/user.rb
index 300e918513a..355a174ba9a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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
diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb
index fcbdc94c097..71b306864b2 100644
--- a/app/services/ci/parse_dotenv_artifact_service.rb
+++ b/app/services/ci/parse_dotenv_artifact_service.rb
@@ -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?
diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
index 18049648e26..cee94b994a3 100644
--- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb
+++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb
@@ -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
diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb
index 6504172109e..404642acf72 100644
--- a/app/services/projects/container_repository/third_party/delete_tags_service.rb
+++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb
@@ -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
diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml
index fea3ff4c3ba..8a2de6f53b7 100644
--- a/app/views/admin/application_settings/_registry.html.haml
+++ b/app/views/admin/application_settings/_registry.html.haml
@@ -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"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 746d613934c..735a7fa4fea 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -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")
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index 4d64521f9b0..1fd2194e25b 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -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'),
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index b85c3d862cd..9f8129c8c08 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -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')
diff --git a/changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml b/changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml
new file mode 100644
index 00000000000..900d9d9be1e
--- /dev/null
+++ b/changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml
@@ -0,0 +1,5 @@
+---
+title: Add timeout support in the delete tags service for the GitLab Registry
+merge_request: 36319
+author:
+type: changed
diff --git a/changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml b/changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml
new file mode 100644
index 00000000000..b8374b50c95
--- /dev/null
+++ b/changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Replace fa-exclamation-circle and fa-lightbulb-o with GitLab SVG icons
+merge_request: 40857
+author:
+type: changed
diff --git a/changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml b/changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml
new file mode 100644
index 00000000000..cd71e4bfbf6
--- /dev/null
+++ b/changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml
@@ -0,0 +1,5 @@
+---
+title: Bug fix GraphQL file uploads accepting non-file input
+merge_request: 39763
+author:
+type: fixed
diff --git a/changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml b/changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml
new file mode 100644
index 00000000000..1d41e6fa09c
--- /dev/null
+++ b/changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml
@@ -0,0 +1,5 @@
+---
+title: Migrating buttons and classes to match GitLab UI
+merge_request: 40409
+author:
+type: other
diff --git a/changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml b/changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml
new file mode 100644
index 00000000000..5f1698427a4
--- /dev/null
+++ b/changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Add MergeRequest sort options to GraphQL API
+merge_request: 40138
+author:
+type: added
diff --git a/changelogs/unreleased/add-flash-spacing-on-merge-request.yml b/changelogs/unreleased/add-flash-spacing-on-merge-request.yml
new file mode 100644
index 00000000000..345f3bee2bc
--- /dev/null
+++ b/changelogs/unreleased/add-flash-spacing-on-merge-request.yml
@@ -0,0 +1,5 @@
+---
+title: Add Flash spacing on merge request show page
+merge_request: 39903
+author:
+type: changed
diff --git a/changelogs/unreleased/dblessing-atlassian-integration.yml b/changelogs/unreleased/dblessing-atlassian-integration.yml
new file mode 100644
index 00000000000..f65d5d6f351
--- /dev/null
+++ b/changelogs/unreleased/dblessing-atlassian-integration.yml
@@ -0,0 +1,5 @@
+---
+title: Add Atlassian Identity to store identity/credentials
+merge_request: 40176
+author:
+type: added
diff --git a/changelogs/unreleased/fix-regexp-dotenv.yml b/changelogs/unreleased/fix-regexp-dotenv.yml
new file mode 100644
index 00000000000..4c6247d2259
--- /dev/null
+++ b/changelogs/unreleased/fix-regexp-dotenv.yml
@@ -0,0 +1,5 @@
+---
+title: Fix RegExp for dotenv report artifact
+merge_request: 38562
+author:
+type: fixed
diff --git a/changelogs/unreleased/tr-incident-tabs.yml b/changelogs/unreleased/tr-incident-tabs.yml
new file mode 100644
index 00000000000..7664bf0f47d
--- /dev/null
+++ b/changelogs/unreleased/tr-incident-tabs.yml
@@ -0,0 +1,5 @@
+---
+title: Add Summary tab for incident issues
+merge_request: 39822
+author:
+type: added
diff --git a/config/feature_flags/development/container_registry_expiration_policies_throttling.yml b/config/feature_flags/development/container_registry_expiration_policies_throttling.yml
new file mode 100644
index 00000000000..5169bcdfa28
--- /dev/null
+++ b/config/feature_flags/development/container_registry_expiration_policies_throttling.yml
@@ -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
diff --git a/db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb b/db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb
new file mode 100644
index 00000000000..d3865db2e18
--- /dev/null
+++ b/db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb
@@ -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
diff --git a/db/migrate/20200821194920_create_atlassian_identities.rb b/db/migrate/20200821194920_create_atlassian_identities.rb
new file mode 100644
index 00000000000..1aab9ed6381
--- /dev/null
+++ b/db/migrate/20200821194920_create_atlassian_identities.rb
@@ -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
diff --git a/db/schema_migrations/20200710113437 b/db/schema_migrations/20200710113437
new file mode 100644
index 00000000000..02e9161ed7f
--- /dev/null
+++ b/db/schema_migrations/20200710113437
@@ -0,0 +1 @@
+3d49c22b718c5b4af0a7372584fe12ab730e1ffca501c7f582f7d01200708eb1 \ No newline at end of file
diff --git a/db/schema_migrations/20200821194920 b/db/schema_migrations/20200821194920
new file mode 100644
index 00000000000..b681c3269c3
--- /dev/null
+++ b/db/schema_migrations/20200821194920
@@ -0,0 +1 @@
+d92cdef33a892fdd1761d9491bc8e4c782e9db348d4a6848a1470e99e644fbfd \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index f5a8d4f9f16..19eec2aea26 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -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;
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index faa9cd519fd..d5b80575acb 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -9521,6 +9521,71 @@ type MergeRequestSetWipPayload {
}
"""
+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
"""
enum MergeRequestState {
@@ -11742,6 +11807,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.
"""
sourceBranches: [String!]
@@ -16809,6 +16879,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.
"""
sourceBranches: [String!]
@@ -16884,6 +16959,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.
"""
sourceBranches: [String!]
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 5921f3719c1..683a0a032e3 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -26641,6 +26641,89 @@
},
{
"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",
"description": "State of a GitLab merge request",
"fields": null,
@@ -34772,6 +34855,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",
"type": {
@@ -49462,6 +49555,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.",
"type": {
@@ -49647,6 +49750,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.",
"type": {
diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md
index b103486928a..0f29b2be54e 100644
--- a/doc/user/packages/container_registry/index.md
+++ b/doc/user/packages/container_registry/index.md
@@ -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.
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 599d5bd0baf..c4a3385efd0 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -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
diff --git a/lib/api/helpers/snippets_helpers.rb b/lib/api/helpers/snippets_helpers.rb
index 79367da8d1f..560c61c7e53 100644
--- a/lib/api/helpers/snippets_helpers.rb
+++ b/lib/api/helpers/snippets_helpers.rb
@@ -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
diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb
index 0d27a3eca26..bed86e9990d 100644
--- a/lib/api/project_snippets.rb
+++ b/lib/api/project_snippets.rb
@@ -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]
diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb
index 1a3283aed98..b6914d571dc 100644
--- a/lib/api/snippets.rb
+++ b/lib/api/snippets.rb
@@ -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?
diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb
index 118eb8e2d7c..e6ca33d749b 100644
--- a/lib/container_registry/client.rb
+++ b/lib/container_registry/client.rb
@@ -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
diff --git a/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb b/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb
index afea7c602be..25b68db2233 100644
--- a/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb
+++ b/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb
@@ -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
diff --git a/lib/gitlab/graphql/pagination/keyset/order_info.rb b/lib/gitlab/graphql/pagination/keyset/order_info.rb
index 12bcc4993b5..df103a73209 100644
--- a/lib/gitlab/graphql/pagination/keyset/order_info.rb
+++ b/lib/gitlab/graphql/pagination/keyset/order_info.rb
@@ -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)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9305d66f79d..e780e4ffc8a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/qa/qa/support/json_formatter.rb b/qa/qa/support/json_formatter.rb
index 0c3ab63dce0..ceafa34b1c2 100644
--- a/qa/qa/support/json_formatter.rb
+++ b/qa/qa/support/json_formatter.rb
@@ -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
diff --git a/spec/factories/atlassian_identities.rb b/spec/factories/atlassian_identities.rb
new file mode 100644
index 00000000000..698cf4ae7ad
--- /dev/null
+++ b/spec/factories/atlassian_identities.rb
@@ -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
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index c281ba3aab9..369b258a63d 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -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
diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb
new file mode 100644
index 00000000000..57dfb370bf4
--- /dev/null
+++ b/spec/features/issues/incident_issue_spec.rb
@@ -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
diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js
index 2e95d79ea49..8bad3e37e99 100644
--- a/spec/frontend/diffs/components/settings_dropdown_spec.js
+++ b/spec/frontend/diffs/components/settings_dropdown_spec.js
@@ -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', () => {
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index 10806499d5e..698f7dbb1e4 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -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);
+ });
+ });
+ });
});
diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js
index 0053475dd13..f3c078184a9 100644
--- a/spec/frontend/issue_show/components/description_spec.js
+++ b/spec/frontend/issue_show/components/description_spec.js
@@ -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);
diff --git a/spec/frontend/issue_show/components/incident_tabs_spec.js b/spec/frontend/issue_show/components/incident_tabs_spec.js
new file mode 100644
index 00000000000..19f75345450
--- /dev/null
+++ b/spec/frontend/issue_show/components/incident_tabs_spec.js
@@ -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);
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/index_spec.js b/spec/frontend/issue_show/index_spec.js
deleted file mode 100644
index e80d1b83c11..00000000000
--- a/spec/frontend/issue_show/index_spec.js
+++ /dev/null
@@ -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();
- });
- });
-});
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js
new file mode 100644
index 00000000000..29b4f49f74f
--- /dev/null
+++ b/spec/frontend/issue_show/issue_spec.js
@@ -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();
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/mock_data.js b/spec/frontend/issue_show/mock_data.js
index ff01a004186..6d17eb08bf7 100644
--- a/spec/frontend/issue_show/mock_data.js
+++ b/spec/frontend/issue_show/mock_data.js
@@ -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,
+};
diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
index f9acbe46820..a548589d269 100644
--- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
@@ -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)
diff --git a/spec/graphql/types/merge_request_sort_enum_spec.rb b/spec/graphql/types/merge_request_sort_enum_spec.rb
new file mode 100644
index 00000000000..472eba1a50d
--- /dev/null
+++ b/spec/graphql/types/merge_request_sort_enum_spec.rb
@@ -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
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 95255d534af..44a89bfa35e 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -75,7 +75,8 @@ RSpec.describe GitlabSchema.types['Project'] do
:merged_before,
:author_username,
:assignee_username,
- :milestone_title
+ :milestone_title,
+ :sort
)
end
end
diff --git a/spec/helpers/container_registry_helper_spec.rb b/spec/helpers/container_registry_helper_spec.rb
new file mode 100644
index 00000000000..6e6e8137b3e
--- /dev/null
+++ b/spec/helpers/container_registry_helper_spec.rb
@@ -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
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 9b32758c053..8e83bd0ee9d 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -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
diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb
index aa947329c33..4daf7375a40 100644
--- a/spec/lib/container_registry/client_spec.rb
+++ b/spec/lib/container_registry/client_spec.rb
@@ -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
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index ab25608e2f0..0c4318013e3 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -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) }
diff --git a/spec/models/atlassian/identity_spec.rb b/spec/models/atlassian/identity_spec.rb
new file mode 100644
index 00000000000..a1dfe5b0e51
--- /dev/null
+++ b/spec/models/atlassian/identity_spec.rb
@@ -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
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 052afc28ef7..63c2b6fbf7b 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -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) }
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index 0c356fc5118..bdd13a77d7f 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -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
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 3f9c6981de1..248957a43e0 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -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
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 9cd666e541f..98f1c5bb2cc 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -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) }
diff --git a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
index 9a9c7107b20..2189ae3c519 100644
--- a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
+++ b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb
@@ -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
diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb
index bb63a5994b0..9446e10c01d 100644
--- a/spec/requests/api/graphql/project/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/project/merge_requests_spec.rb
@@ -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
diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb
index 4e2f6e108eb..81dd9022657 100644
--- a/spec/requests/api/snippets_spec.rb
+++ b/spec/requests/api/snippets_spec.rb
@@ -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'
+ let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } }
+ let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } }
+ let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } }
+ let(:delete_action) { { action: 'delete', file_path: 'CONTRIBUTING.md' } }
+ let(:bad_file_path) { { action: 'create', file_path: '../../etc/passwd', content: 'bar' } }
+ let(:bad_previous_path) { { action: 'create', previous_path: '../../etc/passwd', file_path: 'CHANGELOG', content: 'bar' } }
+ let(:invalid_move) { { action: 'move', file_path: 'missing_previous_path.txt' } }
+
+ context 'with snippet file changes' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:is_multi_file, :file_name, :content, :files, :status) do
+ true | nil | nil | [create_action] | :success
+ true | nil | nil | [update_action] | :success
+ true | nil | nil | [move_action] | :success
+ true | nil | nil | [delete_action] | :success
+ true | nil | nil | [create_action, update_action] | :success
+ true | 'foo.txt' | 'bar' | [create_action] | :bad_request
+ true | 'foo.txt' | 'bar' | nil | :bad_request
+ true | nil | nil | nil | :bad_request
+ true | 'foo.txt' | nil | [create_action] | :bad_request
+ true | nil | 'bar' | [create_action] | :bad_request
+ true | '' | nil | [create_action] | :bad_request
+ true | nil | '' | [create_action] | :bad_request
+ true | nil | nil | [bad_file_path] | :bad_request
+ true | nil | nil | [bad_previous_path] | :bad_request
+ true | nil | nil | [invalid_move] | :forbidden
+
+ false | 'foo.txt' | 'bar' | nil | :success
+ false | 'foo.txt' | nil | nil | :success
+ false | nil | 'bar' | nil | :success
+ false | 'foo.txt' | 'bar' | [create_action] | :bad_request
+ false | nil | nil | nil | :bad_request
+ false | nil | '' | nil | :bad_request
+ false | nil | nil | [bad_file_path] | :bad_request
+ false | nil | nil | [bad_previous_path] | :bad_request
+ end
+
+ with_them do
+ before do
+ allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(is_multi_file)
+ end
+
+ it 'has the correct response' do
+ update_params = {}.tap do |params|
+ params[:files] = files if files
+ params[:file_name] = file_name if file_name
+ params[:content] = content if content
+ end
+
+ update_snippet(params: update_params)
+
+ expect(response).to have_gitlab_http_status(status)
+ end
+ end
+
+ context 'when save fails due to a repository commit error' do
+ before do
+ allow_next_instance_of(Repository) do |instance|
+ allow(instance).to receive(:multi_action).and_raise(Gitlab::Git::CommitError)
+ end
+
+ update_snippet(params: { files: [create_action] })
+ end
+
+ it 'returns a bad request response' do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
+ shared_examples 'snippet non-file updates' do
+ it 'updates a snippet non-file attributes' do
new_description = 'New description'
+ new_title = 'New title'
+ new_visibility = 'internal'
- update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' })
+ update_snippet(params: { title: new_title, description: new_description, visibility: new_visibility })
- expect(response).to have_gitlab_http_status(:ok)
snippet.reload
- expect(snippet.content).to eq(new_content)
- expect(snippet.description).to eq(new_description)
- expect(snippet.visibility).to eq('internal')
+
+ 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: '' })
diff --git a/spec/services/ci/parse_dotenv_artifact_service_spec.rb b/spec/services/ci/parse_dotenv_artifact_service_spec.rb
index a5f01187a83..91b81af9fd1 100644
--- a/spec/services/ci/parse_dotenv_artifact_service_spec.rb
+++ b/spec/services/ci/parse_dotenv_artifact_service_spec.rb
@@ -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
diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb
index 3014ccbd7ba..5116427dad2 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -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
diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
index 68c232e5d83..3bbcec8775e 100644
--- a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
@@ -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
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index 87525734490..5635ba3df05 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -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
diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb
index a17163328f4..84ef7723b9b 100644
--- a/spec/support/shared_examples/requests/snippet_shared_examples.rb
+++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb
@@ -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'