Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
f0a387b4a5
commit
d88ab3545c
|
@ -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
|
||||
|
|
|
@ -1 +1 @@
|
|||
6b31501b13eae70aea5061edc8273c551ba4c349
|
||||
93762b621c011fe570339c1c247d5197c2cfefcc
|
||||
|
|
|
@ -1 +1 @@
|
|||
1.57.0
|
||||
1.58.0
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
#import "./issue.fragment.graphql"
|
||||
|
||||
query getIssues(
|
||||
$hideUsers: Boolean = false
|
||||
$isProject: Boolean = false
|
||||
$isSignedIn: Boolean = false
|
||||
$fullPath: ID!
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
#import "./issue.fragment.graphql"
|
||||
|
||||
query getIssuesWithoutCrm(
|
||||
$hideUsers: Boolean = false
|
||||
$isProject: Boolean = false
|
||||
$isSignedIn: Boolean = false
|
||||
$fullPath: ID!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">·</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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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
|
|
@ -58,3 +58,6 @@
|
|||
#{line}
|
||||
|
||||
- current_line += line_count
|
||||
|
||||
- if blame_pagination
|
||||
= paginate(blame_pagination, theme: "gitlab")
|
||||
|
|
|
@ -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 } }
|
||||
|
|
|
@ -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
|
|
@ -5,4 +5,4 @@ rollout_issue_url:
|
|||
milestone: '14.10'
|
||||
type: development
|
||||
group: group::code review
|
||||
default_enabled: false
|
||||
default_enabled: true
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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 |
|
@ -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**.
|
||||
|
|
|
@ -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 |
|
@ -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)
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
module Gitlab
|
||||
module Database
|
||||
module MigrationHelpers
|
||||
include Migrations::ReestablishedConnectionStack
|
||||
include Migrations::BackgroundMigrationHelpers
|
||||
include Migrations::BatchedBackgroundMigrationHelpers
|
||||
include DynamicModelHelpers
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue