Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-06-10 12:08:58 +00:00
parent 23ff717a29
commit 0211553b0c
107 changed files with 2235 additions and 819 deletions

View File

@ -94,9 +94,9 @@ dependency_scanning:
stage: test
needs: []
variables:
DS_MAJOR_VERSION: 2
DS_EXCLUDED_PATHS: "qa/qa/ee/fixtures/secure_premade_reports,spec,ee/spec" # GitLab-specific
script:
- export DS_VERSION=${SP_VERSION:-$(echo "$CI_SERVER_VERSION" | sed 's/^\([0-9]*\)\.\([0-9]*\).*/\1-\2-stable/')}
- |
if ! docker info &>/dev/null; then
if [ -z "$DOCKER_HOST" -a "$KUBERNETES_PORT" ]; then
@ -138,7 +138,7 @@ dependency_scanning:
) \
--volume "$PWD:/code" \
--volume /var/run/docker.sock:/var/run/docker.sock \
"registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$DS_VERSION" /code
"registry.gitlab.com/gitlab-org/security-products/dependency-scanning:$DS_MAJOR_VERSION" /code
artifacts:
paths:
- gl-dependency-scanning-report.json # GitLab-specific

View File

@ -182,6 +182,9 @@ Rails/ApplicationRecord:
- ee/db/**/*.rb
- ee/spec/**/*.rb
Cop/DefaultScope:
Enabled: true
Rails/FindBy:
Enabled: true
Include:

View File

