Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
c203c40cda
commit
9fc7cdf0b7
34 changed files with 736 additions and 88 deletions
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import VueDraggable from 'vuedraggable';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
|
||||
import DashboardHeader from './dashboard_header.vue';
|
||||
import DashboardPanel from './dashboard_panel.vue';
|
||||
|
@ -24,7 +25,7 @@ import {
|
|||
expandedPanelPayloadFromUrl,
|
||||
convertVariablesForURL,
|
||||
} from '../utils';
|
||||
import { metricStates } from '../constants';
|
||||
import { metricStates, keyboardShortcutKeys } from '../constants';
|
||||
import { defaultTimeRange } from '~/vue_shared/constants';
|
||||
|
||||
export default {
|
||||
|
@ -149,6 +150,7 @@ export default {
|
|||
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
|
||||
isRearrangingPanels: false,
|
||||
originalDocumentTitle: document.title,
|
||||
hoveredPanel: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -214,9 +216,13 @@ export default {
|
|||
},
|
||||
created() {
|
||||
window.addEventListener('keyup', this.onKeyup);
|
||||
|
||||
Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut);
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('keyup', this.onKeyup);
|
||||
|
||||
Mousetrap.unbind(Object.values(keyboardShortcutKeys));
|
||||
},
|
||||
mounted() {
|
||||
if (!this.hasMetrics) {
|
||||
|
@ -326,6 +332,56 @@ export default {
|
|||
|
||||
return isNumberOfPanelsEven || !isLastPanel;
|
||||
},
|
||||
/**
|
||||
* TODO: Investigate this to utilize the eventBus from Vue
|
||||
* The intentation behind this cleanup is to allow for better tests
|
||||
* as well as use the correct eventBus facilities that are compatible
|
||||
* with Vue 3
|
||||
* https://gitlab.com/gitlab-org/gitlab/-/issues/225583
|
||||
*/
|
||||
//
|
||||
runShortcut(e) {
|
||||
const panel = this.$refs[this.hoveredPanel];
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
const [panelInstance] = panel;
|
||||
let actionToRun = '';
|
||||
|
||||
switch (e.key) {
|
||||
case keyboardShortcutKeys.EXPAND:
|
||||
actionToRun = 'onExpandFromKeyboardShortcut';
|
||||
break;
|
||||
|
||||
case keyboardShortcutKeys.VISIT_LOGS:
|
||||
actionToRun = 'visitLogsPageFromKeyboardShortcut';
|
||||
break;
|
||||
|
||||
case keyboardShortcutKeys.SHOW_ALERT:
|
||||
actionToRun = 'showAlertModalFromKeyboardShortcut';
|
||||
break;
|
||||
|
||||
case keyboardShortcutKeys.DOWNLOAD_CSV:
|
||||
actionToRun = 'downloadCsvFromKeyboardShortcut';
|
||||
break;
|
||||
|
||||
case keyboardShortcutKeys.CHART_COPY:
|
||||
actionToRun = 'copyChartLinkFromKeyboardShotcut';
|
||||
break;
|
||||
|
||||
default:
|
||||
actionToRun = 'onExpandFromKeyboardShortcut';
|
||||
break;
|
||||
}
|
||||
|
||||
panelInstance[actionToRun]();
|
||||
},
|
||||
setHoveredPanel(groupKey, graphIndex) {
|
||||
this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`;
|
||||
},
|
||||
clearHoveredPanel() {
|
||||
this.hoveredPanel = '';
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
goBackLabel: s__('Metrics|Go back (Esc)'),
|
||||
|
@ -407,6 +463,8 @@ export default {
|
|||
'draggable-enabled': isRearrangingPanels,
|
||||
'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length),
|
||||
}"
|
||||
@mouseover="setHoveredPanel(groupData.key, graphIndex)"
|
||||
@mouseout="clearHoveredPanel"
|
||||
>
|
||||
<div class="position-relative draggable-panel js-draggable-panel">
|
||||
<div
|
||||
|
@ -420,6 +478,7 @@ export default {
|
|||
</div>
|
||||
|
||||
<dashboard-panel
|
||||
:ref="`dashboard-panel-${groupData.key}-${graphIndex}`"
|
||||
:settings-path="settingsPath"
|
||||
:clipboard-text="generatePanelUrl(groupData.group, graphData)"
|
||||
:graph-data="graphData"
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
import { mapState } from 'vuex';
|
||||
import { pickBy } from 'lodash';
|
||||
import invalidUrl from '~/lib/utils/invalid_url';
|
||||
import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility';
|
||||
import {
|
||||
GlResizeObserverDirective,
|
||||
GlIcon,
|
||||
|
@ -29,7 +30,6 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
|
|||
import TrackEventDirective from '~/vue_shared/directives/track_event';
|
||||
import AlertWidget from './alert_widget.vue';
|
||||
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
|
||||
import { isSafeURL } from '~/lib/utils/url_utility';
|
||||
|
||||
const events = {
|
||||
timeRangeZoom: 'timerangezoom',
|
||||
|
@ -244,6 +244,9 @@ export default {
|
|||
this.hasMetricsInDb
|
||||
);
|
||||
},
|
||||
alertModalId() {
|
||||
return `alert-modal-${this.graphData.id}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.refreshTitleTooltip();
|
||||
|
@ -282,6 +285,11 @@ export default {
|
|||
onExpand() {
|
||||
this.$emit(events.expand);
|
||||
},
|
||||
onExpandFromKeyboardShortcut() {
|
||||
if (this.isContextualMenuShown) {
|
||||
this.onExpand();
|
||||
}
|
||||
},
|
||||
setAlerts(alertPath, alertAttributes) {
|
||||
if (alertAttributes) {
|
||||
this.$set(this.allAlerts, alertPath, alertAttributes);
|
||||
|
@ -292,6 +300,34 @@ export default {
|
|||
safeUrl(url) {
|
||||
return isSafeURL(url) ? url : '#';
|
||||
},
|
||||
showAlertModal() {
|
||||
this.$root.$emit('bv::show::modal', this.alertModalId);
|
||||
},
|
||||
showAlertModalFromKeyboardShortcut() {
|
||||
if (this.isContextualMenuShown) {
|
||||
this.showAlertModal();
|
||||
}
|
||||
},
|
||||
visitLogsPage() {
|
||||
if (this.logsPathWithTimeRange) {
|
||||
visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL()));
|
||||
}
|
||||
},
|
||||
visitLogsPageFromKeyboardShortcut() {
|
||||
if (this.isContextualMenuShown) {
|
||||
this.visitLogsPage();
|
||||
}
|
||||
},
|
||||
downloadCsvFromKeyboardShortcut() {
|
||||
if (this.csvText && this.isContextualMenuShown) {
|
||||
this.$refs.downloadCsvLink.$el.firstChild.click();
|
||||
}
|
||||
},
|
||||
copyChartLinkFromKeyboardShotcut() {
|
||||
if (this.clipboardText && this.isContextualMenuShown) {
|
||||
this.$refs.copyChartLink.$el.firstChild.click();
|
||||
}
|
||||
},
|
||||
},
|
||||
panelTypes,
|
||||
};
|
||||
|
@ -313,7 +349,7 @@ export default {
|
|||
<alert-widget
|
||||
v-if="isContextualMenuShown && alertWidgetAvailable"
|
||||
class="mx-1"
|
||||
:modal-id="`alert-modal-${graphData.id}`"
|
||||
:modal-id="alertModalId"
|
||||
:alerts-endpoint="alertsEndpoint"
|
||||
:relevant-queries="graphData.metrics"
|
||||
:alerts-to-manage="getGraphAlerts(graphData.metrics)"
|
||||
|
@ -328,7 +364,7 @@ export default {
|
|||
ref="contextualMenu"
|
||||
data-qa-selector="prometheus_graph_widgets"
|
||||
>
|
||||
<div class="d-flex align-items-center">
|
||||
<div data-testid="dropdown-wrapper" class="d-flex align-items-center">
|
||||
<gl-dropdown
|
||||
v-gl-tooltip
|
||||
toggle-class="shadow-none border-0"
|
||||
|
@ -383,7 +419,7 @@ export default {
|
|||
</gl-dropdown-item>
|
||||
<gl-dropdown-item
|
||||
v-if="alertWidgetAvailable"
|
||||
v-gl-modal="`alert-modal-${graphData.id}`"
|
||||
v-gl-modal="alertModalId"
|
||||
data-qa-selector="alert_widget_menu_item"
|
||||
>
|
||||
{{ __('Alerts') }}
|
||||
|
|
|
@ -251,3 +251,17 @@ export const VARIABLE_TYPES = {
|
|||
* before passing the data to the backend.
|
||||
*/
|
||||
export const VARIABLE_PREFIX = 'var-';
|
||||
|
||||
/**
|
||||
* All of the actions inside each panel dropdown can be accessed
|
||||
* via keyboard shortcuts than can be activated via mouse hovers
|
||||
* and or focus via tabs.
|
||||
*/
|
||||
|
||||
export const keyboardShortcutKeys = {
|
||||
EXPAND: 'e',
|
||||
VISIT_LOGS: 'l',
|
||||
SHOW_ALERT: 'a',
|
||||
DOWNLOAD_CSV: 'd',
|
||||
CHART_COPY: 'c',
|
||||
};
|
||||
|
|
|
@ -27,7 +27,7 @@ export default {
|
|||
return this.author.webUrl || this.author.web_url;
|
||||
},
|
||||
avatarUrl() {
|
||||
return this.author.avatarUrl || this.author.avatar_url;
|
||||
return this.author.avatarUrl || this.author.avatar_url || gl.mrWidgetData.defaultAvatarUrl;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -40,6 +40,6 @@ export default {
|
|||
class="author-link inline"
|
||||
>
|
||||
<img :src="avatarUrl" class="avatar avatar-inline s16" />
|
||||
<span v-if="showAuthorName" class="author"> {{ author.name }} </span>
|
||||
<span v-if="showAuthorName" class="author">{{ author.name }}</span>
|
||||
</a>
|
||||
</template>
|
||||
|
|
|
@ -8,6 +8,7 @@ export default () => {
|
|||
if (gl.mrWidget) return;
|
||||
|
||||
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
|
||||
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
|
||||
|
||||
const vm = new Vue(MrWidgetOptions);
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ class ApplicationController < ActionController::Base
|
|||
include Impersonation
|
||||
include Gitlab::Logging::CloudflareHelper
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
include ControllerWithFeatureCategory
|
||||
|
||||
before_action :authenticate_user!, except: [:route_not_found]
|
||||
before_action :enforce_terms!, if: :should_enforce_terms?
|
||||
|
|
45
app/controllers/concerns/controller_with_feature_category.rb
Normal file
45
app/controllers/concerns/controller_with_feature_category.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ControllerWithFeatureCategory
|
||||
extend ActiveSupport::Concern
|
||||
include Gitlab::ClassAttributes
|
||||
|
||||
class_methods do
|
||||
def feature_category(category, config = {})
|
||||
validate_config!(config)
|
||||
|
||||
category_config = Config.new(category, config[:only], config[:except], config[:if], config[:unless])
|
||||
# Add the config to the beginning. That way, the last defined one takes precedence.
|
||||
feature_category_configuration.unshift(category_config)
|
||||
end
|
||||
|
||||
def feature_category_for_action(action)
|
||||
category_config = feature_category_configuration.find { |config| config.matches?(action) }
|
||||
|
||||
category_config&.category || superclass_feature_category_for_action(action)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_config!(config)
|
||||
invalid_keys = config.keys - [:only, :except, :if, :unless]
|
||||
if invalid_keys.any?
|
||||
raise ArgumentError, "unknown arguments: #{invalid_keys} "
|
||||
end
|
||||
|
||||
if config.key?(:only) && config.key?(:except)
|
||||
raise ArgumentError, "cannot configure both `only` and `except`"
|
||||
end
|
||||
end
|
||||
|
||||
def feature_category_configuration
|
||||
class_attributes[:feature_category_config] ||= []
|
||||
end
|
||||
|
||||
def superclass_feature_category_for_action(action)
|
||||
return unless superclass.respond_to?(:feature_category_for_action)
|
||||
|
||||
superclass.feature_category_for_action(action)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,38 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module ControllerWithFeatureCategory
|
||||
class Config
|
||||
attr_reader :category
|
||||
|
||||
def initialize(category, only, except, if_proc, unless_proc)
|
||||
@category = category.to_sym
|
||||
@only, @except = only&.map(&:to_s), except&.map(&:to_s)
|
||||
@if_proc, @unless_proc = if_proc, unless_proc
|
||||
end
|
||||
|
||||
def matches?(action)
|
||||
included?(action) && !excluded?(action) &&
|
||||
if_proc?(action) && !unless_proc?(action)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :only, :except, :if_proc, :unless_proc
|
||||
|
||||
def if_proc?(action)
|
||||
if_proc.nil? || if_proc.call(action)
|
||||
end
|
||||
|
||||
def unless_proc?(action)
|
||||
unless_proc.present? && unless_proc.call(action)
|
||||
end
|
||||
|
||||
def included?(action)
|
||||
only.nil? || only.include?(action)
|
||||
end
|
||||
|
||||
def excluded?(action)
|
||||
except.present? && except.include?(action)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -45,6 +45,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
|
|||
|
||||
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
|
||||
|
||||
feature_category :source_code_management,
|
||||
unless: -> (action) { action.ends_with?("_reports") }
|
||||
feature_category :code_testing,
|
||||
only: [:test_reports, :coverage_reports, :terraform_reports]
|
||||
feature_category :accessibility_testing,
|
||||
only: [:accessibility_reports]
|
||||
|
||||
def index
|
||||
@merge_requests = @issuables
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ module AlertManagement
|
|||
return error_invalid_status unless status_key
|
||||
|
||||
if alert.update(status_event: status_event)
|
||||
resolve_todos if resolved?
|
||||
success
|
||||
else
|
||||
error(alert.errors.full_messages.to_sentence)
|
||||
|
@ -28,12 +29,16 @@ module AlertManagement
|
|||
|
||||
attr_reader :alert, :user, :status
|
||||
|
||||
delegate :project, to: :alert
|
||||
delegate :project, :resolved?, to: :alert
|
||||
|
||||
def allowed?
|
||||
user.can?(:update_alert_management_alert, project)
|
||||
end
|
||||
|
||||
def resolve_todos
|
||||
TodoService.new.resolve_todos_for_target(alert, user)
|
||||
end
|
||||
|
||||
def status_key
|
||||
strong_memoize(:status_key) do
|
||||
AlertManagement::Alert::STATUSES.key(status)
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
module WorkerAttributes
|
||||
extend ActiveSupport::Concern
|
||||
include Gitlab::ClassAttributes
|
||||
|
||||
# Resource boundaries that workers can declare through the
|
||||
# `resource_boundary` attribute
|
||||
|
@ -30,24 +31,24 @@ module WorkerAttributes
|
|||
}.stringify_keys.freeze
|
||||
|
||||
class_methods do
|
||||
def feature_category(value)
|
||||
def feature_category(value, *extras)
|
||||
raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned
|
||||
|
||||
worker_attributes[:feature_category] = value
|
||||
class_attributes[:feature_category] = value
|
||||
end
|
||||
|
||||
# Special case: mark this work as not associated with a feature category
|
||||
# this should be used for cross-cutting concerns, such as mailer workers.
|
||||
def feature_category_not_owned!
|
||||
worker_attributes[:feature_category] = :not_owned
|
||||
class_attributes[:feature_category] = :not_owned
|
||||
end
|
||||
|
||||
def get_feature_category
|
||||
get_worker_attribute(:feature_category)
|
||||
get_class_attribute(:feature_category)
|
||||
end
|
||||
|
||||
def feature_category_not_owned?
|
||||
get_worker_attribute(:feature_category) == :not_owned
|
||||
get_feature_category == :not_owned
|
||||
end
|
||||
|
||||
# This should be set to :high for jobs that need to be run
|
||||
|
@ -61,11 +62,11 @@ module WorkerAttributes
|
|||
def urgency(urgency)
|
||||
raise "Invalid urgency: #{urgency}" unless VALID_URGENCIES.include?(urgency)
|
||||
|
||||
worker_attributes[:urgency] = urgency
|
||||
class_attributes[:urgency] = urgency
|
||||
end
|
||||
|
||||
def get_urgency
|
||||
worker_attributes[:urgency] || :low
|
||||
class_attributes[:urgency] || :low
|
||||
end
|
||||
|
||||
# Set this attribute on a job when it will call to services outside of the
|
||||
|
@ -73,85 +74,64 @@ module WorkerAttributes
|
|||
# doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies for
|
||||
# details
|
||||
def worker_has_external_dependencies!
|
||||
worker_attributes[:external_dependencies] = true
|
||||
class_attributes[:external_dependencies] = true
|
||||
end
|
||||
|
||||
# Returns a truthy value if the worker has external dependencies.
|
||||
# See doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies
|
||||
# for details
|
||||
def worker_has_external_dependencies?
|
||||
worker_attributes[:external_dependencies]
|
||||
class_attributes[:external_dependencies]
|
||||
end
|
||||
|
||||
def worker_resource_boundary(boundary)
|
||||
raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary
|
||||
|
||||
worker_attributes[:resource_boundary] = boundary
|
||||
class_attributes[:resource_boundary] = boundary
|
||||
end
|
||||
|
||||
def get_worker_resource_boundary
|
||||
worker_attributes[:resource_boundary] || :unknown
|
||||
class_attributes[:resource_boundary] || :unknown
|
||||
end
|
||||
|
||||
def idempotent!
|
||||
worker_attributes[:idempotent] = true
|
||||
class_attributes[:idempotent] = true
|
||||
end
|
||||
|
||||
def idempotent?
|
||||
worker_attributes[:idempotent]
|
||||
class_attributes[:idempotent]
|
||||
end
|
||||
|
||||
def weight(value)
|
||||
worker_attributes[:weight] = value
|
||||
class_attributes[:weight] = value
|
||||
end
|
||||
|
||||
def get_weight
|
||||
worker_attributes[:weight] ||
|
||||
class_attributes[:weight] ||
|
||||
NAMESPACE_WEIGHTS[queue_namespace] ||
|
||||
1
|
||||
end
|
||||
|
||||
def tags(*values)
|
||||
worker_attributes[:tags] = values
|
||||
class_attributes[:tags] = values
|
||||
end
|
||||
|
||||
def get_tags
|
||||
Array(worker_attributes[:tags])
|
||||
Array(class_attributes[:tags])
|
||||
end
|
||||
|
||||
def deduplicate(strategy, options = {})
|
||||
worker_attributes[:deduplication_strategy] = strategy
|
||||
worker_attributes[:deduplication_options] = options
|
||||
class_attributes[:deduplication_strategy] = strategy
|
||||
class_attributes[:deduplication_options] = options
|
||||
end
|
||||
|
||||
def get_deduplicate_strategy
|
||||
worker_attributes[:deduplication_strategy] ||
|
||||
class_attributes[:deduplication_strategy] ||
|
||||
Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DEFAULT_STRATEGY
|
||||
end
|
||||
|
||||
def get_deduplication_options
|
||||
worker_attributes[:deduplication_options] || {}
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# Returns a worker attribute declared on this class or its parent class.
|
||||
# This approach allows declared attributes to be inherited by
|
||||
# child classes.
|
||||
def get_worker_attribute(name)
|
||||
worker_attributes[name] || superclass_worker_attributes(name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def worker_attributes
|
||||
@attributes ||= {}
|
||||
end
|
||||
|
||||
def superclass_worker_attributes(name)
|
||||
return unless superclass.include? WorkerAttributes
|
||||
|
||||
superclass.get_worker_attribute(name)
|
||||
class_attributes[:deduplication_options] || {}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Resolve user's todo when an alert is resolved
|
||||
merge_request: 35700
|
||||
author:
|
||||
type: added
|
5
changelogs/unreleased/225295-fix-missing-avatar.yml
Normal file
5
changelogs/unreleased/225295-fix-missing-avatar.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix missing avatar in MR widget
|
||||
merge_request: 36034
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add keyboard shortcuts to metrics dashboard
|
||||
merge_request: 32804
|
||||
author:
|
||||
type: added
|
|
@ -16,6 +16,28 @@ This section contains a journal of recent validation tests and links to the rele
|
|||
|
||||
The following are GitLab upgrade validation tests we performed.
|
||||
|
||||
### June 2020
|
||||
|
||||
[Upgrade Geo multi-server installation](https://gitlab.com/gitlab-org/gitlab/-/issues/223284):
|
||||
|
||||
- Description: Tested upgrading from GitLab 12.9.10 to 12.10.12 package in a multi-server
|
||||
configuration. Monitored for downtime using the looping pipeline and HAProxy stats dashboards.
|
||||
- Outcome: Partial success because we observed downtime during the upgrade of the primary and secondary sites.
|
||||
- Follow up issues/actions:
|
||||
- [Fix zero-downtime upgrade process/instructions for multi-node Geo deployments](https://gitlab.com/gitlab-org/gitlab/-/issues/225684)
|
||||
- [Geo:check Rake task: Exclude AuthorizedKeysCommand check if node not running Puma/Unicorn](https://gitlab.com/gitlab-org/gitlab/-/issues/225454)
|
||||
- [Update instructions in the next upgrade issue to include monitoring HAProxy dashboards](https://gitlab.com/gitlab-org/gitlab/-/issues/225359)
|
||||
|
||||
[Upgrade Geo multi-server installation](https://gitlab.com/gitlab-org/gitlab/-/issues/208104):
|
||||
|
||||
- Description: Tested upgrading from GitLab 12.8.1 to 12.9.10 package in a multi-server
|
||||
configuration.
|
||||
- Outcome: Partial success because we did not run the looping pipeline during the demo to validate
|
||||
zero-downtime.
|
||||
- Follow up issues:
|
||||
- [Clarify hup Puma/Unicorn should include deploy node](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5460)
|
||||
- [Investigate MR creation failure after upgrade to 12.9.10](https://gitlab.com/gitlab-org/gitlab/-/issues/223282) Closed as false positive.
|
||||
|
||||
### February 2020
|
||||
|
||||
[Upgrade Geo multi-node installation](https://gitlab.com/gitlab-org/gitlab/-/issues/201837):
|
||||
|
|
|
@ -11799,6 +11799,11 @@ type SecurityReportSummarySection {
|
|||
"""
|
||||
scannedResourcesCount: Int
|
||||
|
||||
"""
|
||||
Path to download all the scanned resources in CSV format
|
||||
"""
|
||||
scannedResourcesCsvPath: String
|
||||
|
||||
"""
|
||||
Total number of vulnerabilities
|
||||
"""
|
||||
|
|
|
@ -34498,6 +34498,20 @@
|
|||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "scannedResourcesCsvPath",
|
||||
"description": "Path to download all the scanned resources in CSV format",
|
||||
"args": [
|
||||
|
||||
],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "vulnerabilitiesCount",
|
||||
"description": "Total number of vulnerabilities",
|
||||
|
|
|
@ -1701,6 +1701,7 @@ Represents a section of a summary of a security report
|
|||
| Name | Type | Description |
|
||||
| --- | ---- | ---------- |
|
||||
| `scannedResourcesCount` | Int | Total number of scanned resources |
|
||||
| `scannedResourcesCsvPath` | String | Path to download all the scanned resources in CSV format |
|
||||
| `vulnerabilitiesCount` | Int | Total number of vulnerabilities |
|
||||
|
||||
## SentryDetailedError
|
||||
|
|
|
@ -33,7 +33,7 @@ Kubernetes-specific environment variables are detailed in the
|
|||
| `CI` | all | 0.4 | Mark that job is executed in CI environment |
|
||||
| `CI_API_V4_URL` | 11.7 | all | The GitLab API v4 root URL |
|
||||
| `CI_BUILDS_DIR` | all | 11.10 | Top-level directory where builds are executed. |
|
||||
| `CI_COMMIT_BEFORE_SHA` | 11.2 | all | The previous latest commit present on a branch before a merge request. Only populated when there is a merge request associated with the pipeline. |
|
||||
| `CI_COMMIT_BEFORE_SHA` | 11.2 | all | The previous latest commit present on a branch. Is always `0000000000000000000000000000000000000000` in pipelines for merge requests. |
|
||||
| `CI_COMMIT_DESCRIPTION` | 10.8 | all | The description of the commit: the message without first line, if the title is shorter than 100 characters; full message in other case. |
|
||||
| `CI_COMMIT_MESSAGE` | 10.8 | all | The full commit message. |
|
||||
| `CI_COMMIT_REF_NAME` | 9.0 | all | The branch or tag name for which project is built |
|
||||
|
@ -71,8 +71,8 @@ Kubernetes-specific environment variables are detailed in the
|
|||
| `CI_JOB_URL` | 11.1 | 0.5 | Job details URL |
|
||||
| `CI_KUBERNETES_ACTIVE` | 13.0 | all | Included with the value `true` only if the pipeline has a Kubernetes cluster available for deployments. Not included if no cluster is available. Can be used as an alternative to [`only:kubernetes`/`except:kubernetes`](../yaml/README.md#onlykubernetesexceptkubernetes) with [`rules:if`](../yaml/README.md#rulesif) |
|
||||
| `CI_MERGE_REQUEST_ASSIGNEES` | 11.9 | all | Comma-separated list of username(s) of assignee(s) for the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_ID` | 11.6 | all | The ID of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_IID` | 11.6 | all | The IID of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_ID` | 11.6 | all | The project-level ID of the merge request. Only available if [the pipelines are for merge requests](../merge_request_pipelines/index.md) and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_IID` | 11.6 | all | The instance-level IID of the merge request. Only available If [the pipelines are for merge requests](../merge_request_pipelines/index.md) and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_LABELS` | 11.9 | all | Comma-separated label names of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_MILESTONE` | 11.9 | all | The milestone title of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the merge request is created. |
|
||||
| `CI_MERGE_REQUEST_PROJECT_ID` | 11.6 | all | The ID of the project of the merge request if [the pipelines are for merge requests](../merge_request_pipelines/index.md). Available only if `only: [merge_requests]` or [`rules`](../yaml/README.md#rules) syntax is used and the merge request is created. |
|
||||
|
@ -94,7 +94,7 @@ Kubernetes-specific environment variables are detailed in the
|
|||
| `CI_PAGES_URL` | 11.8 | all | URL to GitLab Pages-built pages. Always belongs to a subdomain of `CI_PAGES_DOMAIN`. |
|
||||
| `CI_PIPELINE_ID` | 8.10 | all | The unique ID of the current pipeline that GitLab CI/CD uses internally |
|
||||
| `CI_PIPELINE_IID` | 11.0 | all | The unique ID of the current pipeline scoped to project |
|
||||
| `CI_PIPELINE_SOURCE` | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `trigger`, `schedule`, `api`, `external`, `pipeline` (renamed to `cross_project_pipeline` since 13.0), `chat`, `webide`, `merge_request_event`, `external_pull_request_event`, and `parent_pipeline`. For pipelines created before GitLab 9.5, this will show as `unknown` |
|
||||
| `CI_PIPELINE_SOURCE` | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `schedule`, `api`, `external`, `chat`, `webide`, `merge_request_event`, `external_pull_request_event`, `parent_pipeline`, [`trigger`, or `pipeline`](../triggers/README.md#authentication-tokens) (renamed to `cross_project_pipeline` since 13.0). For pipelines created before GitLab 9.5, this will show as `unknown`. |
|
||||
| `CI_PIPELINE_TRIGGERED` | all | all | The flag to indicate that job was [triggered](../triggers/README.md) |
|
||||
| `CI_PIPELINE_URL` | 11.1 | 0.5 | Pipeline details URL |
|
||||
| `CI_PROJECT_DIR` | all | all | The full path where the repository is cloned and where the job is run. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see [Advanced configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) for GitLab Runner. |
|
||||
|
|
|
@ -1341,7 +1341,7 @@ docker build:
|
|||
script: docker build -t my-image:$CI_COMMIT_REF_SLUG .
|
||||
rules:
|
||||
- changes:
|
||||
- Dockerfile
|
||||
- Dockerfile
|
||||
when: manual
|
||||
allow_failure: true
|
||||
```
|
||||
|
@ -1367,7 +1367,7 @@ job:
|
|||
script: docker build -t my-image:$CI_COMMIT_REF_SLUG .
|
||||
rules:
|
||||
- exists:
|
||||
- Dockerfile
|
||||
- Dockerfile
|
||||
```
|
||||
|
||||
You can also use glob patterns to match multiple files in any directory within
|
||||
|
@ -1380,7 +1380,7 @@ job:
|
|||
script: bundle exec rspec
|
||||
rules:
|
||||
- exists:
|
||||
- spec/**.rb
|
||||
- spec/**.rb
|
||||
```
|
||||
|
||||
NOTE: **Note:**
|
||||
|
@ -1427,8 +1427,8 @@ docker build:
|
|||
rules:
|
||||
- if: '$VAR == "string value"'
|
||||
changes: # Will include the job and set to when:manual if any of the follow paths match a modified file.
|
||||
- Dockerfile
|
||||
- docker/scripts/*
|
||||
- Dockerfile
|
||||
- docker/scripts/*
|
||||
when: manual
|
||||
# - when: never would be redundant here, this is implied any time rules are listed.
|
||||
```
|
||||
|
|
BIN
doc/user/application_security/dast/img/dast_on_demand_v13_2.png
Normal file
BIN
doc/user/application_security/dast/img/dast_on_demand_v13_2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
|
@ -44,10 +44,10 @@ see the details and the URL(s) affected.
|
|||
![DAST Widget Clicked](img/dast_single_v13_0.png)
|
||||
|
||||
[Dynamic Application Security Testing (DAST)](https://en.wikipedia.org/wiki/Dynamic_Application_Security_Testing)
|
||||
uses the popular open source tool [OWASP ZAProxy](https://github.com/zaproxy/zaproxy)
|
||||
uses the popular open source tool [OWASP Zed Attack Proxy](https://www.zaproxy.org/)
|
||||
to perform an analysis on your running web application.
|
||||
|
||||
By default, DAST executes [ZAP Baseline Scan](https://github.com/zaproxy/zaproxy/wiki/ZAP-Baseline-Scan)
|
||||
By default, DAST executes [ZAP Baseline Scan](https://www.zaproxy.org/docs/docker/baseline-scan/)
|
||||
and performs passive scanning only. It won't actively attack your application.
|
||||
However, DAST can be [configured](#full-scan)
|
||||
to also perform an *active scan*: attack your application and produce a more extensive security report.
|
||||
|
@ -599,6 +599,44 @@ security reports without requiring internet access.
|
|||
|
||||
Alternatively, you can use the variable `SECURE_ANALYZERS_PREFIX` to override the base registry address of the `dast` image.
|
||||
|
||||
## On-Demand Scans
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/218465) in GitLab 13.2.
|
||||
> - It's deployed behind a feature flag, disabled by default.
|
||||
> - It's disabled on GitLab.com.
|
||||
> - It's able to be enabled or disabled per-project.
|
||||
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-on-demand-scans).
|
||||
|
||||
Passive DAST scans may be run on demand against a target website, outside the DevOps lifecycle. These scans will
|
||||
always be associated with the default or `master` branch of your project and the results can be seen in the project dashboard.
|
||||
|
||||
![DAST On-Demand Scan](img/dast_on_demand_v13_2.png)
|
||||
|
||||
### Enable or disable On-Demand Scans
|
||||
|
||||
On-Demand Scans is under development and not ready for production use. It is
|
||||
deployed behind a feature flag that is **disabled by default**.
|
||||
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
|
||||
can enable it for your instance. On-Demand Scans can be enabled or disabled per-project
|
||||
|
||||
To enable it:
|
||||
|
||||
```ruby
|
||||
# Instance-wide
|
||||
Feature.enable(:security_on_demand_scans_feature_flag)
|
||||
# or by project
|
||||
Feature.enable(:security_on_demand_scans_feature_flag, Project.find(<project id>))
|
||||
```
|
||||
|
||||
To disable it:
|
||||
|
||||
```ruby
|
||||
# Instance-wide
|
||||
Feature.disable(:security_on_demand_scans_feature_flag)
|
||||
# or by project
|
||||
Feature.disable(:security_on_demand_scans_feature_flag, Project.find(<project id>))
|
||||
```
|
||||
|
||||
## Reports
|
||||
|
||||
The DAST tool outputs a report file in JSON format by default. However, this tool can also generate reports in
|
||||
|
|
30
lib/gitlab/class_attributes.rb
Normal file
30
lib/gitlab/class_attributes.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module ClassAttributes
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
protected
|
||||
|
||||
# Returns an attribute declared on this class or its parent class.
|
||||
# This approach allows declared attributes to be inherited by
|
||||
# child classes.
|
||||
def get_class_attribute(name)
|
||||
class_attributes[name] || superclass_attributes(name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_attributes
|
||||
@class_attributes ||= {}
|
||||
end
|
||||
|
||||
def superclass_attributes(name)
|
||||
return unless superclass.include? Gitlab::ClassAttributes
|
||||
|
||||
superclass.get_class_attribute(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -32,6 +32,10 @@ module Gitlab
|
|||
|
||||
action = "#{controller.action_name}"
|
||||
|
||||
# Try to get the feature category, but don't fail when the controller is
|
||||
# not an ApplicationController.
|
||||
feature_category = controller.class.try(:feature_category_for_action, action).to_s
|
||||
|
||||
# Devise exposes a method called "request_format" that does the below.
|
||||
# However, this method is not available to all controllers (e.g. certain
|
||||
# Doorkeeper controllers). As such we use the underlying code directly.
|
||||
|
@ -45,7 +49,7 @@ module Gitlab
|
|||
action = "#{action}.#{suffix}"
|
||||
end
|
||||
|
||||
{ controller: controller.class.name, action: action }
|
||||
{ controller: controller.class.name, action: action, feature_category: feature_category }
|
||||
end
|
||||
|
||||
def labels_from_endpoint
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "fast_spec_helper"
|
||||
require "rspec-parameterized"
|
||||
require_relative "../../../../app/controllers/concerns/controller_with_feature_category/config"
|
||||
|
||||
RSpec.describe ControllerWithFeatureCategory::Config do
|
||||
describe "#matches?" do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
where(:only_actions, :except_actions, :if_proc, :unless_proc, :test_action, :expected) do
|
||||
nil | nil | nil | nil | "action" | true
|
||||
[:included] | nil | nil | nil | "action" | false
|
||||
[:included] | nil | nil | nil | "included" | true
|
||||
nil | [:excluded] | nil | nil | "excluded" | false
|
||||
nil | nil | true | nil | "action" | true
|
||||
[:included] | nil | true | nil | "action" | false
|
||||
[:included] | nil | true | nil | "included" | true
|
||||
nil | [:excluded] | true | nil | "excluded" | false
|
||||
nil | nil | false | nil | "action" | false
|
||||
[:included] | nil | false | nil | "action" | false
|
||||
[:included] | nil | false | nil | "included" | false
|
||||
nil | [:excluded] | false | nil | "excluded" | false
|
||||
nil | nil | nil | true | "action" | false
|
||||
[:included] | nil | nil | true | "action" | false
|
||||
[:included] | nil | nil | true | "included" | false
|
||||
nil | [:excluded] | nil | true | "excluded" | false
|
||||
nil | nil | nil | false | "action" | true
|
||||
[:included] | nil | nil | false | "action" | false
|
||||
[:included] | nil | nil | false | "included" | true
|
||||
nil | [:excluded] | nil | false | "excluded" | false
|
||||
nil | nil | true | false | "action" | true
|
||||
[:included] | nil | true | false | "action" | false
|
||||
[:included] | nil | true | false | "included" | true
|
||||
nil | [:excluded] | true | false | "excluded" | false
|
||||
nil | nil | false | true | "action" | false
|
||||
[:included] | nil | false | true | "action" | false
|
||||
[:included] | nil | false | true | "included" | false
|
||||
nil | [:excluded] | false | true | "excluded" | false
|
||||
end
|
||||
|
||||
with_them do
|
||||
let(:config) do
|
||||
if_to_proc = if_proc.nil? ? nil : -> (_) { if_proc }
|
||||
unless_to_proc = unless_proc.nil? ? nil : -> (_) { unless_proc }
|
||||
|
||||
described_class.new(:category, only_actions, except_actions, if_to_proc, unless_to_proc)
|
||||
end
|
||||
|
||||
specify { expect(config.matches?(test_action)).to be(expected) }
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,66 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fast_spec_helper'
|
||||
require_relative "../../../app/controllers/concerns/controller_with_feature_category"
|
||||
require_relative "../../../app/controllers/concerns/controller_with_feature_category/config"
|
||||
|
||||
RSpec.describe ControllerWithFeatureCategory do
|
||||
describe ".feature_category_for_action" do
|
||||
let(:base_controller) do
|
||||
Class.new do
|
||||
include ControllerWithFeatureCategory
|
||||
end
|
||||
end
|
||||
|
||||
let(:controller) do
|
||||
Class.new(base_controller) do
|
||||
feature_category :baz
|
||||
feature_category :foo, except: %w(update edit)
|
||||
feature_category :bar, only: %w(index show)
|
||||
feature_category :quux, only: %w(destroy)
|
||||
feature_category :quuz, only: %w(destroy)
|
||||
end
|
||||
end
|
||||
|
||||
let(:subclass) do
|
||||
Class.new(controller) do
|
||||
feature_category :qux, only: %w(index)
|
||||
end
|
||||
end
|
||||
|
||||
it "is nil when nothing was defined" do
|
||||
expect(base_controller.feature_category_for_action("hello")).to be_nil
|
||||
end
|
||||
|
||||
it "returns the expected category", :aggregate_failures do
|
||||
expect(controller.feature_category_for_action("update")).to eq(:baz)
|
||||
expect(controller.feature_category_for_action("hello")).to eq(:foo)
|
||||
expect(controller.feature_category_for_action("index")).to eq(:bar)
|
||||
end
|
||||
|
||||
it "returns the closest match for categories defined in subclasses" do
|
||||
expect(subclass.feature_category_for_action("index")).to eq(:qux)
|
||||
expect(subclass.feature_category_for_action("show")).to eq(:bar)
|
||||
end
|
||||
|
||||
it "returns the last defined feature category when multiple match" do
|
||||
expect(controller.feature_category_for_action("destroy")).to eq(:quuz)
|
||||
end
|
||||
|
||||
it "raises an error when using including and excluding the same action" do
|
||||
expect do
|
||||
Class.new(base_controller) do
|
||||
feature_category :hello, only: [:world], except: [:world]
|
||||
end
|
||||
end.to raise_error(%r(cannot configure both `only` and `except`))
|
||||
end
|
||||
|
||||
it "raises an error when using unknown arguments" do
|
||||
expect do
|
||||
Class.new(base_controller) do
|
||||
feature_category :hello, hello: :world
|
||||
end
|
||||
end.to raise_error(%r(unknown arguments))
|
||||
end
|
||||
end
|
||||
end
|
82
spec/controllers/every_controller_spec.rb
Normal file
82
spec/controllers/every_controller_spec.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe "Every controller" do
|
||||
context "feature categories" do
|
||||
let_it_be(:feature_categories) do
|
||||
YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:to_sym).to_set
|
||||
end
|
||||
|
||||
let_it_be(:controller_actions) do
|
||||
# This will return tuples of all controller actions defined in the routes
|
||||
# Only for controllers inheriting ApplicationController
|
||||
# Excluding controllers from gems (OAuth, Sidekiq)
|
||||
Rails.application.routes.routes
|
||||
.map { |route| route.required_defaults.presence }
|
||||
.compact
|
||||
.select { |route| route[:controller].present? && route[:action].present? }
|
||||
.map { |route| [constantize_controller(route[:controller]), route[:action]] }
|
||||
.reject { |route| route.first.nil? || !route.first.include?(ControllerWithFeatureCategory) }
|
||||
end
|
||||
|
||||
let_it_be(:routes_without_category) do
|
||||
controller_actions.map do |controller, action|
|
||||
"#{controller}##{action}" unless controller.feature_category_for_action(action)
|
||||
end.compact
|
||||
end
|
||||
|
||||
it "has feature categories" do
|
||||
pending("We'll work on defining categories for all controllers: "\
|
||||
"https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/463")
|
||||
|
||||
expect(routes_without_category).to be_empty, "#{routes_without_category.first(10)} did not have a category"
|
||||
end
|
||||
|
||||
it "completed controllers don't get new routes without categories" do
|
||||
completed_controllers = [Projects::MergeRequestsController].map(&:to_s)
|
||||
|
||||
newly_introduced_missing_category = routes_without_category.select do |route|
|
||||
completed_controllers.any? { |controller| route.start_with?(controller) }
|
||||
end
|
||||
|
||||
expect(newly_introduced_missing_category).to be_empty
|
||||
end
|
||||
|
||||
it "recognizes the feature categories" do
|
||||
routes_unknown_category = controller_actions.map do |controller, action|
|
||||
used_category = controller.feature_category_for_action(action)
|
||||
next unless used_category
|
||||
next if used_category == :not_owned
|
||||
|
||||
["#{controller}##{action}", used_category] unless feature_categories.include?(used_category)
|
||||
end.compact
|
||||
|
||||
expect(routes_unknown_category).to be_empty, "#{routes_unknown_category.first(10)} had an unknown category"
|
||||
end
|
||||
|
||||
it "doesn't define or exclude categories on removed actions", :aggregate_failures do
|
||||
controller_actions.group_by(&:first).each do |controller, controller_action|
|
||||
existing_actions = controller_action.map(&:last)
|
||||
used_actions = actions_defined_in_feature_category_config(controller)
|
||||
non_existing_used_actions = used_actions - existing_actions
|
||||
|
||||
expect(non_existing_used_actions).to be_empty,
|
||||
"#{controller} used #{non_existing_used_actions} to define feature category, but the route does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def constantize_controller(name)
|
||||
"#{name.camelize}Controller".constantize
|
||||
rescue NameError
|
||||
nil # some controllers, like the omniauth ones are dynamic
|
||||
end
|
||||
|
||||
def actions_defined_in_feature_category_config(controller)
|
||||
feature_category_configs = controller.send(:class_attributes)[:feature_category_config]
|
||||
feature_category_configs.map do |config|
|
||||
Array(config.send(:only)) + Array(config.send(:except))
|
||||
end.flatten.uniq.map(&:to_s)
|
||||
end
|
||||
end
|
|
@ -1168,6 +1168,34 @@ describe('Dashboard', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('keyboard shortcuts', () => {
|
||||
const currentDashboard = dashboardGitResponse[1].path;
|
||||
const panelRef = 'dashboard-panel-response-metrics-aws-elb-4-1'; // skip expanded panel
|
||||
|
||||
// While the recommendation in the documentation is to test
|
||||
// with a data-testid attribute, I want to make sure that
|
||||
// the dashboard panels have a ref attribute set.
|
||||
const getDashboardPanel = () => wrapper.find({ ref: panelRef });
|
||||
|
||||
beforeEach(() => {
|
||||
setupStoreWithData(store);
|
||||
store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, {
|
||||
currentDashboard,
|
||||
});
|
||||
createShallowWrapper({ hasMetrics: true });
|
||||
|
||||
wrapper.setData({ hoveredPanel: panelRef });
|
||||
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
it('contains a ref attribute inside a DashboardPanel component', () => {
|
||||
const dashboardPanel = getDashboardPanel();
|
||||
|
||||
expect(dashboardPanel.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add custom metrics', () => {
|
||||
const findAddMetricButton = () => wrapper.find(DashboardHeader).find({ ref: 'addMetricBtn' });
|
||||
|
||||
|
|
|
@ -1,39 +1,61 @@
|
|||
import Vue from 'vue';
|
||||
import mountComponent from 'helpers/vue_mount_component_helper';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue';
|
||||
|
||||
window.gl = window.gl || {};
|
||||
|
||||
describe('MrWidgetAuthor', () => {
|
||||
let vm;
|
||||
let wrapper;
|
||||
let oldWindowGl;
|
||||
const mockAuthor = {
|
||||
name: 'Administrator',
|
||||
username: 'root',
|
||||
webUrl: 'http://localhost:3000/root',
|
||||
avatarUrl: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const Component = Vue.extend(MrWidgetAuthor);
|
||||
|
||||
vm = mountComponent(Component, {
|
||||
author: {
|
||||
name: 'Administrator',
|
||||
username: 'root',
|
||||
webUrl: 'http://localhost:3000/root',
|
||||
avatarUrl:
|
||||
'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
|
||||
oldWindowGl = window.gl;
|
||||
window.gl = {
|
||||
mrWidgetData: {
|
||||
defaultAvatarUrl: 'no_avatar.png',
|
||||
},
|
||||
};
|
||||
wrapper = shallowMount(MrWidgetAuthor, {
|
||||
propsData: {
|
||||
author: mockAuthor,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
wrapper.destroy();
|
||||
window.gl = oldWindowGl;
|
||||
});
|
||||
|
||||
it('renders link with the author web url', () => {
|
||||
expect(vm.$el.getAttribute('href')).toEqual('http://localhost:3000/root');
|
||||
expect(wrapper.attributes('href')).toBe('http://localhost:3000/root');
|
||||
});
|
||||
|
||||
it('renders image with avatar url', () => {
|
||||
expect(vm.$el.querySelector('img').getAttribute('src')).toEqual(
|
||||
expect(wrapper.find('img').attributes('src')).toBe(
|
||||
'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders image with default avatar url when no avatarUrl is present in author', async () => {
|
||||
wrapper.setProps({
|
||||
author: {
|
||||
...mockAuthor,
|
||||
avatarUrl: null,
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.find('img').attributes('src')).toBe('no_avatar.png');
|
||||
});
|
||||
|
||||
it('renders author name', () => {
|
||||
expect(vm.$el.textContent.trim()).toEqual('Administrator');
|
||||
expect(wrapper.find('span').text()).toBe('Administrator');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@ import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
|
|||
|
||||
describe('MRWidgetAutoMergeEnabled', () => {
|
||||
let vm;
|
||||
let oldWindowGl;
|
||||
const targetBranchPath = '/foo/bar';
|
||||
const targetBranch = 'foo';
|
||||
const sha = '1EA2EZ34';
|
||||
|
@ -16,6 +17,13 @@ describe('MRWidgetAutoMergeEnabled', () => {
|
|||
const Component = Vue.extend(autoMergeEnabledComponent);
|
||||
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
|
||||
|
||||
oldWindowGl = window.gl;
|
||||
window.gl = {
|
||||
mrWidgetData: {
|
||||
defaultAvatarUrl: 'no_avatar.png',
|
||||
},
|
||||
};
|
||||
|
||||
vm = mountComponent(Component, {
|
||||
mr: {
|
||||
shouldRemoveSourceBranch: false,
|
||||
|
@ -35,6 +43,7 @@ describe('MRWidgetAutoMergeEnabled', () => {
|
|||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
window.gl = oldWindowGl;
|
||||
});
|
||||
|
||||
describe('computed', () => {
|
||||
|
|
41
spec/lib/gitlab/class_attributes_spec.rb
Normal file
41
spec/lib/gitlab/class_attributes_spec.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
require 'fast_spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::ClassAttributes do
|
||||
let(:klass) do
|
||||
Class.new do
|
||||
include Gitlab::ClassAttributes
|
||||
|
||||
def self.get_attribute(name)
|
||||
get_class_attribute(name)
|
||||
end
|
||||
|
||||
def self.set_attribute(name, value)
|
||||
class_attributes[name] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:subclass) { Class.new(klass) }
|
||||
|
||||
describe ".get_class_attribute" do
|
||||
it "returns values set on the class" do
|
||||
klass.set_attribute(:foo, :bar)
|
||||
|
||||
expect(klass.get_attribute(:foo)).to eq(:bar)
|
||||
end
|
||||
|
||||
it "returns values set on a superclass" do
|
||||
klass.set_attribute(:foo, :bar)
|
||||
|
||||
expect(subclass.get_attribute(:foo)).to eq(:bar)
|
||||
end
|
||||
|
||||
it "returns values from the subclass over attributes from a superclass" do
|
||||
klass.set_attribute(:foo, :baz)
|
||||
subclass.set_attribute(:foo, :bar)
|
||||
|
||||
expect(subclass.get_attribute(:foo)).to eq(:bar)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -93,23 +93,23 @@ RSpec.describe Gitlab::Metrics::WebTransaction do
|
|||
|
||||
context 'when request goes to ActionController' do
|
||||
let(:request) { double(:request, format: double(:format, ref: :html)) }
|
||||
let(:controller_class) { double(:controller_class, name: 'TestController') }
|
||||
|
||||
before do
|
||||
klass = double(:klass, name: 'TestController')
|
||||
controller = double(:controller, class: klass, action_name: 'show', request: request)
|
||||
controller = double(:controller, class: controller_class, action_name: 'show', request: request)
|
||||
|
||||
env['action_controller.instance'] = controller
|
||||
end
|
||||
|
||||
it 'tags a transaction with the name and action of a controller' do
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show' })
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: '' })
|
||||
end
|
||||
|
||||
context 'when the request content type is not :html' do
|
||||
let(:request) { double(:request, format: double(:format, ref: :json)) }
|
||||
|
||||
it 'appends the mime type to the transaction action' do
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show.json' })
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show.json', feature_category: '' })
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -117,7 +117,14 @@ RSpec.describe Gitlab::Metrics::WebTransaction do
|
|||
let(:request) { double(:request, format: double(:format, ref: 'http://example.com')) }
|
||||
|
||||
it 'does not append the MIME type to the transaction action' do
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show' })
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: '' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the feature category is known' do
|
||||
it 'includes it in the feature category label' do
|
||||
expect(controller_class).to receive(:feature_category_for_action).with('show').and_return(:source_code_management)
|
||||
expect(transaction.labels).to eq({ controller: 'TestController', action: 'show', feature_category: "source_code_management" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -227,7 +227,8 @@ RSpec.describe API::Repositories do
|
|||
end
|
||||
|
||||
describe "GET /projects/:id/repository/archive(.:format)?:sha" do
|
||||
let(:route) { "/projects/#{project.id}/repository/archive" }
|
||||
let(:project_id) { CGI.escape(project.full_path) }
|
||||
let(:route) { "/projects/#{project_id}/repository/archive" }
|
||||
|
||||
before do
|
||||
allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(false)
|
||||
|
@ -246,7 +247,7 @@ RSpec.describe API::Repositories do
|
|||
end
|
||||
|
||||
it 'returns the repository archive archive.zip' do
|
||||
get api("/projects/#{project.id}/repository/archive.zip", user)
|
||||
get api("/projects/#{project_id}/repository/archive.zip", user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
|
||||
|
@ -257,7 +258,7 @@ RSpec.describe API::Repositories do
|
|||
end
|
||||
|
||||
it 'returns the repository archive archive.tar.bz2' do
|
||||
get api("/projects/#{project.id}/repository/archive.tar.bz2", user)
|
||||
get api("/projects/#{project_id}/repository/archive.tar.bz2", user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:ok)
|
||||
|
||||
|
@ -277,7 +278,7 @@ RSpec.describe API::Repositories do
|
|||
it 'rate limits user when thresholds hit' do
|
||||
allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?).and_return(true)
|
||||
|
||||
get api("/projects/#{project.id}/repository/archive.tar.bz2", user)
|
||||
get api("/projects/#{project_id}/repository/archive.tar.bz2", user)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:too_many_requests)
|
||||
end
|
||||
|
@ -302,6 +303,13 @@ RSpec.describe API::Repositories do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when unauthenticated and project path has dots' do
|
||||
it_behaves_like 'repository archive' do
|
||||
let(:project) { create(:project, :public, :repository, path: 'path.with.dot') }
|
||||
let(:current_user) { nil }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when unauthenticated', 'and project is private' do
|
||||
it_behaves_like '404 response' do
|
||||
let(:request) { get api(route) }
|
||||
|
|
|
@ -25,7 +25,7 @@ RSpec.describe AlertManagement::UpdateAlertStatusService do
|
|||
end
|
||||
end
|
||||
|
||||
let(:new_status) { Types::AlertManagement::StatusEnum.values['ACKNOWLEDGED'].value }
|
||||
let(:new_status) { AlertManagement::Alert::STATUSES[:acknowledged] }
|
||||
let(:can_update) { true }
|
||||
|
||||
subject(:response) { service.execute }
|
||||
|
@ -45,6 +45,23 @@ RSpec.describe AlertManagement::UpdateAlertStatusService do
|
|||
expect { response }.to change { alert.acknowledged? }.to(true)
|
||||
end
|
||||
|
||||
context 'resolving status' do
|
||||
let(:new_status) { AlertManagement::Alert::STATUSES[:resolved] }
|
||||
|
||||
it 'updates the status' do
|
||||
expect { response }.to change { alert.resolved? }.to(true)
|
||||
end
|
||||
|
||||
context 'user has a pending todo' do
|
||||
let(:user) { create(:user) }
|
||||
let!(:todo) { create(:todo, :pending, target: alert, user: user, project: alert.project) }
|
||||
|
||||
it 'resolves the todo' do
|
||||
expect { response }.to change { todo.reload.state }.from('pending').to('done')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no permissions' do
|
||||
let(:can_update) { false }
|
||||
|
||||
|
|
Loading…
Reference in a new issue