Skip to content
Snippets Groups Projects
Commit 5afd8575 authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent 8bda404e
No related branches found
No related tags found
No related merge requests found
Showing
with 483 additions and 310 deletions
const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length));
export default class PasteMarkdownTable {
constructor(clipboardData) {
this.data = clipboardData;
this.columnWidths = [];
this.rows = [];
this.tableFound = this.parseTable();
}
isTable() {
return this.tableFound;
}
 
static maxColumnWidth(rows, columnIndex) {
return Math.max.apply(null, rows.map(row => row[columnIndex].length));
convertToTableMarkdown() {
this.calculateColumnWidths();
const markdownRows = this.rows.map(
row =>
// | Name | Title | Email Address |
// |--------------|-------|----------------|
// | Jane Atler | CEO | jane@acme.com |
// | John Doherty | CTO | john@acme.com |
// | Sally Smith | CFO | sally@acme.com |
`| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`,
);
// Insert a header break (e.g. -----) to the second row
markdownRows.splice(1, 0, this.generateHeaderBreak());
return markdownRows.join('\n');
}
 
// Private methods below
// To determine whether the cut data is a table, the following criteria
// must be satisfied with the clipboard data:
//
// 1. MIME types "text/plain" and "text/html" exist
// 2. The "text/html" data must have a single <table> element
static isTable(data) {
const types = new Set(data.types);
if (!types.has('text/html') || !types.has('text/plain')) {
// 3. The number of rows in the "text/plain" data matches that of the "text/html" data
// 4. The max number of columns in "text/plain" matches that of the "text/html" data
parseTable() {
if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) {
return false;
}
 
const htmlData = data.getData('text/html');
const doc = new DOMParser().parseFromString(htmlData, 'text/html');
const htmlData = this.data.getData('text/html');
this.doc = new DOMParser().parseFromString(htmlData, 'text/html');
const tables = this.doc.querySelectorAll('table');
 
// We're only looking for exactly one table. If there happens to be
// multiple tables, it's possible an application copied data into
// the clipboard that is not related to a simple table. It may also be
// complicated converting multiple tables into Markdown.
if (doc.querySelectorAll('table').length === 1) {
return true;
if (tables.length !== 1) {
return false;
}
 
return false;
}
convertToTableMarkdown() {
const text = this.data.getData('text/plain').trim();
this.rows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g).map(row => row.split('\t'));
this.normalizeRows();
this.calculateColumnWidths();
const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g);
 
const markdownRows = this.rows.map(
row =>
// | Name | Title | Email Address |
// |--------------|-------|----------------|
// | Jane Atler | CEO | jane@acme.com |
// | John Doherty | CTO | john@acme.com |
// | Sally Smith | CFO | sally@acme.com |
`| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`,
);
// Now check that the number of rows matches between HTML and text
if (this.doc.querySelectorAll('tr').length !== splitRows.length) {
return false;
}
 
// Insert a header break (e.g. -----) to the second row
markdownRows.splice(1, 0, this.generateHeaderBreak());
this.rows = splitRows.map(row => row.split('\t'));
this.normalizeRows();
 
return markdownRows.join('\n');
// Check that the max number of columns in the HTML matches the number of
// columns in the text. GitHub, for example, copies a line number and the
// line itself into the HTML data.
if (!this.columnCountsMatch()) {
return false;
}
return true;
}
 
// Ensure each row has the same number of columns
Loading
Loading
@@ -69,10 +92,21 @@ export default class PasteMarkdownTable {
 
calculateColumnWidths() {
this.columnWidths = this.rows[0].map((_column, columnIndex) =>
PasteMarkdownTable.maxColumnWidth(this.rows, columnIndex),
maxColumnWidth(this.rows, columnIndex),
);
}
 
columnCountsMatch() {
const textColumnCount = this.rows[0].length;
let htmlColumnCount = 0;
this.doc.querySelectorAll('table tr').forEach(row => {
htmlColumnCount = Math.max(row.cells.length, htmlColumnCount);
});
return textColumnCount === htmlColumnCount;
}
formatColumn(column, index) {
const spaces = Array(this.columnWidths[index] - column.length + 1).join(' ');
return column + spaces;
Loading
Loading
Loading
Loading
@@ -176,11 +176,11 @@ export default function dropzoneInput(form) {
const pasteEvent = event.originalEvent;
const { clipboardData } = pasteEvent;
if (clipboardData && clipboardData.items) {
const converter = new PasteMarkdownTable(clipboardData);
// Apple Numbers copies a table as an image, HTML, and text, so
// we need to check for the presence of a table first.
if (PasteMarkdownTable.isTable(clipboardData)) {
if (converter.isTable()) {
event.preventDefault();
const converter = new PasteMarkdownTable(clipboardData);
const text = converter.convertToTableMarkdown();
pasteText(text);
} else {
Loading
Loading
Loading
Loading
@@ -22,9 +22,11 @@ import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils';
import { getTimeDiff, getAddMetricTrackingOptions } from '../utils';
import { metricStates } from '../constants';
 
const defaultTimeDiff = getTimeDiff();
export default {
components: {
VueDraggable,
Loading
Loading
@@ -168,9 +170,10 @@ export default {
return {
state: 'gettingStarted',
formIsValid: null,
selectedTimeWindow: {},
isRearrangingPanels: false,
startDate: getParameterValues('start')[0] || defaultTimeDiff.start,
endDate: getParameterValues('end')[0] || defaultTimeDiff.end,
hasValidDates: true,
isRearrangingPanels: false,
};
},
computed: {
Loading
Loading
@@ -228,24 +231,10 @@ export default {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
} else {
const defaultRange = getTimeDiff();
const start = getParameterValues('start')[0] || defaultRange.start;
const end = getParameterValues('end')[0] || defaultRange.end;
const range = {
start,
end,
};
this.selectedTimeWindow = range;
if (!isValidDate(start) || !isValidDate(end)) {
this.hasValidDates = false;
this.showInvalidDateError();
} else {
this.hasValidDates = true;
this.fetchData(range);
}
this.fetchData({
start: this.startDate,
end: this.endDate,
});
}
},
methods: {
Loading
Loading
@@ -267,9 +256,20 @@ export default {
key,
});
},
showInvalidDateError() {
createFlash(s__('Metrics|Link contains an invalid time window.'));
onDateTimePickerApply(params) {
redirectTo(mergeUrlParams(params, window.location.href));
},
onDateTimePickerInvalid() {
createFlash(
s__(
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
),
);
this.startDate = defaultTimeDiff.start;
this.endDate = defaultTimeDiff.end;
},
generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
Loading
Loading
@@ -287,9 +287,6 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
},
/**
* Return a single empty state for a group.
*
Loading
Loading
@@ -378,15 +375,16 @@ export default {
</gl-form-group>
 
<gl-form-group
v-if="hasValidDates"
:label="s__('Metrics|Show last')"
label-size="sm"
label-for="monitor-time-window-dropdown"
class="col-sm-6 col-md-6 col-lg-4"
>
<date-time-picker
:selected-time-window="selectedTimeWindow"
@onApply="onDateTimePickerApply"
:start="startDate"
:end="endDate"
@apply="onDateTimePickerApply"
@invalid="onDateTimePickerInvalid"
/>
</gl-form-group>
</template>
Loading
Loading
Loading
Loading
@@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
getTimeDiff,
isValidDate,
getTimeWindow,
stringToISODate,
ISODateToString,
truncateZerosInDateTime,
isDateTimePickerInputValid,
} from '~/monitoring/utils';
import { timeWindows } from '~/monitoring/constants';
 
const events = {
apply: 'apply',
invalid: 'invalid',
};
export default {
components: {
Icon,
Loading
Loading
@@ -23,77 +30,94 @@ export default {
GlDropdownItem,
},
props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timeWindows: {
type: Object,
required: false,
default: () => timeWindows,
},
selectedTimeWindow: {
type: Object,
required: false,
default: () => {},
},
},
data() {
return {
selectedTimeWindowText: '',
customTime: {
from: null,
to: null,
},
startDate: this.start,
endDate: this.end,
};
},
computed: {
applyEnabled() {
return Boolean(this.inputState.from && this.inputState.to);
startInputValid() {
return isValidDate(this.startDate);
},
inputState() {
const { from, to } = this.customTime;
return {
from: from && isDateTimePickerInputValid(from),
to: to && isDateTimePickerInputValid(to),
};
endInputValid() {
return isValidDate(this.endDate);
},
},
watch: {
selectedTimeWindow() {
this.verifyTimeRange();
isValid() {
return this.startInputValid && this.endInputValid;
},
startInput: {
get() {
return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
},
set(val) {
// Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
},
},
endInput: {
get() {
return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
},
set(val) {
// Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
},
},
timeWindowText() {
const timeWindow = getTimeWindow({ start: this.start, end: this.end });
if (timeWindow) {
return this.timeWindows[timeWindow];
} else if (isValidDate(this.start) && isValidDate(this.end)) {
return sprintf(s__('%{start} to %{end}'), {
start: this.formatDate(this.start),
end: this.formatDate(this.end),
});
}
return '';
},
},
mounted() {
this.verifyTimeRange();
// Validate on mounted, and trigger an update if needed
if (!this.isValid) {
this.$emit(events.invalid);
}
},
methods: {
activeTimeWindow(key) {
return this.timeWindows[key] === this.selectedTimeWindowText;
formatDate(date) {
return truncateZerosInDateTime(ISODateToString(date));
},
setCustomTimeWindowParameter() {
this.$emit('onApply', {
start: stringToISODate(this.customTime.from),
end: stringToISODate(this.customTime.to),
});
},
setTimeWindowParameter(key) {
setTimeWindow(key) {
const { start, end } = getTimeDiff(key);
this.$emit('onApply', {
start,
end,
});
this.startDate = start;
this.endDate = end;
this.apply();
},
closeDropdown() {
this.$refs.dropdown.hide();
},
verifyTimeRange() {
const range = getTimeWindow(this.selectedTimeWindow);
if (range) {
this.selectedTimeWindowText = this.timeWindows[range];
} else {
this.customTime = {
from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
};
this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
}
apply() {
this.$emit(events.apply, {
start: this.startDate,
end: this.endDate,
});
},
},
};
Loading
Loading
@@ -101,7 +125,7 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
:text="selectedTimeWindowText"
:text="timeWindowText"
menu-class="time-window-dropdown-menu"
class="js-time-window-dropdown"
>
Loading
Loading
@@ -113,24 +137,21 @@ export default {
>
<date-time-picker-input
id="custom-time-from"
v-model="customTime.from"
v-model="startInput"
:label="__('From')"
:state="inputState.from"
:state="startInputValid"
/>
<date-time-picker-input
id="custom-time-to"
v-model="customTime.to"
v-model="endInput"
:label="__('To')"
:state="inputState.to"
:state="endInputValid"
/>
<gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button
variant="success"
:disabled="!applyEnabled"
@click="setCustomTimeWindowParameter"
>{{ __('Apply') }}</gl-button
>
<gl-button variant="success" :disabled="!isValid" @click="apply()">
{{ __('Apply') }}
</gl-button>
</gl-form-group>
</gl-form-group>
<gl-form-group
Loading
Loading
@@ -142,14 +163,14 @@ export default {
<gl-dropdown-item
v-for="(value, key) in timeWindows"
:key="key"
:active="activeTimeWindow(key)"
:active="value === timeWindowText"
active-class="active"
@click="setTimeWindowParameter(key)"
@click="setTimeWindow(key)"
>
<icon
name="mobile-issue-close"
class="align-bottom"
:class="{ invisible: !activeTimeWindow(key) }"
:class="{ invisible: value !== timeWindowText }"
/>
{{ value }}
</gl-dropdown-item>
Loading
Loading
---
title: Add remaining project services to usage ping
merge_request: 21843
author:
type: added
---
title: Custom snowplow events for monitoring alerts
merge_request: 21963
author:
type: added
Loading
Loading
@@ -54,18 +54,20 @@ We follow a simple formula roughly based on hungarian notation.
*Formula*: `element :<descriptor>_<type>`
 
- `descriptor`: The natural-language description of what the element is. On the login page, this could be `username`, or `password`.
- `type`: A physical control on the page that can be seen by a user.
- `type`: A generic control on the page that can be seen by a user.
- `_button`
- `_link`
- `_tab`
- `_dropdown`
- `_field`
- `_checkbox`
- `_container`: an element that includes other elements, but doesn't present visible content itself. E.g., an element that has a third-party editor inside it, but which isn't the editor itself and so doesn't include the editor's content.
- `_content`: any element that contains text, images, or any other content displayed to the user.
- `_dropdown`
- `_field`: a text input element.
- `_link`
- `_modal`: a popup modal dialog, e.g., a confirmation prompt.
- `_placeholder`: a temporary element that appears while content is loading. For example, the elements that are displayed instead of discussions while the discussions are being fetched.
- `_radio`
- `_content`
- `_tab`
 
*Note: This list is a work in progress. This list will eventually be the end-all enumeration of all available types.
I.e., any element that does not end with something in this list is bad form.*
*Note: If none of the listed types are suitable, please open a merge request to add an appropriate type to the list.*
 
### Examples
 
Loading
Loading
Loading
Loading
@@ -178,18 +178,17 @@ module Gitlab
 
# rubocop: disable CodeReuse/ActiveRecord
def services_usage
types = {
SlackService: :projects_slack_notifications_active,
SlackSlashCommandsService: :projects_slack_slash_active,
PrometheusService: :projects_prometheus_active,
CustomIssueTrackerService: :projects_custom_issue_tracker_active,
JenkinsService: :projects_jenkins_active,
MattermostService: :projects_mattermost_active
}
service_counts = count(Service.active.where(template: false).where.not(type: 'JiraService').group(:type), fallback: Hash.new(-1))
results = Service.available_services_names.each_with_object({}) do |service_name, response|
response["projects_#{service_name}_active".to_sym] = service_counts["#{service_name}_service".camelize] || 0
end
 
results = count(Service.active.by_type(types.keys).group(:type), fallback: Hash.new(-1))
types.each_with_object({}) { |(klass, key), response| response[key] = results[klass.to_s] || 0 }
.merge(jira_usage)
# Keep old Slack keys for backward compatibility, https://gitlab.com/gitlab-data/analytics/issues/3241
results[:projects_slack_notifications_active] = results[:projects_slack_active]
results[:projects_slack_slash_active] = results[:projects_slack_slash_commands_active]
results.merge(jira_usage)
end
 
def jira_usage
Loading
Loading
@@ -223,6 +222,7 @@ module Gitlab
 
results
end
# rubocop: enable CodeReuse/ActiveRecord
 
def user_preferences_usage
{} # augmented in EE
Loading
Loading
@@ -233,7 +233,6 @@ module Gitlab
rescue ActiveRecord::StatementInvalid
fallback
end
# rubocop: enable CodeReuse/ActiveRecord
 
def approximate_counts
approx_counts = Gitlab::Database::Count.approximate_counts(APPROXIMATE_COUNT_MODELS)
Loading
Loading
Loading
Loading
@@ -9,14 +9,6 @@ module Sentry
Error = Class.new(StandardError)
MissingKeysError = Class.new(StandardError)
ResponseInvalidSizeError = Class.new(StandardError)
BadRequestError = Class.new(StandardError)
SENTRY_API_SORT_VALUE_MAP = {
# <accepted_by_client> => <accepted_by_sentry_api>
'frequency' => 'freq',
'first_seen' => 'new',
'last_seen' => nil
}.freeze
 
attr_accessor :url, :token
 
Loading
Loading
@@ -25,30 +17,8 @@ module Sentry
@token = token
end
 
def list_issues(**keyword_args)
response = get_issues(keyword_args)
issues = response[:issues]
pagination = response[:pagination]
validate_size(issues)
handle_mapping_exceptions do
{
issues: map_to_errors(issues),
pagination: pagination
}
end
end
private
 
def validate_size(issues)
return if Gitlab::Utils::DeepSize.new(issues).valid?
raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."
end
def handle_mapping_exceptions(&block)
yield
rescue KeyError => e
Loading
Loading
@@ -85,31 +55,6 @@ module Sentry
handle_response(response)
end
 
def get_issues(**keyword_args)
response = http_get(
issues_api_url,
query: list_issue_sentry_query(keyword_args)
)
{
issues: response[:body],
pagination: Sentry::PaginationParser.parse(response[:headers])
}
end
def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil)
unless SENTRY_API_SORT_VALUE_MAP.key?(sort)
raise BadRequestError, 'Invalid value for sort param'
end
{
query: "is:#{issue_status} #{search_term}".strip,
limit: limit,
sort: SENTRY_API_SORT_VALUE_MAP[sort],
cursor: cursor
}.compact
end
def handle_request_exceptions
yield
rescue Gitlab::HTTP::Error => e
Loading
Loading
@@ -139,58 +84,5 @@ module Sentry
def raise_error(message)
raise Client::Error, message
end
def issues_api_url
issues_url = URI(@url + '/issues/')
issues_url.path.squeeze!('/')
issues_url
end
def map_to_errors(issues)
issues.map(&method(:map_to_error))
end
def issue_url(id)
issues_url = @url + "/issues/#{id}"
parse_sentry_url(issues_url)
end
def project_url
parse_sentry_url(@url)
end
def parse_sentry_url(api_url)
url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url)
uri = URI(url)
uri.path.squeeze!('/')
# Remove trailing slash
uri = uri.to_s.gsub(/\/\z/, '')
uri
end
def map_to_error(issue)
Gitlab::ErrorTracking::Error.new(
id: issue.fetch('id'),
first_seen: issue.fetch('firstSeen', nil),
last_seen: issue.fetch('lastSeen', nil),
title: issue.fetch('title', nil),
type: issue.fetch('type', nil),
user_count: issue.fetch('userCount', nil),
count: issue.fetch('count', nil),
message: issue.dig('metadata', 'value'),
culprit: issue.fetch('culprit', nil),
external_url: issue_url(issue.fetch('id')),
short_id: issue.fetch('shortId', nil),
status: issue.fetch('status', nil),
frequency: issue.dig('stats', '24h'),
project_id: issue.dig('project', 'id'),
project_name: issue.dig('project', 'name'),
project_slug: issue.dig('project', 'slug')
)
end
end
end
Loading
Loading
@@ -3,6 +3,31 @@
module Sentry
class Client
module Issue
BadRequestError = Class.new(StandardError)
SENTRY_API_SORT_VALUE_MAP = {
# <accepted_by_client> => <accepted_by_sentry_api>
'frequency' => 'freq',
'first_seen' => 'new',
'last_seen' => nil
}.freeze
def list_issues(**keyword_args)
response = get_issues(keyword_args)
issues = response[:issues]
pagination = response[:pagination]
validate_size(issues)
handle_mapping_exceptions do
{
issues: map_to_errors(issues),
pagination: pagination
}
end
end
def issue_details(issue_id:)
issue = get_issue(issue_id: issue_id)
 
Loading
Loading
@@ -11,6 +36,37 @@ module Sentry
 
private
 
def get_issues(**keyword_args)
response = http_get(
issues_api_url,
query: list_issue_sentry_query(keyword_args)
)
{
issues: response[:body],
pagination: Sentry::PaginationParser.parse(response[:headers])
}
end
def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil)
unless SENTRY_API_SORT_VALUE_MAP.key?(sort)
raise BadRequestError, 'Invalid value for sort param'
end
{
query: "is:#{issue_status} #{search_term}".strip,
limit: limit,
sort: SENTRY_API_SORT_VALUE_MAP[sort],
cursor: cursor
}.compact
end
def validate_size(issues)
return if Gitlab::Utils::DeepSize.new(issues).valid?
raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."
end
def get_issue(issue_id:)
http_get(issue_api_url(issue_id))[:body]
end
Loading
Loading
@@ -19,6 +75,13 @@ module Sentry
http_put(issue_api_url(issue_id), params)[:body]
end
 
def issues_api_url
issues_url = URI("#{url}/issues/")
issues_url.path.squeeze!('/')
issues_url
end
def issue_api_url(issue_id)
issue_url = URI(url)
issue_url.path = "/api/0/issues/#{CGI.escape(issue_id.to_s)}/"
Loading
Loading
@@ -35,6 +98,50 @@ module Sentry
gitlab_plugin.dig('issue', 'url')
end
 
def issue_url(id)
parse_sentry_url("#{url}/issues/#{id}")
end
def project_url
parse_sentry_url(url)
end
def parse_sentry_url(api_url)
url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url)
uri = URI(url)
uri.path.squeeze!('/')
# Remove trailing slash
uri = uri.to_s.gsub(/\/\z/, '')
uri
end
def map_to_errors(issues)
issues.map(&method(:map_to_error))
end
def map_to_error(issue)
Gitlab::ErrorTracking::Error.new(
id: issue.fetch('id'),
first_seen: issue.fetch('firstSeen', nil),
last_seen: issue.fetch('lastSeen', nil),
title: issue.fetch('title', nil),
type: issue.fetch('type', nil),
user_count: issue.fetch('userCount', nil),
count: issue.fetch('count', nil),
message: issue.dig('metadata', 'value'),
culprit: issue.fetch('culprit', nil),
external_url: issue_url(issue.fetch('id')),
short_id: issue.fetch('shortId', nil),
status: issue.fetch('status', nil),
frequency: issue.dig('stats', '24h'),
project_id: issue.dig('project', 'id'),
project_name: issue.dig('project', 'name'),
project_slug: issue.dig('project', 'slug')
)
end
def map_to_detailed_error(issue)
Gitlab::ErrorTracking::DetailedError.new(
id: issue.fetch('id'),
Loading
Loading
Loading
Loading
@@ -269,9 +269,6 @@ msgstr ""
msgid "%{firstLabel} +%{labelCount} more"
msgstr ""
 
msgid "%{from} to %{to}"
msgstr ""
msgid "%{global_id} is not a valid id for %{expected_type}."
msgstr ""
 
Loading
Loading
@@ -370,6 +367,9 @@ msgstr ""
msgid "%{spammable_titlecase} was submitted to Akismet successfully."
msgstr ""
 
msgid "%{start} to %{end}"
msgstr ""
msgid "%{state} epics"
msgstr ""
 
Loading
Loading
@@ -557,6 +557,9 @@ msgid_plural "%d groups"
msgstr[0] ""
msgstr[1] ""
 
msgid "1 hour"
msgstr ""
msgid "1 merged merge request"
msgid_plural "%{merge_requests} merged merge requests"
msgstr[0] ""
Loading
Loading
@@ -607,6 +610,9 @@ msgstr ""
msgid "20-29 contributions"
msgstr ""
 
msgid "24 hours"
msgstr ""
msgid "2FA"
msgstr ""
 
Loading
Loading
@@ -619,6 +625,9 @@ msgstr ""
msgid "3 hours"
msgstr ""
 
msgid "30 days"
msgstr ""
msgid "30 minutes"
msgstr ""
 
Loading
Loading
@@ -640,6 +649,9 @@ msgstr ""
msgid "404|Please contact your GitLab administrator if you think this is a mistake."
msgstr ""
 
msgid "7 days"
msgstr ""
msgid "8 hours"
msgstr ""
 
Loading
Loading
@@ -11478,7 +11490,7 @@ msgstr ""
msgid "Metrics|Legend label (optional)"
msgstr ""
 
msgid "Metrics|Link contains an invalid time window."
msgid "Metrics|Link contains an invalid time window, please verify the link to see the requested time range."
msgstr ""
 
msgid "Metrics|Max"
Loading
Loading
@@ -18797,6 +18809,9 @@ msgstr ""
msgid "ThreatMonitoring|Requests"
msgstr ""
 
msgid "ThreatMonitoring|Show last"
msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics"
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -19,4 +19,5 @@ group :test do
gem 'pry-byebug', '~> 3.5.1', platform: :mri
gem "ruby-debug-ide", "~> 0.7.0"
gem "debase", "~> 0.2.4.1"
gem 'timecop', '~> 0.9.1'
end
Loading
Loading
@@ -99,6 +99,7 @@ GEM
childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2)
thread_safe (0.3.6)
timecop (0.9.1)
tzinfo (1.2.5)
thread_safe (~> 0.1)
unf (0.1.4)
Loading
Loading
@@ -128,6 +129,7 @@ DEPENDENCIES
rspec_junit_formatter (~> 0.4.1)
ruby-debug-ide (~> 0.7.0)
selenium-webdriver (~> 3.12)
timecop (~> 0.9.1)
 
BUNDLED WITH
1.17.3
Loading
Loading
@@ -488,8 +488,9 @@ module QA
end
autoload :Api, 'qa/support/api'
autoload :Dates, 'qa/support/dates'
autoload :Waiter, 'qa/support/waiter'
autoload :Repeater, 'qa/support/repeater'
autoload :Retrier, 'qa/support/retrier'
autoload :Waiter, 'qa/support/waiter'
autoload :WaitForRequests, 'qa/support/wait_for_requests'
end
end
Loading
Loading
Loading
Loading
@@ -26,20 +26,20 @@ module QA
wait_for_requests
end
 
def wait(max: 60, interval: 0.1, reload: true)
QA::Support::Waiter.wait(max: max, interval: interval) do
def wait(max: 60, interval: 0.1, reload: true, raise_on_failure: false)
Support::Waiter.wait_until(max_duration: max, sleep_interval: interval, raise_on_failure: raise_on_failure) do
yield || (reload && refresh && false)
end
end
 
def retry_until(max_attempts: 3, reload: false, sleep_interval: 0)
QA::Support::Retrier.retry_until(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do
def retry_until(max_attempts: 3, reload: false, sleep_interval: 0, raise_on_failure: false)
Support::Retrier.retry_until(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval, raise_on_failure: raise_on_failure) do
yield
end
end
 
def retry_on_exception(max_attempts: 3, reload: false, sleep_interval: 0.5)
QA::Support::Retrier.retry_on_exception(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do
Support::Retrier.retry_on_exception(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do
yield
end
end
Loading
Loading
Loading
Loading
@@ -4,6 +4,7 @@ module QA
module Resource
module Events
MAX_WAIT = 10
RAISE_ON_FAILURE = true
 
EventNotFoundError = Class.new(RuntimeError)
 
Loading
Loading
@@ -21,7 +22,7 @@ module QA
end
 
def wait_for_event
event_found = QA::Support::Waiter.wait(max: max_wait) do
event_found = Support::Waiter.wait_until(max_duration: max_wait, raise_on_failure: raise_on_failure) do
yield
end
 
Loading
Loading
@@ -31,6 +32,10 @@ module QA
def max_wait
MAX_WAIT
end
def raise_on_failure
RAISE_ON_FAILURE
end
end
end
end
Loading
Loading
# frozen_string_literal: true
require 'active_support/inflector'
module QA
module Support
module Repeater
DEFAULT_MAX_WAIT_TIME = 60
RetriesExceededError = Class.new(RuntimeError)
WaitExceededError = Class.new(RuntimeError)
def repeat_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false)
attempts = 0
start = Time.now
begin
while remaining_attempts?(attempts, max_attempts) && remaining_time?(start, max_duration)
QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") if max_attempts
result = yield
return result if result
sleep_and_reload_if_needed(sleep_interval, reload_page)
attempts += 1
end
rescue StandardError, RSpec::Expectations::ExpectationNotMetError
raise unless retry_on_exception
attempts += 1
if remaining_attempts?(attempts, max_attempts) && remaining_time?(start, max_duration)
sleep_and_reload_if_needed(sleep_interval, reload_page)
retry
else
raise
end
end
if raise_on_failure
raise RetriesExceededError, "Retry condition not met after #{max_attempts} #{'attempt'.pluralize(max_attempts)}" unless remaining_attempts?(attempts, max_attempts)
raise WaitExceededError, "Wait condition not met after #{max_duration} #{'second'.pluralize(max_duration)}"
end
false
end
private
def sleep_and_reload_if_needed(sleep_interval, reload_page)
sleep(sleep_interval)
reload_page.refresh if reload_page
end
def remaining_attempts?(attempts, max_attempts)
max_attempts ? attempts < max_attempts : true
end
def remaining_time?(start, max_duration)
max_duration ? Time.now - start < max_duration : true
end
end
end
end
Loading
Loading
@@ -3,49 +3,61 @@
module QA
module Support
module Retrier
extend Repeater
module_function
 
def retry_on_exception(max_attempts: 3, reload_page: nil, sleep_interval: 0.5)
QA::Runtime::Logger.debug("with retry_on_exception: max_attempts #{max_attempts}; sleep_interval #{sleep_interval}")
attempts = 0
QA::Runtime::Logger.debug(
<<~MSG.tr("\n", ' ')
with retry_on_exception: max_attempts: #{max_attempts};
reload_page: #{reload_page};
sleep_interval: #{sleep_interval}
MSG
)
 
begin
QA::Runtime::Logger.debug("Attempt number #{attempts + 1}")
yield
rescue StandardError, RSpec::Expectations::ExpectationNotMetError
sleep sleep_interval
reload_page.refresh if reload_page
attempts += 1
result = nil
repeat_until(
max_attempts: max_attempts,
reload_page: reload_page,
sleep_interval: sleep_interval,
retry_on_exception: true
) do
result = yield
 
retry if attempts < max_attempts
QA::Runtime::Logger.debug("Raising exception after #{max_attempts} attempts")
raise
# This method doesn't care what the return value of the block is.
# We set it to `true` so that it doesn't repeat if there's no exception
true
end
end
def retry_until(max_attempts: 3, reload_page: nil, sleep_interval: 0, exit_on_failure: false)
QA::Runtime::Logger.debug("with retry_until: max_attempts #{max_attempts}; sleep_interval #{sleep_interval}; reload_page:#{reload_page}")
attempts = 0
QA::Runtime::Logger.debug("ended retry_on_exception")
 
while attempts < max_attempts
QA::Runtime::Logger.debug("Attempt number #{attempts + 1}")
result = yield
return result if result
result
end
 
sleep sleep_interval
def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: false, retry_on_exception: false)
# For backwards-compatibility
max_attempts = 3 if max_attempts.nil? && max_duration.nil?
 
reload_page.refresh if reload_page
start_msg ||= ["with retry_until:"]
start_msg << "max_attempts: #{max_attempts};" if max_attempts
start_msg << "max_duration: #{max_duration};" if max_duration
start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}"
QA::Runtime::Logger.debug(start_msg.join(' '))
 
attempts += 1
end
if exit_on_failure
QA::Runtime::Logger.debug("Raising exception after #{max_attempts} attempts")
raise
result = nil
repeat_until(
max_attempts: max_attempts,
max_duration: max_duration,
reload_page: reload_page,
sleep_interval: sleep_interval,
raise_on_failure: raise_on_failure,
retry_on_exception: retry_on_exception
) do
result = yield
end
QA::Runtime::Logger.debug("ended retry_until")
 
false
result
end
end
end
Loading
Loading
Loading
Loading
@@ -3,30 +3,39 @@
module QA
module Support
module Waiter
DEFAULT_MAX_WAIT_TIME = 60
extend Repeater
 
module_function
 
def wait(max: DEFAULT_MAX_WAIT_TIME, interval: 0.1)
QA::Runtime::Logger.debug("with wait: max #{max}; interval #{interval}")
start = Time.now
def wait(max: singleton_class::DEFAULT_MAX_WAIT_TIME, interval: 0.1)
wait_until(max_duration: max, sleep_interval: interval, raise_on_failure: false) do
yield
end
end
 
while Time.now - start < max
result = yield
if result
log_end(Time.now - start)
return result
end
def wait_until(max_duration: singleton_class::DEFAULT_MAX_WAIT_TIME, reload_page: nil, sleep_interval: 0.1, raise_on_failure: false, retry_on_exception: false)
QA::Runtime::Logger.debug(
<<~MSG.tr("\n", ' ')
with wait_until: max_duration: #{max_duration};
reload_page: #{reload_page};
sleep_interval: #{sleep_interval};
raise_on_failure: #{raise_on_failure}
MSG
)
 
sleep(interval)
result = nil
self.repeat_until(
max_duration: max_duration,
reload_page: reload_page,
sleep_interval: sleep_interval,
raise_on_failure: raise_on_failure,
retry_on_exception: retry_on_exception
) do
result = yield
end
log_end(Time.now - start)
false
end
QA::Runtime::Logger.debug("ended wait_until")
 
def self.log_end(duration)
QA::Runtime::Logger.debug("ended wait after #{duration} seconds")
result
end
end
end
Loading
Loading
Loading
Loading
@@ -12,7 +12,7 @@ module QA
fill_in 'password', with: QA::Runtime::Env.github_password
click_on 'Sign in'
 
Support::Retrier.retry_until(exit_on_failure: true, sleep_interval: 35) do
Support::Retrier.retry_until(raise_on_failure: true, sleep_interval: 35) do
otp = OnePassword::CLI.new.otp
 
fill_in 'otp', with: otp
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment