Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-05-17 15:09:01 +00:00
parent f0a387b4a5
commit d88ab3545c
79 changed files with 1487 additions and 380 deletions

View File

@ -176,4 +176,3 @@ You can either [create a follow-up issue for Feature Flag Cleanup](https://gitla
```
/label ~"feature flag" ~"type::feature" ~"feature::addition"
/assign DRI

View File

@ -1 +1 @@
6b31501b13eae70aea5061edc8273c551ba4c349
93762b621c011fe570339c1c247d5197c2cfefcc

View File

@ -1 +1 @@
1.57.0
1.58.0

View File

@ -93,6 +93,7 @@ const Api = {
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
secureFilePath: '/api/:version/projects/:project_id/secure_files/:secure_file_id',
secureFilesPath: '/api/:version/projects/:project_id/secure_files',
dependencyProxyPath: '/api/:version/groups/:id/dependency_proxy/cache',
@ -978,6 +979,22 @@ const Api = {
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...options } });
},
uploadProjectSecureFile(projectId, fileData) {
const url = Api.buildUrl(this.secureFilesPath).replace(':project_id', projectId);
const headers = { 'Content-Type': 'multipart/form-data' };
return axios.post(url, fileData, { headers });
},
deleteProjectSecureFile(projectId, secureFileId) {
const url = Api.buildUrl(this.secureFilePath)
.replace(':project_id', projectId)
.replace(':secure_file_id', secureFileId);
return axios.delete(url);
},
async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);

View File

@ -1,22 +1,48 @@
<script>
import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import {
GlAlert,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlModal,
GlModalDirective,
GlPagination,
GlSprintf,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import httpStatusCodes from '~/lib/utils/http_status';
import { __, s__, sprintf } from '~/locale';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlAlert,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlModal,
GlPagination,
GlSprintf,
GlTable,
TimeagoTooltip,
},
inject: ['projectId'],
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
inject: ['projectId', 'admin', 'fileSizeLimit'],
docsLink: helpPagePath('ci/secure_files/index'),
DEFAULT_PER_PAGE,
i18n: {
deleteLabel: __('Delete File'),
uploadLabel: __('Upload File'),
uploadingLabel: __('Uploading...'),
pagination: {
next: __('Next'),
prev: __('Prev'),
@ -26,23 +52,45 @@ export default {
'Use Secure Files to store files used by your pipelines such as Android keystores, or Apple provisioning profiles and signing certificates.',
),
moreInformation: __('More information'),
uploadErrorMessages: {
duplicate: __('A file with this name already exists.'),
tooLarge: __('File too large. Secure Files must be less than %{limit} MB.'),
},
deleteModalTitle: s__('SecureFiles|Delete %{name}?'),
deleteModalMessage: s__(
'SecureFiles|Secure File %{name} will be permanently deleted. Are you sure?',
),
deleteModalButton: s__('SecureFiles|Delete secure file'),
},
deleteModalId: 'deleteModalId',
data() {
return {
page: 1,
totalItems: 0,
loading: false,
uploading: false,
error: false,
errorMessage: null,
projectSecureFiles: [],
deleteModalFileId: null,
deleteModalFileName: null,
};
},
fields: [
{
key: 'name',
label: __('Filename'),
tdClass: 'gl-vertical-align-middle!',
},
{
key: 'created_at',
label: __('Uploaded'),
tdClass: 'gl-vertical-align-middle!',
},
{
key: 'actions',
label: '',
tdClass: 'gl-text-right gl-vertical-align-middle!',
},
],
computed: {
@ -59,6 +107,18 @@ export default {
this.getProjectSecureFiles();
},
methods: {
async deleteSecureFile(secureFileId) {
this.loading = true;
this.error = false;
try {
await Api.deleteProjectSecureFile(this.projectId, secureFileId);
this.getProjectSecureFiles();
} catch (error) {
Sentry.captureException(error);
this.error = true;
this.errorMessage = error;
}
},
async getProjectSecureFiles(page) {
this.loading = true;
const response = await Api.projectSecureFiles(this.projectId, { page });
@ -68,6 +128,48 @@ export default {
this.projectSecureFiles = response.data;
this.loading = false;
this.uploading = false;
},
async uploadSecureFile() {
this.error = null;
this.uploading = true;
const [file] = this.$refs.fileUpload.files;
try {
await Api.uploadProjectSecureFile(this.projectId, this.uploadFormData(file));
this.getProjectSecureFiles();
} catch (error) {
this.error = true;
this.errorMessage = this.formattedErrorMessage(error);
this.uploading = false;
}
},
formattedErrorMessage(error) {
let message = '';
if (error?.response?.data?.message?.name) {
message = this.$options.i18n.uploadErrorMessages.duplicate;
} else if (error.response.status === httpStatusCodes.PAYLOAD_TOO_LARGE) {
message = sprintf(this.$options.i18n.uploadErrorMessages.tooLarge, {
limit: this.fileSizeLimit,
});
} else {
Sentry.captureException(error);
message = error;
}
return message;
},
loadFileSelctor() {
this.$refs.fileUpload.click();
},
setDeleteModalData(secureFile) {
this.deleteModalFileId = secureFile.id;
this.deleteModalFileName = secureFile.name;
},
uploadFormData(file) {
const formData = new FormData();
formData.append('name', file.name);
formData.append('file', file);
return formData;
},
},
};
@ -75,16 +177,51 @@ export default {
<template>
<div>
<h1 data-testid="title" class="gl-font-size-h1 gl-mt-3 gl-mb-0">{{ $options.i18n.title }}</h1>
<gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = null">
{{ errorMessage }}
</gl-alert>
<div class="row">
<div class="col-md-12 col-lg-6 gl-display-flex">
<div class="gl-flex-direction-column gl-flex-wrap">
<h1 class="gl-font-size-h1 gl-mt-3 gl-mb-0">
{{ $options.i18n.title }}
</h1>
</div>
</div>
<p>
<span data-testid="info-message" class="gl-mr-2">
{{ $options.i18n.overviewMessage }}
<gl-link :href="$options.docsLink" target="_blank">{{
$options.i18n.moreInformation
}}</gl-link>
</span>
</p>
<div class="col-md-12 col-lg-6">
<div class="gl-display-flex gl-flex-wrap gl-justify-content-end">
<gl-button v-if="admin" class="gl-mt-3" variant="info" @click="loadFileSelctor">
<span v-if="uploading">
<gl-loading-icon size="sm" class="gl-my-5" inline />
{{ $options.i18n.uploadingLabel }}
</span>
<span v-else>
<gl-icon name="upload" class="gl-mr-2" /> {{ $options.i18n.uploadLabel }}
</span>
</gl-button>
<input
id="file-upload"
ref="fileUpload"
type="file"
class="hidden"
data-qa-selector="file_upload_field"
@change="uploadSecureFile"
/>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 col-lg-12 gl-my-4">
<span data-testid="info-message">
{{ $options.i18n.overviewMessage }}
<gl-link :href="$options.docsLink" target="_blank">{{
$options.i18n.moreInformation
}}</gl-link>
</span>
</div>
</div>
<gl-table
:busy="loading"
@ -111,7 +248,20 @@ export default {
<template #cell(created_at)="{ item }">
<timeago-tooltip :time="item.created_at" />
</template>
<template #cell(actions)="{ item }">
<gl-button
v-if="admin"
v-gl-modal="$options.deleteModalId"
v-gl-tooltip.hover.top="$options.i18n.deleteLabel"
variant="danger"
icon="remove"
:aria-label="$options.i18n.deleteLabel"
@click="setDeleteModalData(item)"
/>
</template>
</gl-table>
<gl-pagination
v-if="!loading"
v-model="page"
@ -121,5 +271,25 @@ export default {
:prev-text="$options.i18n.pagination.prev"
align="center"
/>
<gl-modal
:ref="$options.deleteModalId"
:modal-id="$options.deleteModalId"
title-tag="h4"
category="primary"
:ok-title="$options.i18n.deleteModalButton"
ok-variant="danger"
@ok="deleteSecureFile(deleteModalFileId)"
>
<template #modal-title>
<gl-sprintf :message="$options.i18n.deleteModalTitle">
<template #name>{{ deleteModalFileName }}</template>
</gl-sprintf>
</template>
<gl-sprintf :message="$options.i18n.deleteModalMessage">
<template #name>{{ deleteModalFileName }}</template>
</gl-sprintf>
</gl-modal>
</div>
</template>

View File

@ -1,14 +1,19 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import SecureFilesList from './components/secure_files_list.vue';
export const initCiSecureFiles = (selector = '#js-ci-secure-files') => {
const containerEl = document.querySelector(selector);
const { projectId } = containerEl.dataset;
const { admin } = containerEl.dataset;
const { fileSizeLimit } = containerEl.dataset;
return new Vue({
el: containerEl,
provide: {
projectId,
admin: parseBoolean(admin),
fileSizeLimit,
},
render(createElement) {
return createElement(SecureFilesList);

View File

@ -125,6 +125,7 @@ export default {
'isAnonymousSearchDisabled',
'isIssueRepositioningDisabled',
'isProject',
'isPublicVisibilityRestricted',
'isSignedIn',
'jiraIntegrationPath',
'newIssuePath',
@ -209,6 +210,7 @@ export default {
const isIidSearch = ISSUE_REFERENCE.test(this.searchQuery);
return {
fullPath: this.fullPath,
hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
iid: isIidSearch ? this.searchQuery.slice(1) : undefined,
isProject: this.isProject,
isSignedIn: this.isSignedIn,

View File

@ -101,6 +101,7 @@ export function mountIssuesListApp() {
isAnonymousSearchDisabled,
isIssueRepositioningDisabled,
isProject,
isPublicVisibilityRestricted,
isSignedIn,
jiraIntegrationPath,
markdownHelpPath,
@ -144,6 +145,7 @@ export function mountIssuesListApp() {
isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
isProject: parseBoolean(isProject),
isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted),
isSignedIn: parseBoolean(isSignedIn),
jiraIntegrationPath,
newIssuePath,

View File

@ -2,6 +2,7 @@
#import "./issue.fragment.graphql"
query getIssues(
$hideUsers: Boolean = false
$isProject: Boolean = false
$isSignedIn: Boolean = false
$fullPath: ID!

View File

@ -2,6 +2,7 @@
#import "./issue.fragment.graphql"
query getIssuesWithoutCrm(
$hideUsers: Boolean = false
$isProject: Boolean = false
$isSignedIn: Boolean = false
$fullPath: ID!

View File

@ -17,7 +17,7 @@ fragment IssueFragment on Issue {
userDiscussionsCount @include(if: $isSignedIn)
webPath
webUrl
assignees {
assignees @skip(if: $hideUsers) {
nodes {
__typename
id
@ -27,7 +27,7 @@ fragment IssueFragment on Issue {
webUrl
}
}
author {
author @skip(if: $hideUsers) {
__typename
id
avatarUrl

View File

@ -22,6 +22,7 @@ const httpStatusCodes = {
METHOD_NOT_ALLOWED: 405,
CONFLICT: 409,
GONE: 410,
PAYLOAD_TOO_LARGE: 413,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,

View File

@ -93,6 +93,7 @@ export default {
data() {
return {
pipeline: null,
failureMessages: [],
failureType: null,
isCanceling: false,
isRetrying: false,
@ -159,8 +160,9 @@ export default {
},
},
methods: {
reportFailure(errorType) {
reportFailure(errorType, errorMessages = []) {
this.failureType = errorType;
this.failureMessages = errorMessages;
},
async postPipelineAction(name, mutation) {
try {
@ -176,7 +178,7 @@ export default {
if (errors.length > 0) {
this.isRetrying = false;
this.reportFailure(POST_FAILURE);
this.reportFailure(POST_FAILURE, errors);
} else {
await this.$apollo.queries.pipeline.refetch();
if (!this.isFinished) {
@ -214,7 +216,7 @@ export default {
});
if (errors.length > 0) {
this.reportFailure(DELETE_FAILURE);
this.reportFailure(DELETE_FAILURE, errors);
this.isDeleting = false;
} else {
redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success'));
@ -231,9 +233,11 @@ export default {
</script>
<template>
<div class="js-pipeline-header-container">
<gl-alert v-if="hasError" :variant="failure.variant" :dismissible="false">{{
failure.text
}}</gl-alert>
<gl-alert v-if="hasError" :title="failure.text" :variant="failure.variant" :dismissible="false">
<div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`">
{{ failureMessage }}
</div>
</gl-alert>
<ci-header
v-if="shouldRenderContent"
:status="pipeline.detailedStatus"

View File

@ -55,7 +55,7 @@ export default {
return createdSecondsAgo < SECONDS_IN_DAY;
},
author() {
return this.issuable.author;
return this.issuable.author || {};
},
webUrl() {
return this.issuable.gitlabWebUrl || this.issuable.webUrl;
@ -215,7 +215,7 @@ export default {
<span class="gl-display-none gl-sm-display-inline">
<span aria-hidden="true">&middot;</span>
<span class="issuable-authored gl-mr-3">
<gl-sprintf :message="__('created %{timeAgo} by %{author}')">
<gl-sprintf v-if="author.name" :message="__('created %{timeAgo} by %{author}')">
<template #timeAgo>
<span
v-gl-tooltip.bottom
@ -241,6 +241,17 @@ export default {
</gl-link>
</template>
</gl-sprintf>
<gl-sprintf v-else :message="__('created %{timeAgo}')">
<template #timeAgo>
<span
v-gl-tooltip.bottom
:title="tooltipTitle(issuable.createdAt)"
data-testid="issuable-created-at"
>
{{ createdAt }}
</span>
</template>
</gl-sprintf>
</span>
<slot name="timeframe"></slot>
</span>

View File

@ -23,8 +23,11 @@ class Projects::BlameController < Projects::ApplicationController
environment_params[:find_latest] = true
@environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last
@blame = Gitlab::Blame.new(@blob, @commit)
@blame = Gitlab::View::Presenter::Factory.new(@blame, project: @project, path: @path).fabricate!
blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page))
@blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path).fabricate!
render locals: { blame_pagination: blame_service.pagination }
end
end

View File

@ -6,5 +6,6 @@ class Projects::Ci::SecureFilesController < Projects::ApplicationController
feature_category :pipeline_authoring
def show
render_404 unless Feature.enabled?(:ci_secure_files, project)
end
end

View File

@ -50,8 +50,6 @@ module Mutations
private
def authorized_find_task!(task_id)
# TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
task_id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(task_id)
task = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(task_id))
if current_user.can?(:delete_work_item, task)
@ -64,8 +62,6 @@ module Mutations
# method used by `authorized_find!(id: id)`
def find_object(id:)
# TODO: Remove coercion when working on https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::WorkItem].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end

