Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
23ff717a29
commit
0211553b0c
|
@ -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
|
||||
|
|
|
@ -182,6 +182,9 @@ Rails/ApplicationRecord:
|
|||
- ee/db/**/*.rb
|
||||
- ee/spec/**/*.rb
|
||||
|
||||
Cop/DefaultScope:
|
||||
Enabled: true
|
||||
|
||||
Rails/FindBy:
|
||||
Enabled: true
|
||||
Include:
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -35,7 +35,6 @@ export default {
|
|||
name: `${this.path ? `${this.path}/` : ''}${name}`,
|
||||
type: 'blob',
|
||||
content,
|
||||
base64: !isText,
|
||||
binary: !isText,
|
||||
rawPath: !isText ? target.result : '',
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -119,3 +119,7 @@ export function readFileAsDataURL(file) {
|
|||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function getFileEOL(content = '') {
|
||||
return content.includes('\r\n') ? 'CRLF' : 'LF';
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
·
|
||||
</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>
|
|
@ -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)"
|
||||
|
|
|
@ -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">
|
||||
·
|
||||
</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"
|
||||
|
|
|
@ -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} .`;
|
||||
|
|
|
@ -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') }}
|
||||
|
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Lazy load commit_date and authored_date on Commit
|
||||
merge_request: 34181
|
||||
author:
|
||||
type: performance
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add Evidence to Releases GraphQL endpoint
|
||||
merge_request: 33254
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Use GitLab SVG icon for file attacher action
|
||||
merge_request: 34196
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove addMilestone logic from issue model
|
||||
merge_request: 32235
|
||||
author: nuwe1
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove findAssignee logic from issue model
|
||||
merge_request: 32238
|
||||
author: nuwe1
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove removeLabel logic from issue model
|
||||
merge_request: 32251
|
||||
author: nuwe1
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove removeLabels logic from issue model
|
||||
merge_request: 32252
|
||||
author: nuwe1
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove removeMultipleIssues logic from list model
|
||||
merge_request: 32254
|
||||
author: nuwe1
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add blobs field to SnippetType in GraphQL
|
||||
merge_request: 33657
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix default path when creating project from group template
|
||||
merge_request: 30597
|
||||
author: Lee Tickett
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix rendering of very long paths in merge request file tree
|
||||
merge_request: 34153
|
||||
author:
|
||||
type: fixed
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:**
|
||||
|
|
|
@ -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
|
||||
"""
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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` |
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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. -->
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
|
|
|
@ -23,6 +23,11 @@ describe('Delete Modal', () => {
|
|||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
it('contains a GlModal', () => {
|
||||
mountComponent();
|
||||
expect(findModal().exists()).toBe(true);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
@ -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
|
|
@ -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'] }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue