summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js73
-rw-r--r--app/assets/javascripts/behaviors/index.js2
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js74
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js454
-rw-r--r--app/assets/javascripts/dispatcher.js8
-rw-r--r--app/assets/javascripts/droplab/constants.js2
-rw-r--r--app/assets/javascripts/droplab/drop_down.js29
-rw-r--r--app/assets/javascripts/droplab/hook.js2
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue16
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue2
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js2
-rw-r--r--app/assets/javascripts/main.js5
-rw-r--r--app/assets/javascripts/notes/components/issue_comment_form.vue22
-rw-r--r--app/assets/javascripts/notes/components/issue_discussion.vue6
-rw-r--r--app/assets/javascripts/notes/components/issue_note_form.vue12
-rw-r--r--app/assets/javascripts/notes/components/issue_notes_app.vue6
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js (renamed from app/assets/javascripts/notes/services/issue_notes_service.js)0
-rw-r--r--app/assets/javascripts/notes/stores/actions.js4
-rw-r--r--app/assets/javascripts/notes/stores/getters.js4
-rw-r--r--app/assets/javascripts/notes/stores/index.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/project.js3
-rw-r--r--app/assets/javascripts/projects/ci_cd_settings_bundle.js19
-rw-r--r--app/assets/javascripts/projects_dropdown/components/projects_list_item.vue25
-rw-r--r--app/assets/javascripts/repo/components/commit_sidebar/list.vue89
-rw-r--r--app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue35
-rw-r--r--app/assets/javascripts/repo/components/commit_sidebar/list_item.vue36
-rw-r--r--app/assets/javascripts/repo/components/repo.vue32
-rw-r--r--app/assets/javascripts/repo/components/repo_commit_section.vue159
-rw-r--r--app/assets/javascripts/repo/components/repo_editor.vue54
-rw-r--r--app/assets/javascripts/repo/components/repo_file.vue5
-rw-r--r--app/assets/javascripts/repo/components/repo_file_buttons.vue10
-rw-r--r--app/assets/javascripts/repo/components/repo_preview.vue6
-rw-r--r--app/assets/javascripts/repo/components/repo_sidebar.vue10
-rw-r--r--app/assets/javascripts/repo/components/repo_tab.vue25
-rw-r--r--app/assets/javascripts/repo/components/repo_tabs.vue4
-rw-r--r--app/assets/javascripts/repo/lib/common/disposable.js14
-rw-r--r--app/assets/javascripts/repo/lib/common/model.js56
-rw-r--r--app/assets/javascripts/repo/lib/common/model_manager.js32
-rw-r--r--app/assets/javascripts/repo/lib/decorations/controller.js43
-rw-r--r--app/assets/javascripts/repo/lib/diff/controller.js71
-rw-r--r--app/assets/javascripts/repo/lib/diff/diff.js30
-rw-r--r--app/assets/javascripts/repo/lib/diff/diff_worker.js10
-rw-r--r--app/assets/javascripts/repo/lib/editor.js79
-rw-r--r--app/assets/javascripts/repo/lib/editor_options.js2
-rw-r--r--app/assets/javascripts/repo/services/index.js4
-rw-r--r--app/assets/javascripts/repo/stores/getters.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue11
-rw-r--r--app/assets/stylesheets/framework/animations.scss2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss22
-rw-r--r--app/assets/stylesheets/framework/common.scss33
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss1
-rw-r--r--app/assets/stylesheets/framework/flash.scss12
-rw-r--r--app/assets/stylesheets/framework/icons.scss28
-rw-r--r--app/assets/stylesheets/framework/new-nav.scss0
-rw-r--r--app/assets/stylesheets/framework/selects.scss18
-rw-r--r--app/assets/stylesheets/framework/tw_bootstrap.scss27
-rw-r--r--app/assets/stylesheets/pages/issuable.scss5
-rw-r--r--app/assets/stylesheets/pages/issues.scss69
-rw-r--r--app/assets/stylesheets/pages/login.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss11
-rw-r--r--app/assets/stylesheets/pages/projects.scss25
-rw-r--r--app/assets/stylesheets/pages/repo.scss460
-rw-r--r--app/controllers/concerns/issuable_actions.rb2
-rw-r--r--app/controllers/projects/branches_controller.rb2
-rw-r--r--app/controllers/projects/issues_controller.rb3
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb19
-rw-r--r--app/finders/notes_finder.rb3
-rw-r--r--app/finders/runner_jobs_finder.rb22
-rw-r--r--app/helpers/application_settings_helper.rb7
-rw-r--r--app/helpers/auto_devops_helper.rb18
-rw-r--r--app/helpers/button_helper.rb56
-rw-r--r--app/helpers/issuables_helper.rb1
-rw-r--r--app/models/application_setting.rb26
-rw-r--r--app/models/ci/pipeline.rb3
-rw-r--r--app/models/ci/runner.rb6
-rw-r--r--app/models/clusters/cluster.rb9
-rw-r--r--app/models/clusters/platforms/kubernetes.rb143
-rw-r--r--app/models/concerns/has_variable.rb4
-rw-r--r--app/models/concerns/issuable.rb21
-rw-r--r--app/models/email.rb1
-rw-r--r--app/models/environment.rb4
-rw-r--r--app/models/group.rb14
-rw-r--r--app/models/merge_request.rb55
-rw-r--r--app/models/merge_request_diff.rb72
-rw-r--r--app/models/milestone.rb6
-rw-r--r--app/models/namespace.rb6
-rw-r--r--app/models/note.rb5
-rw-r--r--app/models/project.rb35
-rw-r--r--app/models/project_services/kubernetes_service.rb5
-rw-r--r--app/models/protected_branch.rb4
-rw-r--r--app/models/protected_tag.rb4
-rw-r--r--app/models/snippet.rb11
-rw-r--r--app/models/storage/hashed_project.rb1
-rw-r--r--app/models/user.rb24
-rw-r--r--app/policies/group_policy.rb2
-rw-r--r--app/policies/namespace_policy.rb1
-rw-r--r--app/services/base_count_service.rb8
-rw-r--r--app/services/ci/create_pipeline_service.rb55
-rw-r--r--app/services/compare_service.rb12
-rw-r--r--app/services/issuable_base_service.rb33
-rw-r--r--app/services/issues/base_service.rb8
-rw-r--r--app/services/issues/update_service.rb7
-rw-r--r--app/services/merge_requests/base_service.rb8
-rw-r--r--app/services/merge_requests/build_service.rb82
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb24
-rw-r--r--app/services/merge_requests/update_service.rb5
-rw-r--r--app/services/projects/hashed_storage/migrate_attachments_service.rb54
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb70
-rw-r--r--app/services/projects/hashed_storage_migration_service.rb62
-rw-r--r--app/services/projects/update_service.rb10
-rw-r--r--app/uploaders/file_uploader.rb11
-rw-r--r--app/views/admin/application_settings/_form.html.haml24
-rw-r--r--app/views/events/_event.atom.builder7
-rw-r--r--app/views/layouts/_flash.html.haml16
-rw-r--r--app/views/layouts/devise.html.haml3
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/projects/clusters/new.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/forks/_fork_button.html.haml26
-rw-r--r--app/views/projects/forks/new.html.haml17
-rw-r--r--app/views/projects/issues/_discussion.html.haml2
-rw-r--r--app/views/projects/issues/_new_branch.html.haml60
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml26
-rw-r--r--app/views/projects/show.html.haml2
-rw-r--r--app/views/projects/tree/show.html.haml2
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/projects/_project.html.haml2
-rw-r--r--app/views/shared/repo/_repo.html.haml1
-rw-r--r--app/workers/create_pipeline_worker.rb16
-rw-r--r--app/workers/project_migrate_hashed_storage_worker.rb26
138 files changed, 2429 insertions, 1191 deletions
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
new file mode 100644
index 00000000000..cdea625fc8c
--- /dev/null
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -0,0 +1,73 @@
+import Clipboard from 'clipboard';
+
+function showTooltip(target, title) {
+ const $target = $(target);
+ const originalTitle = $target.data('original-title');
+
+ if (!$target.data('hideTooltip')) {
+ $target
+ .attr('title', title)
+ .tooltip('fixTitle')
+ .tooltip('show')
+ .attr('title', originalTitle)
+ .tooltip('fixTitle');
+ }
+}
+
+function genericSuccess(e) {
+ showTooltip(e.trigger, 'Copied');
+ // Clear the selection and blur the trigger so it loses its border
+ e.clearSelection();
+ $(e.trigger).blur();
+}
+
+/**
+ * Safari > 10 doesn't support `execCommand`, so instead we inform the user to copy manually.
+ * See http://clipboardjs.com/#browser-support
+ */
+function genericError(e) {
+ let key;
+ if (/Mac/i.test(navigator.userAgent)) {
+ key = '⌘'; // Command
+ } else {
+ key = 'Ctrl';
+ }
+ showTooltip(e.trigger, `Press ${key}-C to copy`);
+}
+
+export default function initCopyToClipboard() {
+ const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
+ clipboard.on('success', genericSuccess);
+ clipboard.on('error', genericError);
+
+ /**
+ * This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting
+ * of plain text or GFM. The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and
+ * `gfm` keys into the `data-clipboard-text` attribute that ClipboardJS reads from.
+ * When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly`
+ * attribute`), sets its value to the value of this data attribute, focusses on it, and finally
+ * programmatically issues the 'Copy' command, this code intercepts the copy command/event at
+ * the last minute to deconstruct this JSON hash and set the `text/plain` and `text/x-gfm` copy
+ * data types to the intended values.
+ */
+ $(document).on('copy', 'body > textarea[readonly]', (e) => {
+ const clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ const text = e.target.value;
+
+ let json;
+ try {
+ json = JSON.parse(text);
+ } catch (ex) {
+ return;
+ }
+
+ if (!json.text || !json.gfm) return;
+
+ e.preventDefault();
+
+ clipboardData.setData('text/plain', json.text);
+ clipboardData.setData('text/x-gfm', json.gfm);
+ });
+}
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 671532394a9..34e905222b4 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -1,6 +1,7 @@
import './autosize';
import './bind_in_out';
import initCopyAsGFM from './copy_as_gfm';
+import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior';
import installGlEmojiElement from './gl_emoji';
import './quick_submit';
@@ -9,3 +10,4 @@ import './toggler_behavior';
installGlEmojiElement();
initCopyAsGFM();
+initCopyToClipboard();
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
deleted file mode 100644
index 1f3c7e1772d..00000000000
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, prefer-arrow-callback, max-len */
-
-import Clipboard from 'vendor/clipboard';
-
-var genericError, genericSuccess, showTooltip;
-
-genericSuccess = function(e) {
- showTooltip(e.trigger, 'Copied');
- // Clear the selection and blur the trigger so it loses its border
- e.clearSelection();
- return $(e.trigger).blur();
-};
-
-// Safari doesn't support `execCommand`, so instead we inform the user to
-// copy manually.
-//
-// See http://clipboardjs.com/#browser-support
-genericError = function(e) {
- var key;
- if (/Mac/i.test(navigator.userAgent)) {
- key = '⌘'; // Command
- } else {
- key = 'Ctrl';
- }
- return showTooltip(e.trigger, "Press " + key + "-C to copy");
-};
-
-showTooltip = function(target, title) {
- var $target = $(target);
- var originalTitle = $target.data('original-title');
-
- if (!$target.data('hideTooltip')) {
- $target
- .attr('title', 'Copied')
- .tooltip('fixTitle')
- .tooltip('show')
- .attr('title', originalTitle)
- .tooltip('fixTitle');
- }
-};
-
-$(function() {
- const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
- clipboard.on('success', genericSuccess);
- clipboard.on('error', genericError);
-
- // This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
- // The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text`
- // attribute that ClipboardJS reads from.
- // When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value
- // to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command,
- // this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the
- // `text/plain` and `text/x-gfm` copy data types to the intended values.
- $(document).on('copy', 'body > textarea[readonly]', function(e) {
- const clipboardData = e.originalEvent.clipboardData;
- if (!clipboardData) return;
-
- const text = e.target.value;
-
- let json;
- try {
- json = JSON.parse(text);
- } catch (ex) {
- return;
- }
-
- if (!json.text || !json.gfm) return;
-
- e.preventDefault();
-
- clipboardData.setData('text/plain', json.text);
- clipboardData.setData('text/x-gfm', json.gfm);
- });
-});
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index bf40eb3ee11..23425672b16 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -2,6 +2,7 @@
import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
+import { __, sprintf } from './locale';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
@@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch';
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
+ this.availableButton = this.wrapperEl.querySelector('.available');
+ this.branchInput = this.wrapperEl.querySelector('.js-branch-name');
+ this.branchMessage = this.wrapperEl.querySelector('.js-branch-message');
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
- this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
+ this.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
- this.availableButton = this.wrapperEl.querySelector('.available');
+ this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
+ this.refInput = this.wrapperEl.querySelector('.js-ref');
+ this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
- this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
+ this.branchCreated = false;
+ this.branchIsValid = true;
this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
+ this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false;
+ this.isCreatingBranch = false;
this.isCreatingMergeRequest = false;
+ this.isGettingRef = false;
this.mergeRequestCreated = false;
- this.isCreatingBranch = false;
- this.branchCreated = false;
+ this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
+ this.refIsValid = true;
+ this.refsPath = this.wrapperEl.dataset.refsPath;
+ this.suggestedRef = this.refInput.value;
- this.init();
- }
+ // These regexps are used to replace
+ // a backend generated new branch name and its source (ref)
+ // with user's inputs.
+ this.regexps = {
+ branch: {
+ createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'),
+ createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'),
+ },
+ ref: {
+ createBranchPath: new RegExp('(ref=)(.+?)$'),
+ createMrPath: new RegExp('(ref=)(.+?)$'),
+ },
+ };
- init() {
- this.checkAbilityToCreateBranch();
+ this.init();
}
available() {
@@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.add('hide');
}
- unavailable() {
- this.availableButton.classList.add('hide');
- this.unavailableButton.classList.remove('hide');
- }
-
- enable() {
- this.createMergeRequestButton.classList.remove('disabled');
- this.createMergeRequestButton.removeAttribute('disabled');
-
- this.dropdownToggle.classList.remove('disabled');
- this.dropdownToggle.removeAttribute('disabled');
- }
-
- disable() {
- this.createMergeRequestButton.classList.add('disabled');
- this.createMergeRequestButton.setAttribute('disabled', 'disabled');
-
- this.dropdownToggle.classList.add('disabled');
- this.dropdownToggle.setAttribute('disabled', 'disabled');
- }
-
- hide() {
- this.wrapperEl.classList.add('hide');
- }
-
- setUnavailableButtonState(isLoading = true) {
- if (isLoading) {
- this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin');
- this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
- this.unavailableButtonText.textContent = 'Checking branch availability…';
- } else {
- this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin');
- this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
- this.unavailableButtonText.textContent = 'New branch unavailable';
- }
+ bindEvents() {
+ this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
+ this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
+ this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
+ this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
}
checkAbilityToCreateBranch() {
@@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown {
});
}
- initDroplab() {
- this.droplab = new DropLab();
+ createBranch() {
+ return $.ajax({
+ method: 'POST',
+ dataType: 'json',
+ url: this.createBranchPath,
+ beforeSend: () => (this.isCreatingBranch = true),
+ })
+ .done((data) => {
+ this.branchCreated = true;
+ window.location.href = data.url;
+ })
+ .fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
+ }
- this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter],
- this.getDroplabConfig());
+ createMergeRequest() {
+ return $.ajax({
+ method: 'POST',
+ dataType: 'json',
+ url: this.createMrPath,
+ beforeSend: () => (this.isCreatingMergeRequest = true),
+ })
+ .done((data) => {
+ this.mergeRequestCreated = true;
+ window.location.href = data.url;
+ })
+ .fail(() => new Flash('Failed to create Merge Request. Please try again.'));
+ }
+
+ disable() {
+ this.disableCreateAction();
+
+ this.dropdownToggle.classList.add('disabled');
+ this.dropdownToggle.setAttribute('disabled', 'disabled');
+ }
+
+ disableCreateAction() {
+ this.createMergeRequestButton.classList.add('disabled');
+ this.createMergeRequestButton.setAttribute('disabled', 'disabled');
+
+ this.createTargetButton.classList.add('disabled');
+ this.createTargetButton.setAttribute('disabled', 'disabled');
+ }
+
+ enable() {
+ this.createMergeRequestButton.classList.remove('disabled');
+ this.createMergeRequestButton.removeAttribute('disabled');
+
+ this.createTargetButton.classList.remove('disabled');
+ this.createTargetButton.removeAttribute('disabled');
+
+ this.dropdownToggle.classList.remove('disabled');
+ this.dropdownToggle.removeAttribute('disabled');
+ }
+
+ static findByValue(objects, ref, returnFirstMatch = false) {
+ if (!objects || !objects.length) return false;
+ if (objects.indexOf(ref) > -1) return ref;
+ if (returnFirstMatch) return objects.find(item => new RegExp(`^${ref}`).test(item));
+
+ return false;
}
getDroplabConfig() {
return {
- InputSetter: [{
- input: this.createMergeRequestButton,
- valueAttribute: 'data-value',
- inputAttribute: 'data-action',
- }, {
- input: this.createMergeRequestButton,
- valueAttribute: 'data-text',
- }],
+ addActiveClassToDropdownButton: true,
+ InputSetter: [
+ {
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ },
+ {
+ input: this.createMergeRequestButton,
+ valueAttribute: 'data-text',
+ },
+ {
+ input: this.createTargetButton,
+ valueAttribute: 'data-value',
+ inputAttribute: 'data-action',
+ },
+ {
+ input: this.createTargetButton,
+ valueAttribute: 'data-text',
+ },
+ ],
};
}
- bindEvents() {
- this.createMergeRequestButton
- .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
+ static getInputSelectedText(input) {
+ const start = input.selectionStart;
+ const end = input.selectionEnd;
+
+ return input.value.substr(start, end - start);
+ }
+
+ getRef(ref, target = 'all') {
+ if (!ref) return false;
+
+ return $.ajax({
+ method: 'GET',
+ dataType: 'json',
+ url: this.refsPath + ref,
+ beforeSend: () => {
+ this.isGettingRef = true;
+ },
+ })
+ .always(() => {
+ this.isGettingRef = false;
+ })
+ .done((data) => {
+ const branches = data[Object.keys(data)[0]];
+ const tags = data[Object.keys(data)[1]];
+ let result;
+
+ if (target === 'branch') {
+ result = CreateMergeRequestDropdown.findByValue(branches, ref);
+ } else {
+ result = CreateMergeRequestDropdown.findByValue(branches, ref, true) ||
+ CreateMergeRequestDropdown.findByValue(tags, ref, true);
+ this.suggestedRef = result;
+ }
+
+ return this.updateInputState(target, ref, result);
+ })
+ .fail(() => {
+ this.unavailable();
+ this.disable();
+ new Flash('Failed to get ref.');
+
+ return false;
+ });
+ }
+
+ getTargetData(target) {
+ return {
+ input: this[`${target}Input`],
+ message: this[`${target}Message`],
+ };
+ }
+
+ hide() {
+ this.wrapperEl.classList.add('hide');
+ }
+
+ init() {
+ this.checkAbilityToCreateBranch();
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+
+ this.droplab.init(
+ this.dropdownToggle,
+ this.dropdownList,
+ [InputSetter],
+ this.getDroplabConfig(),
+ );
+ }
+
+ inputsAreValid() {
+ return this.branchIsValid && this.refIsValid;
}
isBusy() {
return this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
- this.branchCreated;
+ this.branchCreated ||
+ this.isGettingRef;
}
- onClickCreateMergeRequestButton(e) {
+ onChangeInput(event) {
+ let target;
+ let value;
+
+ if (event.srcElement === this.branchInput) {
+ target = 'branch';
+ value = this.branchInput.value;
+ } else if (event.srcElement === this.refInput) {
+ target = 'ref';
+ value = event.srcElement.value.slice(0, event.srcElement.selectionStart) +
+ event.srcElement.value.slice(event.srcElement.selectionEnd);
+ } else {
+ return false;
+ }
+
+ if (this.isGettingRef) return false;
+
+ // `ENTER` key submits the data.
+ if (event.keyCode === 13 && this.inputsAreValid()) {
+ event.preventDefault();
+ return this.createMergeRequestButton.click();
+ }
+
+ // If the input is empty, use the original value generated by the backend.
+ if (!value) {
+ this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
+ this.createMrPath = this.wrapperEl.dataset.createMrPath;
+
+ if (target === 'branch') {
+ this.branchIsValid = true;
+ } else {
+ this.refIsValid = true;
+ }
+
+ this.enable();
+ this.showAvailableMessage(target);
+ return true;
+ }
+
+ this.showCheckingMessage(target);
+ this.refDebounce(value, target);
+
+ return true;
+ }
+
+ onClickCreateMergeRequestButton(event) {
let xhr = null;
- e.preventDefault();
+ event.preventDefault();
if (this.isBusy()) {
return;
}
- if (e.target.dataset.action === CREATE_MERGE_REQUEST) {
+ if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
- } else if (e.target.dataset.action === CREATE_BRANCH) {
+ } else if (event.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
@@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown {
this.disable();
}
- createMergeRequest() {
- return $.ajax({
- method: 'POST',
- dataType: 'json',
- url: this.createMrPath,
- beforeSend: () => (this.isCreatingMergeRequest = true),
- })
- .done((data) => {
- this.mergeRequestCreated = true;
- window.location.href = data.url;
- })
- .fail(() => new Flash('Failed to create Merge Request. Please try again.'));
+ onClickSetFocusOnBranchNameInput() {
+ this.branchInput.focus();
}
- createBranch() {
- return $.ajax({
- method: 'POST',
- dataType: 'json',
- url: this.createBranchPath,
- beforeSend: () => (this.isCreatingBranch = true),
- })
- .done((data) => {
- this.branchCreated = true;
- window.location.href = data.url;
- })
- .fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
+ // `TAB` autocompletes the source.
+ static processTab(event) {
+ if (event.keyCode !== 9 || this.isGettingRef) return;
+
+ const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
+
+ // if nothing selected, we don't need to autocomplete anything. Do the default TAB action.
+ // If a user manually selected text, don't autocomplete anything. Do the default TAB action.
+ if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
+
+ event.preventDefault();
+ window.getSelection().removeAllRanges();
+ }
+
+ removeMessage(target) {
+ const { input, message } = this.getTargetData(target);
+ const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline'];
+ const messageClasses = ['gl-field-hint', 'gl-field-error-message', 'gl-field-success-message'];
+
+ inputClasses.forEach(cssClass => input.classList.remove(cssClass));
+ messageClasses.forEach(cssClass => message.classList.remove(cssClass));
+ message.style.display = 'none';
+ }
+
+ setUnavailableButtonState(isLoading = true) {
+ if (isLoading) {
+ this.unavailableButtonArrow.classList.add('fa-spin');
+ this.unavailableButtonArrow.classList.add('fa-spinner');
+ this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = __('Checking branch availability...');
+ } else {
+ this.unavailableButtonArrow.classList.remove('fa-spin');
+ this.unavailableButtonArrow.classList.remove('fa-spinner');
+ this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
+ this.unavailableButtonText.textContent = __('New branch unavailable');
+ }
+ }
+
+ showAvailableMessage(target) {
+ const { input, message } = this.getTargetData(target);
+ const text = target === 'branch' ? __('Branch name') : __('Source');
+
+ this.removeMessage(target);
+ input.classList.add('gl-field-success-outline');
+ message.classList.add('gl-field-success-message');
+ message.textContent = sprintf(__('%{text} is available'), { text });
+ message.style.display = 'inline-block';
+ }
+
+ showCheckingMessage(target) {
+ const { message } = this.getTargetData(target);
+ const text = target === 'branch' ? __('branch name') : __('source');
+
+ this.removeMessage(target);
+ message.classList.add('gl-field-hint');
+ message.textContent = sprintf(__('Checking %{text} availability…'), { text });
+ message.style.display = 'inline-block';
+ }
+
+ showNotAvailableMessage(target) {
+ const { input, message } = this.getTargetData(target);
+ const text = target === 'branch' ? __('Branch is already taken') : __('Source is not available');
+
+ this.removeMessage(target);
+ input.classList.add('gl-field-error-outline');
+ message.classList.add('gl-field-error-message');
+ message.textContent = text;
+ message.style.display = 'inline-block';
+ }
+
+ unavailable() {
+ this.availableButton.classList.add('hide');
+ this.unavailableButton.classList.remove('hide');
+ }
+
+ updateInputState(target, ref, result) {
+ // target - 'branch' or 'ref' - which the input field we are searching a ref for.
+ // ref - string - what a user typed.
+ // result - string - what has been found on backend.
+
+ const pathReplacement = `$1${ref}`;
+
+ // If a found branch equals exact the same text a user typed,
+ // that means a new branch cannot be created as it already exists.
+ if (ref === result) {
+ if (target === 'branch') {
+ this.branchIsValid = false;
+ this.showNotAvailableMessage('branch');
+ } else {
+ this.refIsValid = true;
+ this.refInput.dataset.value = ref;
+ this.showAvailableMessage('ref');
+ this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath,
+ pathReplacement);
+ this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath,
+ pathReplacement);
+ }
+ } else if (target === 'branch') {
+ this.branchIsValid = true;
+ this.showAvailableMessage('branch');
+ this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath,
+ pathReplacement);
+ this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath,
+ pathReplacement);
+ } else {
+ this.refIsValid = false;
+ this.refInput.dataset.value = ref;
+ this.disableCreateAction();
+ this.showNotAvailableMessage('ref');
+
+ // Show ref hint.
+ if (result) {
+ this.refInput.value = result;
+ this.refInput.setSelectionRange(ref.length, result.length);
+ }
+ }
+
+ if (this.inputsAreValid()) {
+ this.enable();
+ } else {
+ this.disableCreateAction();
+ }
}
}
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 34708977d20..a21c92f24d6 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -383,6 +383,7 @@ import ProjectVariables from './project_variables';
projectImport();
break;
case 'projects:pipelines:new':
+ case 'projects:pipelines:create':
new NewBranchForm($('.js-new-pipeline-form'));
break;
case 'projects:pipelines:builds':
@@ -521,6 +522,13 @@ import ProjectVariables from './project_variables';
case 'projects:settings:ci_cd:show':
// Initialize expandable settings panels
initSettingsPanels();
+
+ import(/* webpackChunkName: "ci-cd-settings" */ './projects/ci_cd_settings_bundle')
+ .then(ciCdSettings => ciCdSettings.default())
+ .catch((err) => {
+ Flash(s__('ProjectSettings|Problem setting up the CI/CD settings JavaScript'));
+ throw err;
+ });
case 'groups:settings:ci_cd:show':
new ProjectVariables();
break;
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
index 868d47e91b3..673e9bb4c0f 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/droplab/constants.js
@@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
+const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding';
// Matches `{{anything}}` and `{{ everything }}`.
const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
@@ -13,4 +14,5 @@ export {
ACTIVE_CLASS,
TEMPLATE_REGEX,
IGNORE_CLASS,
+ IGNORE_HIDING_CLASS,
};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index 3901bb177fe..5eb0a339a1c 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -1,15 +1,18 @@
import utils from './utils';
-import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
+import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants';
class DropDown {
- constructor(list) {
+ constructor(list, config = {}) {
this.currentIndex = 0;
this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = [];
-
this.eventWrapper = {};
+ if (config.addActiveClassToDropdownButton) {
+ this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
+ }
+
this.getItems();
this.initTemplateString();
this.addEvents();
@@ -42,7 +45,7 @@ class DropDown {
this.addSelectedClass(selected);
e.preventDefault();
- this.hide();
+ if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide();
const listEvent = new CustomEvent('click.dl', {
detail: {
@@ -67,7 +70,20 @@ class DropDown {
addEvents() {
this.eventWrapper.clickEvent = this.clickEvent.bind(this);
+ this.eventWrapper.closeDropdown = this.closeDropdown.bind(this);
+
this.list.addEventListener('click', this.eventWrapper.clickEvent);
+ this.list.addEventListener('keyup', this.eventWrapper.closeDropdown);
+ }
+
+ closeDropdown(event) {
+ // `ESC` key closes the dropdown.
+ if (event.keyCode === 27) {
+ event.preventDefault();
+ return this.toggle();
+ }
+
+ return true;
}
setData(data) {
@@ -110,6 +126,8 @@ class DropDown {
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
+
+ if (this.dropdownToggle) this.dropdownToggle.classList.add('active');
}
hide() {
@@ -117,6 +135,8 @@ class DropDown {
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
+
+ if (this.dropdownToggle) this.dropdownToggle.classList.remove('active');
}
toggle() {
@@ -128,6 +148,7 @@ class DropDown {
destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
+ this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown);
}
static setImagesSrc(template) {
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
index cf78165b0d8..8a8dcde9f88 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/droplab/hook.js
@@ -3,7 +3,7 @@ import DropDown from './drop_down';
class Hook {
constructor(trigger, list, plugins, config) {
this.trigger = trigger;
- this.list = new DropDown(list);
+ this.list = new DropDown(list, config);
this.type = 'Hook';
this.event = 'click';
this.plugins = plugins || [];
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 67261c1c9b4..44deab9288e 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -41,7 +41,7 @@ const createFlashEl = (message, type, isInContentWrapper = false) => `
`;
const removeFlashClickListener = (flashEl, fadeTransition) => {
- flashEl.parentNode.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+ flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
};
/*
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 1f9571ff413..5bdc7c99503 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -16,6 +16,10 @@ export default {
required: true,
type: String,
},
+ updateEndpoint: {
+ required: true,
+ type: String,
+ },
canUpdate: {
required: true,
type: Boolean,
@@ -262,6 +266,8 @@ export default {
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus"
+ :issuable-type="issuableType"
+ :update-url="updateEndpoint"
/>
<edited-component
v-if="hasUpdated"
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 48bad8f1e68..b7559ced946 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -22,6 +22,16 @@
required: false,
default: '',
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ updateUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -48,7 +58,7 @@
if (this.canUpdate) {
// eslint-disable-next-line no-new
new TaskList({
- dataType: 'issue',
+ dataType: this.issuableType,
fieldName: 'description',
selector: '.detail-page-description',
});
@@ -95,7 +105,9 @@
<textarea
class="hidden js-task-list-field"
v-if="descriptionText"
- v-model="descriptionText">
+ v-model="descriptionText"
+ :data-update-url="updateUrl"
+ >
</textarea>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index 00002709ac6..a363d06d950 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -79,7 +79,7 @@
v-tooltip
v-if="showInlineEditButton && canUpdate"
type="button"
- class="btn-blank btn-edit note-action-button"
+ class="btn btn-default btn-edit btn-svg"
v-html="pencilIcon"
title="Edit title and description"
data-placement="bottom"
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 6fa1e84c170..33cc807912c 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -190,7 +190,7 @@ export const insertText = (target, text) => {
target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave
- $(target).trigger('input');
+ target.dispatchEvent(new Event('input'));
// Trigger autosize
const event = document.createEvent('Event');
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 08e326cba9c..dcc0fa63b63 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -44,7 +44,6 @@ import './commits';
import './compare';
import './compare_autocomplete';
import './confirm_danger_modal';
-import './copy_to_clipboard';
import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
import './gl_field_error';
@@ -301,6 +300,8 @@ $(function () {
const flashContainer = document.querySelector('.flash-container');
if (flashContainer && flashContainer.children.length) {
- removeFlashClickListener(flashContainer.children[0]);
+ flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => {
+ removeFlashClickListener(flashEl);
+ });
}
});
diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue
index 30e02554b65..dbc900f4c04 100644
--- a/app/assets/javascripts/notes/components/issue_comment_form.vue
+++ b/app/assets/javascripts/notes/components/issue_comment_form.vue
@@ -22,7 +22,7 @@
noteType: constants.COMMENT,
// Can't use mapGetters,
// this needs to be in the data object because it belongs to the state
- issueState: this.$store.getters.getIssueData.state,
+ issueState: this.$store.getters.getNoteableData.state,
isSubmitting: false,
isSubmitButtonDisabled: true,
};
@@ -46,7 +46,7 @@
...mapGetters([
'getCurrentUserLastNote',
'getUserData',
- 'getIssueData',
+ 'getNoteableData',
'getNotesData',
]),
isLoggedIn() {
@@ -59,7 +59,7 @@
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
},
canCreateNote() {
- return this.getIssueData.current_user.can_create_note;
+ return this.getNoteableData.current_user.can_create_note;
},
issueActionButtonTitle() {
if (this.note.length) {
@@ -85,16 +85,16 @@
return this.getNotesData.quickActionsDocsPath;
},
markdownPreviewPath() {
- return this.getIssueData.preview_note_path;
+ return this.getNoteableData.preview_note_path;
},
author() {
return this.getUserData;
},
canUpdateIssue() {
- return this.getIssueData.current_user.can_update;
+ return this.getNoteableData.current_user.can_update;
},
endpoint() {
- return this.getIssueData.create_note_path;
+ return this.getNoteableData.create_note_path;
},
},
methods: {
@@ -119,7 +119,7 @@
data: {
note: {
noteable_type: constants.NOTEABLE_TYPE,
- noteable_id: this.getIssueData.id,
+ noteable_id: this.getNoteableData.id,
note: this.note,
},
},
@@ -207,7 +207,7 @@
},
initAutoSave() {
if (this.isLoggedIn) {
- this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue');
+ this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getNoteableData.id], 'issue');
}
},
initTaskList() {
@@ -266,9 +266,9 @@
<div class="error-alert"></div>
<issue-warning
- v-if="hasWarning(getIssueData)"
- :is-locked="isLocked(getIssueData)"
- :is-confidential="isConfidential(getIssueData)"
+ v-if="hasWarning(getNoteableData)"
+ :is-locked="isLocked(getNoteableData)"
+ :is-confidential="isConfidential(getNoteableData)"
/>
<markdown-field
diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue
index 0f13221b81e..26f729d8875 100644
--- a/app/assets/javascripts/notes/components/issue_discussion.vue
+++ b/app/assets/javascripts/notes/components/issue_discussion.vue
@@ -41,7 +41,7 @@
],
computed: {
...mapGetters([
- 'getIssueData',
+ 'getNoteableData',
]),
discussion() {
return this.note.notes[0];
@@ -50,10 +50,10 @@
return this.discussion.author;
},
canReply() {
- return this.getIssueData.current_user.can_create_note;
+ return this.getNoteableData.current_user.can_create_note;
},
newNotePath() {
- return this.getIssueData.create_note_path;
+ return this.getNoteableData.create_note_path;
},
lastUpdatedBy() {
const { notes } = this.note;
diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue
index e2539d6b89d..4d527cb6643 100644
--- a/app/assets/javascripts/notes/components/issue_note_form.vue
+++ b/app/assets/javascripts/notes/components/issue_note_form.vue
@@ -46,8 +46,8 @@
computed: {
...mapGetters([
'getDiscussionLastNote',
- 'getIssueData',
- 'getIssueDataByProp',
+ 'getNoteableData',
+ 'getNoteableDataByProp',
'getNotesDataByProp',
'getUserDataByProp',
]),
@@ -55,7 +55,7 @@
return `#note_${this.noteId}`;
},
markdownPreviewPath() {
- return this.getIssueDataByProp('preview_note_path');
+ return this.getNoteableDataByProp('preview_note_path');
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -129,9 +129,9 @@
class="edit-note common-note-form js-quick-submit gfm-form">
<issue-warning
- v-if="hasWarning(getIssueData)"
- :is-locked="isLocked(getIssueData)"
- :is-confidential="isConfidential(getIssueData)"
+ v-if="hasWarning(getNoteableData)"
+ :is-locked="isLocked(getNoteableData)"
+ :is-confidential="isConfidential(getNoteableData)"
/>
<markdown-field
diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue
index 5c9119644e3..4cfcffa2391 100644
--- a/app/assets/javascripts/notes/components/issue_notes_app.vue
+++ b/app/assets/javascripts/notes/components/issue_notes_app.vue
@@ -14,7 +14,7 @@
export default {
name: 'issueNotesApp',
props: {
- issueData: {
+ noteableData: {
type: Object,
required: true,
},
@@ -56,7 +56,7 @@
actionToggleAward: 'toggleAward',
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
setNotesData: 'setNotesData',
- setIssueData: 'setIssueData',
+ setNoteableData: 'setNoteableData',
setUserData: 'setUserData',
setLastFetchedAt: 'setLastFetchedAt',
setTargetNoteHash: 'setTargetNoteHash',
@@ -106,7 +106,7 @@
},
created() {
this.setNotesData(this.notesData);
- this.setIssueData(this.issueData);
+ this.setNoteableData(this.noteableData);
this.setUserData(this.userData);
},
mounted() {
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index e2ea37408cf..8d74c5de5cf 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -10,7 +10,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
const notesDataset = document.getElementById('js-vue-notes').dataset;
return {
- issueData: JSON.parse(notesDataset.issueData),
+ noteableData: JSON.parse(notesDataset.noteableData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: {
lastFetchedAt: notesDataset.lastFetchedAt,
@@ -26,7 +26,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({
render(createElement) {
return createElement('issue-notes-app', {
props: {
- issueData: this.issueData,
+ noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
},
diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index b51b0cb2013..b51b0cb2013 100644
--- a/app/assets/javascripts/notes/services/issue_notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 6f04aecc9b7..085b18642ba 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -4,7 +4,7 @@ import Poll from '../../lib/utils/poll';
import * as types from './mutation_types';
import * as utils from './utils';
import * as constants from '../constants';
-import service from '../services/issue_notes_service';
+import service from '../services/notes_service';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
@@ -12,7 +12,7 @@ import { isInViewport, scrollToElement } from '../../lib/utils/common_utils';
let eTagPoll;
export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
-export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data);
+export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data);
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 1f0c6af6156..e18b277119e 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -6,8 +6,8 @@ export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
export const getNotesDataByProp = state => prop => state.notesData[prop];
-export const getIssueData = state => state.issueData;
-export const getIssueDataByProp = state => prop => state.issueData[prop];
+export const getNoteableData = state => state.noteableData;
+export const getNoteableDataByProp = state => prop => state.noteableData[prop];
export const getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
index 8e0c8531bbc..488a9ca38d3 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -15,7 +15,7 @@ export default new Vuex.Store({
// holds endpoints and permissions provided through haml
notesData: {},
userData: {},
- issueData: {},
+ noteableData: {},
},
actions,
getters,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index cd71533ba9d..d520c197407 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -3,7 +3,7 @@ export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
export const DELETE_NOTE = 'DELETE_NOTE';
export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
export const SET_NOTES_DATA = 'SET_NOTES_DATA';
-export const SET_ISSUE_DATA = 'SET_ISSUE_DATA';
+export const SET_NOTEABLE_DATA = 'SET_NOTEABLE_DATA';
export const SET_USER_DATA = 'SET_USER_DATA';
export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index c2a08f3d6fe..20f81a430c2 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -66,8 +66,8 @@ export default {
Object.assign(state, { notesData: data });
},
- [types.SET_ISSUE_DATA](state, data) {
- Object.assign(state, { issueData: data });
+ [types.SET_NOTEABLE_DATA](state, data) {
+ Object.assign(state, { noteableData: data });
},
[types.SET_USER_DATA](state, data) {
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index 36b6a5ed376..3131e71d9d6 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -17,13 +17,14 @@ export default class Project {
$('a', $cloneOptions).on('click', (e) => {
const $this = $(e.currentTarget);
const url = $this.attr('href');
+ const activeText = $this.find('.dropdown-menu-inner-title').text();
e.preventDefault();
$('.is-active', $cloneOptions).not($this).removeClass('is-active');
$this.toggleClass('is-active');
$projectCloneField.val(url);
- $cloneBtnText.text($this.text());
+ $cloneBtnText.text(activeText);
return $('.clone').text(url);
});
diff --git a/app/assets/javascripts/projects/ci_cd_settings_bundle.js b/app/assets/javascripts/projects/ci_cd_settings_bundle.js
new file mode 100644
index 00000000000..90e418f6771
--- /dev/null
+++ b/app/assets/javascripts/projects/ci_cd_settings_bundle.js
@@ -0,0 +1,19 @@
+function updateAutoDevopsRadios(radioWrappers) {
+ radioWrappers.forEach((radioWrapper) => {
+ const radio = radioWrapper.querySelector('.js-auto-devops-enable-radio');
+ const runPipelineCheckboxWrapper = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox-wrapper');
+ const runPipelineCheckbox = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox');
+
+ if (runPipelineCheckbox) {
+ runPipelineCheckbox.checked = radio.checked;
+ runPipelineCheckboxWrapper.classList.toggle('hide', !radio.checked);
+ }
+ });
+}
+
+export default function initCiCdSettings() {
+ const radioWrappers = document.querySelectorAll('.js-auto-devops-enable-radio-wrapper');
+ radioWrappers.forEach(radioWrapper =>
+ radioWrapper.addEventListener('change', () => updateAutoDevopsRadios(radioWrappers)),
+ );
+}
diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
index fe5179de206..d482a7025de 100644
--- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
+++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue
@@ -48,6 +48,27 @@ export default {
}
return this.projectName;
},
+ /**
+ * Smartly truncates project namespace by doing two things;
+ * 1. Only include Group names in path by removing project name
+ * 2. Only include first and last group names in the path
+ * when namespace has more than 2 groups present
+ *
+ * First part (removal of project name from namespace) can be
+ * done from backend but doing so involves migration of
+ * existing project namespaces which is not wise thing to do.
+ */
+ truncatedNamespace() {
+ const namespaceArr = this.namespace.split(' / ');
+ namespaceArr.splice(-1, 1);
+ let namespace = namespaceArr.join(' / ');
+
+ if (namespaceArr.length > 2) {
+ namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
+ }
+
+ return namespace;
+ },
},
};
</script>
@@ -87,9 +108,7 @@ export default {
<div
class="project-namespace"
:title="namespace"
- >
- {{namespace}}
- </div>
+ >{{truncatedNamespace}}</div>
</div>
</a>
</li>
diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list.vue b/app/assets/javascripts/repo/components/commit_sidebar/list.vue
new file mode 100644
index 00000000000..fb862e7bf01
--- /dev/null
+++ b/app/assets/javascripts/repo/components/commit_sidebar/list.vue
@@ -0,0 +1,89 @@
+<script>
+ import icon from '../../../vue_shared/components/icon.vue';
+ import listItem from './list_item.vue';
+ import listCollapsed from './list_collapsed.vue';
+
+ export default {
+ components: {
+ icon,
+ listItem,
+ listCollapsed,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ fileList: {
+ type: Array,
+ required: true,
+ },
+ collapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ toggleCollapsed() {
+ this.$emit('toggleCollapsed');
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-commit-panel-section">
+ <header
+ class="multi-file-commit-panel-header"
+ :class="{
+ 'is-collapsed': collapsed,
+ }"
+ >
+ <icon
+ name="list-bulleted"
+ :size="18"
+ css-classes="append-right-default"
+ />
+ <template v-if="!collapsed">
+ {{ title }}
+ <button
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn"
+ @click="toggleCollapsed"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-angle-double-right"
+ >
+ </i>
+ </button>
+ </template>
+ </header>
+ <div class="multi-file-commit-list">
+ <list-collapsed
+ v-if="collapsed"
+ />
+ <template v-else>
+ <ul
+ v-if="fileList.length"
+ class="list-unstyled append-bottom-0"
+ >
+ <li
+ v-for="file in fileList"
+ :key="file.key"
+ >
+ <list-item
+ :file="file"
+ />
+ </li>
+ </ul>
+ <div
+ v-else
+ class="help-block prepend-top-0"
+ >
+ No changes
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue
new file mode 100644
index 00000000000..6a0262f271b
--- /dev/null
+++ b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue
@@ -0,0 +1,35 @@
+<script>
+ import { mapGetters } from 'vuex';
+ import icon from '../../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ computed: {
+ ...mapGetters([
+ 'addedFiles',
+ 'modifiedFiles',
+ ]),
+ },
+ };
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-list-collapsed text-center"
+ >
+ <icon
+ name="file-addition"
+ :size="18"
+ css-classes="multi-file-addition append-bottom-10"
+ />
+ {{ addedFiles.length }}
+ <icon
+ name="file-modified"
+ :size="18"
+ css-classes="multi-file-modified prepend-top-10 append-bottom-10"
+ />
+ {{ modifiedFiles.length }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue
new file mode 100644
index 00000000000..742f746e02f
--- /dev/null
+++ b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue
@@ -0,0 +1,36 @@
+<script>
+ import icon from '../../../vue_shared/components/icon.vue';
+
+ export default {
+ components: {
+ icon,
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ iconName() {
+ return this.file.tempFile ? 'file-addition' : 'file-modified';
+ },
+ iconClass() {
+ return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
+ },
+ },
+ };
+</script>
+
+<template>
+ <div class="multi-file-commit-list-item">
+ <icon
+ :name="iconName"
+ :size="16"
+ :css-classes="iconClass"
+ />
+ <span class="multi-file-commit-list-path">
+ {{ file.path }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue
index 98117802016..a00e1e9d809 100644
--- a/app/assets/javascripts/repo/components/repo.vue
+++ b/app/assets/javascripts/repo/components/repo.vue
@@ -40,20 +40,24 @@ export default {
</script>
<template>
- <div class="repository-view">
- <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}">
- <repo-sidebar/>
- <div
- v-if="isCollapsed"
- class="panel-right"
- >
- <repo-tabs/>
- <component
- :is="currentBlobView"
- />
- <repo-file-buttons/>
- </div>
+ <div
+ class="multi-file"
+ :class="{
+ 'is-collapsed': isCollapsed
+ }"
+ >
+ <repo-sidebar/>
+ <div
+ v-if="isCollapsed"
+ class="multi-file-edit-pane"
+ >
+ <repo-tabs />
+ <component
+ class="multi-file-edit-pane-content"
+ :is="currentBlobView"
+ />
+ <repo-file-buttons />
</div>
- <repo-commit-section v-if="changedFiles.length" />
+ <repo-commit-section />
</div>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue
index 377e3d65348..d3344d0c8dc 100644
--- a/app/assets/javascripts/repo/components/repo_commit_section.vue
+++ b/app/assets/javascripts/repo/components/repo_commit_section.vue
@@ -1,11 +1,18 @@
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
+import tooltip from '../../vue_shared/directives/tooltip';
+import icon from '../../vue_shared/components/icon.vue';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
-import { n__ } from '../../locale';
+import commitFilesList from './commit_sidebar/list.vue';
export default {
components: {
PopupDialog,
+ icon,
+ commitFilesList,
+ },
+ directives: {
+ tooltip,
},
data() {
return {
@@ -13,6 +20,7 @@ export default {
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
+ collapsed: true,
};
},
computed: {
@@ -23,10 +31,10 @@ export default {
'changedFiles',
]),
commitButtonDisabled() {
- return !this.commitMessage || this.submitCommitsLoading;
+ return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length;
},
- commitButtonText() {
- return n__('Commit %d file', 'Commit %d files', this.changedFiles.length);
+ commitMessageCount() {
+ return this.commitMessage.length;
},
},
methods: {
@@ -77,12 +85,20 @@ export default {
this.submitCommitsLoading = false;
});
},
+ toggleCollapsed() {
+ this.collapsed = !this.collapsed;
+ },
},
};
</script>
<template>
-<div id="commit-area">
+<div
+ class="multi-file-commit-panel"
+ :class="{
+ 'is-collapsed': collapsed,
+ }"
+>
<popup-dialog
v-if="showNewBranchDialog"
:primary-button-label="__('Create new branch')"
@@ -92,78 +108,71 @@ export default {
@toggle="showNewBranchDialog = false"
@submit="makeCommit(true)"
/>
+ <button
+ v-if="collapsed"
+ type="button"
+ class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10"
+ @click="toggleCollapsed"
+ >
+ <i
+ aria-hidden="true"
+ class="fa fa-angle-double-left"
+ >
+ </i>
+ </button>
+ <commit-files-list
+ title="Staged"
+ :file-list="changedFiles"
+ :collapsed="collapsed"
+ @toggleCollapsed="toggleCollapsed"
+ />
<form
- class="form-horizontal"
- @submit.prevent="tryCommit()">
- <fieldset>
- <div class="form-group">
- <label class="col-md-4 control-label staged-files">
- Staged files ({{changedFiles.length}})
- </label>
- <div class="col-md-6">
- <ul class="list-unstyled changed-files">
- <li
- v-for="(file, index) in changedFiles"
- :key="index">
- <span class="help-block">
- {{ file.path }}
- </span>
- </li>
- </ul>
- </div>
- </div>
- <div class="form-group">
- <label
- class="col-md-4 control-label"
- for="commit-message">
- Commit message
- </label>
- <div class="col-md-6">
- <textarea
- id="commit-message"
- class="form-control"
- name="commit-message"
- v-model="commitMessage">
- </textarea>
- </div>
- </div>
- <div class="form-group target-branch">
- <label
- class="col-md-4 control-label"
- for="target-branch">
- Target branch
- </label>
- <div class="col-md-6">
- <span class="help-block">
- {{currentBranch}}
- </span>
- </div>
- </div>
- <div class="col-md-offset-4 col-md-6">
- <button
- type="submit"
- :disabled="commitButtonDisabled"
- class="btn btn-success">
- <i
- v-if="submitCommitsLoading"
- class="js-commit-loading-icon fa fa-spinner fa-spin"
- aria-hidden="true"
- aria-label="loading">
- </i>
- <span class="commit-summary">
- {{ commitButtonText }}
- </span>
- </button>
- </div>
- <div class="col-md-offset-4 col-md-6">
- <div class="checkbox">
- <label>
- <input type="checkbox" v-model="startNewMR">
- <span>Start a <strong>new merge request</strong> with these changes</span>
- </label>
- </div>
+ class="form-horizontal multi-file-commit-form"
+ @submit.prevent="tryCommit"
+ v-if="!collapsed"
+ >
+ <div class="multi-file-commit-fieldset">
+ <textarea
+ class="form-control multi-file-commit-message"
+ name="commit-message"
+ v-model="commitMessage"
+ placeholder="Commit message"
+ >
+ </textarea>
+ </div>
+ <div class="multi-file-commit-fieldset">
+ <label
+ v-tooltip
+ title="Create a new merge request with these changes"
+ data-container="body"
+ data-placement="top"
+ >
+ <input
+ type="checkbox"
+ v-model="startNewMR"
+ />
+ Merge Request
+ </label>
+ <button
+ type="submit"
+ :disabled="commitButtonDisabled"
+ class="btn btn-default btn-sm append-right-10 prepend-left-10"
+ >
+ <i
+ v-if="submitCommitsLoading"
+ class="js-commit-loading-icon fa fa-spinner fa-spin"
+ aria-hidden="true"
+ aria-label="loading"
+ >
+ </i>
+ Commit
+ </button>
+ <div
+ class="multi-file-commit-message-count"
+ >
+ {{ commitMessageCount }}
</div>
- </fieldset>
+ </div>
</form>
</div>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue
index 1c864b176b1..f37cbd1e961 100644
--- a/app/assets/javascripts/repo/components/repo_editor.vue
+++ b/app/assets/javascripts/repo/components/repo_editor.vue
@@ -3,19 +3,18 @@
import { mapGetters, mapActions } from 'vuex';
import flash from '../../flash';
import monacoLoader from '../monaco_loader';
+import Editor from '../lib/editor';
export default {
- destroyed() {
- if (this.monacoInstance) {
- this.monacoInstance.destroy();
- }
+ beforeDestroy() {
+ this.editor.dispose();
},
mounted() {
- if (this.monaco) {
+ if (this.editor && monaco) {
this.initMonaco();
} else {
monacoLoader(['vs/editor/editor.main'], () => {
- this.monaco = monaco;
+ this.editor = Editor.create(monaco);
this.initMonaco();
});
@@ -29,47 +28,25 @@ export default {
initMonaco() {
if (this.shouldHideEditor) return;
- if (this.monacoInstance) {
- this.monacoInstance.setModel(null);
- }
+ this.editor.clearEditor();
this.getRawFileData(this.activeFile)
.then(() => {
- if (!this.monacoInstance) {
- this.monacoInstance = this.monaco.editor.create(this.$el, {
- model: null,
- readOnly: false,
- contextmenu: true,
- scrollBeyondLastLine: false,
- });
-
- this.languages = this.monaco.languages.getLanguages();
-
- this.addMonacoEvents();
- }
-
- this.setupEditor();
+ this.editor.createInstance(this.$refs.editor);
})
+ .then(() => this.setupEditor())
.catch(() => flash('Error setting up monaco. Please try again.'));
},
setupEditor() {
if (!this.activeFile) return;
- const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw;
- const foundLang = this.languages.find(lang =>
- lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0,
- );
- const newModel = this.monaco.editor.createModel(
- content, foundLang ? foundLang.id : 'plaintext',
- );
+ const model = this.editor.createModel(this.activeFile);
- this.monacoInstance.setModel(newModel);
- },
- addMonacoEvents() {
- this.monacoInstance.onKeyUp(() => {
+ this.editor.attachModel(model);
+ model.onChange((m) => {
this.changeFileContent({
file: this.activeFile,
- content: this.monacoInstance.getValue(),
+ content: m.getValue(),
});
});
},
@@ -99,9 +76,14 @@ export default {
class="blob-viewer-container blob-editor-container"
>
<div
- v-if="shouldHideEditor"
+ v-show="shouldHideEditor"
v-html="activeFile.html"
>
</div>
+ <div
+ v-show="!shouldHideEditor"
+ ref="editor"
+ >
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue
index 5be47d568e7..75787ad6103 100644
--- a/app/assets/javascripts/repo/components/repo_file.vue
+++ b/app/assets/javascripts/repo/components/repo_file.vue
@@ -55,7 +55,7 @@
class="file"
@click.prevent="clickedTreeRow(file)">
<td
- class="multi-file-table-col-name"
+ class="multi-file-table-name"
:colspan="submoduleColSpan"
>
<i
@@ -85,12 +85,11 @@
</td>
<template v-if="!isCollapsed && !isSubmodule">
- <td class="hidden-sm hidden-xs">
+ <td class="multi-file-table-col-commit-message hidden-sm hidden-xs">
<a
v-if="file.lastCommit.message"
@click.stop
:href="file.lastCommit.url"
- class="commit-message"
>
{{ file.lastCommit.message }}
</a>
diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue
index dd948ee84fb..34f0d51819a 100644
--- a/app/assets/javascripts/repo/components/repo_file_buttons.vue
+++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue
@@ -22,12 +22,12 @@ export default {
<template>
<div
v-if="showButtons"
- class="repo-file-buttons"
+ class="multi-file-editor-btn-group"
>
<a
:href="activeFile.rawPath"
target="_blank"
- class="btn btn-default raw"
+ class="btn btn-default btn-sm raw"
rel="noopener noreferrer">
{{ rawDownloadButtonLabel }}
</a>
@@ -38,17 +38,17 @@ export default {
aria-label="File actions">
<a
:href="activeFile.blamePath"
- class="btn btn-default blame">
+ class="btn btn-default btn-sm blame">
Blame
</a>
<a
:href="activeFile.commitsPath"
- class="btn btn-default history">
+ class="btn btn-default btn-sm history">
History
</a>
<a
:href="activeFile.permalink"
- class="btn btn-default permalink">
+ class="btn btn-default btn-sm permalink">
Permalink
</a>
</div>
diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue
index d1883299bd9..6ce9267f598 100644
--- a/app/assets/javascripts/repo/components/repo_preview.vue
+++ b/app/assets/javascripts/repo/components/repo_preview.vue
@@ -32,10 +32,12 @@ export default {
</script>
<template>
-<div class="blob-viewer-container">
+<div>
<div
v-if="!activeFile.renderError"
- v-html="activeFile.html">
+ v-html="activeFile.html"
+ class="multi-file-preview-holder"
+ >
</div>
<div
v-else-if="activeFile.tempFile"
diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue
index 9365b09326f..4ea21913129 100644
--- a/app/assets/javascripts/repo/components/repo_sidebar.vue
+++ b/app/assets/javascripts/repo/components/repo_sidebar.vue
@@ -44,20 +44,16 @@ export default {
</script>
<template>
-<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}">
+<div class="ide-file-list">
<table class="table">
<thead>
<tr>
<th
v-if="isCollapsed"
- class="repo-file-options title"
>
- <strong class="clgray">
- {{ projectName }}
- </strong>
</th>
<template v-else>
- <th class="name multi-file-table-col-name">
+ <th class="name multi-file-table-name">
Name
</th>
<th class="hidden-sm hidden-xs last-commit">
@@ -79,7 +75,7 @@ export default {
:key="n"
/>
<repo-file
- v-for="(file, index) in treeList"
+ v-for="file in treeList"
:key="file.key"
:file="file"
/>
diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue
index da0714c368c..fb29a60df66 100644
--- a/app/assets/javascripts/repo/components/repo_tab.vue
+++ b/app/assets/javascripts/repo/components/repo_tab.vue
@@ -36,27 +36,32 @@ export default {
<template>
<li
- :class="{ active : tab.active }"
@click="setFileActive(tab)"
>
<button
type="button"
- class="close-btn"
+ class="multi-file-tab-close"
@click.stop.prevent="closeFile({ file: tab })"
- :aria-label="closeLabel">
+ :aria-label="closeLabel"
+ :class="{
+ 'modified': tab.changed,
+ }"
+ :disabled="tab.changed"
+ >
<i
class="fa"
:class="changedClass"
- aria-hidden="true">
+ aria-hidden="true"
+ >
</i>
</button>
- <a
- href="#"
- class="repo-tab"
+ <div
+ class="multi-file-tab"
+ :class="{active : tab.active }"
:title="tab.url"
- @click.prevent.stop="setFileActive(tab)">
- {{tab.name}}
- </a>
+ >
+ {{ tab.name }}
+ </div>
</li>
</template>
diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue
index 59beae53e8d..ab0bef4f0ac 100644
--- a/app/assets/javascripts/repo/components/repo_tabs.vue
+++ b/app/assets/javascripts/repo/components/repo_tabs.vue
@@ -16,14 +16,12 @@
<template>
<ul
- id="tabs"
- class="list-unstyled"
+ class="multi-file-tabs list-unstyled append-bottom-0"
>
<repo-tab
v-for="tab in openFiles"
:key="tab.id"
:tab="tab"
/>
- <li class="tabs-divider" />
</ul>
</template>
diff --git a/app/assets/javascripts/repo/lib/common/disposable.js b/app/assets/javascripts/repo/lib/common/disposable.js
new file mode 100644
index 00000000000..84b29bdb600
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/common/disposable.js
@@ -0,0 +1,14 @@
+export default class Disposable {
+ constructor() {
+ this.disposers = new Set();
+ }
+
+ add(...disposers) {
+ disposers.forEach(disposer => this.disposers.add(disposer));
+ }
+
+ dispose() {
+ this.disposers.forEach(disposer => disposer.dispose());
+ this.disposers.clear();
+ }
+}
diff --git a/app/assets/javascripts/repo/lib/common/model.js b/app/assets/javascripts/repo/lib/common/model.js
new file mode 100644
index 00000000000..23c4811e6c0
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/common/model.js
@@ -0,0 +1,56 @@
+/* global monaco */
+import Disposable from './disposable';
+
+export default class Model {
+ constructor(monaco, file) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.file = file;
+ this.content = file.content !== '' ? file.content : file.raw;
+
+ this.disposable.add(
+ this.originalModel = this.monaco.editor.createModel(
+ this.file.raw,
+ undefined,
+ new this.monaco.Uri(null, null, `original/${this.file.path}`),
+ ),
+ this.model = this.monaco.editor.createModel(
+ this.content,
+ undefined,
+ new this.monaco.Uri(null, null, this.file.path),
+ ),
+ );
+
+ this.events = new Map();
+ }
+
+ get url() {
+ return this.model.uri.toString();
+ }
+
+ get path() {
+ return this.file.path;
+ }
+
+ getModel() {
+ return this.model;
+ }
+
+ getOriginalModel() {
+ return this.originalModel;
+ }
+
+ onChange(cb) {
+ this.events.set(
+ this.path,
+ this.disposable.add(
+ this.model.onDidChangeContent(e => cb(this.model, e)),
+ ),
+ );
+ }
+
+ dispose() {
+ this.disposable.dispose();
+ this.events.clear();
+ }
+}
diff --git a/app/assets/javascripts/repo/lib/common/model_manager.js b/app/assets/javascripts/repo/lib/common/model_manager.js
new file mode 100644
index 00000000000..fd462252795
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/common/model_manager.js
@@ -0,0 +1,32 @@
+import Disposable from './disposable';
+import Model from './model';
+
+export default class ModelManager {
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.disposable = new Disposable();
+ this.models = new Map();
+ }
+
+ hasCachedModel(path) {
+ return this.models.has(path);
+ }
+
+ addModel(file) {
+ if (this.hasCachedModel(file.path)) {
+ return this.models.get(file.path);
+ }
+
+ const model = new Model(this.monaco, file);
+ this.models.set(model.path, model);
+ this.disposable.add(model);
+
+ return model;
+ }
+
+ dispose() {
+ // dispose of all the models
+ this.disposable.dispose();
+ this.models.clear();
+ }
+}
diff --git a/app/assets/javascripts/repo/lib/decorations/controller.js b/app/assets/javascripts/repo/lib/decorations/controller.js
new file mode 100644
index 00000000000..0954b7973c4
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/decorations/controller.js
@@ -0,0 +1,43 @@
+export default class DecorationsController {
+ constructor(editor) {
+ this.editor = editor;
+ this.decorations = new Map();
+ this.editorDecorations = new Map();
+ }
+
+ getAllDecorationsForModel(model) {
+ if (!this.decorations.has(model.url)) return [];
+
+ const modelDecorations = this.decorations.get(model.url);
+ const decorations = [];
+
+ modelDecorations.forEach(val => decorations.push(...val));
+
+ return decorations;
+ }
+
+ addDecorations(model, decorationsKey, decorations) {
+ const decorationMap = this.decorations.get(model.url) || new Map();
+
+ decorationMap.set(decorationsKey, decorations);
+
+ this.decorations.set(model.url, decorationMap);
+
+ this.decorate(model);
+ }
+
+ decorate(model) {
+ const decorations = this.getAllDecorationsForModel(model);
+ const oldDecorations = this.editorDecorations.get(model.url) || [];
+
+ this.editorDecorations.set(
+ model.url,
+ this.editor.instance.deltaDecorations(oldDecorations, decorations),
+ );
+ }
+
+ dispose() {
+ this.decorations.clear();
+ this.editorDecorations.clear();
+ }
+}
diff --git a/app/assets/javascripts/repo/lib/diff/controller.js b/app/assets/javascripts/repo/lib/diff/controller.js
new file mode 100644
index 00000000000..dc0b1c95e59
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/diff/controller.js
@@ -0,0 +1,71 @@
+/* global monaco */
+import { throttle } from 'underscore';
+import DirtyDiffWorker from './diff_worker';
+import Disposable from '../common/disposable';
+
+export const getDiffChangeType = (change) => {
+ if (change.modified) {
+ return 'modified';
+ } else if (change.added) {
+ return 'added';
+ } else if (change.removed) {
+ return 'removed';
+ }
+
+ return '';
+};
+
+export const getDecorator = change => ({
+ range: new monaco.Range(
+ change.lineNumber,
+ 1,
+ change.endLineNumber,
+ 1,
+ ),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
+ },
+});
+
+export default class DirtyDiffController {
+ constructor(modelManager, decorationsController) {
+ this.disposable = new Disposable();
+ this.editorSimpleWorker = null;
+ this.modelManager = modelManager;
+ this.decorationsController = decorationsController;
+ this.dirtyDiffWorker = new DirtyDiffWorker();
+ this.throttledComputeDiff = throttle(this.computeDiff, 250);
+ this.decorate = this.decorate.bind(this);
+
+ this.dirtyDiffWorker.addEventListener('message', this.decorate);
+ }
+
+ attachModel(model) {
+ model.onChange(() => this.throttledComputeDiff(model));
+ }
+
+ computeDiff(model) {
+ this.dirtyDiffWorker.postMessage({
+ path: model.path,
+ originalContent: model.getOriginalModel().getValue(),
+ newContent: model.getModel().getValue(),
+ });
+ }
+
+ reDecorate(model) {
+ this.decorationsController.decorate(model);
+ }
+
+ decorate({ data }) {
+ const decorations = data.changes.map(change => getDecorator(change));
+ this.decorationsController.addDecorations(data.path, 'dirtyDiff', decorations);
+ }
+
+ dispose() {
+ this.disposable.dispose();
+
+ this.dirtyDiffWorker.removeEventListener('message', this.decorate);
+ this.dirtyDiffWorker.terminate();
+ }
+}
diff --git a/app/assets/javascripts/repo/lib/diff/diff.js b/app/assets/javascripts/repo/lib/diff/diff.js
new file mode 100644
index 00000000000..0e37f5c4704
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/diff/diff.js
@@ -0,0 +1,30 @@
+import { diffLines } from 'diff';
+
+// eslint-disable-next-line import/prefer-default-export
+export const computeDiff = (originalContent, newContent) => {
+ const changes = diffLines(originalContent, newContent);
+
+ let lineNumber = 1;
+ return changes.reduce((acc, change) => {
+ const findOnLine = acc.find(c => c.lineNumber === lineNumber);
+
+ if (findOnLine) {
+ Object.assign(findOnLine, change, {
+ modified: true,
+ endLineNumber: (lineNumber + change.count) - 1,
+ });
+ } else if ('added' in change || 'removed' in change) {
+ acc.push(Object.assign({}, change, {
+ lineNumber,
+ modified: undefined,
+ endLineNumber: (lineNumber + change.count) - 1,
+ }));
+ }
+
+ if (!change.removed) {
+ lineNumber += change.count;
+ }
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/repo/lib/diff/diff_worker.js b/app/assets/javascripts/repo/lib/diff/diff_worker.js
new file mode 100644
index 00000000000..e74c4046330
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/diff/diff_worker.js
@@ -0,0 +1,10 @@
+import { computeDiff } from './diff';
+
+self.addEventListener('message', (e) => {
+ const data = e.data;
+
+ self.postMessage({
+ path: data.path,
+ changes: computeDiff(data.originalContent, data.newContent),
+ });
+});
diff --git a/app/assets/javascripts/repo/lib/editor.js b/app/assets/javascripts/repo/lib/editor.js
new file mode 100644
index 00000000000..db499444402
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/editor.js
@@ -0,0 +1,79 @@
+import DecorationsController from './decorations/controller';
+import DirtyDiffController from './diff/controller';
+import Disposable from './common/disposable';
+import ModelManager from './common/model_manager';
+import editorOptions from './editor_options';
+
+export default class Editor {
+ static create(monaco) {
+ this.editorInstance = new Editor(monaco);
+
+ return this.editorInstance;
+ }
+
+ constructor(monaco) {
+ this.monaco = monaco;
+ this.currentModel = null;
+ this.instance = null;
+ this.dirtyDiffController = null;
+ this.disposable = new Disposable();
+
+ this.disposable.add(
+ this.modelManager = new ModelManager(this.monaco),
+ this.decorationsController = new DecorationsController(this),
+ );
+ }
+
+ createInstance(domElement) {
+ if (!this.instance) {
+ this.disposable.add(
+ this.instance = this.monaco.editor.create(domElement, {
+ model: null,
+ readOnly: false,
+ contextmenu: true,
+ scrollBeyondLastLine: false,
+ }),
+ this.dirtyDiffController = new DirtyDiffController(
+ this.modelManager, this.decorationsController,
+ ),
+ );
+ }
+ }
+
+ createModel(file) {
+ return this.modelManager.addModel(file);
+ }
+
+ attachModel(model) {
+ this.instance.setModel(model.getModel());
+ this.dirtyDiffController.attachModel(model);
+
+ this.currentModel = model;
+
+ this.instance.updateOptions(editorOptions.reduce((acc, obj) => {
+ Object.keys(obj).forEach((key) => {
+ Object.assign(acc, {
+ [key]: obj[key](model),
+ });
+ });
+ return acc;
+ }, {}));
+
+ this.dirtyDiffController.reDecorate(model);
+ }
+
+ clearEditor() {
+ if (this.instance) {
+ this.instance.setModel(null);
+ }
+ }
+
+ dispose() {
+ this.disposable.dispose();
+
+ // dispose main monaco instance
+ if (this.instance) {
+ this.instance = null;
+ }
+ }
+}
diff --git a/app/assets/javascripts/repo/lib/editor_options.js b/app/assets/javascripts/repo/lib/editor_options.js
new file mode 100644
index 00000000000..701affc466e
--- /dev/null
+++ b/app/assets/javascripts/repo/lib/editor_options.js
@@ -0,0 +1,2 @@
+export default [{
+}];
diff --git a/app/assets/javascripts/repo/services/index.js b/app/assets/javascripts/repo/services/index.js
index 2fb45dcb03c..994d325e991 100644
--- a/app/assets/javascripts/repo/services/index.js
+++ b/app/assets/javascripts/repo/services/index.js
@@ -16,6 +16,10 @@ export default {
return Promise.resolve(file.content);
}
+ if (file.raw) {
+ return Promise.resolve(file.raw);
+ }
+
return Vue.http.get(file.rawPath, { params: { format: 'json' } })
.then(res => res.text());
},
diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js
index 1ed05ac6e35..5ce9f449905 100644
--- a/app/assets/javascripts/repo/stores/getters.js
+++ b/app/assets/javascripts/repo/stores/getters.js
@@ -34,3 +34,7 @@ export const canEditFile = (state) => {
openedFiles.length &&
(currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary);
};
+
+export const addedFiles = state => changedFiles(state).filter(f => f.tempFile);
+
+export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile);
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index 8f116233e72..4216660da8c 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -12,6 +12,9 @@
/>
*/
+ // only allow classes in images.scss e.g. s12
+ const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
+
export default {
props: {
name: {
@@ -22,7 +25,10 @@
size: {
type: Number,
required: false,
- default: 0,
+ default: 16,
+ validator(value) {
+ return validSizes.includes(value);
+ },
},
cssClasses: {
@@ -42,10 +48,11 @@
},
};
</script>
+
<template>
<svg
:class="[iconSizeClass, cssClasses]">
- <use
+ <use
v-bind="{'xlink:href':spriteHref}"/>
</svg>
</template>
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 374988bb590..728f9a27aca 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -125,7 +125,7 @@
@include transition(border-color);
}
-.note-action-button .link-highlight,
+.note-action-button,
.toolbar-btn,
.dropdown-toggle-caret {
@include transition(color);
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index dfa3d4c6fb9..cdc2aa196dd 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -88,17 +88,6 @@
border-color: $border-dark;
color: $color;
}
-
- svg {
-
- path {
- fill: $color;
- }
-
- use {
- stroke: $color;
- }
- }
}
@mixin btn-green {
@@ -142,6 +131,13 @@
}
}
+@mixin btn-svg {
+ height: $gl-padding;
+ width: $gl-padding;
+ top: 0;
+ vertical-align: text-top;
+}
+
.btn {
@include btn-default;
@include btn-white;
@@ -440,3 +436,7 @@
text-decoration: none;
}
}
+
+.btn-svg svg {
+ @include btn-svg;
+}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 5e4ddf366ef..cb1aad90a9c 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -2,14 +2,43 @@
.cgray { color: $common-gray; }
.clgray { color: $common-gray-light; }
.cred { color: $common-red; }
-svg.cred { fill: $common-red; }
.cgreen { color: $common-green; }
-svg.cgreen { fill: $common-green; }
.cdark { color: $common-gray-dark; }
+
+.text-plain,
+.text-plain:hover {
+ color: $gl-text-color;
+}
+
.text-secondary {
color: $gl-text-color-secondary;
}
+.text-primary,
+.text-primary:hover {
+ color: $brand-primary;
+}
+
+.text-success,
+.text-success:hover {
+ color: $brand-success;
+}
+
+.text-danger,
+.text-danger:hover {
+ color: $brand-danger;
+}
+
+.text-warning,
+.text-warning:hover {
+ color: $brand-warning;
+}
+
+.text-info,
+.text-info:hover {
+ color: $brand-info;
+}
+
.underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; }
.light { color: $common-gray; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 579bd48fac6..30d5d7a653b 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -1002,6 +1002,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
+ white-space: nowrap;
}
&:hover {
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index e1b086ebb2b..88ce119ee3a 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -34,8 +34,15 @@
}
}
+ .flash-success {
+ @extend .alert;
+ @extend .alert-success;
+ margin: 0;
+ }
+
.flash-notice,
- .flash-alert {
+ .flash-alert,
+ .flash-success {
border-radius: $border-radius-default;
.container-fluid,
@@ -48,7 +55,8 @@
margin-bottom: 0;
.flash-notice,
- .flash-alert {
+ .flash-alert,
+ .flash-success {
border-radius: 0;
}
}
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 1ab5e6a93f9..e2084e8f85f 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -1,35 +1,49 @@
.ci-status-icon-success,
.ci-status-icon-passed {
- color: $green-500;
+ svg {
+ fill: $green-500;
+ }
}
.ci-status-icon-failed {
- color: $gl-danger;
+ svg {
+ fill: $gl-danger;
+ }
}
.ci-status-icon-pending,
.ci-status-icon-failed_with_warnings,
.ci-status-icon-success_with_warnings {
- color: $orange-500;
+ svg {
+ fill: $orange-500;
+ }
}
.ci-status-icon-running {
- color: $blue-400;
+ svg {
+ fill: $blue-400;
+ }
}
.ci-status-icon-canceled,
.ci-status-icon-disabled,
.ci-status-icon-not-found {
- color: $gl-text-color;
+ svg {
+ fill: $gl-text-color;
+ }
}
.ci-status-icon-created,
.ci-status-icon-skipped {
- color: $gray-darkest;
+ svg {
+ fill: $gray-darkest;
+ }
}
.ci-status-icon-manual {
- color: $gl-text-color;
+ svg {
+ fill: $gl-text-color;
+ }
}
.icon-link {
diff --git a/app/assets/stylesheets/framework/new-nav.scss b/app/assets/stylesheets/framework/new-nav.scss
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/assets/stylesheets/framework/new-nav.scss
+++ /dev/null
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index bb70b270299..dbee7073975 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -134,19 +134,22 @@
}
.select2-search {
- padding: 15px 15px 5px;
+ padding: $grid-size;
.select2-drop-auto-width & {
- padding: 15px 15px 5px;
+ padding: $grid-size;
}
input {
- padding: 2px 25px 2px 5px;
+ padding: $grid-size;
background: $white-light image-url('select2.png');
+ background-clip: content-box;
+ background-origin: content-box;
background-repeat: no-repeat;
- background-position: right 0 bottom 6px;
+ background-position: right 0 bottom 0 !important;
border: 1px solid $input-border;
border-radius: $border-radius-default;
+ line-height: 16px;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
&:focus {
@@ -156,11 +159,16 @@
&.select2-active {
background-color: $white-light;
background-image: image-url('select2-spinner.gif') !important;
+ background-origin: content-box;
background-repeat: no-repeat;
- background-position: right 5px center !important;
+ background-position: right 6px center !important;
background-size: 16px 16px !important;
}
}
+
+ + .select2-results {
+ padding-top: 0;
+ }
}
.select2-results {
diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss
index d5c6ddbb4a5..1c6e2bf3074 100644
--- a/app/assets/stylesheets/framework/tw_bootstrap.scss
+++ b/app/assets/stylesheets/framework/tw_bootstrap.scss
@@ -195,33 +195,6 @@ summary {
}
}
-// Typography =================================================================
-
-.text-primary,
-.text-primary:hover {
- color: $brand-primary;
-}
-
-.text-success,
-.text-success:hover {
- color: $brand-success;
-}
-
-.text-danger,
-.text-danger:hover {
- color: $brand-danger;
-}
-
-.text-warning,
-.text-warning:hover {
- color: $brand-warning;
-}
-
-.text-info,
-.text-info:hover {
- color: $brand-info;
-}
-
// Prevent datetimes on tooltips to break into two lines
.local-timeago {
white-space: nowrap;
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 7131c18ce3d..63c51747f92 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -70,14 +70,13 @@
.title {
padding: 0;
- margin-bottom: 16px;
+ margin-bottom: $gl-padding;
border-bottom: 0;
}
.btn-edit {
margin-left: auto;
- // Set height to match title height
- height: 2em;
+ height: $gl-padding * 2;
}
// Border around images in issue and MR descriptions.
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 8bb68ad2425..d3dda2e7d25 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -203,7 +203,24 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
- @include new-style-dropdown;
+ .branch-message,
+ .ref-message {
+ display: none;
+ }
+
+ .ref::selection {
+ color: $placeholder-text-color;
+ }
+
+ .dropdown {
+ .dropdown-menu-toggle {
+ min-width: 285px;
+ }
+
+ .dropdown-select {
+ width: 285px;
+ }
+ }
.btn-group:not(.hide) {
display: flex;
@@ -214,15 +231,16 @@ ul.related-merge-requests > li {
flex-shrink: 0;
}
- .dropdown-menu {
+ .create-merge-request-dropdown-menu {
width: 300px;
opacity: 1;
visibility: visible;
transform: translateY(0);
display: none;
+ margin-top: 4px;
}
- .dropdown-toggle {
+ .create-merge-request-dropdown-toggle {
.fa-caret-down {
pointer-events: none;
color: inherit;
@@ -230,18 +248,50 @@ ul.related-merge-requests > li {
}
}
+ .droplab-item-ignore {
+ pointer-events: auto;
+ }
+
+ .create-item {
+ cursor: pointer;
+ margin: 0 1px;
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-item-hover-bg;
+ color: $gl-text-color;
+ }
+ }
+
+ li.divider {
+ margin: 8px 10px;
+ }
+
li:not(.divider) {
+ padding: 8px 9px;
+
+ &:last-child {
+ padding-bottom: 8px;
+ }
+
&.droplab-item-selected {
.icon-container {
i {
visibility: visible;
}
}
+
+ .description {
+ display: block;
+ }
+ }
+
+ &.droplab-item-ignore {
+ padding-top: 8px;
}
.icon-container {
float: left;
- padding-left: 6px;
i {
visibility: hidden;
@@ -249,13 +299,12 @@ ul.related-merge-requests > li {
}
.description {
- padding-left: 30px;
- font-size: 13px;
+ padding-left: 22px;
+ }
- strong {
- display: block;
- font-weight: $gl-font-weight-bold;
- }
+ input,
+ span {
+ margin: 4px 0 0;
}
}
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index b7985c4dea5..b2250a1ce2f 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -252,6 +252,10 @@
background: $white-light;
}
+ .login-page-broadcast {
+ margin-top: 50px;
+ }
+
.navless-container {
padding: 65px 15px; // height of footer + bottom padding of email confirmation link
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 2461b818219..4fe182c9fce 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -543,10 +543,7 @@ ul.notes {
}
svg {
- height: 16px;
- width: 16px;
- top: 0;
- vertical-align: text-top;
+ @include btn-svg;
}
.award-control-icon-positive,
@@ -780,12 +777,6 @@ ul.notes {
}
}
- svg {
- fill: currentColor;
- height: 16px;
- width: 16px;
- }
-
.loading {
margin: 0;
height: auto;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index aaad6dbba8e..2dc0c288a6d 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -291,14 +291,7 @@
}
svg {
-
- path {
- fill: $layout-link-gray;
- }
-
- use {
- stroke: $layout-link-gray;
- }
+ fill: $layout-link-gray;
}
.fa-caret-down {
@@ -402,6 +395,18 @@
}
}
}
+
+ .clone-dropdown-btn {
+ background-color: $white-light;
+ }
+
+ .clone-options-dropdown {
+ min-width: 240px;
+
+ .dropdown-menu-inner-content {
+ min-width: 320px;
+ }
+ }
}
.project-repo-buttons {
@@ -886,10 +891,6 @@ pre.light-well {
font-size: $gl-font-size;
}
- a {
- color: $gl-text-color;
- }
-
.avatar-container,
.controls {
flex: 0 0 auto;
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index d93c51d5448..402412eae71 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -35,270 +35,276 @@
}
}
-.repository-view {
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- color: $almost-black;
+.multi-file {
+ display: flex;
+ height: calc(100vh - 145px);
+ border-top: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+
+ &.is-collapsed {
+ .ide-file-list {
+ max-width: 250px;
+ }
+ }
+}
- .code.white pre .hll {
- background-color: $well-light-border !important;
+.ide-file-list {
+ flex: 1;
+ overflow: scroll;
+
+ .file {
+ cursor: pointer;
}
- .tree-content-holder {
- display: -webkit-flex;
- display: flex;
- min-height: 300px;
+ a {
+ color: $gl-text-color;
}
- .tree-content-holder-mini {
- height: 100vh;
+ th {
+ position: sticky;
+ top: 0;
}
+}
- .panel-right {
- display: -webkit-flex;
- display: flex;
- -webkit-flex-direction: column;
- flex-direction: column;
- width: 80%;
- height: 100%;
+.multi-file-table-name,
+.multi-file-table-col-commit-message {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 0;
+}
- .monaco-editor.vs {
- .current-line {
- border: 0;
- background: $well-light-border;
- }
+.multi-file-table-name {
+ width: 350px;
+}
- .line-numbers {
- cursor: pointer;
+.multi-file-table-col-commit-message {
+ width: 50%;
+}
- &:hover {
- text-decoration: underline;
- }
- }
- }
+.multi-file-edit-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ border-left: 1px solid $white-dark;
+ overflow: hidden;
+}
- .blob-no-preview {
- .vertical-center {
- justify-content: center;
- width: 100%;
- }
- }
+.multi-file-tabs {
+ display: flex;
+ overflow: scroll;
+ background-color: $white-normal;
+ box-shadow: inset 0 -1px $white-dark;
- &.blob-editor-container {
- overflow: hidden;
- }
+ > li {
+ position: relative;
+ }
+}
- .blob-viewer-container {
- -webkit-flex: 1;
- flex: 1;
- overflow: auto;
-
- > div,
- .file-content:not(.wiki) {
- display: flex;
- }
-
- > div,
- .file-content,
- .blob-viewer,
- .line-number,
- .blob-content,
- .code {
- min-height: 100%;
- width: 100%;
- }
-
- .line-numbers {
- min-width: 44px;
- }
-
- .blob-content {
- flex: 1;
- overflow-x: auto;
- }
- }
+.multi-file-tab {
+ @include str-truncated(150px);
+ padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
+ background-color: $gray-normal;
+ border-right: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
+ cursor: pointer;
+
+ &.active {
+ background-color: $white-light;
+ border-bottom-color: $white-light;
+ }
+}
- #tabs {
- position: relative;
- flex-shrink: 0;
- display: flex;
- width: 100%;
- padding-left: 0;
- margin-bottom: 0;
- white-space: nowrap;
- overflow-y: hidden;
- overflow-x: auto;
-
- li {
- position: relative;
- background: $gray-normal;
- padding: #{$gl-padding / 2} $gl-padding;
- border-right: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
- cursor: pointer;
-
- &.active {
- background: $white-light;
- border-bottom: 0;
- }
-
- a {
- @include str-truncated(100px);
- color: $gl-text-color;
- vertical-align: middle;
- text-decoration: none;
- margin-right: 12px;
-
- &:focus {
- outline: none;
- }
- }
-
- .close-btn {
- position: absolute;
- right: 8px;
- top: 50%;
- padding: 0;
- background: none;
- border: 0;
- font-size: $gl-font-size;
- transform: translateY(-50%);
- }
-
- .close-icon:hover {
- color: $hint-color;
- }
-
- .close-icon,
- .unsaved-icon {
- color: $gray-darkest;
- }
-
- .unsaved-icon {
- color: $brand-success;
- }
-
- &.tabs-divider {
- width: 100%;
- background-color: $white-light;
- border-right: 0;
- border-top-right-radius: 2px;
- }
- }
- }
+.multi-file-tab-close {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ padding: 0;
+ background: none;
+ border: 0;
+ font-size: $gl-font-size;
+ color: $gray-darkest;
+ transform: translateY(-50%);
+
+ &:not(.modified):hover,
+ &:not(.modified):focus {
+ color: $hint-color;
+ }
- .repo-file-buttons {
- background-color: $white-light;
- padding: 5px 10px;
- border-top: 1px solid $white-normal;
- }
+ &.modified {
+ color: $indigo-700;
+ }
+}
- #binary-viewer {
- height: 80vh;
- overflow: auto;
- margin: 0;
-
- .blob-viewer {
- padding-top: 20px;
- padding-left: 20px;
- }
-
- .binary-unknown {
- text-align: center;
- padding-top: 100px;
- background: $gray-light;
- height: 100%;
- font-size: 17px;
-
- span {
- display: block;
- }
- }
- }
+.multi-file-edit-pane-content {
+ flex: 1;
+ height: 0;
+}
+
+.multi-file-editor-btn-group {
+ padding: $grid-size;
+ border-top: 1px solid $white-dark;
+}
+
+// Not great, but this is to deal with our current output
+.multi-file-preview-holder {
+ height: 100%;
+ overflow: scroll;
+
+ .blob-viewer {
+ height: 100%;
}
- #commit-area {
- background: $gray-light;
- padding: 20px;
+ .file-content.code {
+ display: flex;
- .help-block {
- padding-top: 7px;
- margin-top: 0;
+ i {
+ margin-left: -10px;
}
}
- #view-toggler {
- height: 41px;
- position: relative;
- display: block;
- border-bottom: 1px solid $white-normal;
- background: $white-light;
- margin-top: -5px;
+ .line-numbers {
+ min-width: 50px;
}
- #binary-viewer {
- img {
- max-width: 100%;
- }
+ .file-content,
+ .line-numbers,
+ .blob-content,
+ .code {
+ min-height: 100%;
}
+}
- #sidebar {
- flex: 1;
- height: 100%;
+.multi-file-commit-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 290px;
+ padding: $gl-padding;
+ background-color: $gray-light;
+ border-left: 1px solid $white-dark;
+
+ &.is-collapsed {
+ width: 60px;
+ padding: 0;
+ }
+}
- &.sidebar-mini {
- width: 20%;
- border-right: 1px solid $white-normal;
- overflow: auto;
- }
+.multi-file-commit-panel-section {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
- .table {
- margin-bottom: 0;
- }
+.multi-file-commit-panel-header {
+ display: flex;
+ align-items: center;
+ padding: 0 0 12px;
+ margin-bottom: 12px;
+ border-bottom: 1px solid $white-dark;
- tr {
- .repo-file-options {
- padding: 2px 16px;
- width: 100%;
- }
-
- .title {
- font-size: 10px;
- text-transform: uppercase;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: middle;
- }
-
- .file-icon {
- margin-right: 5px;
- }
-
- td {
- white-space: nowrap;
- }
- }
+ &.is-collapsed {
+ border-bottom: 1px solid $white-dark;
- .file {
- cursor: pointer;
+ svg {
+ margin-left: auto;
+ margin-right: auto;
}
+ }
+}
- a {
- @include str-truncated(250px);
- color: $almost-black;
- }
+.multi-file-commit-panel-collapse-btn {
+ padding-top: 0;
+ padding-bottom: 0;
+ margin-left: auto;
+ font-size: 20px;
+
+ &.is-collapsed {
+ margin-right: auto;
+ }
+}
+
+.multi-file-commit-list {
+ flex: 1;
+ overflow: scroll;
+}
+
+.multi-file-commit-list-item {
+ display: flex;
+ align-items: center;
+}
+
+.multi-file-addition {
+ fill: $green-500;
+}
+
+.multi-file-modified {
+ fill: $orange-500;
+}
+
+.multi-file-commit-list-collapsed {
+ display: flex;
+ flex-direction: column;
+
+ > svg {
+ margin-left: auto;
+ margin-right: auto;
}
}
-.render-error {
- min-height: calc(100vh - 62px);
+.multi-file-commit-list-path {
+ @include str-truncated(100%);
+}
+
+.multi-file-commit-form {
+ padding-top: 12px;
+ border-top: 1px solid $white-dark;
+}
+
+.multi-file-commit-fieldset {
+ display: flex;
+ align-items: center;
+ padding-bottom: 12px;
- p {
- width: 100%;
+ .btn {
+ flex: 1;
}
}
-.multi-file-table-col-name {
- width: 350px;
+.multi-file-commit-message.form-control {
+ height: 80px;
+ resize: none;
+}
+
+.dirty-diff {
+ // !important need to override monaco inline style
+ width: 4px !important;
+ left: 0 !important;
+
+ &-modified {
+ background-color: $blue-500;
+ }
+
+ &-added {
+ background-color: $green-600;
+ }
+
+ &-removed {
+ height: 0 !important;
+ width: 0 !important;
+ bottom: -2px;
+ border-style: solid;
+ border-width: 5px;
+ border-color: transparent transparent transparent $red-500;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100px;
+ height: 1px;
+ background-color: rgba($red-500, .5);
+ }
+ }
}
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index f6d9f88032f..744e448e8df 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -54,7 +54,7 @@ module IssuableActions
end
def destroy
- Issuable::DestroyService.new(project, current_user).execute(issuable)
+ Issuable::DestroyService.new(issuable.project, current_user).execute(issuable)
TodoService.new.destroy_issuable(issuable, current_user)
name = issuable.human_class_name
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index f28df83d5a5..56df9991fda 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -41,7 +41,7 @@ class Projects::BranchesController < Projects::ApplicationController
branch_name = sanitize(strip_tags(params[:branch_name]))
branch_name = Addressable::URI.unescape(branch_name)
- redirect_to_autodeploy = project.empty_repo? && project.deployment_services.present?
+ redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present?
result = CreateBranchService.new(project, current_user)
.execute(branch_name, ref)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 28fee0465d5..d7a3441a245 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -158,7 +158,8 @@ class Projects::IssuesController < Projects::ApplicationController
end
def create_merge_request
- result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
+ create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
+ result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 764a9c7111e..1511fc08c89 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -65,7 +65,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
if params[:ref].present?
@ref = params[:ref]
- @commit = @repository.commit("refs/heads/#{@ref}")
+ @commit = @repository.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
render layout: false
@@ -76,7 +76,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
if params[:ref].present?
@ref = params[:ref]
- @commit = @target_project.commit("refs/heads/#{@ref}")
+ @commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
render layout: false
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index d60a24d3f1d..9f966889995 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -27,7 +27,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@merge_request.merge_request_diff
end
- @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff.order_id_desc
+ @merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc
@comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
if params[:start_sha].present?
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index abab2e2f0c9..b890818c475 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -6,11 +6,19 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
end
def update
- if @project.update(update_params)
- flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
- redirect_to project_settings_ci_cd_path(@project)
- else
- render 'show'
+ Projects::UpdateService.new(project, current_user, update_params).tap do |service|
+ if service.execute
+ flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
+
+ if service.run_auto_devops_pipeline?
+ CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
+ flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
+ end
+
+ redirect_to project_settings_ci_cd_path(@project)
+ else
+ render 'show'
+ end
end
end
@@ -21,6 +29,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_in_minutes, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
+ :run_auto_devops_pipeline_implicit, :run_auto_devops_pipeline_explicit,
auto_devops_attributes: [:id, :domain, :enabled]
)
end
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 02eb983bf55..12157818bcd 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -104,8 +104,7 @@ class NotesFinder
query = @params[:search]
return notes unless query
- pattern = "%#{query}%"
- notes.where(Note.arel_table[:note].matches(pattern))
+ notes.search(query)
end
# Notes changed since last fetch
diff --git a/app/finders/runner_jobs_finder.rb b/app/finders/runner_jobs_finder.rb
new file mode 100644
index 00000000000..52340f94523
--- /dev/null
+++ b/app/finders/runner_jobs_finder.rb
@@ -0,0 +1,22 @@
+class RunnerJobsFinder
+ attr_reader :runner, :params
+
+ def initialize(runner, params = {})
+ @runner = runner
+ @params = params
+ end
+
+ def execute
+ items = @runner.builds
+ items = by_status(items)
+ items
+ end
+
+ private
+
+ def by_status(items)
+ return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
+
+ items.where(status: params[:status])
+ end
+end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 6fc4248b245..dccde46fa33 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -30,9 +30,9 @@ module ApplicationSettingsHelper
def enabled_project_button(project, protocol)
case protocol
when 'ssh'
- ssh_clone_button(project, 'bottom', append_link: false)
+ ssh_clone_button(project, append_link: false)
else
- http_clone_button(project, 'bottom', append_link: false)
+ http_clone_button(project, append_link: false)
end
end
@@ -177,6 +177,9 @@ module ApplicationSettingsHelper
:ed25519_key_restriction,
:email_author_in_body,
:enabled_git_access_protocol,
+ :gitaly_timeout_default,
+ :gitaly_timeout_medium,
+ :gitaly_timeout_fast,
:gravatar_enabled,
:hashed_storage_enabled,
:help_page_hide_commercial_content,
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index 483b957decb..ec6194d204f 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -8,9 +8,25 @@ module AutoDevopsHelper
!project.ci_service
end
+ def show_run_auto_devops_pipeline_checkbox_for_instance_setting?(project)
+ return false if project.repository.gitlab_ci_yml
+
+ if project&.auto_devops&.enabled.present?
+ !project.auto_devops.enabled && current_application_settings.auto_devops_enabled?
+ else
+ current_application_settings.auto_devops_enabled?
+ end
+ end
+
+ def show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(project)
+ return false if project.repository.gitlab_ci_yml
+
+ !project.auto_devops_enabled?
+ end
+
def auto_devops_warning_message(project)
missing_domain = !project.auto_devops&.has_domain?
- missing_service = !project.kubernetes_service&.active?
+ missing_service = !project.deployment_platform&.active?
if missing_service
params = {
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 8e8feeea1d8..d06cf2de2c3 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -56,42 +56,36 @@ module ButtonHelper
end
end
- def http_clone_button(project, placement = 'right', append_link: true)
- klass = 'http-selector'
- klass << ' has-tooltip' if current_user.try(:require_extra_setup_for_git_auth?)
-
+ def http_clone_button(project, append_link: true)
protocol = gitlab_config.protocol.upcase
+ dropdown_description = http_dropdown_description(protocol)
+ append_url = project.http_url_to_repo if append_link
+
+ dropdown_item_with_description(protocol, dropdown_description, href: append_url)
+ end
+
+ def http_dropdown_description(protocol)
+ if current_user.try(:require_password_creation_for_git?)
+ _("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
+ else
+ _("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
+ end
+ end
- tooltip_title =
- if current_user.try(:require_password_creation_for_git?)
- _("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
- else
- _("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
- end
+ def ssh_clone_button(project, append_link: true)
+ dropdown_description = _("You won't be able to pull or push project code via SSH until you add an SSH key to your profile") if current_user.try(:require_ssh_key?)
+ append_url = project.ssh_url_to_repo if append_link
- content_tag (append_link ? :a : :span), protocol,
- class: klass,
- href: (project.http_url_to_repo if append_link),
- data: {
- html: true,
- placement: placement,
- container: 'body',
- title: tooltip_title
- }
+ dropdown_item_with_description('SSH', dropdown_description, href: append_url)
end
- def ssh_clone_button(project, placement = 'right', append_link: true)
- klass = 'ssh-selector'
- klass << ' has-tooltip' if current_user.try(:require_ssh_key?)
+ def dropdown_item_with_description(title, description, href: nil)
+ button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title')
+ button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description
- content_tag (append_link ? :a : :span), 'SSH',
- class: klass,
- href: (project.ssh_url_to_repo if append_link),
- data: {
- html: true,
- placement: placement,
- container: 'body',
- title: _('Add an SSH key to your profile to pull or push via SSH.')
- }
+ content_tag (href ? :a : :span),
+ button_content,
+ class: "#{title.downcase}-selector",
+ href: (href if href)
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index a9840d19178..4c60f4b0cd0 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -212,6 +212,7 @@ module IssuablesHelper
def issuable_initial_data(issuable)
data = {
endpoint: issuable_path(issuable),
+ updateEndpoint: "#{issuable_path(issuable)}.json",
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 01455a52d2a..3117c98c846 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -172,6 +172,27 @@ class ApplicationSetting < ActiveRecord::Base
end
end
+ validates :gitaly_timeout_default,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :gitaly_timeout_medium,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :gitaly_timeout_medium,
+ numericality: { less_than_or_equal_to: :gitaly_timeout_default },
+ if: :gitaly_timeout_default
+ validates :gitaly_timeout_medium,
+ numericality: { greater_than_or_equal_to: :gitaly_timeout_fast },
+ if: :gitaly_timeout_fast
+
+ validates :gitaly_timeout_fast,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :gitaly_timeout_fast,
+ numericality: { less_than_or_equal_to: :gitaly_timeout_default },
+ if: :gitaly_timeout_default
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -308,7 +329,10 @@ class ApplicationSetting < ActiveRecord::Base
two_factor_grace_period: 48,
user_default_external: false,
polling_interval_multiplier: 1,
- usage_ping_enabled: Settings.gitlab['usage_ping_enabled']
+ usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
+ gitaly_timeout_fast: 10,
+ gitaly_timeout_medium: 30,
+ gitaly_timeout_default: 55
}
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index ebbefc51a4f..eebbf7c4218 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -40,7 +40,6 @@ module Ci
validates :status, presence: { unless: :importing? }
validate :valid_commit_sha, unless: :importing?
- after_initialize :set_config_source, if: :new_record?
after_create :keep_around_commits, unless: :importing?
enum source: {
@@ -365,7 +364,7 @@ module Ci
end
def has_kubernetes_active?
- project.kubernetes_service&.active?
+ project.deployment_platform&.active?
end
def has_stage_seeds?
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index c6509f89117..d39610a8995 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -1,6 +1,7 @@
module Ci
class Runner < ActiveRecord::Base
extend Gitlab::Ci::Model
+ include Gitlab::SQL::Pattern
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
ONLINE_CONTACT_TIMEOUT = 1.hour
@@ -59,10 +60,7 @@ module Ci
#
# Returns an ActiveRecord::Relation.
def self.search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:token].matches(pattern).or(t[:description].matches(pattern)))
+ fuzzy_search(query, [:token, :description])
end
def self.contact_time_deadline
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 185d9473aab..6d7fb4b7dbf 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -17,8 +17,7 @@ module Clusters
# we force autosave to happen when we save `Cluster` model
has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true
- # We have to ":destroy" it today to ensure that we clean also the Kubernetes Integration
- has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes'
has_one :application_helm, class_name: 'Clusters::Applications::Helm'
has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
@@ -29,15 +28,9 @@ module Clusters
validates :name, cluster_name: true
validate :restrict_modification, on: :update
- # TODO: Move back this into Clusters::Platforms::Kubernetes in 10.3
- # We need callback here because `enabled` belongs to Clusters::Cluster
- # Callbacks in Clusters::Platforms::Kubernetes will not be called after update
- after_save :update_kubernetes_integration!
-
delegate :status, to: :provider, allow_nil: true
delegate :status_reason, to: :provider, allow_nil: true
delegate :on_creation?, to: :provider, allow_nil: true
- delegate :update_kubernetes_integration!, to: :platform, allow_nil: true
delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true
delegate :installed?, to: :application_helm, prefix: true, allow_nil: true
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 6dc1ee810d3..7ab670cf1ef 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -1,7 +1,12 @@
module Clusters
module Platforms
class Kubernetes < ActiveRecord::Base
+ include Gitlab::CurrentSettings
+ include Gitlab::Kubernetes
+ include ReactiveCaching
+
self.table_name = 'cluster_platforms_kubernetes'
+ self.reactive_cache_key = ->(kubernetes) { [kubernetes.class.model_name.singular, kubernetes.id] }
belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster'
@@ -29,19 +34,14 @@ module Clusters
validates :api_url, url: true, presence: true
validates :token, presence: true
- # TODO: Glue code till we migrate Kubernetes Integration into Platforms::Kubernetes
- after_destroy :destroy_kubernetes_integration!
+ after_save :clear_reactive_cache!
alias_attribute :ca_pem, :ca_cert
delegate :project, to: :cluster, allow_nil: true
delegate :enabled?, to: :cluster, allow_nil: true
- class << self
- def namespace_for_project(project)
- "#{project.path}-#{project.id}"
- end
- end
+ alias_method :active?, :enabled?
def actual_namespace
if namespace.present?
@@ -51,58 +51,127 @@ module Clusters
end
end
- def default_namespace
- self.class.namespace_for_project(project) if project
+ def predefined_variables
+ config = YAML.dump(kubeconfig)
+
+ variables = [
+ { key: 'KUBE_URL', value: api_url, public: true },
+ { key: 'KUBE_TOKEN', value: token, public: false },
+ { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true },
+ { key: 'KUBECONFIG', value: config, public: false, file: true }
+ ]
+
+ if ca_pem.present?
+ variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true }
+ variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true }
+ end
+
+ variables
end
- def kubeclient
- @kubeclient ||= kubernetes_service.kubeclient if manages_kubernetes_service?
+ # Constructs a list of terminals from the reactive cache
+ #
+ # Returns nil if the cache is empty, in which case you should try again a
+ # short time later
+ def terminals(environment)
+ with_reactive_cache do |data|
+ pods = filter_by_label(data[:pods], app: environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }
+ terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
+ end
end
- def update_kubernetes_integration!
- raise 'Kubernetes service already configured' unless manages_kubernetes_service?
+ # Caches resources in the namespace so other calls don't need to block on
+ # network access
+ def calculate_reactive_cache
+ return unless enabled? && project && !project.pending_delete?
- # This is neccesary, otheriwse enabled? returns true even though cluster updated with enabled: false
- cluster.reload
+ # We may want to cache extra things in the future
+ { pods: read_pods }
+ end
- ensure_kubernetes_service&.update!(
- active: enabled?,
- api_url: api_url,
- namespace: namespace,
+ def kubeclient
+ @kubeclient ||= build_kubeclient!
+ end
+
+ private
+
+ def kubeconfig
+ to_kubeconfig(
+ url: api_url,
+ namespace: actual_namespace,
token: token,
- ca_pem: ca_cert
- )
+ ca_pem: ca_pem)
+ end
+
+ def default_namespace
+ return unless project
+
+ slug = "#{project.path}-#{project.id}".downcase
+ slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '')
end
- def active?
- manages_kubernetes_service?
+ def build_kubeclient!(api_path: 'api', api_version: 'v1')
+ raise "Incomplete settings" unless api_url && actual_namespace
+
+ unless (username && password) || token
+ raise "Either username/password or token is required to access API"
+ end
+
+ ::Kubeclient::Client.new(
+ join_api_url(api_path),
+ api_version,
+ auth_options: kubeclient_auth_options,
+ ssl_options: kubeclient_ssl_options,
+ http_proxy_uri: ENV['http_proxy']
+ )
end
- private
+ # Returns a hash of all pods in the namespace
+ def read_pods
+ kubeclient = build_kubeclient!
- def enforce_namespace_to_lower_case
- self.namespace = self.namespace&.downcase
+ kubeclient.get_pods(namespace: actual_namespace).as_json
+ rescue KubeException => err
+ raise err unless err.error_code == 404
+
+ []
end
- # TODO: glue code till we migrate Kubernetes Service into Platforms::Kubernetes class
- def manages_kubernetes_service?
- return true unless kubernetes_service&.active?
+ def kubeclient_ssl_options
+ opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
- kubernetes_service.api_url == api_url
+ if ca_pem.present?
+ opts[:cert_store] = OpenSSL::X509::Store.new
+ opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
+ end
+
+ opts
end
- def destroy_kubernetes_integration!
- return unless manages_kubernetes_service?
+ def kubeclient_auth_options
+ { bearer_token: token }
+ end
+
+ def join_api_url(api_path)
+ url = URI.parse(api_url)
+ prefix = url.path.sub(%r{/+\z}, '')
+
+ url.path = [prefix, api_path].join("/")
- kubernetes_service&.destroy!
+ url.to_s
end
- def kubernetes_service
- @kubernetes_service ||= project&.kubernetes_service
+ def terminal_auth
+ {
+ token: token,
+ ca_pem: ca_pem,
+ max_session_time: current_application_settings.terminal_max_session_time
+ }
end
- def ensure_kubernetes_service
- @kubernetes_service ||= kubernetes_service || project&.build_kubernetes_service
+ def enforce_namespace_to_lower_case
+ self.namespace = self.namespace&.downcase
end
end
end
diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb
index 9585b5583dc..8a241e4374a 100644
--- a/app/models/concerns/has_variable.rb
+++ b/app/models/concerns/has_variable.rb
@@ -16,6 +16,10 @@ module HasVariable
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
+ def key=(new_key)
+ super(new_key.to_s.strip)
+ end
+
def to_runner_variable
{ key: key, value: value, public: false }
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index e607707475f..5ca4a7086cb 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -122,9 +122,7 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def search(query)
- title = to_fuzzy_arel(:title, query)
-
- where(title)
+ fuzzy_search(query, [:title])
end
# Searches for records with a matching title or description.
@@ -135,10 +133,7 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def full_search(query)
- title = to_fuzzy_arel(:title, query)
- description = to_fuzzy_arel(:description, query)
-
- where(title&.or(description))
+ fuzzy_search(query, [:title, :description])
end
def sort(method, excluded_labels: [])
@@ -255,8 +250,10 @@ module Issuable
participants(user).include?(user)
end
- def to_hook_data(user, old_labels: [], old_assignees: [], old_total_time_spent: nil)
+ def to_hook_data(user, old_associations: {})
changes = previous_changes
+ old_labels = old_associations.fetch(:labels, [])
+ old_assignees = old_associations.fetch(:assignees, [])
if old_labels != labels
changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
@@ -270,8 +267,12 @@ module Issuable
end
end
- if old_total_time_spent != total_time_spent
- changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ end
end
Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
diff --git a/app/models/email.rb b/app/models/email.rb
index 2da8b050149..d6516761f0a 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -1,5 +1,6 @@
class Email < ActiveRecord::Base
include Sortable
+ include Gitlab::SQL::Pattern
belongs_to :user
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 21a028e351c..bf69b4c50f0 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -138,11 +138,11 @@ class Environment < ActiveRecord::Base
end
def has_terminals?
- project.deployment_service.present? && available? && last_deployment.present?
+ project.deployment_platform.present? && available? && last_deployment.present?
end
def terminals
- project.deployment_service.terminals(self) if has_terminals?
+ project.deployment_platform.terminals(self) if has_terminals?
end
def has_metrics?
diff --git a/app/models/group.rb b/app/models/group.rb
index dc4500360b9..76262acf50c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -50,20 +50,6 @@ class Group < Namespace
Gitlab::Database.postgresql?
end
- # Searches for groups matching the given query.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search(query)
- table = Namespace.arel_table
- pattern = "%#{query}%"
-
- where(table[:name].matches(pattern).or(table[:path].matches(pattern)))
- end
-
def sort(method)
if method == 'storage_size_desc'
# storage_size is a virtual column so we need to
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index e4d8f486c77..bbc01e9677c 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -283,9 +283,9 @@ class MergeRequest < ActiveRecord::Base
if persisted?
merge_request_diff.commit_shas
elsif compare_commits
- compare_commits.reverse.map(&:sha)
+ compare_commits.to_a.reverse.map(&:sha)
else
- []
+ Array(diff_head_sha)
end
end
@@ -364,16 +364,28 @@ class MergeRequest < ActiveRecord::Base
# We use these attributes to force these to the intended values.
attr_writer :target_branch_sha, :source_branch_sha
+ def source_branch_ref
+ return @source_branch_sha if @source_branch_sha
+ return unless source_branch
+
+ Gitlab::Git::BRANCH_REF_PREFIX + source_branch
+ end
+
+ def target_branch_ref
+ return @target_branch_sha if @target_branch_sha
+ return unless target_branch
+
+ Gitlab::Git::BRANCH_REF_PREFIX + target_branch
+ end
+
def source_branch_head
return unless source_project
- source_branch_ref = @source_branch_sha || source_branch
source_project.repository.commit(source_branch_ref) if source_branch_ref
end
def target_branch_head
- target_branch_ref = @target_branch_sha || target_branch
- target_project.repository.commit(target_branch_ref) if target_branch_ref
+ target_project.repository.commit(target_branch_ref)
end
def branch_merge_base_commit
@@ -482,7 +494,7 @@ class MergeRequest < ActiveRecord::Base
def merge_request_diff_for(diff_refs_or_sha)
@merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha|
- diffs = merge_request_diffs.viewable.select_without_diff
+ diffs = merge_request_diffs.viewable
h[diff_refs_or_sha] =
if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs)
diffs.find_by_diff_refs(diff_refs_or_sha)
@@ -887,7 +899,8 @@ class MergeRequest < ActiveRecord::Base
def compute_diverged_commits_count
return 0 unless source_branch_sha && target_branch_sha
- Gitlab::Git::Commit.between(target_project.repository.raw_repository, source_branch_sha, target_branch_sha).size
+ target_project.repository
+ .count_commits_between(source_branch_sha, target_branch_sha)
end
private :compute_diverged_commits_count
@@ -906,28 +919,18 @@ class MergeRequest < ActiveRecord::Base
# Note that this could also return SHA from now dangling commits
#
def all_commit_shas
- if persisted?
- # MySQL doesn't support LIMIT in a subquery.
- diffs_relation =
- if Gitlab::Database.postgresql?
- merge_request_diffs.order(id: :desc).limit(100)
- else
- merge_request_diffs
- end
+ return commit_shas unless persisted?
- column_shas = MergeRequestDiffCommit
- .where(merge_request_diff: diffs_relation)
- .limit(10_000)
- .pluck('sha')
+ diffs_relation = merge_request_diffs
- serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas)
+ # MySQL doesn't support LIMIT in a subquery.
+ diffs_relation = diffs_relation.recent if Gitlab::Database.postgresql?
- (column_shas + serialised_shas).uniq
- elsif compare_commits
- compare_commits.to_a.reverse.map(&:id)
- else
- [diff_head_sha]
- end
+ MergeRequestDiffCommit
+ .where(merge_request_diff: diffs_relation)
+ .limit(10_000)
+ .pluck('sha')
+ .uniq
end
def merge_commit
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 9ee561b5161..c37aa0a594b 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,14 +1,14 @@
class MergeRequestDiff < ActiveRecord::Base
include Sortable
include Importable
- include Gitlab::EncodingHelper
include ManualInverseAssociation
+ include IgnorableColumn
- # Prevent store of diff if commits amount more then 500
+ # Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
- # Valid types of serialized diffs allowed by Gitlab::Git::Diff
- VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta].freeze
+ ignore_column :st_commits,
+ :st_diffs
belongs_to :merge_request
manual_inverse_association :merge_request, :merge_request_diff
@@ -16,9 +16,6 @@ class MergeRequestDiff < ActiveRecord::Base
has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
- serialize :st_commits # rubocop:disable Cop/ActiveRecordSerialize
- serialize :st_diffs # rubocop:disable Cop/ActiveRecordSerialize
-
state_machine :state, initial: :empty do
state :collected
state :overflow
@@ -32,6 +29,8 @@ class MergeRequestDiff < ActiveRecord::Base
scope :viewable, -> { without_state(:empty) }
+ scope :recent, -> { order(id: :desc).limit(100) }
+
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
@@ -40,14 +39,6 @@ class MergeRequestDiff < ActiveRecord::Base
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
end
- def self.select_without_diff
- select(column_names - ['st_diffs'])
- end
-
- def st_commits
- super || []
- end
-
# Collect information about commits and diff from repository
# and save it to the database as serialized data
def save_git_content
@@ -129,11 +120,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commit_shas
- if st_commits.present?
- st_commits.map { |commit| commit[:id] }
- else
- merge_request_diff_commits.map(&:sha)
- end
+ merge_request_diff_commits.map(&:sha)
end
def diff_refs=(new_diff_refs)
@@ -208,34 +195,11 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commits_count
- if st_commits.present?
- st_commits.size
- else
- merge_request_diff_commits.size
- end
- end
-
- def utf8_st_diffs
- return [] if st_diffs.blank?
-
- st_diffs.map do |diff|
- diff.each do |k, v|
- diff[k] = encode_utf8(v) if v.respond_to?(:encoding)
- end
- end
+ merge_request_diff_commits.size
end
private
- # Old GitLab implementations may have generated diffs as ["--broken-diff"].
- # Avoid an error 500 by ignoring bad elements. See:
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/20776
- def valid_raw_diff?(raw)
- return false unless raw.respond_to?(:each)
-
- raw.any? { |element| VALID_CLASSES.include?(element.class) }
- end
-
def create_merge_request_diff_files(diffs)
rows = diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
@@ -259,9 +223,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def load_diffs(options)
- return Gitlab::Git::DiffCollection.new([]) unless diffs_from_database
-
- raw = diffs_from_database
+ raw = merge_request_diff_files.map(&:to_hash)
if paths = options[:paths]
raw = raw.select do |diff|
@@ -272,22 +234,8 @@ class MergeRequestDiff < ActiveRecord::Base
Gitlab::Git::DiffCollection.new(raw, options)
end
- def diffs_from_database
- return @diffs_from_database if defined?(@diffs_from_database)
-
- @diffs_from_database =
- if st_diffs.present?
- if valid_raw_diff?(st_diffs)
- st_diffs
- end
- elsif merge_request_diff_files.present?
- merge_request_diff_files.map(&:to_hash)
- end
- end
-
def load_commits
- commits = st_commits.presence || merge_request_diff_commits
- commits = commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
+ commits = merge_request_diff_commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
CommitCollection
.new(merge_request.source_project, commits, merge_request.source_branch)
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 01458120cda..c06ee8083f0 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -13,6 +13,7 @@ class Milestone < ActiveRecord::Base
include Referable
include StripAttribute
include Milestoneish
+ include Gitlab::SQL::Pattern
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -73,10 +74,7 @@ class Milestone < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
+ fuzzy_search(query, [:title, :description])
end
def filter_by_state(milestones, state)
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 4d401e7ba18..fa76729a702 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -9,6 +9,7 @@ class Namespace < ActiveRecord::Base
include Routable
include AfterCommitQueue
include Storage::LegacyNamespace
+ include Gitlab::SQL::Pattern
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -86,10 +87,7 @@ class Namespace < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation
def search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:name].matches(pattern).or(t[:path].matches(pattern)))
+ fuzzy_search(query, [:name, :path])
end
def clean_path(path)
diff --git a/app/models/note.rb b/app/models/note.rb
index 50c9caf8529..340fe087f82 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -14,6 +14,7 @@ class Note < ActiveRecord::Base
include ResolvableNote
include IgnorableColumn
include Editable
+ include Gitlab::SQL::Pattern
module SpecialRole
FIRST_TIME_CONTRIBUTOR = :first_time_contributor
@@ -167,6 +168,10 @@ class Note < ActiveRecord::Base
def has_special_role?(role, note)
note.special_role == role
end
+
+ def search(query)
+ fuzzy_search(query, [:note])
+ end
end
def cross_reference?
diff --git a/app/models/project.rb b/app/models/project.rb
index e276bd2422d..eaf4f555d3b 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -234,8 +234,8 @@ class Project < ActiveRecord::Base
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
validates :ci_config_path,
- format: { without: /\.{2}/,
- message: 'cannot include directory traversal.' },
+ format: { without: /(\.{2}|\A\/)/,
+ message: 'cannot include leading slash or directory traversal.' },
length: { maximum: 255 },
allow_blank: true
validates :name,
@@ -273,8 +273,9 @@ class Project < ActiveRecord::Base
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
- scope :with_hashed_storage, -> { where('storage_version >= 1') }
- scope :with_legacy_storage, -> { where(storage_version: [nil, 0]) }
+ scope :with_storage_feature, ->(feature) { where('storage_version >= :version', version: HASHED_STORAGE_FEATURES[feature]) }
+ scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) }
+ scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) }
scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
scope :sorted_by_stars, -> { reorder('projects.star_count DESC') }
@@ -425,17 +426,11 @@ class Project < ActiveRecord::Base
#
# query - The search query as a String.
def search(query)
- pattern = to_pattern(query)
-
- where(
- arel_table[:path].matches(pattern)
- .or(arel_table[:name].matches(pattern))
- .or(arel_table[:description].matches(pattern))
- )
+ fuzzy_search(query, [:path, :name, :description])
end
def search_by_title(query)
- non_archived.where(arel_table[:name].matches(to_pattern(query)))
+ non_archived.fuzzy_search(query, [:name])
end
def visibility_levels
@@ -604,7 +599,7 @@ class Project < ActiveRecord::Base
def ci_config_path=(value)
# Strip all leading slashes so that //foo -> foo
- super(value&.sub(%r{\A/+}, '')&.delete("\0"))
+ super(value&.delete("\0"))
end
def import_url=(value)
@@ -902,12 +897,10 @@ class Project < ActiveRecord::Base
@ci_service ||= ci_services.reorder(nil).find_by(active: true)
end
- def deployment_services
- services.where(category: :deployment)
- end
-
- def deployment_service
- @deployment_service ||= deployment_services.reorder(nil).find_by(active: true)
+ # TODO: This will be extended for multiple enviroment clusters
+ def deployment_platform
+ @deployment_platform ||= clusters.find_by(enabled: true)&.platform_kubernetes
+ @deployment_platform ||= services.where(category: :deployment).reorder(nil).find_by(active: true)
end
def monitoring_services
@@ -1552,9 +1545,9 @@ class Project < ActiveRecord::Base
end
def deployment_variables
- return [] unless deployment_service
+ return [] unless deployment_platform
- deployment_service.predefined_variables
+ deployment_platform.predefined_variables
end
def auto_devops_variables
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index bc62972dbb0..b82567ce2b3 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -1,3 +1,8 @@
+##
+# NOTE:
+# We'll move this class to Clusters::Platforms::Kubernetes, which contains exactly the same logic.
+# After we've migrated data, we'll remove KubernetesService. This would happen in a few months.
+# If you're modyfiyng this class, please note that you should update the same change in Clusters::Platforms::Kubernetes.
class KubernetesService < DeploymentService
include Gitlab::CurrentSettings
include Gitlab::Kubernetes
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 89bfc5f9a9c..d28fed11ca8 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -10,7 +10,9 @@ class ProtectedBranch < ActiveRecord::Base
def self.protected?(project, ref_name)
return true if project.empty_repo? && default_branch_protected?
- self.matching(ref_name, protected_refs: project.protected_branches).present?
+ refs = project.protected_branches.select(:name)
+
+ self.matching(ref_name, protected_refs: refs).present?
end
def self.default_branch_protected?
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index f38109c0e52..42a9bcf7723 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -5,6 +5,8 @@ class ProtectedTag < ActiveRecord::Base
protected_ref_access_levels :create
def self.protected?(project, ref_name)
- self.matching(ref_name, protected_refs: project.protected_tags).present?
+ refs = project.protected_tags.select(:name)
+
+ self.matching(ref_name, protected_refs: refs).present?
end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 2a5f07a15c4..05a16f11b59 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -9,6 +9,7 @@ class Snippet < ActiveRecord::Base
include Mentionable
include Spammable
include Editable
+ include Gitlab::SQL::Pattern
extend Gitlab::CurrentSettings
@@ -135,10 +136,7 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- t = arel_table
- pattern = "%#{query}%"
-
- where(t[:title].matches(pattern).or(t[:file_name].matches(pattern)))
+ fuzzy_search(query, [:title, :file_name])
end
# Searches for snippets with matching content.
@@ -149,10 +147,7 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search_code(query)
- table = Snippet.arel_table
- pattern = "%#{query}%"
-
- where(table[:content].matches(pattern))
+ fuzzy_search(query, [:content])
end
end
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb
index f025f40994e..fae1b64961a 100644
--- a/app/models/storage/hashed_project.rb
+++ b/app/models/storage/hashed_project.rb
@@ -4,7 +4,6 @@ module Storage
delegate :gitlab_shell, :repository_storage_path, to: :project
ROOT_PATH_PREFIX = '@hashed'.freeze
- STORAGE_VERSION = 1
def initialize(project)
@project = project
diff --git a/app/models/user.rb b/app/models/user.rb
index cf6b36559a8..14941fd7f98 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -313,9 +313,6 @@ class User < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
- table = arel_table
- pattern = User.to_pattern(query)
-
order = <<~SQL
CASE
WHEN users.name = %{query} THEN 0
@@ -325,11 +322,8 @@ class User < ActiveRecord::Base
END
SQL
- where(
- table[:name].matches(pattern)
- .or(table[:email].matches(pattern))
- .or(table[:username].matches(pattern))
- ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
+ fuzzy_search(query, [:name, :email, :username])
+ .reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
end
# searches user by given pattern
@@ -337,16 +331,16 @@ class User < ActiveRecord::Base
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
def search_with_secondary_emails(query)
- table = arel_table
email_table = Email.arel_table
- pattern = "%#{query}%"
- matched_by_emails_user_ids = email_table.project(email_table[:user_id]).where(email_table[:email].matches(pattern))
+ matched_by_emails_user_ids = email_table
+ .project(email_table[:user_id])
+ .where(Email.fuzzy_arel_match(:email, query))
where(
- table[:name].matches(pattern)
- .or(table[:email].matches(pattern))
- .or(table[:username].matches(pattern))
- .or(table[:id].in(matched_by_emails_user_ids))
+ fuzzy_arel_match(:name, query)
+ .or(fuzzy_arel_match(:email, query))
+ .or(fuzzy_arel_match(:username, query))
+ .or(arel_table[:id].in(matched_by_emails_user_ids))
)
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 8af9738d75c..a2518bc1080 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -34,6 +34,8 @@ class GroupPolicy < BasePolicy
rule { admin } .enable :read_group
rule { has_projects } .enable :read_group
+ rule { has_access }.enable :read_namespace
+
rule { developer }.enable :admin_milestones
rule { reporter }.enable :admin_label
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index 92213f0155e..eb01218eb0a 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -8,6 +8,7 @@ class NamespacePolicy < BasePolicy
rule { owner | admin }.policy do
enable :create_projects
enable :admin_namespace
+ enable :read_namespace
end
rule { personal_project & ~can_create_personal_project }.prevent :create_projects
diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb
index 99cc9a196e6..19873fe09a5 100644
--- a/app/services/base_count_service.rb
+++ b/app/services/base_count_service.rb
@@ -9,7 +9,7 @@ class BaseCountService
end
def count
- Rails.cache.fetch(cache_key, raw: raw?) { uncached_count }.to_i
+ Rails.cache.fetch(cache_key, cache_options) { uncached_count }.to_i
end
def refresh_cache
@@ -31,4 +31,10 @@ class BaseCountService
def cache_key
raise NotImplementedError, 'cache_key must be implemented and return a String'
end
+
+ # subclasses can override to add any specific options, such as
+ # super.merge({ expires_in: 5.minutes })
+ def cache_options
+ { raw: raw? }
+ end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 31a712ccc1b..7b9ea223d26 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -2,27 +2,24 @@ module Ci
class CreatePipelineService < BaseService
attr_reader :pipeline
- SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
+ SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
+ Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
Gitlab::Ci::Pipeline::Chain::Validate::Config,
Gitlab::Ci::Pipeline::Chain::Skip,
Gitlab::Ci::Pipeline::Chain::Create].freeze
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block)
- @pipeline = Ci::Pipeline.new(
- source: source,
- project: project,
- ref: ref,
- sha: sha,
- before_sha: before_sha,
- tag: tag_exists?,
- trigger_requests: Array(trigger_request),
- user: current_user,
- pipeline_schedule: schedule,
- protected: project.protected_for?(ref)
- )
-
- command = OpenStruct.new(ignore_skip_ci: ignore_skip_ci,
+ @pipeline = Ci::Pipeline.new
+
+ command = OpenStruct.new(source: source,
+ origin_ref: params[:ref],
+ checkout_sha: params[:checkout_sha],
+ after_sha: params[:after],
+ before_sha: params[:before],
+ trigger_request: trigger_request,
+ schedule: schedule,
+ ignore_skip_ci: ignore_skip_ci,
save_incompleted: save_on_errors,
seeds_block: block,
project: project,
@@ -45,14 +42,6 @@ module Ci
private
- def commit
- @commit ||= project.commit(origin_sha || origin_ref)
- end
-
- def sha
- commit.try(:id)
- end
-
def update_merge_requests_head_pipeline
return unless pipeline.latest?
@@ -76,26 +65,6 @@ module Ci
.created_or_pending
end
- def before_sha
- params[:checkout_sha] || params[:before] || Gitlab::Git::BLANK_SHA
- end
-
- def origin_sha
- params[:checkout_sha] || params[:after]
- end
-
- def origin_ref
- params[:ref]
- end
-
- def tag_exists?
- project.repository.tag_exists?(ref)
- end
-
- def ref
- @ref ||= Gitlab::Git.ref_name(origin_ref)
- end
-
def pipeline_created_counter
@pipeline_created_counter ||= Gitlab::Metrics
.counter(:pipelines_created_total, "Counter of pipelines created")
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index 53f16a236d2..1db91c3c90c 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -1,17 +1,17 @@
require 'securerandom'
-# Compare 2 branches for one repo or between repositories
+# Compare 2 refs for one repo or between repositories
# and return Gitlab::Git::Compare object that responds to commits and diffs
class CompareService
- attr_reader :start_project, :start_branch_name
+ attr_reader :start_project, :start_ref_name
- def initialize(new_start_project, new_start_branch_name)
+ def initialize(new_start_project, new_start_ref_name)
@start_project = new_start_project
- @start_branch_name = new_start_branch_name
+ @start_ref_name = new_start_ref_name
end
- def execute(target_project, target_branch, straight: false)
- raw_compare = target_project.repository.compare_source_branch(target_branch, start_project.repository, start_branch_name, straight: straight)
+ def execute(target_project, target_ref, straight: false)
+ raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
Compare.new(raw_compare, target_project, straight: straight) if raw_compare
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 39a7299ff60..2c51ac13815 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -169,10 +169,7 @@ class IssuableBaseService < BaseService
change_todo(issuable)
toggle_award(issuable)
filter_params(issuable)
- old_labels = issuable.labels.to_a
- old_mentioned_users = issuable.mentioned_users.to_a
- old_assignees = issuable.assignees.to_a
- old_total_time_spent = issuable.total_time_spent
+ old_associations = associations_before_update(issuable)
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
@@ -193,18 +190,13 @@ class IssuableBaseService < BaseService
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels)
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_associations[:labels])
end
- handle_changes(
- issuable,
- old_labels: old_labels,
- old_mentioned_users: old_mentioned_users,
- old_assignees: old_assignees
- )
+ handle_changes(issuable, old_associations: old_associations)
new_assignees = issuable.assignees.to_a
- affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees)
+ affected_assignees = (old_associations[:assignees] + new_assignees) - (old_associations[:assignees] & new_assignees)
invalidate_cache_counts(issuable, users: affected_assignees.compact)
after_update(issuable)
@@ -212,9 +204,8 @@ class IssuableBaseService < BaseService
execute_hooks(
issuable,
'update',
- old_labels: old_labels,
- old_assignees: old_assignees,
- old_total_time_spent: old_total_time_spent)
+ old_associations: old_associations
+ )
issuable.update_project_counter_caches if update_project_counters
end
@@ -267,6 +258,18 @@ class IssuableBaseService < BaseService
end
end
+ def associations_before_update(issuable)
+ associations =
+ {
+ labels: issuable.labels.to_a,
+ mentioned_users: issuable.mentioned_users.to_a,
+ assignees: issuable.assignees.to_a
+ }
+ associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent)
+
+ associations
+ end
+
def has_changes?(issuable, old_labels: [], old_assignees: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 0f711bcc3cf..9f6cfc0f6d3 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -1,7 +1,7 @@
module Issues
class BaseService < ::IssuableBaseService
- def hook_data(issue, action, old_labels: [], old_assignees: [], old_total_time_spent: nil)
- hook_data = issue.to_hook_data(current_user, old_labels: old_labels, old_assignees: old_assignees, old_total_time_spent: old_total_time_spent)
+ def hook_data(issue, action, old_associations: {})
+ hook_data = issue.to_hook_data(current_user, old_associations: old_associations)
hook_data[:object_attributes][:action] = action
hook_data
@@ -22,8 +22,8 @@ module Issues
issue, issue.project, current_user, old_assignees)
end
- def execute_hooks(issue, action = 'open', old_labels: [], old_assignees: [], old_total_time_spent: nil)
- issue_data = hook_data(issue, action, old_labels: old_labels, old_assignees: old_assignees, old_total_time_spent: old_total_time_spent)
+ def execute_hooks(issue, action = 'open', old_associations: {})
+ issue_data = hook_data(issue, action, old_associations: old_associations)
hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks
issue.project.execute_hooks(issue_data, hooks_scope)
issue.project.execute_services(issue_data, hooks_scope)
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 1b7b5927c5a..d7aa7e2347e 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -14,9 +14,10 @@ module Issues
end
def handle_changes(issue, options)
- old_labels = options[:old_labels] || []
- old_mentioned_users = options[:old_mentioned_users] || []
- old_assignees = options[:old_assignees] || []
+ old_associations = options.fetch(:old_associations, {})
+ old_labels = old_associations.fetch(:labels, [])
+ old_mentioned_users = old_associations.fetch(:mentioned_users, [])
+ old_assignees = old_associations.fetch(:assignees, [])
if has_changes?(issue, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(issue, current_user)
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index f6ffd48deae..6b32d65a74b 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -4,8 +4,8 @@ module MergeRequests
SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, state, nil)
end
- def hook_data(merge_request, action, old_rev: nil, old_labels: [], old_assignees: [], old_total_time_spent: nil)
- hook_data = merge_request.to_hook_data(current_user, old_labels: old_labels, old_assignees: old_assignees, old_total_time_spent: old_total_time_spent)
+ def hook_data(merge_request, action, old_rev: nil, old_associations: {})
+ hook_data = merge_request.to_hook_data(current_user, old_associations: old_associations)
hook_data[:object_attributes][:action] = action
if old_rev && !Gitlab::Git.blank_ref?(old_rev)
hook_data[:object_attributes][:oldrev] = old_rev
@@ -14,9 +14,9 @@ module MergeRequests
hook_data
end
- def execute_hooks(merge_request, action = 'open', old_rev: nil, old_labels: [], old_assignees: [], old_total_time_spent: nil)
+ def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {})
if merge_request.project
- merge_data = hook_data(merge_request, action, old_rev: old_rev, old_labels: old_labels, old_assignees: old_assignees, old_total_time_spent: old_total_time_spent)
+ merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations)
merge_request.project.execute_hooks(merge_data, :merge_request_hooks)
merge_request.project.execute_services(merge_data, :merge_request_hooks)
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index f3b99e1ec8c..c2fb01466df 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -1,6 +1,8 @@
module MergeRequests
class BuildService < MergeRequests::BaseService
def execute
+ @issue_iid = params.delete(:issue_iid)
+
self.merge_request = MergeRequest.new(params)
merge_request.compare_commits = []
merge_request.source_project = find_source_project
@@ -18,7 +20,17 @@ module MergeRequests
attr_accessor :merge_request
- delegate :target_branch, :source_branch, :source_project, :target_project, :compare_commits, :wip_title, :description, :errors, to: :merge_request
+ delegate :target_branch,
+ :target_branch_ref,
+ :target_project,
+ :source_branch,
+ :source_branch_ref,
+ :source_project,
+ :compare_commits,
+ :wip_title,
+ :description,
+ :errors,
+ to: :merge_request
def find_source_project
return source_project if source_project.present? && can?(current_user, :read_project, source_project)
@@ -54,10 +66,10 @@ module MergeRequests
def compare_branches
compare = CompareService.new(
source_project,
- source_branch
+ source_branch_ref
).execute(
target_project,
- target_branch
+ target_branch_ref
)
if compare
@@ -106,37 +118,53 @@ module MergeRequests
# more than one commit in the MR
#
def assign_title_and_description
- if match = source_branch.match(/\A(\d+)-/)
- iid = match[1]
- end
+ assign_title_and_description_from_single_commit
+ assign_title_from_issue
- commits = compare_commits
- if commits && commits.count == 1
- commit = commits.first
- merge_request.title = commit.title
- merge_request.description ||= commit.description.try(:strip)
- elsif iid && issue = target_project.get_issue(iid, current_user)
- case issue
- when Issue
- merge_request.title = "Resolve \"#{issue.title}\""
- when ExternalIssue
- merge_request.title = "Resolve #{issue.title}"
- end
+ merge_request.title ||= source_branch.titleize.humanize
+ merge_request.title = wip_title if compare_commits.empty?
+
+ append_closes_description
+ end
+
+ def append_closes_description
+ return unless issue_iid
+
+ closes_issue = "Closes ##{issue_iid}"
+
+ if description.present?
+ merge_request.description += closes_issue.prepend("\n\n")
else
- merge_request.title = source_branch.titleize.humanize
+ merge_request.description = closes_issue
end
+ end
- if iid
- closes_issue = "Closes ##{iid}"
+ def assign_title_and_description_from_single_commit
+ commits = compare_commits
+
+ return unless commits&.count == 1
+
+ commit = commits.first
+ merge_request.title ||= commit.title
+ merge_request.description ||= commit.description.try(:strip)
+ end
+
+ def assign_title_from_issue
+ return unless issue
- if description.present?
- merge_request.description += closes_issue.prepend("\n\n")
- else
- merge_request.description = closes_issue
+ merge_request.title =
+ case issue
+ when Issue then "Resolve \"#{issue.title}\""
+ when ExternalIssue then "Resolve #{issue.title}"
end
- end
+ end
+
+ def issue_iid
+ @issue_iid ||= source_branch.match(/\A(\d+)-/).try(:[], 1)
+ end
- merge_request.title = wip_title if commits.empty?
+ def issue
+ @issue ||= target_project.get_issue(issue_iid, current_user)
end
end
end
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
index da39a380451..89dab1dd028 100644
--- a/app/services/merge_requests/create_from_issue_service.rb
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -1,7 +1,18 @@
module MergeRequests
class CreateFromIssueService < MergeRequests::CreateService
+ def initialize(project, user, params)
+ # branch - the name of new branch
+ # ref - the source of new branch.
+
+ @branch_name = params[:branch_name]
+ @issue_iid = params[:issue_iid]
+ @ref = params[:ref]
+
+ super(project, user)
+ end
+
def execute
- return error('Invalid issue iid') unless issue_iid.present? && issue.present?
+ return error('Invalid issue iid') unless @issue_iid.present? && issue.present?
params[:label_ids] = issue.label_ids if issue.label_ids.any?
@@ -21,20 +32,16 @@ module MergeRequests
private
- def issue_iid
- @isssue_iid ||= params.delete(:issue_iid)
- end
-
def issue
- @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid)
+ @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid)
end
def branch_name
- @branch_name ||= issue.to_branch_name
+ @branch ||= @branch_name || issue.to_branch_name
end
def ref
- project.default_branch || 'master'
+ @ref || project.default_branch || 'master'
end
def merge_request
@@ -43,6 +50,7 @@ module MergeRequests
def merge_request_params
{
+ issue_iid: @issue_iid,
source_project_id: project.id,
source_branch: branch_name,
target_project_id: project.id,
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1f394cacc64..c153872c874 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -22,8 +22,9 @@ module MergeRequests
end
def handle_changes(merge_request, options)
- old_labels = options[:old_labels] || []
- old_mentioned_users = options[:old_mentioned_users] || []
+ old_associations = options.fetch(:old_associations, {})
+ old_labels = old_associations.fetch(:labels, [])
+ old_mentioned_users = old_associations.fetch(:mentioned_users, [])
if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb
new file mode 100644
index 00000000000..f8aaec8a9c0
--- /dev/null
+++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb
@@ -0,0 +1,54 @@
+module Projects
+ module HashedStorage
+ AttachmentMigrationError = Class.new(StandardError)
+
+ class MigrateAttachmentsService < BaseService
+ attr_reader :logger, :old_path, :new_path
+
+ def initialize(project, logger = nil)
+ @project = project
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ @old_path = project.full_path
+ @new_path = project.disk_path
+
+ origin = FileUploader.dynamic_path_segment(project)
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments]
+ target = FileUploader.dynamic_path_segment(project)
+
+ result = move_folder!(origin, target)
+ project.save!
+
+ if result && block_given?
+ yield
+ end
+
+ result
+ end
+
+ private
+
+ def move_folder!(old_path, new_path)
+ unless File.directory?(old_path)
+ logger.info("Skipped attachments migration from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
+ return
+ end
+
+ if File.exist?(new_path)
+ logger.error("Cannot migrate attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})")
+ raise AttachmentMigrationError, "Target path '#{new_path}' already exist"
+ end
+
+ # Create hashed storage base path folder
+ FileUtils.mkdir_p(File.dirname(new_path))
+
+ FileUtils.mv(old_path, new_path)
+ logger.info("Migrated project attachments from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})")
+
+ true
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
new file mode 100644
index 00000000000..7212e7524ab
--- /dev/null
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -0,0 +1,70 @@
+module Projects
+ module HashedStorage
+ class MigrateRepositoryService < BaseService
+ include Gitlab::ShellAdapter
+
+ attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger
+
+ def initialize(project, logger = nil)
+ @project = project
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ @old_disk_path = project.disk_path
+ has_wiki = project.wiki.repository_exists?
+
+ @old_storage_version = project.storage_version
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
+ project.ensure_storage_path_exists
+
+ @new_disk_path = project.disk_path
+
+ result = move_repository(@old_disk_path, @new_disk_path)
+
+ if has_wiki
+ @old_wiki_disk_path = "#{@old_disk_path}.wiki"
+ result &&= move_repository("#{@old_wiki_disk_path}", "#{@new_disk_path}.wiki")
+ end
+
+ unless result
+ rollback_folder_move
+ project.storage_version = nil
+ end
+
+ project.repository_read_only = false
+ project.save!
+
+ if result && block_given?
+ yield
+ end
+
+ result
+ end
+
+ private
+
+ def move_repository(from_name, to_name)
+ from_exists = gitlab_shell.exists?(project.repository_storage_path, "#{from_name}.git")
+ to_exists = gitlab_shell.exists?(project.repository_storage_path, "#{to_name}.git")
+
+ # If we don't find the repository on either original or target we should log that as it could be an issue if the
+ # project was not originally empty.
+ if !from_exists && !to_exists
+ logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
+ return false
+ elsif !from_exists
+ # Repository have been moved already.
+ return true
+ end
+
+ gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
+ end
+
+ def rollback_folder_move
+ move_repository(@new_disk_path, @old_disk_path)
+ move_repository("#{@new_disk_path}.wiki", "#{@old_disk_path}.wiki")
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb
index f5945f3b87f..662702c1db5 100644
--- a/app/services/projects/hashed_storage_migration_service.rb
+++ b/app/services/projects/hashed_storage_migration_service.rb
@@ -1,68 +1,22 @@
module Projects
class HashedStorageMigrationService < BaseService
- include Gitlab::ShellAdapter
-
- attr_reader :old_disk_path, :new_disk_path
+ attr_reader :logger
def initialize(project, logger = nil)
@project = project
- @logger ||= Rails.logger
+ @logger = logger || Rails.logger
end
def execute
- return if project.hashed_storage?(:repository)
-
- @old_disk_path = project.disk_path
- has_wiki = project.wiki.repository_exists?
-
- project.storage_version = Storage::HashedProject::STORAGE_VERSION
- project.ensure_storage_path_exists
-
- @new_disk_path = project.disk_path
-
- result = move_repository(@old_disk_path, @new_disk_path)
-
- if has_wiki
- result &&= move_repository("#{@old_disk_path}.wiki", "#{@new_disk_path}.wiki")
- end
-
- unless result
- rollback_folder_move
- return
+ # Migrate repository from Legacy to Hashed Storage
+ unless project.hashed_storage?(:repository)
+ return unless HashedStorage::MigrateRepositoryService.new(project, logger).execute
end
- project.repository_read_only = false
- project.save!
-
- block_given? ? yield : result
- end
-
- private
-
- def move_repository(from_name, to_name)
- from_exists = gitlab_shell.exists?(project.repository_storage_path, "#{from_name}.git")
- to_exists = gitlab_shell.exists?(project.repository_storage_path, "#{to_name}.git")
-
- # If we don't find the repository on either original or target we should log that as it could be an issue if the
- # project was not originally empty.
- if !from_exists && !to_exists
- logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
- return false
- elsif !from_exists
- # Repository have been moved already.
- return true
+ # Migrate attachments from Legacy to Hashed Storage
+ unless project.hashed_storage?(:attachments)
+ HashedStorage::MigrateAttachmentsService.new(project, logger).execute
end
-
- gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
- end
-
- def rollback_folder_move
- move_repository(@new_disk_path, @old_disk_path)
- move_repository("#{@new_disk_path}.wiki", "#{@old_disk_path}.wiki")
- end
-
- def logger
- @logger
end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 13e292a18bf..72eecc61c96 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -15,7 +15,7 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end
- if project.update_attributes(params.except(:default_branch))
+ if project.update_attributes(update_params)
if project.previous_changes.include?('path')
project.rename_repo
else
@@ -31,8 +31,16 @@ module Projects
end
end
+ def run_auto_devops_pipeline?
+ params.dig(:run_auto_devops_pipeline_explicit) == 'true' || params.dig(:run_auto_devops_pipeline_implicit) == 'true'
+ end
+
private
+ def update_params
+ params.except(:default_branch, :run_auto_devops_pipeline_explicit, :run_auto_devops_pipeline_implicit)
+ end
+
def renaming_project_with_container_registry_tags?
new_path = params[:path]
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index f4a5cf75018..71658df5b41 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -31,12 +31,19 @@ class FileUploader < GitlabUploader
# Returns a String without a trailing slash
def self.dynamic_path_segment(project)
if project.hashed_storage?(:attachments)
- File.join(CarrierWave.root, base_dir, project.disk_path)
+ dynamic_path_builder(project.disk_path)
else
- File.join(CarrierWave.root, base_dir, project.full_path)
+ dynamic_path_builder(project.full_path)
end
end
+ # Auxiliary method to build dynamic path segment when not using a project model
+ #
+ # Prefer to use the `.dynamic_path_segment` as it includes Hashed Storage specific logic
+ def self.dynamic_path_builder(path)
+ File.join(CarrierWave.root, base_dir, path)
+ end
+
attr_accessor :model
attr_reader :secret
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 64249c91dd0..a9d0503bc73 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -732,6 +732,30 @@
Number of Git pushes after which 'git gc' is run.
%fieldset
+ %legend Gitaly Timeouts
+ .form-group
+ = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_default, class: 'form-control'
+ .help-block
+ Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
+ for git fetch/push operations or Sidekiq jobs.
+ .form-group
+ = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_fast, class: 'form-control'
+ .help-block
+ Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast.
+ If they exceed this threshold, there may be a problem with a storage shard and 'failing fast'
+ can help maintain the stability of the GitLab instance.
+ .form-group
+ = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.number_field :gitaly_timeout_medium, class: 'form-control'
+ .help-block
+ Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
+
+ %fieldset
%legend Web terminal
.form-group
= f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2'
diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder
index e2aec532a9d..38741fe6662 100644
--- a/app/views/events/_event.atom.builder
+++ b/app/views/events/_event.atom.builder
@@ -5,7 +5,12 @@ xml.entry do
xml.link href: event_feed_url(event)
xml.title truncate(event_feed_title(event), length: 80)
xml.updated event.updated_at.xmlschema
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author_email))
+
+ # We're deliberately re-using "event.author" here since this data is
+ # eager-loaded. This allows us to re-use the user object's Email address,
+ # instead of having to run additional queries to figure out what Email to use
+ # for the avatar.
+ xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author))
xml.author do
xml.username event.author_username
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index baa8036de10..05ddd0ec733 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,10 +1,8 @@
.flash-container.flash-container-page
- - if alert
- .flash-alert
- %div{ class: (container_class) }
- %span= alert
-
- - elsif notice
- .flash-notice
- %div{ class: (container_class) }
- %span= notice
+ -# We currently only support `alert`, `notice`, `success`
+ - flash.each do |key, value|
+ -# Don't show a flash message if the message is nil
+ - if value
+ %div{ class: "flash-#{key}" }
+ %div{ class: (container_class) }
+ %span= value
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 52fb46eb8c9..97016d28535 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -4,7 +4,8 @@
%body.ui_charcoal.login-page.application.navless{ data: { page: body_data_page } }
.page-wrap
= render "layouts/header/empty"
- = render "layouts/broadcast"
+ .login-page-broadcast
+ = render "layouts/broadcast"
.container.navless-container
.content
= render "layouts/flash"
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 66146e61263..2ce960df13c 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -266,7 +266,7 @@
Pages
- else
- = nav_link(path: %w[members#show]) do
+ = nav_link(controller: :project_members) do
= link_to project_settings_members_path(@project), title: 'Members', class: 'shortcuts-tree' do
.nav-icon-container
= sprite_icon('users')
diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml
index 6b321f60212..665120c7e49 100644
--- a/app/views/projects/clusters/new.html.haml
+++ b/app/views/projects/clusters/new.html.haml
@@ -5,7 +5,7 @@
.col-sm-4
= render 'sidebar'
.col-sm-8
- - if @project.kubernetes_service&.active?
+ - if @project.deployment_platform&.active?
%h4.prepend-top-0= s_('ClusterIntegration|Cluster management')
%p= s_('ClusterIntegration|A cluster has been set up on this project through the Kubernetes integration page')
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 8b9c1bbb602..5f607c2ab25 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -67,8 +67,8 @@
- if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
- .status-icon-container{ class: "ci-status-icon-#{last_pipeline.status}" }
- = link_to project_pipeline_path(@project, last_pipeline.id) do
+ .status-icon-container
+ = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
= ci_icon_for_status(last_pipeline.status)
#{ _('Pipeline') }
= link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 5ebeae5c35f..71206f3a386 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -147,7 +147,7 @@
%ul
%li Be careful. Renaming a project's repository can have unintended side effects.
%li You will need to update your local repositories to point to the new location.
- - if @project.deployment_services.any?
+ - if @project.deployment_platform.present?
%li Your deployment services will be broken, you will need to manually fix the services after renaming.
= f.submit 'Rename project', class: "btn btn-warning"
- if can?(current_user, :change_namespace, @project)
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
new file mode 100644
index 00000000000..8a549d431ee
--- /dev/null
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -0,0 +1,26 @@
+- avatar = namespace_icon(namespace, 100)
+- can_create_project = current_user.can?(:create_projects, namespace)
+
+- if forked_project = namespace.find_fork_of(@project)
+ .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked
+ = link_to project_path(forked_project) do
+ - if /no_((\w*)_)*avatar/.match(avatar)
+ = project_identicon(namespace, class: "avatar s100 identicon")
+ - else
+ .avatar-container.s100
+ = image_tag(avatar, class: "avatar s100")
+ %h5.prepend-top-default
+ = namespace.human_name
+- else
+ .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: ("disabled" unless can_create_project) }
+ = link_to project_forks_path(@project, namespace_key: namespace.id),
+ method: "POST",
+ class: ("disabled has-tooltip" unless can_create_project),
+ title: (_('You have reached your project limit') unless can_create_project) do
+ - if /no_((\w*)_)*avatar/.match(avatar)
+ = project_identicon(namespace, class: "avatar s100 identicon")
+ - else
+ .avatar-container.s100
+ = image_tag(avatar, class: "avatar s100")
+ %h5.prepend-top-default
+ = namespace.human_name
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index e9613534dde..475c6ba4d3d 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -14,22 +14,7 @@
%h5.prepend-top-0.append-bottom-0.prepend-left-default.append-right-default
Click to fork the project
- @namespaces.each do |namespace|
- - avatar = namespace_icon(namespace, 100)
- - can_create_project = current_user.can?(:create_projects, namespace)
- - forked_project = namespace.find_fork_of(@project)
- - fork_path = forked_project ? project_path(forked_project) : project_forks_path(@project, namespace_key: namespace.id)
- .bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: [("disabled" unless can_create_project), ("forked" if forked_project)] }
- = link_to fork_path,
- method: "POST",
- class: [("js-fork-thumbnail" unless forked_project), ("disabled has-tooltip" unless can_create_project)],
- title: (_('You have reached your project limit') unless can_create_project) do
- - if /no_((\w*)_)*avatar/.match(avatar)
- = project_identicon(namespace, class: "avatar s100 identicon")
- - else
- .avatar-container.s100
- = image_tag(avatar, class: "avatar s100")
- %h5.prepend-top-default
- = namespace.human_name
+ = render 'fork_button', namespace: namespace
- else
%strong
No available namespaces to fork the project.
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 483f28c74f2..1eccc0509bd 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -13,5 +13,5 @@
quick_actions_docs_path: help_page_path('user/project/quick_actions'),
notes_path: notes_url,
last_fetched_at: Time.now.to_i,
- issue_data: serialize_issuable(@issue),
+ noteable_data: serialize_issuable(@issue),
current_user_data: UserSerializer.new.represent(current_user).to_json } }
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 4f78102be0c..331d62cf247 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -1,34 +1,50 @@
- can_create_merge_request = can?(current_user, :create_merge_request, @project)
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
-- value = can_create_merge_request ? 'Create a merge request' : 'Create a branch'
+- value = can_create_merge_request ? 'Create merge request' : 'Create branch'
- if can?(current_user, :push_code, @project)
- .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } }
+ - can_create_path = can_create_branch_project_issue_path(@project, @issue)
+ - create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch)
+ - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
+ - refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
+
+ .create-mr-dropdown-wrap{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
.btn-group.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin')
%span.text
Checking branch availability…
.btn-group.available.hide
- %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } }
- %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } }
+ %button.btn.js-create-merge-request.btn-default{ type: 'button', data: { action: data_action } }
+ = value
+
+ %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-default.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
= icon('caret-down')
- %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
+
+ %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
- if can_create_merge_request
- %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } }
- .menu-item
- .icon-container
- = icon('check')
- .description
- %strong Create a merge request
- %span
- Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'.
- %li.divider.droplab-item-ignore
- %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } }
- .menu-item
- .icon-container
- = icon('check')
- .description
- %strong Create a branch
- %span
- Creates a branch named after this issue, from '#{@project.default_branch}'.
+ %li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } }
+ .menu-item.droplab-item-ignore-hiding
+ .icon-container.droplab-item-ignore-hiding= icon('check')
+ .description.droplab-item-ignore-hiding Create merge request and branch
+
+ %li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } }
+ .menu-item.droplab-item-ignore-hiding
+ .icon-container.droplab-item-ignore-hiding= icon('check')
+ .description.droplab-item-ignore-hiding Create branch
+ %li.divider
+
+ %li.droplab-item-ignore
+ Branch name
+ %input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
+ %span.js-branch-message.branch-message.droplab-item-ignore
+
+ %li.droplab-item-ignore
+ Source (branch or tag)
+ %input.js-ref.ref.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
+ %span.js-ref-message.ref-message.droplab-item-ignore
+
+ %li.droplab-item-ignore
+ %button.btn.btn-success.js-create-target.droplab-item-ignore{ type: 'button', data: { action: 'create-mr' } }
+ Create merge request
+
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index 77211099830..ee4fa663b9f 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -13,29 +13,39 @@
%p.settings-message.text-center
= message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
- .radio
+ .radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_true do
- = form.radio_button :enabled, 'true'
+ = form.radio_button :enabled, 'true', class: 'js-auto-devops-enable-radio'
%strong Enable Auto DevOps
%br
%span.descr
The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project.
- .radio
+ - if show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(@project)
+ .checkbox.hide.js-run-auto-devops-pipeline-checkbox-wrapper
+ = label_tag 'project[run_auto_devops_pipeline_explicit]' do
+ = check_box_tag 'project[run_auto_devops_pipeline_explicit]', true, false, class: 'js-run-auto-devops-pipeline-checkbox'
+ = s_('ProjectSettings|Immediately run a pipeline on the default branch')
+
+ .radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_false do
- = form.radio_button :enabled, 'false'
+ = form.radio_button :enabled, 'false', class: 'js-auto-devops-enable-radio'
%strong Disable Auto DevOps
%br
%span.descr
An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
- .radio
- = form.label :enabled_nil do
- = form.radio_button :enabled, ''
+ .radio.js-auto-devops-enable-radio-wrapper
+ = form.label :enabled_ do
+ = form.radio_button :enabled, '', class: 'js-auto-devops-enable-radio'
%strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'})
%br
%span.descr
Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>.
- %br
+ - if show_run_auto_devops_pipeline_checkbox_for_instance_setting?(@project)
+ .checkbox.hide.js-run-auto-devops-pipeline-checkbox-wrapper
+ = label_tag 'project[run_auto_devops_pipeline_implicit]' do
+ = check_box_tag 'project[run_auto_devops_pipeline_implicit]', true, false, class: 'js-run-auto-devops-pipeline-checkbox'
+ = s_('ProjectSettings|Immediately run a pipeline on the default branch')
%p
You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.
= form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index 705a4607ad2..7a68aa16aa4 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -67,7 +67,7 @@
- if koding_enabled? && @repository.koding_yml.blank?
%li.missing
= link_to _('Set up Koding'), add_koding_stack_path(@project)
- - if @repository.gitlab_ci_yml.blank? && @project.deployment_service.present?
+ - if @repository.gitlab_ci_yml.blank? && @project.deployment_platform.present?
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml', commit_message: 'Set up auto deploy', branch_name: 'auto-deploy', context: 'autodeploy') do
#{ _('Set up auto deploy') }
diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml
index 745a6040488..64cc70053ef 100644
--- a/app/views/projects/tree/show.html.haml
+++ b/app/views/projects/tree/show.html.haml
@@ -11,6 +11,6 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'repo'
-%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] }
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 3d9c90c38fe..fba08092351 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -7,7 +7,7 @@
%span
= enabled_project_button(project, enabled_protocol)
- else
- %a#clone-dropdown.clone-dropdown-btn.btn{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.btn.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown' } }
%span
= default_clone_protocol.upcase
= icon('caret-down')
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 52a8fe8bb67..98bfc7c4d36 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -20,7 +20,7 @@
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
.project-details
%h3.prepend-top-0.append-bottom-0
- = link_to project_path(project), class: dom_class(project) do
+ = link_to project_path(project), class: 'text-plain' do
%span.project-full-name
%span.namespace-name
- if project.namespace && !skip_namespace
diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml
index 5867ea58378..87e8c416194 100644
--- a/app/views/shared/repo/_repo.html.haml
+++ b/app/views/shared/repo/_repo.html.haml
@@ -1,3 +1,4 @@
+- @no_container = true;
#repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project),
url: content_url,
diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb
new file mode 100644
index 00000000000..865ad1ba420
--- /dev/null
+++ b/app/workers/create_pipeline_worker.rb
@@ -0,0 +1,16 @@
+class CreatePipelineWorker
+ include Sidekiq::Worker
+ include PipelineQueue
+
+ enqueue_in group: :creation
+
+ def perform(project_id, user_id, ref, source, params = {})
+ project = Project.find(project_id)
+ user = User.find(user_id)
+ params = params.deep_symbolize_keys
+
+ Ci::CreatePipelineService
+ .new(project, user, ref: ref)
+ .execute(source, **params)
+ end
+end
diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb
index ca276d7801c..127aa6b9d7d 100644
--- a/app/workers/project_migrate_hashed_storage_worker.rb
+++ b/app/workers/project_migrate_hashed_storage_worker.rb
@@ -2,10 +2,34 @@ class ProjectMigrateHashedStorageWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
+ LEASE_TIMEOUT = 30.seconds.to_i
+
def perform(project_id)
project = Project.find_by(id: project_id)
return if project.nil? || project.pending_delete?
- ::Projects::HashedStorageMigrationService.new(project, logger).execute
+ uuid = lease_for(project_id).try_obtain
+ if uuid
+ ::Projects::HashedStorageMigrationService.new(project, logger).execute
+ else
+ false
+ end
+ rescue => ex
+ cancel_lease_for(project_id, uuid) if uuid
+ raise ex
+ end
+
+ def lease_for(project_id)
+ Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT)
+ end
+
+ private
+
+ def lease_key(project_id)
+ "project_migrate_hashed_storage_worker:#{project_id}"
+ end
+
+ def cancel_lease_for(project_id, uuid)
+ Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid)
end
end