View File

@ -24,7 +24,7 @@ module Resolvers
end
def resolve(ids: nil, filenames: nil, at_version: nil)
context.scoped_set!(:at_version_argument, VersionID.coerce_isolated_input(at_version)) if at_version
context.scoped_set!(:at_version_argument, at_version) if at_version
::Gitlab::Graphql::Lazy.with_value(version(at_version)) do |visible_at|
::DesignManagement::DesignsFinder.new(

View File

@ -1,21 +1,5 @@
# frozen_string_literal: true
module GraphQLExtensions
module ScalarExtensions
# Allow ID to unify with GlobalID Types
def ==(other)
if name == 'ID' && other.is_a?(self.class) &&
other.type_class.ancestors.include?(::Types::GlobalIDType)
return true
end
super
end
end
end
::GraphQL::ScalarType.prepend(GraphQLExtensions::ScalarExtensions)
module Types
class GlobalIDType < BaseScalar
graphql_name 'GlobalID'

View File

@ -157,10 +157,6 @@ module Types
end
def milestone(id:)
# This field coerces its ID, and thus allows the use of ID typed values.
# This should be removed when app/graphql/queries/burndown_chart/burnup.query.graphql
# has been fixed/removed and as part of !83457
id = ::Types::GlobalIDType[Milestone].coerce_input(id, context)
GitlabSchema.find_by_gid(id)
end

View File

@ -204,6 +204,8 @@ module IssuesHelper
initial_sort: current_user&.user_preference&.issues_sort,
is_anonymous_search_disabled: Feature.enabled?(:disable_anonymous_search, type: :ops).to_s,
is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s,
is_public_visibility_restricted:
Gitlab::CurrentSettings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC).to_s,
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
rss_path: url_for(safe_params.merge(rss_url_options)),

View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
# Service class to correctly initialize Gitlab::Blame and Kaminari pagination
# objects
module Projects
class BlameService
PER_PAGE = 1000
def initialize(blob, commit, params)
@blob = blob
@commit = commit
@page = extract_page(params)
end
def blame
Gitlab::Blame.new(blob, commit, range: blame_range)
end
def pagination
return unless pagination_enabled?
Kaminari.paginate_array([], total_count: blob_lines_count)
.page(page)
.per(per_page)
.limit(per_page)
end
private
attr_reader :blob, :commit, :page
def blame_range
return unless pagination_enabled?
first_line = (page - 1) * per_page + 1
last_line = (first_line + per_page).to_i - 1
first_line..last_line
end
def extract_page(params)
page = params.fetch(:page, 1).to_i
return 1 if page < 1 || overlimit?(page)
page
end
def per_page
PER_PAGE
end
def overlimit?(page)
page * per_page >= blob_lines_count + per_page
end
def blob_lines_count
@blob_lines_count ||= blob.data.lines.count
end
def pagination_enabled?
Feature.enabled?(:blame_page_pagination, commit.project)
end
end
end

View File

@ -58,3 +58,6 @@
#{line}
- current_line += line_count
- if blame_pagination
= paginate(blame_pagination, theme: "gitlab")

View File

@ -1,5 +1,3 @@
- @content_class = "limit-container-width"
- page_title s_('Secure Files')
#js-ci-secure-files{ data: { project_id: @project.id } }
#js-ci-secure-files{ data: { project_id: @project.id, admin: can?(current_user, :admin_secure_files, @project).to_s, file_size_limit: Ci::SecureFile::FILE_SIZE_LIMIT.to_mb } }

View File

@ -0,0 +1,8 @@
---
name: blame_page_pagination
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85827
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/360927
milestone: '15.0'
type: development
group: group::source code
default_enabled: false

View File

@ -5,4 +5,4 @@ rollout_issue_url:
milestone: '14.10'
type: development
group: group::code review
default_enabled: false
default_enabled: true

View File

@ -27,7 +27,6 @@ Rails.autoloaders.each do |autoloader|
'dn' => 'DN',
'gitlab_cli_activity_unique_counter' => 'GitLabCliActivityUniqueCounter',
'global_id_type' => 'GlobalIDType',
'global_id_compatibility' => 'GlobalIDCompatibility',
'hll' => 'HLL',
'hll_redis_counter' => 'HLLRedisCounter',
'redis_hll_metric' => 'RedisHLLMetric',

View File

@ -10,7 +10,7 @@ type: reference, api
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in GitLab 13.7.
WARNING:
These endpoints are deprecated and will be removed in GitLab 14.0. Use the [DORA metrics API](dora/metrics.md) instead.
These endpoints have been removed in GitLab 14.0. Use the [DORA metrics API](dora/metrics.md) instead.
All methods require reporter authorization.

View File

