Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e643b1a376
commit
ff71e5f91c
|
|
@ -1,23 +1,10 @@
|
|||
---
|
||||
# Cop supports --auto-correct.
|
||||
Layout/HashAlignment:
|
||||
# Offense count: 3804
|
||||
# Offense count: 630
|
||||
# Temporarily disabled due to too many offenses
|
||||
Enabled: false
|
||||
Exclude:
|
||||
- 'app/controllers/admin/ci/variables_controller.rb'
|
||||
- 'app/controllers/admin/system_info_controller.rb'
|
||||
- 'app/controllers/oauth/token_info_controller.rb'
|
||||
- 'app/controllers/projects/feature_flags_controller.rb'
|
||||
- 'app/controllers/repositories/git_http_client_controller.rb'
|
||||
- 'app/controllers/repositories/lfs_api_controller.rb'
|
||||
- 'app/controllers/repositories/lfs_locks_api_controller.rb'
|
||||
- 'app/controllers/uploads_controller.rb'
|
||||
- 'app/graphql/mutations/award_emojis/toggle.rb'
|
||||
- 'app/graphql/mutations/ci/runner/update.rb'
|
||||
- 'app/graphql/mutations/design_management/move.rb'
|
||||
- 'app/graphql/mutations/issues/set_severity.rb'
|
||||
- 'app/graphql/mutations/security/ci_configuration/base_security_analyzer.rb'
|
||||
- 'app/models/bulk_imports/configuration.rb'
|
||||
- 'app/models/ci/bridge.rb'
|
||||
- 'app/models/ci/build_trace_metadata.rb'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
<script>
|
||||
import { GlLink, GlTableLite, GlDropdownItem, GlDropdown, GlButton } from '@gitlab/ui';
|
||||
import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { last } from 'lodash';
|
||||
import { numberToHumanSize } from '~/lib/utils/number_utils';
|
||||
import { __ } from '~/locale';
|
||||
import { __, s__ } from '~/locale';
|
||||
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
|
||||
import Tracking from '~/tracking';
|
||||
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
|
||||
import FileIcon from '~/vue_shared/components/file_icon.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import {
|
||||
REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
|
||||
SELECT_PACKAGE_FILE_TRACKING_ACTION,
|
||||
TRACKING_LABEL_PACKAGE_ASSET,
|
||||
TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
|
||||
} from '~/packages_and_registries/package_registry/constants';
|
||||
|
|
@ -17,9 +19,10 @@ export default {
|
|||
name: 'PackageFiles',
|
||||
components: {
|
||||
GlLink,
|
||||
GlTableLite,
|
||||
GlTable,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlFormCheckbox,
|
||||
GlButton,
|
||||
FileIcon,
|
||||
TimeAgoTooltip,
|
||||
|
|
@ -32,13 +35,29 @@ export default {
|
|||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
packageFiles: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedReferences: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
areFilesSelected() {
|
||||
return this.selectedReferences.length > 0;
|
||||
},
|
||||
areAllFilesSelected() {
|
||||
return this.packageFiles.every(this.isSelected);
|
||||
},
|
||||
filesTableRows() {
|
||||
return this.packageFiles.map((pf) => ({
|
||||
...pf,
|
||||
|
|
@ -46,6 +65,9 @@ export default {
|
|||
pipeline: last(pf.pipelines),
|
||||
}));
|
||||
},
|
||||
hasSelectedSomeFiles() {
|
||||
return this.areFilesSelected && !this.areAllFilesSelected;
|
||||
},
|
||||
showCommitColumn() {
|
||||
// note that this is always false for now since we do not return
|
||||
// pipelines associated to files for performance concerns
|
||||
|
|
@ -53,6 +75,12 @@ export default {
|
|||
},
|
||||
filesTableHeaderFields() {
|
||||
return [
|
||||
{
|
||||
key: 'checkbox',
|
||||
label: __('Select all'),
|
||||
class: 'gl-w-4',
|
||||
hide: !this.canDelete,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: __('Name'),
|
||||
|
|
@ -98,22 +126,71 @@ export default {
|
|||
this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET });
|
||||
}
|
||||
},
|
||||
updateSelectedReferences(selection) {
|
||||
this.track(SELECT_PACKAGE_FILE_TRACKING_ACTION);
|
||||
this.selectedReferences = selection;
|
||||
},
|
||||
isSelected(packageFile) {
|
||||
return this.selectedReferences.find((reference) => reference.id === packageFile.id);
|
||||
},
|
||||
handleFileDeleteSelected() {
|
||||
this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION);
|
||||
this.$emit('delete-files', this.selectedReferences);
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
deleteFile: __('Delete file'),
|
||||
deleteSelected: s__('PackageRegistry|Delete selected'),
|
||||
moreActionsText: __('More actions'),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
|
||||
<gl-table-lite
|
||||
<div class="gl-pt-6">
|
||||
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
|
||||
<h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
|
||||
<gl-button
|
||||
v-if="canDelete"
|
||||
:disabled="isLoading || !areFilesSelected"
|
||||
category="secondary"
|
||||
variant="danger"
|
||||
data-testid="delete-selected"
|
||||
@click="handleFileDeleteSelected"
|
||||
>
|
||||
{{ $options.i18n.deleteSelected }}
|
||||
</gl-button>
|
||||
</div>
|
||||
<gl-table
|
||||
:fields="filesTableHeaderFields"
|
||||
:items="filesTableRows"
|
||||
show-empty
|
||||
selectable
|
||||
select-mode="multi"
|
||||
selected-variant="primary"
|
||||
:tbody-tr-attr="{ 'data-testid': 'file-row' }"
|
||||
@row-selected="updateSelectedReferences"
|
||||
>
|
||||
<template #head(checkbox)="{ selectAllRows, clearSelected }">
|
||||
<gl-form-checkbox
|
||||
v-if="canDelete"
|
||||
data-testid="package-files-checkbox-all"
|
||||
:checked="areAllFilesSelected"
|
||||
:indeterminate="hasSelectedSomeFiles"
|
||||
@change="areAllFilesSelected ? clearSelected() : selectAllRows()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }">
|
||||
<gl-form-checkbox
|
||||
v-if="canDelete"
|
||||
class="gl-mt-1"
|
||||
:checked="rowSelected"
|
||||
data-testid="package-files-checkbox"
|
||||
@change="rowSelected ? unselectRow() : selectRow()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #cell(name)="{ item, toggleDetails, detailsShowing }">
|
||||
<gl-button
|
||||
v-if="hasDetails(item)"
|
||||
|
|
@ -164,7 +241,7 @@ export default {
|
|||
no-caret
|
||||
right
|
||||
>
|
||||
<gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)">
|
||||
<gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])">
|
||||
{{ $options.i18n.deleteFile }}
|
||||
</gl-dropdown-item>
|
||||
</gl-dropdown>
|
||||
|
|
@ -184,6 +261,6 @@ export default {
|
|||
<file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" />
|
||||
</div>
|
||||
</template>
|
||||
</gl-table-lite>
|
||||
</gl-table>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@ export {
|
|||
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
|
||||
PULL_PACKAGE_TRACKING_ACTION,
|
||||
DELETE_PACKAGE_FILE_TRACKING_ACTION,
|
||||
DELETE_PACKAGE_FILES_TRACKING_ACTION,
|
||||
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
|
||||
REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
|
||||
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
|
||||
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
|
||||
SELECT_PACKAGE_FILE_TRACKING_ACTION,
|
||||
} from '~/packages_and_registries/shared/constants';
|
||||
|
||||
export const PACKAGE_TYPE_CONAN = 'CONAN';
|
||||
|
|
@ -81,6 +84,12 @@ export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
|
|||
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
|
||||
'PackageRegistry|Package file deleted successfully',
|
||||
);
|
||||
export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__(
|
||||
'PackageRegistry|Something went wrong while deleting the package assets.',
|
||||
);
|
||||
export const DELETE_PACKAGE_FILES_SUCCESS_MESSAGE = s__(
|
||||
'PackageRegistry|Package assets deleted successfully',
|
||||
);
|
||||
export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
|
||||
'PackageRegistry|Failed to load the package data',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
mutation destroyPackageFile($id: PackagesPackageFileID!) {
|
||||
destroyPackageFile(input: { id: $id }) {
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
mutation destroyPackageFiles($projectPath: ID!, $ids: [PackagesPackageFileID!]!) {
|
||||
destroyPackageFiles(input: { projectPath: $projectPath, ids: $ids }) {
|
||||
errors
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ query getPackageDetails($id: PackagesPackageID!) {
|
|||
id
|
||||
path
|
||||
name
|
||||
fullPath
|
||||
}
|
||||
tags(first: 10) {
|
||||
nodes {
|
||||
|
|
@ -39,6 +40,9 @@ query getPackageDetails($id: PackagesPackageID!) {
|
|||
}
|
||||
}
|
||||
packageFiles(first: 100) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
fileMd5
|
||||
|
|
|
|||
|
|
@ -34,16 +34,19 @@ import {
|
|||
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
|
||||
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
|
||||
DELETE_PACKAGE_FILE_TRACKING_ACTION,
|
||||
DELETE_PACKAGE_FILES_TRACKING_ACTION,
|
||||
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
|
||||
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
|
||||
SHOW_DELETE_SUCCESS_ALERT,
|
||||
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
|
||||
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
|
||||
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
|
||||
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
|
||||
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
|
||||
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
|
||||
} from '~/packages_and_registries/package_registry/constants';
|
||||
|
||||
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
|
||||
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
|
||||
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
|
||||
import Tracking from '~/tracking';
|
||||
|
||||
|
|
@ -83,7 +86,8 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
fileToDelete: null,
|
||||
filesToDelete: [],
|
||||
mutationLoading: false,
|
||||
packageEntity: {},
|
||||
};
|
||||
},
|
||||
|
|
@ -114,6 +118,9 @@ export default {
|
|||
projectName() {
|
||||
return this.packageEntity.project?.name;
|
||||
},
|
||||
projectPath() {
|
||||
return this.packageEntity.project?.fullPath;
|
||||
},
|
||||
packageId() {
|
||||
return this.$route.params.id;
|
||||
},
|
||||
|
|
@ -131,6 +138,9 @@ export default {
|
|||
isLoading() {
|
||||
return this.$apollo.queries.packageEntity.loading;
|
||||
},
|
||||
packageFilesLoading() {
|
||||
return this.isLoading || this.mutationLoading;
|
||||
},
|
||||
isValidPackage() {
|
||||
return this.isLoading || Boolean(this.packageEntity.name);
|
||||
},
|
||||
|
|
@ -175,12 +185,14 @@ export default {
|
|||
|
||||
window.location.replace(`${returnTo}?${modalQuery}`);
|
||||
},
|
||||
async deletePackageFile(id) {
|
||||
async deletePackageFiles(ids) {
|
||||
this.mutationLoading = true;
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: destroyPackageFileMutation,
|
||||
mutation: destroyPackageFilesMutation,
|
||||
variables: {
|
||||
id,
|
||||
projectPath: this.projectPath,
|
||||
ids,
|
||||
},
|
||||
awaitRefetchQueries: true,
|
||||
refetchQueries: [
|
||||
|
|
@ -190,35 +202,53 @@ export default {
|
|||
},
|
||||
],
|
||||
});
|
||||
if (data?.destroyPackageFile?.errors[0]) {
|
||||
throw data.destroyPackageFile.errors[0];
|
||||
if (data?.destroyPackageFiles?.errors[0]) {
|
||||
throw data.destroyPackageFiles.errors[0];
|
||||
}
|
||||
createFlash({
|
||||
message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
|
||||
message:
|
||||
ids.length === 1
|
||||
? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
|
||||
: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
createFlash({
|
||||
message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
|
||||
message:
|
||||
ids.length === 1
|
||||
? DELETE_PACKAGE_FILE_ERROR_MESSAGE
|
||||
: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
|
||||
type: 'warning',
|
||||
captureError: true,
|
||||
error,
|
||||
});
|
||||
}
|
||||
this.mutationLoading = false;
|
||||
},
|
||||
handleFileDelete(file) {
|
||||
handleFileDelete(files) {
|
||||
this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
|
||||
if (this.packageFiles.length === 1) {
|
||||
if (
|
||||
files.length === this.packageFiles.length &&
|
||||
!this.packageEntity.packageFiles?.pageInfo?.hasNextPage
|
||||
) {
|
||||
this.$refs.deleteModal.show();
|
||||
} else {
|
||||
this.fileToDelete = { ...file };
|
||||
this.$refs.deleteFileModal.show();
|
||||
this.filesToDelete = files;
|
||||
if (files.length === 1) {
|
||||
this.$refs.deleteFileModal.show();
|
||||
} else if (files.length > 1) {
|
||||
this.$refs.deleteFilesModal.show();
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmFileDelete() {
|
||||
this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
|
||||
this.deletePackageFile(this.fileToDelete.id);
|
||||
this.fileToDelete = null;
|
||||
confirmFilesDelete() {
|
||||
if (this.filesToDelete.length === 1) {
|
||||
this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
|
||||
} else {
|
||||
this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
|
||||
}
|
||||
this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
|
||||
this.filesToDelete = [];
|
||||
},
|
||||
},
|
||||
i18n: {
|
||||
|
|
@ -244,6 +274,10 @@ export default {
|
|||
text: __('Delete'),
|
||||
attributes: [{ variant: 'danger' }, { category: 'primary' }],
|
||||
},
|
||||
filesDeletePrimaryAction: {
|
||||
text: s__('PackageRegistry|Permanently delete assets'),
|
||||
attributes: [{ variant: 'danger' }, { category: 'primary' }],
|
||||
},
|
||||
cancelAction: {
|
||||
text: __('Cancel'),
|
||||
},
|
||||
|
|
@ -291,9 +325,10 @@ export default {
|
|||
<package-files
|
||||
v-if="showFiles"
|
||||
:can-delete="packageEntity.canDestroy"
|
||||
:is-loading="packageFilesLoading"
|
||||
:package-files="packageFiles"
|
||||
@download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
|
||||
@delete-file="handleFileDelete"
|
||||
@delete-files="handleFileDelete"
|
||||
/>
|
||||
</gl-tab>
|
||||
|
||||
|
|
@ -359,15 +394,43 @@ export default {
|
|||
:action-primary="$options.modal.fileDeletePrimaryAction"
|
||||
:action-cancel="$options.modal.cancelAction"
|
||||
data-testid="delete-file-modal"
|
||||
@primary="confirmFileDelete"
|
||||
@primary="confirmFilesDelete"
|
||||
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
|
||||
>
|
||||
<template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
|
||||
<gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent">
|
||||
<gl-sprintf v-if="filesToDelete.length === 1" :message="$options.i18n.deleteFileModalContent">
|
||||
<template #filename>
|
||||
<strong>{{ fileToDelete.file_name }}</strong>
|
||||
<strong>{{ filesToDelete[0].fileName }}</strong>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</gl-modal>
|
||||
|
||||
<gl-modal
|
||||
ref="deleteFilesModal"
|
||||
size="sm"
|
||||
modal-id="delete-files-modal"
|
||||
:action-primary="$options.modal.filesDeletePrimaryAction"
|
||||
:action-cancel="$options.modal.cancelAction"
|
||||
data-testid="delete-files-modal"
|
||||
@primary="confirmFilesDelete"
|
||||
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
|
||||
>
|
||||
<template #modal-title>{{
|
||||
n__(
|
||||
`PackageRegistry|Delete 1 asset`,
|
||||
`PackageRegistry|Delete %d assets`,
|
||||
filesToDelete.length,
|
||||
)
|
||||
}}</template>
|
||||
<span v-if="filesToDelete.length > 0">
|
||||
{{
|
||||
n__(
|
||||
`PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`,
|
||||
`PackageRegistry|You are about to delete %d assets. This operation is irreversible.`,
|
||||
filesToDelete.length,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</gl-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package';
|
|||
export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package';
|
||||
export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package';
|
||||
export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file';
|
||||
export const DELETE_PACKAGE_FILES_TRACKING_ACTION = 'delete_package_files';
|
||||
export const SELECT_PACKAGE_FILE_TRACKING_ACTION = 'select_package_file';
|
||||
export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file';
|
||||
export const REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION =
|
||||
'request_delete_selected_package_file';
|
||||
export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file';
|
||||
export const DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset';
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class Admin::Ci::VariablesController < Admin::ApplicationController
|
|||
|
||||
def render_instance_variables
|
||||
render status: :ok,
|
||||
json: {
|
||||
json: {
|
||||
variables: Ci::InstanceVariableSerializer.new.represent(variables)
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ class Admin::SystemInfoController < Admin::ApplicationController
|
|||
disk = Sys::Filesystem.stat(mount.mount_point)
|
||||
@disks.push({
|
||||
bytes_total: disk.bytes_total,
|
||||
bytes_used: disk.bytes_used,
|
||||
disk_name: mount.name,
|
||||
mount_path: disk.path
|
||||
bytes_used: disk.bytes_used,
|
||||
disk_name: mount.name,
|
||||
mount_path: disk.path
|
||||
})
|
||||
rescue Sys::Filesystem::Error
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class Oauth::TokenInfoController < Doorkeeper::TokenInfoController
|
|||
|
||||
# maintain backwards compatibility
|
||||
render json: token_json.merge(
|
||||
'scopes' => token_json[:scope],
|
||||
'scopes' => token_json[:scope],
|
||||
'expires_in_seconds' => token_json[:expires_in]
|
||||
), status: :ok
|
||||
else
|
||||
|
|
|
|||
|
|
@ -111,9 +111,9 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
|
|||
.permit(:name, :description, :active,
|
||||
scopes_attributes: [:id, :environment_scope, :active, :_destroy,
|
||||
strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
|
||||
strategies_attributes: [:id, :name, :user_list_id, :_destroy,
|
||||
parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
|
||||
scopes_attributes: [:id, :environment_scope, :_destroy]])
|
||||
strategies_attributes: [:id, :name, :user_list_id, :_destroy,
|
||||
parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
|
||||
scopes_attributes: [:id, :environment_scope, :_destroy]])
|
||||
end
|
||||
|
||||
def feature_flag_json(feature_flag)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ module Repositories
|
|||
render plain: "HTTP Basic: Access denied\n" \
|
||||
"You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
|
||||
"You can generate one at #{profile_personal_access_tokens_url}",
|
||||
status: :unauthorized
|
||||
status: :unauthorized
|
||||
end
|
||||
|
||||
def repository
|
||||
|
|
|
|||
|
|
@ -173,12 +173,12 @@ module Repositories
|
|||
LfsObjectsProject.link_to_project!(lfs_object, project)
|
||||
|
||||
Gitlab::AppJsonLogger.info(message: "LFS object auto-linked to forked project",
|
||||
lfs_object_oid: lfs_object.oid,
|
||||
lfs_object_size: lfs_object.size,
|
||||
source_project_id: project.fork_source.id,
|
||||
source_project_path: project.fork_source.full_path,
|
||||
target_project_id: project.project_id,
|
||||
target_project_path: project.full_path)
|
||||
lfs_object_oid: lfs_object.oid,
|
||||
lfs_object_size: lfs_object.size,
|
||||
source_project_id: project.fork_source.id,
|
||||
source_project_path: project.fork_source.full_path,
|
||||
target_project_id: project.project_id,
|
||||
target_project_path: project.full_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ module Repositories
|
|||
|
||||
def render_json(data, process = true)
|
||||
render json: build_payload(data, process),
|
||||
content_type: LfsRequest::CONTENT_TYPE,
|
||||
status: @result[:http_status]
|
||||
content_type: LfsRequest::CONTENT_TYPE,
|
||||
status: @result[:http_status]
|
||||
end
|
||||
|
||||
def build_payload(data, process)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ class UploadsController < ApplicationController
|
|||
UnknownUploadModelError = Class.new(StandardError)
|
||||
|
||||
MODEL_CLASSES = {
|
||||
"user" => User,
|
||||
"project" => Project,
|
||||
"note" => Note,
|
||||
"group" => Group,
|
||||
"appearance" => Appearance,
|
||||
"user" => User,
|
||||
"project" => Project,
|
||||
"note" => Note,
|
||||
"group" => Group,
|
||||
"appearance" => Appearance,
|
||||
"personal_snippet" => PersonalSnippet,
|
||||
"projects/topic" => Projects::Topic,
|
||||
"projects/topic" => Projects::Topic,
|
||||
'alert_management_metric_image' => ::AlertManagement::MetricImage,
|
||||
nil => PersonalSnippet
|
||||
}.freeze
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ module Mutations
|
|||
graphql_name 'AwardEmojiToggle'
|
||||
|
||||
field :toggled_on, GraphQL::Types::Boolean, null: false,
|
||||
description: 'Indicates the status of the emoji. ' \
|
||||
'True if the toggle awarded the emoji, and false if the toggle removed the emoji.'
|
||||
description: 'Indicates the status of the emoji. ' \
|
||||
'True if the toggle awarded the emoji, and false if the toggle removed the emoji.'
|
||||
|
||||
def resolve(args)
|
||||
awardable = authorized_find!(id: args[:awardable_id])
|
||||
|
|
|
|||
|
|
@ -39,15 +39,17 @@ module Mutations
|
|||
required: false,
|
||||
description: 'Indicates the runner is not allowed to receive jobs.'
|
||||
|
||||
argument :locked, GraphQL::Types::Boolean, required: false,
|
||||
description: 'Indicates the runner is locked.'
|
||||
argument :locked, GraphQL::Types::Boolean,
|
||||
required: false,
|
||||
description: 'Indicates the runner is locked.'
|
||||
|
||||
argument :run_untagged, GraphQL::Types::Boolean,
|
||||
required: false,
|
||||
description: 'Indicates the runner is able to run untagged jobs.'
|
||||
|
||||
argument :tag_list, [GraphQL::Types::String], required: false,
|
||||
description: 'Tags associated with the runner.'
|
||||
argument :tag_list, [GraphQL::Types::String],
|
||||
required: false,
|
||||
description: 'Tags associated with the runner.'
|
||||
|
||||
field :runner,
|
||||
Types::Ci::RunnerType,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ module Mutations
|
|||
argument :next, DesignID, required: false, as: :next_design,
|
||||
description: "ID of the immediately following design."
|
||||
|
||||
field :design_collection, Types::DesignManagement::DesignCollectionType,
|
||||
null: true,
|
||||
field :design_collection, Types::DesignManagement::DesignCollectionType, null: true,
|
||||
description: "Current state of the collection."
|
||||
|
||||
def resolve(**args)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ module Mutations
|
|||
required: true,
|
||||
description: 'Full path of the project.'
|
||||
|
||||
field :success_path, GraphQL::Types::String, null: true,
|
||||
field :success_path, GraphQL::Types::String,
|
||||
null: true,
|
||||
description: 'Redirect path to use when the response is successful.'
|
||||
|
||||
field :branch, GraphQL::Types::String, null: true,
|
||||
description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
|
||||
field :branch, GraphQL::Types::String,
|
||||
null: true,
|
||||
description: 'Branch that has the new/modified `.gitlab-ci.yml` file.'
|
||||
|
||||
authorize :push_code
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@ module Ci
|
|||
build.run_status_commit_hooks!
|
||||
|
||||
Ci::BuildFinishedWorker.perform_async(id)
|
||||
|
||||
observe_report_types
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1257,6 +1259,20 @@ module Ci
|
|||
expires_in: RUNNERS_STATUS_CACHE_EXPIRATION
|
||||
) { yield }
|
||||
end
|
||||
|
||||
def observe_report_types
|
||||
return unless ::Gitlab.com? && Feature.enabled?(:report_artifact_build_completed_metrics_on_build_completion)
|
||||
|
||||
report_types = options&.dig(:artifacts, :reports)&.keys || []
|
||||
|
||||
report_types.each do |report_type|
|
||||
next unless Ci::JobArtifact::REPORT_TYPES.include?(report_type)
|
||||
|
||||
::Gitlab::Ci::Artifacts::Metrics
|
||||
.build_completed_report_type_counter(report_type)
|
||||
.increment(status: status)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,24 @@
|
|||
class ProjectMemberPresenter < MemberPresenter
|
||||
presents ::ProjectMember
|
||||
|
||||
def access_level_roles
|
||||
ProjectMember.permissible_access_level_roles(current_user, source)
|
||||
end
|
||||
|
||||
def can_remove?
|
||||
# If this user is attempting to manage an Owner member and doesn't have permission, do not allow
|
||||
return can_manage_owners? if member.owner?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def can_update?
|
||||
# If this user is attempting to manage an Owner member and doesn't have permission, do not allow
|
||||
return can_manage_owners? if member.owner?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def admin_member_permission
|
||||
|
|
@ -16,6 +34,10 @@ class ProjectMemberPresenter < MemberPresenter
|
|||
def destroy_member_permission
|
||||
:destroy_project_member
|
||||
end
|
||||
|
||||
def can_manage_owners?
|
||||
can?(current_user, :manage_owners, source)
|
||||
end
|
||||
end
|
||||
|
||||
ProjectMemberPresenter.prepend_mod_with('ProjectMemberPresenter')
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ module AlertManagement
|
|||
if alert.save
|
||||
alert.execute_integrations
|
||||
SystemNoteService.create_new_alert(alert, alert_source)
|
||||
elsif alert.errors[:fingerprint].any?
|
||||
refind_and_increment_alert
|
||||
else
|
||||
logger.warn(
|
||||
message: "Unable to create AlertManagement::Alert",
|
||||
|
|
@ -66,6 +68,8 @@ module AlertManagement
|
|||
alert_source: alert_source
|
||||
)
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
refind_and_increment_alert
|
||||
end
|
||||
|
||||
def process_incident_issues
|
||||
|
|
@ -102,6 +106,12 @@ module AlertManagement
|
|||
AlertManagement::Alert.new(**incoming_payload.alert_params, ended_at: nil)
|
||||
end
|
||||
|
||||
def refind_and_increment_alert
|
||||
clear_memoization(:alert)
|
||||
|
||||
process_firing_alert
|
||||
end
|
||||
|
||||
def resolving_alert?
|
||||
incoming_payload.ends_at.present?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -45,5 +45,5 @@
|
|||
= gl_tab_link_to _("SSH keys"), keys_admin_user_path(@user)
|
||||
= gl_tab_link_to _("Identities"), admin_user_identities_path(@user)
|
||||
- if impersonation_enabled?
|
||||
= gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user)
|
||||
= gl_tab_link_to _("Impersonation Tokens"), admin_user_impersonation_tokens_path(@user), data: { qa_selector: 'impersonation_tokens_tab' }
|
||||
.gl-mb-3
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: report_artifact_build_completed_metrics_on_build_completion
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80334
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/369500
|
||||
milestone: '15.3'
|
||||
type: development
|
||||
group: group::static analysis
|
||||
default_enabled: false
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: track_agent_users_using_ci_tunnel
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/92300
|
||||
rollout_issue_url:
|
||||
milestone: '15.3'
|
||||
type: development
|
||||
group: group::configure
|
||||
default_enabled: false
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
key_path: redis_hll_counters.kubernetes_agent.agent_users_using_ci_tunnel_monthly
|
||||
description: MAU of the Agent for Kubernetes CI/CD Tunnel
|
||||
product_section: ops
|
||||
product_stage: configure
|
||||
product_group: configure
|
||||
product_category: kubernetes_management
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: "15.3"
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61685
|
||||
time_frame: 28d
|
||||
data_source: redis_hll
|
||||
data_category: optional
|
||||
instrumentation_class: RedisHLLMetric
|
||||
options:
|
||||
events:
|
||||
- agent_users_using_ci_tunnel
|
||||
performance_indicator_type: []
|
||||
distribution:
|
||||
- ce
|
||||
- ee
|
||||
tier:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
key_path: redis_hll_counters.kubernetes_agent.agent_users_using_ci_tunnel_weekly
|
||||
description: WAU of the Agent for Kubernetes CI/CD Tunnel
|
||||
product_section: ops
|
||||
product_stage: configure
|
||||
product_group: configure
|
||||
product_category: kubernetes_management
|
||||
value_type: number
|
||||
status: active
|
||||
milestone: "15.3"
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61685
|
||||
time_frame: 7d
|
||||
data_source: redis_hll
|
||||
data_category: optional
|
||||
instrumentation_class: RedisHLLMetric
|
||||
performance_indicator_type: []
|
||||
options:
|
||||
events:
|
||||
- agent_users_using_ci_tunnel
|
||||
distribution:
|
||||
- ce
|
||||
- ee
|
||||
tier:
|
||||
- free
|
||||
- premium
|
||||
- ultimate
|
||||
|
|
@ -111,6 +111,7 @@ The following metrics are available:
|
|||
| `failed_login_captcha_total` | Gauge | 11.0 | Counter of failed CAPTCHA attempts during login | |
|
||||
| `successful_login_captcha_total` | Gauge | 11.0 | Counter of successful CAPTCHA attempts during login | |
|
||||
| `auto_devops_pipelines_completed_total` | Counter | 12.7 | Counter of completed Auto DevOps pipelines, labeled by status | |
|
||||
| `artifact_report_<report_type>_builds_completed_total` | Counter | 15.3 | Counter of completed CI Builds with report-type artifacts, grouped by report type and labeled by status | |
|
||||
| `gitlab_metrics_dashboard_processing_time_ms` | Summary | 12.10 | Metrics dashboard processing time in milliseconds | service, stages |
|
||||
| `action_cable_active_connections` | Gauge | 13.4 | Number of ActionCable WS clients currently connected | `server_mode` |
|
||||
| `action_cable_broadcasts_total` | Counter | 13.10 | The number of ActionCable broadcasts emitted | `server_mode` |
|
||||
|
|
|
|||
|
|
@ -11944,6 +11944,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodecisecurefileregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodecisecurefileregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodecisecurefileregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.groupWikiRepositoryRegistries`
|
||||
|
||||
|
|
@ -11961,6 +11962,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodegroupwikirepositoryregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodegroupwikirepositoryregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodegroupwikirepositoryregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.jobArtifactRegistries`
|
||||
|
||||
|
|
@ -11978,6 +11980,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodejobartifactregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodejobartifactregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodejobartifactregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.lfsObjectRegistries`
|
||||
|
||||
|
|
@ -11995,6 +11998,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodelfsobjectregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodelfsobjectregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodelfsobjectregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.mergeRequestDiffRegistries`
|
||||
|
||||
|
|
@ -12012,6 +12016,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodemergerequestdiffregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodemergerequestdiffregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodemergerequestdiffregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.packageFileRegistries`
|
||||
|
||||
|
|
@ -12029,6 +12034,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodepackagefileregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodepackagefileregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodepackagefileregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.pagesDeploymentRegistries`
|
||||
|
||||
|
|
@ -12046,6 +12052,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodepagesdeploymentregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodepagesdeploymentregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodepagesdeploymentregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.pipelineArtifactRegistries`
|
||||
|
||||
|
|
@ -12063,6 +12070,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodepipelineartifactregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodepipelineartifactregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodepipelineartifactregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.snippetRepositoryRegistries`
|
||||
|
||||
|
|
@ -12080,6 +12088,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodesnippetrepositoryregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodesnippetrepositoryregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodesnippetrepositoryregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.terraformStateVersionRegistries`
|
||||
|
||||
|
|
@ -12097,6 +12106,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodeterraformstateversionregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodeterraformstateversionregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodeterraformstateversionregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
##### `GeoNode.uploadRegistries`
|
||||
|
||||
|
|
@ -12114,6 +12124,7 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="geonodeuploadregistriesids"></a>`ids` | [`[ID!]`](#id) | Filters registries by their ID. |
|
||||
| <a id="geonodeuploadregistriesreplicationstate"></a>`replicationState` | [`ReplicableStateEnum`](#replicablestateenum) | Filters registries by their replication state. |
|
||||
| <a id="geonodeuploadregistriesverificationstate"></a>`verificationState` | [`VerificationStateEnum`](#verificationstateenum) | Filters registries by their verification state. |
|
||||
|
||||
### `GrafanaIntegration`
|
||||
|
||||
|
|
@ -20608,6 +20619,16 @@ Possible states of a user.
|
|||
| <a id="userstateblocked"></a>`blocked` | User has been blocked and is prevented from using the system. |
|
||||
| <a id="userstatedeactivated"></a>`deactivated` | User is no longer active and is unable to use the system. |
|
||||
|
||||
### `VerificationStateEnum`
|
||||
|
||||
| Value | Description |
|
||||
| ----- | ----------- |
|
||||
| <a id="verificationstateenumdisabled"></a>`DISABLED` | Verification process is disabled. |
|
||||
| <a id="verificationstateenumfailed"></a>`FAILED` | Verification process finished but failed. |
|
||||
| <a id="verificationstateenumpending"></a>`PENDING` | Verification process has not started. |
|
||||
| <a id="verificationstateenumstarted"></a>`STARTED` | Verification process is in progress. |
|
||||
| <a id="verificationstateenumsucceeded"></a>`SUCCEEDED` | Verification process finished successfully. |
|
||||
|
||||
### `VisibilityLevelsEnum`
|
||||
|
||||
| Value | Description |
|
||||
|
|
|
|||
|
|
@ -282,9 +282,15 @@ This saves reviewers time and helps authors catch mistakes earlier.
|
|||
|
||||
Verify that the merge request meets all [contribution acceptance criteria](contributing/merge_request_workflow.md#contribution-acceptance-criteria).
|
||||
|
||||
If a merge request is too large, fixes more than one issue, or implements more
|
||||
than one feature, you should guide the author towards splitting the merge request
|
||||
into smaller merge requests.
|
||||
You should guide the author towards splitting the merge request into smaller merge requests if it is:
|
||||
|
||||
- Too large.
|
||||
- Fixes more than one issue.
|
||||
- Implements more than one feature.
|
||||
- Has a high complexity resulting in additional risk.
|
||||
|
||||
The author may choose to request that the current maintainers and reviewers review the split MRs
|
||||
or request a new group of maintainers and reviewers.
|
||||
|
||||
When you are confident
|
||||
that it meets all requirements, you should:
|
||||
|
|
@ -308,19 +314,6 @@ Because a maintainer's job only depends on their knowledge of the overall GitLab
|
|||
codebase, and not that of any specific domain, they can review, approve, and merge
|
||||
merge requests from any team and in any product area.
|
||||
|
||||
A maintainer should ask the author to make a merge request smaller if it is:
|
||||
|
||||
- Too large.
|
||||
- Fixes more than one issue.
|
||||
- Implements more than one feature.
|
||||
- Has a high complexity resulting in additional risk.
|
||||
|
||||
The maintainer, any of the
|
||||
reviewers, or a merge request coach can step up to help the author to divide work
|
||||
into smaller iterations, and guide the author on how to split the merge request.
|
||||
The author may choose to request that the current maintainers and reviewers review the split MRs
|
||||
or request a new group of maintainers and reviewers.
|
||||
|
||||
Maintainers do their best to also review the specifics of the chosen solution
|
||||
before merging, but as they are not necessarily [domain experts](#domain-experts), they may be poorly
|
||||
placed to do so without an unreasonable investment of time. In those cases, they
|
||||
|
|
|
|||
|
|
@ -494,10 +494,15 @@ curl --request GET --header "Gitlab-Kas-Api-Request: <JWT token>" \
|
|||
Called from GitLab agent server (`kas`) to increase the usage
|
||||
metric counters.
|
||||
|
||||
| Attribute | Type | Required | Description |
|
||||
|:----------|:-------|:---------|:------------|
|
||||
| `gitops_sync_count` | integer| no | The number to increase the `gitops_sync_count` counter by |
|
||||
| `k8s_api_proxy_request_count` | integer| no | The number to increase the `k8s_api_proxy_request_count` counter by |
|
||||
| Attribute | Type | Required | Description |
|
||||
|:---------------------------------------------------------------------------|:--------------|:---------|:-----------------------------------------------------------------------------------------------------------------|
|
||||
| `gitops_sync_count` (DEPRECATED) | integer | no | The number to increase the `gitops_sync` counter by |
|
||||
| `k8s_api_proxy_request_count` (DEPRECATED) | integer | no | The number to increase the `k8s_api_proxy_request` counter by |
|
||||
| `counters` | hash | no | The number to increase the `k8s_api_proxy_request` counter by |
|
||||
| `counters["k8s_api_proxy_request"]` | integer | no | The number to increase the `k8s_api_proxy_request` counter by |
|
||||
| `counters["gitops_sync"]` | integer | no | The number to increase the `gitops_sync` counter by |
|
||||
| `unique_counters` | hash | no | The number to increase the `k8s_api_proxy_request` counter by |
|
||||
| `unique_counters["agent_users_using_ci_tunnel"]` | integer array | no | The set of unique user ids that have interacted a CI Tunnel to track the `agent_users_using_ci_tunnel` metric event |
|
||||
|
||||
```plaintext
|
||||
POST /internal/kubernetes/usage_metrics
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ module API
|
|||
# Kubernetes Internal API
|
||||
module Internal
|
||||
class Kubernetes < ::API::Base
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
feature_category :kubernetes_management
|
||||
before do
|
||||
check_feature_enabled
|
||||
|
|
@ -58,6 +60,23 @@ module API
|
|||
def agent_has_access_to_project?(project)
|
||||
Guest.can?(:download_code, project) || agent.has_access_to?(project)
|
||||
end
|
||||
|
||||
def count_events
|
||||
strong_memoize(:count_events) do
|
||||
events = params.slice(:gitops_sync_count, :k8s_api_proxy_request_count)
|
||||
events.transform_keys! { |event| event.to_s.chomp('_count') }
|
||||
events = params[:counters]&.slice(:gitops_sync, :k8s_api_proxy_request) unless events.present?
|
||||
events
|
||||
end
|
||||
end
|
||||
|
||||
def increment_unique_events
|
||||
events = params[:unique_counters]&.slice(:agent_users_using_ci_tunnel)
|
||||
|
||||
events&.each do |event, entity_ids|
|
||||
increment_unique_values(event, entity_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
namespace 'internal' do
|
||||
|
|
@ -125,14 +144,27 @@ module API
|
|||
detail 'Updates usage metrics for agent'
|
||||
end
|
||||
params do
|
||||
# Todo: Remove gitops_sync_count and k8s_api_proxy_request_count in the next milestone
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/369489
|
||||
# We're only keeping it for backwards compatibility until KAS is released
|
||||
# using `counts:` instead
|
||||
optional :gitops_sync_count, type: Integer, desc: 'The count to increment the gitops_sync metric by'
|
||||
optional :k8s_api_proxy_request_count, type: Integer, desc: 'The count to increment the k8s_api_proxy_request_count metric by'
|
||||
optional :counters, type: Hash do
|
||||
optional :gitops_sync, type: Integer, desc: 'The count to increment the gitops_sync metric by'
|
||||
optional :k8s_api_proxy_request, type: Integer, desc: 'The count to increment the k8s_api_proxy_request_count metric by'
|
||||
end
|
||||
mutually_exclusive :counters, :gitops_sync_count
|
||||
mutually_exclusive :counters, :k8s_api_proxy_request_count
|
||||
|
||||
optional :unique_counters, type: Hash do
|
||||
optional :agent_users_using_ci_tunnel, type: Set[Integer], desc: 'A set of user ids that have interacted a CI Tunnel to'
|
||||
end
|
||||
end
|
||||
post '/' do
|
||||
events = params.slice(:gitops_sync_count, :k8s_api_proxy_request_count)
|
||||
events.transform_keys! { |event| event.to_s.chomp('_count') }
|
||||
Gitlab::UsageDataCounters::KubernetesAgentCounter.increment_event_counts(count_events) if count_events
|
||||
|
||||
Gitlab::UsageDataCounters::KubernetesAgentCounter.increment_event_counts(events)
|
||||
increment_unique_events
|
||||
|
||||
no_content!
|
||||
rescue ArgumentError => e
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ module Gitlab
|
|||
class Metrics
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
def self.build_completed_report_type_counter(report_type)
|
||||
name = "artifact_report_#{report_type}_builds_completed_total".to_sym
|
||||
comment = "Number of completed builds with #{report_type} report artifacts"
|
||||
|
||||
::Gitlab::Metrics.counter(name, comment)
|
||||
end
|
||||
|
||||
def increment_destroyed_artifacts_count(size)
|
||||
destroyed_artifacts_counter.increment({}, size.to_i)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ module Gitlab
|
|||
ide_edit
|
||||
importer
|
||||
incident_management_alerts
|
||||
kubernetes_agent
|
||||
pipeline_authoring
|
||||
search
|
||||
secure
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
- name: agent_users_using_ci_tunnel
|
||||
category: kubernetes_agent
|
||||
redis_slot: agent
|
||||
aggregation: weekly
|
||||
feature_flag: track_agent_users_using_ci_tunnel
|
||||
|
|
@ -27753,6 +27753,11 @@ msgstr ""
|
|||
msgid "PackageRegistry|Debian"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Delete 1 asset"
|
||||
msgid_plural "PackageRegistry|Delete %d assets"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "PackageRegistry|Delete Package File"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -27762,6 +27767,9 @@ msgstr ""
|
|||
msgid "PackageRegistry|Delete package"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Delete selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Delete this package"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -27858,6 +27866,9 @@ msgstr ""
|
|||
msgid "PackageRegistry|Package Registry"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Package assets deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Package deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -27872,6 +27883,9 @@ msgstr[1] ""
|
|||
msgid "PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Permanently delete assets"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Pip Command"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -27929,6 +27943,9 @@ msgstr ""
|
|||
msgid "PackageRegistry|Show Yarn commands"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Something went wrong while deleting the package assets."
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|Something went wrong while deleting the package file."
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -27989,6 +28006,11 @@ msgstr ""
|
|||
msgid "PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?"
|
||||
msgstr ""
|
||||
|
||||
msgid "PackageRegistry|You are about to delete 1 asset. This operation is irreversible."
|
||||
msgid_plural "PackageRegistry|You are about to delete %d assets. This operation is irreversible."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@
|
|||
"yaml": "^2.0.0-10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@gitlab/eslint-plugin": "14.0.0",
|
||||
"@gitlab/eslint-plugin": "15.0.0",
|
||||
"@gitlab/stylelint-config": "4.1.0",
|
||||
"@graphql-eslint/eslint-plugin": "3.10.6",
|
||||
"@testing-library/dom": "^7.16.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module QA
|
||||
module Page
|
||||
module Admin
|
||||
module Overview
|
||||
module Users
|
||||
module Components
|
||||
class ImpersonationTokens < Page::Base
|
||||
include Page::Component::AccessTokens
|
||||
include Page::Component::ConfirmModal
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -8,6 +8,7 @@ module QA
|
|||
class Show < QA::Page::Base
|
||||
view 'app/views/admin/users/_head.html.haml' do
|
||||
element :impersonate_user_link
|
||||
element :impersonation_tokens_tab
|
||||
end
|
||||
|
||||
view 'app/views/admin/users/show.html.haml' do
|
||||
|
|
@ -32,6 +33,11 @@ module QA
|
|||
click_element(:user_actions_dropdown_toggle, username: user.username)
|
||||
end
|
||||
|
||||
def go_to_impersonation_tokens(&block)
|
||||
navigate_to_tab(:impersonation_tokens_tab)
|
||||
Users::Components::ImpersonationTokens.perform(&block)
|
||||
end
|
||||
|
||||
def click_impersonate_user
|
||||
click_element(:impersonate_user_link)
|
||||
end
|
||||
|
|
@ -50,6 +56,20 @@ module QA
|
|||
click_element :approve_user_button
|
||||
click_element :approve_user_confirm_button
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def navigate_to_tab(element_name)
|
||||
wait_until(reload: false) do
|
||||
click_element element_name unless on_impersontation_tokens_tab?
|
||||
|
||||
on_impersontation_tokens_tab?(wait: 10)
|
||||
end
|
||||
end
|
||||
|
||||
def on_impersontation_tokens_tab?(wait: 1)
|
||||
has_css?(".active", text: 'Impersonation Tokens', wait: wait)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module QA
|
||||
module Resource
|
||||
class ImpersonationToken < Base
|
||||
attr_writer :name
|
||||
|
||||
attribute :id
|
||||
attribute :user
|
||||
attribute :token
|
||||
attribute :expires_at
|
||||
|
||||
def api_get_path
|
||||
"/users/#{user.id}/impersonation_tokens/#{id}"
|
||||
rescue NoValueError
|
||||
token = parse_body(api_get_from("/users/#{user.id}/impersonation_tokens")).find { |t| t[:name] == name }
|
||||
|
||||
raise ResourceNotFoundError unless token
|
||||
|
||||
@id = token[:id]
|
||||
retry
|
||||
end
|
||||
|
||||
def api_post_path
|
||||
api_get_path
|
||||
end
|
||||
|
||||
def name
|
||||
@name ||= "api-impersonation-access-token-#{Faker::Alphanumeric.alphanumeric(number: 8)}"
|
||||
end
|
||||
|
||||
def api_post_body
|
||||
{
|
||||
name: name,
|
||||
scopes: ["api"],
|
||||
expires_at: expires_at.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def api_delete_path
|
||||
"/users/#{user.id}/impersonation_tokens/#{id}"
|
||||
end
|
||||
|
||||
def resource_web_url(resource)
|
||||
super
|
||||
rescue ResourceURLMissingError
|
||||
# this particular resource does not expose a web_url property
|
||||
end
|
||||
|
||||
def revoke_via_browser_ui!
|
||||
Flow::Login.sign_in_unless_signed_in(user: Runtime::User.admin)
|
||||
|
||||
Page::Main::Menu.perform(&:go_to_admin_area)
|
||||
Page::Admin::Menu.perform(&:go_to_users_overview)
|
||||
Page::Admin::Overview::Users::Index.perform do |index|
|
||||
index.search_user(user.username)
|
||||
index.click_user(user.name)
|
||||
end
|
||||
|
||||
Page::Admin::Overview::Users::Show.perform do |show|
|
||||
show.go_to_impersonation_tokens do |impersonation_tokens|
|
||||
impersonation_tokens.revoke_first_token_with_name(name)
|
||||
end
|
||||
end
|
||||
yield if block_given?
|
||||
end
|
||||
|
||||
# Expire in 2 days just in case the token is created just before midnight
|
||||
def expires_at
|
||||
@expires_at || Time.now.utc.to_date + 2
|
||||
end
|
||||
|
||||
def fabricate!
|
||||
Flow::Login.sign_in_unless_signed_in(user: Runtime::User.admin)
|
||||
|
||||
Page::Main::Menu.perform(&:go_to_admin_area)
|
||||
Page::Admin::Menu.perform(&:go_to_users_overview)
|
||||
Page::Admin::Overview::Users::Index.perform do |index|
|
||||
index.search_user(user.username)
|
||||
index.click_user(user.name)
|
||||
end
|
||||
|
||||
Page::Admin::Overview::Users::Show.perform do |show|
|
||||
show.go_to_impersonation_tokens do |impersonation_tokens|
|
||||
impersonation_tokens.fill_token_name(name)
|
||||
impersonation_tokens.check_api
|
||||
impersonation_tokens.fill_expiry_date(expires_at)
|
||||
impersonation_tokens.click_create_token_button
|
||||
self.token = impersonation_tokens.created_access_token
|
||||
end
|
||||
end
|
||||
|
||||
reload!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module QA
|
||||
RSpec.describe 'Manage' do
|
||||
describe 'Impersonation tokens', :requires_admin do
|
||||
let(:admin_api_client) { Runtime::API::Client.as_admin }
|
||||
|
||||
let!(:user) do
|
||||
Resource::User.fabricate_via_api! do |usr|
|
||||
usr.api_client = admin_api_client
|
||||
usr.hard_delete_on_api_removal = true
|
||||
end
|
||||
end
|
||||
|
||||
it(
|
||||
'can be created and revoked via the UI',
|
||||
testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/368888'
|
||||
) do
|
||||
impersonation_token = QA::Resource::ImpersonationToken.fabricate_via_browser_ui! do |impersonation_token|
|
||||
impersonation_token.user = user
|
||||
end
|
||||
|
||||
expect(impersonation_token.token).not_to be_nil
|
||||
|
||||
impersonation_token.revoke_via_browser_ui!
|
||||
|
||||
expect(page).to have_text("Revoked impersonation token #{impersonation_token.name}!")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -659,6 +659,19 @@ FactoryBot.define do
|
|||
end
|
||||
end
|
||||
|
||||
trait :multiple_report_artifacts do
|
||||
options do
|
||||
{
|
||||
artifacts: {
|
||||
reports: {
|
||||
sast: 'gl-sast-report.json',
|
||||
container_scanning: 'gl-container-scanning-report.json'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
trait :non_public_artifacts do
|
||||
options do
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,106 +12,188 @@ RSpec.describe 'Projects > Members > Manage members', :js do
|
|||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:project) { create(:project, :internal, namespace: group) }
|
||||
|
||||
let(:project_owner) { create(:user, name: "ProjectOwner", username: "project_owner") }
|
||||
let(:project_maintainer) { create(:user, name: "ProjectMaintainer", username: "project_maintainer") }
|
||||
let(:group_owner) { user1 }
|
||||
let(:project_developer) { user2 }
|
||||
|
||||
before do
|
||||
sign_in(user1)
|
||||
group.add_owner(user1)
|
||||
project.add_maintainer(project_maintainer)
|
||||
project.add_owner(project_owner)
|
||||
group.add_owner(group_owner)
|
||||
|
||||
sign_in(group_owner)
|
||||
end
|
||||
|
||||
it 'show members from project and group', :aggregate_failures do
|
||||
project.add_developer(user2)
|
||||
project.add_developer(project_developer)
|
||||
|
||||
visit_members_page
|
||||
|
||||
expect(first_row).to have_content(user1.name)
|
||||
expect(second_row).to have_content(user2.name)
|
||||
expect(first_row).to have_content(group_owner.name)
|
||||
expect(second_row).to have_content(project_developer.name)
|
||||
end
|
||||
|
||||
it 'show user once if member of both group and project', :aggregate_failures do
|
||||
project.add_developer(user1)
|
||||
group.add_reporter(project_maintainer)
|
||||
|
||||
visit_members_page
|
||||
|
||||
expect(first_row).to have_content(user1.name)
|
||||
expect(second_row).to be_blank
|
||||
expect(first_row).to have_content(group_owner.name)
|
||||
expect(second_row).to have_content(project_maintainer.name)
|
||||
expect(third_row).to have_content(project_owner.name)
|
||||
expect(all_rows[3]).to be_blank
|
||||
end
|
||||
|
||||
it 'update user access level' do
|
||||
project.add_developer(user2)
|
||||
|
||||
visit_members_page
|
||||
|
||||
page.within find_member_row(user2) do
|
||||
click_button('Developer')
|
||||
click_button('Reporter')
|
||||
|
||||
expect(page).to have_button('Reporter')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when owner' do
|
||||
it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do
|
||||
visit_members_page
|
||||
|
||||
click_on 'Invite members'
|
||||
|
||||
click_on 'Guest'
|
||||
wait_for_requests
|
||||
|
||||
page.within '.dropdown-menu' do
|
||||
expect(page).to have_button('Guest')
|
||||
expect(page).to have_button('Reporter')
|
||||
expect(page).to have_button('Developer')
|
||||
expect(page).to have_button('Maintainer')
|
||||
expect(page).to have_button('Owner')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when maintainer' do
|
||||
let(:maintainer) { create(:user) }
|
||||
|
||||
context 'update user access level' do
|
||||
before do
|
||||
project.add_maintainer(maintainer)
|
||||
sign_in(maintainer)
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do
|
||||
context 'as maintainer' do
|
||||
let(:current_user) { project_maintainer }
|
||||
|
||||
it 'can update a non-Owner member' do
|
||||
project.add_developer(project_developer)
|
||||
|
||||
visit_members_page
|
||||
|
||||
page.within find_member_row(project_developer) do
|
||||
click_button('Developer')
|
||||
|
||||
page.within '.dropdown-menu' do
|
||||
expect(page).not_to have_button('Owner')
|
||||
end
|
||||
|
||||
click_button('Reporter')
|
||||
|
||||
expect(page).to have_button('Reporter')
|
||||
end
|
||||
end
|
||||
|
||||
it 'cannot update an Owner member' do
|
||||
visit_members_page
|
||||
|
||||
page.within find_member_row(project_owner) do
|
||||
expect(page).not_to have_button('Owner')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'as owner' do
|
||||
let(:current_user) { group_owner }
|
||||
|
||||
it 'can update a project Owner member' do
|
||||
visit_members_page
|
||||
|
||||
page.within find_member_row(project_owner) do
|
||||
click_button('Owner')
|
||||
click_button('Reporter')
|
||||
|
||||
expect(page).to have_button('Reporter')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'uses ProjectMember valid_access_level_roles for the invite members modal options', :aggregate_failures do
|
||||
before do
|
||||
sign_in(current_user)
|
||||
|
||||
visit_members_page
|
||||
|
||||
click_on 'Invite members'
|
||||
|
||||
click_on 'Guest'
|
||||
wait_for_requests
|
||||
end
|
||||
|
||||
page.within '.dropdown-menu' do
|
||||
expect(page).to have_button('Guest')
|
||||
expect(page).to have_button('Reporter')
|
||||
expect(page).to have_button('Developer')
|
||||
expect(page).to have_button('Maintainer')
|
||||
expect(page).not_to have_button('Owner')
|
||||
context 'when owner' do
|
||||
let(:current_user) { project_owner }
|
||||
|
||||
it 'shows Owner in the dropdown' do
|
||||
page.within '.dropdown-menu' do
|
||||
expect(page).to have_button('Guest')
|
||||
expect(page).to have_button('Reporter')
|
||||
expect(page).to have_button('Developer')
|
||||
expect(page).to have_button('Maintainer')
|
||||
expect(page).to have_button('Owner')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when maintainer' do
|
||||
let(:current_user) { project_maintainer }
|
||||
|
||||
it 'does not show the Owner option' do
|
||||
page.within '.dropdown-menu' do
|
||||
expect(page).to have_button('Guest')
|
||||
expect(page).to have_button('Reporter')
|
||||
expect(page).to have_button('Developer')
|
||||
expect(page).to have_button('Maintainer')
|
||||
expect(page).not_to have_button('Owner')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'remove user from project' do
|
||||
other_user = create(:user)
|
||||
project.add_developer(other_user)
|
||||
describe 'remove user from project' do
|
||||
before do
|
||||
project.add_developer(project_developer)
|
||||
|
||||
visit_members_page
|
||||
sign_in(current_user)
|
||||
|
||||
# Open modal
|
||||
page.within find_member_row(other_user) do
|
||||
click_button 'Remove member'
|
||||
visit_members_page
|
||||
end
|
||||
|
||||
within_modal do
|
||||
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
|
||||
click_button('Remove member')
|
||||
context 'when maintainer' do
|
||||
let(:current_user) { project_maintainer }
|
||||
|
||||
it 'can only remove non-Owner members' do
|
||||
page.within find_member_row(project_owner) do
|
||||
expect(page).not_to have_button('Remove member')
|
||||
end
|
||||
|
||||
# Open modal
|
||||
page.within find_member_row(project_developer) do
|
||||
click_button 'Remove member'
|
||||
end
|
||||
|
||||
within_modal do
|
||||
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
|
||||
click_button('Remove member')
|
||||
end
|
||||
|
||||
wait_for_requests
|
||||
|
||||
expect(members_table).not_to have_content(project_developer.name)
|
||||
expect(members_table).to have_content(project_owner.name)
|
||||
end
|
||||
end
|
||||
|
||||
wait_for_requests
|
||||
context 'when owner' do
|
||||
let(:current_user) { group_owner }
|
||||
|
||||
expect(members_table).not_to have_content(other_user.name)
|
||||
it 'can remove any direct member' do
|
||||
page.within find_member_row(project_owner) do
|
||||
expect(page).to have_button('Remove member')
|
||||
end
|
||||
|
||||
# Open modal
|
||||
page.within find_member_row(project_owner) do
|
||||
click_button 'Remove member'
|
||||
end
|
||||
|
||||
within_modal do
|
||||
expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
|
||||
click_button('Remove member')
|
||||
end
|
||||
|
||||
wait_for_requests
|
||||
|
||||
expect(members_table).not_to have_content(project_owner.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'inviting members', 'project-members-page' do
|
||||
|
|
@ -130,7 +212,7 @@ RSpec.describe 'Projects > Members > Manage members', :js do
|
|||
external_project_bot = create(:user, :project_bot, name: '_external_project_bot_')
|
||||
external_project = create(:project, group: external_group)
|
||||
external_project.add_maintainer(external_project_bot)
|
||||
external_project.add_maintainer(user1)
|
||||
external_project.add_maintainer(group_owner)
|
||||
|
||||
visit_members_page
|
||||
|
||||
|
|
@ -143,8 +225,8 @@ RSpec.describe 'Projects > Members > Manage members', :js do
|
|||
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_content(user1.name)
|
||||
expect(page).to have_content(user2.name)
|
||||
expect(page).to have_content(group_owner.name)
|
||||
expect(page).to have_content(project_developer.name)
|
||||
expect(page).not_to have_content(internal_project_bot.name)
|
||||
expect(page).not_to have_content(external_project_bot.name)
|
||||
end
|
||||
|
|
@ -155,7 +237,7 @@ RSpec.describe 'Projects > Members > Manage members', :js do
|
|||
let_it_be(:project) { create(:project, :public) }
|
||||
|
||||
before do
|
||||
sign_out(user1)
|
||||
sign_out(group_owner)
|
||||
end
|
||||
|
||||
it 'does not show the Invite members button when not signed in' do
|
||||
|
|
@ -192,7 +274,7 @@ RSpec.describe 'Projects > Members > Manage members', :js do
|
|||
end
|
||||
|
||||
it 'shows 2FA badge to user with "Maintainer" access level' do
|
||||
project.add_maintainer(user1)
|
||||
sign_in(project_maintainer)
|
||||
|
||||
visit_members_page
|
||||
|
||||
|
|
@ -209,7 +291,7 @@ RSpec.describe 'Projects > Members > Manage members', :js do
|
|||
end
|
||||
|
||||
it 'does not show 2FA badge to users with access level below "Maintainer"' do
|
||||
group.add_developer(user1)
|
||||
group.add_developer(group_owner)
|
||||
|
||||
visit_members_page
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { GlDropdown, GlButton } from '@gitlab/ui';
|
||||
import { GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui';
|
||||
import { nextTick } from 'vue';
|
||||
import stubChildren from 'helpers/stub_children';
|
||||
import { stubComponent } from 'helpers/stub_component';
|
||||
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import { packageFiles as packageFilesMock } from 'jest/packages_and_registries/package_registry/mock_data';
|
||||
import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
|
||||
|
|
@ -11,6 +11,7 @@ describe('Package Files', () => {
|
|||
let wrapper;
|
||||
|
||||
const findAllRows = () => wrapper.findAllByTestId('file-row');
|
||||
const findDeleteSelectedButton = () => wrapper.findByTestId('delete-selected');
|
||||
const findFirstRow = () => extendedWrapper(findAllRows().at(0));
|
||||
const findSecondRow = () => extendedWrapper(findAllRows().at(1));
|
||||
const findFirstRowDownloadLink = () => findFirstRow().findByTestId('download-link');
|
||||
|
|
@ -22,19 +23,27 @@ describe('Package Files', () => {
|
|||
const findActionMenuDelete = () => findFirstActionMenu().findByTestId('delete-file');
|
||||
const findFirstToggleDetailsButton = () => findFirstRow().findComponent(GlButton);
|
||||
const findFirstRowShaComponent = (id) => wrapper.findByTestId(id);
|
||||
const findCheckAllCheckbox = () => wrapper.findByTestId('package-files-checkbox-all');
|
||||
const findAllRowCheckboxes = () => wrapper.findAllByTestId('package-files-checkbox');
|
||||
|
||||
const files = packageFilesMock();
|
||||
const [file] = files;
|
||||
|
||||
const createComponent = ({ packageFiles = [file], canDelete = true } = {}) => {
|
||||
const createComponent = ({
|
||||
packageFiles = [file],
|
||||
isLoading = false,
|
||||
canDelete = true,
|
||||
stubs,
|
||||
} = {}) => {
|
||||
wrapper = mountExtended(PackageFiles, {
|
||||
propsData: {
|
||||
canDelete,
|
||||
isLoading,
|
||||
packageFiles,
|
||||
},
|
||||
stubs: {
|
||||
...stubChildren(PackageFiles),
|
||||
GlTableLite: false,
|
||||
GlTable: false,
|
||||
...stubs,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -157,46 +166,170 @@ describe('Package Files', () => {
|
|||
expect(findSecondRowCommitLink().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('action menu', () => {
|
||||
describe('when the user can delete', () => {
|
||||
it('exists', () => {
|
||||
createComponent();
|
||||
describe('action menu', () => {
|
||||
describe('when the user can delete', () => {
|
||||
it('exists', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findFirstActionMenu().exists()).toBe(true);
|
||||
expect(findFirstActionMenu().props('icon')).toBe('ellipsis_v');
|
||||
expect(findFirstActionMenu().props('textSrOnly')).toBe(true);
|
||||
expect(findFirstActionMenu().props('text')).toMatchInterpolatedText('More actions');
|
||||
});
|
||||
expect(findFirstActionMenu().exists()).toBe(true);
|
||||
expect(findFirstActionMenu().props('icon')).toBe('ellipsis_v');
|
||||
expect(findFirstActionMenu().props('textSrOnly')).toBe(true);
|
||||
expect(findFirstActionMenu().props('text')).toMatchInterpolatedText('More actions');
|
||||
});
|
||||
|
||||
describe('menu items', () => {
|
||||
describe('delete file', () => {
|
||||
it('exists', () => {
|
||||
createComponent();
|
||||
describe('menu items', () => {
|
||||
describe('delete file', () => {
|
||||
it('exists', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findActionMenuDelete().exists()).toBe(true);
|
||||
});
|
||||
expect(findActionMenuDelete().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits a delete event when clicked', () => {
|
||||
createComponent();
|
||||
it('emits a delete event when clicked', async () => {
|
||||
createComponent();
|
||||
|
||||
findActionMenuDelete().vm.$emit('click');
|
||||
await findActionMenuDelete().trigger('click');
|
||||
|
||||
const [[{ id }]] = wrapper.emitted('delete-file');
|
||||
expect(id).toBe(file.id);
|
||||
});
|
||||
const [[items]] = wrapper.emitted('delete-files');
|
||||
const [{ id }] = items;
|
||||
expect(id).toBe(file.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user can not delete', () => {
|
||||
const canDelete = false;
|
||||
describe('when the user can not delete', () => {
|
||||
const canDelete = false;
|
||||
|
||||
it('does not exist', () => {
|
||||
createComponent({ canDelete });
|
||||
it('does not exist', () => {
|
||||
createComponent({ canDelete });
|
||||
|
||||
expect(findFirstActionMenu().exists()).toBe(false);
|
||||
expect(findFirstActionMenu().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi select', () => {
|
||||
describe('when user can delete', () => {
|
||||
it('delete selected button exists & is disabled', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findDeleteSelectedButton().exists()).toBe(true);
|
||||
expect(findDeleteSelectedButton().text()).toMatchInterpolatedText('Delete selected');
|
||||
expect(findDeleteSelectedButton().props('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('delete selected button exists & is disabled when isLoading prop is true', () => {
|
||||
createComponent({ isLoading: true });
|
||||
|
||||
expect(findDeleteSelectedButton().props('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('checkboxes to select file are visible', () => {
|
||||
createComponent({ packageFiles: files });
|
||||
|
||||
expect(findCheckAllCheckbox().exists()).toBe(true);
|
||||
expect(findAllRowCheckboxes()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('selecting a checkbox enables delete selected button', async () => {
|
||||
createComponent();
|
||||
|
||||
const first = findAllRowCheckboxes().at(0);
|
||||
|
||||
await first.setChecked(true);
|
||||
|
||||
expect(findDeleteSelectedButton().props('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
describe('select all checkbox', () => {
|
||||
it('will toggle between selecting all and deselecting all files', async () => {
|
||||
const getChecked = () => findAllRowCheckboxes().filter((x) => x.element.checked === true);
|
||||
|
||||
createComponent({ packageFiles: files });
|
||||
|
||||
expect(getChecked()).toHaveLength(0);
|
||||
|
||||
await findCheckAllCheckbox().setChecked(true);
|
||||
|
||||
expect(getChecked()).toHaveLength(files.length);
|
||||
|
||||
await findCheckAllCheckbox().setChecked(false);
|
||||
|
||||
expect(getChecked()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('will toggle the indeterminate state when some but not all files are selected', async () => {
|
||||
const expectIndeterminateState = (state) =>
|
||||
expect(findCheckAllCheckbox().props('indeterminate')).toBe(state);
|
||||
|
||||
createComponent({
|
||||
packageFiles: files,
|
||||
stubs: { GlFormCheckbox: stubComponent(GlFormCheckbox, { props: ['indeterminate'] }) },
|
||||
});
|
||||
|
||||
expectIndeterminateState(false);
|
||||
|
||||
await findSecondRow().trigger('click');
|
||||
|
||||
expectIndeterminateState(true);
|
||||
|
||||
await findSecondRow().trigger('click');
|
||||
|
||||
expectIndeterminateState(false);
|
||||
|
||||
findCheckAllCheckbox().trigger('click');
|
||||
|
||||
expectIndeterminateState(false);
|
||||
|
||||
await findSecondRow().trigger('click');
|
||||
|
||||
expectIndeterminateState(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a delete event when selected', async () => {
|
||||
createComponent();
|
||||
|
||||
const first = findAllRowCheckboxes().at(0);
|
||||
|
||||
await first.setChecked(true);
|
||||
|
||||
await findDeleteSelectedButton().trigger('click');
|
||||
|
||||
const [[items]] = wrapper.emitted('delete-files');
|
||||
const [{ id }] = items;
|
||||
expect(id).toBe(file.id);
|
||||
});
|
||||
|
||||
it('emits delete event with both items when all are selected', async () => {
|
||||
createComponent({ packageFiles: files });
|
||||
|
||||
await findCheckAllCheckbox().setChecked(true);
|
||||
|
||||
await findDeleteSelectedButton().trigger('click');
|
||||
|
||||
const [[items]] = wrapper.emitted('delete-files');
|
||||
expect(items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user cannot delete', () => {
|
||||
const canDelete = false;
|
||||
|
||||
it('delete selected button does not exist', () => {
|
||||
createComponent({ canDelete });
|
||||
|
||||
expect(findDeleteSelectedButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('checkboxes to select file are not visible', () => {
|
||||
createComponent({ packageFiles: files, canDelete });
|
||||
|
||||
expect(findCheckAllCheckbox().exists()).toBe(false);
|
||||
expect(findAllRowCheckboxes()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ export const packageDetailsQuery = (extendPackage) => ({
|
|||
id: '1',
|
||||
path: 'projectPath',
|
||||
name: 'gitlab-test',
|
||||
fullPath: 'gitlab-test',
|
||||
},
|
||||
tags: {
|
||||
nodes: packageTags(),
|
||||
|
|
@ -231,6 +232,9 @@ export const packageDetailsQuery = (extendPackage) => ({
|
|||
__typename: 'PipelineConnection',
|
||||
},
|
||||
packageFiles: {
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
},
|
||||
nodes: packageFiles(),
|
||||
__typename: 'PackageFileConnection',
|
||||
},
|
||||
|
|
@ -310,16 +314,16 @@ export const packageDestroyMutationError = () => ({
|
|||
],
|
||||
});
|
||||
|
||||
export const packageDestroyFileMutation = () => ({
|
||||
export const packageDestroyFilesMutation = () => ({
|
||||
data: {
|
||||
destroyPackageFile: {
|
||||
destroyPackageFiles: {
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
export const packageDestroyFileMutationError = () => ({
|
||||
export const packageDestroyFilesMutationError = () => ({
|
||||
data: {
|
||||
destroyPackageFile: null,
|
||||
destroyPackageFiles: null,
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
|
|
@ -331,7 +335,7 @@ export const packageDestroyFileMutationError = () => ({
|
|||
column: 3,
|
||||
},
|
||||
],
|
||||
path: ['destroyPackageFile'],
|
||||
path: ['destroyPackageFiles'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { GlEmptyState, GlBadge, GlTabs, GlTab } from '@gitlab/ui';
|
||||
import Vue from 'vue';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
|
|
@ -22,6 +22,8 @@ import {
|
|||
PACKAGE_TYPE_COMPOSER,
|
||||
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
|
||||
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
|
||||
DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
|
||||
DELETE_PACKAGE_FILES_ERROR_MESSAGE,
|
||||
PACKAGE_TYPE_NUGET,
|
||||
PACKAGE_TYPE_MAVEN,
|
||||
PACKAGE_TYPE_CONAN,
|
||||
|
|
@ -29,7 +31,7 @@ import {
|
|||
PACKAGE_TYPE_NPM,
|
||||
} from '~/packages_and_registries/package_registry/constants';
|
||||
|
||||
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
|
||||
import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
|
||||
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
|
||||
import {
|
||||
packageDetailsQuery,
|
||||
|
|
@ -38,8 +40,8 @@ import {
|
|||
dependencyLinks,
|
||||
emptyPackageDetailsQuery,
|
||||
packageFiles,
|
||||
packageDestroyFileMutation,
|
||||
packageDestroyFileMutationError,
|
||||
packageDestroyFilesMutation,
|
||||
packageDestroyFilesMutationError,
|
||||
} from '../mock_data';
|
||||
|
||||
jest.mock('~/flash');
|
||||
|
|
@ -65,14 +67,14 @@ describe('PackagesApp', () => {
|
|||
|
||||
function createComponent({
|
||||
resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
|
||||
fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()),
|
||||
filesDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFilesMutation()),
|
||||
routeId = '1',
|
||||
} = {}) {
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const requestHandlers = [
|
||||
[getPackageDetails, resolver],
|
||||
[destroyPackageFileMutation, fileDeleteMutationResolver],
|
||||
[destroyPackageFilesMutation, filesDeleteMutationResolver],
|
||||
];
|
||||
apolloProvider = createMockApollo(requestHandlers);
|
||||
|
||||
|
|
@ -110,6 +112,7 @@ describe('PackagesApp', () => {
|
|||
const findDeleteButton = () => wrapper.findByTestId('delete-package');
|
||||
const findPackageFiles = () => wrapper.findComponent(PackageFiles);
|
||||
const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal');
|
||||
const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal');
|
||||
const findVersionRows = () => wrapper.findAllComponents(VersionRow);
|
||||
const noVersionsMessage = () => wrapper.findByTestId('no-versions-message');
|
||||
const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge);
|
||||
|
|
@ -288,6 +291,7 @@ describe('PackagesApp', () => {
|
|||
|
||||
expect(findPackageFiles().props('packageFiles')[0]).toMatchObject(expectedFile);
|
||||
expect(findPackageFiles().props('canDelete')).toBe(packageData().canDestroy);
|
||||
expect(findPackageFiles().props('isLoading')).toEqual(false);
|
||||
});
|
||||
|
||||
it('does not render the package files table when the package is composer', async () => {
|
||||
|
|
@ -303,12 +307,10 @@ describe('PackagesApp', () => {
|
|||
});
|
||||
|
||||
describe('deleting a file', () => {
|
||||
let showDeleteFileSpy;
|
||||
let showDeletePackageSpy;
|
||||
const [fileToDelete] = packageFiles();
|
||||
|
||||
const doDeleteFile = () => {
|
||||
findPackageFiles().vm.$emit('delete-file', fileToDelete);
|
||||
const doDeleteFile = async () => {
|
||||
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
|
||||
|
||||
findDeleteFileModal().vm.$emit('primary');
|
||||
|
||||
|
|
@ -320,12 +322,10 @@ describe('PackagesApp', () => {
|
|||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findDeleteFileModal().exists()).toBe(true);
|
||||
const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show');
|
||||
const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
|
||||
|
||||
showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show');
|
||||
showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
|
||||
|
||||
findPackageFiles().vm.$emit('delete-file', fileToDelete);
|
||||
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
|
||||
|
||||
expect(showDeletePackageSpy).not.toBeCalled();
|
||||
expect(showDeleteFileSpy).toBeCalled();
|
||||
|
|
@ -336,6 +336,9 @@ describe('PackagesApp', () => {
|
|||
const resolver = jest.fn().mockResolvedValue(
|
||||
packageDetailsQuery({
|
||||
packageFiles: {
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
nodes: [packageFile],
|
||||
__typename: 'PackageFileConnection',
|
||||
},
|
||||
|
|
@ -348,17 +351,29 @@ describe('PackagesApp', () => {
|
|||
|
||||
await waitForPromises();
|
||||
|
||||
expect(findDeleteModal().exists()).toBe(true);
|
||||
const showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show');
|
||||
const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
|
||||
|
||||
showDeleteFileSpy = jest.spyOn(wrapper.vm.$refs.deleteFileModal, 'show');
|
||||
showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
|
||||
|
||||
findPackageFiles().vm.$emit('delete-file', fileToDelete);
|
||||
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
|
||||
|
||||
expect(showDeletePackageSpy).toBeCalled();
|
||||
expect(showDeleteFileSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('confirming on the modal sets the loading state', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findPackageFiles().vm.$emit('delete-files', [fileToDelete]);
|
||||
|
||||
findDeleteFileModal().vm.$emit('primary');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findPackageFiles().props('isLoading')).toEqual(true);
|
||||
});
|
||||
|
||||
it('confirming on the modal deletes the file and shows a success message', async () => {
|
||||
const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
|
||||
createComponent({ resolver });
|
||||
|
|
@ -378,7 +393,7 @@ describe('PackagesApp', () => {
|
|||
|
||||
describe('errors', () => {
|
||||
it('shows an error when the mutation request fails', async () => {
|
||||
createComponent({ fileDeleteMutationResolver: jest.fn().mockRejectedValue() });
|
||||
createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
|
||||
await waitForPromises();
|
||||
|
||||
await doDeleteFile();
|
||||
|
|
@ -392,9 +407,9 @@ describe('PackagesApp', () => {
|
|||
|
||||
it('shows an error when the mutation request returns an error payload', async () => {
|
||||
createComponent({
|
||||
fileDeleteMutationResolver: jest
|
||||
filesDeleteMutationResolver: jest
|
||||
.fn()
|
||||
.mockResolvedValue(packageDestroyFileMutationError()),
|
||||
.mockResolvedValue(packageDestroyFilesMutationError()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
|
|
@ -408,6 +423,117 @@ describe('PackagesApp', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting multiple files', () => {
|
||||
const doDeleteFiles = async () => {
|
||||
findPackageFiles().vm.$emit('delete-files', packageFiles());
|
||||
|
||||
findDeleteFilesModal().vm.$emit('primary');
|
||||
|
||||
return waitForPromises();
|
||||
};
|
||||
|
||||
it('opens delete files confirmation modal', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
const showDeleteFilesSpy = jest.spyOn(wrapper.vm.$refs.deleteFilesModal, 'show');
|
||||
|
||||
findPackageFiles().vm.$emit('delete-files', packageFiles());
|
||||
|
||||
expect(showDeleteFilesSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('confirming on the modal sets the loading state', async () => {
|
||||
createComponent();
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
findPackageFiles().vm.$emit('delete-files', packageFiles());
|
||||
|
||||
findDeleteFilesModal().vm.$emit('primary');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(findPackageFiles().props('isLoading')).toEqual(true);
|
||||
});
|
||||
|
||||
it('confirming on the modal deletes the file and shows a success message', async () => {
|
||||
const resolver = jest.fn().mockResolvedValue(packageDetailsQuery());
|
||||
createComponent({ resolver });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
await doDeleteFiles();
|
||||
|
||||
expect(createFlash).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
|
||||
}),
|
||||
);
|
||||
// we are re-fetching the package details, so we expect the resolver to have been called twice
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('errors', () => {
|
||||
it('shows an error when the mutation request fails', async () => {
|
||||
createComponent({ filesDeleteMutationResolver: jest.fn().mockRejectedValue() });
|
||||
await waitForPromises();
|
||||
|
||||
await doDeleteFiles();
|
||||
|
||||
expect(createFlash).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error when the mutation request returns an error payload', async () => {
|
||||
createComponent({
|
||||
filesDeleteMutationResolver: jest
|
||||
.fn()
|
||||
.mockResolvedValue(packageDestroyFilesMutationError()),
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
await doDeleteFiles();
|
||||
|
||||
expect(createFlash).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: DELETE_PACKAGE_FILES_ERROR_MESSAGE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting all files', () => {
|
||||
it('opens the delete package confirmation modal', async () => {
|
||||
const resolver = jest.fn().mockResolvedValue(
|
||||
packageDetailsQuery({
|
||||
packageFiles: {
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
},
|
||||
nodes: packageFiles(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
createComponent({
|
||||
resolver,
|
||||
});
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
const showDeletePackageSpy = jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
|
||||
|
||||
findPackageFiles().vm.$emit('delete-files', packageFiles());
|
||||
|
||||
expect(showDeletePackageSpy).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('versions', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,25 @@ require 'spec_helper'
|
|||
RSpec.describe Gitlab::Ci::Artifacts::Metrics, :prometheus do
|
||||
let(:metrics) { described_class.new }
|
||||
|
||||
describe '.build_completed_report_type_counter' do
|
||||
context 'when incrementing by more than one' do
|
||||
let(:sast_counter) { described_class.send(:build_completed_report_type_counter, :sast) }
|
||||
let(:dast_counter) { described_class.send(:build_completed_report_type_counter, :dast) }
|
||||
|
||||
it 'increments a single counter' do
|
||||
[dast_counter, sast_counter].each do |counter|
|
||||
counter.increment(status: 'success')
|
||||
counter.increment(status: 'success')
|
||||
counter.increment(status: 'failed')
|
||||
|
||||
expect(counter.get(status: 'success')).to eq 2.0
|
||||
expect(counter.get(status: 'failed')).to eq 1.0
|
||||
expect(counter.values.count).to eq 2
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#increment_destroyed_artifacts' do
|
||||
context 'when incrementing by more than one' do
|
||||
let(:counter) { metrics.send(:destroyed_artifacts_counter) }
|
||||
|
|
|
|||
|
|
@ -276,32 +276,12 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
|
|||
end
|
||||
|
||||
describe '#disconnect_alternates' do
|
||||
let(:project) { create(:project, :repository) }
|
||||
let(:repository) { project.repository }
|
||||
let(:repository_path) { File.join(TestEnv.repos_path, repository.relative_path) }
|
||||
let(:pool_repository) { create(:pool_repository) }
|
||||
let(:object_pool) { pool_repository.object_pool }
|
||||
let(:object_pool_service) { Gitlab::GitalyClient::ObjectPoolService.new(object_pool) }
|
||||
it 'sends a disconnect_git_alternates message' do
|
||||
expect_any_instance_of(Gitaly::ObjectPoolService::Stub)
|
||||
.to receive(:disconnect_git_alternates)
|
||||
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
|
||||
|
||||
before do
|
||||
object_pool_service.create(repository) # rubocop:disable Rails/SaveBang
|
||||
object_pool_service.link_repository(repository)
|
||||
end
|
||||
|
||||
it 'deletes the alternates file' do
|
||||
repository.disconnect_alternates
|
||||
|
||||
alternates_file = File.join(repository_path, "objects", "info", "alternates")
|
||||
|
||||
expect(File.exist?(alternates_file)).to be_falsey
|
||||
end
|
||||
|
||||
context 'when called twice' do
|
||||
it "doesn't raise an error" do
|
||||
repository.disconnect_alternates
|
||||
|
||||
expect { repository.disconnect_alternates }.not_to raise_error
|
||||
end
|
||||
client.disconnect_alternates
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
|
|||
'work_items',
|
||||
'ci_users',
|
||||
'error_tracking',
|
||||
'manage'
|
||||
'manage',
|
||||
'kubernetes_agent'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1335,6 +1335,43 @@ RSpec.describe Ci::Build do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'state transition metrics' do
|
||||
using RSpec::Parameterized::TableSyntax
|
||||
|
||||
subject { build.send(event) }
|
||||
|
||||
where(:ff_enabled, :state, :report_count, :trait) do
|
||||
true | :success! | 1 | :sast
|
||||
true | :cancel! | 1 | :sast
|
||||
true | :drop! | 2 | :multiple_report_artifacts
|
||||
true | :success! | 0 | :allowed_to_fail
|
||||
true | :skip! | 0 | :pending
|
||||
false | :success! | 0 | :sast
|
||||
end
|
||||
|
||||
with_them do
|
||||
let(:build) { create(:ci_build, trait, project: project, pipeline: pipeline) }
|
||||
let(:event) { state }
|
||||
|
||||
context "when transitioning to #{params[:state]}" do
|
||||
before do
|
||||
allow(Gitlab).to receive(:com?).and_return(true)
|
||||
stub_feature_flags(report_artifact_build_completed_metrics_on_build_completion: ff_enabled)
|
||||
end
|
||||
|
||||
it 'increments build_completed_report_type metric' do
|
||||
expect(
|
||||
::Gitlab::Ci::Artifacts::Metrics
|
||||
).to receive(
|
||||
:build_completed_report_type_counter
|
||||
).exactly(report_count).times.and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'state transition as a deployable' do
|
||||
subject { build.send(event) }
|
||||
|
||||
|
|
|
|||
|
|
@ -55,39 +55,95 @@ RSpec.describe ProjectMemberPresenter do
|
|||
end
|
||||
|
||||
describe '#can_update?' do
|
||||
context 'when user can update_project_member' do
|
||||
context 'when user is NOT attempting to update an Owner' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(true)
|
||||
allow(project_member).to receive(:owner?).and_return(false)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_update?).to eq(true) }
|
||||
context 'when user can update_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(true)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_update?).to eq(true) }
|
||||
end
|
||||
|
||||
context 'when user cannot update_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(false)
|
||||
allow(presenter).to receive(:can?).with(user, :override_project_member, presenter).and_return(false)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_update?).to eq(false) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user cannot update_project_member' do
|
||||
context 'when user is attempting to update an Owner' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(false)
|
||||
allow(presenter).to receive(:can?).with(user, :override_project_member, presenter).and_return(false)
|
||||
allow(project_member).to receive(:owner?).and_return(true)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_update?).to eq(false) }
|
||||
context 'when user can manage owners' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :manage_owners, project).and_return(true)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_update?).to eq(true) }
|
||||
end
|
||||
|
||||
context 'when user cannot manage owners' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :manage_owners, project).and_return(false)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_update?).to eq(false) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#can_remove?' do
|
||||
context 'when user can destroy_project_member' do
|
||||
context 'when user is NOT attempting to remove an Owner' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :destroy_project_member, presenter).and_return(true)
|
||||
allow(project_member).to receive(:owner?).and_return(false)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_remove?).to eq(true) }
|
||||
context 'when user can destroy_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :destroy_project_member, presenter).and_return(true)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_remove?).to eq(true) }
|
||||
end
|
||||
|
||||
context 'when user cannot destroy_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :destroy_project_member, presenter).and_return(false)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_remove?).to eq(false) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user cannot destroy_project_member' do
|
||||
context 'when user is attempting to remove an Owner' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :destroy_project_member, presenter).and_return(false)
|
||||
allow(project_member).to receive(:owner?).and_return(true)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_remove?).to eq(false) }
|
||||
context 'when user can manage owners' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :manage_owners, project).and_return(true)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_remove?).to eq(true) }
|
||||
end
|
||||
|
||||
context 'when user cannot manage owners' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :manage_owners, project).and_return(false)
|
||||
end
|
||||
|
||||
specify { expect(presenter.can_remove?).to eq(false) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -99,7 +155,7 @@ RSpec.describe ProjectMemberPresenter do
|
|||
|
||||
context 'and user can update_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(true)
|
||||
allow(presenter).to receive(:can_update?).and_return(true)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_approve?).to eq(true) }
|
||||
|
|
@ -107,8 +163,7 @@ RSpec.describe ProjectMemberPresenter do
|
|||
|
||||
context 'and user cannot update_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(false)
|
||||
allow(presenter).to receive(:can?).with(user, :override_project_member, presenter).and_return(false)
|
||||
allow(presenter).to receive(:can_update?).and_return(false)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_approve?).to eq(false) }
|
||||
|
|
@ -122,7 +177,7 @@ RSpec.describe ProjectMemberPresenter do
|
|||
|
||||
context 'and user can update_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(true)
|
||||
allow(presenter).to receive(:can_update?).and_return(true)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_approve?).to eq(false) }
|
||||
|
|
@ -130,7 +185,7 @@ RSpec.describe ProjectMemberPresenter do
|
|||
|
||||
context 'and user cannot update_project_member' do
|
||||
before do
|
||||
allow(presenter).to receive(:can?).with(user, :update_project_member, presenter).and_return(false)
|
||||
allow(presenter).to receive(:can_update?).and_return(false)
|
||||
end
|
||||
|
||||
it { expect(presenter.can_approve?).to eq(false) }
|
||||
|
|
@ -138,9 +193,32 @@ RSpec.describe ProjectMemberPresenter do
|
|||
end
|
||||
end
|
||||
|
||||
it_behaves_like '#valid_level_roles', :project do
|
||||
describe 'valid level roles' do
|
||||
before do
|
||||
entity.group = group
|
||||
allow(Ability).to receive(:allowed?).and_call_original
|
||||
allow(Ability).to receive(:allowed?).with(member_user, :manage_owners, entity).and_return(can_manage_owners)
|
||||
end
|
||||
|
||||
context 'when user cannot manage owners' do
|
||||
it_behaves_like '#valid_level_roles', :project do
|
||||
let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Reporter' => 20 } }
|
||||
let(:can_manage_owners) { false }
|
||||
|
||||
before do
|
||||
entity.group = group
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user can manage owners' do
|
||||
it_behaves_like '#valid_level_roles', :project do
|
||||
let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Owner' => 50, 'Reporter' => 20 } }
|
||||
let(:can_manage_owners) { true }
|
||||
|
||||
before do
|
||||
entity.group = group
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ RSpec.describe API::Internal::Kubernetes do
|
|||
end
|
||||
end
|
||||
|
||||
describe 'POST /internal/kubernetes/usage_metrics' do
|
||||
describe 'POST /internal/kubernetes/usage_metrics', :clean_gitlab_redis_shared_state do
|
||||
def send_request(headers: {}, params: {})
|
||||
post api('/internal/kubernetes/usage_metrics'), params: params, headers: headers.reverse_merge(jwt_auth_headers)
|
||||
end
|
||||
|
|
@ -69,29 +69,102 @@ RSpec.describe API::Internal::Kubernetes do
|
|||
context 'is authenticated for an agent' do
|
||||
let!(:agent_token) { create(:cluster_agent_token) }
|
||||
|
||||
# Todo: Remove gitops_sync_count and k8s_api_proxy_request_count in the next milestone
|
||||
# https://gitlab.com/gitlab-org/gitlab/-/issues/369489
|
||||
# We're only keeping it for backwards compatibility until KAS is released
|
||||
# using `counts:` instead
|
||||
context 'deprecated events' do
|
||||
it 'returns no_content for valid events' do
|
||||
send_request(params: { gitops_sync_count: 10, k8s_api_proxy_request_count: 5 })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns no_content for counts of zero' do
|
||||
send_request(params: { gitops_sync_count: 0, k8s_api_proxy_request_count: 0 })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns 400 for non number' do
|
||||
send_request(params: { gitops_sync_count: 'string', k8s_api_proxy_request_count: 1 })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'returns 400 for negative number' do
|
||||
send_request(params: { gitops_sync_count: -1, k8s_api_proxy_request_count: 1 })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'tracks events' do
|
||||
counters = { gitops_sync_count: 10, k8s_api_proxy_request_count: 5 }
|
||||
expected_counters = {
|
||||
kubernetes_agent_gitops_sync: counters[:gitops_sync_count],
|
||||
kubernetes_agent_k8s_api_proxy_request: counters[:k8s_api_proxy_request_count]
|
||||
}
|
||||
|
||||
send_request(params: counters)
|
||||
|
||||
expect(Gitlab::UsageDataCounters::KubernetesAgentCounter.totals).to eq(expected_counters)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns no_content for valid events' do
|
||||
send_request(params: { gitops_sync_count: 10, k8s_api_proxy_request_count: 5 })
|
||||
counters = { gitops_sync: 10, k8s_api_proxy_request: 5 }
|
||||
unique_counters = { agent_users_using_ci_tunnel: [10] }
|
||||
|
||||
send_request(params: { counters: counters, unique_counters: unique_counters })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns no_content for counts of zero' do
|
||||
send_request(params: { gitops_sync_count: 0, k8s_api_proxy_request_count: 0 })
|
||||
counters = { gitops_sync: 0, k8s_api_proxy_request: 0 }
|
||||
unique_counters = { agent_users_using_ci_tunnel: [] }
|
||||
|
||||
send_request(params: { counters: counters, unique_counters: unique_counters })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'returns 400 for non number' do
|
||||
send_request(params: { gitops_sync_count: 'string', k8s_api_proxy_request_count: 1 })
|
||||
it 'returns 400 for non counter number' do
|
||||
counters = { gitops_sync: 'string', k8s_api_proxy_request: 0 }
|
||||
|
||||
send_request(params: { counters: counters })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'returns 400 for negative number' do
|
||||
send_request(params: { gitops_sync_count: -1, k8s_api_proxy_request_count: 1 })
|
||||
it 'returns 400 for non unique_counter set' do
|
||||
unique_counters = { agent_users_using_ci_tunnel: 1 }
|
||||
|
||||
send_request(params: { unique_counters: unique_counters })
|
||||
|
||||
expect(response).to have_gitlab_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'tracks events' do
|
||||
counters = { gitops_sync: 10, k8s_api_proxy_request: 5 }
|
||||
unique_counters = { agent_users_using_ci_tunnel: [10] }
|
||||
expected_counters = {
|
||||
kubernetes_agent_gitops_sync: counters[:gitops_sync],
|
||||
kubernetes_agent_k8s_api_proxy_request: counters[:k8s_api_proxy_request]
|
||||
}
|
||||
|
||||
send_request(params: { counters: counters, unique_counters: unique_counters })
|
||||
|
||||
expect(Gitlab::UsageDataCounters::KubernetesAgentCounter.totals).to eq(expected_counters)
|
||||
|
||||
expect(
|
||||
Gitlab::UsageDataCounters::HLLRedisCounter
|
||||
.unique_events(
|
||||
event_names: 'agent_users_using_ci_tunnel',
|
||||
start_date: Date.current, end_date: Date.current + 10
|
||||
)
|
||||
).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
|
|||
end
|
||||
|
||||
it_behaves_like 'processes new firing alert'
|
||||
include_examples 'handles race condition in alert creation'
|
||||
|
||||
context 'with resolving payload' do
|
||||
let(:prometheus_status) { 'resolved' }
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ RSpec.describe Projects::Alerting::NotifyService do
|
|||
|
||||
it_behaves_like 'processes new firing alert'
|
||||
it_behaves_like 'properly assigns the alert properties'
|
||||
include_examples 'handles race condition in alert creation'
|
||||
|
||||
it 'passes the integration to alert processing' do
|
||||
expect(Gitlab::AlertManagement::Payload)
|
||||
|
|
|
|||
|
|
@ -63,16 +63,23 @@ RSpec.shared_examples '#valid_level_roles' do |entity_name|
|
|||
let(:entity) { create(entity_name) } # rubocop:disable Rails/SaveBang
|
||||
let(:entity_member) { create("#{entity_name}_member", :developer, source: entity, user: member_user) }
|
||||
let(:presenter) { described_class.new(entity_member, current_user: member_user) }
|
||||
let(:expected_roles) { { 'Developer' => 30, 'Maintainer' => 40, 'Reporter' => 20 } }
|
||||
|
||||
it 'returns all roles when no parent member is present' do
|
||||
expect(presenter.valid_level_roles).to eq(entity_member.class.access_level_roles)
|
||||
context 'when no parent member is present' do
|
||||
let(:all_permissible_roles) { entity_member.class.permissible_access_level_roles(member_user, entity) }
|
||||
|
||||
it 'returns all permissible roles' do
|
||||
expect(presenter.valid_level_roles).to eq(all_permissible_roles)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns higher roles when a parent member is present' do
|
||||
group.add_reporter(member_user)
|
||||
context 'when parent member is present' do
|
||||
before do
|
||||
group.add_reporter(member_user)
|
||||
end
|
||||
|
||||
expect(presenter.valid_level_roles).to eq(expected_roles)
|
||||
it 'returns higher roles when a parent member is present' do
|
||||
expect(presenter.valid_level_roles).to eq(expected_roles)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ RSpec.shared_examples 'creates an alert management alert or errors' do
|
|||
end
|
||||
|
||||
context 'and fails to save' do
|
||||
let(:errors) { double(messages: { hosts: ['hosts array is over 255 chars'] })}
|
||||
let(:errors) { double(messages: { hosts: ['hosts array is over 255 chars'] }, '[]': [] )}
|
||||
|
||||
before do
|
||||
allow(service).to receive(:alert).and_call_original
|
||||
|
|
@ -46,6 +46,46 @@ RSpec.shared_examples 'creates an alert management alert or errors' do
|
|||
end
|
||||
end
|
||||
|
||||
RSpec.shared_examples 'handles race condition in alert creation' do
|
||||
let(:other_alert) { create(:alert_management_alert, project: project) }
|
||||
|
||||
context 'when another alert is saved at the same time' do
|
||||
before do
|
||||
allow_next_instance_of(::AlertManagement::Alert) do |alert|
|
||||
allow(alert).to receive(:save) do
|
||||
other_alert.update!(fingerprint: alert.fingerprint)
|
||||
|
||||
raise ActiveRecord::RecordNotUnique
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'finds the other alert and increments the counter' do
|
||||
subject
|
||||
|
||||
expect(other_alert.reload.events).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when another alert is saved before the validation runes' do
|
||||
before do
|
||||
allow_next_instance_of(::AlertManagement::Alert) do |alert|
|
||||
allow(alert).to receive(:save).and_wrap_original do |method, *args|
|
||||
other_alert.update!(fingerprint: alert.fingerprint)
|
||||
|
||||
method.call(*args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'finds the other alert and increments the counter' do
|
||||
subject
|
||||
|
||||
expect(other_alert.reload.events).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# This shared_example requires the following variables:
|
||||
# - last_alert_attributes, last created alert
|
||||
# - project, project that alert created
|
||||
|
|
|
|||
|
|
@ -1019,10 +1019,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@gitlab/at.js/-/at.js-1.5.7.tgz#1ee6f838cc4410a1d797770934df91d90df8179e"
|
||||
integrity sha512-c6ySRK/Ma7lxwpIVbSAF3P+xiTLrNTGTLRx4/pHK111AdFxwgUwrYF6aVZFXvmG65jHOJHoa0eQQ21RW6rm0Rg==
|
||||
|
||||
"@gitlab/eslint-plugin@14.0.0":
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-14.0.0.tgz#dc841d83521afdaf86afc943f94ad11d19c37b7c"
|
||||
integrity sha512-idTZojh+0lvKqdPcNlY4w3c9+qCTS0WYBrFkagWRifUYBqXGHbWw8CRfxCMYZSA3GnFRuxXhodpilRFq2YzURw==
|
||||
"@gitlab/eslint-plugin@15.0.0":
|
||||
version "15.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-15.0.0.tgz#fbf5da0b6e6812a681552c8043f75af95de45fc4"
|
||||
integrity sha512-bVKP132SYbtOhvtFRnRSy2W3x9zWbp12dkvaHJCm+3UOe1nFXGXjRfKAdw5w4bjX2Rb3oaM8/TiiUmg6J+2gZQ==
|
||||
dependencies:
|
||||
"@babel/core" "^7.17.0"
|
||||
"@babel/eslint-parser" "^7.17.0"
|
||||
|
|
|
|||
Loading…
Reference in New Issue