@ -40,13 +40,11 @@ class ListIssue {
}
removeLabel(removeLabel) {
if (removeLabel) {
this.labels = this.labels.filter(label => removeLabel.id !== label.id);
}
boardsStore.removeIssueLabel(this, removeLabel);
}
removeLabels(labels) {
labels.forEach(this.removeLabel.bind(this));
boardsStore.removeIssueLabels(this, labels);
}
addAssignee(assignee) {
@ -54,7 +52,7 @@ class ListIssue {
}
findAssignee(findAssignee) {
return this.assignees.find(assignee => assignee.id === findAssignee.id);
return boardsStore.findIssueAssignee(this, findAssignee);
}
removeAssignee(removeAssignee) {
@ -66,10 +64,7 @@ class ListIssue {
}
addMilestone(milestone) {
const miletoneId = this.milestone ? this.milestone.id : null;
if (IS_EE && milestone.id !== miletoneId) {
this.milestone = new ListMilestone(milestone);
}
boardsStore.addIssueMilestone(this, milestone);
}
removeMilestone(removeMilestone) {

View File

@ -158,18 +158,7 @@ class List {
}
removeMultipleIssues(removeIssues) {
const ids = removeIssues.map(issue => issue.id);
this.issues = this.issues.filter(issue => {
const matchesRemove = ids.includes(issue.id);
if (matchesRemove) {
this.issuesSize -= 1;
issue.removeLabel(this.label);
}
return !matchesRemove;
});
return boardsStore.removeListMultipleIssues(this, removeIssues);
}
removeIssue(removeIssue) {

View File

@ -277,6 +277,20 @@ const boardsStore = {
return !matchesRemove;
});
},
removeListMultipleIssues(list, removeIssues) {
const ids = removeIssues.map(issue => issue.id);
list.issues = list.issues.filter(issue => {
const matchesRemove = ids.includes(issue.id);
if (matchesRemove) {
list.issuesSize -= 1;
issue.removeLabel(list.label);
}
return !matchesRemove;
});
},
startMoving(list, issue) {
Object.assign(this.moving, { list, issue });
@ -684,6 +698,11 @@ const boardsStore = {
),
);
},
removeIssueLabel(issue, removeLabel) {
if (removeLabel) {
issue.labels = issue.labels.filter(label => removeLabel.id !== label.id);
}
},
addIssueAssignee(issue, assignee) {
if (!issue.findAssignee(assignee)) {
@ -691,6 +710,10 @@ const boardsStore = {
}
},
removeIssueLabels(issue, labels) {
labels.forEach(issue.removeLabel.bind(issue));
},
bulkUpdate(issueIds, extraData = {}) {
const data = {
update: Object.assign(extraData, {
@ -763,6 +786,10 @@ const boardsStore = {
}
},
findIssueAssignee(issue, findAssignee) {
return issue.assignees.find(assignee => assignee.id === findAssignee.id);
},
clearMultiSelect() {
this.multiSelect.list = [];
},
@ -771,6 +798,13 @@ const boardsStore = {
issue.assignees = [];
},
addIssueMilestone(issue, milestone) {
const miletoneId = issue.milestone ? issue.milestone.id : null;
if (IS_EE && milestone.id !== miletoneId) {
issue.milestone = new ListMilestone(milestone);
}
},
refreshIssueData(issue, obj) {
issue.id = obj.id;
issue.iid = obj.iid;

View File

@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue';
import { getFileEOL } from '../utils';
export default {
components: {
@ -8,6 +9,9 @@ export default {
},
computed: {
...mapGetters(['activeFile']),
activeFileEOL() {
return getFileEOL(this.activeFile.content);
},
},
};
</script>
@ -16,7 +20,7 @@ export default {
<div class="ide-status-list d-flex">
<template v-if="activeFile">
<div class="ide-status-file">{{ activeFile.name }}</div>
<div class="ide-status-file">{{ activeFile.eol }}</div>
<div class="ide-status-file">{{ activeFileEOL }}</div>
<div v-if="!activeFile.binary" class="ide-status-file">
{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}
</div>

View File

@ -35,7 +35,6 @@ export default {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content,
base64: !isText,
binary: !isText,
rawPath: !isText ? target.result : '',
});

View File

@ -83,10 +83,6 @@ export default {
active: this.isPreviewViewMode,
};
},
fileType() {
const info = viewerInformationForPath(this.file.path);
return (info && info.id) || '';
},
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
@ -99,6 +95,12 @@ export default {
currentBranchCommit() {
return this.currentBranch?.commit.id;
},
previewMode() {
return viewerInformationForPath(this.file.path);
},
fileType() {
return this.previewMode?.id || '';
},
},
watch: {
file(newVal, oldVal) {
@ -181,7 +183,6 @@ export default {
'setFileLanguage',
'setEditorPosition',
'setFileViewMode',
'setFileEOL',
'updateViewer',
'removePendingTab',
'triggerFilesChange',
@ -260,7 +261,6 @@ export default {
const monacoModel = model.getModel();
const content = monacoModel.getValue();
this.changeFileContent({ path: file.path, content });
this.setFileEOL({ eol: this.model.eol });
});
// Handle Cursor Position
@ -280,11 +280,6 @@ export default {
this.setFileLanguage({
fileLanguage: this.model.language,
});
// Get File eol
this.setFileEOL({
eol: this.model.eol,
});
},
refreshEditorDimensions() {
if (this.showEditor) {
@ -331,16 +326,15 @@ export default {
role="button"
@click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
<template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template>
<template v-else>{{ __('Review') }}</template>
{{ __('Edit') }}
</a>
</li>
<li v-if="file.previewMode" :class="previewTabCSS">
<li v-if="previewMode" :class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ file.previewMode.previewTitle }}</a
>{{ previewMode.previewTitle }}</a
>
</li>
</ul>

View File

@ -53,10 +53,6 @@ export default class Model {
return this.model.getModeId();
}
get eol() {
return this.model.getEOL() === '\n' ? 'LF' : 'CRLF';
}
get path() {
return this.file.key;
}

View File

@ -19,7 +19,6 @@ export const decorateFiles = ({
branchId,
tempFile = false,
content = '',
base64 = false,
binary = false,
rawPath = '',
}) => {
@ -88,10 +87,8 @@ export const decorateFiles = ({
tempFile,
changed: tempFile,
content,
base64,
binary: (previewMode && previewMode.binary) || binary,
rawPath,
previewMode,
parentPath,
});

View File

@ -29,7 +29,6 @@ export const createTempEntry = (
name,
type,
content = '',
base64 = false,
binary = false,
rawPath = '',
openFile = true,
@ -60,7 +59,6 @@ export const createTempEntry = (
type,
tempFile: true,
content,
base64,
binary,
rawPath,
});
@ -92,7 +90,6 @@ export const addTempImage = ({ dispatch, getters }, { name, rawPath = '' }) =>
name: getters.getAvailableFileName(name),
type: 'blob',
content: rawPath.split('base64,')[1],
base64: true,
binary: true,
rawPath,
openFile: false,

View File

@ -169,12 +169,6 @@ export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => {
}
};
export const setFileEOL = ({ getters, commit }, { eol }) => {
if (getters.activeFile) {
commit(types.SET_FILE_EOL, { file: getters.activeFile, eol });
}
};
export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => {
if (getters.activeFile) {
commit(types.SET_FILE_POSITION, {

View File

@ -40,7 +40,6 @@ export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION';
export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const SET_FILE_EOL = 'SET_FILE_EOL';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';

View File

@ -99,11 +99,6 @@ export default {
fileLanguage,
});
},
[types.SET_FILE_EOL](state, { file, eol }) {
Object.assign(state.entries[file.path], {
eol,
});
},
[types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) {
Object.assign(state.entries[file.path], {
editorRow,

View File

@ -1,5 +1,10 @@
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility';
import {
relativePathToAbsolute,
isAbsolute,
isRootRelative,
isBase64DataUrl,
} from '~/lib/utils/url_utility';
export const dataStructure = () => ({
id: '',
@ -31,13 +36,10 @@ export const dataStructure = () => ({
binary: false,
raw: '',
content: '',
base64: false,
editorRow: 1,
editorColumn: 1,
fileLanguage: '',
eol: '',
viewMode: FILE_VIEW_MODE_EDITOR,
previewMode: null,
size: 0,
parentPath: null,
lastOpenedAt: 0,
@ -60,10 +62,8 @@ export const decorateData = entity => {
active = false,
opened = false,
changed = false,
base64 = false,
binary = false,
rawPath = '',
previewMode,
file_lock,
parentPath = '',
} = entity;
@ -82,10 +82,8 @@ export const decorateData = entity => {
active,
changed,
content,
base64,
binary,
rawPath,
previewMode,
file_lock,
parentPath,
});
@ -136,7 +134,7 @@ export const createCommitPayload = ({
file_path: f.path,
previous_path: f.prevPath || undefined,
content: f.prevPath && !f.changed ? null : f.content || undefined,
encoding: f.base64 ? 'base64' : 'text',
encoding: isBase64DataUrl(f.rawPath) ? 'base64' : 'text',
last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
})),
start_sha: newBranch ? rootGetters.lastCommit.id : undefined,

View File

@ -119,3 +119,7 @@ export function readFileAsDataURL(file) {
reader.readAsDataURL(file);
});
}
export function getFileEOL(content = '') {
return content.includes('\r\n') ? 'CRLF' : 'LF';
}

View File

@ -90,6 +90,13 @@ export const truncatePathMiddleToLength = (text, maxWidth) => {
while (returnText.length >= maxWidth) {
const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR);
if (textSplit.length === 0) {
// There are n - 1 path separators for n segments, so 2n - 1 <= maxWidth
const maxSegments = Math.floor((maxWidth + 1) / 2);
return new Array(maxSegments).fill(ELLIPSIS_CHAR).join('/');
}
const middleIndex = Math.floor(textSplit.length / 2);
returnText = textSplit

View File

@ -243,6 +243,15 @@ export function isRootRelative(url) {
return /^\//.test(url);
}
/**
* Returns true if url is a base64 data URL
*
* @param {String} url
*/
export function isBase64DataUrl(url) {
return /^data:[.\w+-]+\/[.\w+-]+;base64,/.test(url);
}
/**
* Returns true if url is an absolute or root-relative URL
*

View File

@ -0,0 +1,33 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
import {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
} from '../../constants/index';
export default {
components: {
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: false,
default: '',
},
},
i18n: {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:svg-path="noContainersImage"
:description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
class="gl-mx-auto gl-my-0"
/>
</template>

View File

@ -0,0 +1,34 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
loader: {
repeat: 10,
width: 1000,
height: 40,
},
};
</script>
<template>
<div>
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
:width="$options.loader.width"
:height="$options.loader.height"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="15" x="0" y="12.5" height="15" rx="4" />
<rect width="250" x="25" y="10" height="20" rx="4" />
<circle cx="290" cy="20" r="10" />
<rect width="100" x="315" y="10" height="20" rx="4" />
<rect width="100" x="500" y="10" height="20" rx="4" />
<rect width="100" x="630" y="10" height="20" rx="4" />
<rect x="960" y="0" width="40" height="40" rx="4" />
</gl-skeleton-loader>
</div>
</template>

View File

@ -0,0 +1,210 @@
<script>
import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import {
LIST_KEY_TAG,
LIST_KEY_IMAGE_ID,
LIST_KEY_SIZE,
LIST_KEY_LAST_UPDATED,
LIST_KEY_ACTIONS,
LIST_KEY_CHECKBOX,
LIST_LABEL_TAG,
LIST_LABEL_IMAGE_ID,
LIST_LABEL_SIZE,
LIST_LABEL_LAST_UPDATED,
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
} from '../../constants/index';
export default {
components: {
GlTable,
GlFormCheckbox,
GlButton,
ClipboardButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
tags: {
type: Array,
required: false,
default: () => [],
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
isDesktop: {
type: Boolean,
required: false,
default: false,
},
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
},
data() {
return {
selectedItems: [],
};
},
computed: {
fields() {
const tagClass = this.isDesktop ? 'w-25' : '';
const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
return [
{ key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
{
key: LIST_KEY_TAG,
label: LIST_LABEL_TAG,
class: `${tagClass} js-tag-column`,
innerClass: tagInnerClass,
},
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
{ key: LIST_KEY_ACTIONS, label: '' },
].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
},
tagsNames() {
return this.tags.map(t => t.name);
},
selectAllChecked() {
return this.selectedItems.length === this.tags.length && this.tags.length > 0;
},
},
watch: {
tagsNames: {
immediate: false,
handler(tagsNames) {
this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t));
},
},
},
methods: {
formatSize(size) {
return numberToHumanSize(size);
},
layers(layers) {
return layers ? n__('%d layer', '%d layers', layers) : '';
},
onSelectAllChange() {
if (this.selectAllChecked) {
this.selectedItems = [];
} else {
this.selectedItems = this.tags.map(x => x.name);
}
},
updateSelectedItems(name) {
const delIndex = this.selectedItems.findIndex(x => x === name);
if (delIndex > -1) {
this.selectedItems.splice(delIndex, 1);
} else {
this.selectedItems.push(name);
}
},
},
};
</script>
<template>
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading">
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
data-testid="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<span class="gl-display-flex gl-justify-content-end">
<gl-button
v-gl-tooltip
data-testid="bulkDeleteButton"
:disabled="!selectedItems || selectedItems.length === 0"
icon="remove"
variant="danger"
:title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
@click="$emit('delete', selectedItems)"
/>
</span>
</template>
<template #cell(checkbox)="{item}">
<gl-form-checkbox
data-testid="rowCheckbox"
:checked="selectedItems.includes(item.name)"
@change="updateSelectedItems(item.name)"
/>
</template>
<template #cell(name)="{item, field}">
<div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']">
<span
v-gl-tooltip
data-testid="rowNameText"
:title="item.name"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
>
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
data-testid="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
</template>
<template #cell(short_revision)="{value}">
<span data-testid="rowShortRevision">
{{ value }}
</span>
</template>
<template #cell(total_size)="{item}">
<span data-testid="rowSize">
{{ formatSize(item.total_size) }}
<template v-if="item.total_size && item.layers">
&middot;
</template>
{{ layers(item.layers) }}
</span>
</template>
<template #cell(created_at)="{value}">
<span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{item}">
<span class="gl-display-flex gl-justify-content-end">
<gl-button
data-testid="singleDeleteButton"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:disabled="!item.destroy_path"
variant="danger"
icon="remove"
category="secondary"
@click="$emit('delete', [item.name])"
/>
</span>
</template>
<template #empty>
<slot name="empty"></slot>
</template>
<template #table-busy>
<slot name="loader"></slot>
</template>
</gl-table>
</template>

View File

@ -125,7 +125,7 @@ export default {
:disabled="disabledDelete"
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
class="btn-inverted"
category="secondary"
variant="danger"
icon="remove"
@click="$emit('delete', item)"

View File

@ -1,41 +1,17 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import {
GlTable,
GlFormCheckbox,
GlDeprecatedButton,
GlIcon,
GlTooltipDirective,
GlPagination,
GlEmptyState,
GlResizeObserverDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import TagsTable from '../components/details_page/tags_table.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
import { decodeAndParse } from '../utils';
import {
LIST_KEY_TAG,
LIST_KEY_IMAGE_ID,
LIST_KEY_SIZE,
LIST_KEY_LAST_UPDATED,
LIST_KEY_ACTIONS,
LIST_KEY_CHECKBOX,
LIST_LABEL_TAG,
LIST_LABEL_IMAGE_ID,
LIST_LABEL_SIZE,
LIST_LABEL_LAST_UPDATED,
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
ALERT_SUCCESS_TAG,
ALERT_DANGER_TAG,
ALERT_SUCCESS_TAGS,
@ -46,66 +22,29 @@ export default {
components: {
DeleteAlert,
DetailsHeader,
GlTable,
GlFormCheckbox,
GlDeprecatedButton,
GlIcon,
ClipboardButton,
GlPagination,
DeleteModal,
GlSkeletonLoader,
GlEmptyState,
TagsTable,
TagsLoader,
EmptyTagsState,
},
directives: {
GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [timeagoMixin, Tracking.mixin()],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
},
mixins: [Tracking.mixin()],
data() {
return {
selectedItems: [],
itemsToBeDeleted: [],
selectAllChecked: false,
modalDescription: null,
isDesktop: true,
deleteAlertType: null,
};
},
computed: {
...mapGetters(['tags']),
...mapState(['tagsPagination', 'isLoading', 'config']),
...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
imageName() {
const { name } = decodeAndParse(this.$route.params.id);
return name;
},
fields() {
const tagClass = this.isDesktop ? 'w-25' : '';
const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
return [
{ key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
{
key: LIST_KEY_TAG,
label: LIST_LABEL_TAG,
class: `${tagClass} js-tag-column`,
innerClass: tagInnerClass,
},
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
{ key: LIST_KEY_ACTIONS, label: '' },
].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
},
tracking() {
return {
label:
@ -126,48 +65,8 @@ export default {
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
formatSize(size) {
return numberToHumanSize(size);
},
layers(layers) {
return layers ? n__('%d layer', '%d layers', layers) : '';
},
onSelectAllChange() {
if (this.selectAllChecked) {
this.deselectAll();
} else {
this.selectAll();
}
},
selectAll() {
this.selectedItems = this.tags.map(x => x.name);
this.selectAllChecked = true;
},
deselectAll() {
this.selectedItems = [];
this.selectAllChecked = false;
},
updateSelectedItems(name) {
const delIndex = this.selectedItems.findIndex(x => x === name);
if (delIndex > -1) {
this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false;
} else {
this.selectedItems.push(name);
if (this.selectedItems.length === this.tags.length) {
this.selectAllChecked = true;
}
}
},
deleteSingleItem(name) {
this.itemsToBeDeleted = [{ ...this.tags.find(t => t.name === name) }];
this.track('click_button');
this.$refs.deleteModal.show();
},
deleteMultipleItems() {
this.itemsToBeDeleted = this.selectedItems.map(name => ({
deleteTags(toBeDeletedList) {
this.itemsToBeDeleted = toBeDeletedList.map(name => ({
...this.tags.find(t => t.name === name),
}));
this.track('click_button');
@ -176,7 +75,6 @@ export default {
handleSingleDelete() {
const [itemToDelete] = this.itemsToBeDeleted;
this.itemsToBeDeleted = [];
this.selectedItems = this.selectedItems.filter(name => name !== itemToDelete.name);
return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAG;
@ -188,7 +86,6 @@ export default {
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
this.selectedItems = [];
return this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => x.name),
@ -227,116 +124,14 @@ export default {
<details-header :image-name="imageName" />
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
ref="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<gl-deprecated-button
ref="bulkDeleteButton"
v-gl-tooltip
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
:title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</template>
<template #cell(checkbox)="{item}">
<gl-form-checkbox
ref="rowCheckbox"
class="js-row-checkbox"
:checked="selectedItems.includes(item.name)"
@change="updateSelectedItems(item.name)"
/>
</template>
<template #cell(name)="{item, field}">
<div ref="rowName" :class="[field.innerClass, 'gl-display-flex']">
<span
v-gl-tooltip
data-testid="rowNameText"
:title="item.name"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
>
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
ref="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
</template>
<template #cell(short_revision)="{value}">
<span ref="rowShortRevision">
{{ value }}
</span>
</template>
<template #cell(total_size)="{item}">
<span ref="rowSize">
{{ formatSize(item.total_size) }}
<template v-if="item.total_size && item.layers">
&middot;
</template>
{{ layers(item.layers) }}
</span>
</template>
<template #cell(created_at)="{value}">
<span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{item}">
<gl-deprecated-button
ref="singleDeleteButton"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:disabled="!item.destroy_path"
variant="danger"
class="js-delete-registry float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(item.name)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</template>
<tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags">
<template #empty>
<template v-if="isLoading">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
:width="$options.loader.width"
:height="$options.loader.height"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="15" x="0" y="12.5" height="15" rx="4" />
<rect width="250" x="25" y="10" height="20" rx="4" />
<circle cx="290" cy="20" r="10" />
<rect width="100" x="315" y="10" height="20" rx="4" />
<rect width="100" x="500" y="10" height="20" rx="4" />
<rect width="100" x="630" y="10" height="20" rx="4" />
<rect x="960" y="0" width="40" height="40" rx="4" />
</gl-skeleton-loader>
</template>
<gl-empty-state
v-else
:title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:svg-path="config.noContainersImage"
:description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
class="mx-auto my-0"
/>
<empty-tags-state :no-containers-image="config.noContainersImage" />
</template>
</gl-table>
<template #loader>
<tags-loader v-once />
</template>
</tags-table>
<gl-pagination
v-if="!isLoading"

View File

@ -1,9 +1,3 @@
export const tags = state => {
// to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading
// this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete
return state.isLoading ? [] : state.tags;
};
export const dockerBuildCommand = state => {
/* eslint-disable @gitlab/require-i18n-strings */
return `docker build -t ${state.config.repositoryUrl} .`;

View File

@ -1,5 +1,5 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
export default {
components: {
@ -7,6 +7,7 @@ export default {
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
},
props: {
markdownDocsPath: {
@ -59,7 +60,9 @@ export default {
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
<i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
<template>
<gl-icon name="media" :size="16" />
</template>
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
@ -67,7 +70,9 @@ export default {
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
<template>
<gl-icon name="media" :size="16" />
</template>
</span>
<span class="uploading-error-message"></span>
@ -87,8 +92,10 @@ export default {
</gl-sprintf>
</span>
<gl-button class="markdown-selector button-attach-file" variant="link">
<i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i
><span class="text-attach-file">{{ __('Attach a file') }}</span>
<template>
<gl-icon name="media" :size="16" />
</template>
<span class="text-attach-file">{{ __('Attach a file') }}</span>
</gl-button>
<gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link">
{{ __('Cancel') }}

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Types
class EvidenceType < BaseObject
graphql_name 'ReleaseEvidence'
description 'Evidence for a release'
authorize :download_code
present_using Releases::EvidencePresenter
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the evidence'
field :sha, GraphQL::STRING_TYPE, null: true,
description: 'SHA1 ID of the evidence hash'
field :filepath, GraphQL::STRING_TYPE, null: true,
description: 'URL from where the evidence can be downloaded'
field :collected_at, Types::TimeType, null: true,
description: 'Timestamp when the evidence was collected'
end
end

View File

@ -27,6 +27,8 @@ module Types
description: 'Assets of the release'
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones associated to the release'
field :evidences, Types::EvidenceType.connection_type, null: true,
description: 'Evidence for the release'
field :author, Types::UserType, null: true,
description: 'User that created the release'

View File

@ -65,6 +65,11 @@ module Types
calls_gitaly: true,
null: false
field :blobs, type: [Types::Snippets::BlobType],
description: 'Snippet blobs',
calls_gitaly: true,
null: false
field :ssh_url_to_repo, type: GraphQL::STRING_TYPE,
description: 'SSH URL to the snippet repository',
calls_gitaly: true,

View File

@ -18,7 +18,7 @@ class Badge < ApplicationRecord
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
default_scope { order_created_at_asc }
default_scope { order_created_at_asc } # rubocop:disable Cop/DefaultScope
scope :order_created_at_asc, -> { reorder(created_at: :asc) }

View File

@ -45,7 +45,7 @@ module Ci
end
def valid_local?
return true if Feature.enabled?('ci_disable_validates_dependencies')
return true if Feature.enabled?(:ci_disable_validates_dependencies)
local.all?(&:valid_dependency?)
end

View File

@ -5,7 +5,7 @@ module Ci
include StripAttribute
self.table_name = 'ci_freeze_periods'
default_scope { order(created_at: :asc) }
default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope
belongs_to :project, inverse_of: :freeze_periods

View File

@ -9,7 +9,7 @@ class Event < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include UsageStatistics
default_scope { reorder(nil) }
default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope
ACTIONS = HashWithIndifferentAccess.new(
created: 1,

View File

@ -31,7 +31,7 @@ class Label < ApplicationRecord
validates :title, uniqueness: { scope: [:group_id, :project_id] }
validates :title, length: { maximum: 255 }
default_scope { order(title: :asc) }
default_scope { order(title: :asc) } # rubocop:disable Cop/DefaultScope
scope :templates, -> { where(template: true, type: [Label.name, nil]) }
scope :with_title, ->(title) { where(title: title) }

View File

@ -13,7 +13,7 @@ class GroupMember < Member
# Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) }
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) }
scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count }

View File

@ -9,7 +9,7 @@ class ProjectMember < Member
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\AProject\z/ }
validates :access_level, inclusion: { in: Gitlab::Access.values }
default_scope { where(source_type: SOURCE_TYPE) }
default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope
scope :in_project, ->(project) { where(source_id: project.id) }
scope :in_namespaces, ->(groups) do

View File

@ -4,6 +4,7 @@ class ProjectMetricsSetting < ApplicationRecord
belongs_to :project
validates :external_dashboard_url,
allow_nil: true,
length: { maximum: 255 },
addressable_url: { enforce_sanitization: true, ascii_only: true }

View File

@ -1,32 +1,35 @@
# frozen_string_literal: true
class Releases::Evidence < ApplicationRecord
include ShaAttribute
include Presentable
module Releases
class Evidence < ApplicationRecord
include ShaAttribute
include Presentable
belongs_to :release, inverse_of: :evidences
belongs_to :release, inverse_of: :evidences
default_scope { order(created_at: :asc) }
default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope
sha_attribute :summary_sha
alias_attribute :collected_at, :created_at
sha_attribute :summary_sha
alias_attribute :collected_at, :created_at
alias_attribute :sha, :summary_sha
def milestones
@milestones ||= release.milestones.includes(:issues)
end
##
# Return `summary` without sensitive information.
#
# Removing issues from summary in order to prevent leaking confidential ones.
# See more https://gitlab.com/gitlab-org/gitlab/issues/121930
def summary
safe_summary = read_attribute(:summary)
safe_summary.dig('release', 'milestones')&.each do |milestone|
milestone.delete('issues')
def milestones
@milestones ||= release.milestones.includes(:issues)
end
safe_summary
##
# Return `summary` without sensitive information.
#
# Removing issues from summary in order to prevent leaking confidential ones.
# See more https://gitlab.com/gitlab-org/gitlab/issues/121930
def summary
safe_summary = read_attribute(:summary)
safe_summary.dig('release', 'milestones')&.each do |milestone|
milestone.delete('issues')
end
safe_summary
end
end
end

View File

@ -4,7 +4,7 @@ class RepositoryLanguage < ApplicationRecord
belongs_to :project
belongs_to :programming_language
default_scope { includes(:programming_language) }
default_scope { includes(:programming_language) } # rubocop:disable Cop/DefaultScope
validates :project, presence: true
validates :share, inclusion: { in: 0..100, message: "The share of a language is between 0 and 100" }

View File

@ -36,10 +36,14 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated
end
def blob
blobs.first
end
def blobs
if snippet.empty_repo?
snippet.blob
[snippet.blob]
else
snippet.blobs.first
snippet.blobs
end
end

View File

@ -41,9 +41,9 @@ module Projects
attribs = params[:metrics_setting_attributes]
return {} unless attribs
destroy = attribs[:external_dashboard_url].blank?
attribs[:external_dashboard_url] = attribs[:external_dashboard_url].presence
{ metrics_setting_attributes: attribs.merge(_destroy: destroy) }
{ metrics_setting_attributes: attribs }
end
def error_tracking_params

View File

@ -55,7 +55,7 @@
%span.badge.badge-pill.count= number_with_delimiter(issues_count)
%ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} }
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index'], html_options: { class: "fly-out-top-item" } ) do
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index', 'iterations#index'], html_options: { class: "fly-out-top-item" } ) do
= link_to issues_group_path(@group) do
%strong.fly-out-top-item-name
= _('Issues')
@ -85,6 +85,8 @@
%span
= _('Milestones')
= render_if_exists 'layouts/nav/sidebar/iterations_link'
- if group_sidebar_link?(:merge_requests)
= nav_link(path: 'groups#merge_requests') do
= link_to merge_requests_group_path(@group) do

View File

@ -0,0 +1,5 @@
---
title: Lazy load commit_date and authored_date on Commit
merge_request: 34181
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Add Evidence to Releases GraphQL endpoint
merge_request: 33254
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Use GitLab SVG icon for file attacher action
merge_request: 34196
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove addMilestone logic from issue model
merge_request: 32235
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove findAssignee logic from issue model
merge_request: 32238
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove removeLabel logic from issue model
merge_request: 32251
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove removeLabels logic from issue model
merge_request: 32252
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Remove removeMultipleIssues logic from list model
merge_request: 32254
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Add blobs field to SnippetType in GraphQL
merge_request: 33657
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix default path when creating project from group template
merge_request: 30597
author: Lee Tickett
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Fix rendering of very long paths in merge request file tree
merge_request: 34153
author:
type: fixed

View File

@ -22,7 +22,6 @@ module Gitlab
require_dependency Rails.root.join('lib/gitlab/middleware/basic_health_check')
require_dependency Rails.root.join('lib/gitlab/middleware/same_site_cookies')
require_dependency Rails.root.join('lib/gitlab/middleware/handle_ip_spoof_attack_error')
require_dependency Rails.root.join('lib/gitlab/runtime')
# Settings in config/environments/* take precedence over those specified here.
# Application configuration should go into files in config/initializers

View File

@ -49,8 +49,6 @@ Rails.application.configure do
# Do not log asset requests
config.assets.quiet = true
config.allow_concurrency = Gitlab::Runtime.multi_threaded?
# BetterErrors live shell (REPL) on every stack frame
BetterErrors::Middleware.allow_ip!("127.0.0.1/0")

View File

@ -77,6 +77,4 @@ Rails.application.configure do
config.action_mailer.raise_delivery_errors = true
config.eager_load = true
config.allow_concurrency = Gitlab::Runtime.multi_threaded?
end

View File

@ -17,7 +17,7 @@ class BackfillDeploymentClustersFromDeployments < ActiveRecord::Migration[6.0]
class Deployment < ActiveRecord::Base
include EachBatch
default_scope { where('cluster_id IS NOT NULL') }
default_scope { where('cluster_id IS NOT NULL') } # rubocop:disable Cop/DefaultScope
self.table_name = 'deployments'
end

View File

@ -105,6 +105,21 @@ To activate the changes, run the following command:
sudo gitlab-ctl reconfigure
```
### Security
PlantUML has features that allows fetching network resources.
```plaintext
@startuml
start
' ...
!include http://localhost/
stop;
@enduml
```
**If you self-host the PlantUML server, network controls should be put in place to isolate it.**
## GitLab
You need to enable PlantUML integration from Settings under Admin Area. To do

View File

@ -351,7 +351,7 @@ you can flip the feature flag from a Rails console.
1. Flip the switch and disable it:
```ruby
Feature.enable('ci_disable_validates_dependencies')
Feature.enable(:ci_disable_validates_dependencies)
```
**In installations from source:**
@ -366,7 +366,7 @@ you can flip the feature flag from a Rails console.
1. Flip the switch and disable it:
```ruby
Feature.enable('ci_disable_validates_dependencies')
Feature.enable(:ci_disable_validates_dependencies)
```
## Set the maximum file size of the artifacts

View File

@ -128,13 +128,13 @@ sudo -u git -H bin/rails console -e production
**To check if incremental logging (trace) is enabled:**
```ruby
Feature.enabled?('ci_enable_live_trace')
Feature.enabled?(:ci_enable_live_trace)
```
**To enable incremental logging (trace):**
```ruby
Feature.enable('ci_enable_live_trace')
Feature.enable(:ci_enable_live_trace)
```
NOTE: **Note:**

View File

@ -9971,6 +9971,31 @@ type Release {
"""
descriptionHtml: String
"""
Evidence for the release
"""
evidences(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): ReleaseEvidenceConnection
"""
Milestones associated to the release
"""
@ -10109,6 +10134,66 @@ type ReleaseEdge {
node: Release
}
"""
Evidence for a release
"""
type ReleaseEvidence {
"""
Timestamp when the evidence was collected
"""
collectedAt: Time
"""
URL from where the evidence can be downloaded
"""
filepath: String
"""
ID of the evidence
"""
id: ID!
"""
SHA1 ID of the evidence hash
"""
sha: String
}
"""
The connection type for ReleaseEvidence.
"""
type ReleaseEvidenceConnection {
"""
A list of edges.
"""
edges: [ReleaseEvidenceEdge]
"""
A list of nodes.
"""
nodes: [ReleaseEvidence]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type ReleaseEvidenceEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: ReleaseEvidence
}
type ReleaseLink {
"""
Indicates the link points to an external resource
@ -11162,6 +11247,11 @@ type Snippet implements Noteable {
"""
blob: SnippetBlob!
"""
Snippet blobs
"""
blobs: [SnippetBlob!]!
"""
Timestamp this snippet was created
"""

View File

@ -29243,6 +29243,59 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "evidences",
"description": "Evidence for the release",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ReleaseEvidenceConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "milestones",
"description": "Milestones associated to the release",
@ -29609,6 +29662,191 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ReleaseEvidence",
"description": "Evidence for a release",
"fields": [
{
"name": "collectedAt",
"description": "Timestamp when the evidence was collected",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filepath",
"description": "URL from where the evidence can be downloaded",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the evidence",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sha",
"description": "SHA1 ID of the evidence hash",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ReleaseEvidenceConnection",
"description": "The connection type for ReleaseEvidence.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ReleaseEvidenceEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ReleaseEvidence",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ReleaseEvidenceEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ReleaseEvidence",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ReleaseLink",
@ -32974,6 +33212,32 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "blobs",
"description": "Snippet blobs",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SnippetBlob",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "Timestamp this snippet was created",

View File

@ -1406,6 +1406,17 @@ Represents a Project Member
| --- | ---- | ---------- |
| `assetsCount` | Int | Number of assets of the release |
## ReleaseEvidence
Evidence for a release
| Name | Type | Description |
| --- | ---- | ---------- |
| `collectedAt` | Time | Timestamp when the evidence was collected |
| `filepath` | String | URL from where the evidence can be downloaded |
| `id` | ID! | ID of the evidence |
| `sha` | String | SHA1 ID of the evidence hash |
## ReleaseLink
| Name | Type | Description |
@ -1632,6 +1643,7 @@ Represents a snippet entry
| --- | ---- | ---------- |
| `author` | User! | The owner of the snippet |
| `blob` | SnippetBlob! | Snippet blob |
| `blobs` | SnippetBlob! => Array | Snippet blobs |
| `createdAt` | Time! | Timestamp this snippet was created |
| `description` | String | Description of the snippet |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |

View File

@ -6,8 +6,8 @@ file, as well as information and history about our changelog process.
## Overview
Each bullet point, or **entry**, in our [`CHANGELOG.md`](https://gitlab.com/gitlab-org/gitlab/blob/master/CHANGELOG.md) file is
generated from a single data file in the [`changelogs/unreleased/`](https://gitlab.com/gitlab-org/gitlab-foss/tree/master/changelogs/)
(or corresponding EE) folder. The file is expected to be a [YAML](https://en.wikipedia.org/wiki/YAML) file in the
generated from a single data file in the [`changelogs/unreleased/`](https://gitlab.com/gitlab-org/gitlab/tree/master/changelogs/unreleased/).
The file is expected to be a [YAML](https://en.wikipedia.org/wiki/YAML) file in the
following format:
```yaml

View File

@ -0,0 +1,85 @@
---
type: reference
stage: Plan
group: Project Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Iterations **(STARTER)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/214713) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.1.
> - It's deployed behind a feature flag, disabled by default.
> - It's disabled on GitLab.com.
> - It's able to be enabled or disabled per-group
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-iterations-core-only). **(CORE ONLY)**
Iterations are a way to track issues over a period of time. This allows teams
to track velocity and volatility metrics. Iterations can be used with [milestones](../../project/milestones/index.md)
for tracking over different time periods.
For example, you can use:
- Milestones for Program Increments, which span 8-12 weeks.
- Iterations for Sprints, which span 2 weeks.
In GitLab, iterations are similar to milestones, with a few differences:
- Iterations are only available to groups.
- A group can only have one active iteration at a time.
- Iterations require both a start and an end date.
- Iteration date ranges cannot overlap.
## View the iterations list
To view the iterations list, in a group, go to **{issues}** **Issues > Iterations**.
From there you can create a new iteration or click an iteration to get a more detailed view.
## Create an iteration
NOTE: **Note:**
A permission level of [Developer or higher](../../permissions.md) is required to create iterations.
To create an iteration:
1. In a group, go to **{issues}** **Issues > Iterations**.
1. Click **New iteration**.
1. Enter the title, a description (optional), a start date, and a due date.
1. Click **Create iteration**. The iteration details page opens.
### Enable Iterations **(CORE ONLY)**
GitLab Iterations feature is under development and not ready for production use.
It is deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can enable it for your instance. `:group_iterations` can be enabled or disabled per-group.
To enable it:
```ruby
# Instance-wide
Feature.enable(:group_iterations)
# or by group
Feature.enable(:group_iterations, Group.find(<group id>))
```
To disable it:
```ruby
# Instance-wide
Feature.disable(:group_iterations)
# or by group
Feature.disable(:group_iterations, Group.find(<group id>))
```
<!-- ## Troubleshooting
Include any troubleshooting steps that you can foresee. If you know beforehand what issues
one might have when setting this up, or when something is changed, or on upgrading, it's
important to describe those, too. Think of things that may go wrong and include them here.
This is important to minimize requests for support, and to avoid doc comments with
questions that you know someone might ask.
Each scenario can be a third-level heading, e.g. `### Getting error message X`.
If you have none to add when creating a doc, leave this section in place
but commented out to help encourage others to add to it in the future. -->

View File

@ -6,7 +6,7 @@ module API
class Evidence < Grape::Entity
include ::API::Helpers::Presentable
expose :summary_sha, as: :sha
expose :sha
expose :filepath
expose :collected_at
end

View File

@ -62,7 +62,7 @@ module Gitlab
class PrometheusService < ActiveRecord::Base
self.inheritance_column = :_type_disabled
self.table_name = 'services'
default_scope { where(type: type) }
default_scope { where(type: type) } # rubocop:disable Cop/DefaultScope
def self.type
'PrometheusService'

View File

@ -147,7 +147,7 @@ module Gitlab
raise AlreadyArchivedError, 'Could not write to the archived trace'
elsif current_path
File.open(current_path, mode)
elsif Feature.enabled?('ci_enable_live_trace', job.project)
elsif Feature.enabled?(:ci_enable_live_trace, job.project)
Gitlab::Ci::Trace::ChunkedIO.new(job)
else
File.open(ensure_path, mode)

View File

@ -7,6 +7,7 @@ module Gitlab
include Gitlab::EncodingHelper
prepend Gitlab::Git::RuggedImpl::Commit
extend Gitlab::Git::WrapsGitalyErrors
include Gitlab::Utils::StrongMemoize
attr_accessor :raw_commit, :head
@ -231,6 +232,18 @@ module Gitlab
parent_ids.first
end
def committed_date
strong_memoize(:committed_date) do
init_date_from_gitaly(raw_commit.committer) if raw_commit
end
end
def authored_date
strong_memoize(:authored_date) do
init_date_from_gitaly(raw_commit.author) if raw_commit
end
end
# Returns a diff object for the changes from this commit's first parent.
# If there is no parent, then the diff is between this commit and an
# empty repo. See Repository#diff for keys allowed in the +options+
@ -369,11 +382,9 @@ module Gitlab
# subject from the message to make it clearer when there's one
# available but not the other.
@message = message_from_gitaly_body
@authored_date = init_date_from_gitaly(commit.author)
@author_name = commit.author.name.dup
@author_email = commit.author.email.dup
@committed_date = init_date_from_gitaly(commit.committer)
@committer_name = commit.committer.name.dup
@committer_email = commit.committer.email.dup
@parent_ids = Array(commit.parent_ids)

View File

@ -71,15 +71,16 @@ module Gitlab
end
end
# Queries Prometheus for values aggregated by the given label string.
# Queries Prometheus with the given aggregate query and groups the results by mapping
# metric labels to their respective values.
#
# @return [Hash] mapping labels to their aggregate numeric values, or the empty hash if no results were found
def aggregate(func:, metric:, by:, time: Time.now)
response = query("#{func} (#{metric}) by (#{by})", time: time)
def aggregate(aggregate_query, time: Time.now)
response = query(aggregate_query, time: time)
response.to_h do |result|
group_name = result.dig('metric', by)
key = block_given? ? yield(result['metric']) : result['metric']
_timestamp, value = result['value']
[group_name, value.to_i]
[key, value.to_i]
end
end

View File

@ -18,6 +18,7 @@ module Gitlab
class << self
include Gitlab::Utils::UsageData
include Gitlab::Utils::StrongMemoize
include Gitlab::UsageDataConcerns::Topology
def data(force_refresh: false)
Rails.cache.fetch('usage_data', force: force_refresh, expires_in: 2.weeks) do
@ -247,25 +248,6 @@ module Gitlab
}
end
def topology_usage_data
topology_data, duration = measure_duration do
alt_usage_data(fallback: {}) do
{
nodes: topology_node_data
}.compact
end
end
{ topology: topology_data.merge(duration_s: duration) }
end
def topology_node_data
with_prometheus_client do |client|
by_instance_mem =
client.aggregate(func: 'avg', metric: 'node_memory_MemTotal_bytes', by: 'instance').compact
by_instance_mem.values.map { |v| { node_memory_total_bytes: v } }
end
end
def app_server_type
Gitlab::Runtime.identify.to_s
rescue Gitlab::Runtime::IdentificationError => e

View File

@ -0,0 +1,118 @@
# frozen_string_literal: true
module Gitlab
module UsageDataConcerns
module Topology
include Gitlab::Utils::UsageData
def topology_usage_data
topology_data, duration = measure_duration do
alt_usage_data(fallback: {}) do
{
nodes: topology_node_data
}.compact
end
end
{ topology: topology_data.merge(duration_s: duration) }
end
private
def topology_node_data
with_prometheus_client do |client|
# node-level data
by_instance_mem = topology_node_memory(client)
by_instance_cpus = topology_node_cpus(client)
# service-level data
by_instance_by_job_by_metric_memory = topology_all_service_memory(client)
by_instance_by_job_process_count = topology_all_service_process_count(client)
instances = Set.new(by_instance_mem.keys + by_instance_cpus.keys)
instances.map do |instance|
{
node_memory_total_bytes: by_instance_mem[instance],
node_cpus: by_instance_cpus[instance],
node_services:
topology_node_services(instance, by_instance_by_job_process_count, by_instance_by_job_by_metric_memory)
}.compact
end
end
end
def topology_node_memory(client)
aggregate_single(client, 'avg (node_memory_MemTotal_bytes) by (instance)')
end
def topology_node_cpus(client)
aggregate_single(client, 'count (node_cpu_seconds_total{mode="idle"}) by (instance)')
end
def topology_all_service_memory(client)
aggregate_many(
client,
'avg ({__name__=~"ruby_process_(resident|unique|proportional)_memory_bytes"}) by (instance, job, __name__)'
)
end
def topology_all_service_process_count(client)
aggregate_many(client, 'count (ruby_process_start_time_seconds) by (instance, job)')
end
def topology_node_services(instance, all_process_counts, all_process_memory)
# returns all node service data grouped by service name as the key
instance_service_data =
topology_instance_service_process_count(instance, all_process_counts)
.deep_merge(topology_instance_service_memory(instance, all_process_memory))
# map to list of hashes where service name becomes a value instead
instance_service_data.map do |service, data|
{ name: service.to_s }.merge(data)
end
end
def topology_instance_service_process_count(instance, all_instance_data)
topology_data_for_instance(instance, all_instance_data).to_h do |metric, count|
job = metric['job'].underscore.to_sym
[job, { process_count: count }]
end
end
def topology_instance_service_memory(instance, all_instance_data)
topology_data_for_instance(instance, all_instance_data).each_with_object({}) do |entry, hash|
metric, memory = entry
job = metric['job'].underscore.to_sym
key =
case metric['__name__']
when 'ruby_process_resident_memory_bytes' then :process_memory_rss
when 'ruby_process_unique_memory_bytes' then :process_memory_uss
when 'ruby_process_proportional_memory_bytes' then :process_memory_pss
end
hash[job] ||= {}
hash[job][key] ||= memory
end
end
def topology_data_for_instance(instance, all_instance_data)
all_instance_data.filter { |metric, _value| metric['instance'] == instance }
end
def drop_port(instance)
instance.gsub(/:.+$/, '')
end
# Will retain a single `instance` key that values are mapped to
def aggregate_single(client, query)
client.aggregate(query) { |metric| drop_port(metric['instance']) }
end
# Will retain a composite key that values are mapped to
def aggregate_many(client, query)
client.aggregate(query) do |metric|
metric['instance'] = drop_port(metric['instance'])
metric
end
end
end
end
end

View File

@ -9281,6 +9281,9 @@ msgstr ""
msgid "Failed Jobs"
msgstr ""
msgid "Failed on"
msgstr ""
msgid "Failed to add a Zoom meeting"
msgstr ""
@ -14642,6 +14645,9 @@ msgstr ""
msgid "New issue title"
msgstr ""
msgid "New iteration"
msgstr ""
msgid "New iteration created"
msgstr ""
@ -14819,6 +14825,9 @@ msgstr ""
msgid "No grouping"
msgstr ""
msgid "No iterations to show"
msgstr ""
msgid "No job log"
msgstr ""
@ -15757,6 +15766,9 @@ msgstr ""
msgid "Passed"
msgstr ""
msgid "Passed on"
msgstr ""
msgid "Password"
msgstr ""
@ -27246,6 +27258,9 @@ msgstr ""
msgid "revised"
msgstr ""
msgid "satisfied"
msgstr ""
msgid "score"
msgstr ""

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module RuboCop
module Cop
# Cop that blacklists the use of `default_scope`.
class DefaultScope < RuboCop::Cop::Cop
MSG = <<~EOF
Do not use `default_scope`, as it does not follow the principle of
least surprise. See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33847
for more details.
EOF
def_node_matcher :default_scope?, <<~PATTERN
(send {nil? (const nil? ...)} :default_scope ...)
PATTERN
def on_send(node)
return unless default_scope?(node)
add_offense(node, location: :expression)
end
end
end
end

View File

@ -3,5 +3,7 @@
FactoryBot.define do
factory :evidence, class: 'Releases::Evidence' do
release
summary_sha { "760d6cdfb0879c3ffedec13af470e0f71cf52c6cde4d" }
summary { { "release": { "tag": "v4.0", "name": "New release", "project_name": "Project name" } } }
end
end

View File

@ -75,7 +75,7 @@ describe 'Container Registry', :js do
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service }
click_on(class: 'js-delete-registry')
first('[data-testid="singleDeleteButton"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end

View File

@ -46,6 +46,7 @@ describe 'Group navbar' do
before do
stub_feature_flags(group_push_rules: false)
stub_feature_flags(group_iterations: false)
group.add_maintainer(user)
sign_in(user)
end

View File

@ -84,7 +84,7 @@ describe 'Container Registry', :js do
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service }
first('.js-delete-registry').click
first('[data-testid="singleDeleteButton"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end

View File

@ -5,10 +5,10 @@ import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync
const TEST_FILE = {
name: 'lorem.md',
eol: 'LF',
editorRow: 3,
editorColumn: 23,
fileLanguage: 'markdown',
content: 'abc\nndef',
};
const localVue = createLocalVue();
@ -56,7 +56,8 @@ describe('ide/components/ide_status_list', () => {
});
it('shows file eol', () => {
expect(wrapper.text()).toContain(TEST_FILE.name);
expect(wrapper.text()).not.toContain('CRLF');
expect(wrapper.text()).toContain('LF');
});
it('shows file editor position', () => {

View File

@ -85,7 +85,6 @@ describe('new dropdown upload', () => {
name: textFile.name,
type: 'blob',
content: 'plain text',
base64: false,
binary: false,
rawPath: '',
});
@ -103,7 +102,6 @@ describe('new dropdown upload', () => {
name: binaryFile.name,
type: 'blob',
content: binaryTarget.result.split('base64,')[1],
base64: true,
binary: true,
rawPath: binaryTarget.result,
});

View File

@ -94,13 +94,28 @@ describe('RepoEditor', () => {
});
describe('when file is markdown', () => {
beforeEach(done => {
vm.file.previewMode = {
id: 'markdown',
previewTitle: 'Preview Markdown',
};
let mock;
vm.$nextTick(done);
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: '<p>testing 123</p>',
});
Vue.set(vm, 'file', {
...vm.file,
projectId: 'namespace/project',
path: 'sample.md',
content: 'testing 123',
});
vm.$store.state.entries[vm.file.path] = vm.file;
return vm.$nextTick();
});
afterEach(() => {
mock.restore();
});
it('renders an Edit and a Preview Tab', done => {
@ -114,49 +129,9 @@ describe('RepoEditor', () => {
done();
});
});
});
describe('when file is markdown and viewer mode is review', () => {
let mock;
beforeEach(done => {
mock = new MockAdapter(axios);
vm.file.projectId = 'namespace/project';
vm.file.previewMode = {
id: 'markdown',
previewTitle: 'Preview Markdown',
};
vm.file.content = 'testing 123';
vm.$store.state.viewer = 'diff';
mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: '<p>testing 123</p>',
});
vm.$nextTick(done);
});
afterEach(() => {
mock.restore();
});
it('renders an Edit and a Preview Tab', done => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
expect(tabs.length).toBe(2);
expect(tabs[0].textContent.trim()).toBe('Review');
expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
done();
});
});
it('renders markdown for tempFile', done => {
vm.file.tempFile = true;
vm.file.path = `${vm.file.path}.md`;
vm.$store.state.entries[vm.file.path] = vm.file;
vm.$nextTick()
.then(() => {
@ -171,6 +146,20 @@ describe('RepoEditor', () => {
.then(done)
.catch(done.fail);
});
describe('when not in edit mode', () => {
beforeEach(async () => {
await vm.$nextTick();
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
return vm.$nextTick();
});
it('shows no tabs', () => {
expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0);
});
});
});
describe('when open file is binary and not raw', () => {
@ -560,7 +549,6 @@ describe('RepoEditor', () => {
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
base64: true,
binary: true,
rawPath: 'data:image/png;base64,Zm9v',
});

View File

@ -13,8 +13,8 @@ import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants';
import testAction from '../../../../helpers/vuex_action_helper';
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrl: jest.fn(),
joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths,
}));
const TEST_COMMIT_SHA = '123456789';

View File

@ -46,7 +46,7 @@ describe('Multi-file store utils', () => {
path: 'added',
tempFile: true,
content: 'new file content',
base64: true,
rawPath: 'data:image/png;base64,abc',
lastCommitSha: '123456789',
},
{ ...file('deletedFile'), path: 'deletedFile', deleted: true },
@ -117,7 +117,7 @@ describe('Multi-file store utils', () => {
path: 'added',
tempFile: true,
content: 'new file content',
base64: true,
rawPath: 'data:image/png;base64,abc',
lastCommitSha: '123456789',
},
],

View File

@ -192,6 +192,20 @@ describe('text_utility', () => {
'app/…/…/diff',
);
});
describe('given a path too long for the maxWidth', () => {
it.each`
path | maxWidth | result
${'aa/bb/cc'} | ${1} | ${'…'}
${'aa/bb/cc'} | ${2} | ${'…'}
${'aa/bb/cc'} | ${3} | ${'…/…'}
${'aa/bb/cc'} | ${4} | ${'…/…'}
${'aa/bb/cc'} | ${5} | ${'…/…/…'}
`('truncates ($path, $maxWidth) to $result', ({ path, maxWidth, result }) => {
expect(result.length).toBeLessThanOrEqual(maxWidth);
expect(textUtils.truncatePathMiddleToLength(path, maxWidth)).toEqual(result);
});
});
});
describe('slugifyWithUnderscore', () => {

View File

@ -371,6 +371,23 @@ describe('URL utility', () => {
});
});
describe('isBase64DataUrl', () => {
it.each`
url | valid
${undefined} | ${false}
${'http://gitlab.com'} | ${false}
${'data:image/png;base64,abcdef'} | ${true}
${'data:application/smil+xml;base64,abcdef'} | ${true}
${'data:application/vnd.syncml+xml;base64,abcdef'} | ${true}
${'data:application/vnd.3m.post-it-notes;base64,abcdef'} | ${true}
${'notaurl'} | ${false}
${'../relative_url'} | ${false}
${'<a></a>'} | ${false}
`('returns $valid for $url', ({ url, valid }) => {
expect(urlUtils.isBase64DataUrl(url)).toBe(valid);
});
});
describe('relativePathToAbsolute', () => {
it.each`
path | base | result

View File

@ -0,0 +1,63 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TagsLoader component has the correct markup 1`] = `
<div>
<div
preserve-aspect-ratio="xMinYMax meet"
>
<rect
height="15"
rx="4"
width="15"
x="0"
y="12.5"
/>
<rect
height="20"
rx="4"
width="250"
x="25"
y="10"
/>
<circle
cx="290"
cy="20"
r="10"
/>
<rect
height="20"
rx="4"
width="100"
x="315"
y="10"
/>
<rect
height="20"
rx="4"
width="100"
x="500"
y="10"
/>
<rect
height="20"
rx="4"
width="100"
x="630"
y="10"
/>
<rect
height="40"
rx="4"
width="40"
x="960"
y="0"
/>
</div>
</div>
`;

View File

@ -19,6 +19,11 @@ describe('Delete alert', () => {
wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData });
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when deleteAlertType is null', () => {
it('does not show the alert', () => {
mountComponent();

View File

@ -23,6 +23,11 @@ describe('Delete Modal', () => {
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains a GlModal', () => {
mountComponent();
expect(findModal().exists()).toBe(true);

View File

@ -15,6 +15,11 @@ describe('Details Header', () => {
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('has the correct title ', () => {
mountComponent();
expect(wrapper.text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE);

View File

@ -0,0 +1,43 @@
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
} from '~/registry/explorer/constants';
describe('EmptyTagsState component', () => {
let wrapper;
const findEmptyState = () => wrapper.find(GlEmptyState);
const mountComponent = () => {
wrapper = shallowMount(component, {
stubs: {
GlEmptyState,
},
propsData: {
noContainersImage: 'foo',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains gl-empty-state', () => {
mountComponent();
expect(findEmptyState().exist()).toBe(true);
});
it('has the correct props', () => {
mountComponent();
expect(findEmptyState().props()).toMatchObject({
title: EMPTY_IMAGE_REPOSITORY_TITLE,
description: EMPTY_IMAGE_REPOSITORY_MESSAGE,
svgPath: 'foo',
});
});
});

View File

@ -0,0 +1,49 @@
import { shallowMount } from '@vue/test-utils';
import component from '~/registry/explorer/components/details_page/tags_loader.vue';
import { GlSkeletonLoader } from '../../stubs';
describe('TagsLoader component', () => {
let wrapper;
const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader);
const mountComponent = () => {
wrapper = shallowMount(component, {
stubs: {
GlSkeletonLoader,
},
// set the repeat to 1 to avoid a long and verbose snapshot
loader: {
...component.loader,
repeat: 1,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('produces the correct amount of loaders ', () => {
mountComponent();
expect(findGlSkeletonLoaders().length).toBe(1);
});
it('has the correct props', () => {
mountComponent();
expect(
findGlSkeletonLoaders()
.at(0)
.props(),
).toMatchObject({
width: component.loader.width,
height: component.loader.height,
});
});
it('has the correct markup', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
});

View File

@ -0,0 +1,287 @@
import { mount } from '@vue/test-utils';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/components/details_page/tags_table.vue';
import { tagsListResponse } from '../../mock_data';
describe('tags_table', () => {
let wrapper;
const tags = [...tagsListResponse.data];
const findMainCheckbox = () => wrapper.find('[data-testid="mainCheckbox"]');
const findFirstRowItem = testid => wrapper.find(`[data-testid="${testid}"]`);
const findBulkDeleteButton = () => wrapper.find('[data-testid="bulkDeleteButton"]');
const findAllDeleteButtons = () => wrapper.findAll('[data-testid="singleDeleteButton"]');
const findAllCheckboxes = () => wrapper.findAll('[data-testid="rowCheckbox"]');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
const findLoaderSlot = () => wrapper.find('[data-testid="loaderSlot"]');
const findEmptySlot = () => wrapper.find('[data-testid="emptySlot"]');
const mountComponent = (propsData = { tags, isDesktop: true }) => {
wrapper = mount(component, {
stubs: {
...stubChildren(component),
GlTable: false,
},
propsData,
slots: {
loader: '<div data-testid="loaderSlot"></div>',
empty: '<div data-testid="emptySlot"></div>',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each([
'rowCheckbox',
'rowName',
'rowShortRevision',
'rowSize',
'rowTime',
'singleDeleteButton',
])('%s exist in the table', element => {
mountComponent();
expect(findFirstRowItem(element).exists()).toBe(true);
});
describe('header checkbox', () => {
it('exists', () => {
mountComponent();
expect(findMainCheckbox().exists()).toBe(true);
});
it('if selected selects all the rows', () => {
mountComponent();
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
expect(findCheckedCheckboxes()).toHaveLength(tags.length);
});
});
it('if deselect deselects all the row', () => {
mountComponent();
findMainCheckbox().vm.$emit('change');
return wrapper.vm
.$nextTick()
.then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findMainCheckbox().attributes('checked')).toBe(undefined);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
});
describe('row checkbox', () => {
beforeEach(() => {
mountComponent();
});
it('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems).toEqual([tags[0].name]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
});
});
it('if deselect remove name from selectedItems', () => {
wrapper.setData({ selectedItems: [tags[0].name] });
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems.length).toBe(0);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined);
});
});
});
describe('header delete button', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(findBulkDeleteButton().exists()).toBe(true);
});
it('is disabled if no item is selected', () => {
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
});
it('is enabled if at least one item is selected', () => {
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy();
});
});
describe('on click', () => {
it('when one item is selected', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[['centos6']]]);
});
it('when multiple items are selected', () => {
findMainCheckbox().vm.$emit('change');
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[tags.map(t => t.name)]]);
});
});
});
describe('row delete button', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(
findAllDeleteButtons()
.at(0)
.exists(),
).toBe(true);
});
it('is disabled if the item has no destroy_path', () => {
expect(
findAllDeleteButtons()
.at(1)
.attributes('disabled'),
).toBe('true');
});
it('on click', () => {
findAllDeleteButtons()
.at(0)
.vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[['centos6']]]);
});
});
describe('name cell', () => {
it('tag column has a tooltip with the tag name', () => {
mountComponent();
expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name);
});
describe('on desktop viewport', () => {
beforeEach(() => {
mountComponent();
});
it('table header has class w-25', () => {
expect(findFirsTagColumn().classes()).toContain('w-25');
});
it('tag column has the mw-m class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
});
});
describe('on mobile viewport', () => {
beforeEach(() => {
mountComponent({ tags, isDesktop: false });
});
it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
});
it('tag column has the gl-justify-content-end class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
});
});
});
describe('last updated cell', () => {
let timeCell;
beforeEach(() => {
mountComponent();
timeCell = findFirstRowItem('rowTime');
});
it('displays the time in string format', () => {
expect(timeCell.text()).toBe('2 years ago');
});
it('has a tooltip timestamp', () => {
expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000');
});
});
describe('empty state slot', () => {
describe('when the table is empty', () => {
beforeEach(() => {
mountComponent({ tags: [], isDesktop: true });
});
it('does not show table rows', () => {
expect(findFirstTagNameText().exists()).toBe(false);
});
it('has the empty state slot', () => {
expect(findEmptySlot().exists()).toBe(true);
});
});
describe('when the table is not empty', () => {
beforeEach(() => {
mountComponent({ tags, isDesktop: true });
});
it('does show table rows', () => {
expect(findFirstTagNameText().exists()).toBe(true);
});
it('does not show the empty state', () => {
expect(findEmptySlot().exists()).toBe(false);
});
});
});
describe('loader slot', () => {
describe('when the data is loading', () => {
beforeEach(() => {
mountComponent({ isLoading: true, tags });
});
it('show the loader', () => {
expect(findLoaderSlot().exists()).toBe(true);
});
it('does not show the table rows', () => {
expect(findFirstTagNameText().exists()).toBe(false);
});
});
describe('when the data is not loading', () => {
beforeEach(() => {
mountComponent({ isLoading: false, tags });
});
it('does not show the loader', () => {
expect(findLoaderSlot().exists()).toBe(false);
});
it('shows the table rows', () => {
expect(findFirstTagNameText().exists()).toBe(true);
});
});
});
});