@ -30,7 +30,22 @@ to be emitted from the rails application:
## Defining a new SLI
An SLI can be defined using the `Gitlab::Metrics::Sli` class.
An SLI can be defined using the `Gitlab::Metrics::Sli::Apdex` or
`Gitlab::Metrics::Sli::ErrorRate` class. These work in broadly the same way, but
for clarity, they define different metric names:
1. `Gitlab::Metrics::Sli::Apdex.new('foo')` defines:
1. `gitlab_sli:foo_apdex:total` for the total number of measurements.
1. `gitlab_sli:foo_apdex:success_total` for the number of successful
measurements.
1. `Gitlab::Metrics::Sli::ErrorRate.new('foo')` defines:
1. `gitlab_sli:foo_error_rate:total` for the total number of measurements.
1. `gitlab_sli:foo_error_rate:error_total` for the number of error
measurements - as this is an error rate, it's more natural to talk about
errors divided by the total.
As shown in this example, they can share a base name (`foo` in this example). We
recommend this when they refer to the same operation.
Before the first scrape, it is important to have [initialized the SLI
with all possible
@ -41,7 +56,7 @@ To initialize an SLI, use the `.initialize_sli` class method, for
example:
```ruby
Gitlab::Metrics::Sli.initialize_sli(:received_email, [
Gitlab::Metrics::Sli::Apdex.initialize_sli(:received_email, [
{
feature_category: :team_planning,
email_type: :create_issue
@ -67,7 +82,7 @@ this adds is understood and acceptable.
Tracking an operation in the newly defined SLI can be done like this:
```ruby
Gitlab::Metrics::Sli[:received_email].increment(
Gitlab::Metrics::Sli::Apdex[:received_email].increment(
labels: {
feature_category: :service_desk,
email_type: :service_desk
@ -79,20 +94,26 @@ Gitlab::Metrics::Sli[:received_email].increment(
Calling `#increment` on this SLI will increment the total Prometheus counter
```prometheus
gitlab_sli:received_email:total{ feature_category='service_desk', email_type='service_desk' }
gitlab_sli:received_email_apdex:total{ feature_category='service_desk', email_type='service_desk' }
```
If the `success:` argument passed is truthy, then the success counter
will also be incremented:
If the `success:` argument passed is truthy, then the success counter will also
be incremented:
```prometheus
gitlab_sli:received_email:success_total{ feature_category='service_desk', email_type='service_desk' }
gitlab_sli:received_email_apdex:success_total{ feature_category='service_desk', email_type='service_desk' }
```
So far, only tracking `apdex` using a success rate is supported. If you
need to track errors this way, please upvote
[this issue](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1395)
and leave a comment so we can prioritize this.
For error rate SLIs, the equivalent argument is called `error:`:
```ruby
Gitlab::Metrics::Sli::ErrorRate[:merge].increment(
labels: {
merge_type: :fast_forward
},
error: !merge_success?
)
```
## Using the SLI in service monitoring and alerts

View File

@ -146,7 +146,7 @@ To remove a page:
Use [feature X](<link-to-issue>) instead.
```
1. Remove the page from the left nav.
1. Remove the page's entry from the global navigation by editing [`navigation.yaml`](https://gitlab.com/gitlab-org/gitlab-docs/blob/main/content/_data/navigation.yaml) in `gitlab-docs`.
This content is removed from the documentation as part of the Technical Writing team's
[regularly scheduled tasks](https://about.gitlab.com/handbook/engineering/ux/technical-writing/#regularly-scheduled-tasks).
@ -163,7 +163,7 @@ To remove a topic:
```markdown
<!--- start_remove The following content will be removed on remove_date: '2023-08-22' -->
## Title (removed) **(ULTIMATE SELF)**
This feature was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/351963) in GitLab 14.8

View File

@ -255,8 +255,6 @@ Let's look in the UI and confirm your changes. Go to your project.
![Commit message](img/commit_message_v14_10.png)
- Above the file list, select **History** to view your commit details.
Now you can return to the command line and change back to your personal branch
(`git checkout example-tutorial-branch`). You can continue updating files or
creating new ones. Type `git status` to view the status

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -303,7 +303,7 @@ To resolve a thread:
At the top of the page, the number of unresolved threads is updated:
![Count of unresolved threads](img/unresolved_threads_v14_1.png)
![Count of unresolved threads](img/unresolved_threads_v15.png)
### Move all unresolved threads in a merge request to an issue
@ -311,7 +311,7 @@ If you have multiple unresolved threads in a merge request, you can
create an issue to resolve them separately. In the merge request, at the top of the page,
select **Create issue to resolve all threads** (**{issue-new}**):
![Open new issue for all unresolved threads](img/create-new-issue_v14_3.png)
![Open new issue for all unresolved threads](img/create-new-issue_v15.png)
All threads are marked as resolved, and a link is added from the merge request to
the newly created issue.
@ -330,7 +330,8 @@ the newly created issue.
### Prevent merge unless all threads are resolved
You can prevent merge requests from being merged until all threads are
resolved.
resolved. When this setting is enabled, the **Unresolved threads** counter in a merge request
is shown in orange when at least one thread remains unresolved.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings > General**.

View File

@ -14,7 +14,7 @@ changes.
By default, the diff view compares the versions of files in the merge request source branch
to the files in the target branch, and shows only the parts of a file that have changed.
![Example screenshot of a source code diff](img/mr-diff-example_v14_8.png)
![Example screenshot of a source code diff](img/mr-diff-example_v15.png)
## Show all changes in a merge request

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -14,7 +14,7 @@ module Gitlab
worker_class = worker_for_tracking_database[tracking_database]
if worker_class.nil?
raise ArgumentError, "tracking_database must be one of [#{worker_for_tracking_database.keys.join(', ')}]"
raise ArgumentError, "The '#{tracking_database}' must be one of #{worker_for_tracking_database.keys.to_a}"
end
new(worker_class)

View File

@ -1,5 +1,5 @@
variables:
DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.26.0'
DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.25.0'
.dast-auto-deploy:
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:${DAST_AUTO_DEPLOY_IMAGE_VERSION}"

View File

@ -1,5 +1,5 @@
variables:
AUTO_DEPLOY_IMAGE_VERSION: 'v2.26.0'
AUTO_DEPLOY_IMAGE_VERSION: 'v2.25.0'
.auto-deploy:
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}"

View File

@ -1,5 +1,5 @@
variables:
AUTO_DEPLOY_IMAGE_VERSION: 'v2.26.0'
AUTO_DEPLOY_IMAGE_VERSION: 'v2.25.0'
.auto-deploy:
image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}"

View File

@ -3,6 +3,7 @@
module Gitlab
module Database
module MigrationHelpers
include Migrations::ReestablishedConnectionStack
include Migrations::BackgroundMigrationHelpers
include Migrations::BatchedBackgroundMigrationHelpers
include DynamicModelHelpers

View File

@ -27,7 +27,7 @@ module Gitlab
return
end
Gitlab::Database::QueryAnalyzer.instance.within([validator_class]) do
Gitlab::Database::QueryAnalyzer.instance.within([validator_class, connection_validator_class]) do
validator_class.allowed_gitlab_schemas = self.allowed_gitlab_schemas
super
@ -45,6 +45,10 @@ module Gitlab
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas
end
def connection_validator_class
Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection
end
def unmatched_schemas
(self.allowed_gitlab_schemas || []) - allowed_schemas_for_connection
end

View File

@ -47,6 +47,19 @@ module Gitlab
'in the body of your migration class'
end
# Background Migrations do not work well for in cases requiring to update `gitlab_shared`
# Once the decomposition is done, enqueued jobs for `gitlab_shared` tables (on CI database)
# will not be executed since the queue (which is stored in Redis) is tied to main database, not to schema.
# The batched background migrations do not have those limitations since the tracking tables
# are properly database-only.
if background_migration_restrict_gitlab_migration_schemas&.include?(:gitlab_shared)
raise 'The `#queue_background_migration_jobs_by_range_at_intervals` cannot " \
"use `restrict_gitlab_migration:` " with `:gitlab_shared`. ' \
'Background migrations do encode migration worker which is tied to a given database. ' \
'After split this worker will not be properly duplicated into decomposed database. ' \
'Use batched background migrations instead that do support well working across all databases.'
end
raise "#{model_class} does not have an ID column of #{primary_column_name} to use for batch ranges" unless model_class.column_names.include?(primary_column_name.to_s)
raise "#{primary_column_name} is not an integer or string column" unless [:integer, :string].include?(model_class.columns_hash[primary_column_name.to_s].type)
@ -96,14 +109,20 @@ module Gitlab
# delay_interval - The duration between each job's scheduled time
# batch_size - The maximum number of jobs to fetch to memory from the database.
def requeue_background_migration_jobs_by_range_at_intervals(job_class_name, delay_interval, batch_size: BATCH_SIZE, initial_delay: 0)
job_coordinator = coordinator_for_tracking_database
if transaction_open?
raise 'The `#requeue_background_migration_jobs_by_range_at_intervals` can not be run inside a transaction, ' \
'you can disable transactions by calling disable_ddl_transaction! ' \
'in the body of your migration class'
end
if background_migration_restrict_gitlab_migration_schemas&.any?
raise 'The `#requeue_background_migration_jobs_by_range_at_intervals` cannot use `restrict_gitlab_migration:`. ' \
'The `#requeue_background_migration_jobs_by_range_at_intervals` needs to be executed on all databases since ' \
'each database has its own queue of background migrations.'
end
job_coordinator = coordinator_for_tracking_database
# To not overload the worker too much we enforce a minimum interval both
# when scheduling and performing jobs.
delay_interval = [delay_interval, job_coordinator.minimum_interval].max
@ -145,34 +164,40 @@ module Gitlab
# This method does not garauntee that all jobs completed successfully.
# It can only be used if the previous background migration used the queue_background_migration_jobs_by_range_at_intervals helper.
def finalize_background_migration(class_name, delete_tracking_jobs: ['succeeded'])
if self.is_a?(::Gitlab::Database::MigrationHelpers::RestrictGitlabSchema)
raise 'The `#finalize_background_migration` is currently not supported with `Migration[2.0]`. Use `Migration[1.0]`. ' \
'For more information visit: https://docs.gitlab.com/ee/development/database/migrations_for_multiple_databases.html'
end
if transaction_open?
raise 'The `#finalize_background_migration` can not be run inside a transaction, ' \
'you can disable transactions by calling disable_ddl_transaction! ' \
'in the body of your migration class'
end
job_coordinator = coordinator_for_tracking_database
# Empty the sidekiq queue.
job_coordinator.steal(class_name)
# Process pending tracked jobs.
jobs = Gitlab::Database::BackgroundMigrationJob.pending.for_migration_class(class_name)
jobs.find_each do |job|
job_coordinator.perform(job.class_name, job.arguments)
if background_migration_restrict_gitlab_migration_schemas&.any?
raise 'The `#finalize_background_migration` cannot use `restrict_gitlab_migration:`. ' \
'The `#finalize_background_migration` needs to be executed on all databases since ' \
'each database has its own queue of background migrations.'
end
# Empty the sidekiq queue.
job_coordinator.steal(class_name)
job_coordinator = coordinator_for_tracking_database
# Delete job tracking rows.
delete_job_tracking(class_name, status: delete_tracking_jobs) if delete_tracking_jobs
with_restored_connection_stack do
# Since we are running trusted code (background migration class) allow to execute any type of finalize
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.with_suppressed do
# Empty the sidekiq queue.
job_coordinator.steal(class_name)
# Process pending tracked jobs.
jobs = Gitlab::Database::BackgroundMigrationJob.pending.for_migration_class(class_name)
jobs.find_each do |job|
job_coordinator.perform(job.class_name, job.arguments)
end
# Empty the sidekiq queue.
job_coordinator.steal(class_name)
# Delete job tracking rows.
delete_job_tracking(class_name, status: delete_tracking_jobs) if delete_tracking_jobs
end
end
end
def migrate_in(*args, coordinator: coordinator_for_tracking_database)
@ -197,6 +222,10 @@ module Gitlab
private
def background_migration_restrict_gitlab_migration_schemas
self.allowed_gitlab_schemas if self.respond_to?(:allowed_gitlab_schemas)
end
def with_migration_context(&block)
Gitlab::ApplicationContext.with_context(caller_id: self.class.to_s, &block)
end
@ -206,11 +235,9 @@ module Gitlab
end
def coordinator_for_tracking_database
Gitlab::BackgroundMigration.coordinator_for_database(tracking_database)
end
tracking_database = Gitlab::Database.db_config_name(connection)
def tracking_database
Gitlab::BackgroundMigration::DEFAULT_TRACKING_DATABASE
Gitlab::BackgroundMigration.coordinator_for_database(tracking_database)
end
end
end

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
module Gitlab
module Database
module Migrations
module ReestablishedConnectionStack
# This is workaround for `db:migrate` that switches `ActiveRecord::Base.connection`
# depending on execution. This is subject to be removed once proper fix is implemented:
# https://gitlab.com/gitlab-org/gitlab/-/issues/362341
#
# In some cases when we run application code we need to restore application connection stack:
# - ApplicationRecord (in fact ActiveRecord::Base): points to main
# - Ci::ApplicationRecord: points to ci
#
# rubocop:disable Database/MultipleDatabases
def with_restored_connection_stack(&block)
original_handler = ActiveRecord::Base.connection_handler
original_db_config = ActiveRecord::Base.connection_db_config
return yield if ActiveRecord::Base.configurations.primary?(original_db_config.name)
# If the `ActiveRecord::Base` connection is different than `:main`
# re-establish and configure `SharedModel` context accordingly
# to previously established `ActiveRecord::Base` to allow the application
# code to use `ApplicationRecord` and `Ci::ApplicationRecord` usual way.
# We swap a connection handler as migration context does hold an actual
# connection which we cannot close.
base_model = Gitlab::Database.database_base_models.fetch(original_db_config.name.to_sym)
# copy connections over to new connection handler
db_configs = original_handler.connection_pool_names.map do |connection_pool_name|
[connection_pool_name.constantize, connection_pool_name.constantize.connection_db_config]
end
new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
ActiveRecord::Base.connection_handler = new_handler
db_configs.each do |klass, db_config|
new_handler.establish_connection(db_config, owner_name: klass)
end
# re-establish ActiveRecord::Base to main
ActiveRecord::Base.establish_connection :main # rubocop:disable Database/EstablishConnection
Gitlab::Database::SharedModel.using_connection(base_model.connection) do
yield
end
ensure
ActiveRecord::Base.connection_handler = original_handler
new_handler&.clear_all_connections!
end
# rubocop:enable Database/MultipleDatabases
end
end
end
end

View File

@ -6,7 +6,6 @@ module Gitlab
module TableManagementHelpers
include ::Gitlab::Database::SchemaHelpers
include ::Gitlab::Database::MigrationHelpers
include ::Gitlab::Database::Migrations::BackgroundMigrationHelpers
ALLOWED_TABLES = %w[audit_events web_hook_logs].freeze
ERROR_SCOPE = 'table partitioning'

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
module Gitlab
module Database
module QueryAnalyzers
# The purpose of this analyzer is to validate if tables observed
# are properly used according to schema used by current connection
class GitlabSchemasValidateConnection < Base
CrossSchemaAccessError = Class.new(QueryAnalyzerError)
class << self
def enabled?
true
end
def analyze(parsed)
tables = parsed.pg.select_tables + parsed.pg.dml_tables
table_schemas = ::Gitlab::Database::GitlabSchema.table_schemas(tables)
return if table_schemas.empty?
allowed_schemas = ::Gitlab::Database.gitlab_schemas_for_connection(parsed.connection)
return unless allowed_schemas
invalid_schemas = table_schemas - allowed_schemas
if invalid_schemas.any?
message = "The query tried to access #{tables} (of #{table_schemas.to_a}) "
message += "which is outside of allowed schemas (#{allowed_schemas}) "
message += "for the current connection '#{Gitlab::Database.db_config_name(parsed.connection)}'"
raise CrossSchemaAccessError, message
end
end
end
end
end
end
end

View File

@ -15,14 +15,16 @@ module Gitlab
previous_connection = self.overriding_connection
unless previous_connection.nil? || previous_connection.equal?(connection)
raise 'cannot nest connection overrides for shared models with different connections'
raise "Cannot change connection for Gitlab::Database::SharedModel "\
"from '#{Gitlab::Database.db_config_name(previous_connection)}' "\
"to '#{Gitlab::Database.db_config_name(connection)}'"
end
self.overriding_connection = connection
yield
ensure
self.overriding_connection = nil unless previous_connection.equal?(self.overriding_connection)
self.overriding_connection = previous_connection
end
def connection

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Graphql
module GlobalIDCompatibility
# TODO: remove this module once the compatibility layer is no longer needed.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
def coerce_global_id_arguments!(args)
global_id_arguments = self.class.arguments.values.select do |arg|
arg.type.is_a?(Class) && arg.type <= ::Types::GlobalIDType
end
global_id_arguments.each do |arg|
k = arg.keyword
args[k] &&= arg.type.coerce_isolated_input(args[k])
end
end
end
end
end

View File

@ -5,16 +5,16 @@ module Gitlab
module RailsSlis
class << self
def initialize_request_slis!
Gitlab::Metrics::Sli.initialize_sli(:rails_request_apdex, possible_request_labels) unless Gitlab::Metrics::Sli.initialized?(:rails_request_apdex)
Gitlab::Metrics::Sli.initialize_sli(:graphql_query_apdex, possible_graphql_query_labels) unless Gitlab::Metrics::Sli.initialized?(:graphql_query_apdex)
Gitlab::Metrics::Sli::Apdex.initialize_sli(:rails_request, possible_request_labels)
Gitlab::Metrics::Sli::Apdex.initialize_sli(:graphql_query, possible_graphql_query_labels)
end
def request_apdex
Gitlab::Metrics::Sli[:rails_request_apdex]
Gitlab::Metrics::Sli::Apdex[:rails_request]
end
def graphql_query_apdex
Gitlab::Metrics::Sli[:graphql_query_apdex]
Gitlab::Metrics::Sli::Apdex[:graphql_query]
end
private

View File

@ -2,12 +2,10 @@
module Gitlab
module Metrics
class Sli
SliNotInitializedError = Class.new(StandardError)
module Sli
COUNTER_PREFIX = 'gitlab_sli'
class << self
module ClassMethods
INITIALIZATION_MUTEX = Mutex.new
def [](name)
@ -16,6 +14,8 @@ module Gitlab
def initialize_sli(name, possible_label_combinations)
INITIALIZATION_MUTEX.synchronize do
next known_slis[name] if initialized?(name)
sli = new(name)
sli.initialize_counters(possible_label_combinations)
known_slis[name] = sli
@ -33,6 +33,10 @@ module Gitlab
end
end
def self.included(mod)
mod.extend(ClassMethods)
end
attr_reader :name
def initialize(name)
@ -41,16 +45,17 @@ module Gitlab
end
def initialize_counters(possible_label_combinations)
@initialized_with_combinations = possible_label_combinations.any?
# This module is effectively an abstract class
@initialized_with_combinations = possible_label_combinations.any? # rubocop:disable Gitlab/ModuleWithInstanceVariables
possible_label_combinations.each do |label_combination|
total_counter.get(label_combination)
success_counter.get(label_combination)
numerator_counter.get(label_combination)
end
end
def increment(labels:, success:)
def increment(labels:, increment_numerator:)
total_counter.increment(labels)
success_counter.increment(labels) if success
numerator_counter.increment(labels) if increment_numerator
end
def initialized?
@ -60,24 +65,44 @@ module Gitlab
private
def total_counter
prometheus.counter(total_counter_name.to_sym, "Total number of measurements for #{name}")
prometheus.counter(counter_name('total'), "Total number of measurements for #{name}")
end
def success_counter
prometheus.counter(success_counter_name.to_sym, "Number of successful measurements for #{name}")
end
def total_counter_name
"#{COUNTER_PREFIX}:#{name}:total"
end
def success_counter_name
"#{COUNTER_PREFIX}:#{name}:success_total"
def counter_name(suffix)
:"#{COUNTER_PREFIX}:#{name}_#{self.class.name.demodulize.underscore}:#{suffix}"
end
def prometheus
Gitlab::Metrics
end
class Apdex
include Sli
def increment(labels:, success:)
super(labels: labels, increment_numerator: success)
end
private
def numerator_counter
prometheus.counter(counter_name('success_total'), "Number of successful measurements for #{name}")
end
end
class ErrorRate
include Sli
def increment(labels:, error:)
super(labels: labels, increment_numerator: error)
end
private
def numerator_counter
prometheus.counter(counter_name('error_total'), "Number of error measurements for #{name}")
end
end
end
end
end

View File

@ -1581,6 +1581,9 @@ msgstr ""
msgid "A file with '%{file_name}' already exists in %{branch} branch"
msgstr ""
msgid "A file with this name already exists."
msgstr ""
msgid "A group is a collection of several projects"
msgstr ""
@ -11462,6 +11465,9 @@ msgstr ""
msgid "DastProfiles|Password form field"
msgstr ""
msgid "DastProfiles|Profile is being used by this on-demand scan"
msgstr ""
msgid "DastProfiles|Profile name"
msgstr ""
@ -11935,6 +11941,9 @@ msgstr ""
msgid "Delete Comment"
msgstr ""
msgid "Delete File"
msgstr ""
msgid "Delete Internal Note"
msgstr ""
@ -15943,6 +15952,9 @@ msgstr ""
msgid "File templates"
msgstr ""
msgid "File too large. Secure Files must be less than %{limit} MB."
msgstr ""
msgid "File upload error."
msgstr ""
@ -19360,6 +19372,9 @@ msgstr ""
msgid "In this page you will find information about the settings that are used in your current instance."
msgstr ""
msgid "In use"
msgstr ""
msgid "InProductMarketing|%{organization_name} logo"
msgstr ""
@ -33422,6 +33437,15 @@ msgstr ""
msgid "Secure token that identifies an external storage request."
msgstr ""
msgid "SecureFiles|Delete %{name}?"
msgstr ""
msgid "SecureFiles|Delete secure file"
msgstr ""
msgid "SecureFiles|Secure File %{name} will be permanently deleted. Are you sure?"
msgstr ""
msgid "Security"
msgstr ""
@ -40579,6 +40603,9 @@ msgstr ""
msgid "Uploading changes to terminal"
msgstr ""
msgid "Uploading..."
msgstr ""
msgid "Upstream"
msgstr ""

View File

@ -9,17 +9,35 @@ RSpec.describe Projects::Ci::SecureFilesController do
subject(:show_request) { get :show, params: { namespace_id: project.namespace, project_id: project } }
describe 'GET #show' do
context 'with enough privileges' do
before do
sign_in(user)
project.add_developer(user)
show_request
context 'when the :ci_secure_files feature flag is enabled' do
context 'with enough privileges' do
before do
stub_feature_flags(ci_secure_files: true)
sign_in(user)
project.add_developer(user)
show_request
end
it { expect(response).to have_gitlab_http_status(:ok) }
it 'renders show page' do
expect(response).to render_template :show
end
end
end
it { expect(response).to have_gitlab_http_status(:ok) }
context 'when the :ci_secure_files feature flag is disabled' do
context 'with enough privileges' do
before do
stub_feature_flags(ci_secure_files: false)
sign_in(user)
project.add_developer(user)
show_request
end
it 'renders show page' do
expect(response).to render_template :show
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end

View File

@ -2,7 +2,7 @@
FactoryBot.define do
factory :ci_secure_file, class: 'Ci::SecureFile' do
name { 'filename' }
sequence(:name) { |n| "file#{n}" }
file { fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks', 'application/octet-stream') }
checksum { 'foo1234' }
project

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'File blame', :js do
include TreeHelper
let_it_be(:project) { create(:project, :public, :repository) }
let(:path) { 'CHANGELOG' }
def visit_blob_blame(path)
visit project_blame_path(project, tree_join('master', path))
wait_for_all_requests
end
it 'displays the blame page without pagination' do
visit_blob_blame(path)
expect(page).to have_css('.blame-commit')
expect(page).not_to have_css('.gl-pagination')
end
context 'when blob length is over the blame range limit' do
before do
stub_const('Projects::BlameService::PER_PAGE', 2)
end
it 'displays two first lines of the file with pagination' do
visit_blob_blame(path)
expect(page).to have_css('.blame-commit')
expect(page).to have_css('.gl-pagination')
expect(page).to have_css('#L1')
expect(page).not_to have_css('#L3')
expect(find('.page-link.active')).to have_text('1')
end
context 'when user clicks on the next button' do
before do
visit_blob_blame(path)
find('.js-next-button').click
end
it 'displays next two lines of the file with pagination' do
expect(page).not_to have_css('#L1')
expect(page).to have_css('#L3')
expect(find('.page-link.active')).to have_text('2')
end
end
context 'when feature flag disabled' do
before do
stub_feature_flags(blame_page_pagination: false)
end
it 'displays the blame page without pagination' do
visit_blob_blame(path)
expect(page).to have_css('.blame-commit')
expect(page).not_to have_css('.gl-pagination')
end
end
end
end

View File

@ -7,13 +7,55 @@ RSpec.describe 'Secure Files', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(ci_secure_files_read_only: false)
project.add_maintainer(user)
sign_in(user)
visit project_ci_secure_files_path(project)
end
it 'user sees the Secure Files list component' do
visit project_ci_secure_files_path(project)
expect(page).to have_content('There are no records to show')
end
it 'prompts the user to confirm before deleting a file' do
file = create(:ci_secure_file, project: project)
visit project_ci_secure_files_path(project)
expect(page).to have_content(file.name)
find('button.btn-danger').click
expect(page).to have_content("Delete #{file.name}?")
click_on('Delete secure file')
visit project_ci_secure_files_path(project)
expect(page).not_to have_content(file.name)
end
it 'displays an uploaded file in the file list' do
visit project_ci_secure_files_path(project)
expect(page).to have_content('There are no records to show')
page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
click_button 'Upload File'
end
expect(page).to have_content('upload-keystore.jks')
end
it 'displays an error when a duplicate file upload is attempted' do
create(:ci_secure_file, project: project, name: 'upload-keystore.jks')
visit project_ci_secure_files_path(project)
expect(page).to have_content('upload-keystore.jks')
page.attach_file('spec/fixtures/ci_secure_files/upload-keystore.jks') do
click_button 'Upload File'
end
expect(page).to have_content('A file with this name already exists.')
end
end

View File

@ -2,6 +2,36 @@
require 'spec_helper'
RSpec.describe 'User views tags', :feature do
context 'with html' do
let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) }
let(:user) { create(:user) }
let(:tag_name) { "stable" }
let!(:release) { create(:release, project: project, tag: tag_name) }
before do
project.add_developer(user)
project.repository.add_tag(user, tag_name, project.default_branch_or_main)
sign_in(user)
end
shared_examples 'renders the tag index page' do
it do
visit project_tags_path(project)
expect(page).to have_content tag_name
end
end
it_behaves_like 'renders the tag index page'
context 'when tag name contains a slash' do
let(:tag_name) { "stable/v0.1" }
it_behaves_like 'renders the tag index page'
end
end
context 'rss' do
shared_examples 'has access to the tags RSS feed' do
it do

View File

@ -1593,6 +1593,38 @@ describe('Api', () => {
});
});
describe('uploadProjectSecureFile', () => {
it('uploads a secure file to a project', async () => {
const projectId = 1;
const secureFile = {
id: projectId,
title: 'File Name',
permissions: 'read_only',
checksum: '12345',
checksum_algorithm: 'sha256',
created_at: '2022-02-21T15:27:18',
};
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files`;
mock.onPost(expectedUrl).reply(httpStatus.OK, secureFile);
const { data } = await Api.uploadProjectSecureFile(projectId, 'some data');
expect(data).toEqual(secureFile);
});
});
describe('deleteProjectSecureFile', () => {
it('removes a secure file from a project', async () => {
const projectId = 1;
const secureFileId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files/${secureFileId}`;
mock.onDelete(expectedUrl).reply(httpStatus.NO_CONTENT, '');
const { data } = await Api.deleteProjectSecureFile(projectId, secureFileId);
expect(data).toEqual('');
});
});
describe('dependency proxy cache', () => {
it('schedules the cache list for deletion', async () => {
const groupId = 1;

View File

@ -10,6 +10,7 @@ import { secureFiles } from '../mock_data';
const dummyApiVersion = 'v3000';
const dummyProjectId = 1;
const fileSizeLimit = 5;
const dummyUrlRoot = '/gitlab';
const dummyGon = {
api_version: dummyApiVersion,
@ -33,9 +34,13 @@ describe('SecureFilesList', () => {
window.gon = originalGon;
});
const createWrapper = (props = {}) => {
const createWrapper = (admin = true, props = {}) => {
wrapper = mount(SecureFilesList, {
provide: { projectId: dummyProjectId },
provide: {
projectId: dummyProjectId,
admin,
fileSizeLimit,
},
...props,
});
};
@ -46,6 +51,8 @@ describe('SecureFilesList', () => {
const findHeaderAt = (i) => wrapper.findAll('thead th').at(i);
const findPagination = () => wrapper.findAll('ul.pagination');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findUploadButton = () => wrapper.findAll('span.gl-button-text');
const findDeleteButton = () => wrapper.findAll('tbody tr td button.btn-danger');
describe('when secure files exist in a project', () => {
beforeEach(async () => {
@ -135,4 +142,42 @@ describe('SecureFilesList', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('admin permissions', () => {
describe('with admin permissions', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper();
await waitForPromises();
});
it('displays the upload button', () => {
expect(findUploadButton().exists()).toBe(true);
});
it('displays a delete button', () => {
expect(findDeleteButton().exists()).toBe(true);
});
});
describe('without admin permissions', () => {
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(expectedUrl).reply(200, secureFiles);
createWrapper(false);
await waitForPromises();
});
it('does not display the upload button', () => {
expect(findUploadButton().exists()).toBe(false);
});
it('does not display a delete button', () => {
expect(findDeleteButton().exists()).toBe(false);
});
});
});
});

View File

@ -82,6 +82,7 @@ describe('CE IssuesListApp component', () => {
isAnonymousSearchDisabled: false,
isIssueRepositioningDisabled: false,
isProject: true,
isPublicVisibilityRestricted: false,
isSignedIn: true,
jiraIntegrationPath: 'jira/integration/path',
newIssuePath: 'new/issue/path',
@ -932,4 +933,23 @@ describe('CE IssuesListApp component', () => {
});
});
});
describe('public visibility', () => {
it.each`
description | isPublicVisibilityRestricted | isSignedIn | hideUsers
${'shows users when public visibility is not restricted and is not signed in'} | ${false} | ${false} | ${false}
${'shows users when public visibility is not restricted and is signed in'} | ${false} | ${true} | ${false}
${'hides users when public visibility is restricted and is not signed in'} | ${true} | ${false} | ${true}
${'shows users when public visibility is restricted and is signed in'} | ${true} | ${true} | ${false}
`('$description', ({ isPublicVisibilityRestricted, isSignedIn, hideUsers }) => {
const mockQuery = jest.fn().mockResolvedValue(defaultQueryResponse);
wrapper = mountComponent({
provide: { isPublicVisibilityRestricted, isSignedIn },
issuesQueryResponse: mockQuery,
});
jest.runOnlyPendingTimers();
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
});
});
});

View File

@ -1,4 +1,4 @@
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
import { GlAlert, GlModal, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
@ -21,6 +21,7 @@ describe('Pipeline details header', () => {
let glModalDirective;
let mutate = jest.fn();
const findAlert = () => wrapper.find(GlAlert);
const findDeleteModal = () => wrapper.find(GlModal);
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
const findCancelButton = () => wrapper.find('[data-testid="cancelPipeline"]');
@ -121,6 +122,22 @@ describe('Pipeline details header', () => {
it('should render retry action tooltip', () => {
expect(findRetryButton().attributes('title')).toBe(BUTTON_TOOLTIP_RETRY);
});
it('should display error message on failure', async () => {
const failureMessage = 'failure message';
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: {
pipelineRetry: {
errors: [failureMessage],
},
},
});
findRetryButton().vm.$emit('click');
await waitForPromises();
expect(findAlert().text()).toBe(failureMessage);
});
});
describe('Retry action failed', () => {
@ -156,6 +173,22 @@ describe('Pipeline details header', () => {
variables: { id: mockRunningPipelineHeader.id },
});
});
it('should display error message on failure', async () => {
const failureMessage = 'failure message';
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: {
pipelineCancel: {
errors: [failureMessage],
},
},
});
findCancelButton().vm.$emit('click');
await waitForPromises();
expect(findAlert().text()).toBe(failureMessage);
});
});
describe('Delete action', () => {
@ -179,6 +212,22 @@ describe('Pipeline details header', () => {
variables: { id: mockFailedPipelineHeader.id },
});
});
it('should display error message on failure', async () => {
const failureMessage = 'failure message';
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
data: {
pipelineDestroy: {
errors: [failureMessage],
},
},
});
findDeleteModal().vm.$emit('ok');
await waitForPromises();
expect(findAlert().text()).toBe(failureMessage);
});
});
describe('Permissions', () => {

View File

@ -246,131 +246,6 @@ RSpec.describe Types::GlobalIDType do
end
end
describe 'compatibility' do
def query(doc, vars)
GraphQL::Query.new(schema, document: doc, context: {}, variables: vars)
end
def run_query(gql_query, vars)
query(GraphQL.parse(gql_query), vars).result
end
all_types = [::GraphQL::Types::ID, ::Types::GlobalIDType, ::Types::GlobalIDType[::Project]]
shared_examples 'a working query' do
# Simplified schema to test compatibility
let!(:schema) do
# capture values so they can be closed over
arg_type = argument_type
res_type = result_type
project = Class.new(GraphQL::Schema::Object) do
graphql_name 'Project'
field :name, String, null: false
field :id, res_type, null: false, resolver_method: :global_id
def global_id
object.to_global_id
end
end
Class.new(GraphQL::Schema) do
query(Class.new(GraphQL::Schema::Object) do
graphql_name 'Query'
field :project_by_id, project, null: true do
argument :id, arg_type, required: true
end
# This is needed so that all types are always registered as input types
field :echo, String, null: true do
argument :id, ::GraphQL::Types::ID, required: false
argument :gid, ::Types::GlobalIDType, required: false
argument :pid, ::Types::GlobalIDType[::Project], required: false
end
def project_by_id(id:)
gid = ::Types::GlobalIDType[::Project].coerce_isolated_input(id)
gid.model_class.find(gid.model_id)
end
def echo(id: nil, gid: nil, pid: nil)
"id: #{id}, gid: #{gid}, pid: #{pid}"
end
end)
end
end
it 'works' do
res = run_query(document, 'projectId' => project.to_global_id.to_s)
expect(res['errors']).to be_blank
expect(res.dig('data', 'project', 'name')).to eq(project.name)
expect(res.dig('data', 'project', 'id')).to eq(project.to_global_id.to_s)
end
end
context 'when the client declares the argument as ID the actual argument can be any type' do
let(:document) do
<<-GRAPHQL
query($projectId: ID!){
project: projectById(id: $projectId) {
name, id
}
}
GRAPHQL
end
where(:result_type, :argument_type) do
all_types.flat_map { |arg_type| all_types.zip([arg_type].cycle) }
end
with_them do
it_behaves_like 'a working query'
end
end
context 'when the client passes the argument as GlobalID' do
let(:document) do
<<-GRAPHQL
query($projectId: GlobalID!) {
project: projectById(id: $projectId) {
name, id
}
}
GRAPHQL
end
let(:argument_type) { ::Types::GlobalIDType }
where(:result_type) { all_types }
with_them do
it_behaves_like 'a working query'
end
end
context 'when the client passes the argument as ProjectID' do
let(:document) do
<<-GRAPHQL
query($projectId: ProjectID!) {
project: projectById(id: $projectId) {
name, id
}
}
GRAPHQL
end
let(:argument_type) { ::Types::GlobalIDType[::Project] }
where(:result_type) { all_types }
with_them do
it_behaves_like 'a working query'
end
end
end
describe '.model_name_to_graphql_name' do
it 'returns a graphql name for the given model name' do
expect(described_class.model_name_to_graphql_name('DesignManagement::Design')).to eq('DesignManagementDesignID')

View File

@ -302,6 +302,7 @@ RSpec.describe IssuesHelper do
is_anonymous_search_disabled: 'true',
is_issue_repositioning_disabled: 'true',
is_project: 'true',
is_public_visibility_restricted: 'false',
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'),

View File

@ -18,7 +18,7 @@ RSpec.describe Gitlab::BackgroundMigration::JobCoordinator do
it 'raises an error' do
expect do
described_class.for_tracking_database('notvalid')
end.to raise_error(ArgumentError, /tracking_database must be one of/)
end.to raise_error(ArgumentError, /must be one of/)
end
end
end

View File

@ -401,8 +401,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
ci: :dml_not_allowed
},
gitlab_schema_gitlab_shared: {
main: :dml_access_denied,
ci: :dml_access_denied
main: :runtime_error,
ci: :runtime_error
},
gitlab_schema_gitlab_main: {
main: :success,
@ -486,6 +486,37 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
ci: :skipped
}
}
},
"does raise exception about cross schema access when suppressing restriction to ensure" => {
migration: ->(klass) do
# The purpose of this test is to ensure that we use ApplicationRecord
# a correct connection will be used:
# - this is a case for finalizing background migrations
def up
Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas.with_suppressed do
::ApplicationRecord.connection.execute("SELECT 1 FROM ci_builds")
end
end
def down
end
end,
query_matcher: /FROM ci_builds/,
setup: -> (_) { skip_if_multiple_databases_not_setup },
expected: {
no_gitlab_schema: {
main: :cross_schema_error,
ci: :success
},
gitlab_schema_gitlab_shared: {
main: :cross_schema_error,
ci: :success
},
gitlab_schema_gitlab_main: {
main: :cross_schema_error,
ci: :skipped
}
}
}
}
end
@ -517,6 +548,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
%i[no_gitlab_schema gitlab_schema_gitlab_main gitlab_schema_gitlab_shared].each do |restrict_gitlab_migration|
context "while restrict_gitlab_migration=#{restrict_gitlab_migration}" do
it "does run migrate :up and :down" do
instance_eval(&setup) if setup
expected_result = expected.fetch(restrict_gitlab_migration)[db_config_name.to_sym]
skip "not configured" unless expected_result
@ -543,10 +576,18 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DMLAccessDeniedError)
expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DMLAccessDeniedError) { migration_class.migrate(:down) } }.not_to raise_error
when :runtime_error
expect { migration_class.migrate(:up) }.to raise_error(RuntimeError)
expect { ignore_error(RuntimeError) { migration_class.migrate(:down) } }.not_to raise_error
when :ddl_not_allowed
expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError)
expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError) { migration_class.migrate(:down) } }.not_to raise_error
when :cross_schema_error
expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection::CrossSchemaAccessError)
expect { ignore_error(Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection::CrossSchemaAccessError) { migration_class.migrate(:down) } }.not_to raise_error
when :skipped
expect_next_instance_of(migration_class) do |migration_object|
expect(migration_object).to receive(:migration_skipped).and_call_original

