summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock2
-rw-r--r--app/assets/javascripts/gl_dropdown.js4
-rw-r--r--app/assets/javascripts/ide/components/ide.vue40
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue19
-rw-r--r--app/assets/javascripts/ide/stores/actions.js52
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js118
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js18
-rw-r--r--app/assets/javascripts/ide/stores/getters.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js11
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations/branch.js6
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js5
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue61
-rw-r--r--app/assets/javascripts/repository/index.js25
-rw-r--r--app/assets/javascripts/repository/queries/getProjectShortPath.graphql3
-rw-r--r--app/assets/javascripts/repository/utils/title.js2
-rw-r--r--app/assets/javascripts/visual_review_toolbar/index.js2
-rw-r--r--app/assets/javascripts/visual_review_toolbar/styles/toolbar.css149
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss4
-rw-r--r--app/controllers/admin/projects_controller.rb11
-rw-r--r--app/helpers/environments_helper.rb3
-rw-r--r--app/helpers/projects_helper.rb8
-rw-r--r--app/models/ci/build.rb4
-rw-r--r--app/models/ci/job_artifact.rb13
-rw-r--r--app/models/project.rb1
-rw-r--r--app/serializers/build_details_entity.rb5
-rw-r--r--app/serializers/job_artifact_report_entity.rb13
-rw-r--r--app/views/admin/projects/_projects.html.haml4
-rw-r--r--app/views/projects/_files.html.haml7
-rw-r--r--app/views/projects/_home_panel.html.haml2
-rw-r--r--app/views/projects/settings/operations/_external_dashboard.html.haml2
-rw-r--r--app/views/projects/tree/_readme.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml121
-rw-r--r--changelogs/unreleased/45687-web-ide-empty-state.yml5
-rw-r--r--changelogs/unreleased/61639-flaky-spec-issue-boards-labels-creates-project-label-spec-features-boards-sidebar_spec-rb-350.yml5
-rw-r--r--config/gitlab.yml.example2
-rw-r--r--config/initializers/1_settings.rb1
-rw-r--r--config/initializers/7_prometheus_metrics.rb8
-rw-r--r--config/initializers/rack_timeout.rb23
-rw-r--r--config/routes/admin.rb2
-rw-r--r--config/webpack.config.review_toolbar.js58
-rw-r--r--doc/administration/high_availability/nfs.md14
-rw-r--r--doc/administration/integration/plantuml.md2
-rw-r--r--doc/administration/job_artifacts.md3
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md18
-rw-r--r--doc/gitlab-basics/README.md2
-rw-r--r--doc/gitlab-basics/create-your-ssh-keys.md30
-rw-r--r--doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.pngbin10534 -> 0 bytes
-rw-r--r--doc/ssh/README.md19
-rw-r--r--doc/user/admin_area/index.md24
-rw-r--r--doc/user/application_security/security_dashboard/index.md1
-rw-r--r--doc/user/profile/personal_access_tokens.md1
-rw-r--r--doc/user/project/settings/index.md6
-rw-r--r--lib/api/entities.rb1
-rw-r--r--lib/api/helpers/issues_helpers.rb8
-rw-r--r--lib/api/helpers/protected_branches_helpers.rb13
-rw-r--r--lib/api/issues.rb14
-rw-r--r--lib/api/protected_branches.rb26
-rw-r--r--lib/gitlab/cluster/lifecycle_events.rb8
-rw-r--r--lib/gitlab/metrics/samplers/puma_sampler.rb92
-rw-r--r--lib/gitlab/path_regex.rb1
-rw-r--r--lib/gitlab/rack_timeout_observer.rb46
-rw-r--r--lib/tasks/gitlab/assets.rake6
-rw-r--r--locale/gitlab.pot21
-rw-r--r--package.json3
-rw-r--r--spec/features/boards/sidebar_spec.rb4
-rw-r--r--spec/features/projects/files/project_owner_creates_license_file_spec.rb1
-rw-r--r--spec/features/projects/files/user_creates_files_spec.rb1
-rw-r--r--spec/features/projects/show/user_sees_collaboration_links_spec.rb1
-rw-r--r--spec/frontend/ide/stores/mutations/branch_spec.js35
-rw-r--r--spec/frontend/ide/stores/mutations/project_spec.js23
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js44
-rw-r--r--spec/helpers/environments_helper_spec.rb49
-rw-r--r--spec/helpers/projects_helper_spec.rb22
-rw-r--r--spec/javascripts/ide/components/ide_spec.js92
-rw-r--r--spec/javascripts/ide/components/ide_tree_list_spec.js59
-rw-r--r--spec/javascripts/ide/stores/actions/project_spec.js203
-rw-r--r--spec/javascripts/ide/stores/actions/tree_spec.js32
-rw-r--r--spec/javascripts/ide/stores/actions_spec.js64
-rw-r--r--spec/javascripts/ide/stores/modules/commit/actions_spec.js59
-rw-r--r--spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb96
-rw-r--r--spec/lib/gitlab/rack_timeout_observer_spec.rb58
-rw-r--r--spec/models/ci/build_spec.rb12
-rw-r--r--spec/models/ci/job_artifact_spec.rb15
-rw-r--r--spec/serializers/build_details_entity_spec.rb9
-rw-r--r--spec/serializers/job_artifact_report_entity_spec.rb28
-rw-r--r--vendor/assets/javascripts/visual_review_toolbar.js (renamed from public/visual-review-toolbar.js)355
88 files changed, 1747 insertions, 700 deletions
diff --git a/Gemfile b/Gemfile
index f5f963bb2ff..66348414506 100644
--- a/Gemfile
+++ b/Gemfile
@@ -154,6 +154,7 @@ end
group :puma do
gem 'puma', '~> 3.12', require: false
gem 'puma_worker_killer', require: false
+ gem 'rack-timeout', require: false
end
# State machine
diff --git a/Gemfile.lock b/Gemfile.lock
index 9e922d8a3bb..3f85fa958bd 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -680,6 +680,7 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
+ rack-timeout (0.5.1)
rails (5.1.7)
actioncable (= 5.1.7)
actionmailer (= 5.1.7)
@@ -1174,6 +1175,7 @@ DEPENDENCIES
rack-cors (~> 1.0.0)
rack-oauth2 (~> 1.9.3)
rack-proxy (~> 0.6.0)
+ rack-timeout
rails (= 5.1.7)
rails-controller-testing
rails-i18n (~> 5.1)
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 6a4c1aab308..a143d79097b 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -335,6 +335,10 @@ GitLabDropdown = (function() {
_this.fullData = data;
_this.parseData(_this.fullData);
_this.focusTextInput();
+
+ // Update dropdown position since remote data may have changed dropdown size
+ _this.dropdown.find('.dropdown-menu-toggle').dropdown('update');
+
if (
_this.options.filterable &&
_this.filter &&
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 9894ebb0624..e41b1530226 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,7 @@
<script>
import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
@@ -22,6 +23,8 @@ export default {
FindFile,
ErrorMessage,
CommitEditorHeader,
+ GlButton,
+ GlLoadingIcon,
},
props: {
rightPaneComponent: {
@@ -47,13 +50,15 @@ export default {
'someUncommittedChanges',
'isCommitModeActive',
'allBlobs',
+ 'emptyRepo',
+ 'currentTree',
]),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
},
methods: {
- ...mapActions(['toggleFileFinder']),
+ ...mapActions(['toggleFileFinder', 'openNewEntryModal']),
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
@@ -98,17 +103,40 @@ export default {
<repo-editor :file="activeFile" class="multi-file-edit-pane-content" />
</template>
<template v-else>
- <div v-once class="ide-empty-state">
+ <div class="ide-empty-state">
<div class="row js-empty-state">
<div class="col-12">
<div class="svg-content svg-250"><img :src="emptyStateSvgPath" /></div>
</div>
<div class="col-12">
<div class="text-content text-center">
- <h4>Welcome to the GitLab IDE</h4>
- <p>
- Select a file from the left sidebar to begin editing. Afterwards, you'll be able
- to commit your changes.
+ <h4>
+ {{ __('Make and review changes in the browser with the Web IDE') }}
+ </h4>
+ <template v-if="emptyRepo">
+ <p>
+ {{
+ __(
+ "Create a new file as there are no files yet. Afterwards, you'll be able to commit your changes.",
+ )
+ }}
+ </p>
+ <gl-button
+ variant="success"
+ :title="__('New file')"
+ :aria-label="__('New file')"
+ @click="openNewEntryModal({ type: 'blob' })"
+ >
+ {{ __('New file') }}
+ </gl-button>
+ </template>
+ <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" />
+ <p v-else>
+ {{
+ __(
+ "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes.",
+ )
+ }}
</p>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 81374f26645..95782b2c88a 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -54,14 +54,17 @@ export default {
<slot name="header"></slot>
</header>
<div class="ide-tree-body h-100">
- <file-row
- v-for="file in currentTree.tree"
- :key="file.key"
- :file="file"
- :level="0"
- :extra-component="$options.FileRowExtra"
- @toggleTreeOpen="toggleTreeOpen"
- />
+ <template v-if="currentTree.tree.length">
+ <file-row
+ v-for="file in currentTree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ :extra-component="$options.FileRowExtra"
+ @toggleTreeOpen="toggleTreeOpen"
+ />
+ </template>
+ <div v-else class="file-row">{{ __('No files') }}</div>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index fd678e6e10c..dc8ca732879 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,12 +1,15 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
+import _ from 'underscore';
import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
+import service from '../services';
-export const redirectToUrl = (_, url) => visitUrl(url);
+export const redirectToUrl = (self, url) => visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
@@ -239,6 +242,53 @@ export const renameEntry = (
}
};
+export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) =>
+ new Promise((resolve, reject) => {
+ const currentProject = state.projects[projectId];
+ if (!currentProject || !currentProject.branches[branchId] || force) {
+ service
+ .getBranchData(projectId, branchId)
+ .then(({ data }) => {
+ const { id } = data.commit;
+ commit(types.SET_BRANCH, {
+ projectPath: projectId,
+ branchName: branchId,
+ branch: data,
+ });
+ commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
+ resolve(data);
+ })
+ .catch(e => {
+ if (e.response.status === 404) {
+ reject(e);
+ } else {
+ flash(
+ __('Error loading branch data. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+
+ reject(
+ new Error(
+ sprintf(
+ __('Branch not loaded - %{branchId}'),
+ {
+ branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
+ },
+ false,
+ ),
+ ),
+ );
+ }
+ });
+ } else {
+ resolve(currentProject.branches[branchId]);
+ }
+ });
+
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 4b10d148ebf..dd8f17e4f3a 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -35,48 +35,6 @@ export const getProjectData = ({ commit, state }, { namespace, projectId, force
}
});
-export const getBranchData = (
- { commit, dispatch, state },
- { projectId, branchId, force = false } = {},
-) =>
- new Promise((resolve, reject) => {
- if (
- typeof state.projects[`${projectId}`] === 'undefined' ||
- !state.projects[`${projectId}`].branches[branchId] ||
- force
- ) {
- service
- .getBranchData(`${projectId}`, branchId)
- .then(({ data }) => {
- const { id } = data.commit;
- commit(types.SET_BRANCH, {
- projectPath: `${projectId}`,
- branchName: branchId,
- branch: data,
- });
- commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
- resolve(data);
- })
- .catch(e => {
- if (e.response.status === 404) {
- dispatch('showBranchNotFoundError', branchId);
- } else {
- flash(
- __('Error loading branch data. Please try again.'),
- 'alert',
- document,
- null,
- false,
- true,
- );
- }
- reject(new Error(`Branch not loaded - ${projectId}/${branchId}`));
- });
- } else {
- resolve(state.projects[`${projectId}`].branches[branchId]);
- }
- });
-
export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) =>
service
.getBranchData(projectId, branchId)
@@ -125,40 +83,66 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => {
});
};
-export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath }) => {
- dispatch('setCurrentBranchId', branchId);
-
- dispatch('getBranchData', {
- projectId,
- branchId,
+export const showEmptyState = ({ commit, state }, { projectId, branchId }) => {
+ const treePath = `${projectId}/${branchId}`;
+ commit(types.CREATE_TREE, { treePath });
+ commit(types.TOGGLE_LOADING, {
+ entry: state.trees[treePath],
+ forceValue: false,
});
+};
- return dispatch('getFiles', {
+export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => {
+ dispatch('setCurrentBranchId', branchId);
+
+ if (getters.emptyRepo) {
+ return dispatch('showEmptyState', { projectId, branchId });
+ }
+ return dispatch('getBranchData', {
projectId,
branchId,
})
.then(() => {
- if (basePath) {
- const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
- const treeEntryKey = Object.keys(state.entries).find(
- key => key === path && !state.entries[key].pending,
- );
- const treeEntry = state.entries[treeEntryKey];
-
- if (treeEntry) {
- dispatch('handleTreeEntryAction', treeEntry);
- } else {
- dispatch('createTempEntry', {
- name: path,
- type: 'blob',
- });
- }
- }
- })
- .then(() => {
dispatch('getMergeRequestsForBranch', {
projectId,
branchId,
});
+ dispatch('getFiles', {
+ projectId,
+ branchId,
+ })
+ .then(() => {
+ if (basePath) {
+ const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
+ const treeEntryKey = Object.keys(state.entries).find(
+ key => key === path && !state.entries[key].pending,
+ );
+ const treeEntry = state.entries[treeEntryKey];
+
+ if (treeEntry) {
+ dispatch('handleTreeEntryAction', treeEntry);
+ } else {
+ dispatch('createTempEntry', {
+ name: path,
+ type: 'blob',
+ });
+ }
+ }
+ })
+ .catch(
+ () =>
+ new Error(
+ sprintf(
+ __('An error occurred whilst getting files for - %{branchId}'),
+ {
+ branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`,
+ },
+ false,
+ ),
+ ),
+ );
+ })
+ .catch(() => {
+ dispatch('showBranchNotFoundError', branchId);
});
};
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 3d83e4a9ba5..75511574d3e 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -74,17 +74,13 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } =
resolve();
})
.catch(e => {
- if (e.response.status === 404) {
- dispatch('showBranchNotFoundError', branchId);
- } else {
- dispatch('setErrorMessage', {
- text: __('An error occurred whilst loading all the files.'),
- action: payload =>
- dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)),
- actionText: __('Please try again'),
- actionPayload: { projectId, branchId },
- });
- }
+ dispatch('setErrorMessage', {
+ text: __('An error occurred whilst loading all the files.'),
+ action: payload =>
+ dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)),
+ actionText: __('Please try again'),
+ actionPayload: { projectId, branchId },
+ });
reject(e);
});
} else {
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 490658a4543..7a88ac5b116 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -36,6 +36,9 @@ export const currentMergeRequest = state => {
export const currentProject = state => state.projects[state.currentProjectId];
+export const emptyRepo = state =>
+ state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo;
+
export const currentTree = state =>
state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index c2760eb1554..f81bdb8a30e 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -135,6 +135,17 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
return null;
}
+ if (!data.parent_ids.length) {
+ commit(
+ rootTypes.TOGGLE_EMPTY_STATE,
+ {
+ projectPath: rootState.currentProjectId,
+ value: false,
+ },
+ { root: true },
+ );
+ }
+
dispatch('setLastCommitMessage', data);
dispatch('updateCommitMessage', '');
return dispatch('updateFilesAfterCommit', {
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index a5f8098dc17..86ab76136df 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -12,6 +12,7 @@ export const SET_LINKS = 'SET_LINKS';
export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
+export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
// Merge Request Mutation Types
export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST';
diff --git a/app/assets/javascripts/ide/stores/mutations/branch.js b/app/assets/javascripts/ide/stores/mutations/branch.js
index e09f88878f4..6afd8de2aa4 100644
--- a/app/assets/javascripts/ide/stores/mutations/branch.js
+++ b/app/assets/javascripts/ide/stores/mutations/branch.js
@@ -19,6 +19,12 @@ export default {
});
},
[types.SET_BRANCH_WORKING_REFERENCE](state, { projectId, branchId, reference }) {
+ if (!state.projects[projectId].branches[branchId]) {
+ Object.assign(state.projects[projectId].branches, {
+ [branchId]: {},
+ });
+ }
+
Object.assign(state.projects[projectId].branches[branchId], {
workingReference: reference,
});
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
index 284b39a2c72..9230f3839c1 100644
--- a/app/assets/javascripts/ide/stores/mutations/project.js
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -21,4 +21,9 @@ export default {
}),
});
},
+ [types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) {
+ Object.assign(state.projects[projectPath], {
+ empty_repo: value,
+ });
+ },
};
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
new file mode 100644
index 00000000000..6eca015036f
--- /dev/null
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -0,0 +1,61 @@
+<script>
+import getRefMixin from '../mixins/get_ref';
+import getProjectShortPath from '../queries/getProjectShortPath.graphql';
+
+export default {
+ apollo: {
+ projectShortPath: {
+ query: getProjectShortPath,
+ },
+ },
+ mixins: [getRefMixin],
+ props: {
+ currentPath: {
+ type: String,
+ required: false,
+ default: '/',
+ },
+ },
+ data() {
+ return {
+ projectShortPath: '',
+ };
+ },
+ computed: {
+ pathLinks() {
+ return this.currentPath
+ .split('/')
+ .filter(p => p !== '')
+ .reduce(
+ (acc, name, i) => {
+ const path = `${i > 0 ? acc[i].path : ''}/${name}`;
+
+ return acc.concat({
+ name,
+ path,
+ to: `/tree/${this.ref}${path}`,
+ });
+ },
+ [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}` }],
+ );
+ },
+ },
+ methods: {
+ isLast(i) {
+ return i === this.pathLinks.length - 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <nav :aria-label="__('Files breadcrumb')">
+ <ol class="breadcrumb repo-breadcrumb">
+ <li v-for="(link, i) in pathLinks" :key="i" class="breadcrumb-item">
+ <router-link :to="link.to" :aria-current="isLast(i) ? 'page' : null">
+ {{ link.name }}
+ </router-link>
+ </li>
+ </ol>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index f992d4b6d54..52f53be045b 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,24 +1,27 @@
import Vue from 'vue';
import createRouter from './router';
import App from './components/app.vue';
+import Breadcrumbs from './components/breadcrumbs.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
- const { projectPath, ref, fullName } = el.dataset;
+ const { projectPath, projectShortPath, ref, fullName } = el.dataset;
const router = createRouter(projectPath, ref);
apolloProvider.clients.defaultClient.cache.writeData({
data: {
projectPath,
+ projectShortPath,
ref,
},
});
- router.afterEach(({ params: { pathMatch } }) => setTitle(pathMatch, ref, fullName));
- router.afterEach(to => {
- const isRoot = to.params.pathMatch === undefined || to.params.pathMatch === '/';
+ router.afterEach(({ params: { pathMatch } }) => {
+ const isRoot = pathMatch === undefined || pathMatch === '/';
+
+ setTitle(pathMatch, ref, fullName);
if (!isRoot) {
document
@@ -31,6 +34,20 @@ export default function setupVueRepositoryList() {
.forEach(elem => elem.classList.toggle('hidden', !isRoot));
});
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: document.getElementById('js-repo-breadcrumb'),
+ router,
+ apolloProvider,
+ render(h) {
+ return h(Breadcrumbs, {
+ props: {
+ currentPath: this.$route.params.pathMatch,
+ },
+ });
+ },
+ });
+
return new Vue({
el,
router,
diff --git a/app/assets/javascripts/repository/queries/getProjectShortPath.graphql b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql
new file mode 100644
index 00000000000..34eb26598c2
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getProjectShortPath.graphql
@@ -0,0 +1,3 @@
+query getProjectShortPath {
+ projectShortPath @client
+}
diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js
index 3c3e918c0a8..4e194640e92 100644
--- a/app/assets/javascripts/repository/utils/title.js
+++ b/app/assets/javascripts/repository/utils/title.js
@@ -1,5 +1,7 @@
// eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => {
+ if (!pathMatch) return;
+
const path = pathMatch.replace(/^\//, '');
const isEmpty = path === '';
diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js
new file mode 100644
index 00000000000..91d0382feac
--- /dev/null
+++ b/app/assets/javascripts/visual_review_toolbar/index.js
@@ -0,0 +1,2 @@
+import './styles/toolbar.css';
+import 'vendor/visual_review_toolbar';
diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
new file mode 100644
index 00000000000..342b3599a44
--- /dev/null
+++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
@@ -0,0 +1,149 @@
+/*
+ As a standalone script, the toolbar has its own css
+ */
+
+#gitlab-collapse > * {
+ pointer-events: none;
+}
+
+#gitlab-form-wrapper {
+ display: flex;
+ flex-direction: column;
+ width: 100%
+}
+
+#gitlab-review-container {
+ max-width: 22rem;
+ max-height: 22rem;
+ overflow: scroll;
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ display: flex;
+ flex-direction: row-reverse;
+ padding: 1rem;
+ background-color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
+ 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
+ 'Noto Color Emoji';
+ font-size: .8rem;
+ font-weight: 400;
+ color: #2e2e2e;
+}
+
+.gitlab-open-wrapper {
+ max-width: 22rem;
+ max-height: 22rem;
+}
+
+.gitlab-closed-wrapper {
+ max-width: 3.4rem;
+ max-height: 3.4rem;
+}
+
+.gitlab-button {
+ cursor: pointer;
+ transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear;
+}
+
+.gitlab-button-secondary {
+ background: none #fff;
+ margin: 0 .5rem;
+ border: 1px solid #e3e3e3;
+}
+
+.gitlab-button-secondary:hover {
+ background-color: #f0f0f0;
+ border-color: #e3e3e3;
+ color: #2e2e2e;
+}
+
+.gitlab-button-secondary:active {
+ color: #2e2e2e;
+ background-color: #e1e1e1;
+ border-color: #dadada;
+}
+
+.gitlab-button-success:hover {
+ color: #fff;
+ background-color: #137e3f;
+ border-color: #127339;
+}
+
+.gitlab-button-success:active {
+ background-color: #168f48;
+ border-color: #12753a;
+ color: #fff;
+}
+
+.gitlab-button-success {
+ background-color: #1aaa55;
+ border: 1px solid #168f48;
+ color: #fff;
+}
+
+.gitlab-button-wide {
+ width: 100%;
+}
+
+.gitlab-button-wrapper {
+ margin-top: 1rem;
+ display: flex;
+ align-items: baseline;
+ justify-content: flex-end;
+}
+
+.gitlab-collapse {
+ width: 2.4rem;
+ height: 2.2rem;
+ margin-left: 1rem;
+ padding: .5rem;
+}
+
+.gitlab-collapse-closed {
+ align-self: center;
+}
+
+.gitlab-checkbox-label {
+ padding: 0 .2rem;
+}
+
+.gitlab-checkbox-wrapper {
+ display: flex;
+ align-items: baseline;
+}
+
+.gitlab-label {
+ font-weight: 600;
+ display: inline-block;
+ width: 100%;
+}
+
+.gitlab-link {
+ color: #1b69b6;
+ text-decoration: none;
+ background-color: transparent;
+ background-image: none;
+}
+
+.gitlab-message {
+ padding: .25rem 0;
+ margin: 0;
+ line-height: 1.2rem;
+}
+
+.gitlab-metadata-note {
+ font-size: .7rem;
+ line-height: 1rem;
+ color: #666;
+ margin-bottom: 0;
+}
+
+.gitlab-input {
+ width: 100%;
+ border: 1px solid #dfdfdf;
+ border-radius: 4px;
+ padding: .1rem .2rem;
+ min-height: 2rem;
+ max-width: 17rem;
+}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 8fb4027bf97..cd951f67293 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -570,10 +570,10 @@
}
.dropdown-menu-close {
- right: 5px;
+ top: $gl-padding-4;
+ right: $gl-padding-8;
width: 20px;
height: 20px;
- top: -1px;
}
.dropdown-menu-close-icon {
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index aeff0c96b64..70db15916b9 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -3,7 +3,7 @@
class Admin::ProjectsController < Admin::ApplicationController
include MembersPresentation
- before_action :project, only: [:show, :transfer, :repository_check]
+ before_action :project, only: [:show, :transfer, :repository_check, :destroy]
before_action :group, only: [:show, :transfer]
def index
@@ -35,6 +35,15 @@ class Admin::ProjectsController < Admin::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ def destroy
+ ::Projects::DestroyService.new(@project, current_user, {}).async_execute
+ flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.full_name }
+
+ redirect_to admin_projects_path, status: :found
+ rescue Projects::DestroyService::DestroyError => ex
+ redirect_to admin_projects_path, status: 302, alert: ex.message
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def transfer
namespace = Namespace.find_by(id: params[:new_namespace_id])
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 365b94f5a3e..8002eb08ada 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -30,7 +30,8 @@ module EnvironmentsHelper
"environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project),
"tags-path" => project_tags_path(project),
- "has-metrics" => "#{environment.has_metrics?}"
+ "has-metrics" => "#{environment.has_metrics?}",
+ "external-dashboard-url" => project.metrics_setting_external_dashboard_url
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index f798bfbf703..e587cf4045d 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -343,6 +343,10 @@ module ProjectsHelper
description.html_safe % { project_name: project.name }
end
+ def metrics_external_dashboard_url
+ @project.metrics_setting_external_dashboard_url
+ end
+
private
def get_project_nav_tabs(project, current_user)
@@ -655,4 +659,8 @@ module ProjectsHelper
project.builds_enabled? &&
!project.repository.gitlab_ci_yml
end
+
+ def vue_file_list_enabled?
+ Gitlab::Graphql.enabled? && Feature.enabled?(:vue_file_list, @project)
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 1b67a7272bc..56786fae6ea 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -765,6 +765,10 @@ module Ci
end
end
+ def report_artifacts
+ job_artifacts.with_reports
+ end
+
# Virtual deployment status depending on the environment status.
def deployment_status
return unless starts_environment?
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 3beb76ffc2b..0dbeab30498 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -26,10 +26,13 @@ module Ci
metrics: 'metrics.txt'
}.freeze
- TYPE_AND_FORMAT_PAIRS = {
+ INTERNAL_TYPES = {
archive: :zip,
metadata: :gzip,
- trace: :raw,
+ trace: :raw
+ }.freeze
+
+ REPORT_TYPES = {
junit: :gzip,
metrics: :gzip,
@@ -45,6 +48,8 @@ module Ci
performance: :raw
}.freeze
+ TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
+
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
@@ -66,6 +71,10 @@ module Ci
where(file_type: types)
end
+ scope :with_reports, -> do
+ with_file_types(REPORT_TYPES.keys.map(&:to_s))
+ end
+
scope :test_reports, -> do
with_file_types(TEST_REPORT_FILE_TYPES)
end
diff --git a/app/models/project.rb b/app/models/project.rb
index ab4da61dcf8..20895923d3b 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -309,6 +309,7 @@ class Project < ApplicationRecord
delegate :group_clusters_enabled?, to: :group, allow_nil: true
delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
+ delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
# Validations
validates :creator, presence: true, on: :create
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 6928968edc0..67e44ee9d10 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -42,6 +42,11 @@ class BuildDetailsEntity < JobEntity
end
end
+ expose :report_artifacts,
+ as: :reports,
+ using: JobArtifactReportEntity,
+ if: -> (*) { can?(current_user, :read_build, build) }
+
expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity
expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :erase_build, build) } do |build|
erase_project_job_path(project, build)
diff --git a/app/serializers/job_artifact_report_entity.rb b/app/serializers/job_artifact_report_entity.rb
new file mode 100644
index 00000000000..4280351a6b0
--- /dev/null
+++ b/app/serializers/job_artifact_report_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class JobArtifactReportEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :file_type
+ expose :file_format
+ expose :size
+
+ expose :download_path do |artifact|
+ download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_format)
+ end
+end
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 9117f63f939..2f7ad35eb3e 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -7,7 +7,7 @@
= link_to 'Edit', edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn"
%button.delete-project-button.btn.btn-danger{ data: { toggle: 'modal',
target: '#delete-project-modal',
- delete_project_url: project_path(project),
+ delete_project_url: admin_project_path(project),
project_name: project.name }, type: 'button' }
= s_('AdminProjects|Delete')
@@ -17,7 +17,7 @@
- if project.archived
%span.badge.badge-warning archived
.title
- = link_to(admin_namespace_project_path(project.namespace, project)) do
+ = link_to(admin_project_path(project)) do
.dash-project-avatar
.avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 7f50a7e4294..2b0c3985755 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -4,7 +4,6 @@
- project = local_assigns.fetch(:project) { @project }
- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) }
- show_auto_devops_callout = show_auto_devops_callout?(@project)
-- vue_file_list = Feature.enabled?(:vue_file_list, @project)
#tree-holder.tree-holder.clearfix
.nav-block
@@ -14,11 +13,11 @@
= render 'shared/commit_well', commit: commit, ref: ref, project: project
- if is_project_overview
- .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list) }
+ .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) }
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- - if vue_file_list
- #js-tree-list{ data: { project_path: @project.full_path, full_name: @project.name_with_namespace, ref: ref } }
+ - if vue_file_list_enabled?
+ #js-tree-list{ data: { project_path: @project.full_path, project_short_path: @project.path, ref: ref, full_name: @project.name_with_namespace } }
- if @tree.readme
= render "projects/tree/readme", readme: @tree.readme
- else
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 6de8848d3a1..9f5241344a7 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,7 +1,7 @@
- empty_repo = @project.empty_repo?
- show_auto_devops_callout = show_auto_devops_callout?(@project)
- max_project_topic_length = 15
-.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if Feature.enabled?(:vue_file_list, @project))] }
+.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] }
.row.append-bottom-8
.home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml
index 2fbb9195a04..f049c35b38d 100644
--- a/app/views/projects/settings/operations/_external_dashboard.html.haml
+++ b/app/views/projects/settings/operations/_external_dashboard.html.haml
@@ -1,2 +1,2 @@
-.js-operation-settings{ data: { external_dashboard: { path: '',
+.js-operation-settings{ data: { external_dashboard: { path: metrics_external_dashboard_url,
help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } }
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index e935af23659..4f6c7e1f9a6 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,5 +1,5 @@
- if readme.rich_viewer
- %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if Feature.enabled?(:vue_file_list, @project))] }
+ %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if vue_file_list_enabled?)] }
.js-file-title.file-title
= blob_icon readme.mode, readme.name
= link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index ec8e5234bd4..ea6349f2f57 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -6,71 +6,74 @@
= render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true
- if on_top_of_branch?
- - addtotree_toggle_attributes = { href: '#', 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' }
+ - addtotree_toggle_attributes = { 'data-toggle': 'dropdown', 'data-target': '.add-to-tree-dropdown', 'data-boundary': 'window' }
- else
- addtotree_toggle_attributes = { title: _("You can only add files when you are on a branch"), data: { container: 'body' }, class: 'disabled has-tooltip' }
- %ul.breadcrumb.repo-breadcrumb
- %li.breadcrumb-item
- = link_to project_tree_path(@project, @ref) do
- = @project.path
- - path_breadcrumbs do |title, path|
+ - if vue_file_list_enabled?
+ #js-repo-breadcrumb
+ - else
+ %ul.breadcrumb.repo-breadcrumb
%li.breadcrumb-item
- = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
+ = link_to project_tree_path(@project, @ref) do
+ = @project.path
+ - path_breadcrumbs do |title, path|
+ %li.breadcrumb-item
+ = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path))
- - if can_collaborate || can_create_mr_from_fork
- %li.breadcrumb-item
- %a.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes }
- = sprite_icon('plus', size: 16, css_class: 'float-left')
- = sprite_icon('arrow-down', size: 16, css_class: 'float-left')
- - if on_top_of_branch?
- .add-to-tree-dropdown
- %ul.dropdown-menu
- - if can_edit_tree?
- %li.dropdown-header
- #{ _('This directory') }
- %li
- = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do
- #{ _('New file') }
- %li
- = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
- #{ _('Upload file') }
- %li
- = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
- #{ _('New directory') }
- - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
- %li
- - continue_params = { to: project_new_blob_path(@project, @id),
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New file') }
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to upload a file again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('Upload file') }
- %li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to create a new directory again.",
- notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
- = link_to fork_path, method: :post do
- #{ _('New directory') }
+ - if can_collaborate || can_create_mr_from_fork
+ %li.breadcrumb-item
+ %button.btn.add-to-tree.qa-add-to-tree{ addtotree_toggle_attributes, type: 'button' }
+ = sprite_icon('plus', size: 16, css_class: 'float-left')
+ = sprite_icon('arrow-down', size: 16, css_class: 'float-left')
+ - if on_top_of_branch?
+ .add-to-tree-dropdown
+ %ul.dropdown-menu
+ - if can_edit_tree?
+ %li.dropdown-header
+ #{ _('This directory') }
+ %li
+ = link_to project_new_blob_path(@project, @id), class: 'qa-new-file-option' do
+ #{ _('New file') }
+ %li
+ = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do
+ #{ _('Upload file') }
+ %li
+ = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do
+ #{ _('New directory') }
+ - elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
+ %li
+ - continue_params = { to: project_new_blob_path(@project, @id),
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('New file') }
+ %li
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to upload a file again.",
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('Upload file') }
+ %li
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to create a new directory again.",
+ notice_now: edit_in_new_fork_notice_now }
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
+ = link_to fork_path, method: :post do
+ #{ _('New directory') }
- - if can?(current_user, :push_code, @project)
- %li.divider
- %li.dropdown-header
- #{ _('This repository') }
- %li
- = link_to new_project_branch_path(@project) do
- #{ _('New branch') }
- %li
- = link_to new_project_tag_path(@project) do
- #{ _('New tag') }
+ - if can?(current_user, :push_code, @project)
+ %li.divider
+ %li.dropdown-header
+ #{ _('This repository') }
+ %li
+ = link_to new_project_branch_path(@project) do
+ #{ _('New branch') }
+ %li
+ = link_to new_project_tag_path(@project) do
+ #{ _('New tag') }
.tree-controls
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
diff --git a/changelogs/unreleased/45687-web-ide-empty-state.yml b/changelogs/unreleased/45687-web-ide-empty-state.yml
new file mode 100644
index 00000000000..9ef148275ab
--- /dev/null
+++ b/changelogs/unreleased/45687-web-ide-empty-state.yml
@@ -0,0 +1,5 @@
+---
+title: Empty project state for Web IDE
+merge_request: 26556
+author:
+type: added
diff --git a/changelogs/unreleased/61639-flaky-spec-issue-boards-labels-creates-project-label-spec-features-boards-sidebar_spec-rb-350.yml b/changelogs/unreleased/61639-flaky-spec-issue-boards-labels-creates-project-label-spec-features-boards-sidebar_spec-rb-350.yml
new file mode 100644
index 00000000000..9b4f13353f5
--- /dev/null
+++ b/changelogs/unreleased/61639-flaky-spec-issue-boards-labels-creates-project-label-spec-features-boards-sidebar_spec-rb-350.yml
@@ -0,0 +1,5 @@
+---
+title: Fix dropdown position when loading remote data
+merge_request: 28526
+author:
+type: fixed
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index 23377b43f78..c83f569d885 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -752,6 +752,8 @@ production: &base
monitoring:
# Time between sampling of unicorn socket metrics, in seconds
# unicorn_sampler_interval: 10
+ # Time between sampling of Puma metrics, in seconds
+ # puma_sampler_interval: 5
# IP whitelist to access monitoring endpoints
ip_whitelist:
- 127.0.0.0/8
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index d56bd7654af..0c8d94ccaed 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -491,6 +491,7 @@ Settings.webpack.dev_server['port'] ||= 3808
Settings['monitoring'] ||= Settingslogic.new({})
Settings.monitoring['ip_whitelist'] ||= ['127.0.0.1/8']
Settings.monitoring['unicorn_sampler_interval'] ||= 10
+Settings.monitoring['puma_sampler_interval'] ||= 5
Settings.monitoring['ruby_sampler_interval'] ||= 60
Settings.monitoring['sidekiq_exporter'] ||= Settingslogic.new({})
Settings.monitoring.sidekiq_exporter['enabled'] ||= false
diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb
index 8052880cc3d..68f8487d377 100644
--- a/config/initializers/7_prometheus_metrics.rb
+++ b/config/initializers/7_prometheus_metrics.rb
@@ -29,12 +29,18 @@ if !Rails.env.test? && Gitlab::Metrics.prometheus_metrics_enabled?
Gitlab::Cluster::LifecycleEvents.on_worker_start do
defined?(::Prometheus::Client.reinitialize_on_pid_change) && Prometheus::Client.reinitialize_on_pid_change
- unless Sidekiq.server?
+ if defined?(::Unicorn)
Gitlab::Metrics::Samplers::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start
end
Gitlab::Metrics::Samplers::RubySampler.initialize_instance(Settings.monitoring.ruby_sampler_interval).start
end
+
+ if defined?(::Puma)
+ Gitlab::Cluster::LifecycleEvents.on_master_start do
+ Gitlab::Metrics::Samplers::PumaSampler.initialize_instance(Settings.monitoring.puma_sampler_interval).start
+ end
+ end
end
Gitlab::Cluster::LifecycleEvents.on_master_restart do
diff --git a/config/initializers/rack_timeout.rb b/config/initializers/rack_timeout.rb
new file mode 100644
index 00000000000..5c4f2dd708c
--- /dev/null
+++ b/config/initializers/rack_timeout.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+# Unicorn terminates any request which runs longer than 60 seconds.
+# Puma doesn't have any timeout mechanism for terminating long-running
+# requests, to make sure that server is not paralyzed by long-running
+# or stuck queries, we add a request timeout which terminates the
+# request after 60 seconds. This may be dangerous in some situations
+# (https://github.com/heroku/rack-timeout/blob/master/doc/exceptions.md)
+# and it's used only as the last resort. In such case this termination is
+# logged and we should fix the potential timeout issue in the code itself.
+
+if defined?(::Puma) && !Rails.env.test?
+ require 'rack/timeout/base'
+
+ Gitlab::Application.configure do |config|
+ config.middleware.insert_before(Rack::Runtime, Rack::Timeout,
+ service_timeout: 60,
+ wait_timeout: 90)
+ end
+
+ observer = Gitlab::RackTimeoutObserver.new
+ Rack::Timeout.register_state_change_observer(:gitlab_rack_timeout, &observer.callback)
+end
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index bc19219a0b8..ae79beb1dba 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -83,7 +83,7 @@ namespace :admin do
resources(:projects,
path: '/',
constraints: { id: Gitlab::PathRegex.project_route_regex },
- only: [:show]) do
+ only: [:show, :destroy]) do
member do
put :transfer
diff --git a/config/webpack.config.review_toolbar.js b/config/webpack.config.review_toolbar.js
new file mode 100644
index 00000000000..baaba7ed387
--- /dev/null
+++ b/config/webpack.config.review_toolbar.js
@@ -0,0 +1,58 @@
+const path = require('path');
+const CompressionPlugin = require('compression-webpack-plugin');
+
+const ROOT_PATH = path.resolve(__dirname, '..');
+const CACHE_PATH = process.env.WEBPACK_CACHE_PATH || path.join(ROOT_PATH, 'tmp/cache');
+const NO_SOURCEMAPS = process.env.NO_SOURCEMAPS;
+const IS_PRODUCTION = process.env.NODE_ENV === 'production';
+
+const devtool = IS_PRODUCTION ? 'source-map' : 'cheap-module-eval-source-map';
+
+const alias = {
+ vendor: path.join(ROOT_PATH, 'vendor/assets/javascripts'),
+ spec: path.join(ROOT_PATH, 'spec/javascripts'),
+};
+
+module.exports = {
+ mode: IS_PRODUCTION ? 'production' : 'development',
+
+ context: path.join(ROOT_PATH, 'app/assets/javascripts'),
+
+ name: 'visual_review_toolbar',
+
+ entry: './visual_review_toolbar',
+
+ output: {
+ path: path.join(ROOT_PATH, 'public/assets/webpack'),
+ filename: 'visual_review_toolbar.js',
+ library: 'VisualReviewToolbar',
+ libraryTarget: 'var',
+ },
+
+ resolve: {
+ alias,
+ },
+
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ loader: 'babel-loader',
+ options: {
+ cacheDirectory: path.join(CACHE_PATH, 'babel-loader'),
+ },
+ },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ ],
+ },
+
+ plugins: [
+ // compression can require a lot of compute time and is disabled in CI
+ new CompressionPlugin(),
+ ].filter(Boolean),
+
+ devtool: NO_SOURCEMAPS ? false : devtool,
+};
diff --git a/doc/administration/high_availability/nfs.md b/doc/administration/high_availability/nfs.md
index caadec3ac4e..d1233d815ed 100644
--- a/doc/administration/high_availability/nfs.md
+++ b/doc/administration/high_availability/nfs.md
@@ -39,13 +39,8 @@ options:
### Improving NFS performance with GitLab
-NOTE: **Note:**
-This is only available starting in certain versions of GitLab:
- * 11.5.11
- * 11.6.11
- * 11.7.12
- * 11.8.8
- * 11.9.0 and up (e.g. 11.10, 11.11, etc.)
+NOTE: **Note:** This is only available starting in certain versions of GitLab: 11.5.11,
+11.6.11, 11.7.12, 11.8.8, 11.9.0 and up (e.g. 11.10, 11.11, etc.)
If you are using NFS to share Git data, we recommend that you enable a
number of feature flags that will allow GitLab application processes to
@@ -107,6 +102,11 @@ stored on a local volume.
For more details on another person's experience with EFS, see
[Amazon's Elastic File System: Burst Credits](https://rawkode.com/2017/04/16/amazons-elastic-file-system-burst-credits/)
+## Avoid using CephFS and GlusterFS
+
+GitLab strongly recommends against using CephFS and GlusterFS.
+These distributed file systems are not well-suited for GitLab's input/output access patterns because git uses many small files and access times and file locking times to propagate will make git activity very slow.
+
## Avoid using PostgreSQL with NFS
GitLab strongly recommends against running your PostgreSQL database
diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md
index b7b820abb40..82e0c14ffc2 100644
--- a/doc/administration/integration/plantuml.md
+++ b/doc/administration/integration/plantuml.md
@@ -27,7 +27,7 @@ own PlantUML server is easy in Debian/Ubuntu distributions using Tomcat.
First you need to create a `plantuml.war` file from the source code:
```
-sudo apt-get install graphviz openjdk-7-jdk git-core maven
+sudo apt-get install graphviz openjdk-8-jdk git-core maven
git clone https://github.com/plantuml/plantuml-server.git
cd plantuml-server
mvn package
diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md
index e7792106f81..ef370573a98 100644
--- a/doc/administration/job_artifacts.md
+++ b/doc/administration/job_artifacts.md
@@ -100,6 +100,9 @@ artifacts, you can use an object storage like AWS S3 instead.
This configuration relies on valid AWS credentials to be configured already.
Use an object storage option like AWS S3 to store job artifacts.
+DANGER: **Danger:**
+If you're enabling S3 in [GitLab HA](high_availability/README.md), you will need to have an [NFS mount set up for CI traces and artifacts](high_availability/nfs.md#a-single-nfs-mount) or enable [live tracing](job_traces.md#new-live-trace-architecture). If these settings are not set, you will risk job traces disappearing or not being saved.
+
### Object Storage Settings
For source installations the following settings are nested under `artifacts:` and then `object_store:`. On omnibus installs they are prefixed by `artifacts_object_store_`.
diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md
index c243dd9edbb..4529bd3bee3 100644
--- a/doc/administration/monitoring/prometheus/gitlab_metrics.md
+++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md
@@ -103,6 +103,24 @@ Some basic Ruby runtime metrics are available:
[GC.stat]: https://ruby-doc.org/core-2.3.0/GC.html#method-c-stat
+## Puma Metrics **[EXPERIMENTAL]**
+
+When Puma is used instead of Unicorn, following metrics are available:
+
+| Metric | Type | Since | Description |
+|:-------------------------------------------- |:------- |:----- |:----------- |
+| puma_workers | Gauge | 12.0 | Total number of workers |
+| puma_running_workers | Gauge | 12.0 | Number of booted workers |
+| puma_stale_workers | Gauge | 12.0 | Number of old workers |
+| puma_phase | Gauge | 12.0 | Phase number (increased during phased restarts) |
+| puma_running | Gauge | 12.0 | Number of running threads |
+| puma_queued_connections | Gauge | 12.0 | Number of connections in that worker's "todo" set waiting for a worker thread |
+| puma_active_connections | Gauge | 12.0 | Number of threads processing a request |
+| puma_pool_capacity | Gauge | 12.0 | Number of requests the worker is capable of taking right now |
+| puma_max_threads | Gauge | 12.0 | Maximum number of worker threads |
+| puma_idle_threads | Gauge | 12.0 | Number of spawned threads which are not processing a request |
+| rack_state_total | Gauge | 12.0 | Number of requests in a given rack state |
+
## Metrics shared directory
GitLab's Prometheus client requires a directory to store metrics data shared between multi-process services.
diff --git a/doc/gitlab-basics/README.md b/doc/gitlab-basics/README.md
index 28ba20bec09..00ac6d6b650 100644
--- a/doc/gitlab-basics/README.md
+++ b/doc/gitlab-basics/README.md
@@ -15,7 +15,7 @@ This documentation is split into the following groups:
The following are guides to basic GitLab functionality:
-- [Create and add your SSH Keys](create-your-ssh-keys.md), for enabling Git over SSH.
+- [Create and add your SSH public key](create-your-ssh-keys.md), for enabling Git over SSH.
- [Create a project](create-project.md), to start using GitLab.
- [Create a group](../user/group/index.md#create-a-new-group), to combine and administer projects together.
- [Create a branch](create-branch.md), to make changes to files stored in a project's repository.
diff --git a/doc/gitlab-basics/create-your-ssh-keys.md b/doc/gitlab-basics/create-your-ssh-keys.md
index 8fecdc6948e..aac73d4c9c5 100644
--- a/doc/gitlab-basics/create-your-ssh-keys.md
+++ b/doc/gitlab-basics/create-your-ssh-keys.md
@@ -1,22 +1,22 @@
-# How to create your SSH keys
+# Create and add your SSH public key
-This topic describes how to create SSH keys. You do this to use Git over SSH instead of Git over HTTP.
+This topic describes how to:
-## Creating your SSH keys
+- Create an SSH key pair to use with GitLab.
+- Add the SSH public key to your GitLab account.
-1. Go to your [command line](start-using-git.md) and follow the [instructions](../ssh/README.md) to generate your SSH key pair.
-1. Log in to GitLab.
-1. In the upper-right corner, click your avatar and select **Settings**.
-1. On the **User Settings** menu, select **SSH keys**.
-1. Paste the **public** key generated in the first step in the **Key**
- text field.
-1. Optionally, give it a descriptive title so that you can recognize it in the
- event you add multiple keys.
-1. Finally, click the **Add key** button to add it to GitLab. You will be able to see
- its fingerprint, title, and creation date.
+You do this to use [Git over SSH instead of Git over HTTP](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols).
- ![SSH key single page](img/profile_settings_ssh_keys_single_key.png)
+## Creating your SSH key pair
+
+1. Go to your [command line](start-using-git.md).
+1. Follow the [instructions](../ssh/README.md#generating-a-new-ssh-key-pair) to generate your SSH key pair.
+
+## Adding your SSH public key to GitLab
+
+To add the SSH public key to GitLab,
+see [Adding an SSH key to your GitLab account](../ssh/README.md#adding-an-ssh-key-to-your-gitlab-account).
NOTE: **Note:**
Once you add a key, you cannot edit it. If the paste
-didn't work, you need to remove the offending key and re-add it.
+[didn't work](../ssh/README.md#testing-that-everything-is-set-up-correctly), you need to remove the key and re-add it.
diff --git a/doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.png b/doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.png
deleted file mode 100644
index 8014f1d5301..00000000000
--- a/doc/gitlab-basics/img/profile_settings_ssh_keys_single_key.png
+++ /dev/null
Binary files differ
diff --git a/doc/ssh/README.md b/doc/ssh/README.md
index 9c4a391e8da..3bfebfc5d9b 100644
--- a/doc/ssh/README.md
+++ b/doc/ssh/README.md
@@ -55,7 +55,7 @@ As an admin, you can restrict
By default, all keys are permitted, which is also the case for
[GitLab.com](../user/gitlab_com/index.md#ssh-host-keys-fingerprints).
-## ED25519 SSH keys
+### ED25519 SSH keys
Following [best practices](https://linux-audit.com/using-ed25519-openssh-keys-instead-of-dsa-rsa-ecdsa/),
you should always favor [ED25519](https://ed25519.cr.yp.to/) SSH keys, since they
@@ -65,7 +65,7 @@ They were introduced in OpenSSH 6.5, so any modern OS should include the
option to create them. If for any reason your OS or the GitLab instance you
interact with doesn't support this, you can fallback to RSA.
-## RSA SSH keys
+### RSA SSH keys
RSA keys are the most common ones and therefore the most compatible with
servers that may have an old OpenSSH version. Use them if the GitLab server
@@ -166,12 +166,13 @@ Now, it's time to add the newly created public key to your GitLab account.
NOTE: **Note:**
If you opted to create an RSA key, the name might differ.
-1. Add your public SSH key to your GitLab account by clicking your avatar
- in the upper right corner and selecting **Settings**. From there on,
- navigate to **SSH Keys** and paste your public key in the "Key" section.
- If you created the key with a comment, this will appear under "Title".
- If not, give your key an identifiable title like _Work Laptop_ or
- _Home Workstation_, and click **Add key**.
+1. Add your **public** SSH key to your GitLab account by:
+ 1. Clicking your avatar in the upper right corner and selecting **Settings**.
+ 1. Navigating to **SSH Keys** and pasting your **public** key in the **Key** field. If you:
+
+ - Created the key with a comment, this will appear in the **Title** field.
+ - Created the key without a comment, give your key an identifiable title like _Work Laptop_ or _Home Workstation_.
+ 1. Click the **Add key** button.
NOTE: **Note:**
If you manually copied your public SSH key make sure you copied the entire
@@ -305,7 +306,7 @@ who needs to know and configure the private key.
GitLab administrators set up Global Deploy keys in the Admin area under the
section **Deploy Keys**. Ensure keys have a meaningful title as that will be
the primary way for project maintainers and owners to identify the correct Global
-Deploy key to add. For instance, if the key gives access to a SaaS CI instance,
+Deploy key to add. For instance, if the key gives access to a SaaS CI instance,
use the name of that service in the key name if that is all it is used for.
When creating Global Shared Deploy keys, give some thought to the granularity
of keys - they could be of very narrow usage such as just a specific service or
diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md
index 4ed1287abbc..db5da26ca0a 100644
--- a/doc/user/admin_area/index.md
+++ b/doc/user/admin_area/index.md
@@ -114,3 +114,27 @@ To change the sort order, click the sort dropdown and select the desired order.
To search for users, enter your criteria in the search field. The user search is case
insensitive, and applies partial matching to name and username. To search for an email address,
you must provide the complete email address.
+
+## Administer Jobs
+
+You can administer all jobs in the GitLab instance from the Admin Area's Jobs page.
+
+To access the Jobs page, go to **Admin Area > Overview > Jobs**.
+
+All jobs are listed, in reverse order of their job ID.
+
+Click the **All** tab to list all jobs. Click the **Pending**, **Running**, or **Finished** tab to list only jobs of that status.
+
+For each job, the following details are listed:
+
+| Field | Description |
+|--------- | ----------- |
+| Status | Job status, either **passed**, **skipped**, or **failed**. |
+| Job | Includes links to the job, branch, and the commit that started the job. |
+| Pipeline | Includes a link to the specific pipeline. |
+| Project | Name of the project, and organization, to which the job belongs. |
+| Runner | Name of the CI runner assigned to execute the job. |
+| Stage | Stage that the job is declared in a `.gitlab-ci.yml` file. |
+| Name | Name of the job specified in a `.gitlab-ci.yml` file. |
+| Timing | Duration of the job, and how long ago the job completed. |
+| Coverage | Percentage of tests coverage. |
diff --git a/doc/user/application_security/security_dashboard/index.md b/doc/user/application_security/security_dashboard/index.md
index ca31e15c65f..19eeb06a259 100644
--- a/doc/user/application_security/security_dashboard/index.md
+++ b/doc/user/application_security/security_dashboard/index.md
@@ -54,6 +54,7 @@ First, navigate to the Security Dashboard found under your group's
Once you're on the dashboard, at the top you should see a series of filters for:
- Severity
+- Confidence
- Report type
- Project
diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md
index 4085f3b678c..5166524d13b 100644
--- a/doc/user/profile/personal_access_tokens.md
+++ b/doc/user/profile/personal_access_tokens.md
@@ -49,6 +49,7 @@ the following table.
[2fa]: ../account/two_factor_authentication.md
[api]: ../../api/README.md
[ce-3749]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3749
+[ce-5951]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5951
[ce-14838]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14838
[container registry]: ../project/container_registry.md
[users]: ../../api/users.md
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index 99dd018a3ba..737dea1de6c 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -100,9 +100,9 @@ Only project Owners and Admin users have the [permissions] to transfer a project
You can transfer an existing project into a [group](../../group/index.md) if:
-1. you have at least **Maintainer** [permissions] to that group
-1. you are an **Owner** of the project.
-
+1. You have at least **Maintainer** [permissions] to that group.
+1. The project is in a subgroup you own.
+1. You are at least a **Maintainer** of the project under your personal namespace.
Similarly, if you are an owner of a group, you can transfer any of its projects
under your own user.
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 96a1ccefbe5..3d7cf2c0bb1 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -239,6 +239,7 @@ module API
end
end
+ expose :empty_repo?, as: :empty_repo
expose :archived?, as: :archived
expose :visibility
expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group }
diff --git a/lib/api/helpers/issues_helpers.rb b/lib/api/helpers/issues_helpers.rb
index fc66cec5341..5b7199fddb0 100644
--- a/lib/api/helpers/issues_helpers.rb
+++ b/lib/api/helpers/issues_helpers.rb
@@ -3,6 +3,14 @@
module API
module Helpers
module IssuesHelpers
+ extend Grape::API::Helpers
+
+ params :optional_issue_params_ee do
+ end
+
+ params :optional_issues_params_ee do
+ end
+
def self.update_params_at_least_one_of
[
:assignee_id,
diff --git a/lib/api/helpers/protected_branches_helpers.rb b/lib/api/helpers/protected_branches_helpers.rb
new file mode 100644
index 00000000000..0fc6841d79a
--- /dev/null
+++ b/lib/api/helpers/protected_branches_helpers.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module API
+ module Helpers
+ module ProtectedBranchesHelpers
+ extend ActiveSupport::Concern
+ extend Grape::API::Helpers
+
+ params :optional_params_ee do
+ end
+ end
+ end
+end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index 0b4da01f3c8..56960a2eb64 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -9,16 +9,6 @@ module API
before { authenticate_non_get! }
helpers do
- if Gitlab.ee?
- params :issues_params_ee do
- optional :weight, types: [Integer, String], integer_none_any: true, desc: 'The weight of the issue'
- end
-
- params :issue_params_ee do
- optional :weight, type: Integer, desc: 'The weight of the issue'
- end
- end
-
params :issues_stats_params do
optional :labels, type: Array[String], coerce_with: Validations::Types::LabelsList.coerce, desc: 'Comma-separated list of label names'
optional :milestone, type: String, desc: 'Milestone title'
@@ -47,7 +37,7 @@ module API
optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji'
optional :confidential, type: Boolean, desc: 'Filter confidential or public issues'
- use :issues_params_ee if Gitlab.ee?
+ use :optional_issues_params_ee
end
params :issues_params do
@@ -73,7 +63,7 @@ module API
optional :confidential, type: Boolean, desc: 'Boolean parameter if the issue should be confidential'
optional :discussion_locked, type: Boolean, desc: " Boolean parameter indicating if the issue's discussion is locked"
- use :issue_params_ee if Gitlab.ee?
+ use :optional_issue_params_ee
end
end
diff --git a/lib/api/protected_branches.rb b/lib/api/protected_branches.rb
index f8cce1ed784..33dea25289a 100644
--- a/lib/api/protected_branches.rb
+++ b/lib/api/protected_branches.rb
@@ -8,6 +8,8 @@ module API
before { authorize_admin_project }
+ helpers Helpers::ProtectedBranchesHelpers
+
params do
requires :id, type: String, desc: 'The ID of a project'
end
@@ -52,29 +54,7 @@ module API
values: ProtectedBranch::MergeAccessLevel.allowed_access_levels,
desc: 'Access levels allowed to merge (defaults: `40`, maintainer access level)'
- if Gitlab.ee?
- optional :unprotect_access_level, type: Integer,
- values: ProtectedBranch::UnprotectAccessLevel.allowed_access_levels,
- desc: 'Access levels allowed to unprotect (defaults: `40`, maintainer access level)'
-
- optional :allowed_to_push, type: Array, desc: 'An array of users/groups allowed to push' do
- optional :access_level, type: Integer, values: ProtectedBranch::PushAccessLevel.allowed_access_levels
- optional :user_id, type: Integer
- optional :group_id, type: Integer
- end
-
- optional :allowed_to_merge, type: Array, desc: 'An array of users/groups allowed to merge' do
- optional :access_level, type: Integer, values: ProtectedBranch::MergeAccessLevel.allowed_access_levels
- optional :user_id, type: Integer
- optional :group_id, type: Integer
- end
-
- optional :allowed_to_unprotect, type: Array, desc: 'An array of users/groups allowed to unprotect' do
- optional :access_level, type: Integer, values: ProtectedBranch::UnprotectAccessLevel.allowed_access_levels
- optional :user_id, type: Integer
- optional :group_id, type: Integer
- end
- end
+ use :optional_params_ee
end
# rubocop: disable CodeReuse/ActiveRecord
post ':id/protected_branches' do
diff --git a/lib/gitlab/cluster/lifecycle_events.rb b/lib/gitlab/cluster/lifecycle_events.rb
index b05dca409d1..e0f9eb59924 100644
--- a/lib/gitlab/cluster/lifecycle_events.rb
+++ b/lib/gitlab/cluster/lifecycle_events.rb
@@ -44,6 +44,14 @@ module Gitlab
(@master_restart_hooks ||= []) << block
end
+ def on_master_start(&block)
+ if in_clustered_environment?
+ on_before_fork(&block)
+ else
+ on_worker_start(&block)
+ end
+ end
+
#
# Lifecycle integration methods (called from unicorn.rb, puma.rb, etc.)
#
diff --git a/lib/gitlab/metrics/samplers/puma_sampler.rb b/lib/gitlab/metrics/samplers/puma_sampler.rb
new file mode 100644
index 00000000000..87669b253bc
--- /dev/null
+++ b/lib/gitlab/metrics/samplers/puma_sampler.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'puma/state_file'
+
+module Gitlab
+ module Metrics
+ module Samplers
+ class PumaSampler < BaseSampler
+ def metrics
+ @metrics ||= init_metrics
+ end
+
+ def init_metrics
+ {
+ puma_workers: ::Gitlab::Metrics.gauge(:puma_workers, 'Total number of workers'),
+ puma_running_workers: ::Gitlab::Metrics.gauge(:puma_running_workers, 'Number of active workers'),
+ puma_stale_workers: ::Gitlab::Metrics.gauge(:puma_stale_workers, 'Number of stale workers'),
+ puma_phase: ::Gitlab::Metrics.gauge(:puma_phase, 'Phase number (increased during phased restarts)'),
+ puma_running: ::Gitlab::Metrics.gauge(:puma_running, 'Number of running threads'),
+ puma_queued_connections: ::Gitlab::Metrics.gauge(:puma_queued_connections, 'Number of connections in that worker\'s "todo" set waiting for a worker thread'),
+ puma_active_connections: ::Gitlab::Metrics.gauge(:puma_active_connections, 'Number of threads processing a request'),
+ puma_pool_capacity: ::Gitlab::Metrics.gauge(:puma_pool_capacity, 'Number of requests the worker is capable of taking right now'),
+ puma_max_threads: ::Gitlab::Metrics.gauge(:puma_max_threads, 'Maximum number of worker threads'),
+ puma_idle_threads: ::Gitlab::Metrics.gauge(:puma_idle_threads, 'Number of spawned threads which are not processing a request')
+ }
+ end
+
+ def sample
+ json_stats = puma_stats
+ return unless json_stats
+
+ stats = JSON.parse(json_stats)
+
+ if cluster?(stats)
+ sample_cluster(stats)
+ else
+ sample_single_worker(stats)
+ end
+ end
+
+ private
+
+ def puma_stats
+ Puma.stats
+ rescue NoMethodError
+ Rails.logger.info "PumaSampler: stats are not available yet, waiting for Puma to boot"
+ nil
+ end
+
+ def sample_cluster(stats)
+ set_master_metrics(stats)
+
+ stats['worker_status'].each do |worker|
+ labels = { worker: "worker_#{worker['index']}" }
+
+ metrics[:puma_phase].set(labels, worker['phase'])
+ set_worker_metrics(worker['last_status'], labels)
+ end
+ end
+
+ def sample_single_worker(stats)
+ metrics[:puma_workers].set({}, 1)
+ metrics[:puma_running_workers].set({}, 1)
+
+ set_worker_metrics(stats)
+ end
+
+ def cluster?(stats)
+ stats['worker_status'].present?
+ end
+
+ def set_master_metrics(stats)
+ labels = { worker: "master" }
+
+ metrics[:puma_workers].set(labels, stats['workers'])
+ metrics[:puma_running_workers].set(labels, stats['booted_workers'])
+ metrics[:puma_stale_workers].set(labels, stats['old_workers'])
+ metrics[:puma_phase].set(labels, stats['phase'])
+ end
+
+ def set_worker_metrics(stats, labels = {})
+ metrics[:puma_running].set(labels, stats['running'])
+ metrics[:puma_queued_connections].set(labels, stats['backlog'])
+ metrics[:puma_active_connections].set(labels, stats['max_threads'] - stats['pool_capacity'])
+ metrics[:puma_pool_capacity].set(labels, stats['pool_capacity'])
+ metrics[:puma_max_threads].set(labels, stats['max_threads'])
+ metrics[:puma_idle_threads].set(labels, stats['running'] + stats['pool_capacity'] - stats['max_threads'])
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb
index aa2c1ac9cef..a07b1246bee 100644
--- a/lib/gitlab/path_regex.rb
+++ b/lib/gitlab/path_regex.rb
@@ -58,7 +58,6 @@ module Gitlab
uploads
users
v2
- visual-review-toolbar.js
].freeze
# This list should contain all words following `/*namespace_id/:project_id` in
diff --git a/lib/gitlab/rack_timeout_observer.rb b/lib/gitlab/rack_timeout_observer.rb
new file mode 100644
index 00000000000..80d3f7dea60
--- /dev/null
+++ b/lib/gitlab/rack_timeout_observer.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class RackTimeoutObserver
+ def initialize
+ @counter = Gitlab::Metrics.counter(:rack_state_total, 'Number of requests in a given rack state')
+ end
+
+ # returns the Proc to be used as the observer callback block
+ def callback
+ method(:log_timeout_exception)
+ end
+
+ private
+
+ def log_timeout_exception(env)
+ info = env[::Rack::Timeout::ENV_INFO_KEY]
+ return unless info
+
+ @counter.increment(labels(info, env))
+ end
+
+ def labels(info, env)
+ params = controller_params(env) || grape_params(env) || {}
+
+ {
+ controller: params['controller'],
+ action: params['action'],
+ route: params['route'],
+ state: info.state
+ }
+ end
+
+ def controller_params(env)
+ env['action_dispatch.request.parameters']
+ end
+
+ def grape_params(env)
+ endpoint = env[Grape::Env::API_ENDPOINT]
+ route = endpoint&.route&.pattern&.origin
+ return unless route
+
+ { 'route' => route }
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/assets.rake b/lib/tasks/gitlab/assets.rake
index 7a42e4e92a0..a07ae3a418a 100644
--- a/lib/tasks/gitlab/assets.rake
+++ b/lib/tasks/gitlab/assets.rake
@@ -10,9 +10,15 @@ namespace :gitlab do
rake:assets:precompile
webpack:compile
gitlab:assets:fix_urls
+ gitlab:assets:compile_vrt
].each(&Gitlab::TaskHelpers.method(:invoke_and_time_task))
end
+ desc 'GitLab | Assets | Compile visual review toolbar'
+ task :compile_vrt do
+ system 'yarn', 'webpack-vrt'
+ end
+
desc 'GitLab | Assets | Clean up old compiled frontend assets'
task clean: ['rake:assets:clean']
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4fbcab95420..af53da84bea 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -952,6 +952,9 @@ msgstr ""
msgid "An error occurred whilst fetching the latest pipeline."
msgstr ""
+msgid "An error occurred whilst getting files for - %{branchId}"
+msgstr ""
+
msgid "An error occurred whilst loading all the files."
msgstr ""
@@ -1482,6 +1485,9 @@ msgstr ""
msgid "Branch name"
msgstr ""
+msgid "Branch not loaded - %{branchId}"
+msgstr ""
+
msgid "BranchSwitcherPlaceholder|Search branches"
msgstr ""
@@ -2993,6 +2999,9 @@ msgstr ""
msgid "Create a new branch"
msgstr ""
+msgid "Create a new file as there are no files yet. Afterwards, you'll be able to commit your changes."
+msgstr ""
+
msgid "Create a new issue"
msgstr ""
@@ -4386,6 +4395,9 @@ msgstr ""
msgid "Files"
msgstr ""
+msgid "Files breadcrumb"
+msgstr ""
+
msgid "Files, directories, and submodules in the path %{path} for commit reference %{ref}"
msgstr ""
@@ -5793,6 +5805,9 @@ msgstr ""
msgid "MRDiff|Show full file"
msgstr ""
+msgid "Make and review changes in the browser with the Web IDE"
+msgstr ""
+
msgid "Make issue confidential."
msgstr ""
@@ -6449,6 +6464,9 @@ msgstr ""
msgid "No file selected"
msgstr ""
+msgid "No files"
+msgstr ""
+
msgid "No files found."
msgstr ""
@@ -8612,6 +8630,9 @@ msgstr ""
msgid "Select Archive Format"
msgstr ""
+msgid "Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes."
+msgstr ""
+
msgid "Select a group to invite"
msgstr ""
diff --git a/package.json b/package.json
index a8a7ee7ceb9..7c992f61c00 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
"stylelint-create-utility-map": "node scripts/frontend/stylelint/stylelint-utility-map.js",
"test": "node scripts/frontend/test",
"webpack": "NODE_OPTIONS=\"--max-old-space-size=3584\" webpack --config config/webpack.config.js",
- "webpack-prod": "NODE_OPTIONS=\"--max-old-space-size=3584\" NODE_ENV=production webpack --config config/webpack.config.js"
+ "webpack-prod": "NODE_OPTIONS=\"--max-old-space-size=3584\" NODE_ENV=production webpack --config config/webpack.config.js",
+ "webpack-vrt": "NODE_OPTIONS=\"--max-old-space-size=3584\" NODE_ENV=production webpack --config config/webpack.config.review_toolbar.js"
},
"dependencies": {
"@babel/core": "^7.4.4",
diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb
index 87c0dc40e5c..b1798c11361 100644
--- a/spec/features/boards/sidebar_spec.rb
+++ b/spec/features/boards/sidebar_spec.rb
@@ -352,6 +352,8 @@ describe 'Issue Boards', :js do
page.within('.labels') do
click_link 'Edit'
+ wait_for_requests
+
click_link 'Create project label'
fill_in 'new_label_name', with: 'test label'
first('.suggest-colors-dropdown a').click
@@ -368,6 +370,8 @@ describe 'Issue Boards', :js do
page.within('.labels') do
click_link 'Edit'
+ wait_for_requests
+
click_link 'Create project label'
fill_in 'new_label_name', with: 'test label'
first('.suggest-colors-dropdown a').click
diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb
index 6762460971f..44715261b8b 100644
--- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb
+++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb
@@ -5,6 +5,7 @@ describe 'Projects > Files > Project owner creates a license file', :js do
let(:project_maintainer) { project.owner }
before do
+ stub_feature_flags(vue_file_list: false)
project.repository.delete_file(project_maintainer, 'LICENSE',
message: 'Remove LICENSE', branch_name: 'master')
sign_in(project_maintainer)
diff --git a/spec/features/projects/files/user_creates_files_spec.rb b/spec/features/projects/files/user_creates_files_spec.rb
index dd2964c2186..69f8bd4d319 100644
--- a/spec/features/projects/files/user_creates_files_spec.rb
+++ b/spec/features/projects/files/user_creates_files_spec.rb
@@ -12,6 +12,7 @@ describe 'Projects > Files > User creates files' do
let(:user) { create(:user) }
before do
+ stub_feature_flags(vue_file_list: false)
stub_feature_flags(web_ide_default: false)
project.add_maintainer(user)
diff --git a/spec/features/projects/show/user_sees_collaboration_links_spec.rb b/spec/features/projects/show/user_sees_collaboration_links_spec.rb
index 24777788248..46586b891e7 100644
--- a/spec/features/projects/show/user_sees_collaboration_links_spec.rb
+++ b/spec/features/projects/show/user_sees_collaboration_links_spec.rb
@@ -5,6 +5,7 @@ describe 'Projects > Show > Collaboration links' do
let(:user) { create(:user) }
before do
+ stub_feature_flags(vue_file_list: false)
project.add_developer(user)
sign_in(user)
end
diff --git a/spec/frontend/ide/stores/mutations/branch_spec.js b/spec/frontend/ide/stores/mutations/branch_spec.js
index 29eb859ddaf..0900b25d5d3 100644
--- a/spec/frontend/ide/stores/mutations/branch_spec.js
+++ b/spec/frontend/ide/stores/mutations/branch_spec.js
@@ -37,4 +37,39 @@ describe('Multi-file store branch mutations', () => {
expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit');
});
});
+
+ describe('SET_BRANCH_WORKING_REFERENCE', () => {
+ beforeEach(() => {
+ localState.projects = {
+ Foo: {
+ branches: {
+ bar: {},
+ },
+ },
+ };
+ });
+
+ it('sets workingReference for existing branch', () => {
+ mutations.SET_BRANCH_WORKING_REFERENCE(localState, {
+ projectId: 'Foo',
+ branchId: 'bar',
+ reference: 'foo-bar-ref',
+ });
+
+ expect(localState.projects.Foo.branches.bar.workingReference).toBe('foo-bar-ref');
+ });
+
+ it('does not fail on non-existent just yet branch', () => {
+ expect(localState.projects.Foo.branches.unknown).toBeUndefined();
+
+ mutations.SET_BRANCH_WORKING_REFERENCE(localState, {
+ projectId: 'Foo',
+ branchId: 'unknown',
+ reference: 'fun-fun-ref',
+ });
+
+ expect(localState.projects.Foo.branches.unknown).not.toBeUndefined();
+ expect(localState.projects.Foo.branches.unknown.workingReference).toBe('fun-fun-ref');
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/mutations/project_spec.js b/spec/frontend/ide/stores/mutations/project_spec.js
new file mode 100644
index 00000000000..b3ce39c33d2
--- /dev/null
+++ b/spec/frontend/ide/stores/mutations/project_spec.js
@@ -0,0 +1,23 @@
+import mutations from '~/ide/stores/mutations/project';
+import state from '~/ide/stores/state';
+
+describe('Multi-file store branch mutations', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ localState.projects = { abcproject: { empty_repo: true } };
+ });
+
+ describe('TOGGLE_EMPTY_STATE', () => {
+ it('sets empty_repo for project to passed value', () => {
+ mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: false });
+
+ expect(localState.projects.abcproject.empty_repo).toBe(false);
+
+ mutations.TOGGLE_EMPTY_STATE(localState, { projectPath: 'abcproject', value: true });
+
+ expect(localState.projects.abcproject.empty_repo).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
new file mode 100644
index 00000000000..068fa317a87
--- /dev/null
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -0,0 +1,44 @@
+import { shallowMount, RouterLinkStub } from '@vue/test-utils';
+import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
+
+let vm;
+
+function factory(currentPath) {
+ vm = shallowMount(Breadcrumbs, {
+ propsData: {
+ currentPath,
+ },
+ stubs: {
+ RouterLink: RouterLinkStub,
+ },
+ });
+}
+
+describe('Repository breadcrumbs component', () => {
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it.each`
+ path | linkCount
+ ${'/'} | ${1}
+ ${'app'} | ${2}
+ ${'app/assets'} | ${3}
+ ${'app/assets/javascripts'} | ${4}
+ `('renders $linkCount links for path $path', ({ path, linkCount }) => {
+ factory(path);
+
+ expect(vm.findAll(RouterLinkStub).length).toEqual(linkCount);
+ });
+
+ it('renders last link as active', () => {
+ factory('app/assets');
+
+ expect(
+ vm
+ .findAll(RouterLinkStub)
+ .at(2)
+ .attributes('aria-current'),
+ ).toEqual('page');
+ });
+});
diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb
new file mode 100644
index 00000000000..0c8a8d2f032
--- /dev/null
+++ b/spec/helpers/environments_helper_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe EnvironmentsHelper do
+ set(:environment) { create(:environment) }
+ set(:project) { environment.project }
+ set(:user) { create(:user) }
+
+ describe '#metrics_data' do
+ before do
+ # This is so that this spec also passes in EE.
+ allow(helper).to receive(:current_user).and_return(user)
+ allow(helper).to receive(:can?).and_return(true)
+ end
+
+ let(:metrics_data) { helper.metrics_data(project, environment) }
+
+ it 'returns data' do
+ expect(metrics_data).to include(
+ 'settings-path' => edit_project_service_path(project, 'prometheus'),
+ 'clusters-path' => project_clusters_path(project),
+ 'current-environment-name': environment.name,
+ 'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'),
+ 'empty-getting-started-svg-path' => match_asset_path('/assets/illustrations/monitoring/getting_started.svg'),
+ 'empty-loading-svg-path' => match_asset_path('/assets/illustrations/monitoring/loading.svg'),
+ 'empty-no-data-svg-path' => match_asset_path('/assets/illustrations/monitoring/no_data.svg'),
+ 'empty-unable-to-connect-svg-path' => match_asset_path('/assets/illustrations/monitoring/unable_to_connect.svg'),
+ 'metrics-endpoint' => additional_metrics_project_environment_path(project, environment, format: :json),
+ 'deployment-endpoint' => project_environment_deployments_path(project, environment, format: :json),
+ 'environments-endpoint': project_environments_path(project, format: :json),
+ 'project-path' => project_path(project),
+ 'tags-path' => project_tags_path(project),
+ 'has-metrics' => "#{environment.has_metrics?}",
+ 'external-dashboard-url' => nil
+ )
+ end
+
+ context 'with metrics_setting' do
+ before do
+ create(:project_metrics_setting, project: project, external_dashboard_url: 'http://gitlab.com')
+ end
+
+ it 'adds external_dashboard_url' do
+ expect(metrics_data['external-dashboard-url']).to eq('http://gitlab.com')
+ end
+ end
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 83271aa24a3..3716879c458 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -819,4 +819,26 @@ describe ProjectsHelper do
expect(helper.can_import_members?).to eq true
end
end
+
+ describe '#metrics_external_dashboard_url' do
+ let(:project) { create(:project) }
+
+ before do
+ helper.instance_variable_set(:@project, project)
+ end
+
+ context 'metrics_setting exists' do
+ it 'returns external_dashboard_url' do
+ metrics_setting = create(:project_metrics_setting, project: project)
+
+ expect(helper.metrics_external_dashboard_url).to eq(metrics_setting.external_dashboard_url)
+ end
+ end
+
+ context 'metrics_setting does not exist' do
+ it 'returns nil' do
+ expect(helper.metrics_external_dashboard_url).to eq(nil)
+ end
+ end
+ end
end
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
index dc5790f6562..de4becec1cd 100644
--- a/spec/javascripts/ide/components/ide_spec.js
+++ b/spec/javascripts/ide/components/ide_spec.js
@@ -5,21 +5,53 @@ import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helpe
import { file, resetStore } from '../helpers';
import { projectData } from '../mock_data';
-describe('ide component', () => {
+function bootstrap(projData) {
+ const Component = Vue.extend(ide);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = Object.assign({}, projData);
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [],
+ loading: false,
+ });
+
+ return createComponentWithStore(Component, store, {
+ emptyStateSvgPath: 'svg',
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+}
+
+describe('ide component, empty repo', () => {
let vm;
beforeEach(() => {
- const Component = Vue.extend(ide);
+ const emptyProjData = Object.assign({}, projectData, { empty_repo: true, branches: {} });
+ vm = bootstrap(emptyProjData);
+ vm.$mount();
+ });
- store.state.currentProjectId = 'abcproject';
- store.state.currentBranchId = 'master';
- store.state.projects.abcproject = Object.assign({}, projectData);
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
- vm = createComponentWithStore(Component, store, {
- emptyStateSvgPath: 'svg',
- noChangesStateSvgPath: 'svg',
- committedStateSvgPath: 'svg',
- }).$mount();
+ it('renders "New file" button in empty repo', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull();
+ done();
+ });
+ });
+});
+
+describe('ide component, non-empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = bootstrap(projectData);
+ vm.$mount();
});
afterEach(() => {
@@ -28,17 +60,15 @@ describe('ide component', () => {
resetStore(vm.$store);
});
- it('does not render right when no files open', () => {
- expect(vm.$el.querySelector('.panel-right')).toBeNull();
- });
+ it('shows error message when set', done => {
+ expect(vm.$el.querySelector('.flash-container')).toBe(null);
- it('renders right panel when files are open', done => {
- vm.$store.state.trees['abcproject/mybranch'] = {
- tree: [file()],
+ vm.$store.state.errorMessage = {
+ text: 'error',
};
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.panel-right')).toBeNull();
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.flash-container')).not.toBe(null);
done();
});
@@ -71,17 +101,25 @@ describe('ide component', () => {
});
});
- it('shows error message when set', done => {
- expect(vm.$el.querySelector('.flash-container')).toBe(null);
-
- vm.$store.state.errorMessage = {
- text: 'error',
- };
+ describe('non-existent branch', () => {
+ it('does not render "New file" button for non-existent branch when repo is not empty', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.flash-container')).not.toBe(null);
+ describe('branch with files', () => {
+ beforeEach(() => {
+ store.state.trees['abcproject/master'].tree = [file()];
+ });
- done();
+ it('does not render "New file" button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
});
});
});
diff --git a/spec/javascripts/ide/components/ide_tree_list_spec.js b/spec/javascripts/ide/components/ide_tree_list_spec.js
index 4ecbdb8a55e..f63007c7dd2 100644
--- a/spec/javascripts/ide/components/ide_tree_list_spec.js
+++ b/spec/javascripts/ide/components/ide_tree_list_spec.js
@@ -7,25 +7,23 @@ import { projectData } from '../mock_data';
describe('IDE tree list', () => {
const Component = Vue.extend(IdeTreeList);
+ const normalBranchTree = [file('fileName')];
+ const emptyBranchTree = [];
let vm;
- beforeEach(() => {
+ const bootstrapWithTree = (tree = normalBranchTree) => {
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'master';
store.state.projects.abcproject = Object.assign({}, projectData);
Vue.set(store.state.trees, 'abcproject/master', {
- tree: [file('fileName')],
+ tree,
loading: false,
});
vm = createComponentWithStore(Component, store, {
viewerType: 'edit',
});
-
- spyOn(vm, 'updateViewer').and.callThrough();
-
- vm.$mount();
- });
+ };
afterEach(() => {
vm.$destroy();
@@ -33,22 +31,47 @@ describe('IDE tree list', () => {
resetStore(vm.$store);
});
- it('updates viewer on mount', () => {
- expect(vm.updateViewer).toHaveBeenCalledWith('edit');
- });
+ describe('normal branch', () => {
+ beforeEach(() => {
+ bootstrapWithTree();
+
+ spyOn(vm, 'updateViewer').and.callThrough();
+
+ vm.$mount();
+ });
+
+ it('updates viewer on mount', () => {
+ expect(vm.updateViewer).toHaveBeenCalledWith('edit');
+ });
+
+ it('renders loading indicator', done => {
+ store.state.trees['abcproject/master'].loading = true;
- it('renders loading indicator', done => {
- store.state.trees['abcproject/master'].loading = true;
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
- expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+ done();
+ });
+ });
- done();
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
});
});
- it('renders list of files', () => {
- expect(vm.$el.textContent).toContain('fileName');
+ describe('empty-branch state', () => {
+ beforeEach(() => {
+ bootstrapWithTree(emptyBranchTree);
+
+ spyOn(vm, 'updateViewer').and.callThrough();
+
+ vm.$mount();
+ });
+
+ it('does not load files if the branch is empty', () => {
+ expect(vm.$el.textContent).not.toContain('fileName');
+ expect(vm.$el.textContent).toContain('No files');
+ });
});
});
diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js
index cd519eaed7c..8ecb6129c63 100644
--- a/spec/javascripts/ide/stores/actions/project_spec.js
+++ b/spec/javascripts/ide/stores/actions/project_spec.js
@@ -4,7 +4,7 @@ import {
refreshLastCommitData,
showBranchNotFoundError,
createNewBranchFromDefault,
- getBranchData,
+ showEmptyState,
openBranch,
} from '~/ide/stores/actions';
import store from '~/ide/stores';
@@ -196,39 +196,44 @@ describe('IDE store project actions', () => {
});
});
- describe('getBranchData', () => {
- describe('error', () => {
- it('dispatches branch not found action when response is 404', done => {
- const dispatch = jasmine.createSpy('dispatchSpy');
-
- mock.onGet(/(.*)/).replyOnce(404);
-
- getBranchData(
+ describe('showEmptyState', () => {
+ it('commits proper mutations when supplied error is 404', done => {
+ testAction(
+ showEmptyState,
+ {
+ err: {
+ response: {
+ status: 404,
+ },
+ },
+ projectId: 'abc/def',
+ branchId: 'master',
+ },
+ store.state,
+ [
{
- commit() {},
- dispatch,
- state: store.state,
+ type: 'CREATE_TREE',
+ payload: {
+ treePath: 'abc/def/master',
+ },
},
{
- projectId: 'abc/def',
- branchId: 'master-testing',
+ type: 'TOGGLE_LOADING',
+ payload: {
+ entry: store.state.trees['abc/def/master'],
+ forceValue: false,
+ },
},
- )
- .then(done.fail)
- .catch(() => {
- expect(dispatch.calls.argsFor(0)).toEqual([
- 'showBranchNotFoundError',
- 'master-testing',
- ]);
- done();
- });
- });
+ ],
+ [],
+ done,
+ );
});
});
describe('openBranch', () => {
const branch = {
- projectId: 'feature/lorem-ipsum',
+ projectId: 'abc/def',
branchId: '123-lorem',
};
@@ -238,63 +243,113 @@ describe('IDE store project actions', () => {
'foo/bar-pending': { pending: true },
'foo/bar': { pending: false },
};
-
- spyOn(store, 'dispatch').and.returnValue(Promise.resolve());
});
- it('dispatches branch actions', done => {
- openBranch(store, branch)
- .then(() => {
- expect(store.dispatch.calls.allArgs()).toEqual([
- ['setCurrentBranchId', branch.branchId],
- ['getBranchData', branch],
- ['getFiles', branch],
- ['getMergeRequestsForBranch', branch],
- ]);
- })
- .then(done)
- .catch(done.fail);
- });
+ describe('empty repo', () => {
+ beforeEach(() => {
+ spyOn(store, 'dispatch').and.returnValue(Promise.resolve());
- it('handles tree entry action, if basePath is given', done => {
- openBranch(store, { ...branch, basePath: 'foo/bar/' })
- .then(() => {
- expect(store.dispatch).toHaveBeenCalledWith(
- 'handleTreeEntryAction',
- store.state.entries['foo/bar'],
- );
- })
- .then(done)
- .catch(done.fail);
+ store.state.currentProjectId = 'abc/def';
+ store.state.projects['abc/def'] = {
+ empty_repo: true,
+ };
+ });
+
+ afterEach(() => {
+ resetStore(store);
+ });
+
+ it('dispatches showEmptyState action right away', done => {
+ openBranch(store, branch)
+ .then(() => {
+ expect(store.dispatch.calls.allArgs()).toEqual([
+ ['setCurrentBranchId', branch.branchId],
+ ['showEmptyState', branch],
+ ]);
+ done();
+ })
+ .catch(done.fail);
+ });
});
- it('does not handle tree entry action, if entry is pending', done => {
- openBranch(store, { ...branch, basePath: 'foo/bar-pending' })
- .then(() => {
- expect(store.dispatch).not.toHaveBeenCalledWith(
- 'handleTreeEntryAction',
- jasmine.anything(),
- );
- })
- .then(done)
- .catch(done.fail);
+ describe('existing branch', () => {
+ beforeEach(() => {
+ spyOn(store, 'dispatch').and.returnValue(Promise.resolve());
+ });
+
+ it('dispatches branch actions', done => {
+ openBranch(store, branch)
+ .then(() => {
+ expect(store.dispatch.calls.allArgs()).toEqual([
+ ['setCurrentBranchId', branch.branchId],
+ ['getBranchData', branch],
+ ['getMergeRequestsForBranch', branch],
+ ['getFiles', branch],
+ ]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('handles tree entry action, if basePath is given', done => {
+ openBranch(store, { ...branch, basePath: 'foo/bar/' })
+ .then(() => {
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'handleTreeEntryAction',
+ store.state.entries['foo/bar'],
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('does not handle tree entry action, if entry is pending', done => {
+ openBranch(store, { ...branch, basePath: 'foo/bar-pending' })
+ .then(() => {
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'handleTreeEntryAction',
+ jasmine.anything(),
+ );
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('creates a new file supplied via URL if the file does not exist yet', done => {
+ openBranch(store, { ...branch, basePath: 'not-existent.md' })
+ .then(() => {
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'handleTreeEntryAction',
+ jasmine.anything(),
+ );
+
+ expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
+ name: 'not-existent.md',
+ type: 'blob',
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
});
- it('creates a new file supplied via URL if the file does not exist yet', done => {
- openBranch(store, { ...branch, basePath: 'not-existent.md' })
- .then(() => {
- expect(store.dispatch).not.toHaveBeenCalledWith(
- 'handleTreeEntryAction',
- jasmine.anything(),
- );
+ describe('non-existent branch', () => {
+ beforeEach(() => {
+ spyOn(store, 'dispatch').and.returnValue(Promise.reject());
+ });
- expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
- name: 'not-existent.md',
- type: 'blob',
- });
- })
- .then(done)
- .catch(done.fail);
+ it('dispatches correct branch actions', done => {
+ openBranch(store, branch)
+ .then(() => {
+ expect(store.dispatch.calls.allArgs()).toEqual([
+ ['setCurrentBranchId', branch.branchId],
+ ['getBranchData', branch],
+ ['showBranchNotFoundError', branch.branchId],
+ ]);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
});
});
});
diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js
index 5ed9b9003a7..674ecdc6764 100644
--- a/spec/javascripts/ide/stores/actions/tree_spec.js
+++ b/spec/javascripts/ide/stores/actions/tree_spec.js
@@ -93,38 +93,6 @@ describe('Multi-file store tree actions', () => {
});
describe('error', () => {
- it('dispatches branch not found actions when response is 404', done => {
- const dispatch = jasmine.createSpy('dispatchSpy');
-
- store.state.projects = {
- 'abc/def': {
- web_url: `${gl.TEST_HOST}/files`,
- },
- };
-
- mock.onGet(/(.*)/).replyOnce(404);
-
- getFiles(
- {
- commit() {},
- dispatch,
- state: store.state,
- },
- {
- projectId: 'abc/def',
- branchId: 'master-testing',
- },
- )
- .then(done.fail)
- .catch(() => {
- expect(dispatch.calls.argsFor(0)).toEqual([
- 'showBranchNotFoundError',
- 'master-testing',
- ]);
- done();
- });
- });
-
it('dispatches error action', done => {
const dispatch = jasmine.createSpy('dispatchSpy');
diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js
index 0b5587d02ae..04e236fb042 100644
--- a/spec/javascripts/ide/stores/actions_spec.js
+++ b/spec/javascripts/ide/stores/actions_spec.js
@@ -9,12 +9,15 @@ import actions, {
setErrorMessage,
deleteEntry,
renameEntry,
+ getBranchData,
} from '~/ide/stores/actions';
+import axios from '~/lib/utils/axios_utils';
import store from '~/ide/stores';
import * as types from '~/ide/stores/mutation_types';
import router from '~/ide/ide_router';
import { resetStore, file } from '../helpers';
import testAction from '../../helpers/vuex_action_helper';
+import MockAdapter from 'axios-mock-adapter';
describe('Multi-file store actions', () => {
beforeEach(() => {
@@ -560,4 +563,65 @@ describe('Multi-file store actions', () => {
);
});
});
+
+ describe('getBranchData', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('error', () => {
+ let dispatch;
+ const callParams = [
+ {
+ commit() {},
+ state: store.state,
+ },
+ {
+ projectId: 'abc/def',
+ branchId: 'master-testing',
+ },
+ ];
+
+ beforeEach(() => {
+ dispatch = jasmine.createSpy('dispatchSpy');
+ document.body.innerHTML += '<div class="flash-container"></div>';
+ });
+
+ afterEach(() => {
+ document.querySelector('.flash-container').remove();
+ });
+
+ it('passes the error further unchanged without dispatching any action when response is 404', done => {
+ mock.onGet(/(.*)/).replyOnce(404);
+
+ getBranchData(...callParams)
+ .then(done.fail)
+ .catch(e => {
+ expect(dispatch.calls.count()).toEqual(0);
+ expect(e.response.status).toEqual(404);
+ expect(document.querySelector('.flash-alert')).toBeNull();
+ done();
+ });
+ });
+
+ it('does not pass the error further and flashes an alert if error is not 404', done => {
+ mock.onGet(/(.*)/).replyOnce(418);
+
+ getBranchData(...callParams)
+ .then(done.fail)
+ .catch(e => {
+ expect(dispatch.calls.count()).toEqual(0);
+ expect(e.response).toBeUndefined();
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
+ done();
+ });
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
index cdeb9b4b896..4413a12fac4 100644
--- a/spec/javascripts/ide/stores/modules/commit/actions_spec.js
+++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
@@ -272,6 +272,7 @@ describe('IDE commit module actions', () => {
short_id: '123',
message: 'test message',
committed_date: 'date',
+ parent_ids: '321',
stats: {
additions: '1',
deletions: '2',
@@ -463,5 +464,63 @@ describe('IDE commit module actions', () => {
.catch(done.fail);
});
});
+
+ describe('first commit of a branch', () => {
+ const COMMIT_RESPONSE = {
+ id: '123456',
+ short_id: '123',
+ message: 'test message',
+ committed_date: 'date',
+ parent_ids: [],
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ };
+
+ it('commits TOGGLE_EMPTY_STATE mutation on empty repo', done => {
+ spyOn(service, 'commit').and.returnValue(
+ Promise.resolve({
+ data: COMMIT_RESPONSE,
+ }),
+ );
+
+ spyOn(store, 'commit').and.callThrough();
+
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(store.commit.calls.allArgs()).toEqual(
+ jasmine.arrayContaining([
+ ['TOGGLE_EMPTY_STATE', jasmine.any(Object), jasmine.any(Object)],
+ ]),
+ );
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', done => {
+ COMMIT_RESPONSE.parent_ids.push('1234');
+ spyOn(service, 'commit').and.returnValue(
+ Promise.resolve({
+ data: COMMIT_RESPONSE,
+ }),
+ );
+ spyOn(store, 'commit').and.callThrough();
+
+ store
+ .dispatch('commit/commitChanges')
+ .then(() => {
+ expect(store.commit.calls.allArgs()).not.toEqual(
+ jasmine.arrayContaining([
+ ['TOGGLE_EMPTY_STATE', jasmine.any(Object), jasmine.any(Object)],
+ ]),
+ );
+ done();
+ })
+ .catch(done.fail);
+ });
+ });
});
});
diff --git a/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb
new file mode 100644
index 00000000000..c471c30a194
--- /dev/null
+++ b/spec/lib/gitlab/metrics/samplers/puma_sampler_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::Metrics::Samplers::PumaSampler do
+ subject { described_class.new(5) }
+ let(:null_metric) { double('null_metric', set: nil, observe: nil) }
+
+ before do
+ allow(Gitlab::Metrics::NullMetric).to receive(:instance).and_return(null_metric)
+ end
+
+ describe '#sample' do
+ before do
+ expect(subject).to receive(:puma_stats).and_return(puma_stats)
+ end
+
+ context 'in cluster mode' do
+ let(:puma_stats) do
+ <<~EOS
+ {
+ "workers": 2,
+ "phase": 2,
+ "booted_workers": 2,
+ "old_workers": 0,
+ "worker_status": [{
+ "pid": 32534,
+ "index": 0,
+ "phase": 1,
+ "booted": true,
+ "last_checkin": "2019-05-15T07:57:55Z",
+ "last_status": {
+ "backlog":0,
+ "running":1,
+ "pool_capacity":4,
+ "max_threads": 4
+ }
+ }]
+ }
+ EOS
+ end
+
+ it 'samples master statistics' do
+ labels = { worker: 'master' }
+
+ expect(subject.metrics[:puma_workers]).to receive(:set).with(labels, 2)
+ expect(subject.metrics[:puma_running_workers]).to receive(:set).with(labels, 2)
+ expect(subject.metrics[:puma_stale_workers]).to receive(:set).with(labels, 0)
+ expect(subject.metrics[:puma_phase]).to receive(:set).once.with(labels, 2)
+ expect(subject.metrics[:puma_phase]).to receive(:set).once.with({ worker: 'worker_0' }, 1)
+
+ subject.sample
+ end
+
+ it 'samples worker statistics' do
+ labels = { worker: 'worker_0' }
+
+ expect_worker_stats(labels)
+
+ subject.sample
+ end
+ end
+
+ context 'in single mode' do
+ let(:puma_stats) do
+ <<~EOS
+ {
+ "backlog":0,
+ "running":1,
+ "pool_capacity":4,
+ "max_threads": 4
+ }
+ EOS
+ end
+
+ it 'samples worker statistics' do
+ labels = {}
+
+ expect(subject.metrics[:puma_workers]).to receive(:set).with(labels, 1)
+ expect(subject.metrics[:puma_running_workers]).to receive(:set).with(labels, 1)
+ expect_worker_stats(labels)
+
+ subject.sample
+ end
+ end
+ end
+
+ def expect_worker_stats(labels)
+ expect(subject.metrics[:puma_queued_connections]).to receive(:set).with(labels, 0)
+ expect(subject.metrics[:puma_active_connections]).to receive(:set).with(labels, 0)
+ expect(subject.metrics[:puma_running]).to receive(:set).with(labels, 1)
+ expect(subject.metrics[:puma_pool_capacity]).to receive(:set).with(labels, 4)
+ expect(subject.metrics[:puma_max_threads]).to receive(:set).with(labels, 4)
+ expect(subject.metrics[:puma_idle_threads]).to receive(:set).with(labels, 1)
+ end
+end
diff --git a/spec/lib/gitlab/rack_timeout_observer_spec.rb b/spec/lib/gitlab/rack_timeout_observer_spec.rb
new file mode 100644
index 00000000000..3dc1a8b68fb
--- /dev/null
+++ b/spec/lib/gitlab/rack_timeout_observer_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Gitlab::RackTimeoutObserver do
+ let(:counter) { Gitlab::Metrics::NullMetric.instance }
+
+ before do
+ allow(Gitlab::Metrics).to receive(:counter)
+ .with(any_args)
+ .and_return(counter)
+ end
+
+ describe '#callback' do
+ context 'when request times out' do
+ let(:env) do
+ {
+ ::Rack::Timeout::ENV_INFO_KEY => double(state: :timed_out),
+ 'action_dispatch.request.parameters' => {
+ 'controller' => 'foo',
+ 'action' => 'bar'
+ }
+ }
+ end
+
+ subject { described_class.new }
+
+ it 'increments timeout counter' do
+ expect(counter)
+ .to receive(:increment)
+ .with({ controller: 'foo', action: 'bar', route: nil, state: :timed_out })
+
+ subject.callback.call(env)
+ end
+ end
+
+ context 'when request expires' do
+ let(:endpoint) { double }
+ let(:env) do
+ {
+ ::Rack::Timeout::ENV_INFO_KEY => double(state: :expired),
+ Grape::Env::API_ENDPOINT => endpoint
+ }
+ end
+
+ subject { described_class.new }
+
+ it 'increments timeout counter' do
+ allow(endpoint).to receive_message_chain('route.pattern.origin') { 'foobar' }
+ expect(counter)
+ .to receive(:increment)
+ .with({ controller: nil, action: nil, route: 'foobar', state: :expired })
+
+ subject.callback.call(env)
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index bc81c34f7ab..32eef9e0e01 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -3490,6 +3490,18 @@ describe Ci::Build do
end
end
+ describe '#report_artifacts' do
+ subject { build.report_artifacts }
+
+ context 'when the build has reports' do
+ let!(:report) { create(:ci_job_artifact, :codequality, job: build) }
+
+ it 'returns the artifacts with reports' do
+ expect(subject).to contain_exactly(report)
+ end
+ end
+ end
+
describe '#artifacts_metadata_entry' do
set(:build) { create(:ci_build, project: project) }
let(:path) { 'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif' }
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index 5964f66c398..e6d682c24d9 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -23,6 +23,21 @@ describe Ci::JobArtifact do
it_behaves_like 'having unique enum values'
+ describe '.with_reports' do
+ let!(:artifact) { create(:ci_job_artifact, :archive) }
+
+ subject { described_class.with_reports }
+
+ it { is_expected.to be_empty }
+
+ context 'when there are reports' do
+ let!(:metrics_report) { create(:ci_job_artifact, :junit) }
+ let!(:codequality_report) { create(:ci_job_artifact, :codequality) }
+
+ it { is_expected.to eq([metrics_report, codequality_report]) }
+ end
+ end
+
describe '.test_reports' do
subject { described_class.test_reports }
diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb
index 9c2e5c79a9d..d922e8246c7 100644
--- a/spec/serializers/build_details_entity_spec.rb
+++ b/spec/serializers/build_details_entity_spec.rb
@@ -146,5 +146,14 @@ describe BuildDetailsEntity do
end
end
end
+
+ context 'when the build has reports' do
+ let!(:report) { create(:ci_job_artifact, :codequality, job: build) }
+
+ it 'exposes the report artifacts' do
+ expect(subject[:reports].count).to eq(1)
+ expect(subject[:reports].first[:file_type]).to eq('codequality')
+ end
+ end
end
end
diff --git a/spec/serializers/job_artifact_report_entity_spec.rb b/spec/serializers/job_artifact_report_entity_spec.rb
new file mode 100644
index 00000000000..eef5c16d0fb
--- /dev/null
+++ b/spec/serializers/job_artifact_report_entity_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe JobArtifactReportEntity do
+ let(:report) { create(:ci_job_artifact, :codequality) }
+ let(:entity) { described_class.new(report, request: double) }
+
+ describe '#as_json' do
+ subject { entity.as_json }
+
+ it 'exposes file_type' do
+ expect(subject[:file_type]).to eq(report.file_type)
+ end
+
+ it 'exposes file_format' do
+ expect(subject[:file_format]).to eq(report.file_format)
+ end
+
+ it 'exposes size' do
+ expect(subject[:size]).to eq(report.size)
+ end
+
+ it 'exposes download path' do
+ expect(subject[:download_path]).to include("jobs/#{report.job.id}/artifacts/download")
+ end
+ end
+end
diff --git a/public/visual-review-toolbar.js b/vendor/assets/javascripts/visual_review_toolbar.js
index 6a0fdb29cc2..12a3a4c9672 100644
--- a/public/visual-review-toolbar.js
+++ b/vendor/assets/javascripts/visual_review_toolbar.js
@@ -2,260 +2,11 @@
/////////////////// STYLES ////////////////////
///////////////////////////////////////////////
+// this style must be applied inline
const buttonClearStyles = `
-webkit-appearance: none;
`;
-const buttonBaseStyles = `
- cursor: pointer;
- transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear;
-`;
-
-const buttonSuccessActiveStyles = `
- background-color: #168f48;
- border-color: #12753a;
- color: #fff;
-`;
-
-const buttonSuccessHoverStyles = `
- color: #fff;
- background-color: #137e3f;
- border-color: #127339;
-`;
-
-const buttonSuccessStyles = `
- ${buttonBaseStyles}
- background-color: #1aaa55;
- border: 1px solid #168f48;
- color: #fff;
-`;
-
-const buttonSecondaryStyles = `
- ${buttonBaseStyles}
- background: none #fff;
- margin: 0 .5rem;
- border: 1px solid #e3e3e3;
-`;
-
-const buttonSecondaryActiveStyles = `
- color: #2e2e2e;
- background-color: #e1e1e1;
- border-color: #dadada;
-`;
-
-const buttonSecondaryHoverStyles = `
- background-color: #f0f0f0;
- border-color: #e3e3e3;
- color: #2e2e2e;
-`;
-
-const buttonWideStyles = `
- width: 100%;
-`;
-
-const buttonWrapperStyles = `
- margin-top: 1rem;
- display: flex;
- align-items: baseline;
- justify-content: flex-end;
-`;
-
-const collapseStyles = `
- ${buttonBaseStyles}
- width: 2.4rem;
- height: 2.2rem;
- margin-left: 1rem;
- padding: .5rem;
-`;
-
-const collapseClosedStyles = `
- ${collapseStyles}
- align-self: center;
-`;
-
-const collapseOpenStyles = `
- ${collapseStyles}
-`;
-
-const checkboxLabelStyles = `
- padding: 0 .2rem;
-`;
-
-const checkboxWrapperStyles = `
- display: flex;
- align-items: baseline;
-`;
-
-const formStyles = `
- display: flex;
- flex-direction: column;
- width: 100%
-`;
-
-const labelStyles = `
- font-weight: 600;
- display: inline-block;
- width: 100%;
-`;
-
-const linkStyles = `
- color: #1b69b6;
- text-decoration: none;
- background-color: transparent;
- background-image: none;
-`;
-
-const messageStyles = `
- padding: .25rem 0;
- margin: 0;
- line-height: 1.2rem;
-`;
-
-const metadataNoteStyles = `
- font-size: .7rem;
- line-height: 1rem;
- color: #666;
- margin-bottom: 0;
-`;
-
-const inputStyles = `
- width: 100%;
- border: 1px solid #dfdfdf;
- border-radius: 4px;
- padding: .1rem .2rem;
- min-height: 2rem;
- max-width: 17rem;
-`;
-
-const svgInnerStyles = `
- pointer-events: none;
-`;
-
-const wrapperClosedStyles = `
- max-width: 3.4rem;
- max-height: 3.4rem;
-`;
-
-const wrapperOpenStyles = `
- max-width: 22rem;
- max-height: 22rem;
-`;
-
-const wrapperStyles = `
- max-width: 22rem;
- max-height: 22rem;
- overflow: scroll;
- position: fixed;
- bottom: 1rem;
- right: 1rem;
- display: flex;
- flex-direction: row-reverse;
- padding: 1rem;
- background-color: #fff;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
- 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
- 'Noto Color Emoji';
- font-size: .8rem;
- font-weight: 400;
- color: #2e2e2e;
-`;
-
-const gitlabStyles = `
- #gitlab-collapse > * {
- ${svgInnerStyles}
- }
-
- #gitlab-form-wrapper {
- ${formStyles}
- }
-
- #gitlab-review-container {
- ${wrapperStyles}
- }
-
- .gitlab-open-wrapper {
- ${wrapperOpenStyles}
- }
-
- .gitlab-closed-wrapper {
- ${wrapperClosedStyles}
- }
-
- .gitlab-button-secondary {
- ${buttonSecondaryStyles}
- }
-
- .gitlab-button-secondary:hover {
- ${buttonSecondaryHoverStyles}
- }
-
- .gitlab-button-secondary:active {
- ${buttonSecondaryActiveStyles}
- }
-
- .gitlab-button-success:hover {
- ${buttonSuccessHoverStyles}
- }
-
- .gitlab-button-success:active {
- ${buttonSuccessActiveStyles}
- }
-
- .gitlab-button-success {
- ${buttonSuccessStyles}
- }
-
- .gitlab-button-wide {
- ${buttonWideStyles}
- }
-
- .gitlab-button-wrapper {
- ${buttonWrapperStyles}
- }
-
- .gitlab-collapse-closed {
- ${collapseClosedStyles}
- }
-
- .gitlab-collapse-open {
- ${collapseOpenStyles}
- }
-
- .gitlab-checkbox-label {
- ${checkboxLabelStyles}
- }
-
- .gitlab-checkbox-wrapper {
- ${checkboxWrapperStyles}
- }
-
- .gitlab-label {
- ${labelStyles}
- }
-
- .gitlab-link {
- ${linkStyles}
- }
-
- .gitlab-message {
- ${messageStyles}
- }
-
- .gitlab-metadata-note {
- ${metadataNoteStyles}
- }
-
- .gitlab-input {
- ${inputStyles}
- }
-`;
-
-function addStylesheet() {
- const styleEl = document.createElement('style');
- styleEl.insertAdjacentHTML('beforeend', gitlabStyles);
- document.head.appendChild(styleEl);
-}
-
///////////////////////////////////////////////
/////////////////// STATE ////////////////////
///////////////////////////////////////////////
@@ -275,25 +26,24 @@ const comment = `
<p class='gitlab-metadata-note'>Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p>
</div>
<div class='gitlab-button-wrapper''>
- <button class='gitlab-button-secondary' style='${buttonClearStyles}' type='button' id='gitlab-logout-button'> Logout </button>
- <button class='gitlab-button-success' style='${buttonClearStyles}' type='button' id='gitlab-comment-button'> Send feedback </button>
+ <button class='gitlab-button gitlab-button-secondary' style='${buttonClearStyles}' type='button' id='gitlab-logout-button'> Logout </button>
+ <button class='gitlab-button gitlab-button-success' style='${buttonClearStyles}' type='button' id='gitlab-comment-button'> Send feedback </button>
</div>
`;
const commentIcon = `
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/comment</title><path d="M4 11.132l1.446-.964A1 1 0 0 1 6 10h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v6.132zM6.303 12l-2.748 1.832A1 1 0 0 1 2 13V5a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6.303z" id="gitlab-comment-icon"/></svg>
-`
+`;
const compressIcon = `
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/compress</title><path d="M5.27 12.182l-1.562 1.561a1 1 0 0 1-1.414 0h-.001a1 1 0 0 1 0-1.415l1.56-1.56L2.44 9.353a.5.5 0 0 1 .353-.854H7.09a.5.5 0 0 1 .5.5v4.294a.5.5 0 0 1-.853.353l-1.467-1.465zm6.911-6.914l1.464 1.464a.5.5 0 0 1-.353.854H8.999a.5.5 0 0 1-.5-.5V2.793a.5.5 0 0 1 .854-.354l1.414 1.415 1.56-1.561a1 1 0 1 1 1.415 1.414l-1.561 1.56z" id="gitlab-compress-icon"/></svg>
`;
const collapseButton = `
- <button id='gitlab-collapse' style='${buttonClearStyles}' class='gitlab-collapse-open gitlab-button-secondary'>${compressIcon}</button>
+ <button id='gitlab-collapse' style='${buttonClearStyles}' class='gitlab-button gitlab-button-secondary gitlab-collapse gitlab-collapse-open'>${compressIcon}</button>
`;
-
-const form = (content) => `
+const form = content => `
<div id='gitlab-form-wrapper'>
${content}
</div>
@@ -310,7 +60,7 @@ const login = `
<label for="remember_token" class='gitlab-checkbox-label'>Remember me</label>
</div>
<div class='gitlab-button-wrapper'>
- <button class='gitlab-button-wide gitlab-button-success' style='${buttonClearStyles}' type='button' id='gitlab-login'> Submit </button>
+ <button class='gitlab-button-wide gitlab-button gitlab-button-success' style='${buttonClearStyles}' type='button' id='gitlab-login'> Submit </button>
</div>
`;
@@ -319,25 +69,25 @@ const login = `
///////////////////////////////////////////////
// from https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator
-function getBrowserId (sUsrAg) {
- var aKeys = ["MSIE", "Edge", "Firefox", "Safari", "Chrome", "Opera"],
- nIdx = aKeys.length - 1;
+function getBrowserId(sUsrAg) {
+ var aKeys = ['MSIE', 'Edge', 'Firefox', 'Safari', 'Chrome', 'Opera'],
+ nIdx = aKeys.length - 1;
for (nIdx; nIdx > -1 && sUsrAg.indexOf(aKeys[nIdx]) === -1; nIdx--);
return aKeys[nIdx];
}
-function addCommentForm () {
+function addCommentForm() {
const formWrapper = document.getElementById('gitlab-form-wrapper');
formWrapper.innerHTML = comment;
}
-function addLoginForm () {
+function addLoginForm() {
const formWrapper = document.getElementById('gitlab-form-wrapper');
formWrapper.innerHTML = login;
}
-function authorizeUser () {
+function authorizeUser() {
// Clear any old errors
clearNote('gitlab-token');
@@ -357,13 +107,12 @@ function authorizeUser () {
return;
}
-function authSuccess (token) {
+function authSuccess(token) {
data.token = token;
addCommentForm();
}
-
-function clearNote (inputId) {
+function clearNote(inputId) {
const note = document.getElementById('gitlab-validation-note');
note.innerText = '';
note.style.color = '';
@@ -374,7 +123,7 @@ function clearNote (inputId) {
}
}
-function confirmAndClear (mergeRequestId) {
+function confirmAndClear(mergeRequestId) {
const commentButton = document.getElementById('gitlab-comment-button');
const note = document.getElementById('gitlab-validation-note');
@@ -384,7 +133,7 @@ function confirmAndClear (mergeRequestId) {
setTimeout(resetCommentButton, 1000);
}
-function getInitialState () {
+function getInitialState() {
const { localStorage } = window;
try {
@@ -396,22 +145,21 @@ function getInitialState () {
}
return login;
-
} catch (err) {
return login;
}
}
-function getProjectDetails () {
- const { innerWidth,
- innerHeight,
- location: { href },
- navigator: {
- platform, userAgent
- } } = window;
+function getProjectDetails() {
+ const {
+ innerWidth,
+ innerHeight,
+ location: { href },
+ navigator: { platform, userAgent },
+ } = window;
const browser = getBrowserId(userAgent);
- const scriptEl = document.getElementById('review-app-toolbar-script')
+ const scriptEl = document.getElementById('review-app-toolbar-script');
const { projectId, mergeRequestId, mrUrl } = scriptEl.dataset;
return {
@@ -427,7 +175,7 @@ function getProjectDetails () {
};
}
-function logoutUser () {
+function logoutUser() {
const { localStorage } = window;
// All the browsers we support have localStorage, so let's silently fail
@@ -441,7 +189,7 @@ function logoutUser () {
addLoginForm();
}
-function postComment ({
+function postComment({
href,
platform,
browser,
@@ -478,32 +226,34 @@ function postComment ({
const url = `
${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`;
-
const body = `${commentText} ${detailText}`;
fetch(url, {
- method: 'POST',
- headers: {
+ method: 'POST',
+ headers: {
'PRIVATE-TOKEN': data.token,
'Content-Type': 'application/json',
},
body: JSON.stringify({ body }),
})
- .then((response) => {
- if (response.ok) {
- confirmAndClear(mergeRequestId);
- return;
- }
-
- throw new Error(`${response.status}: ${response.statusText}`)
- })
- .catch((err) => {
- postError(`The feedback was not sent successfully. Please try again. Error: ${err.message}`, 'gitlab-comment');
- resetCommentBox();
- });
+ .then(response => {
+ if (response.ok) {
+ confirmAndClear(mergeRequestId);
+ return;
+ }
+
+ throw new Error(`${response.status}: ${response.statusText}`);
+ })
+ .catch(err => {
+ postError(
+ `The feedback was not sent successfully. Please try again. Error: ${err.message}`,
+ 'gitlab-comment',
+ );
+ resetCommentBox();
+ });
}
-function postError (message, inputId) {
+function postError(message, inputId) {
const note = document.getElementById('gitlab-validation-note');
const field = document.getElementById(inputId);
field.style.borderColor = '#db3b21';
@@ -543,8 +293,7 @@ function setInProgressState() {
commentBox.style.pointerEvents = 'none';
}
-function storeToken (token) {
-
+function storeToken(token) {
const { localStorage } = window;
// All the browsers we support have localStorage, so let's silently fail
@@ -556,7 +305,7 @@ function storeToken (token) {
}
}
-function toggleForm () {
+function toggleForm() {
const container = document.getElementById('gitlab-review-container');
const collapseButton = document.getElementById('gitlab-collapse');
const form = document.getElementById('gitlab-form-wrapper');
@@ -578,7 +327,7 @@ function toggleForm () {
display: 'none',
backgroundColor: 'rgba(255, 255, 255, 0)',
},
- }
+ };
const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN;
@@ -593,9 +342,9 @@ function toggleForm () {
///////////////// INJECTION //////////////////
///////////////////////////////////////////////
-function noop() {};
+function noop() {}
-const eventLookup = ({target: { id }}) => {
+const eventLookup = ({ target: { id } }) => {
switch (id) {
case 'gitlab-collapse':
return toggleForm;
@@ -620,9 +369,9 @@ window.addEventListener('load', () => {
container.insertAdjacentHTML('beforeend', form(content));
document.body.insertBefore(container, document.body.firstChild);
- addStylesheet();
- document.getElementById('gitlab-review-container').addEventListener('click', (event) => {
+ document.getElementById('gitlab-review-container').addEventListener('click', event => {
eventLookup(event)();
});
+
});