summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/ci/reports.gitlab-ci.yml4
-rw-r--r--.rubocop.yml3
-rw-r--r--app/assets/javascripts/boards/models/issue.js13
-rw-r--r--app/assets/javascripts/boards/models/list.js13
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js34
-rw-r--r--app/assets/javascripts/ide/components/ide_status_list.vue6
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue1
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue24
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js4
-rw-r--r--app/assets/javascripts/ide/lib/files.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js5
-rw-r--r--app/assets/javascripts/ide/stores/utils.js16
-rw-r--r--app/assets/javascripts/ide/utils.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js7
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js9
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue33
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue34
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue210
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue2
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue243
-rw-r--r--app/assets/javascripts/registry/explorer/stores/getters.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue17
-rw-r--r--app/graphql/types/evidence_type.rb21
-rw-r--r--app/graphql/types/release_type.rb2
-rw-r--r--app/graphql/types/snippet_type.rb5
-rw-r--r--app/models/badge.rb2
-rw-r--r--app/models/ci/build_dependencies.rb2
-rw-r--r--app/models/ci/freeze_period.rb2
-rw-r--r--app/models/event.rb2
-rw-r--r--app/models/label.rb2
-rw-r--r--app/models/members/group_member.rb2
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/project_metrics_setting.rb1
-rw-r--r--app/models/releases/evidence.rb45
-rw-r--r--app/models/repository_language.rb2
-rw-r--r--app/presenters/snippet_presenter.rb8
-rw-r--r--app/services/projects/operations/update_service.rb4
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml4
-rw-r--r--changelogs/unreleased/217570-improve-performance-for-blame-api.yml5
-rw-r--r--changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml5
-rw-r--r--changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml5
-rw-r--r--changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml5
-rw-r--r--changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml5
-rw-r--r--changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml5
-rw-r--r--changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml5
-rw-r--r--changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml5
-rw-r--r--changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml5
-rw-r--r--changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml5
-rw-r--r--changelogs/unreleased/long-path-mr-bug.yml5
-rw-r--r--config/application.rb1
-rw-r--r--config/environments/development.rb2
-rw-r--r--config/environments/production.rb2
-rw-r--r--db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb2
-rw-r--r--doc/administration/integration/plantuml.md15
-rw-r--r--doc/administration/job_artifacts.md4
-rw-r--r--doc/administration/job_logs.md4
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql90
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json264
-rw-r--r--doc/api/graphql/reference/index.md12
-rw-r--r--doc/development/changelog.md4
-rw-r--r--doc/user/group/iterations/index.md85
-rw-r--r--lib/api/entities/releases/evidence.rb2
-rw-r--r--lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb2
-rw-r--r--lib/gitlab/ci/trace.rb2
-rw-r--r--lib/gitlab/git/commit.rb15
-rw-r--r--lib/gitlab/prometheus_client.rb11
-rw-r--r--lib/gitlab/usage_data.rb20
-rw-r--r--lib/gitlab/usage_data_concerns/topology.rb118
-rw-r--r--locale/gitlab.pot15
-rw-r--r--rubocop/cop/default_scope.rb24
-rw-r--r--spec/factories/evidences.rb2
-rw-r--r--spec/features/groups/container_registry_spec.rb2
-rw-r--r--spec/features/groups/navbar_spec.rb1
-rw-r--r--spec/features/projects/container_registry_spec.rb2
-rw-r--r--spec/frontend/ide/components/ide_status_list_spec.js5
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js2
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js64
-rw-r--r--spec/frontend/ide/stores/modules/commit/actions_spec.js2
-rw-r--r--spec/frontend/ide/stores/utils_spec.js4
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js14
-rw-r--r--spec/frontend/lib/utils/url_utility_spec.js17
-rw-r--r--spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap63
-rw-r--r--spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js5
-rw-r--r--spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js5
-rw-r--r--spec/frontend/registry/explorer/components/details_page/details_header_spec.js5
-rw-r--r--spec/frontend/registry/explorer/components/details_page/empty_tags_state.js43
-rw-r--r--spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js49
-rw-r--r--spec/frontend/registry/explorer/components/details_page/tags_table_spec.js287
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_spec.js5
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js276
-rw-r--r--spec/frontend/registry/explorer/stores/getters_spec.js29
-rw-r--r--spec/frontend/registry/explorer/stubs.js21
-rw-r--r--spec/graphql/types/evidence_type_spec.rb15
-rw-r--r--spec/graphql/types/release_type_spec.rb8
-rw-r--r--spec/graphql/types/snippet_type_spec.rb76
-rw-r--r--spec/lib/gitlab/prometheus_client_spec.rb17
-rw-r--r--spec/lib/gitlab/usage_data_concerns/topology_spec.rb202
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb86
-rw-r--r--spec/models/project_metrics_setting_spec.rb10
-rw-r--r--spec/presenters/snippet_presenter_spec.rb21
-rw-r--r--spec/requests/api/graphql/project/release_spec.rb40
-rw-r--r--spec/rubocop/cop/default_scope_spec.rb48
-rw-r--r--spec/services/projects/operations/update_service_spec.rb24
-rw-r--r--spec/support/helpers/usage_data_helpers.rb12
107 files changed, 2217 insertions, 801 deletions
diff --git a/.gitlab/ci/reports.gitlab-ci.yml b/.gitlab/ci/reports.gitlab-ci.yml
index 4c183c297d5..65abb6c5cba 100644
--- a/.gitlab/ci/reports.gitlab-ci.yml
+++ b/.gitlab/ci/reports.gitlab-ci.yml
@@ -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
diff --git a/.rubocop.yml b/.rubocop.yml
index 3b4a7a9369d..ed17799478a 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -182,6 +182,9 @@ Rails/ApplicationRecord:
- ee/db/**/*.rb
- ee/spec/**/*.rb
+Cop/DefaultScope:
+ Enabled: true
+
Rails/FindBy:
Enabled: true
Include:
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index 7c001b755f3..198c4c9fd77 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -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) {
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index d8257dd3e91..0bd606c6297 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -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) {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index fb3d2b81060..fba9859f3a4 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -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;
diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue
index f0988adbd5d..92d25709bd5 100644
--- a/app/assets/javascripts/ide/components/ide_status_list.vue
+++ b/app/assets/javascripts/ide/components/ide_status_list.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index 7261e0590c8..b2141c13d9f 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -35,7 +35,6 @@ export default {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content,
- base64: !isText,
binary: !isText,
rawPath: !isText ? target.result : '',
});
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 6f38569b021..07d14fd0338 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index 4c743076fb2..c5bb00c3dee 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -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;
}
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index 46b37b50f46..6d85e225fd5 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -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,
});
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 423417f8fdb..c881f1221e5 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -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,
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 397decd479d..47f9337a288 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -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, {
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index c49b723b4bf..d94adc3760f 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 313fa1fe029..c90bc2a3320 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -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,
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 012efa77b96..1c5fe9fe9a5 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -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,
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index ced6ffe0c3e..c28a2bd9f1d 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -119,3 +119,7 @@ export function readFileAsDataURL(file) {
reader.readAsDataURL(file);
});
}
+
+export function getFileEOL(content = '') {
+ return content.includes('\r\n') ? 'CRLF' : 'LF';
+}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index aac58f285f0..be3fe1ed620 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -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
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 6ae4167d5ba..0472b8cf51f 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -244,6 +244,15 @@ export function isRootRelative(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
*
* @param {String} url
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue
new file mode 100644
index 00000000000..0c684d124d5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue
@@ -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>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue
new file mode 100644
index 00000000000..b7afa5fba33
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue
@@ -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>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue
new file mode 100644
index 00000000000..81be778e1e5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue
@@ -0,0 +1,210 @@
+<script>
+import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import {
+ LIST_KEY_TAG,
+ LIST_KEY_IMAGE_ID,
+ LIST_KEY_SIZE,
+ LIST_KEY_LAST_UPDATED,
+ LIST_KEY_ACTIONS,
+ LIST_KEY_CHECKBOX,
+ LIST_LABEL_TAG,
+ LIST_LABEL_IMAGE_ID,
+ LIST_LABEL_SIZE,
+ LIST_LABEL_LAST_UPDATED,
+ REMOVE_TAGS_BUTTON_TITLE,
+ REMOVE_TAG_BUTTON_TITLE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlTable,
+ GlFormCheckbox,
+ GlButton,
+ ClipboardButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ tags: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDesktop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAGS_BUTTON_TITLE,
+ REMOVE_TAG_BUTTON_TITLE,
+ },
+ data() {
+ return {
+ selectedItems: [],
+ };
+ },
+ computed: {
+ fields() {
+ const tagClass = this.isDesktop ? 'w-25' : '';
+ const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
+ return [
+ { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
+ {
+ key: LIST_KEY_TAG,
+ label: LIST_LABEL_TAG,
+ class: `${tagClass} js-tag-column`,
+ innerClass: tagInnerClass,
+ },
+ { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
+ { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
+ { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
+ { key: LIST_KEY_ACTIONS, label: '' },
+ ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
+ },
+ tagsNames() {
+ return this.tags.map(t => t.name);
+ },
+ selectAllChecked() {
+ return this.selectedItems.length === this.tags.length && this.tags.length > 0;
+ },
+ },
+ watch: {
+ tagsNames: {
+ immediate: false,
+ handler(tagsNames) {
+ this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t));
+ },
+ },
+ },
+ methods: {
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ layers(layers) {
+ return layers ? n__('%d layer', '%d layers', layers) : '';
+ },
+ onSelectAllChange() {
+ if (this.selectAllChecked) {
+ this.selectedItems = [];
+ } else {
+ this.selectedItems = this.tags.map(x => x.name);
+ }
+ },
+ updateSelectedItems(name) {
+ const delIndex = this.selectedItems.findIndex(x => x === name);
+
+ if (delIndex > -1) {
+ this.selectedItems.splice(delIndex, 1);
+ } else {
+ this.selectedItems.push(name);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading">
+ <template v-if="isDesktop" #head(checkbox)>
+ <gl-form-checkbox
+ data-testid="mainCheckbox"
+ :checked="selectAllChecked"
+ @change="onSelectAllChange"
+ />
+ </template>
+ <template #head(actions)>
+ <span class="gl-display-flex gl-justify-content-end">
+ <gl-button
+ v-gl-tooltip
+ data-testid="bulkDeleteButton"
+ :disabled="!selectedItems || selectedItems.length === 0"
+ icon="remove"
+ variant="danger"
+ :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
+ @click="$emit('delete', selectedItems)"
+ />
+ </span>
+ </template>
+
+ <template #cell(checkbox)="{item}">
+ <gl-form-checkbox
+ data-testid="rowCheckbox"
+ :checked="selectedItems.includes(item.name)"
+ @change="updateSelectedItems(item.name)"
+ />
+ </template>
+ <template #cell(name)="{item, field}">
+ <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']">
+ <span
+ v-gl-tooltip
+ data-testid="rowNameText"
+ :title="item.name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ >
+ {{ item.name }}
+ </span>
+ <clipboard-button
+ v-if="item.location"
+ data-testid="rowClipboardButton"
+ :title="item.location"
+ :text="item.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </div>
+ </template>
+ <template #cell(short_revision)="{value}">
+ <span data-testid="rowShortRevision">
+ {{ value }}
+ </span>
+ </template>
+ <template #cell(total_size)="{item}">
+ <span data-testid="rowSize">
+ {{ formatSize(item.total_size) }}
+ <template v-if="item.total_size && item.layers">
+ &middot;
+ </template>
+ {{ layers(item.layers) }}
+ </span>
+ </template>
+ <template #cell(created_at)="{value}">
+ <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)">
+ {{ timeFormatted(value) }}
+ </span>
+ </template>
+ <template #cell(actions)="{item}">
+ <span class="gl-display-flex gl-justify-content-end">
+ <gl-button
+ data-testid="singleDeleteButton"
+ :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
+ :disabled="!item.destroy_path"
+ variant="danger"
+ icon="remove"
+ category="secondary"
+ @click="$emit('delete', [item.name])"
+ />
+ </span>
+ </template>
+
+ <template #empty>
+ <slot name="empty"></slot>
+ </template>
+ <template #table-busy>
+ <slot name="loader"></slot>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index f1252b24f6a..cd878c38081 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -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)"
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 31dbc1c8203..598e643ce1a 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -1,41 +1,17 @@
<script>
-import { mapState, mapActions, mapGetters } from 'vuex';
-import {
- GlTable,
- GlFormCheckbox,
- GlDeprecatedButton,
- GlIcon,
- GlTooltipDirective,
- GlPagination,
- GlEmptyState,
- GlResizeObserverDirective,
- GlSkeletonLoader,
-} from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { n__ } from '~/locale';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
+import TagsTable from '../components/details_page/tags_table.vue';
+import TagsLoader from '../components/details_page/tags_loader.vue';
+import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
+
import { decodeAndParse } from '../utils';
import {
- LIST_KEY_TAG,
- LIST_KEY_IMAGE_ID,
- LIST_KEY_SIZE,
- LIST_KEY_LAST_UPDATED,
- LIST_KEY_ACTIONS,
- LIST_KEY_CHECKBOX,
- LIST_LABEL_TAG,
- LIST_LABEL_IMAGE_ID,
- LIST_LABEL_SIZE,
- LIST_LABEL_LAST_UPDATED,
- REMOVE_TAGS_BUTTON_TITLE,
- REMOVE_TAG_BUTTON_TITLE,
- EMPTY_IMAGE_REPOSITORY_TITLE,
- EMPTY_IMAGE_REPOSITORY_MESSAGE,
ALERT_SUCCESS_TAG,
ALERT_DANGER_TAG,
ALERT_SUCCESS_TAGS,
@@ -46,66 +22,29 @@ export default {
components: {
DeleteAlert,
DetailsHeader,
- GlTable,
- GlFormCheckbox,
- GlDeprecatedButton,
- GlIcon,
- ClipboardButton,
GlPagination,
DeleteModal,
- GlSkeletonLoader,
- GlEmptyState,
+ TagsTable,
+ TagsLoader,
+ EmptyTagsState,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
},
- mixins: [timeagoMixin, Tracking.mixin()],
- loader: {
- repeat: 10,
- width: 1000,
- height: 40,
- },
- i18n: {
- REMOVE_TAGS_BUTTON_TITLE,
- REMOVE_TAG_BUTTON_TITLE,
- EMPTY_IMAGE_REPOSITORY_TITLE,
- EMPTY_IMAGE_REPOSITORY_MESSAGE,
- },
+ mixins: [Tracking.mixin()],
data() {
return {
- selectedItems: [],
itemsToBeDeleted: [],
- selectAllChecked: false,
- modalDescription: null,
isDesktop: true,
deleteAlertType: null,
};
},
computed: {
- ...mapGetters(['tags']),
- ...mapState(['tagsPagination', 'isLoading', 'config']),
+ ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
imageName() {
const { name } = decodeAndParse(this.$route.params.id);
return name;
},
- fields() {
- const tagClass = this.isDesktop ? 'w-25' : '';
- const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
- return [
- { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
- {
- key: LIST_KEY_TAG,
- label: LIST_LABEL_TAG,
- class: `${tagClass} js-tag-column`,
- innerClass: tagInnerClass,
- },
- { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
- { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
- { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
- { key: LIST_KEY_ACTIONS, label: '' },
- ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
- },
tracking() {
return {
label:
@@ -126,48 +65,8 @@ export default {
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
- formatSize(size) {
- return numberToHumanSize(size);
- },
- layers(layers) {
- return layers ? n__('%d layer', '%d layers', layers) : '';
- },
- onSelectAllChange() {
- if (this.selectAllChecked) {
- this.deselectAll();
- } else {
- this.selectAll();
- }
- },
- selectAll() {
- this.selectedItems = this.tags.map(x => x.name);
- this.selectAllChecked = true;
- },
- deselectAll() {
- this.selectedItems = [];
- this.selectAllChecked = false;
- },
- updateSelectedItems(name) {
- const delIndex = this.selectedItems.findIndex(x => x === name);
-
- if (delIndex > -1) {
- this.selectedItems.splice(delIndex, 1);
- this.selectAllChecked = false;
- } else {
- this.selectedItems.push(name);
-
- if (this.selectedItems.length === this.tags.length) {
- this.selectAllChecked = true;
- }
- }
- },
- deleteSingleItem(name) {
- this.itemsToBeDeleted = [{ ...this.tags.find(t => t.name === name) }];
- this.track('click_button');
- this.$refs.deleteModal.show();
- },
- deleteMultipleItems() {
- this.itemsToBeDeleted = this.selectedItems.map(name => ({
+ deleteTags(toBeDeletedList) {
+ this.itemsToBeDeleted = toBeDeletedList.map(name => ({
...this.tags.find(t => t.name === name),
}));
this.track('click_button');
@@ -176,7 +75,6 @@ export default {
handleSingleDelete() {
const [itemToDelete] = this.itemsToBeDeleted;
this.itemsToBeDeleted = [];
- this.selectedItems = this.selectedItems.filter(name => name !== itemToDelete.name);
return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAG;
@@ -188,7 +86,6 @@ export default {
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
- this.selectedItems = [];
return this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => x.name),
@@ -227,116 +124,14 @@ export default {
<details-header :image-name="imageName" />
- <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
- <template v-if="isDesktop" #head(checkbox)>
- <gl-form-checkbox
- ref="mainCheckbox"
- :checked="selectAllChecked"
- @change="onSelectAllChange"
- />
- </template>
- <template #head(actions)>
- <gl-deprecated-button
- ref="bulkDeleteButton"
- v-gl-tooltip
- :disabled="!selectedItems || selectedItems.length === 0"
- class="float-right"
- variant="danger"
- :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
- :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
- @click="deleteMultipleItems()"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </template>
-
- <template #cell(checkbox)="{item}">
- <gl-form-checkbox
- ref="rowCheckbox"
- class="js-row-checkbox"
- :checked="selectedItems.includes(item.name)"
- @change="updateSelectedItems(item.name)"
- />
- </template>
- <template #cell(name)="{item, field}">
- <div ref="rowName" :class="[field.innerClass, 'gl-display-flex']">
- <span
- v-gl-tooltip
- data-testid="rowNameText"
- :title="item.name"
- class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
- >
- {{ item.name }}
- </span>
- <clipboard-button
- v-if="item.location"
- ref="rowClipboardButton"
- :title="item.location"
- :text="item.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- </div>
- </template>
- <template #cell(short_revision)="{value}">
- <span ref="rowShortRevision">
- {{ value }}
- </span>
- </template>
- <template #cell(total_size)="{item}">
- <span ref="rowSize">
- {{ formatSize(item.total_size) }}
- <template v-if="item.total_size && item.layers">
- &middot;
- </template>
- {{ layers(item.layers) }}
- </span>
- </template>
- <template #cell(created_at)="{value}">
- <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)">
- {{ timeFormatted(value) }}
- </span>
- </template>
- <template #cell(actions)="{item}">
- <gl-deprecated-button
- ref="singleDeleteButton"
- :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :disabled="!item.destroy_path"
- variant="danger"
- class="js-delete-registry float-right btn-inverted btn-border-color btn-icon"
- @click="deleteSingleItem(item.name)"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </template>
-
+ <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags">
<template #empty>
- <template v-if="isLoading">
- <gl-skeleton-loader
- v-for="index in $options.loader.repeat"
- :key="index"
- :width="$options.loader.width"
- :height="$options.loader.height"
- preserve-aspect-ratio="xMinYMax meet"
- >
- <rect width="15" x="0" y="12.5" height="15" rx="4" />
- <rect width="250" x="25" y="10" height="20" rx="4" />
- <circle cx="290" cy="20" r="10" />
- <rect width="100" x="315" y="10" height="20" rx="4" />
- <rect width="100" x="500" y="10" height="20" rx="4" />
- <rect width="100" x="630" y="10" height="20" rx="4" />
- <rect x="960" y="0" width="40" height="40" rx="4" />
- </gl-skeleton-loader>
- </template>
- <gl-empty-state
- v-else
- :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
- :svg-path="config.noContainersImage"
- :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
- class="mx-auto my-0"
- />
+ <empty-tags-state :no-containers-image="config.noContainersImage" />
+ </template>
+ <template #loader>
+ <tags-loader v-once />
</template>
- </gl-table>
+ </tags-table>
<gl-pagination
v-if="!isLoading"
diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js
index a371d0e6356..7b5d1bd6da3 100644
--- a/app/assets/javascripts/registry/explorer/stores/getters.js
+++ b/app/assets/javascripts/registry/explorer/stores/getters.js
@@ -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} .`;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index a1d652e42a8..330785c9319 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -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') }}
diff --git a/app/graphql/types/evidence_type.rb b/app/graphql/types/evidence_type.rb
new file mode 100644
index 00000000000..a2fc9953c67
--- /dev/null
+++ b/app/graphql/types/evidence_type.rb
@@ -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
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index 30c496f646d..3d8e5a93c68 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -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'
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index b23c4f71ffa..70a29333b3f 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -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,
diff --git a/app/models/badge.rb b/app/models/badge.rb
index 3400d6d407d..4339d419b48 100644
--- a/app/models/badge.rb
+++ b/app/models/badge.rb
@@ -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) }
diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb
index d3ff870e36a..2fcd1708cf4 100644
--- a/app/models/ci/build_dependencies.rb
+++ b/app/models/ci/build_dependencies.rb
@@ -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
diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb
index bf03b92259a..d215372bb45 100644
--- a/app/models/ci/freeze_period.rb
+++ b/app/models/ci/freeze_period.rb
@@ -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
diff --git a/app/models/event.rb b/app/models/event.rb
index 03a43a6e93c..9c0fcbb354b 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -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,
diff --git a/app/models/label.rb b/app/models/label.rb
index 652b5e23490..a9256e311ed 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -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) }
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 431a2ccf416..39b5627d626 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -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 }
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index fa2e0cb8198..833b27756ab 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -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
diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb
index 4886334c53c..689b5a44276 100644
--- a/app/models/project_metrics_setting.rb
+++ b/app/models/project_metrics_setting.rb
@@ -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 }
diff --git a/app/models/releases/evidence.rb b/app/models/releases/evidence.rb
index 124fd50f271..7c428f5ad03 100644
--- a/app/models/releases/evidence.rb
+++ b/app/models/releases/evidence.rb
@@ -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
+ 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)
+ ##
+ # 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.dig('release', 'milestones')&.each do |milestone|
+ milestone.delete('issues')
+ end
- safe_summary
+ safe_summary
+ end
end
end
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index e468d716239..6b1793a551f 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -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" }
diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb
index faaf7568c72..62a90025ce1 100644
--- a/app/presenters/snippet_presenter.rb
+++ b/app/presenters/snippet_presenter.rb
@@ -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
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index c06f572b52f..7aa7ea73639 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -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
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 92b6174795b..cd9765289a4 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -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
diff --git a/changelogs/unreleased/217570-improve-performance-for-blame-api.yml b/changelogs/unreleased/217570-improve-performance-for-blame-api.yml
new file mode 100644
index 00000000000..390e19dc333
--- /dev/null
+++ b/changelogs/unreleased/217570-improve-performance-for-blame-api.yml
@@ -0,0 +1,5 @@
+---
+title: Lazy load commit_date and authored_date on Commit
+merge_request: 34181
+author:
+type: performance
diff --git a/changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml b/changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml
new file mode 100644
index 00000000000..79ffa011f63
--- /dev/null
+++ b/changelogs/unreleased/217816-add-evidence-to-releases-graphql-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Add Evidence to Releases GraphQL endpoint
+merge_request: 33254
+author:
+type: added
diff --git a/changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml b/changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml
new file mode 100644
index 00000000000..9f01694f06a
--- /dev/null
+++ b/changelogs/unreleased/220954-replace-fa-file-image-o-with-gitlab-media-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Use GitLab SVG icon for file attacher action
+merge_request: 34196
+author:
+type: other
diff --git a/changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml b/changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml
new file mode 100644
index 00000000000..7c537b04d84
--- /dev/null
+++ b/changelogs/unreleased/Remove-addMilestone-logic-from-issue-models.yml
@@ -0,0 +1,5 @@
+---
+title: Remove addMilestone logic from issue model
+merge_request: 32235
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml b/changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml
new file mode 100644
index 00000000000..7e4bfe78cf6
--- /dev/null
+++ b/changelogs/unreleased/Remove-findAssignee-logic-from-issues-model.yml
@@ -0,0 +1,5 @@
+---
+title: Remove findAssignee logic from issue model
+merge_request: 32238
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml b/changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml
new file mode 100644
index 00000000000..a04939f68f4
--- /dev/null
+++ b/changelogs/unreleased/Remove-removeLabel-logic-from-issues-model.yml
@@ -0,0 +1,5 @@
+---
+title: Remove removeLabel logic from issue model
+merge_request: 32251
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml b/changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml
new file mode 100644
index 00000000000..18398999113
--- /dev/null
+++ b/changelogs/unreleased/Remove-removeLabels-logic-from-issues-model.yml
@@ -0,0 +1,5 @@
+---
+title: Remove removeLabels logic from issue model
+merge_request: 32252
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml b/changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml
new file mode 100644
index 00000000000..ba525900849
--- /dev/null
+++ b/changelogs/unreleased/Remove-removeMultipleIssues-logic-from-list-model.yml
@@ -0,0 +1,5 @@
+---
+title: Remove removeMultipleIssues logic from list model
+merge_request: 32254
+author: nuwe1
+type: other
diff --git a/changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml b/changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml
new file mode 100644
index 00000000000..89a3d978487
--- /dev/null
+++ b/changelogs/unreleased/al-217784-add-blobs-field-to-snippets-in-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Add blobs field to SnippetType in GraphQL
+merge_request: 33657
+author:
+type: changed
diff --git a/changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml b/changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml
new file mode 100644
index 00000000000..500b688f13f
--- /dev/null
+++ b/changelogs/unreleased/fix_default_path_when_creating_project_from_group_template.yml
@@ -0,0 +1,5 @@
+---
+title: Fix default path when creating project from group template
+merge_request: 30597
+author: Lee Tickett
+type: fixed
diff --git a/changelogs/unreleased/long-path-mr-bug.yml b/changelogs/unreleased/long-path-mr-bug.yml
new file mode 100644
index 00000000000..ab171f3da5d
--- /dev/null
+++ b/changelogs/unreleased/long-path-mr-bug.yml
@@ -0,0 +1,5 @@
+---
+title: Fix rendering of very long paths in merge request file tree
+merge_request: 34153
+author:
+type: fixed
diff --git a/config/application.rb b/config/application.rb
index 0752b6d15a3..b4fc89051f3 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -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
diff --git a/config/environments/development.rb b/config/environments/development.rb
index 25d57467060..9d4fc6ba5e9 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -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")
diff --git a/config/environments/production.rb b/config/environments/production.rb
index c03421040a3..393a274606e 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -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
diff --git a/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb b/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb
index 2db270d303c..76b00796d1a 100644
--- a/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb
+++ b/db/post_migrate/20200406102120_backfill_deployment_clusters_from_deployments.rb
@@ -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
diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md
index 3372d05bd6b..cd25fbf351b 100644
--- a/doc/administration/integration/plantuml.md
+++ b/doc/administration/integration/plantuml.md
@@ -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
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index 79afc5ac745..29f3a8d1942 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -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
diff --git a/doc/administration/job_logs.md b/doc/administration/job_logs.md
index cc922653e80..d3484536a76 100644
--- a/doc/administration/job_logs.md
+++ b/doc/administration/job_logs.md
@@ -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:**
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index b0cc72965aa..a4f4e366428 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -9972,6 +9972,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
"""
milestones(
@@ -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
@@ -11163,6 +11248,11 @@ type Snippet implements Noteable {
blob: SnippetBlob!
"""
+ Snippet blobs
+ """
+ blobs: [SnippetBlob!]!
+
+ """
Timestamp this snippet was created
"""
createdAt: Time!
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 10b34f6af91..c599ead2576 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -29244,6 +29244,59 @@
"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",
"args": [
@@ -29611,6 +29664,191 @@
},
{
"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",
"description": null,
"fields": [
@@ -32975,6 +33213,32 @@
"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",
"args": [
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 96bac1d3549..c2ec2eee136 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -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` |
diff --git a/doc/development/changelog.md b/doc/development/changelog.md
index 3f77da94e4e..5a8e05f888c 100644
--- a/doc/development/changelog.md
+++ b/doc/development/changelog.md
@@ -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
diff --git a/doc/user/group/iterations/index.md b/doc/user/group/iterations/index.md
new file mode 100644
index 00000000000..2704147dcdd
--- /dev/null
+++ b/doc/user/group/iterations/index.md
@@ -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. -->
diff --git a/lib/api/entities/releases/evidence.rb b/lib/api/entities/releases/evidence.rb
index 25b2bf6bf6f..01603a71dbf 100644
--- a/lib/api/entities/releases/evidence.rb
+++ b/lib/api/entities/releases/evidence.rb
@@ -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
diff --git a/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb b/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb
index c652a5bb3fc..e750b8ca374 100644
--- a/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb
+++ b/lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb
@@ -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'
diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb
index 4e83826b249..f76aacc2d19 100644
--- a/lib/gitlab/ci/trace.rb
+++ b/lib/gitlab/ci/trace.rb
@@ -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)
diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb
index a554dc0b667..17d0a62ba8c 100644
--- a/lib/gitlab/git/commit.rb
+++ b/lib/gitlab/git/commit.rb
@@ -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)
diff --git a/lib/gitlab/prometheus_client.rb b/lib/gitlab/prometheus_client.rb
index 9b626728171..213e3ba835d 100644
--- a/lib/gitlab/prometheus_client.rb
+++ b/lib/gitlab/prometheus_client.rb
@@ -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
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 53a2020dd08..01c3712abe4 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -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
diff --git a/lib/gitlab/usage_data_concerns/topology.rb b/lib/gitlab/usage_data_concerns/topology.rb
new file mode 100644
index 00000000000..ee1d9fb22a7
--- /dev/null
+++ b/lib/gitlab/usage_data_concerns/topology.rb
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 6a19d69ca6c..210f51a73db 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/rubocop/cop/default_scope.rb b/rubocop/cop/default_scope.rb
new file mode 100644
index 00000000000..39f8c8e9ed0
--- /dev/null
+++ b/rubocop/cop/default_scope.rb
@@ -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
diff --git a/spec/factories/evidences.rb b/spec/factories/evidences.rb
index 77116d8e9ed..dc9fc374103 100644
--- a/spec/factories/evidences.rb
+++ b/spec/factories/evidences.rb
@@ -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
diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb
index 7e3c1728f3c..505e9b004fa 100644
--- a/spec/features/groups/container_registry_spec.rb
+++ b/spec/features/groups/container_registry_spec.rb
@@ -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
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index fd5b4ec9345..1f608c3a337 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -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
diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb
index 19f2bf8107b..3d78fd6885d 100644
--- a/spec/features/projects/container_registry_spec.rb
+++ b/spec/features/projects/container_registry_spec.rb
@@ -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
diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js
index c9d9d296543..847464ed806 100644
--- a/spec/frontend/ide/components/ide_status_list_spec.js
+++ b/spec/frontend/ide/components/ide_status_list_spec.js
@@ -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', () => {
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
index a418fdeb572..ad27954cd10 100644
--- a/spec/frontend/ide/components/new_dropdown/upload_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -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,
});
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 28a08b0f346..614f62009ad 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -94,47 +94,24 @@ describe('RepoEditor', () => {
});
describe('when file is markdown', () => {
- beforeEach(done => {
- vm.file.previewMode = {
- id: 'markdown',
- previewTitle: 'Preview Markdown',
- };
-
- vm.$nextTick(done);
- });
-
- 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('Edit');
- expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
-
- done();
- });
- });
- });
-
- describe('when file is markdown and viewer mode is review', () => {
let mock;
- beforeEach(done => {
+ beforeEach(() => {
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);
+ 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(() => {
@@ -146,7 +123,7 @@ describe('RepoEditor', () => {
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[0].textContent.trim()).toBe('Edit');
expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
done();
@@ -155,8 +132,6 @@ describe('RepoEditor', () => {
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',
});
diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js
index 52870d7bc75..a14879112fd 100644
--- a/spec/frontend/ide/stores/modules/commit/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js
@@ -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';
diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js
index a032984ad43..d1eb4304c79 100644
--- a/spec/frontend/ide/stores/utils_spec.js
+++ b/spec/frontend/ide/stores/utils_spec.js
@@ -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',
},
],
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index d63e793c43f..76e0e435860 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -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', () => {
diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js
index c494033badd..85e680fe216 100644
--- a/spec/frontend/lib/utils/url_utility_spec.js
+++ b/spec/frontend/lib/utils/url_utility_spec.js
@@ -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
diff --git a/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap
new file mode 100644
index 00000000000..aeb49f88770
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap
@@ -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>
+`;
diff --git a/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js
index 6ef4dcf96b4..5d54986978b 100644
--- a/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/delete_alert_spec.js
@@ -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();
diff --git a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js
index b30818005c3..c77f7a54d34 100644
--- a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js
@@ -23,6 +23,11 @@ describe('Delete Modal', () => {
});
};
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
it('contains a GlModal', () => {
mountComponent();
expect(findModal().exists()).toBe(true);
diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
index ad2b8a12ecc..cb31efa428f 100644
--- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js
@@ -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);
diff --git a/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js b/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js
new file mode 100644
index 00000000000..da80c75a26a
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js
@@ -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',
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js
new file mode 100644
index 00000000000..b27d3e2c042
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/details_page/tags_loader_spec.js
@@ -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();
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js
new file mode 100644
index 00000000000..85cd2f7ebb5
--- /dev/null
+++ b/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js
@@ -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);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
index 5786bb1f869..5ce570fb65b 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
@@ -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);
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 6fa4448083a..316962c72be 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -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,204 +82,76 @@ 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('exists', () => {
- expect(findMainCheckbox().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);
- });
- });
-
- 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('has the empty state', () => {
+ expect(findEmptyTagsState().exists()).toBe(true);
});
- 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();
- });
- });
-
- 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);
- });
- });
+ it('has a skeleton loader', () => {
+ expect(findTagsLoader().exists()).toBe(true);
});
+ });
- describe('header delete button', () => {
- beforeEach(() => {
- mountComponent();
- });
+ describe('table', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
- it('exists', () => {
- mountComponent();
- expect(findBulkDeleteButton().exists()).toBe(true);
- });
+ it('exists', () => {
+ expect(findTagsTable().exists()).toBe(true);
+ });
- it('is disabled if no item is selected', () => {
- mountComponent();
- expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
+ it('has the correct props bound', () => {
+ expect(findTagsTable().props()).toMatchObject({
+ isDesktop: true,
+ isLoading: false,
+ tags: store.state.tags,
});
+ });
- 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('deleteEvent', () => {
+ describe('single item', () => {
+ beforeEach(() => {
+ findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
});
- });
- 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();
- 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');
+ it('maps the selection to itemToBeDeleted', () => {
+ expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]);
+ });
- expect(wrapper.vm.itemsToBeDeleted).toEqual(tagsListResponse.data);
- expect(DeleteModal.methods.show).toHaveBeenCalled();
+ it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
- label: 'bulk_registry_tag_delete',
+ label: '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', () => {
+ describe('multiple items', () => {
beforeEach(() => {
- mountComponent();
+ findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
});
- it('table header has class w-25', () => {
- expect(findFirsTagColumn().classes()).toContain('w-25');
+ it('open the modal', () => {
+ expect(DeleteModal.methods.show).toHaveBeenCalled();
});
- it('tag column has the mw-m class', () => {
- expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
+ it('maps the selection to itemToBeDeleted', () => {
+ expect(wrapper.vm.itemsToBeDeleted).toEqual(store.state.tags);
});
- });
- describe('on mobile viewport', () => {
- beforeEach(() => {
- mountComponent({
- data() {
- return { isDesktop: false };
- },
+ it('tracks a single delete event', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'bulk_registry_tag_delete',
});
});
-
- 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');
});
});
});
@@ -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([]);
- });
});
});
});
diff --git a/spec/frontend/registry/explorer/stores/getters_spec.js b/spec/frontend/registry/explorer/stores/getters_spec.js
index cd053ea8edc..4cab65d2bb0 100644
--- a/spec/frontend/registry/explorer/stores/getters_spec.js
+++ b/spec/frontend/registry/explorer/stores/getters_spec.js
@@ -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
diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js
index 0e178abfbed..d3518c36c82 100644
--- a/spec/frontend/registry/explorer/stubs.js
+++ b/spec/frontend/registry/explorer/stubs.js
@@ -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'],
+};
diff --git a/spec/graphql/types/evidence_type_spec.rb b/spec/graphql/types/evidence_type_spec.rb
new file mode 100644
index 00000000000..4a11f7bcda9
--- /dev/null
+++ b/spec/graphql/types/evidence_type_spec.rb
@@ -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
diff --git a/spec/graphql/types/release_type_spec.rb b/spec/graphql/types/release_type_spec.rb
index 402ad3a7447..feafe5ed519 100644
--- a/spec/graphql/types/release_type_spec.rb
+++ b/spec/graphql/types/release_type_spec.rb
@@ -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'] }
diff --git a/spec/graphql/types/snippet_type_spec.rb b/spec/graphql/types/snippet_type_spec.rb
index adc13d4d651..3c26a05986f 100644
--- a/spec/graphql/types/snippet_type_spec.rb
+++ b/spec/graphql/types/snippet_type_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/prometheus_client_spec.rb b/spec/lib/gitlab/prometheus_client_spec.rb
index 5a8da215788..749192e5795 100644
--- a/spec/lib/gitlab/prometheus_client_spec.rb
+++ b/spec/lib/gitlab/prometheus_client_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/usage_data_concerns/topology_spec.rb b/spec/lib/gitlab/usage_data_concerns/topology_spec.rb
new file mode 100644
index 00000000000..b9eed7a6192
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_concerns/topology_spec.rb
@@ -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
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index bc77b2e2bb5..d23739ee096 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -115,6 +115,10 @@ describe Gitlab::UsageData, :aggregate_failures do
)
end
+ it 'gathers topology data' do
+ expect(subject.keys).to include(:topology)
+ end
+
context 'with existing container expiration policies' do
let_it_be(:disabled) { create(:container_expiration_policy, enabled: false) }
let_it_be(:enabled) { create(:container_expiration_policy, enabled: true) }
@@ -278,88 +282,6 @@ describe Gitlab::UsageData, :aggregate_failures do
end
end
- describe '#topology_usage_data' do
- subject { 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(:aggregate)
- .with(func: 'avg', metric: 'node_memory_MemTotal_bytes', by: 'instance')
- .and_return({
- 'instance1' => 512,
- 'instance2' => 1024
- })
-
- expect(subject[:topology]).to eq({
- duration_s: 0,
- nodes: [
- {
- node_memory_total_bytes: 512
- },
- {
- node_memory_total_bytes: 1024
- }
- ]
- })
- end
- end
-
- context 'and no results are found' do
- it 'does not report anything' do
- expect_prometheus_api_to receive(:aggregate).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(subject[:topology]).to eq({ duration_s: 0 })
- end
- end
-
- def expect_prometheus_api_to(receive_matcher)
- expect_next_instance_of(Gitlab::PrometheusClient) do |client|
- expect(client).to receive_matcher
- end
- end
-
- def allow_prometheus_queries
- allow_next_instance_of(Gitlab::PrometheusClient) do |client|
- allow(client).to receive(:aggregate).and_return({})
- end
- end
- end
-
describe '#app_server_type' do
subject { described_class.app_server_type }
diff --git a/spec/models/project_metrics_setting_spec.rb b/spec/models/project_metrics_setting_spec.rb
index 7df01625ba1..c6f668a11bc 100644
--- a/spec/models/project_metrics_setting_spec.rb
+++ b/spec/models/project_metrics_setting_spec.rb
@@ -44,12 +44,12 @@ describe ProjectMetricsSetting do
it { is_expected.to be_valid }
end
- context 'external_dashboard_url is blank' do
- before do
- subject.external_dashboard_url = ''
- end
+ context 'dashboard_timezone' do
+ it { is_expected.to define_enum_for(:dashboard_timezone).with_values({ local: 0, utc: 1 }) }
- it { is_expected.to be_invalid }
+ it 'defaults to local' do
+ expect(subject.dashboard_timezone).to eq('local')
+ end
end
end
end
diff --git a/spec/presenters/snippet_presenter_spec.rb b/spec/presenters/snippet_presenter_spec.rb
index 591d86652b6..423e9edc219 100644
--- a/spec/presenters/snippet_presenter_spec.rb
+++ b/spec/presenters/snippet_presenter_spec.rb
@@ -163,4 +163,25 @@ describe SnippetPresenter do
end
end
end
+
+ describe '#blobs' do
+ let(:snippet) { personal_snippet }
+
+ subject { presenter.blobs }
+
+ context 'when snippet does not have a repository' do
+ it 'returns an array with one SnippetBlob' do
+ expect(subject.size).to eq(1)
+ expect(subject.first).to eq(snippet.blob)
+ end
+ end
+
+ context 'when snippet has a repository' do
+ let(:snippet) { create(:snippet, :repository, author: user) }
+
+ it 'returns an array with all repository blobs' do
+ expect(subject).to match_array(snippet.blobs)
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb
index f580c69cd44..f8624a97a2b 100644
--- a/spec/requests/api/graphql/project/release_spec.rb
+++ b/spec/requests/api/graphql/project/release_spec.rb
@@ -5,11 +5,12 @@ require 'pp'
describe 'Query.project(fullPath).release(tagName)' do
include GraphqlHelpers
+ include Presentable
let_it_be(:project) { create(:project, :repository) }
let_it_be(:milestone_1) { create(:milestone, project: project) }
let_it_be(:milestone_2) { create(:milestone, project: project) }
- let_it_be(:release) { create(:release, project: project, milestones: [milestone_1, milestone_2]) }
+ let_it_be(:release) { create(:release, :with_evidence, project: project, milestones: [milestone_1, milestone_2]) }
let_it_be(:release_link_1) { create(:release_link, release: release) }
let_it_be(:release_link_2) { create(:release_link, release: release) }
let_it_be(:developer) { create(:user) }
@@ -164,5 +165,42 @@ describe 'Query.project(fullPath).release(tagName)' do
expect(data).to match_array(expected)
end
end
+
+ describe 'evidences' do
+ let(:path) { path_prefix + %w[evidences] }
+ let(:release_fields) do
+ query_graphql_field(:evidences, nil, 'nodes { id sha filepath collectedAt }')
+ end
+
+ context 'for a developer' do
+ it 'finds all evidence fields' do
+ post_query
+
+ evidence = release.evidences.first.present
+ expected = {
+ 'id' => global_id_of(evidence),
+ 'sha' => evidence.sha,
+ 'filepath' => evidence.filepath,
+ 'collectedAt' => evidence.collected_at.utc.iso8601
+ }
+
+ expect(data["nodes"].first).to eq(expected)
+ end
+ end
+
+ context 'for a guest' do
+ let(:current_user) { create :user }
+
+ before do
+ project.add_guest(current_user)
+ end
+
+ it 'denies access' do
+ post_query
+
+ expect(data['node']).to be_nil
+ end
+ end
+ end
end
end
diff --git a/spec/rubocop/cop/default_scope_spec.rb b/spec/rubocop/cop/default_scope_spec.rb
new file mode 100644
index 00000000000..9520915f900
--- /dev/null
+++ b/spec/rubocop/cop/default_scope_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rubocop'
+require 'rubocop/rspec/support'
+require_relative '../../../rubocop/cop/default_scope'
+
+describe RuboCop::Cop::DefaultScope do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ it 'does not flag the use of default_scope with a send receiver' do
+ inspect_source('foo.default_scope')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+
+ it 'flags the use of default_scope with a constant receiver' do
+ inspect_source('User.default_scope')
+
+ expect(cop.offenses.size).to eq(1)
+ end
+
+ it 'flags the use of default_scope with a nil receiver' do
+ inspect_source('class Foo ; default_scope ; end')
+
+ expect(cop.offenses.size).to eq(1)
+ end
+
+ it 'flags the use of default_scope when passing arguments' do
+ inspect_source('class Foo ; default_scope(:foo) ; end')
+
+ expect(cop.offenses.size).to eq(1)
+ end
+
+ it 'flags the use of default_scope when passing a block' do
+ inspect_source('class Foo ; default_scope { :foo } ; end')
+
+ expect(cop.offenses.size).to eq(1)
+ end
+
+ it 'ignores the use of default_scope with a local variable receiver' do
+ inspect_source('users = User.all ; users.default_scope')
+
+ expect(cop.offenses.size).to eq(0)
+ end
+end
diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb
index e5842fb9085..f4d62b48fe5 100644
--- a/spec/services/projects/operations/update_service_spec.rb
+++ b/spec/services/projects/operations/update_service_spec.rb
@@ -126,21 +126,23 @@ describe Projects::Operations::UpdateService do
)
expect(project.metrics_setting.dashboard_timezone).to eq('utc')
end
+ end
- context 'with blank external_dashboard_url in params' do
- let(:params) do
- {
- metrics_setting_attributes: {
- external_dashboard_url: ''
- }
+ context 'with blank external_dashboard_url' do
+ let(:params) do
+ {
+ metrics_setting_attributes: {
+ external_dashboard_url: '',
+ dashboard_timezone: 'utc'
}
- end
+ }
+ end
- it 'destroys the metrics_setting entry in DB' do
- expect(result[:status]).to eq(:success)
+ it 'updates dashboard_timezone' do
+ expect(result[:status]).to eq(:success)
- expect(project.reload.metrics_setting).to be_nil
- end
+ expect(project.reload.metrics_setting.external_dashboard_url).to be(nil)
+ expect(project.metrics_setting.dashboard_timezone).to eq('utc')
end
end
end
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index 15e4c93def7..40779204fd3 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -220,4 +220,16 @@ module UsageDataHelpers
'proxy_download' => false } }
)
end
+
+ def expect_prometheus_api_to(*receive_matchers)
+ expect_next_instance_of(Gitlab::PrometheusClient) do |client|
+ receive_matchers.each { |m| expect(client).to m }
+ end
+ end
+
+ def allow_prometheus_queries
+ allow_next_instance_of(Gitlab::PrometheusClient) do |client|
+ allow(client).to receive(:aggregate).and_return({})
+ end
+ end
end