View File

@ -6,12 +6,21 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
let(:base_class) { ActiveRecord::Migration }
let(:model) do
base_class.new.extend(described_class)
base_class.new
.extend(described_class)
.extend(Gitlab::Database::Migrations::ReestablishedConnectionStack)
end
shared_examples_for 'helpers that enqueue background migrations' do |worker_class, tracking_database|
shared_examples_for 'helpers that enqueue background migrations' do |worker_class, connection_class, tracking_database|
before do
allow(model).to receive(:tracking_database).and_return(tracking_database)
# Due to lib/gitlab/database/load_balancing/configuration.rb:92 requiring RequestStore
# we cannot use stub_feature_flags(force_no_sharing_primary_model: true)
allow(connection_class.connection.load_balancer.configuration)
.to receive(:use_dedicated_connection?).and_return(true)
allow(model).to receive(:connection).and_return(connection_class.connection)
end
describe '#queue_background_migration_jobs_by_range_at_intervals' do
@ -203,6 +212,22 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
end
context 'when using Migration[2.0]' do
let(:base_class) { Class.new(Gitlab::Database::Migration[2.0]) }
context 'when restriction is set to gitlab_shared' do
before do
base_class.restrict_gitlab_migration gitlab_schema: :gitlab_shared
end
it 'does raise an exception' do
expect do
model.queue_background_migration_jobs_by_range_at_intervals(ProjectAuthorization, 'FooJob', 10.seconds)
end.to raise_error /use `restrict_gitlab_migration:` " with `:gitlab_shared`/
end
end
end
context 'when within transaction' do
before do
allow(model).to receive(:transaction_open?).and_return(true)
@ -241,6 +266,27 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
expect(subject).to eq(20.minutes)
end
context 'when using Migration[2.0]' do
let(:base_class) { Class.new(Gitlab::Database::Migration[2.0]) }
it 'does re-enqueue pending jobs' do
subject
expect(worker_class.jobs).not_to be_empty
end
context 'when restriction is set' do
before do
base_class.restrict_gitlab_migration gitlab_schema: :gitlab_main
end
it 'does raise an exception' do
expect { subject }
.to raise_error /The `#requeue_background_migration_jobs_by_range_at_intervals` cannot use `restrict_gitlab_migration:`./
end
end
end
context 'when within transaction' do
before do
allow(model).to receive(:transaction_open?).and_return(true)
@ -373,18 +419,37 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
context 'when using Migration[2.0]' do
let(:base_class) { ::Gitlab::Database::Migration[2.0] }
let(:base_class) { Class.new(Gitlab::Database::Migration[2.0]) }
let!(:job_class) do
Class.new do
def perform(*arguments)
end
it_behaves_like 'finalized tracked background migration', worker_class do
before do
model.finalize_background_migration(job_class_name)
end
end
it 'does raise an exception' do
expect { model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded]) }
.to raise_error /is currently not supported with/
context 'when restriction is set' do
before do
base_class.restrict_gitlab_migration gitlab_schema: :gitlab_main
end
it 'does raise an exception' do
expect { model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded]) }
.to raise_error /The `#finalize_background_migration` cannot use `restrict_gitlab_migration:`./
end
end
end
context 'when running migration in reconfigured ActiveRecord::Base context' do
it_behaves_like 'reconfigures connection stack', tracking_database do
it 'does restore connection hierarchy' do
expect_next_instances_of(job_class, 1..) do |job|
expect(job).to receive(:perform) do
validate_connections!
end
end
model.finalize_background_migration(job_class_name, delete_tracking_jobs: %w[pending succeeded])
end
end
end
@ -505,7 +570,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
context 'when the migration is running against the main database' do
it_behaves_like 'helpers that enqueue background migrations', BackgroundMigrationWorker, 'main'
it_behaves_like 'helpers that enqueue background migrations', BackgroundMigrationWorker, ActiveRecord::Base, 'main'
end
context 'when the migration is running against the ci database', if: Gitlab::Database.has_config?(:ci) do
@ -515,7 +580,7 @@ RSpec.describe Gitlab::Database::Migrations::BackgroundMigrationHelpers do
end
end
it_behaves_like 'helpers that enqueue background migrations', BackgroundMigration::CiDatabaseWorker, 'ci'
it_behaves_like 'helpers that enqueue background migrations', BackgroundMigration::CiDatabaseWorker, Ci::ApplicationRecord, 'ci'
end
describe '#delete_job_tracking' do

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Database::Migrations::ReestablishedConnectionStack do
let(:base_class) { ActiveRecord::Migration }
let(:model) do
base_class.new
.extend(described_class)
end
describe '#with_restored_connection_stack' do
Gitlab::Database.database_base_models.each do |db_config_name, _|
context db_config_name do
it_behaves_like "reconfigures connection stack", db_config_name do
it 'does restore connection hierarchy' do
model.with_restored_connection_stack do
validate_connections!
end
end
primary_db_config = ActiveRecord::Base.configurations.primary?(db_config_name)
it 'does reconfigure connection handler', unless: primary_db_config do
original_handler = ActiveRecord::Base.connection_handler
new_handler = nil
model.with_restored_connection_stack do
new_handler = ActiveRecord::Base.connection_handler
# establish connection
ApplicationRecord.connection.select_one("SELECT 1 FROM projects LIMIT 1")
Ci::ApplicationRecord.connection.select_one("SELECT 1 FROM ci_builds LIMIT 1")
end
expect(new_handler).not_to eq(original_handler), "is reconnected"
expect(new_handler).not_to be_active_connections
expect(ActiveRecord::Base.connection_handler).to eq(original_handler), "is restored"
end
it 'does keep original connection handler', if: primary_db_config do
original_handler = ActiveRecord::Base.connection_handler
new_handler = nil
model.with_restored_connection_stack do
new_handler = ActiveRecord::Base.connection_handler
end
expect(new_handler).to eq(original_handler)
expect(ActiveRecord::Base.connection_handler).to eq(original_handler)
end
end
end
end
end
end

