diff options
Diffstat (limited to 'app')
25 files changed, 286 insertions, 224 deletions
diff --git a/app/assets/javascripts/linked_resources/components/add_issuable_resource_link_form.vue b/app/assets/javascripts/linked_resources/components/add_issuable_resource_link_form.vue deleted file mode 100644 index 6a0deb41fd1..00000000000 --- a/app/assets/javascripts/linked_resources/components/add_issuable_resource_link_form.vue +++ /dev/null @@ -1,75 +0,0 @@ -<script> -import { GlFormGroup, GlButton, GlFormInput } from '@gitlab/ui'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; -import { resourceLinksFormI18n } from '../constants'; - -export default { - name: 'AddIssuableResourceLinkForm', - components: { - GlFormGroup, - GlButton, - GlFormInput, - }, - i18n: resourceLinksFormI18n, - directives: { - autofocusonshow, - }, - props: { - isSubmitting: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - linkTextValue: '', - linkValue: '', - }; - }, - computed: { - isSubmitButtonDisabled() { - return this.linkValue.length === 0 || this.isSubmitting; - }, - }, - methods: { - onFormCancel() { - this.linkValue = ''; - this.linkTextValue = ''; - this.$emit('add-issuable-resource-link-form-cancel'); - }, - }, -}; -</script> - -<template> - <form @submit.prevent> - <gl-form-group :label="$options.i18n.linkTextLabel"> - <gl-form-input - v-model="linkTextValue" - v-autofocusonshow - data-testid="link-text-input" - type="text" - /> - </gl-form-group> - <gl-form-group :label="$options.i18n.linkValueLabel"> - <gl-form-input v-model="linkValue" data-testid="link-value-input" type="text" /> - </gl-form-group> - <div class="gl-mt-5 gl-clearfix"> - <gl-button - category="primary" - variant="confirm" - data-testid="add-button" - :disabled="isSubmitButtonDisabled" - :loading="isSubmitting" - type="submit" - class="gl-float-left" - > - {{ $options.i18n.submitButtonText }} - </gl-button> - <gl-button class="gl-float-right" @click="onFormCancel"> - {{ $options.i18n.cancelButtonText }} - </gl-button> - </div> - </form> -</template> diff --git a/app/assets/javascripts/linked_resources/components/resource_links_block.vue b/app/assets/javascripts/linked_resources/components/resource_links_block.vue deleted file mode 100644 index 46c4fc7f632..00000000000 --- a/app/assets/javascripts/linked_resources/components/resource_links_block.vue +++ /dev/null @@ -1,111 +0,0 @@ -<script> -import { GlLink, GlIcon, GlButton } from '@gitlab/ui'; -import { resourceLinksI18n } from '../constants'; -import AddIssuableResourceLinkForm from './add_issuable_resource_link_form.vue'; - -export default { - name: 'ResourceLinksBlock', - components: { - GlLink, - GlButton, - GlIcon, - AddIssuableResourceLinkForm, - }, - i18n: resourceLinksI18n, - props: { - helpPath: { - type: String, - required: false, - default: '', - }, - canAddResourceLinks: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - isFormVisible: false, - isSubmitting: false, - }; - }, - computed: { - badgeLabel() { - return 0; - }, - hasBody() { - return this.isFormVisible; - }, - }, - methods: { - async toggleResourceLinkForm() { - this.isFormVisible = !this.isFormVisible; - }, - hideResourceLinkForm() { - this.isFormVisible = false; - }, - }, -}; -</script> - -<template> - <div id="resource-links" class="gl-mt-5"> - <div class="card card-slim gl-overflow-hidden"> - <div - :class="{ 'panel-empty-heading border-bottom-0': !hasBody }" - class="card-header gl-display-flex gl-justify-content-space-between" - > - <h3 - class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7" - > - <gl-link - id="user-content-resource-links" - class="anchor position-absolute gl-text-decoration-none" - href="#resource-links" - aria-hidden="true" - /> - <slot name="header-text">{{ $options.i18n.headerText }}</slot> - <gl-link - :href="helpPath" - target="_blank" - class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500" - data-testid="help-link" - :aria-label="$options.i18n.helpText" - > - <gl-icon name="question" :size="12" /> - </gl-link> - - <div class="gl-display-inline-flex"> - <div class="gl-display-inline-flex gl-mx-5"> - <span class="gl-display-inline-flex gl-align-items-center"> - <gl-icon name="link" class="gl-mr-2 gl-text-gray-500" /> - {{ badgeLabel }} - </span> - </div> - <gl-button - v-if="canAddResourceLinks" - icon="plus" - :aria-label="$options.i18n.addButtonText" - @click="toggleResourceLinkForm" - /> - </div> - </h3> - </div> - <div - class="linked-issues-card-body bg-gray-light" - :class="{ - 'gl-p-5': isFormVisible, - }" - > - <div v-show="isFormVisible" class="card-body bordered-box gl-bg-white"> - <add-issuable-resource-link-form - ref="resourceLinkForm" - :is-submitting="isSubmitting" - @add-issuable-resource-link-form-cancel="hideResourceLinkForm" - /> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/linked_resources/constants.js b/app/assets/javascripts/linked_resources/constants.js deleted file mode 100644 index 1b11cfc5f88..00000000000 --- a/app/assets/javascripts/linked_resources/constants.js +++ /dev/null @@ -1,14 +0,0 @@ -import { s__ } from '~/locale'; - -export const resourceLinksI18n = Object.freeze({ - headerText: s__('LinkedResources|Linked resources'), - helpText: s__('LinkedResources|Read more about linked resources'), - addButtonText: s__('LinkedResources|Add a resource link'), -}); - -export const resourceLinksFormI18n = Object.freeze({ - linkTextLabel: s__('LinkedResources|Text (Optional)'), - linkValueLabel: s__('LinkedResources|Link'), - submitButtonText: s__('LinkedResources|Add'), - cancelButtonText: s__('LinkedResources|Cancel'), -}); diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js index 4ac9ca31a84..244adca86c9 100644 --- a/app/assets/javascripts/linked_resources/index.js +++ b/app/assets/javascripts/linked_resources/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; +import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import ResourceLinksBlock from './components/resource_links_block.vue'; export default function initLinkedResources() { const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root'); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue index 56da8e88b7a..bfa99c01c3f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue @@ -1,4 +1,5 @@ <script> +import { uniqueId } from 'lodash'; import { GlIcon, GlPopover, GlLink, GlSprintf } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { timeTilRun } from '../../utils'; @@ -43,6 +44,11 @@ export default { CLEANUP_STATUS_UNFINISHED, PARTIAL_CLEANUP_CONTINUE_MESSAGE, }, + data() { + return { + iconId: uniqueId('status-info-'), + }; + }, computed: { showStatus() { return this.status !== UNSCHEDULED_STATUS; @@ -85,14 +91,14 @@ export default { </span> <gl-icon v-if="failedDelete" - id="status-info" + :id="iconId" :size="14" class="gl-text-gray-500" data-testid="extra-info" name="information-o" /> <gl-popover - target="status-info" + :target="iconId" container="status-popover-container" v-bind="$options.statusPopoverOptions" > diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 7ac9395c725..176f84f6c1a 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -1,9 +1,11 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; import createDefaultClient from '~/lib/graphql'; import WorkItemLinks from './work_item_links.vue'; Vue.use(VueApollo); +Vue.use(GlToast); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 60b30a82e9d..89f086cfca5 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -11,6 +11,7 @@ import { } from '../../constants'; import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { components: { @@ -19,6 +20,7 @@ export default { GlIcon, GlLoadingIcon, WorkItemLinksForm, + WorkItemLinksMenu, }, props: { workItemId: { @@ -156,19 +158,22 @@ export default { <div v-for="child in children" :key="child.id" - class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base" + class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" data-testid="links-child" > <div> <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" /> <span class="gl-word-break-all">{{ child.title }}</span> </div> - <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0"> + <div + class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center" + > <gl-badge :variant="badgeVariant(child.state)"> <span class="gl-sm-display-block">{{ $options.WORK_ITEM_STATUS_TEXT[child.state] }}</span> </gl-badge> + <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" /> </div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue new file mode 100644 index 00000000000..6deb87c5dca --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue @@ -0,0 +1,101 @@ +<script> +import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { produce } from 'immer'; +import { s__ } from '~/locale'; +import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql'; +import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; +import { WIDGET_TYPE_HIERARCHY } from '../../constants'; + +export default { + components: { + GlDropdownItem, + GlDropdown, + GlIcon, + }, + props: { + workItemId: { + type: String, + required: true, + }, + parentWorkItemId: { + type: String, + required: true, + }, + }, + data() { + return { + activeToast: null, + }; + }, + methods: { + toggleChildFromCache(data, store) { + const sourceData = store.readQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.parentWorkItemId }, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex( + (child) => child.id === this.workItemId, + ); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem); + } + }); + + store.writeQuery({ + query: getWorkItemLinksQuery, + variables: { id: this.parentWorkItemId }, + data: newData, + }); + }, + async addChild(data) { + const { data: resp } = await this.$apollo.mutate({ + mutation: changeWorkItemParentMutation, + variables: { id: this.workItemId, parentId: this.parentWorkItemId }, + update: this.toggleChildFromCache.bind(this, data), + }); + + if (resp.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + }, + async removeChild() { + const { data } = await this.$apollo.mutate({ + mutation: changeWorkItemParentMutation, + variables: { id: this.workItemId, parentId: null }, + update: this.toggleChildFromCache.bind(this, null), + }); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.addChild.bind(this, data), + }, + }); + } + }, + }, +}; +</script> + +<template> + <span class="gl-ml-2"> + <gl-dropdown category="tertiary" toggle-class="btn-icon" :right="true"> + <template #button-content> + <gl-icon name="ellipsis_v" :size="14" /> + </template> + <gl-dropdown-item @click="removeChild"> + {{ s__('WorkItem|Remove') }} + </gl-dropdown-item> + </gl-dropdown> + </span> +</template> diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql new file mode 100644 index 00000000000..dc5286174d8 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql @@ -0,0 +1,13 @@ +mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) { + workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) { + workItem { + id + workItemType { + id + } + title + state + } + errors + } +} diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index e42cb9b8422..8ac370b1bd4 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -51,7 +51,7 @@ module Projects def test_suite suite = builds.sum do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, project).load! diff --git a/app/graphql/resolvers/ci/test_suite_resolver.rb b/app/graphql/resolvers/ci/test_suite_resolver.rb index 5d61d9e986b..f758e217b47 100644 --- a/app/graphql/resolvers/ci/test_suite_resolver.rb +++ b/app/graphql/resolvers/ci/test_suite_resolver.rb @@ -28,7 +28,7 @@ module Resolvers def load_test_suite_data(builds) suite = builds.sum do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end Gitlab::Ci::Reports::TestFailureHistory.new(suite.failed.values, pipeline.project).load! diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 87c8bf5cb28..3dd6b3f4a80 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -11,6 +11,7 @@ module Users UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout' SECURITY_NEWSLETTER_CALLOUT = 'security_newsletter_callout' REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze + WEB_HOOK_DISABLED = 'web_hook_disabled' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -60,12 +61,31 @@ module Users !user_dismissed?(SECURITY_NEWSLETTER_CALLOUT) end + def web_hook_disabled_dismissed?(project) + return false unless project + + last_failure = Gitlab::Redis::SharedState.with do |redis| + key = "web_hooks:last_failure:project-#{project.id}" + redis.get(key) + end + + last_failure = DateTime.parse(last_failure) if last_failure + + user_dismissed?(WEB_HOOK_DISABLED, last_failure, namespace: project.namespace) + end + private - def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) + def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil, namespace: nil) return false unless current_user - current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than) + query = { feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than } + + if namespace + current_user.dismissed_callout_for_namespace?(namespace: namespace, **query) + else + current_user.dismissed_callout?(**query) + end end end end diff --git a/app/helpers/web_hooks/web_hooks_helper.rb b/app/helpers/web_hooks/web_hooks_helper.rb new file mode 100644 index 00000000000..95122750c2f --- /dev/null +++ b/app/helpers/web_hooks/web_hooks_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module WebHooks + module WebHooksHelper + EXPIRY_TTL = 1.hour + + def show_project_hook_failed_callout?(project:) + return false unless current_user + return false unless Feature.enabled?(:webhooks_failed_callout, project) + return false unless Feature.enabled?(:web_hooks_disable_failed, project) + return false unless Ability.allowed?(current_user, :read_web_hooks, project) + + # Assumes include of Users::CalloutsHelper + return false if web_hook_disabled_dismissed?(project) + + any_project_hook_failed?(project) # Most expensive query last + end + + private + + def any_project_hook_failed?(project) + Rails.cache.fetch("any_web_hook_failed:#{project.id}", expires_in: EXPIRY_TTL) do + ProjectHook.for_projects(project).disabled.exists? + end + end + end +end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 791bae17271..78b55680b5e 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -1090,7 +1090,7 @@ module Ci end def test_reports - Gitlab::Ci::Reports::TestReports.new.tap do |test_reports| + Gitlab::Ci::Reports::TestReport.new.tap do |test_reports| latest_test_report_builds.find_each do |build| build.collect_test_reports!(test_reports) end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b7ace34141e..bcbf43ee38b 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -27,6 +27,8 @@ class ProjectHook < WebHook belongs_to :project validates :project, presence: true + scope :for_projects, ->(project) { where(project: project) } + def pluralized_name _('Webhooks') end @@ -41,6 +43,19 @@ class ProjectHook < WebHook project end + override :update_last_failure + def update_last_failure + return if executable? + + key = "web_hooks:last_failure:project-#{project_id}" + time = Time.current.utc.iso8601 + + Gitlab::Redis::SharedState.with do |redis| + prev = redis.get(key) + redis.set(key, time) if !prev || prev < time + end + end + private override :web_hooks_disable_failed? diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 71e7206718e..f428d07cd7f 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -48,6 +48,11 @@ class WebHook < ApplicationRecord where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) end + # Inverse of executable + scope :disabled, -> do + where('recent_failures > ? OR disabled_until >= ?', FAILURE_THRESHOLD, Time.current) + end + def executable? !temporarily_disabled? && !permanently_disabled? end @@ -181,6 +186,10 @@ class WebHook < ApplicationRecord raise InterpolationError, "Invalid URL template. Missing key #{e.key}" end + def update_last_failure + # Overridden in child classes. + end + private def web_hooks_disable_failed? diff --git a/app/models/note.rb b/app/models/note.rb index f2ddf0efe47..986a85acac6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -111,6 +111,7 @@ class Note < ApplicationRecord end validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?] + validate :validate_created_after # @deprecated attachments are handled by the Upload model. # @@ -748,6 +749,13 @@ class Note < ApplicationRecord errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT end + def validate_created_after + return unless created_at + return if created_at >= '1970-01-01' + + errors.add(:created_at, s_('Note|The created date provided is too far in the past.')) + end + def noteable_label_url_method for_merge_request? ? :project_merge_requests_url : :project_issues_url end diff --git a/app/models/user.rb b/app/models/user.rb index dc2f36a9ddb..12f434db631 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -222,6 +222,7 @@ class User < ApplicationRecord has_many :custom_attributes, class_name: 'UserCustomAttribute' has_many :callouts, class_name: 'Users::Callout' has_many :group_callouts, class_name: 'Users::GroupCallout' + has_many :namespace_callouts, class_name: 'Users::NamespaceCallout' has_many :term_agreements belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' @@ -2085,6 +2086,13 @@ class User < ApplicationRecord callout_dismissed?(callout, ignore_dismissal_earlier_than) end + def dismissed_callout_for_namespace?(feature_name:, namespace:, ignore_dismissal_earlier_than: nil) + source_feature_name = "#{feature_name}_#{namespace.id}" + callout = namespace_callouts_by_feature_name[source_feature_name] + + callout_dismissed?(callout, ignore_dismissal_earlier_than) + end + # Load the current highest access by looking directly at the user's memberships def current_highest_access_level members.non_request.maximum(:access_level) @@ -2111,6 +2119,11 @@ class User < ApplicationRecord .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id) end + def find_or_initialize_namespace_callout(feature_name, namespace_id) + namespace_callouts + .find_or_initialize_by(feature_name: ::Users::NamespaceCallout.feature_names[feature_name], namespace_id: namespace_id) + end + def can_trigger_notifications? confirmed? && !blocked? && !ghost? end @@ -2228,6 +2241,10 @@ class User < ApplicationRecord @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name) end + def namespace_callouts_by_feature_name + @namespace_callouts_by_feature_name ||= namespace_callouts.index_by(&:source_feature_name) + end + def authorized_groups_without_shared_membership Group.from_union([ groups.select(*Namespace.cached_column_list), diff --git a/app/models/users/namespace_callout.rb b/app/models/users/namespace_callout.rb new file mode 100644 index 00000000000..a20a196a4ef --- /dev/null +++ b/app/models/users/namespace_callout.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + class NamespaceCallout < ApplicationRecord + include Users::Calloutable + + self.table_name = 'user_namespace_callouts' + + belongs_to :namespace + + enum feature_name: { + invite_members_banner: 1, + approaching_seat_count_threshold: 2, # EE-only + storage_enforcement_banner_first_enforcement_threshold: 3, + storage_enforcement_banner_second_enforcement_threshold: 4, + storage_enforcement_banner_third_enforcement_threshold: 5, + storage_enforcement_banner_fourth_enforcement_threshold: 6, + preview_user_over_limit_free_plan_alert: 7, # EE-only + user_reached_limit_free_plan_alert: 8, # EE-only + web_hook_disabled: 9 + } + + validates :namespace, presence: true + validates :feature_name, + presence: true, + uniqueness: { scope: [:user_id, :namespace_id] }, + inclusion: { in: NamespaceCallout.feature_names.keys } + + def source_feature_name + "#{feature_name}_#{namespace_id}" + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index c54dbefc1ae..850f25a6089 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -492,6 +492,7 @@ class ProjectPolicy < BasePolicy enable :update_runners_registration_token enable :admin_project_google_cloud enable :admin_secure_files + enable :read_web_hooks end rule { public_project & metrics_dashboard_allowed }.policy do diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb index 8bdb51320f9..f9146b3677a 100644 --- a/app/services/ci/build_report_result_service.rb +++ b/app/services/ci/build_report_result_service.rb @@ -22,7 +22,7 @@ module Ci private def generate_test_suite_report(build) - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end def tests_params(test_suite) diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index 7323ad417ea..2214a6a2729 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -81,7 +81,7 @@ module Ci def generate_test_suite!(build) # Returns an instance of Gitlab::Ci::Reports::TestSuite - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + build.collect_test_reports!(Gitlab::Ci::Reports::TestReport.new) end def ci_unit_test_attrs(batch) diff --git a/app/services/web_hooks/log_execution_service.rb b/app/services/web_hooks/log_execution_service.rb index 0ee7c41469f..17dcf615830 100644 --- a/app/services/web_hooks/log_execution_service.rb +++ b/app/services/web_hooks/log_execution_service.rb @@ -44,6 +44,7 @@ module WebHooks end log_state_change + hook.update_last_failure end rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError raise if raise_lock_error? diff --git a/app/views/errors/omniauth_error.html.haml b/app/views/errors/omniauth_error.html.haml index e114e4609f8..3090c823677 100644 --- a/app/views/errors/omniauth_error.html.haml +++ b/app/views/errors/omniauth_error.html.haml @@ -2,14 +2,18 @@ .container = render partial: "shared/errors/graphic_422", formats: :svg - %h3 Sign-in using #{@provider} auth failed + %h3 + = _('Sign-in using %{provider} auth failed') % { provider: @provider } - %p.light.subtitle Sign-in failed because #{@error}. + %p.light.subtitle + = _('Sign-in failed because %{error}.') % { error: @error } - %p Try logging in using your username or email. If you have forgotten your password, try recovering it + %p + = _('Try logging in using your username or email. If you have forgotten your password, try recovering it') - = link_to "Sign in", new_session_path(:user), class: 'gl-button btn primary' - = link_to "Recover password", new_password_path(:user), class: 'gl-button btn secondary' + = link_to _('Sign in'), new_session_path(:user), class: 'gl-button btn primary' + = link_to _('Recover password'), new_password_path(:user), class: 'gl-button btn secondary' %hr - %p.light If none of the options work, try contacting a GitLab administrator. + %p.light + = _('If none of the options work, try contacting a GitLab administrator.') diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 54435f675a7..07e299d71ea 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -6,9 +6,9 @@ = preserve(markdown(commit.description, pipeline: :single_line)) .info-well - .well-segment.pipeline-info - .icon-container.gl-vertical-align-text-bottom - = sprite_icon('clock') + .well-segment.pipeline-info{ class: "gl-align-items-baseline!" } + .icon-container + = sprite_icon('clock', css_class: 'gl-top-0!') = pluralize @pipeline.total_size, "job" = @pipeline.ref_text - if @pipeline.duration @@ -20,7 +20,7 @@ - if has_pipeline_badges?(@pipeline) .well-segment.qa-pipeline-badges .icon-container - = sprite_icon('flag') + = sprite_icon('flag', css_class: 'gl-top-0!') - if @pipeline.child? - text = sprintf(s_('Pipelines|Child pipeline (%{link_start}parent%{link_end})'), { link_start: "<a href='#{pipeline_path(@pipeline.triggered_by_pipeline)}' class='text-underline'>", link_end: "</a>"}).html_safe = gl_badge_tag text, { variant: :info, size: :sm }, { class: 'js-pipeline-child has-tooltip', title: s_("Pipelines|This is a child pipeline within the parent pipeline") } @@ -44,13 +44,13 @@ .well-segment.branch-info .icon-container.commit-icon - = custom_icon("icon_commit") + = sprite_icon('commit', css_class: 'gl-top-0!') = link_to commit.short_id, project_commit_path(@project, @pipeline.sha), class: "commit-sha" = clipboard_button(text: @pipeline.sha, title: _("Copy commit SHA")) .well-segment.related-merge-request-info .icon-container - = sprite_icon("git-merge") + = sprite_icon("git-merge", css_class: 'gl-top-0!') %span.related-merge-requests %span.js-truncated-mr-list = @pipeline.all_related_merge_request_text(limit: 1) |