View File

@ -24,6 +24,11 @@ describe('Image List', () => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('list', () => {
it('contains one list element for each image', () => {
expect(findRow().length).toBe(imagesListResponse.data.length);

View File

@ -1,11 +1,11 @@
import { mount } from '@vue/test-utils';
import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue';
import DeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue';
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import { createStore } from '~/registry/explorer/stores/';
import {
SET_MAIN_LOADING,
@ -15,7 +15,7 @@ import {
} from '~/registry/explorer/stores/mutation_types/';
import { tagsListResponse } from '../mock_data';
import { $toast } from '../../shared/mocks';
import { TagsTable, DeleteModal } from '../stubs';
describe('Details Page', () => {
let wrapper;
@ -24,28 +24,19 @@ describe('Details Page', () => {
const findDeleteModal = () => wrapper.find(DeleteModal);
const findPagination = () => wrapper.find(GlPagination);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' });
const findFirstRowItem = ref => wrapper.find({ ref });
const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' });
// findAll and refs seems to no work falling back to class
const findAllDeleteButtons = () => wrapper.findAll('.js-delete-registry');
const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
const findTagsLoader = () => wrapper.find(TagsLoader);
const findTagsTable = () => wrapper.find(TagsTable);
const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyTagsState = () => wrapper.find(EmptyTagsState);
const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
const mountComponent = options => {
wrapper = mount(component, {
wrapper = shallowMount(component, {
store,
stubs: {
...stubChildren(component),
GlSprintf: false,
GlTable,
TagsTable,
DeleteModal,
},
mocks: {
@ -54,7 +45,6 @@ describe('Details Page', () => {
id: routeId,
},
},
$toast,
},
...options,
});
@ -67,7 +57,6 @@ describe('Details Page', () => {
store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data);
store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers);
jest.spyOn(Tracking, 'event');
jest.spyOn(DeleteModal.methods, 'show');
});
afterEach(() => {
@ -78,18 +67,14 @@ describe('Details Page', () => {
describe('when isLoading is true', () => {
beforeEach(() => {
mountComponent();
store.dispatch('receiveTagsListSuccess', { ...tagsListResponse, data: [] });
store.commit(SET_MAIN_LOADING, true);
return wrapper.vm.$nextTick();
});
afterAll(() => store.commit(SET_MAIN_LOADING, false));
afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('has a skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('does not have list items', () => {
expect(findFirstRowItem('rowCheckbox').exists()).toBe(false);
it('binds isLoading to tags-table', () => {
expect(findTagsTable().props('isLoading')).toBe(true);
});
it('does not show pagination', () => {
@ -97,206 +82,78 @@ describe('Details Page', () => {
});
});
describe('table', () => {
it.each([
'rowCheckbox',
'rowName',
'rowShortRevision',
'rowSize',
'rowTime',
'singleDeleteButton',
])('%s exist in the table', element => {
describe('table slots', () => {
beforeEach(() => {
mountComponent();
expect(findFirstRowItem(element).exists()).toBe(true);
});
describe('header checkbox', () => {
beforeEach(() => {
mountComponent();
});
it('has the empty state', () => {
expect(findEmptyTagsState().exists()).toBe(true);
});
it('exists', () => {
expect(findMainCheckbox().exists()).toBe(true);
});
it('has a skeleton loader', () => {
expect(findTagsLoader().exists()).toBe(true);
});
});
it('if selected set selectedItem and allSelected', () => {
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
expect(findCheckedCheckboxes()).toHaveLength(store.state.tags.length);
});
});
describe('table', () => {
beforeEach(() => {
mountComponent();
});
it('if deselect unset selectedItem and allSelected', () => {
wrapper.setData({ selectedItems: [1, 2], selectAllChecked: true });
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBe(undefined);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
it('exists', () => {
expect(findTagsTable().exists()).toBe(true);
});
it('has the correct props bound', () => {
expect(findTagsTable().props()).toMatchObject({
isDesktop: true,
isLoading: false,
tags: store.state.tags,
});
});
describe('row checkbox', () => {
beforeEach(() => {
mountComponent();
});
it('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems).toEqual([store.state.tags[1].name]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
describe('deleteEvent', () => {
describe('single item', () => {
beforeEach(() => {
findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
});
});
it('if deselect remove name from selectedItems', () => {
wrapper.setData({ selectedItems: [store.state.tags[1].name] });
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems.length).toBe(0);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined);
});
});
});
describe('header delete button', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
mountComponent();
expect(findBulkDeleteButton().exists()).toBe(true);
});
it('is disabled if no item is selected', () => {
mountComponent();
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
});
it('is enabled if at least one item is selected', () => {
mountComponent({ data: () => ({ selectedItems: [store.state.tags[0].name] }) });
wrapper.setData({ selectedItems: [1] });
return wrapper.vm.$nextTick().then(() => {
expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy();
});
});
describe('on click', () => {
it('when one item is selected', () => {
mountComponent({ data: () => ({ selectedItems: [store.state.tags[0].name] }) });
jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]);
it('open the modal', () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]);
});
it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
it('when multiple items are selected', () => {
mountComponent({
data: () => ({ selectedItems: store.state.tags.map(t => t.name) }),
});
findBulkDeleteButton().vm.$emit('click');
describe('multiple items', () => {
beforeEach(() => {
findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
});
expect(wrapper.vm.itemsToBeDeleted).toEqual(tagsListResponse.data);
it('open the modal', () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual(store.state.tags);
});
it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
});
});
});
});
describe('row delete button', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(
findAllDeleteButtons()
.at(0)
.exists(),
).toBe(true);
});
it('is disabled if the item has no destroy_path', () => {
expect(
findAllDeleteButtons()
.at(1)
.attributes('disabled'),
).toBe('true');
});
it('on click', () => {
findAllDeleteButtons()
.at(0)
.vm.$emit('click');
expect(DeleteModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
describe('name cell', () => {
it('tag column has a tooltip with the tag name', () => {
mountComponent();
expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name);
});
describe('on desktop viewport', () => {
beforeEach(() => {
mountComponent();
});
it('table header has class w-25', () => {
expect(findFirsTagColumn().classes()).toContain('w-25');
});
it('tag column has the mw-m class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
});
});
describe('on mobile viewport', () => {
beforeEach(() => {
mountComponent({
data() {
return { isDesktop: false };
},
});
});
it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
});
it('tag column has the gl-justify-content-end class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
});
});
});
describe('last updated cell', () => {
let timeCell;
beforeEach(() => {
mountComponent();
timeCell = findFirstRowItem('rowTime');
});
it('displays the time in string format', () => {
expect(timeCell.text()).toBe('2 years ago');
});
it('has a tooltip timestamp', () => {
expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000');
});
});
});
describe('pagination', () => {
@ -343,44 +200,33 @@ describe('Details Page', () => {
describe('confirmDelete event', () => {
describe('when one item is selected to be deleted', () => {
const itemsToBeDeleted = [{ name: 'foo' }];
beforeEach(() => {
mountComponent();
findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
});
it('dispatch requestDeleteTag with the right parameters', () => {
mountComponent({ data: () => ({ itemsToBeDeleted }) });
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', {
tag: itemsToBeDeleted[0],
tag: store.state.tags[0],
params: routeId,
});
});
it('remove the deleted item from the selected items', () => {
mountComponent({ data: () => ({ itemsToBeDeleted, selectedItems: ['foo', 'bar'] }) });
findDeleteModal().vm.$emit('confirmDelete');
expect(wrapper.vm.selectedItems).toEqual(['bar']);
});
});
describe('when more than one item is selected to be deleted', () => {
beforeEach(() => {
mountComponent({
data: () => ({
itemsToBeDeleted: [{ name: 'foo' }, { name: 'bar' }],
selectedItems: ['foo', 'bar'],
}),
});
mountComponent();
findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
});
it('dispatch requestDeleteTags with the right parameters', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', {
ids: ['foo', 'bar'],
ids: store.state.tags.map(t => t.name),
params: routeId,
});
});
it('clears the selectedItems', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(wrapper.vm.selectedItems).toEqual([]);
});
});
});
});

View File

@ -2,35 +2,6 @@ import * as getters from '~/registry/explorer/stores/getters';
describe('Getters RegistryExplorer store', () => {
let state;
const tags = ['foo', 'bar'];
describe('tags', () => {
describe('when isLoading is false', () => {
beforeEach(() => {
state = {
tags,
isLoading: false,
};
});
it('returns tags', () => {
expect(getters.tags(state)).toEqual(state.tags);
});
});
describe('when isLoading is true', () => {
beforeEach(() => {
state = {
tags,
isLoading: true,
};
});
it('returns empty array', () => {
expect(getters.tags(state)).toEqual([]);
});
});
});
describe.each`
getter | prefix | configParameter | suffix

View File

@ -1,3 +1,6 @@
import RealTagsTable from '~/registry/explorer/components/details_page/tags_table.vue';
import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
export const GlModal = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
methods: {
@ -14,3 +17,21 @@ export const RouterLink = {
template: `<div><slot></slot></div>`,
props: ['to'],
};
export const TagsTable = {
props: RealTagsTable.props,
template: `<div><slot name="empty"></slot><slot name="loader"></slot></div>`,
};
export const DeleteModal = {
template: '<div></div>',
methods: {
show: jest.fn(),
},
props: RealDeleteModal.props,
};
export const GlSkeletonLoader = {
template: `<div><slot></slot></div>`,
props: ['width', 'height'],
};

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['ReleaseEvidence'] do
it { expect(described_class).to require_graphql_authorizations(:download_code) }
it 'has the expected fields' do
expected_fields = %w[
id sha filepath collected_at
]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end

View File

@ -9,7 +9,7 @@ describe GitlabSchema.types['Release'] do
expected_fields = %w[
tag_name tag_path
description description_html
name assets milestones author commit
name assets milestones evidences author commit
created_at released_at
]
@ -28,6 +28,12 @@ describe GitlabSchema.types['Release'] do
it { is_expected.to have_graphql_type(Types::MilestoneType.connection_type) }
end
describe 'evidences field' do
subject { described_class.fields['evidences'] }
it { is_expected.to have_graphql_type(Types::EvidenceType.connection_type) }
end
describe 'author field' do
subject { described_class.fields['author'] }

View File

@ -11,7 +11,7 @@ describe GitlabSchema.types['Snippet'] do
:visibility_level, :created_at, :updated_at,
:web_url, :raw_url, :ssh_url_to_repo, :http_url_to_repo,
:notes, :discussions, :user_permissions,
:description_html, :blob]
:description_html, :blob, :blobs]
expect(described_class).to have_graphql_fields(*expected_fields)
end
@ -76,30 +76,14 @@ describe GitlabSchema.types['Snippet'] do
describe '#blob' do
let(:query_blob) { subject.dig('data', 'snippets', 'edges')[0]['node']['blob'] }
let(:query) do
%(
{
snippets {
edges {
node {
blob {
name
path
}
}
}
}
}
)
end
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
subject { GitlabSchema.execute(snippet_query_for(field: 'blob'), context: { current_user: user }).as_json }
context 'when snippet has repository' do
let!(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
let(:blob) { snippet.blobs.first }
it 'returns blob from the repository' do
it 'returns the first blob from the repository' do
expect(query_blob['name']).to eq blob.name
expect(query_blob['path']).to eq blob.path
end
@ -115,4 +99,58 @@ describe GitlabSchema.types['Snippet'] do
end
end
end
describe '#blobs' do
let_it_be(:snippet) { create(:personal_snippet, :public, author: user) }
let(:query_blobs) { subject.dig('data', 'snippets', 'edges')[0]['node']['blobs'] }
subject { GitlabSchema.execute(snippet_query_for(field: 'blobs'), context: { current_user: user }).as_json }
shared_examples 'an array' do
it 'returns an array of snippet blobs' do
expect(query_blobs).to be_an(Array)
end
end
context 'when snippet does not have a repository' do
let(:blob) { snippet.blob }
it_behaves_like 'an array'
it 'contains the first blob from the snippet' do
expect(query_blobs.first['name']).to eq blob.name
expect(query_blobs.first['path']).to eq blob.path
end
end
context 'when snippet has repository' do
let_it_be(:snippet) { create(:personal_snippet, :repository, :public, author: user) }
let(:blobs) { snippet.blobs }
it_behaves_like 'an array'
it 'contains all the blobs from the repository' do
resulting_blobs_names = query_blobs.map { |b| b['name'] }
expect(resulting_blobs_names).to match_array(blobs.map(&:name))
end
end
end
def snippet_query_for(field:)
%(
{
snippets {
edges {
node {
#{field} {
name
path
}
}
}
}
}
)
end
end

View File

@ -172,8 +172,7 @@ describe Gitlab::PrometheusClient do
end
describe '#aggregate' do
let(:user_query) { { func: 'avg', metric: 'metric', by: 'job' } }
let(:prometheus_query) { 'avg (metric) by (job)' }
let(:query) { 'avg (metric) by (job)' }
let(:prometheus_response) do
{
"status": "success",
@ -192,19 +191,19 @@ describe Gitlab::PrometheusClient do
}
}
end
let(:query_url) { prometheus_query_with_time_url(prometheus_query, Time.now.utc) }
let(:query_url) { prometheus_query_with_time_url(query, Time.now.utc) }
around do |example|
Timecop.freeze { example.run }
end
context 'when request returns vector results' do
it 'returns data from the API call' do
it 'returns data from the API call grouped by labels' do
req_stub = stub_prometheus_request(query_url, body: prometheus_response)
expect(subject.aggregate(user_query)).to eq({
"gitlab-rails" => 1,
"gitlab-sidekiq" => 2
expect(subject.aggregate(query)).to eq({
{ "job" => "gitlab-rails" } => 1,
{ "job" => "gitlab-sidekiq" } => 2
})
expect(req_stub).to have_been_requested
end
@ -214,13 +213,13 @@ describe Gitlab::PrometheusClient do
it 'returns {}' do
req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector'))
expect(subject.aggregate(user_query)).to eq({})
expect(subject.aggregate(query)).to eq({})
expect(req_stub).to have_been_requested
end
end
it_behaves_like 'failure response' do
let(:execute_query) { subject.aggregate(user_query) }
let(:execute_query) { subject.aggregate(query) }
end
end

View File

@ -0,0 +1,202 @@
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::UsageDataConcerns::Topology do
include UsageDataHelpers
describe '#topology_usage_data' do
subject { Class.new.extend(described_class).topology_usage_data }
before do
# this pins down time shifts when benchmarking durations
allow(Process).to receive(:clock_gettime).and_return(0)
end
context 'when embedded Prometheus server is enabled' do
before do
expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(true)
expect(Gitlab::Prometheus::Internal).to receive(:uri).and_return('http://prom:9090')
end
it 'contains a topology element' do
allow_prometheus_queries
expect(subject).to have_key(:topology)
end
context 'tracking node metrics' do
it 'contains node level metrics for each instance' do
expect_prometheus_api_to(
receive_node_memory_query,
receive_node_cpu_count_query,
receive_node_service_memory_query,
receive_node_service_process_count_query
)
expect(subject[:topology]).to eq({
duration_s: 0,
nodes: [
{
node_memory_total_bytes: 512,
node_cpus: 8,
node_services: [
{
name: 'gitlab_rails',
process_count: 10,
process_memory_rss: 300,
process_memory_uss: 301,
process_memory_pss: 302
},
{
name: 'gitlab_sidekiq',
process_count: 5,
process_memory_rss: 303
}
]
},
{
node_memory_total_bytes: 1024,
node_cpus: 16,
node_services: [
{
name: 'gitlab_sidekiq',
process_count: 15,
process_memory_rss: 400,
process_memory_pss: 401
}
]
}
]
})
end
end
context 'and some node memory metrics are missing' do
it 'removes the respective entries' do
expect_prometheus_api_to(
receive_node_memory_query(result: []),
receive_node_cpu_count_query,
receive_node_service_memory_query,
receive_node_service_process_count_query
)
keys = subject[:topology][:nodes].flat_map(&:keys)
expect(keys).not_to include(:node_memory_total_bytes)
expect(keys).to include(:node_cpus, :node_services)
end
end
context 'and no results are found' do
it 'does not report anything' do
expect_prometheus_api_to receive(:aggregate).at_least(:once).and_return({})
expect(subject[:topology]).to eq({
duration_s: 0,
nodes: []
})
end
end
context 'and a connection error is raised' do
it 'does not report anything' do
expect_prometheus_api_to receive(:aggregate).and_raise('Connection failed')
expect(subject[:topology]).to eq({ duration_s: 0 })
end
end
end
context 'when embedded Prometheus server is disabled' do
it 'does not report anything' do
expect(Gitlab::Prometheus::Internal).to receive(:prometheus_enabled?).and_return(false)
expect(subject[:topology]).to eq({ duration_s: 0 })
end
end
end
def receive_node_memory_query(result: nil)
receive(:query)
.with('avg (node_memory_MemTotal_bytes) by (instance)', an_instance_of(Hash))
.and_return(result || [
{
'metric' => { 'instance' => 'instance1:8080' },
'value' => [1000, '512']
},
{
'metric' => { 'instance' => 'instance2:8090' },
'value' => [1000, '1024']
}
])
end
def receive_node_cpu_count_query(result: nil)
receive(:query)
.with('count (node_cpu_seconds_total{mode="idle"}) by (instance)', an_instance_of(Hash))
.and_return(result || [
{
'metric' => { 'instance' => 'instance2:8090' },
'value' => [1000, '16']
},
{
'metric' => { 'instance' => 'instance1:8080' },
'value' => [1000, '8']
}
])
end
def receive_node_service_memory_query(result: nil)
receive(:query)
.with('avg ({__name__=~"ruby_process_(resident|unique|proportional)_memory_bytes"}) by (instance, job, __name__)', an_instance_of(Hash))
.and_return(result || [
# instance 1: runs Puma + a small Sidekiq
{
'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails', '__name__' => 'ruby_process_resident_memory_bytes' },
'value' => [1000, '300']
},
{
'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails', '__name__' => 'ruby_process_unique_memory_bytes' },
'value' => [1000, '301']
},
{
'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails', '__name__' => 'ruby_process_proportional_memory_bytes' },
'value' => [1000, '302']
},
{
'metric' => { 'instance' => 'instance1:8090', 'job' => 'gitlab-sidekiq', '__name__' => 'ruby_process_resident_memory_bytes' },
'value' => [1000, '303']
},
# instance 2: runs a dedicated Sidekiq
{
'metric' => { 'instance' => 'instance2:8090', 'job' => 'gitlab-sidekiq', '__name__' => 'ruby_process_resident_memory_bytes' },
'value' => [1000, '400']
},
{
'metric' => { 'instance' => 'instance2:8090', 'job' => 'gitlab-sidekiq', '__name__' => 'ruby_process_proportional_memory_bytes' },
'value' => [1000, '401']
}
])
end
def receive_node_service_process_count_query(result: nil)
receive(:query)
.with('count (ruby_process_start_time_seconds) by (instance, job)', an_instance_of(Hash))
.and_return(result || [
# instance 1
{
'metric' => { 'instance' => 'instance1:8080', 'job' => 'gitlab-rails' },
'value' => [1000, '10']
},
{
'metric' => { 'instance' => 'instance1:8090', 'job' => 'gitlab-sidekiq' },
'value' => [1000, '5']
},
# instance 2
{
'metric' => { 'instance' => 'instance2:8090', 'job' => 'gitlab-sidekiq' },
'value' => [1000, '15']
}
])
end
end

Some files were not shown because too many files have changed in this diff Show More