View File

@ -3,6 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
include Gitlab::Database::Migrations::ReestablishedConnectionStack
include Gitlab::Database::Migrations::BackgroundMigrationHelpers
include Database::MigrationTestingHelpers
@ -13,6 +14,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
end
let(:result_dir) { Dir.mktmpdir }
let(:connection) { ApplicationRecord.connection }
after do
FileUtils.rm_rf(result_dir)

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasValidateConnection, query_analyzers: false do
let(:analyzer) { described_class }
context 'properly observes all queries', :request_store do
using RSpec::Parameterized::TableSyntax
where do
{
"for simple query observes schema correctly" => {
model: ApplicationRecord,
sql: "SELECT 1 FROM projects",
expect_error: nil,
setup: nil
},
"for query accessing gitlab_ci and gitlab_main" => {
model: ApplicationRecord,
sql: "SELECT 1 FROM projects LEFT JOIN ci_builds ON ci_builds.project_id=projects.id",
expect_error: /The query tried to access \["projects", "ci_builds"\]/,
setup: -> (_) { skip_if_multiple_databases_not_setup }
},
"for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => {
model: ApplicationRecord,
sql: "SELECT 1 FROM ci_builds LEFT JOIN projects ON ci_builds.project_id=projects.id",
expect_error: /The query tried to access \["ci_builds", "projects"\]/,
setup: -> (_) { skip_if_multiple_databases_not_setup }
},
"for query accessing main table from CI database" => {
model: Ci::ApplicationRecord,
sql: "SELECT 1 FROM projects",
expect_error: /The query tried to access \["projects"\]/,
setup: -> (_) { skip_if_multiple_databases_not_setup }
},
"for query accessing CI database" => {
model: Ci::ApplicationRecord,
sql: "SELECT 1 FROM ci_builds",
expect_error: nil
},
"for query accessing CI table from main database" => {
model: ::ApplicationRecord,
sql: "SELECT 1 FROM ci_builds",
expect_error: /The query tried to access \["ci_builds"\]/,
setup: -> (_) { skip_if_multiple_databases_not_setup }
}
}
end
with_them do
it do
instance_eval(&setup) if setup
if expect_error
expect { process_sql(model, sql) }.to raise_error(expect_error)
else
expect { process_sql(model, sql) }.not_to raise_error
end
end
end
end
def process_sql(model, sql)
Gitlab::Database::QueryAnalyzer.instance.within([analyzer]) do
# Skip load balancer and retrieve connection assigned to model
Gitlab::Database::QueryAnalyzer.instance.send(:process_sql, sql, model.retrieve_connection)
end
end
end

