Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-05-11 21:09:40 +00:00
parent 636eb69592
commit 3f45eb27e9
81 changed files with 981 additions and 800 deletions

View File

@ -28,6 +28,12 @@ AllCops:
- 'file_hooks/**/*'
CacheRootDirectory: tmp
Cop/AvoidKeywordArgumentsInSidekiqWorkers:
Enabled: true
Include:
- 'app/workers/**/*'
- 'ee/app/workers/**/*'
Cop/StaticTranslationDefinition:
Enabled: true
Exclude:

View File

@ -481,8 +481,6 @@ gem 'countries', '~> 3.0'
gem 'retriable', '~> 3.1.2'
gem 'liquid', '~> 4.0'
# LRU cache
gem 'lru_redux'

View File

@ -602,7 +602,6 @@ GEM
xml-simple
licensee (8.9.2)
rugged (~> 0.24)
liquid (4.0.3)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
@ -1289,7 +1288,6 @@ DEPENDENCIES
letter_opener_web (~> 1.3.4)
license_finder (~> 5.4)
licensee (~> 8.9)
liquid (~> 4.0)
lockbox (~> 0.3.3)
lograge (~> 0.5)
loofah (~> 2.2)

View File

@ -1,10 +1,12 @@
import { isString } from 'lodash';
import { VARIABLE_TYPES } from '../constants';
/**
* This file exclusively deals with parsing user-defined variables
* in dashboard yml file.
*
* As of 13.0, simple custom and advanced custom variables are supported.
* As of 13.0, simple text, advanced text, simple custom and
* advanced custom variables are supported.
*
* In the future iterations, text and query variables will be
* supported
@ -12,13 +14,30 @@ import { VARIABLE_TYPES } from '../constants';
*/
/**
* Utility method to determine if a custom variable is
* simple or not. If its not simple, it is advanced.
* Simple text variable is a string value only.
* This method parses such variables to a standard format.
*
* @param {Array|Object} customVar Array if simple, object if advanced
* @returns {Boolean} true if simple, false if advanced
* @param {String|Object} simpleTextVar
* @returns {Object}
*/
const isSimpleCustomVariable = customVar => Array.isArray(customVar);
const textSimpleVariableParser = simpleTextVar => ({
type: VARIABLE_TYPES.text,
label: null,
value: simpleTextVar,
});
/**
* Advanced text variable is an object.
* This method parses such variables to a standard format.
*
* @param {Object} advTextVar
* @returns {Object}
*/
const textAdvancedVariableParser = advTextVar => ({
type: VARIABLE_TYPES.text,
label: advTextVar.label,
value: advTextVar.options.default_value,
});
/**
* Normalize simple and advanced custom variable options to a standard
@ -26,20 +45,12 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar);
* @param {Object} custom variable option
* @returns {Object} normalized custom variable options
*/
const normalizeDropdownOptions = ({ default: defaultOpt = false, text, value }) => ({
const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
default: defaultOpt,
text,
value,
});
/**
* Simple custom variables have an array of values.
* This method parses such variables options to a standard format.
*
* @param {String} opt option from simple custom variable
*/
const parseSimpleDropdownOptions = opt => ({ text: opt, value: opt });
/**
* Custom advanced variables are rendered as dropdown elements in the dashboard
* header. This method parses advanced custom variables.
@ -52,10 +63,19 @@ const customAdvancedVariableParser = advVariable => {
return {
type: VARIABLE_TYPES.custom,
label: advVariable.label,
options: options.map(normalizeDropdownOptions),
options: options.map(normalizeCustomVariableOptions),
};
};
/**
* Simple custom variables have an array of values.
* This method parses such variables options to a standard format.
*
* @param {String} opt option from simple custom variable
* @returns {Object}
*/
const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
/**
* Custom simple variables are rendered as dropdown elements in the dashboard
* header. This method parses simple custom variables.
@ -66,14 +86,23 @@ const customAdvancedVariableParser = advVariable => {
* @returns {Object}
*/
const customSimpleVariableParser = simpleVar => {
const options = (simpleVar || []).map(parseSimpleDropdownOptions);
const options = (simpleVar || []).map(parseSimpleCustomOptions);
return {
type: VARIABLE_TYPES.custom,
label: null,
options: options.map(normalizeDropdownOptions),
options: options.map(normalizeCustomVariableOptions),
};
};
/**
* Utility method to determine if a custom variable is
* simple or not. If its not simple, it is advanced.
*
* @param {Array|Object} customVar Array if simple, object if advanced
* @returns {Boolean} true if simple, false if advanced
*/
const isSimpleCustomVariable = customVar => Array.isArray(customVar);
/**
* This method returns a parser based on the type of the variable.
* Currently, the supported variables are simple custom and
@ -88,6 +117,10 @@ const getVariableParser = variable => {
return customSimpleVariableParser;
} else if (variable.type === VARIABLE_TYPES.custom) {
return customAdvancedVariableParser;
} else if (variable.type === VARIABLE_TYPES.text) {
return textAdvancedVariableParser;
} else if (isString(variable)) {
return textSimpleVariableParser;
}
return () => null;
};

View File

@ -104,9 +104,15 @@ export default {
},
fields() {
const tagClass = this.isDesktop ? 'w-25' : '';
const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
return [
{ key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
{ key: LIST_KEY_TAG, label: LIST_LABEL_TAG, class: `${tagClass} js-tag-column` },
{
key: LIST_KEY_TAG,
label: LIST_LABEL_TAG,
class: `${tagClass} js-tag-column`,
innerClass: tagInnerClass,
},
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
@ -329,17 +335,24 @@ export default {
@change="updateSelectedItems(index)"
/>
</template>
<template #cell(name)="{item}">
<span ref="rowName">
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
ref="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
<template #cell(name)="{item, field}">
<div ref="rowName" :class="[field.innerClass, 'gl-display-flex']">
<span
v-gl-tooltip
data-testid="rowNameText"
:title="item.name"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
>
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
ref="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
</template>
<template #cell(short_revision)="{value}">
<span ref="rowShortRevision">

View File

@ -13,11 +13,7 @@ export default {
IssuesList,
},
props: {
baseEndpoint: {
type: String,
required: true,
},
headEndpoint: {
endpoint: {
type: String,
required: true,
},
@ -34,15 +30,12 @@ export default {
]),
},
created() {
this.setEndpoints({
baseEndpoint: this.baseEndpoint,
headEndpoint: this.headEndpoint,
});
this.setEndpoint(this.endpoint);
this.fetchReport();
},
methods: {
...mapActions(['fetchReport', 'setEndpoints']),
...mapActions(['fetchReport', 'setEndpoint']),
},
};
</script>

View File

@ -1,49 +1,78 @@
import Visibility from 'visibilityjs';
import Poll from '~/lib/utils/poll';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import { parseAccessibilityReport, compareAccessibilityReports } from './utils';
import { s__ } from '~/locale';
export const setEndpoints = ({ commit }, { baseEndpoint, headEndpoint }) =>
commit(types.SET_ENDPOINTS, { baseEndpoint, headEndpoint });
let eTagPoll;
export const clearEtagPoll = () => {
eTagPoll = null;
};
export const stopPolling = () => {
if (eTagPoll) eTagPoll.stop();
};
export const restartPolling = () => {
if (eTagPoll) eTagPoll.restart();
};
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
/**
* We need to poll the report endpoint while they are being parsed in the Backend.
* This can take up to one minute.
*
* Poll.js will handle etag response.
* While http status code is 204, it means it's parsing, and we'll keep polling
* When http status code is 200, it means parsing is done, we can show the results & stop polling
* When http status code is 500, it means parsing went wrong and we stop polling
*/
export const fetchReport = ({ state, dispatch, commit }) => {
commit(types.REQUEST_REPORT);
// If we don't have both endpoints, throw an error.
if (!state.baseEndpoint || !state.headEndpoint) {
commit(
types.RECEIVE_REPORT_ERROR,
s__('AccessibilityReport|Accessibility report artifact not found'),
);
return;
eTagPoll = new Poll({
resource: {
getReport(endpoint) {
return axios.get(endpoint);
},
},
data: state.endpoint,
method: 'getReport',
successCallback: ({ status, data }) => dispatch('receiveReportSuccess', { status, data }),
errorCallback: () => dispatch('receiveReportError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
axios
.get(state.endpoint)
.then(({ status, data }) => dispatch('receiveReportSuccess', { status, data }))
.catch(() => dispatch('receiveReportError'));
}
Promise.all([
axios.get(state.baseEndpoint).then(response => ({
...response.data,
isHead: false,
})),
axios.get(state.headEndpoint).then(response => ({
...response.data,
isHead: true,
})),
])
.then(responses => dispatch('receiveReportSuccess', responses))
.catch(() =>
commit(
types.RECEIVE_REPORT_ERROR,
s__('AccessibilityReport|Failed to retrieve accessibility report'),
),
);
Visibility.change(() => {
if (!Visibility.hidden() && state.isLoading) {
dispatch('restartPolling');
} else {
dispatch('stopPolling');
}
});
};
export const receiveReportSuccess = ({ commit }, responses) => {
const parsedReports = responses.map(response => ({
isHead: response.isHead,
issues: parseAccessibilityReport(response),
}));
const report = compareAccessibilityReports(parsedReports);
commit(types.RECEIVE_REPORT_SUCCESS, report);
export const receiveReportSuccess = ({ commit, dispatch }, { status, data }) => {
if (status === httpStatusCodes.OK) {
commit(types.RECEIVE_REPORT_SUCCESS, data);
// Stop polling since we have the information already parsed and it won't be changing
dispatch('stopPolling');
}
};
export const receiveReportError = ({ commit, dispatch }) => {
commit(types.RECEIVE_REPORT_ERROR);
dispatch('stopPolling');
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests

View File

@ -10,8 +10,7 @@ export const groupedSummaryText = state => {
return s__('Reports|Accessibility scanning failed loading results');
}
const numberOfResults =
(state.report?.summary?.errors || 0) + (state.report?.summary?.warnings || 0);
const numberOfResults = state.report?.summary?.errored || 0;
if (numberOfResults === 0) {
return s__('Reports|Accessibility scanning detected no issues for the source branch only');
}

View File

@ -1,4 +1,4 @@
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_ENDPOINT = 'SET_ENDPOINT';
export const REQUEST_REPORT = 'REQUEST_REPORT';
export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';

View File

@ -1,9 +1,8 @@
import * as types from './mutation_types';
export default {
[types.SET_ENDPOINTS](state, { baseEndpoint, headEndpoint }) {
state.baseEndpoint = baseEndpoint;
state.headEndpoint = headEndpoint;
[types.SET_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
},
[types.REQUEST_REPORT](state) {
state.isLoading = true;
@ -13,10 +12,9 @@ export default {
state.isLoading = false;
state.report = report;
},
[types.RECEIVE_REPORT_ERROR](state, message) {
[types.RECEIVE_REPORT_ERROR](state) {
state.isLoading = false;
state.hasError = true;
state.errorMessage = message;
state.report = {};
},
};

View File

@ -1,6 +1,5 @@
export default (initialState = {}) => ({
baseEndpoint: initialState.baseEndpoint || '',
headEndpoint: initialState.headEndpoint || '',
endpoint: initialState.endpoint || '',
isLoading: initialState.isLoading || false,
hasError: initialState.hasError || false,
@ -11,9 +10,8 @@ export default (initialState = {}) => ({
* status: {String},
* summary: {
* total: {Number},
* notes: {Number},
* warnings: {Number},
* errors: {Number},
* resolved: {Number},
* errored: {Number},
* },
* existing_errors: {Array.<Object>},
* existing_notes: {Array.<Object>},

View File

@ -1,83 +0,0 @@
import { difference, intersection } from 'lodash';
import {
STATUS_FAILED,
STATUS_SUCCESS,
ACCESSIBILITY_ISSUE_ERROR,
ACCESSIBILITY_ISSUE_WARNING,
} from '../../constants';
export const parseAccessibilityReport = data => {
// Combine all issues into one array
return Object.keys(data.results)
.map(key => [...data.results[key]])
.flat()
.map(issue => JSON.stringify(issue)); // stringify to help with comparisons
};
export const compareAccessibilityReports = reports => {
const result = {
status: '',
summary: {
total: 0,
notes: 0,
errors: 0,
warnings: 0,
},
new_errors: [],
new_notes: [],
new_warnings: [],
resolved_errors: [],
resolved_notes: [],
resolved_warnings: [],
existing_errors: [],
existing_notes: [],
existing_warnings: [],
};
const headReport = reports.filter(report => report.isHead)[0];
const baseReport = reports.filter(report => !report.isHead)[0];
// existing issues are those that exist in both the head report and the base report
const existingIssues = intersection(headReport.issues, baseReport.issues);
// new issues are those that exist in only the head report
const newIssues = difference(headReport.issues, baseReport.issues);
// resolved issues are those that exist in only the base report
const resolvedIssues = difference(baseReport.issues, headReport.issues);
const parseIssues = (issue, issueType, shouldCount) => {
const parsedIssue = JSON.parse(issue);
switch (parsedIssue.type) {
case ACCESSIBILITY_ISSUE_ERROR:
result[`${issueType}_errors`].push(parsedIssue);
if (shouldCount) {
result.summary.errors += 1;
}
break;
case ACCESSIBILITY_ISSUE_WARNING:
result[`${issueType}_warnings`].push(parsedIssue);
if (shouldCount) {
result.summary.warnings += 1;
}
break;
default:
result[`${issueType}_notes`].push(parsedIssue);
if (shouldCount) {
result.summary.notes += 1;
}
break;
}
};
existingIssues.forEach(issue => parseIssues(issue, 'existing', true));
newIssues.forEach(issue => parseIssues(issue, 'new', true));
resolvedIssues.forEach(issue => parseIssues(issue, 'resolved', false));
result.summary.total = result.summary.errors + result.summary.warnings + result.summary.notes;
const hasErrorsOrWarnings = result.summary.errors > 0 || result.summary.warnings > 0;
result.status = hasErrorsOrWarnings ? STATUS_FAILED : STATUS_SUCCESS;
return result;
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -146,11 +146,7 @@ export default {
});
},
shouldShowAccessibilityReport() {
return (
this.accessibilility?.base_path &&
this.accessibilility?.head_path &&
this.glFeatures.accessibilityMergeRequestWidget
);
return this.mr.accessibilityReportPath && this.glFeatures.accessibilityMergeRequestWidget;
},
},
watch: {
@ -396,8 +392,7 @@ export default {
<grouped-accessibility-reports-app
v-if="shouldShowAccessibilityReport"
:base-endpoint="mr.accessibility.base_path"
:head-endpoint="mr.accessibility.head_path"
:endpoint="mr.accessibilityReportPath"
/>
<div class="mr-widget-section">

View File

@ -103,7 +103,7 @@ export default class MergeRequestStore {
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
this.accessibility = data.accessibility || {};
this.accessibilityReportPath = data.accessibility_report_path;
this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);

View File

@ -16,10 +16,10 @@ module Types
field :panel_id, GraphQL::STRING_TYPE, null: true,
description: 'ID of a dashboard panel to which the annotation should be scoped'
field :starting_at, GraphQL::STRING_TYPE, null: true,
field :starting_at, Types::TimeType, null: true,
description: 'Timestamp marking start of annotated time span'
field :ending_at, GraphQL::STRING_TYPE, null: true,
field :ending_at, Types::TimeType, null: true,
description: 'Timestamp marking end of annotated time span'
def panel_id

View File

@ -8,6 +8,7 @@ class SnippetRepository < ApplicationRecord
CommitError = Class.new(StandardError)
InvalidPathError = Class.new(CommitError)
InvalidSignatureError = Class.new(CommitError)
belongs_to :snippet, inverse_of: :snippet_repository
@ -41,8 +42,8 @@ class SnippetRepository < ApplicationRecord
rescue Gitlab::Git::Index::IndexError,
Gitlab::Git::CommitError,
Gitlab::Git::PreReceiveError,
Gitlab::Git::CommandError => error
Gitlab::Git::CommandError,
ArgumentError => error
raise commit_error_exception(error)
end
@ -88,15 +89,23 @@ class SnippetRepository < ApplicationRecord
"#{DEFAULT_EMPTY_FILE_NAME}#{index}.txt"
end
def commit_error_exception(error)
if error.is_a?(Gitlab::Git::Index::IndexError) && invalid_path_error?(error.message)
def commit_error_exception(err)
if invalid_path_error?(err)
InvalidPathError.new('Invalid Path') # To avoid returning the message with the path included
elsif invalid_signature_error?(err)
InvalidSignatureError.new(err.message)
else
CommitError.new(error.message)
CommitError.new(err.message)
end
end
def invalid_path_error?(message)
message.downcase.start_with?('invalid path', 'path cannot include directory traversal')
def invalid_path_error?(err)
err.is_a?(Gitlab::Git::Index::IndexError) &&
err.message.downcase.start_with?('invalid path', 'path cannot include directory traversal')
end
def invalid_signature_error?(err)
err.is_a?(ArgumentError) &&
err.message.downcase.match?(/failed to parse signature/)
end
end

View File

@ -4,6 +4,16 @@ module Prometheus
class ProxyVariableSubstitutionService < BaseService
include Stepable
VARIABLE_INTERPOLATION_REGEX = /
{{ # Variable needs to be wrapped in these chars.
\s* # Allow whitespace before and after the variable name.
(?<variable> # Named capture.
\w+ # Match one or more word characters.
)
\s*
}}
/x.freeze
steps :validate_variables,
:add_params_to_result,
:substitute_params,
@ -49,12 +59,9 @@ module Prometheus
def substitute_liquid_variables(result)
return success(result) unless query(result)
result[:params][:query] =
TemplateEngines::LiquidService.new(query(result)).render(full_context)
result[:params][:query] = gsub(query(result), full_context)
success(result)
rescue TemplateEngines::LiquidService::RenderError => e
error(e.message)
end
def substitute_ruby_variables(result)
@ -75,12 +82,24 @@ module Prometheus
error(_('Malformed string'))
end
def gsub(string, context)
# Search for variables of the form `{{variable}}` in the string and replace
# them with their value.
string.gsub(VARIABLE_INTERPOLATION_REGEX) do |match|
# Replace with the value of the variable, or if there is no such variable,
# replace the invalid variable with itself. So,
# `up{instance="{{invalid_variable}}"}` will remain
# `up{instance="{{invalid_variable}}"}` after substitution.
context.fetch($~[:variable], match)
end
end
def predefined_context
@predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment)
end
def full_context
@full_context ||= predefined_context.reverse_merge(variables_hash)
@full_context ||= predefined_context.stringify_keys.reverse_merge(variables_hash)
end
def variables

View File

@ -1,48 +0,0 @@
# frozen_string_literal: true
module TemplateEngines
class LiquidService < BaseService
RenderError = Class.new(StandardError)
DEFAULT_RENDER_SCORE_LIMIT = 1_000
def initialize(string)
@template = Liquid::Template.parse(string)
end
def render(context, render_score_limit: DEFAULT_RENDER_SCORE_LIMIT)
set_limits(render_score_limit)
@template.render!(context.stringify_keys)
rescue Liquid::MemoryError => e
handle_exception(e, string: @string, context: context)
raise RenderError, _('Memory limit exceeded while rendering template')
rescue Liquid::Error => e
handle_exception(e, string: @string, context: context)
raise RenderError, _('Error rendering query')
end
private
def set_limits(render_score_limit)
@template.resource_limits.render_score_limit = render_score_limit
# We can also set assign_score_limit and render_length_limit if required.
# render_score_limit limits the number of nodes (string, variable, block, tags)
# that are allowed in the template.
# render_length_limit seems to limit the sum of the bytesize of all node blocks.
# assign_score_limit seems to limit the sum of the bytesize of all capture blocks.
end
def handle_exception(exception, extra = {})
log_error(exception.message)
Gitlab::ErrorTracking.track_exception(exception, {
template_string: extra[:string],
variables: extra[:context]
})
end
end
end

View File

@ -961,7 +961,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
:idempotent: true
- :name: create_evidence
:feature_category: :release_evidence
:has_external_dependencies:

View File

@ -1,11 +1,13 @@
# frozen_string_literal: true
class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker
class CreateCommitSignatureWorker
include ApplicationWorker
feature_category :source_code_management
weight 2
idempotent!
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
# Older versions of Git::BranchPushService may push a single commit ID on

View File

@ -0,0 +1,5 @@
---
title: Add elipsis to container registry tag name
merge_request: 31584
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: app:gitlab:check rake task now warns when projects are not in hashed storage
merge_request: 31172
author:
type: changed

View File

@ -0,0 +1,6 @@
---
title: Use gsub instead of the Liquid gem for variable substitution in the Prometheus
proxy API
merge_request: 31482
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix snippet migration when user has invalid info
merge_request: 31488
author:
type: fixed

View File

@ -0,0 +1,6 @@
---
title: Use iso 8601 timestamp format in metrics dashboard annotations graphql resource
to assure multi browser compatibility
merge_request: 31474
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add Rubocop cop to flag keyword arguments usage in Sidekiq workers
merge_request: 31551
author: Arun Kumar Mohan
type: added

View File

@ -7,11 +7,11 @@ For a full list of reference architectures, see
> - **Supported users (approximate):** 1,000
> - **High Availability:** False
| Users | Configuration([8](#footnotes)) | GCP type | AWS type([9](#footnotes)) |
|-------|--------------------------------|---------------|---------------------------|
| 100 | 2 vCPU, 7.2GB Memory | n1-standard-2 | c5.2xlarge |
| 500 | 4 vCPU, 15GB Memory | n1-standard-4 | m5.xlarge |
| 1000 | 8 vCPU, 30GB Memory | n1-standard-8 | m5.2xlarge |
| Users | Configuration([8](#footnotes)) | GCP | AWS([9](#footnotes)) | Azure([9](#footnotes)) |
|-------|--------------------------------|---------------|----------------------|------------------------|
| 100 | 2 vCPU, 7.2GB Memory | n1-standard-2 | m5.large | D2s v3 |
| 500 | 4 vCPU, 15GB Memory | n1-standard-4 | m5.xlarge | D4s v3 |
| 1000 | 8 vCPU, 30GB Memory | n1-standard-8 | m5.2xlarge | D8s v3 |
For situations where you need to serve up to 1,000 users, a single-node
solution with [frequent backups](index.md#automated-backups-core-only) is appropriate

View File

@ -6076,7 +6076,7 @@ type MetricsDashboardAnnotation {
"""
Timestamp marking end of annotated time span
"""
endingAt: String
endingAt: Time
"""
ID of the annotation
@ -6091,7 +6091,7 @@ type MetricsDashboardAnnotation {
"""
Timestamp marking start of annotated time span
"""
startingAt: String
startingAt: Time
}
"""

View File

@ -17041,7 +17041,7 @@
],
"type": {
"kind": "SCALAR",
"name": "String",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
@ -17087,7 +17087,7 @@
],
"type": {
"kind": "SCALAR",
"name": "String",
"name": "Time",
"ofType": null
},
"isDeprecated": false,

View File

@ -916,10 +916,10 @@ Autogenerated return type of MergeRequestSetWip
| Name | Type | Description |
| --- | ---- | ---------- |
| `description` | String | Description of the annotation |
| `endingAt` | String | Timestamp marking end of annotated time span |
| `endingAt` | Time | Timestamp marking end of annotated time span |
| `id` | ID! | ID of the annotation |
| `panelId` | String | ID of a dashboard panel to which the annotation should be scoped |
| `startingAt` | String | Timestamp marking start of annotated time span |
| `startingAt` | Time | Timestamp marking start of annotated time span |
## Milestone

View File

@ -135,11 +135,11 @@ third party ports for other languages like JavaScript, Python, Ruby, and so on.
#### `artifacts:reports:terraform`
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/207527) in GitLab 12.10.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207528) in GitLab 13.0.
> - Requires [GitLab Runner](https://docs.gitlab.com/runner/) 11.5 and above.
The `terraform` report collects Terraform `tfplan.json` files. The collected Terraform
plan reports will be uploaded to GitLab as artifacts and will be automatically shown
The `terraform` report obtains a Terraform `tfplan.json` file. The collected Terraform
plan report will be uploaded to GitLab as an artifact and will be automatically shown
in merge requests.
#### `artifacts:reports:codequality` **(STARTER)**

View File

@ -118,6 +118,7 @@ Once synchronized, changing the field mapped to `id` and `externalId` will likel
### Okta configuration steps
The SAML application that was created during [Single sign-on](index.md#okta-setup-notes) setup for [Okta](https://developer.okta.com/docs/guides/saml-application-setup/overview/) now needs to be set up for SCIM.
Before proceeding, be sure to complete the [GitLab configuration](#gitlab-configuration) process.
1. Sign in to Okta.
1. If you see an **Admin** button in the top right, click the button. This will

View File

@ -20,7 +20,7 @@ by default. To enable it for existing projects, or if you want to disable it:
1. Find the Packages feature and enable or disable it.
1. Click on **Save changes** for the changes to take effect.
You should then be able to see the **Packages** section on the left sidebar.
You should then be able to see the **Packages & Registries** section on the left sidebar.
## Getting started

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@ -19,14 +19,14 @@ have its own space to store its Docker images.
You can read more about Docker Registry at <https://docs.docker.com/registry/introduction/>.
![Container Registry repositories](img/container_registry_repositories_v12_10.png)
![Container Registry repositories](img/container_registry_repositories_v13_0.png)
## Enable the Container Registry for your project
CAUTION: **Warning:**
The Container Registry follows the visibility settings of the project. If the project is public, so is the Container Registry.
If you cannot find the **Packages > Container Registry** entry under your
If you cannot find the **Packages & Registries > Container Registry** entry under your
project's sidebar, it is not enabled in your GitLab instance. Ask your
administrator to enable GitLab Container Registry following the
[administration documentation](../../../administration/packages/container_registry.md).
@ -44,7 +44,7 @@ project:
projects this might be enabled by default. For existing projects
(prior GitLab 8.8), you will have to explicitly enable it.
1. Press **Save changes** for the changes to take effect. You should now be able
to see the **Packages > Container Registry** link in the sidebar.
to see the **Packages & Registries > Container Registry** link in the sidebar.
## Control Container Registry from within GitLab
@ -53,9 +53,9 @@ for both projects and groups.
### Control Container Registry for your project
Navigate to your project's **{package}** **Packages > Container Registry**.
Navigate to your project's **{package}** **Packages & Registries > Container Registry**.
![Container Registry project repositories](img/container_registry_repositories_with_quickstart_v12_10.png)
![Container Registry project repositories](img/container_registry_repositories_with_quickstart_v13_0.png)
This view will:
@ -67,9 +67,9 @@ This view will:
### Control Container Registry for your group
Navigate to your groups's **{package}** **Packages > Container Registry**.
Navigate to your groups's **{package}** **Packages & Registries > Container Registry**.
![Container Registry group repositories](img/container_registry_group_repositories_v12_10.png)
![Container Registry group repositories](img/container_registry_group_repositories_v13_0.png)
This view will:
@ -81,7 +81,7 @@ This view will:
Clicking on the name of any image repository will navigate to the details.
![Container Registry project repository details](img/container_registry_repository_details_v12.10.png)
![Container Registry project repository details](img/container_registry_repository_details_v13.0.png)
NOTE: **Note:**
The following page has the same functionalities both in the **Group level container registry**
@ -108,7 +108,7 @@ For more information on running Docker containers, visit the
## Authenticating to the GitLab Container Registry
If you visit the **Packages > Container Registry** link under your project's
If you visit the **Packages & Registries > Container Registry** link under your project's
menu, you can see the explicit instructions to login to the Container Registry
using your GitLab credentials.
@ -389,7 +389,7 @@ the deleted images.
To delete images from within GitLab:
1. Navigate to your project's or group's **{package}** **Packages > Container Registry**.
1. Navigate to your project's or group's **{package}** **Packages & Registries > Container Registry**.
1. From the **Container Registry** page, you can select what you want to delete,
by either:
@ -401,7 +401,7 @@ To delete images from within GitLab:
1. In the dialog box, click **Remove tag**.
![Container Registry tags](img/container_registry_tags_v12_10.png)
![Container Registry tags](img/container_registry_repository_details_v13.0.png)
### Delete images using the API

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -12,7 +12,7 @@ receiving a request and returning the upstream image from a registry, acting
as a pull-through cache.
The dependency proxy is available in the group level. To access it, navigate to
a group's **Packages > Dependency Proxy**.
a group's **Packages & Registries > Dependency Proxy**.
![Dependency Proxy group page](img/group_dependency_proxy.png)
@ -33,7 +33,7 @@ The following dependency proxies are supported.
With the Docker dependency proxy, you can use GitLab as a source for a Docker image.
To get a Docker image into the dependency proxy:
1. Find the proxy URL on your group's page under **Packages > Dependency Proxy**,
1. Find the proxy URL on your group's page under **Packages & Registries > Dependency Proxy**,
for example `gitlab.com/groupname/dependency_proxy/containers`.
1. Trigger GitLab to pull the Docker image you want (e.g., `alpine:latest` or
`linuxserver/nextcloud:latest`) and store it in the proxy storage by using

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -18,7 +18,7 @@ The Packages feature allows GitLab to act as a repository for the following:
## Enable the Package Registry for your project
If you cannot find the **{package}** **Packages > List** entry under your
If you cannot find the **{package}** **Packages & Registries > Package Registry** entry under your
project's sidebar, it is not enabled in your GitLab instance. Ask your
administrator to enable GitLab Package Registry following the [administration
documentation](../../administration/packages/index.md).
@ -30,14 +30,14 @@ project:
1. Expand the **Visibility, project features, permissions** section and enable the
**Packages** feature on your project.
1. Press **Save changes** for the changes to take effect. You should now be able to
see the **Packages > List** link in the sidebar.
see the **Packages & Registries > Package Registry** link in the sidebar.
### View Packages for your project
Navigating to your project's **{package}** **Packages > List** will show a list
Navigating to your project's **{package}** **Packages & Registries > Package Registry** will show a list
of all packages that have been added to your project.
![Project Packages list](img/project_packages_list_v12_10.png)
![Project Packages list](img/project_packages_list_v13_0.png)
On this page, you can:
@ -51,9 +51,9 @@ On this page, you can:
### View Packages for your group
You can view all packages belonging to a group by navigating to **{package}**
**Packages > List** from the group sidebar.
**Packages & Registries > Package Registry** from the group sidebar.
![Group Packages list](img/group_packages_list_v12_10.png)
![Group Packages list](img/group_packages_list_v13_0.png)
On this page, you can:
@ -68,7 +68,7 @@ On this page, you can:
Additional package information can be viewed by browsing to the package details
page from the either the project or group list.
![Package detail](img/package_detail_v12_10.png)
![Package detail](img/package_detail_v13_0.png)
On this page you can:

View File

@ -21,7 +21,7 @@ to disable it:
1. Find the Packages feature and enable or disable it.
1. Click on **Save changes** for the changes to take effect.
You should then be able to see the **Packages** section on the left sidebar.
You should then be able to see the **Packages & Registries** section on the left sidebar.
Next, you must configure your project to authorize with the GitLab Maven
repository.
@ -595,7 +595,7 @@ Run the publish task:
gradle publish
```
You can then navigate to your project's **Packages** page and see the uploaded
You can then navigate to your project's **Packages & Registries** page and see the uploaded
artifacts or even delete them.
## Installing a package

View File

@ -23,7 +23,7 @@ by default. To enable it for existing projects, or if you want to disable it:
1. Find the Packages feature and enable or disable it.
1. Click on **Save changes** for the changes to take effect.
You should then be able to see the **Packages** section on the left sidebar.
You should then be able to see the **Packages & Registries** section on the left sidebar.
Before proceeding to authenticating with the GitLab NPM Registry, you should
get familiar with the package naming convention.
@ -195,7 +195,7 @@ you can upload an NPM package to your project:
npm publish
```
You can then navigate to your project's **Packages** page and see the uploaded
You can then navigate to your project's **Packages & Registries** page and see the uploaded
packages or even delete them.
If you attempt to publish a package with a name that already exists within

View File

@ -61,7 +61,7 @@ by default. To enable it for existing projects, or if you want to disable it:
1. Find the Packages feature and enable or disable it.
1. Click on **Save changes** for the changes to take effect.
You should then be able to see the **Packages** section on the left sidebar.
You should then be able to see the **Packages & Registries** section on the left sidebar.
## Adding the GitLab NuGet Repository as a source to NuGet

View File

@ -26,7 +26,7 @@ by default. To enable it for existing projects, or if you want to disable it:
1. Find the Packages feature and enable or disable it.
1. Click on **Save changes** for the changes to take effect.
You should then be able to see the **Packages** section on the left sidebar.
You should then be able to see the **Packages & Registries** section on the left sidebar.
## Getting started
@ -197,7 +197,7 @@ Uploading mypypipackage-0.0.1.tar.gz
```
This indicates that the package was uploaded successfully. You can then navigate
to your project's **Packages** page and see the uploaded packages.
to your project's **Packages & Registries** page and see the uploaded packages.
If you did not follow the guide above, the you'll need to ensure your package
has been properly built and you [created a PyPi package with setuptools](https://packaging.python.org/tutorials/packaging-projects/).

View File

@ -194,7 +194,7 @@ Variables for Prometheus queries must be lowercase.
There are 2 methods to specify a variable in a query or dashboard:
1. Variables can be specified using the [Liquid template format](https://shopify.dev/docs/liquid/reference/basics), for example `{{ci_environment_slug}}` ([added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20793) in GitLab 12.6).
1. Variables can be specified using double curly braces, such as `{{ci_environment_slug}}` ([added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20793) in GitLab 12.7).
1. You can also enclose it in quotation marks with curly braces with a leading percent, for example `"%{ci_environment_slug}"`. This method is deprecated though and support will be [removed in the next major release](https://gitlab.com/gitlab-org/gitlab/issues/37990).
#### Query Variables from URL

View File

@ -16,6 +16,7 @@ module Gitlab
retry_index = 0
@invalid_path_error = false
@invalid_signature_error = false
begin
create_repository_and_files(snippet)
@ -23,10 +24,11 @@ module Gitlab
logger.info(message: 'Snippet Migration: repository created and migrated', snippet: snippet.id)
rescue => e
set_file_path_error(e)
set_signature_error(e)
retry_index += 1
retry if retry_index < MAX_RETRIES
retry if retry_index < max_retries
logger.error(message: "Snippet Migration: error migrating snippet. Reason: #{e.message}", snippet: snippet.id)
@ -101,6 +103,7 @@ module Gitlab
# In this scenario the migration bot user will be the one that will commit the files.
def commit_author(snippet)
return migration_bot_user if snippet_content_size_over_limit?(snippet)
return migration_bot_user if @invalid_signature_error
if Gitlab::UserAccessSnippet.new(snippet.author, snippet: snippet).can_do_action?(:update_snippet)
snippet.author
@ -119,7 +122,23 @@ module Gitlab
# the migration can succeed, to achieve that, we'll identify in migration retries
# that the path is invalid
def set_file_path_error(error)
@invalid_path_error = error.is_a?(SnippetRepository::InvalidPathError)
@invalid_path_error ||= error.is_a?(SnippetRepository::InvalidPathError)
end
# We sometimes receive invalid signature from Gitaly if the commit author
# name or email is invalid to create the commit signature.
# In this situation, we set the error and use the migration_bot since
# the information used to build it is valid
def set_signature_error(error)
@invalid_signature_error ||= error.is_a?(SnippetRepository::InvalidSignatureError)
end
# In the case where the snippet file_name is invalid and also the
# snippet author has invalid commit info, we need to increase the
# number of retries by 1, because we will receive two errors
# from Gitaly and, in the third one, we will commit successfully.
def max_retries
MAX_RETRIES + (@invalid_signature_error && @invalid_path_error ? 1 : 0)
end
def snippet_content_size_over_limit?(snippet)

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module SystemCheck
module App
class HashedStorageAllProjectsCheck < SystemCheck::BaseCheck
set_name 'All projects are in hashed storage?'
def check?
!Project.with_unmigrated_storage.exists?
end
def show_error
try_fixing_it(
"Please migrate all projects to hashed storage#{' on the primary' if Gitlab.ee? && Gitlab::Geo.secondary?}",
"as legacy storage is deprecated in 13.0 and support will be removed in 13.4."
)
for_more_information('doc/administration/repository_storage_types.md')
end
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module SystemCheck
module App
class HashedStorageEnabledCheck < SystemCheck::BaseCheck
set_name 'GitLab configured to store new projects in hashed storage?'
def check?
Gitlab::CurrentSettings.current_application_settings.hashed_storage_enabled
end
def show_error
try_fixing_it(
"Please enable the setting",
"`Use hashed storage paths for newly created and renamed projects`",
"in GitLab's Admin panel to avoid security issues and ensure data integrity."
)
for_more_information('doc/administration/repository_storage_types.md')
end
end
end
end

View File

@ -31,7 +31,9 @@ module SystemCheck
SystemCheck::App::GitVersionCheck,
SystemCheck::App::GitUserDefaultSSHConfigCheck,
SystemCheck::App::ActiveUsersCheck,
SystemCheck::App::AuthorizedKeysPermissionCheck
SystemCheck::App::AuthorizedKeysPermissionCheck,
SystemCheck::App::HashedStorageEnabledCheck,
SystemCheck::App::HashedStorageAllProjectsCheck
]
end
end

View File

@ -1069,12 +1069,6 @@ msgstr ""
msgid "AccessTokens|reset it"
msgstr ""
msgid "AccessibilityReport|Accessibility report artifact not found"
msgstr ""
msgid "AccessibilityReport|Failed to retrieve accessibility report"
msgstr ""
msgid "AccessibilityReport|Learn More"
msgstr ""
@ -8519,9 +8513,6 @@ msgstr ""
msgid "Error rendering markdown preview"
msgstr ""
msgid "Error rendering query"
msgstr ""
msgid "Error saving label update."
msgstr ""
@ -12998,9 +12989,6 @@ msgstr ""
msgid "Memory Usage"
msgstr ""
msgid "Memory limit exceeded while rendering template"
msgstr ""
msgid "Merge"
msgstr ""

View File

@ -0,0 +1,20 @@
module RuboCop
module Cop
# Cop that blacklists keyword arguments usage in Sidekiq workers
class AvoidKeywordArgumentsInSidekiqWorkers < RuboCop::Cop::Cop
MSG = "Do not use keyword arguments in Sidekiq workers. " \
"For details, check https://github.com/mperham/sidekiq/issues/2372".freeze
OBSERVED_METHOD = :perform
def on_def(node)
return if node.method_name != OBSERVED_METHOD
node.arguments.each do |argument|
if argument.type == :kwarg || argument.type == :kwoptarg
add_offense(node, location: :expression)
end
end
end
end
end
end

View File

@ -560,3 +560,214 @@ export const mockNamespacedData = {
export const mockLogsPath = '/mockLogsPath';
export const mockLogsHref = `${mockLogsPath}?duration_seconds=${mockTimeRange.duration.seconds}`;
const templatingVariableTypes = {
text: {
simple: 'Simple text',
advanced: {
label: 'Variable 4',
type: 'text',
options: {
default_value: 'default',
},
},
},
custom: {
simple: ['value1', 'value2', 'value3'],
advanced: {
normal: {
label: 'Advanced Var',
type: 'custom',
options: {
values: [
{ value: 'value1', text: 'Var 1 Option 1' },
{
value: 'value2',
text: 'Var 1 Option 2',
default: true,
},
],
},
},
withoutOpts: {
type: 'custom',
options: {},
},
withoutLabel: {
type: 'custom',
options: {
values: [
{ value: 'value1', text: 'Var 1 Option 1' },
{
value: 'value2',
text: 'Var 1 Option 2',
default: true,
},
],
},
},
withoutType: {
label: 'Variable 2',
options: {
values: [
{ value: 'value1', text: 'Var 1 Option 1' },
{
value: 'value2',
text: 'Var 1 Option 2',
default: true,
},
],
},
},
},
},
};
const generateMockTemplatingData = data => {
const vars = data
? {
variables: {
...data,
},
}
: {};
return {
dashboard: {
templating: vars,
},
};
};
const responseForSimpleTextVariable = {
simpleText: {
label: 'simpleText',
type: 'text',
value: 'Simple text',
},
};
const responseForAdvTextVariable = {
advText: {
label: 'Variable 4',
type: 'text',
value: 'default',
},
};
const responseForSimpleCustomVariable = {
simpleCustom: {
label: 'simpleCustom',
options: [
{
default: false,
text: 'value1',
value: 'value1',
},
{
default: false,
text: 'value2',
value: 'value2',
},
{
default: false,
text: 'value3',
value: 'value3',
},
],
type: 'custom',
},
};
const responseForAdvancedCustomVariableWithoutOptions = {
advCustomWithoutOpts: {
label: 'advCustomWithoutOpts',
options: [],
type: 'custom',
},
};
const responseForAdvancedCustomVariableWithoutLabel = {
advCustomWithoutLabel: {
label: 'advCustomWithoutLabel',
options: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
type: 'custom',
},
};
const responseForAdvancedCustomVariable = {
...responseForSimpleCustomVariable,
advCustomNormal: {
label: 'Advanced Var',
options: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
type: 'custom',
},
};
const responsesForAllVariableTypes = {
...responseForSimpleTextVariable,
...responseForAdvTextVariable,
...responseForSimpleCustomVariable,
...responseForAdvancedCustomVariable,
};
export const mockTemplatingData = {
emptyTemplatingProp: generateMockTemplatingData(),
emptyVariablesProp: generateMockTemplatingData({}),
simpleText: generateMockTemplatingData({ simpleText: templatingVariableTypes.text.simple }),
advText: generateMockTemplatingData({ advText: templatingVariableTypes.text.advanced }),
simpleCustom: generateMockTemplatingData({ simpleCustom: templatingVariableTypes.custom.simple }),
advCustomWithoutOpts: generateMockTemplatingData({
advCustomWithoutOpts: templatingVariableTypes.custom.advanced.withoutOpts,
}),
advCustomWithoutType: generateMockTemplatingData({
advCustomWithoutType: templatingVariableTypes.custom.advanced.withoutType,
}),
advCustomWithoutLabel: generateMockTemplatingData({
advCustomWithoutLabel: templatingVariableTypes.custom.advanced.withoutLabel,
}),
simpleAndAdv: generateMockTemplatingData({
simpleCustom: templatingVariableTypes.custom.simple,
advCustomNormal: templatingVariableTypes.custom.advanced.normal,
}),
allVariableTypes: generateMockTemplatingData({
simpleText: templatingVariableTypes.text.simple,
advText: templatingVariableTypes.text.advanced,
simpleCustom: templatingVariableTypes.custom.simple,
advCustomNormal: templatingVariableTypes.custom.advanced.normal,
}),
};
export const mockTemplatingDataResponses = {
emptyTemplatingProp: {},
emptyVariablesProp: {},
simpleText: responseForSimpleTextVariable,
advText: responseForAdvTextVariable,
simpleCustom: responseForSimpleCustomVariable,
advCustomWithoutOpts: responseForAdvancedCustomVariableWithoutOptions,
advCustomWithoutType: {},
advCustomWithoutLabel: responseForAdvancedCustomVariableWithoutLabel,
simpleAndAdv: responseForAdvancedCustomVariable,
allVariableTypes: responsesForAllVariableTypes,
};

View File

@ -1,149 +1,21 @@
import { parseTemplatingVariables } from '~/monitoring/stores/variable_mapping';
import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data';
describe('parseTemplatingVariables', () => {
const generateMockTemplatingData = data => {
const vars = data
? {
variables: {
...data,
},
}
: {};
return {
dashboard: {
templating: vars,
},
};
};
const simpleVar = ['value1', 'value2', 'value3'];
const advVar = {
label: 'Advanced Var',
type: 'custom',
options: {
values: [
{ value: 'value1', text: 'Var 1 Option 1' },
{
value: 'value2',
text: 'Var 1 Option 2',
default: true,
},
],
},
};
const advVarWithoutOptions = {
type: 'custom',
options: {},
};
const advVarWithoutLabel = {
type: 'custom',
options: {
values: [
{ value: 'value1', text: 'Var 1 Option 1' },
{
value: 'value2',
text: 'Var 1 Option 2',
default: true,
},
],
},
};
const advVarWithoutType = {
label: 'Variable 2',
options: {
values: [
{ value: 'value1', text: 'Var 1 Option 1' },
{
value: 'value2',
text: 'Var 1 Option 2',
default: true,
},
],
},
};
const responseForSimpleCustomVariable = {
simpleVar: {
label: 'simpleVar',
options: [
{
default: false,
text: 'value1',
value: 'value1',
},
{
default: false,
text: 'value2',
value: 'value2',
},
{
default: false,
text: 'value3',
value: 'value3',
},
],
type: 'custom',
},
};
const responseForAdvancedCustomVariableWithoutOptions = {
advVarWithoutOptions: {
label: 'advVarWithoutOptions',
options: [],
type: 'custom',
},
};
const responseForAdvancedCustomVariableWithoutLabel = {
advVarWithoutLabel: {
label: 'advVarWithoutLabel',
options: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
type: 'custom',
},
};
const responseForAdvancedCustomVariable = {
...responseForSimpleCustomVariable,
advVar: {
label: 'Advanced Var',
options: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
type: 'custom',
},
};
it.each`
case | input | expected
${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}}
${'Returns empty object for empty templating prop'} | ${generateMockTemplatingData()} | ${{}}
${'Returns empty object for empty variables prop'} | ${generateMockTemplatingData({})} | ${{}}
${'Returns parsed object for simple variable'} | ${generateMockTemplatingData({ simpleVar })} | ${responseForSimpleCustomVariable}
${'Returns parsed object for advanced variable without options'} | ${generateMockTemplatingData({ advVarWithoutOptions })} | ${responseForAdvancedCustomVariableWithoutOptions}
${'Returns parsed object for advanced variable without type'} | ${generateMockTemplatingData({ advVarWithoutType })} | ${{}}
${'Returns parsed object for advanced variable without label'} | ${generateMockTemplatingData({ advVarWithoutLabel })} | ${responseForAdvancedCustomVariableWithoutLabel}
${'Returns parsed object for simple and advanced variables'} | ${generateMockTemplatingData({ simpleVar, advVar })} | ${responseForAdvancedCustomVariable}
case | input | expected
${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}}
${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}}
${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}}
${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText}
${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText}
${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom}
${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts}
${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}}
${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel}
${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv}
${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes}
`('$case', ({ input, expected }) => {
expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected);
});

View File

@ -37,6 +37,7 @@ describe('Details Page', () => {
const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
const findAlert = () => wrapper.find(GlAlert);
const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
@ -248,15 +249,24 @@ describe('Details Page', () => {
});
});
describe('tag cell', () => {
describe('name cell', () => {
it('tag column has a tooltip with the tag name', () => {
mountComponent();
expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name);
});
describe('on desktop viewport', () => {
beforeEach(() => {
mountComponent();
});
it('has class w-25', () => {
it('table header has class w-25', () => {
expect(findFirsTagColumn().classes()).toContain('w-25');
});
it('tag column has the mw-m class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
});
});
describe('on mobile viewport', () => {
@ -268,9 +278,13 @@ describe('Details Page', () => {
});
});
it('does not has class w-25', () => {
it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
});
it('tag column has the gl-justify-content-end class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
});
});
});

View File

@ -3,7 +3,7 @@ import Vuex from 'vuex';
import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue';
import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue';
import store from '~/reports/accessibility_report/store';
import { comparedReportResult } from './mock_data';
import { mockReport } from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
@ -18,8 +18,7 @@ describe('Grouped accessibility reports app', () => {
store: mockStore,
localVue,
propsData: {
baseEndpoint: 'base_endpoint.json',
headEndpoint: 'head_endpoint.json',
endpoint: 'endpoint.json',
},
methods: {
fetchReport: () => {},
@ -66,8 +65,7 @@ describe('Grouped accessibility reports app', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 0,
warnings: 0,
errored: 0,
},
};
});
@ -83,8 +81,7 @@ describe('Grouped accessibility reports app', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 0,
warnings: 1,
errored: 1,
},
};
});
@ -100,8 +97,7 @@ describe('Grouped accessibility reports app', () => {
beforeEach(() => {
mockStore.state.report = {
summary: {
errors: 1,
warnings: 1,
errored: 2,
},
};
});
@ -115,18 +111,15 @@ describe('Grouped accessibility reports app', () => {
describe('with issues to show', () => {
beforeEach(() => {
mockStore.state.report = comparedReportResult;
mockStore.state.report = mockReport;
});
it('renders custom accessibility issue body', () => {
const issueBody = wrapper.find(AccessibilityIssueBody);
expect(issueBody.props('issue').name).toEqual(comparedReportResult.new_errors[0].name);
expect(issueBody.props('issue').code).toEqual(comparedReportResult.new_errors[0].code);
expect(issueBody.props('issue').message).toEqual(
comparedReportResult.new_errors[0].message,
);
expect(issueBody.props('isNew')).toEqual(true);
expect(issueBody.props('issue').code).toBe(mockReport.new_errors[0].code);
expect(issueBody.props('issue').message).toBe(mockReport.new_errors[0].message);
expect(issueBody.props('isNew')).toBe(true);
});
});
});

View File

@ -1,86 +1,55 @@
export const baseReport = {
results: {
'http://about.gitlab.com/users/sign_in': [
{
code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
type: 'error',
typeCode: 1,
message:
'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 2.82:1. Recommendation: change background to #d1470c.',
context:
'<a class="btn btn-nav-cta btn-nav-link-cta" href="/free-trial">\nGet free trial\n</a>',
selector: '#main-nav > div:nth-child(2) > ul > div:nth-child(8) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
'https://about.gitlab.com': [
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
type: 'error',
typeCode: 1,
message:
'Anchor element found with a valid href attribute, but no link content has been supplied.',
context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
selector: '#main-nav > div:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
},
};
export const parsedBaseReport = [
'{"code":"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail","type":"error","typeCode":1,"message":"This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 2.82:1. Recommendation: change background to #d1470c.","context":"<a class=\\"btn btn-nav-cta btn-nav-link-cta\\" href=\\"/free-trial\\">\\nGet free trial\\n</a>","selector":"#main-nav > div:nth-child(2) > ul > div:nth-child(8) > a","runner":"htmlcs","runnerExtras":{}}',
'{"code":"WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent","type":"error","typeCode":1,"message":"Anchor element found with a valid href attribute, but no link content has been supplied.","context":"<a href=\\"/\\" class=\\"navbar-brand animated\\"><svg height=\\"36\\" viewBox=\\"0 0 1...</a>","selector":"#main-nav > div:nth-child(1) > a","runner":"htmlcs","runnerExtras":{}}',
];
export const headReport = {
results: {
'http://about.gitlab.com/users/sign_in': [
{
code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
type: 'error',
typeCode: 1,
message:
'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.',
context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>',
selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
'https://about.gitlab.com': [
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
type: 'error',
typeCode: 1,
message:
'Anchor element found with a valid href attribute, but no link content has been supplied.',
context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
selector: '#main-nav > div:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
},
};
export const comparedReportResult = {
export const mockReport = {
status: 'failed',
summary: {
total: 2,
notes: 0,
errors: 2,
warnings: 0,
resolved: 0,
errored: 2,
},
new_errors: [headReport.results['http://about.gitlab.com/users/sign_in'][0]],
new_errors: [
{
code: 'WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail',
type: 'error',
typeCode: 1,
message:
'This element has insufficient contrast at this conformance level. Expected a contrast ratio of at least 4.5:1, but text in this element has a contrast ratio of 3.84:1. Recommendation: change text colour to #767676.',
context: '<a href="/stages-devops-lifecycle/" class="main-nav-link">Product</a>',
selector: '#main-nav > div:nth-child(2) > ul > li:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
new_notes: [],
new_warnings: [],
resolved_errors: [baseReport.results['http://about.gitlab.com/users/sign_in'][0]],
resolved_errors: [
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
type: 'error',
typeCode: 1,
message:
'Anchor element found with a valid href attribute, but no link content has been supplied.',
context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
selector: '#main-nav > div:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
resolved_notes: [],
resolved_warnings: [],
existing_errors: [headReport.results['https://about.gitlab.com'][0]],
existing_errors: [
{
code: 'WCAG2AA.Principle4.Guideline4_1.4_1_2.H91.A.NoContent',
type: 'error',
typeCode: 1,
message:
'Anchor element found with a valid href attribute, but no link content has been supplied.',
context: '<a href="/" class="navbar-brand animated"><svg height="36" viewBox="0 0 1...</a>',
selector: '#main-nav > div:nth-child(1) > a',
runner: 'htmlcs',
runnerExtras: {},
},
],
existing_notes: [],
existing_warnings: [],
};
export default () => {};

View File

@ -5,7 +5,7 @@ import * as types from '~/reports/accessibility_report/store/mutation_types';
import createStore from '~/reports/accessibility_report/store';
import { TEST_HOST } from 'spec/test_constants';
import testAction from 'helpers/vuex_action_helper';
import { baseReport, headReport, comparedReportResult } from '../mock_data';
import { mockReport } from '../mock_data';
describe('Accessibility Reports actions', () => {
let localState;
@ -18,14 +18,13 @@ describe('Accessibility Reports actions', () => {
describe('setEndpoints', () => {
it('should commit SET_ENDPOINTS mutation', done => {
const baseEndpoint = 'base_endpoint.json';
const headEndpoint = 'head_endpoint.json';
const endpoint = 'endpoint.json';
testAction(
actions.setEndpoints,
{ baseEndpoint, headEndpoint },
actions.setEndpoint,
endpoint,
localState,
[{ type: types.SET_ENDPOINTS, payload: { baseEndpoint, headEndpoint } }],
[{ type: types.SET_ENDPOINT, payload: endpoint }],
[],
done,
);
@ -36,37 +35,14 @@ describe('Accessibility Reports actions', () => {
let mock;
beforeEach(() => {
localState.baseEndpoint = `${TEST_HOST}/endpoint.json`;
localState.headEndpoint = `${TEST_HOST}/endpoint.json`;
localState.endpoint = `${TEST_HOST}/endpoint.json`;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('when no endpoints are given', () => {
beforeEach(() => {
localState.baseEndpoint = null;
localState.headEndpoint = null;
});
it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', done => {
testAction(
actions.fetchReport,
null,
localState,
[
{ type: types.REQUEST_REPORT },
{
type: types.RECEIVE_REPORT_ERROR,
payload: 'Accessibility report artifact not found',
},
],
[],
done,
);
});
actions.stopPolling();
actions.clearEtagPoll();
});
describe('success', () => {
@ -81,7 +57,7 @@ describe('Accessibility Reports actions', () => {
[{ type: types.REQUEST_REPORT }],
[
{
payload: [{ ...data, isHead: false }, { ...data, isHead: true }],
payload: { status: 200, data },
type: 'receiveReportSuccess',
},
],
@ -98,14 +74,8 @@ describe('Accessibility Reports actions', () => {
actions.fetchReport,
null,
localState,
[
{ type: types.REQUEST_REPORT },
{
type: types.RECEIVE_REPORT_ERROR,
payload: 'Failed to retrieve accessibility report',
},
],
[],
[{ type: types.REQUEST_REPORT }],
[{ type: 'receiveReportError' }],
done,
);
});
@ -113,15 +83,39 @@ describe('Accessibility Reports actions', () => {
});
describe('receiveReportSuccess', () => {
it('should commit RECEIVE_REPORT_SUCCESS mutation', done => {
it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', done => {
testAction(
actions.receiveReportSuccess,
[{ ...baseReport, isHead: false }, { ...headReport, isHead: true }],
{ status: 200, data: mockReport },
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: comparedReportResult }],
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }],
[{ type: 'stopPolling' }],
done,
);
});
it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', done => {
testAction(
actions.receiveReportSuccess,
{ status: 204, data: mockReport },
localState,
[],
[],
done,
);
});
});
describe('receiveReportError', () => {
it('should commit RECEIVE_REPORT_ERROR mutation', done => {
testAction(
actions.receiveReportError,
null,
localState,
[{ type: types.RECEIVE_REPORT_ERROR }],
[{ type: 'stopPolling' }],
done,
);
});
});
});

View File

@ -67,8 +67,7 @@ describe('Accessibility reports store getters', () => {
it('returns summary message containing number of errors', () => {
localState.report = {
summary: {
errors: 1,
warnings: 1,
errored: 2,
},
};
const result = 'Accessibility scanning detected 2 issues for the source branch only';
@ -81,8 +80,7 @@ describe('Accessibility reports store getters', () => {
it('returns summary message containing no errors', () => {
localState.report = {
summary: {
errors: 0,
warnings: 0,
errored: 0,
},
};
const result = 'Accessibility scanning detected no issues for the source branch only';
@ -108,7 +106,7 @@ describe('Accessibility reports store getters', () => {
it('returns false', () => {
localState.report = {
status: 'success',
summary: { errors: 0, warnings: 0 },
summary: { errored: 0 },
};
expect(getters.shouldRenderIssuesList(localState)).toEqual(false);

View File

@ -10,17 +10,12 @@ describe('Accessibility Reports mutations', () => {
localState = localStore.state;
});
describe('SET_ENDPOINTS', () => {
it('sets base and head endpoints to give values', () => {
const baseEndpoint = 'base_endpoint.json';
const headEndpoint = 'head_endpoint.json';
mutations.SET_ENDPOINTS(localState, {
baseEndpoint,
headEndpoint,
});
describe('SET_ENDPOINT', () => {
it('sets endpoint to given value', () => {
const endpoint = 'endpoint.json';
mutations.SET_ENDPOINT(localState, endpoint);
expect(localState.baseEndpoint).toEqual(baseEndpoint);
expect(localState.headEndpoint).toEqual(headEndpoint);
expect(localState.endpoint).toEqual(endpoint);
});
});
@ -65,11 +60,5 @@ describe('Accessibility Reports mutations', () => {
expect(localState.hasError).toEqual(true);
});
it('sets errorMessage to given message', () => {
mutations.RECEIVE_REPORT_ERROR(localState, 'message');
expect(localState.errorMessage).toEqual('message');
});
});
});

View File

@ -1,35 +0,0 @@
import * as utils from '~/reports/accessibility_report/store/utils';
import { baseReport, headReport, parsedBaseReport, comparedReportResult } from '../mock_data';
describe('Accessibility Report store utils', () => {
describe('parseAccessibilityReport', () => {
it('returns array of stringified issues', () => {
const result = utils.parseAccessibilityReport(baseReport);
expect(result).toEqual(parsedBaseReport);
});
});
describe('compareAccessibilityReports', () => {
let reports;
beforeEach(() => {
reports = [
{
isHead: false,
issues: utils.parseAccessibilityReport(baseReport),
},
{
isHead: true,
issues: utils.parseAccessibilityReport(headReport),
},
];
});
it('returns the comparison report with a new, resolved, and existing error', () => {
const result = utils.compareAccessibilityReports(reports);
expect(result).toEqual(comparedReportResult);
});
});
});

View File

@ -11,13 +11,14 @@ describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, s
let(:user_state) { 'active' }
let(:ghost) { false }
let(:user_type) { nil }
let(:user_name) { 'Test' }
let!(:user) do
users.create(id: 1,
email: 'user@example.com',
projects_limit: 10,
username: 'test',
name: 'Test',
name: user_name,
state: user_state,
ghost: ghost,
last_activity_on: 1.minute.ago,
@ -232,6 +233,69 @@ describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, s
it_behaves_like 'migration_bot user commits files'
end
context 'when user name is invalid' do
let(:user_name) { '.' }
let!(:snippet) { snippets.create(id: 4, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
let(:ids) { [4, 4] }
after do
raw_repository(snippet).remove
end
it_behaves_like 'migration_bot user commits files'
end
context 'when both user name and snippet file_name are invalid' do
let(:user_name) { '.' }
let!(:other_user) do
users.create(id: 2,
email: 'user2@example.com',
projects_limit: 10,
username: 'test2',
name: 'Test2',
state: user_state,
ghost: ghost,
last_activity_on: 1.minute.ago,
user_type: user_type,
confirmed_at: 1.day.ago)
end
let!(:invalid_snippet) { snippets.create(id: 4, type: 'PersonalSnippet', author_id: user.id, file_name: '.', content: content) }
let!(:snippet) { snippets.create(id: 5, type: 'PersonalSnippet', author_id: other_user.id, file_name: file_name, content: content) }
let(:ids) { [4, 5] }
after do
raw_repository(snippet).remove
raw_repository(invalid_snippet).remove
end
it 'updates the file_name only when it is invalid' do
subject
expect(blob_at(invalid_snippet, 'snippetfile1.txt')).to be
expect(blob_at(snippet, file_name)).to be
end
it_behaves_like 'migration_bot user commits files' do
let(:snippet) { invalid_snippet }
end
it 'does not alter the commit author in subsequent migrations' do
subject
last_commit = raw_repository(snippet).commit
expect(last_commit.author_name).to eq other_user.name
expect(last_commit.author_email).to eq other_user.email
end
it "increases the number of retries temporarily from #{described_class::MAX_RETRIES} to #{described_class::MAX_RETRIES + 1}" do
expect(service).to receive(:create_commit).with(Snippet.find(invalid_snippet.id)).exactly(described_class::MAX_RETRIES + 1).times.and_call_original
expect(service).to receive(:create_commit).with(Snippet.find(snippet.id)).once.and_call_original
subject
end
end
end
def blob_at(snippet, path)

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'spec_helper'
require 'rake_helper'
describe SystemCheck::App::HashedStorageAllProjectsCheck do
before do
silence_output
end
describe '#check?' do
it 'fails when at least one project is in legacy storage' do
create(:project, :legacy_storage)
expect(subject.check?).to be_falsey
end
it 'succeeds when all projects are in hashed storage' do
create(:project)
expect(subject.check?).to be_truthy
end
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'spec_helper'
require 'rake_helper'
describe SystemCheck::App::HashedStorageEnabledCheck do
before do
silence_output
end
describe '#check?' do
it 'fails when hashed storage is disabled' do
stub_application_setting(hashed_storage_enabled: false)
expect(subject.check?).to be_falsey
end
it 'succeeds when hashed storage is enabled' do
stub_application_setting(hashed_storage_enabled: true)
expect(subject.check?).to be_truthy
end
end
end

View File

@ -217,6 +217,22 @@ describe SnippetRepository do
it_behaves_like 'snippet repository with git errors', 'invalid://path/here', described_class::InvalidPathError
it_behaves_like 'snippet repository with git errors', '../../path/traversal/here', described_class::InvalidPathError
it_behaves_like 'snippet repository with git errors', 'README', described_class::CommitError
context 'when user name is invalid' do
let(:user) { create(:user, name: '.') }
it_behaves_like 'snippet repository with git errors', 'non_existing_file', described_class::InvalidSignatureError
end
context 'when user email is empty' do
let(:user) { create(:user) }
before do
user.update_column(:email, '')
end
it_behaves_like 'snippet repository with git errors', 'non_existing_file', described_class::InvalidSignatureError
end
end
end

View File

@ -82,7 +82,7 @@ describe 'Getting Metrics Dashboard Annotations' do
"description" => annotation.description,
"id" => annotation.to_global_id.to_s,
"panelId" => annotation.panel_xid,
"startingAt" => annotation.starting_at.to_s,
"startingAt" => annotation.starting_at.iso8601,
"endingAt" => nil
}]
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'spec_helper'
require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../rubocop/cop/avoid_keyword_arguments_in_sidekiq_workers'
describe RuboCop::Cop::AvoidKeywordArgumentsInSidekiqWorkers do
include CopHelper
subject(:cop) { described_class.new }
it 'flags violation for keyword arguments usage in perform method signature' do
expect_offense(<<~RUBY)
def perform(id:)
^^^^^^^^^^^^^^^^ Do not use keyword arguments in Sidekiq workers. For details, check https://github.com/mperham/sidekiq/issues/2372
end
RUBY
end
it 'flags violation for optional keyword arguments usage in perform method signature' do
expect_offense(<<~RUBY)
def perform(id: nil)
^^^^^^^^^^^^^^^^^^^^ Do not use keyword arguments in Sidekiq workers. For details, check https://github.com/mperham/sidekiq/issues/2372
end
RUBY
end
it 'does not flag a violation for standard optional arguments usage in perform method signature' do
expect_no_offenses(<<~RUBY)
def perform(id = nil)
end
RUBY
end
it 'does not flag a violation for keyword arguments usage in non-perform method signatures' do
expect_no_offenses(<<~RUBY)
def helper(id:)
end
RUBY
end
it 'does not flag a violation for optional keyword arguments usage in non-perform method signatures' do
expect_no_offenses(<<~RUBY)
def helper(id: nil)
end
RUBY
end
end

View File

@ -142,7 +142,7 @@ describe Prometheus::ProxyVariableSubstitutionService do
end
it_behaves_like 'success' do
let(:expected_query) { 'up{pod_name=""}' }
let(:expected_query) { 'up{pod_name="{{pod_name}}"}' }
end
end
@ -161,28 +161,6 @@ describe Prometheus::ProxyVariableSubstitutionService do
end
end
context 'with liquid tags and ruby format variables' do
let(:params_keys) do
{
query: 'up{ {% if true %}env1="%{ci_environment_slug}",' \
'env2="{{ci_environment_slug}}"{% endif %} }'
}
end
# The following spec will fail and should be changed to a 'success' spec
# once we remove support for the Ruby interpolation format.
# https://gitlab.com/gitlab-org/gitlab/issues/37990
#
# Liquid tags `{% %}` cannot be used currently because the Ruby `%`
# operator raises an error when it encounters a Liquid `{% %}` tag in the
# string.
#
# Once we remove support for the Ruby format, users can start using
# Liquid tags.
it_behaves_like 'error', 'Malformed string'
end
context 'ruby template rendering' do
let(:params_keys) do
{ query: 'up{env=%{ci_environment_slug},%{environment_filter}}' }
@ -271,17 +249,79 @@ describe Prometheus::ProxyVariableSubstitutionService do
end
end
context 'when liquid template rendering raises error' do
before do
liquid_service = instance_double(TemplateEngines::LiquidService)
context 'gsub variable substitution tolerance for weirdness' do
context 'with whitespace around variable' do
let(:params_keys) do
{
query: 'up{' \
"env1={{ ci_environment_slug}}," \
"env2={{ci_environment_slug }}," \
"{{ environment_filter }}" \
'}'
}
end
allow(TemplateEngines::LiquidService).to receive(:new).and_return(liquid_service)
allow(liquid_service).to receive(:render).and_raise(
TemplateEngines::LiquidService::RenderError, 'error message'
)
it_behaves_like 'success' do
let(:expected_query) do
'up{' \
"env1=#{environment.slug}," \
"env2=#{environment.slug}," \
"container_name!=\"POD\",environment=\"#{environment.slug}\"" \
'}'
end
end
end
it_behaves_like 'error', 'error message'
context 'with empty variables' do
let(:params_keys) do
{ query: "up{env1={{}},env2={{ }}}" }
end
it_behaves_like 'success' do
let(:expected_query) { "up{env1={{}},env2={{ }}}" }
end
end
context 'with multiple occurrences of variable in string' do
let(:params_keys) do
{ query: "up{env1={{ci_environment_slug}},env2={{ci_environment_slug}}}" }
end
it_behaves_like 'success' do
let(:expected_query) { "up{env1=#{environment.slug},env2=#{environment.slug}}" }
end
end
context 'with multiple variables in string' do
let(:params_keys) do
{ query: "up{env={{ci_environment_slug}},{{environment_filter}}}" }
end
it_behaves_like 'success' do
let(:expected_query) do
"up{env=#{environment.slug}," \
"container_name!=\"POD\",environment=\"#{environment.slug}\"}"
end
end
end
context 'with unknown variables in string' do
let(:params_keys) { { query: "up{env={{env_slug}}}" } }
it_behaves_like 'success' do
let(:expected_query) { "up{env={{env_slug}}}" }
end
end
context 'with unknown and known variables in string' do
let(:params_keys) do
{ query: "up{env={{ci_environment_slug}},other_env={{env_slug}}}" }
end
it_behaves_like 'success' do
let(:expected_query) { "up{env=#{environment.slug},other_env={{env_slug}}}" }
end
end
end
end
end

View File

@ -1,126 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
describe TemplateEngines::LiquidService do
describe '#render' do
let(:template) { 'up{env={{ci_environment_slug}}}' }
let(:result) { subject }
let_it_be(:slug) { 'env_slug' }
let_it_be(:context) do
{
ci_environment_slug: slug,
environment_filter: "container_name!=\"POD\",environment=\"#{slug}\""
}
end
subject { described_class.new(template).render(context) }
it 'with symbol keys in context it substitutes variables' do
expect(result).to include("up{env=#{slug}")
end
context 'with multiple occurrences of variable in template' do
let(:template) do
'up{env1={{ci_environment_slug}},env2={{ci_environment_slug}}}'
end
it 'substitutes variables' do
expect(result).to eq("up{env1=#{slug},env2=#{slug}}")
end
end
context 'with multiple variables in template' do
let(:template) do
'up{env={{ci_environment_slug}},' \
'{{environment_filter}}}'
end
it 'substitutes all variables' do
expect(result).to eq(
"up{env=#{slug}," \
"container_name!=\"POD\",environment=\"#{slug}\"}"
)
end
end
context 'with unknown variables in template' do
let(:template) { 'up{env={{env_slug}}}' }
it 'does not substitute unknown variables' do
expect(result).to eq("up{env=}")
end
end
context 'with extra variables in context' do
let(:template) { 'up{env={{ci_environment_slug}}}' }
it 'substitutes variables' do
# If context has only 1 key, there is no need for this spec.
expect(context.count).to be > 1
expect(result).to eq("up{env=#{slug}}")
end
end
context 'with unknown and known variables in template' do
let(:template) { 'up{env={{ci_environment_slug}},other_env={{env_slug}}}' }
it 'substitutes known variables' do
expect(result).to eq("up{env=#{slug},other_env=}")
end
end
context 'Liquid errors' do
shared_examples 'raises RenderError' do |message|
it do
expect { result }.to raise_error(described_class::RenderError, message)
end
end
context 'when liquid raises error' do
let(:template) { 'up{env={{ci_environment_slug}}' }
let(:liquid_template) { Liquid::Template.new }
before do
allow(Liquid::Template).to receive(:parse).with(template).and_return(liquid_template)
allow(liquid_template).to receive(:render!).and_raise(exception, message)
end
context 'raises Liquid::MemoryError' do
let(:exception) { Liquid::MemoryError }
let(:message) { 'Liquid error: Memory limits exceeded' }
it_behaves_like 'raises RenderError', 'Memory limit exceeded while rendering template'
end
context 'raises Liquid::Error' do
let(:exception) { Liquid::Error }
let(:message) { 'Liquid error: Generic error message' }
it_behaves_like 'raises RenderError', 'Error rendering query'
end
end
context 'with template that is expensive to render' do
let(:template) do
'{% assign loop_count = 1000 %}'\
'{% assign padStr = "0" %}'\
'{% assign number_to_pad = "1" %}'\
'{% assign strLength = number_to_pad | size %}'\
'{% assign padLength = loop_count | minus: strLength %}'\
'{% if padLength > 0 %}'\
' {% assign padded = number_to_pad %}'\
' {% for position in (1..padLength) %}'\
' {% assign padded = padded | prepend: padStr %}'\
' {% endfor %}'\
' {{ padded }}'\
'{% endif %}'
end
it_behaves_like 'raises RenderError', 'Memory limit exceeded while rendering template'
end
end
end
end

View File

@ -17,6 +17,25 @@ describe CreateCommitSignatureWorker do
subject { described_class.new.perform(commit_shas, project.id) }
context 'when a signature is found' do
it_behaves_like 'an idempotent worker' do
let(:job_args) { [commit_shas, project.id] }
before do
# Removing the stub which can cause bugs for multiple calls to
# Project#commits_by.
allow(project).to receive(:commits_by).and_call_original
# Making sure it still goes through all the perform execution.
allow_next_instance_of(::Commit) do |commit|
allow(commit).to receive(:signature_type).and_return(:PGP)
end
allow_next_instance_of(::Gitlab::Gpg::Commit) do |gpg|
expect(gpg).to receive(:signature).once.and_call_original
end
end
end
it 'calls Gitlab::Gpg::Commit#signature' do
commits.each do |commit|
allow(commit).to receive(:signature_type).and_return(:PGP)