View File

@ -51,7 +51,7 @@ RSpec.describe Gitlab::Database::SharedModel do
expect do
described_class.using_connection(second_connection) {}
end.to raise_error(/cannot nest connection overrides/)
end.to raise_error(/Cannot change connection for Gitlab::Database::SharedModel/)
expect(described_class.connection).to be(new_connection)
end

View File

@ -36,18 +36,8 @@ RSpec.describe Gitlab::Metrics::RailsSlis do
}
end
expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { false }
expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:graphql_query_apdex) { false }
expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:rails_request_apdex, array_including(*possible_labels)).and_call_original
expect(Gitlab::Metrics::Sli).to receive(:initialize_sli).with(:graphql_query_apdex, array_including(*possible_graphql_labels)).and_call_original
described_class.initialize_request_slis!
end
it 'does not initialize the SLI if they were initialized already', :aggregate_failures do
expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:rails_request_apdex) { true }
expect(Gitlab::Metrics::Sli).to receive(:initialized?).with(:graphql_query_apdex) { true }
expect(Gitlab::Metrics::Sli).not_to receive(:initialize_sli)
expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(:rails_request, array_including(*possible_labels)).and_call_original
expect(Gitlab::Metrics::Sli::Apdex).to receive(:initialize_sli).with(:graphql_query, array_including(*possible_graphql_labels)).and_call_original
described_class.initialize_request_slis!
end

View File

@ -10,72 +10,151 @@ RSpec.describe Gitlab::Metrics::Sli do
end
describe 'Class methods' do
before do
described_class.instance_variable_set(:@known_slis, nil)
it 'does not allow them to be called on the parent module' do
expect(described_class).not_to respond_to(:[])
expect(described_class).not_to respond_to(:initialize_sli)
end
describe '.[]' do
it 'warns about an uninitialized SLI but returns and stores a new one' do
sli = described_class[:bar]
it 'allows different SLIs to be defined on each subclass' do
apdex_counters = [
fake_total_counter('foo', 'apdex'),
fake_numerator_counter('foo', 'apdex', 'success')
]
expect(described_class[:bar]).to be(sli)
end
error_rate_counters = [
fake_total_counter('foo', 'error_rate'),
fake_numerator_counter('foo', 'error_rate', 'error')
]
it 'returns the same object for multiple accesses' do
sli = described_class.initialize_sli(:huzzah, [])
apdex = described_class::Apdex.initialize_sli(:foo, [{ hello: :world }])
2.times do
expect(described_class[:huzzah]).to be(sli)
expect(apdex_counters).to all(have_received(:get).with(hello: :world))
error_rate = described_class::ErrorRate.initialize_sli(:foo, [{ other: :labels }])
expect(error_rate_counters).to all(have_received(:get).with(other: :labels))
expect(described_class::Apdex[:foo]).to be(apdex)
expect(described_class::ErrorRate[:foo]).to be(error_rate)
end
end
subclasses = {
Gitlab::Metrics::Sli::Apdex => :success,
Gitlab::Metrics::Sli::ErrorRate => :error
}
subclasses.each do |subclass, numerator_type|
subclass_type = subclass.to_s.demodulize.underscore
describe subclass do
describe 'Class methods' do
before do
described_class.instance_variable_set(:@known_slis, nil)
end
describe '.[]' do
it 'returns and stores a new, uninitialized SLI' do
sli = described_class[:bar]
expect(described_class[:bar]).to be(sli)
expect(described_class[:bar]).not_to be_initialized
end
it 'returns the same object for multiple accesses' do
sli = described_class.initialize_sli(:huzzah, [])
2.times do
expect(described_class[:huzzah]).to be(sli)
end
end
end
describe '.initialize_sli' do
it 'returns and stores a new initialized SLI' do
counters = [
fake_total_counter(:bar, subclass_type),
fake_numerator_counter(:bar, subclass_type, numerator_type)
]
sli = described_class.initialize_sli(:bar, [{ hello: :world }])
expect(sli).to be_initialized
expect(counters).to all(have_received(:get).with(hello: :world))
expect(counters).to all(have_received(:get).with(hello: :world))
end
it 'does not change labels for an already-initialized SLI' do
counters = [
fake_total_counter(:bar, subclass_type),
fake_numerator_counter(:bar, subclass_type, numerator_type)
]
sli = described_class.initialize_sli(:bar, [{ hello: :world }])
expect(sli).to be_initialized
expect(counters).to all(have_received(:get).with(hello: :world))
expect(counters).to all(have_received(:get).with(hello: :world))
counters.each do |counter|
expect(counter).not_to receive(:get)
end
expect(described_class.initialize_sli(:bar, [{ other: :labels }])).to eq(sli)
end
end
describe '.initialized?' do
before do
fake_total_counter(:boom, subclass_type)
fake_numerator_counter(:boom, subclass_type, numerator_type)
end
it 'is true when an SLI was initialized with labels' do
expect { described_class.initialize_sli(:boom, [{ hello: :world }]) }
.to change { described_class.initialized?(:boom) }.from(false).to(true)
end
it 'is false when an SLI was not initialized with labels' do
expect { described_class.initialize_sli(:boom, []) }
.not_to change { described_class.initialized?(:boom) }.from(false)
end
end
end
end
describe '.initialized?' do
before do
fake_total_counter(:boom)
fake_success_counter(:boom)
describe '#initialize_counters' do
it 'initializes counters for the passed label combinations' do
counters = [
fake_total_counter(:hey, subclass_type),
fake_numerator_counter(:hey, subclass_type, numerator_type)
]
described_class.new(:hey).initialize_counters([{ foo: 'bar' }, { foo: 'baz' }])
expect(counters).to all(have_received(:get).with({ foo: 'bar' }))
expect(counters).to all(have_received(:get).with({ foo: 'baz' }))
end
end
it 'is true when an SLI was initialized with labels' do
expect { described_class.initialize_sli(:boom, [{ hello: :world }]) }
.to change { described_class.initialized?(:boom) }.from(false).to(true)
describe "#increment" do
let!(:sli) { described_class.new(:heyo) }
let!(:total_counter) { fake_total_counter(:heyo, subclass_type) }
let!(:numerator_counter) { fake_numerator_counter(:heyo, subclass_type, numerator_type) }
it "increments both counters for labels when #{numerator_type} is true" do
sli.increment(labels: { hello: "world" }, numerator_type => true)
expect(total_counter).to have_received(:increment).with({ hello: 'world' })
expect(numerator_counter).to have_received(:increment).with({ hello: 'world' })
end
it "only increments the total counters for labels when #{numerator_type} is false" do
sli.increment(labels: { hello: "world" }, numerator_type => false)
expect(total_counter).to have_received(:increment).with({ hello: 'world' })
expect(numerator_counter).not_to have_received(:increment).with({ hello: 'world' })
end
end
it 'is false when an SLI was not initialized with labels' do
expect { described_class.initialize_sli(:boom, []) }
.not_to change { described_class.initialized?(:boom) }.from(false)
end
end
end
describe '#initialize_counters' do
it 'initializes counters for the passed label combinations' do
counters = [fake_total_counter(:hey), fake_success_counter(:hey)]
described_class.new(:hey).initialize_counters([{ foo: 'bar' }, { foo: 'baz' }])
expect(counters).to all(have_received(:get).with({ foo: 'bar' }))
expect(counters).to all(have_received(:get).with({ foo: 'baz' }))
end
end
describe "#increment" do
let!(:sli) { described_class.new(:heyo) }
let!(:total_counter) { fake_total_counter(:heyo) }
let!(:success_counter) { fake_success_counter(:heyo) }
it 'increments both counters for labels successes' do
sli.increment(labels: { hello: "world" }, success: true)
expect(total_counter).to have_received(:increment).with({ hello: 'world' })
expect(success_counter).to have_received(:increment).with({ hello: 'world' })
end
it 'only increments the total counters for labels when not successful' do
sli.increment(labels: { hello: "world" }, success: false)
expect(total_counter).to have_received(:increment).with({ hello: 'world' })
expect(success_counter).not_to have_received(:increment).with({ hello: 'world' })
end
end
@ -89,11 +168,11 @@ RSpec.describe Gitlab::Metrics::Sli do
fake_counter
end
def fake_total_counter(name)
fake_prometheus_counter("gitlab_sli:#{name}:total")
def fake_total_counter(name, type)
fake_prometheus_counter("gitlab_sli:#{name}_#{type}:total")
end
def fake_success_counter(name)
fake_prometheus_counter("gitlab_sli:#{name}:success_total")
def fake_numerator_counter(name, type, numerator_name)
fake_prometheus_counter("gitlab_sli:#{name}_#{type}:#{numerator_name}_total")
end
end

View File

@ -0,0 +1,129 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::BlameService, :aggregate_failures do
subject(:service) { described_class.new(blob, commit, params) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:commit) { project.repository.commit }
let_it_be(:blob) { project.repository.blob_at('HEAD', 'README.md') }
let(:params) { { page: page } }
let(:page) { nil }
before do
stub_const("#{described_class.name}::PER_PAGE", 2)
end
describe '#blame' do
subject { service.blame }
it 'returns a correct Gitlab::Blame object' do
is_expected.to be_kind_of(Gitlab::Blame)
expect(subject.blob).to eq(blob)
expect(subject.commit).to eq(commit)
expect(subject.range).to eq(1..2)
end
describe 'Pagination range calculation' do
subject { service.blame.range }
context 'with page = 1' do
let(:page) { 1 }
it { is_expected.to eq(1..2) }
end
context 'with page = 2' do
let(:page) { 2 }
it { is_expected.to eq(3..4) }
end
context 'with page = 3 (overlimit)' do
let(:page) { 3 }
it { is_expected.to eq(1..2) }
end
context 'with page = 0 (incorrect)' do
let(:page) { 0 }
it { is_expected.to eq(1..2) }
end
context 'when feature flag disabled' do
before do
stub_feature_flags(blame_page_pagination: false)
end
it { is_expected.to be_nil }
end
end
end
describe '#pagination' do
subject { service.pagination }
it 'returns a pagination object' do
is_expected.to be_kind_of(Kaminari::PaginatableArray)
expect(subject.current_page).to eq(1)
expect(subject.total_pages).to eq(2)
expect(subject.total_count).to eq(4)
end
context 'when feature flag disabled' do
before do
stub_feature_flags(blame_page_pagination: false)
end
it { is_expected.to be_nil }
end
context 'when per_page is above the global max per page limit' do
before do
stub_const("#{described_class.name}::PER_PAGE", 1000)
allow(blob).to receive_message_chain(:data, :lines, :count) { 500 }
end
it 'returns a correct pagination object' do
is_expected.to be_kind_of(Kaminari::PaginatableArray)
expect(subject.current_page).to eq(1)
expect(subject.total_pages).to eq(1)
expect(subject.total_count).to eq(500)
end
end
describe 'Current page' do
subject { service.pagination.current_page }
context 'with page = 1' do
let(:page) { 1 }
it { is_expected.to eq(1) }
end
context 'with page = 2' do
let(:page) { 2 }
it { is_expected.to eq(2) }
end
context 'with page = 3 (overlimit)' do
let(:page) { 3 }
it { is_expected.to eq(1) }
end
context 'with page = 0 (incorrect)' do
let(:page) { 0 }
it { is_expected.to eq(1) }
end
end
end
end

View File

@ -22,9 +22,15 @@ module NextInstanceOf
def stub_new(target, number, ordered = false, *new_args, &blk)
receive_new = receive(:new)
receive_new.ordered if ordered
receive_new.exactly(number).times if number
receive_new.with(*new_args) if new_args.any?
if number.is_a?(Range)
receive_new.at_least(number.begin).times if number.begin
receive_new.at_most(number.end).times if number.end
elsif number
receive_new.exactly(number).times
end
target.to receive_new.and_wrap_original do |method, *original_args|
method.call(*original_args).tap(&blk)
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
RSpec.shared_context 'reconfigures connection stack' do |db_config_name|
before do
skip_if_multiple_databases_not_setup
# Due to lib/gitlab/database/load_balancing/configuration.rb:92 requiring RequestStore
# we cannot use stub_feature_flags(force_no_sharing_primary_model: true)
Gitlab::Database.database_base_models.each do |_, model_class|
allow(model_class.load_balancer.configuration).to receive(:use_dedicated_connection?).and_return(true)
end
ActiveRecord::Base.establish_connection(db_config_name.to_sym) # rubocop:disable Database/EstablishConnection
expect(Gitlab::Database.db_config_name(ActiveRecord::Base.connection)) # rubocop:disable Database/MultipleDatabases
.to eq(db_config_name)
end
around do |example|
with_reestablished_active_record_base do
example.run
end
end
def validate_connections!
model_connections = Gitlab::Database.database_base_models.to_h do |db_config_name, model_class|
[model_class, Gitlab::Database.db_config_name(model_class.connection)]
end
expect(model_connections).to eq(Gitlab::Database.database_base_models.invert)
end
end

View File

@ -101,6 +101,15 @@ RSpec.describe Tooling::Danger::ProjectHelper do
'Rakefile' | [:backend]
'FOO_VERSION' | [:backend]
'lib/scripts/bar.rb' | [:backend, :tooling]
'lib/scripts/bar.js' | [:frontend, :tooling]
'scripts/bar.rb' | [:backend, :tooling]
'scripts/bar.js' | [:frontend, :tooling]
'lib/scripts/subdir/bar.rb' | [:backend, :tooling]
'lib/scripts/subdir/bar.js' | [:frontend, :tooling]
'scripts/subdir/bar.rb' | [:backend, :tooling]
'scripts/subdir/bar.js' | [:frontend, :tooling]
'Dangerfile' | [:tooling]
'danger/bundle_size/Dangerfile' | [:tooling]
'ee/danger/bundle_size/Dangerfile' | [:tooling]

View File

@ -101,6 +101,8 @@ module Tooling
%r{\A\.editorconfig\z} => :tooling,
%r{Dangerfile\z} => :tooling,
%r{\A((ee|jh)/)?(danger/|tooling/danger/)} => :tooling,
%r{\A((ee|jh)/)?(lib/)?scripts/.*\.rb} => [:backend, :tooling],
%r{\A((ee|jh)/)?(lib/)?scripts/.*\.js} => [:frontend, :tooling],
%r{\A((ee|jh)/)?scripts/} => :tooling,
%r{\Atooling/} => :tooling,
%r{(CODEOWNERS)} => :tooling,