diff options
127 files changed, 2192 insertions, 1240 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 92dd4d7610f..6088a1b3515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 10.2.3 (2017-11-30) + +### Fixed (7 changes) + +- Fix hashed storage for Import/Export uploads. !15482 +- Ensure that rake gitlab:cleanup:repos task does not mess with hashed repositories. !15520 +- Ensure that rake gitlab:cleanup:dirs task does not mess with hashed repositories. !15600 +- Fix WIP system note not being created. +- Fix link text from group context. +- Fix defaults for MR states and merge statuses. +- Fix pulling and pushing using a personal access token with the sudo scope. + +### Performance (3 changes) + +- Drastically improve project search performance by no longer searching namespace name. +- Reuse authors when rendering event Atom feeds. +- Optimise StuckCiJobsWorker using cheap SQL query outside, and expensive inside. + + ## 10.2.2 (2017-11-23) ### Fixed (5 changes) @@ -111,7 +111,7 @@ gem 'google-api-client', '~> 0.13.6' gem 'unf', '~> 0.1.4' # Seed data -gem 'seed-fu', '~> 2.3.7' +gem 'seed-fu', '2.3.6' # Upgrade to > 2.3.7 once https://github.com/mbleigh/seed-fu/issues/123 is solved # Markdown and HTML processing gem 'html-pipeline', '~> 1.11.0' diff --git a/Gemfile.lock b/Gemfile.lock index 7375fce8b1e..3f4c930c71d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -815,7 +815,7 @@ GEM rake (>= 0.9, < 13) sass (~> 3.4.20) securecompare (1.0.0) - seed-fu (2.3.7) + seed-fu (2.3.6) activerecord (>= 3.1) activesupport (>= 3.1) select2-rails (3.5.9.3) @@ -1153,7 +1153,7 @@ DEPENDENCIES sanitize (~> 2.0) sass-rails (~> 5.0.6) scss_lint (~> 0.54.0) - seed-fu (~> 2.3.7) + seed-fu (= 2.3.6) select2-rails (~> 3.5.9) selenium-webdriver (~> 3.5) sentry-raven (~> 2.5.3) 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/main.js b/app/assets/javascripts/main.js index 5e0edd823be..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'; 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/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/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/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 9e45ed52163..e2084e8f85f 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,62 +1,48 @@ .ci-status-icon-success, .ci-status-icon-passed { - &, - &:hover, - &:focus { - color: $green-500; + svg { + fill: $green-500; } } .ci-status-icon-failed { - &, - &:hover, - &:focus { - color: $gl-danger; + svg { + fill: $gl-danger; } } .ci-status-icon-pending, .ci-status-icon-failed_with_warnings, .ci-status-icon-success_with_warnings { - &, - &:hover, - &:focus { - color: $orange-500; + svg { + fill: $orange-500; } } .ci-status-icon-running { - &, - &:hover, - &:focus { - color: $blue-400; + svg { + fill: $blue-400; } } .ci-status-icon-canceled, .ci-status-icon-disabled, .ci-status-icon-not-found { - &, - &:hover, - &:focus { - color: $gl-text-color; + svg { + fill: $gl-text-color; } } .ci-status-icon-created, .ci-status-icon-skipped { - &, - &:hover, - &:focus { - color: $gray-darkest; + svg { + fill: $gray-darkest; } } .ci-status-icon-manual { - &, - &:hover, - &:focus { - color: $gl-text-color; + svg { + fill: $gl-text-color; } } 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/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/projects.scss b/app/assets/stylesheets/pages/projects.scss index 2c83b30500d..2dc0c288a6d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -395,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 { diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 6d274cb4ae0..402412eae71 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -275,3 +275,36 @@ 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/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/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 5bb84984142..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 diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 069c29feb80..ec6194d204f 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -26,7 +26,7 @@ module AutoDevopsHelper 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/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index ebbefc51a4f..fd64670f6b0 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -365,7 +365,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/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/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/merge_request.rb b/app/models/merge_request.rb index e232feaeada..bbc01e9677c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -899,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 diff --git a/app/models/project.rb b/app/models/project.rb index 5a3f591c2e7..c6f7f56f311 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -897,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 @@ -1547,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/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/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 1db32379df3..05ddd0ec733 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,6 +1,8 @@ .flash-container.flash-container-page -# We currently only support `alert`, `notice`, `success` - flash.each do |key, value| - %div{ class: "flash-#{key}" } - %div{ class: (container_class) } - %span= 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/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/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/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/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/changelogs/unreleased/13634-broadcast-message.yml b/changelogs/unreleased/13634-broadcast-message.yml new file mode 100644 index 00000000000..26c4c133443 --- /dev/null +++ b/changelogs/unreleased/13634-broadcast-message.yml @@ -0,0 +1,5 @@ +--- +title: Fix broadcast message not showing up on login page +merge_request: 15578 +author: +type: fixed diff --git a/changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml b/changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml new file mode 100644 index 00000000000..cb522cb7611 --- /dev/null +++ b/changelogs/unreleased/39455-clone-dropdown-should-not-have-a-tooltip.yml @@ -0,0 +1,5 @@ +--- +title: Removed tooltip from clone dropdown +merge_request: 15334 +author: +type: other diff --git a/changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml b/changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml new file mode 100644 index 00000000000..00f7dd7c0f0 --- /dev/null +++ b/changelogs/unreleased/40146_fix_special_charecter_search_in_filenames.yml @@ -0,0 +1,5 @@ +--- +title: Fix search results when a filename would contain a special character. +merge_request: 15606 +author: haseebeqx +type: fixed diff --git a/changelogs/unreleased/40291-ignore-hashed-repos-cleanup-repositories.yml b/changelogs/unreleased/40291-ignore-hashed-repos-cleanup-repositories.yml deleted file mode 100644 index 1e3f52b3a9c..00000000000 --- a/changelogs/unreleased/40291-ignore-hashed-repos-cleanup-repositories.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Ensure that rake gitlab:cleanup:repos task does not mess with hashed repositories -merge_request: 15520 -author: -type: fixed diff --git a/changelogs/unreleased/40352-ignore-hashed-repos-cleanup-dirs.yml b/changelogs/unreleased/40352-ignore-hashed-repos-cleanup-dirs.yml deleted file mode 100644 index 0ccbc699729..00000000000 --- a/changelogs/unreleased/40352-ignore-hashed-repos-cleanup-dirs.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Ensure that rake gitlab:cleanup:dirs task does not mess with hashed repositories -merge_request: 15600 -author: -type: fixed diff --git a/changelogs/unreleased/default-values-for-mr-states.yml b/changelogs/unreleased/default-values-for-mr-states.yml deleted file mode 100644 index f873a5335d0..00000000000 --- a/changelogs/unreleased/default-values-for-mr-states.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix defaults for MR states and merge statuses -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/dm-fix-registry-with-sudo-token.yml b/changelogs/unreleased/dm-fix-registry-with-sudo-token.yml deleted file mode 100644 index be687fda147..00000000000 --- a/changelogs/unreleased/dm-fix-registry-with-sudo-token.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix pulling and pushing using a personal access token with the sudo scope -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/dm-project-search-performance.yml b/changelogs/unreleased/dm-project-search-performance.yml deleted file mode 100644 index b533043b163..00000000000 --- a/changelogs/unreleased/dm-project-search-performance.yml +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Drastically improve project search performance by no longer searching namespace - name -merge_request: -author: -type: performance diff --git a/changelogs/unreleased/events-atom-feed-author-query.yml b/changelogs/unreleased/events-atom-feed-author-query.yml deleted file mode 100644 index 84c51f25de7..00000000000 --- a/changelogs/unreleased/events-atom-feed-author-query.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Reuse authors when rendering event Atom feeds -merge_request: -author: -type: performance diff --git a/changelogs/unreleased/fix-import-uploads-hashed-storage.yml b/changelogs/unreleased/fix-import-uploads-hashed-storage.yml deleted file mode 100644 index d43cabbfb8f..00000000000 --- a/changelogs/unreleased/fix-import-uploads-hashed-storage.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix hashed storage for Import/Export uploads -merge_request: 15482 -author: -type: fixed diff --git a/changelogs/unreleased/issue_40374.yml b/changelogs/unreleased/issue_40374.yml deleted file mode 100644 index 73b48b890fe..00000000000 --- a/changelogs/unreleased/issue_40374.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix WIP system note not being created -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/jk-group-mentions-fix.yml b/changelogs/unreleased/jk-group-mentions-fix.yml deleted file mode 100644 index a28e3a87b6d..00000000000 --- a/changelogs/unreleased/jk-group-mentions-fix.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix link text from group context -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/optimise-stuck-ci-jobs-worker.yml b/changelogs/unreleased/optimise-stuck-ci-jobs-worker.yml deleted file mode 100644 index 7f6adfb4fd8..00000000000 --- a/changelogs/unreleased/optimise-stuck-ci-jobs-worker.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Optimise StuckCiJobsWorker using cheap SQL query outside, and expensive inside -merge_request: -author: -type: performance diff --git a/changelogs/unreleased/protected-branches-names.yml b/changelogs/unreleased/protected-branches-names.yml new file mode 100644 index 00000000000..3c6767df571 --- /dev/null +++ b/changelogs/unreleased/protected-branches-names.yml @@ -0,0 +1,5 @@ +--- +title: Only load branch names for protected branch checks +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/use-count_commits-directly.yml b/changelogs/unreleased/use-count_commits-directly.yml new file mode 100644 index 00000000000..549e0744ea4 --- /dev/null +++ b/changelogs/unreleased/use-count_commits-directly.yml @@ -0,0 +1,5 @@ +--- +title: Improve the performance for counting commits +merge_request: 15628 +author: +type: performance diff --git a/config/webpack.config.js b/config/webpack.config.js index f7a7182a627..78ced4c3e8c 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -117,6 +117,10 @@ var config = { options: { limit: 2048 }, }, { + test: /\_worker\.js$/, + loader: 'worker-loader', + }, + { test: /\.(worker(\.min)?\.js|pdf|bmpr)$/, exclude: /node_modules/, loader: 'file-loader', diff --git a/db/fixtures/test/01_admin.rb b/db/fixtures/test/01_admin.rb new file mode 100644 index 00000000000..6f241f6fa4a --- /dev/null +++ b/db/fixtures/test/01_admin.rb @@ -0,0 +1,15 @@ +require './spec/support/sidekiq' + +Gitlab::Seeder.quiet do + User.seed do |s| + s.id = 1 + s.name = 'Administrator' + s.email = 'admin@example.com' + s.notification_email = 'admin@example.com' + s.username = 'root' + s.password = '5iveL!fe' + s.admin = true + s.projects_limit = 100 + s.confirmed_at = DateTime.now + end +end diff --git a/doc/install/installation.md b/doc/install/installation.md index 88000f4c7a9..570b0d5b22f 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -367,6 +367,9 @@ sudo usermod -aG redis git # Enable packfile bitmaps sudo -u git -H git config --global repack.writeBitmaps true + + # Enable push options + sudo -u git -H git config --global receive.advertisePushOptions true # Configure Redis connection settings sudo -u git -H cp config/resque.yml.example config/resque.yml diff --git a/doc/integration/google.md b/doc/integration/google.md index 727ca13ebcf..07a700f7b64 100644 --- a/doc/integration/google.md +++ b/doc/integration/google.md @@ -35,7 +35,7 @@ In Google's side: 1. You should now be able to see a Client ID and Client secret. Note them down or keep this page open as you will need them later. -1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Google Cloud APIs > Container Engine API > Enable** +1. From the **Dashboard** select **ENABLE APIS AND SERVICES > Compute > Google Container Engine API > Enable** On your GitLab server: diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index ddd52136bc4..228d97a87ab 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -49,6 +49,7 @@ module Gitlab # Keep in mind that this method may allocate a lot of memory. It is up # to the caller to limit the number of blobs and blob_size_limit. # + # Gitaly migration issue: https://gitlab.com/gitlab-org/gitaly/issues/798 def batch(repository, blob_references, blob_size_limit: nil) blob_size_limit ||= MAX_DATA_DISPLAY_SIZE blob_references.map do |sha, path| diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index d399636bb28..fb9c3e92d3f 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -505,7 +505,7 @@ module Gitlab # Counts the amount of commits between `from` and `to`. def count_commits_between(from, to) - Commit.between(self, from, to).size + count_commits(ref: "#{from}..#{to}") end # Returns the SHA of the most recent common ancestor of +from+ and +to+ diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 561aa9e162c..e2662fc362b 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -47,8 +47,11 @@ module Gitlab startline = 0 result.each_line.each_with_index do |line, index| - if line =~ /^.*:.*:\d+:/ - ref, filename, startline = line.split(':') + matches = line.match(/^(?<ref>[^:]*):(?<filename>.*):(?<startline>\d+):/) + if matches + ref = matches[:ref] + filename = matches[:filename] + startline = matches[:startline] startline = startline.to_i - index extname = Regexp.escape(File.extname(filename)) basename = filename.sub(/#{extname}$/, '') diff --git a/lib/gitlab/prometheus/queries/query_additional_metrics.rb b/lib/gitlab/prometheus/queries/query_additional_metrics.rb index 7ac6162b54d..5cddc96a643 100644 --- a/lib/gitlab/prometheus/queries/query_additional_metrics.rb +++ b/lib/gitlab/prometheus/queries/query_additional_metrics.rb @@ -76,7 +76,7 @@ module Gitlab timeframe_start: timeframe_start, timeframe_end: timeframe_end, ci_environment_slug: environment.slug, - kube_namespace: environment.project.kubernetes_service&.actual_namespace || '', + kube_namespace: environment.project.deployment_platform&.actual_namespace || '', environment_filter: %{container_name!="POD",environment="#{environment.slug}"} } end diff --git a/package.json b/package.json index 8c1b2c401ed..c0a4db122bd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "bootstrap-sass": "^3.3.6", "brace-expansion": "^1.1.8", "classlist-polyfill": "^1.2.0", + "clipboard": "^1.7.1", "compression-webpack-plugin": "^1.0.0", "copy-webpack-plugin": "^4.0.1", "core-js": "^2.4.1", @@ -33,6 +34,7 @@ "css-loader": "^0.28.0", "d3": "^3.5.11", "deckar01-task_list": "^2.0.0", + "diff": "^3.4.0", "document-register-element": "1.3.0", "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", @@ -74,7 +76,8 @@ "vuex": "^3.0.1", "webpack": "^3.5.5", "webpack-bundle-analyzer": "^2.8.2", - "webpack-stats-plugin": "^0.1.5" + "webpack-stats-plugin": "^0.1.5", + "worker-loader": "^1.1.0" }, "devDependencies": { "@gitlab-org/gitlab-svgs": "^1.1.1", diff --git a/scripts/gitaly-test-spawn b/scripts/gitaly-test-spawn index dd603eec7f6..8e05eca8d7e 100755 --- a/scripts/gitaly-test-spawn +++ b/scripts/gitaly-test-spawn @@ -1,7 +1,8 @@ #!/usr/bin/env ruby gitaly_dir = 'tmp/tests/gitaly' +env = { 'HOME' => File.expand_path('tmp/tests') } args = %W[#{gitaly_dir}/gitaly #{gitaly_dir}/config.toml] # Print the PID of the spawned process -puts spawn(*args, [:out, :err] => 'log/gitaly-test.log') +puts spawn(env, *args, [:out, :err] => 'log/gitaly-test.log') diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 973d6fed288..d731200f70f 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -113,22 +113,38 @@ describe Projects::BranchesController do expect(response).to redirect_to project_tree_path(project, branch) end - it 'redirects to autodeploy setup page' do - result = { status: :success, branch: double(name: branch) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'redirects to autodeploy setup page' do + result = { status: :success, branch: double(name: branch) } + + expect_any_instance_of(CreateBranchService).to receive(:execute).and_return(result) + expect(SystemNoteService).to receive(:new_issue_branch).and_return(true) + + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_iid: issue.iid + + expect(response.location).to include(project_new_blob_path(project, branch)) + expect(response).to have_gitlab_http_status(302) + end + end - project.services << build(:kubernetes_service) + context 'when user configured kubernetes from Integration > Kubernetes' do + before do + project.services << build(:kubernetes_service) + end - expect_any_instance_of(CreateBranchService).to receive(:execute).and_return(result) - expect(SystemNoteService).to receive(:new_issue_branch).and_return(true) + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end - post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - branch_name: branch, - issue_iid: issue.iid + context 'when user configured kubernetes from CI/CD > Clusters' do + before do + create(:cluster, :provided_by_gcp, projects: [project]) + end - expect(response.location).to include(project_new_blob_path(project, branch)) - expect(response).to have_gitlab_http_status(302) + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/features/auto_deploy_spec.rb b/spec/features/auto_deploy_spec.rb index 4a7c3e4f1ab..7a395f62511 100644 --- a/spec/features/auto_deploy_spec.rb +++ b/spec/features/auto_deploy_spec.rb @@ -4,52 +4,74 @@ describe 'Auto deploy' do let(:user) { create(:user) } let(:project) { create(:project, :repository) } - before do - create :kubernetes_service, project: project - project.team << [user, :master] - sign_in user - end + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + context 'when no deployment service is active' do + before do + trun_off + end - context 'when no deployment service is active' do - before do - project.kubernetes_service.update!(active: false) + it 'does not show a button to set up auto deploy' do + visit project_path(project) + expect(page).to have_no_content('Set up auto deploy') + end end - it 'does not show a button to set up auto deploy' do - visit project_path(project) - expect(page).to have_no_content('Set up auto deploy') + context 'when a deployment service is active' do + before do + trun_on + visit project_path(project) + end + + it 'shows a button to set up auto deploy' do + expect(page).to have_link('Set up auto deploy') + end + + it 'includes OpenShift as an available template', :js do + click_link 'Set up auto deploy' + click_button 'Apply a GitLab CI Yaml template' + + within '.gitlab-ci-yml-selector' do + expect(page).to have_content('OpenShift') + end + end + + it 'creates a merge request using "auto-deploy" branch', :js do + click_link 'Set up auto deploy' + click_button 'Apply a GitLab CI Yaml template' + within '.gitlab-ci-yml-selector' do + click_on 'OpenShift' + end + wait_for_requests + click_button 'Commit changes' + + expect(page).to have_content('New Merge Request From auto-deploy into master') + end end end - context 'when a deployment service is active' do + context 'when user configured kubernetes from Integration > Kubernetes' do before do - project.kubernetes_service.update!(active: true) - visit project_path(project) + create :kubernetes_service, project: project + project.team << [user, :master] + sign_in user end - it 'shows a button to set up auto deploy' do - expect(page).to have_link('Set up auto deploy') - end + let(:trun_on) { project.deployment_platform.update!(active: true) } + let(:trun_off) { project.deployment_platform.update!(active: false) } - it 'includes OpenShift as an available template', :js do - click_link 'Set up auto deploy' - click_button 'Apply a GitLab CI Yaml template' + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end - within '.gitlab-ci-yml-selector' do - expect(page).to have_content('OpenShift') - end + context 'when user configured kubernetes from CI/CD > Clusters' do + before do + create(:cluster, :provided_by_gcp, projects: [project]) + project.team << [user, :master] + sign_in user end - it 'creates a merge request using "auto-deploy" branch', :js do - click_link 'Set up auto deploy' - click_button 'Apply a GitLab CI Yaml template' - within '.gitlab-ci-yml-selector' do - click_on 'OpenShift' - end - wait_for_requests - click_button 'Commit changes' + let(:trun_on) { project.deployment_platform.cluster.update!(enabled: true) } + let(:trun_off) { project.deployment_platform.cluster.update!(enabled: false) } - expect(page).to have_content('New Merge Request From auto-deploy into master') - end + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/features/issuables/discussion_lock_spec.rb b/spec/features/issuables/discussion_lock_spec.rb index 7ea29ff252b..ecbe51a7bc2 100644 --- a/spec/features/issuables/discussion_lock_spec.rb +++ b/spec/features/issuables/discussion_lock_spec.rb @@ -14,7 +14,7 @@ describe 'Discussion Lock', :js do project.add_developer(user) end - context 'when the discussion is unlocked' do + context 'when the discussion is unlocked' do it 'the user can lock the issue' do visit project_issue_path(project, issue) diff --git a/spec/features/logout_spec.rb b/spec/features/logout_spec.rb new file mode 100644 index 00000000000..635729efa53 --- /dev/null +++ b/spec/features/logout_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe 'Logout/Sign out', :js do + let(:user) { create(:user) } + + before do + sign_in(user) + visit root_path + end + + it 'sign out redirects to sign in page' do + gitlab_sign_out + + expect(current_path).to eq new_user_session_path + end + + it 'sign out does not show signed out flash notice' do + gitlab_sign_out + + expect(page).not_to have_selector('.flash-notice') + end +end diff --git a/spec/features/projects/clusters/interchangeability_spec.rb b/spec/features/projects/clusters/interchangeability_spec.rb new file mode 100644 index 00000000000..01f9526608f --- /dev/null +++ b/spec/features/projects/clusters/interchangeability_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +feature 'Interchangeability between KubernetesService and Platform::Kubernetes' do + EXCEPT_METHODS = %i[test title description help fields initialize_properties namespace namespace= api_url api_url=].freeze + EXCEPT_METHODS_GREP_V = %w[_touched? _changed? _was].freeze + + it 'Clusters::Platform::Kubernetes covers core interfaces in KubernetesService' do + expected_interfaces = KubernetesService.instance_methods(false) + expected_interfaces = expected_interfaces - EXCEPT_METHODS + EXCEPT_METHODS_GREP_V.each do |g| + expected_interfaces = expected_interfaces.grep_v(/#{Regexp.escape(g)}\z/) + end + + expect(expected_interfaces - Clusters::Platforms::Kubernetes.instance_methods).to be_empty + end +end diff --git a/spec/features/projects/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 5fc3ba54f65..dfcf97ad495 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -101,35 +101,48 @@ feature 'Environment' do end context 'with terminal' do - let(:project) { create(:kubernetes_project, :test_repo) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + context 'for project master' do + let(:role) { :master } - context 'for project master' do - let(:role) { :master } + scenario 'it shows the terminal button' do + expect(page).to have_terminal_button + end - scenario 'it shows the terminal button' do - expect(page).to have_terminal_button + context 'web terminal', :js do + before do + # Stub #terminals as it causes js-enabled feature specs to render the page incorrectly + allow_any_instance_of(Environment).to receive(:terminals) { nil } + visit terminal_project_environment_path(project, environment) + end + + it 'displays a web terminal' do + expect(page).to have_selector('#terminal') + expect(page).to have_link(nil, href: environment.external_url) + end + end end - context 'web terminal', :js do - before do - # Stub #terminals as it causes js-enabled feature specs to render the page incorrectly - allow_any_instance_of(Environment).to receive(:terminals) { nil } - visit terminal_project_environment_path(project, environment) - end + context 'for developer' do + let(:role) { :developer } - it 'displays a web terminal' do - expect(page).to have_selector('#terminal') - expect(page).to have_link(nil, href: environment.external_url) + scenario 'does not show terminal button' do + expect(page).not_to have_terminal_button end end end - context 'for developer' do - let(:role) { :developer } + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project, :test_repo) } - scenario 'does not show terminal button' do - expect(page).not_to have_terminal_button - end + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 879ee6f4b9b..4a05313c14a 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -208,22 +208,35 @@ feature 'Environments page', :js do end context 'when kubernetes terminal is available' do - let(:project) { create(:kubernetes_project, :test_repo) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + context 'for project master' do + let(:role) { :master } - context 'for project master' do - let(:role) { :master } + it 'shows the terminal button' do + expect(page).to have_terminal_button + end + end + + context 'when user is a developer' do + let(:role) { :developer } - it 'shows the terminal button' do - expect(page).to have_terminal_button + it 'does not show terminal button' do + expect(page).not_to have_terminal_button + end end end - context 'when user is a developer' do - let(:role) { :developer } + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project, :test_repo) } - it 'does not show terminal button' do - expect(page).not_to have_terminal_button - end + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let(:cluster) { create(:cluster, :provided_by_gcp, projects: [create(:project, :repository)]) } + let(:project) { cluster.project } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end end diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb index e5158761333..d2c7867febb 100644 --- a/spec/helpers/button_helper_spec.rb +++ b/spec/helpers/button_helper_spec.rb @@ -26,9 +26,10 @@ describe ButtonHelper do context 'when user has password automatically set' do let(:user) { create(:user, password_automatically_set: true) } - it 'shows a password tooltip' do - expect(element.attr('class')).to include(has_tooltip_class) - expect(element.attr('data-title')).to eq('Set a password on your account to pull or push via HTTP.') + it 'shows the password text on the dropdown' do + description = element.search('.dropdown-menu-inner-content').first + + expect(description.inner_text).to eq 'Set a password on your account to pull or push via HTTP.' end end end @@ -39,17 +40,10 @@ describe ButtonHelper do end context 'when user has no personal access tokens' do - it 'has a personal access token tooltip ' do - expect(element.attr('class')).to include(has_tooltip_class) - expect(element.attr('data-title')).to eq('Create a personal access token on your account to pull or push via HTTP.') - end - end - - context 'when user has a personal access token' do - it 'shows no tooltip' do - create(:personal_access_token, user: user) + it 'has a personal access token text on the dropdown description ' do + description = element.search('.dropdown-menu-inner-content').first - expect(element.attr('class')).not_to include(has_tooltip_class) + expect(description.inner_text).to eq 'Create a personal access token on your account to pull or push via HTTP.' end end end @@ -63,6 +57,41 @@ describe ButtonHelper do end end + describe 'ssh_button' do + let(:user) { create(:user) } + let(:project) { build_stubbed(:project) } + + def element + element = helper.ssh_clone_button(project) + + Nokogiri::HTML::DocumentFragment.parse(element).first_element_child + end + + before do + allow(helper).to receive(:current_user).and_return(user) + end + + context 'without an ssh key on the user' do + it 'shows a warning on the dropdown description' do + description = element.search('.dropdown-menu-inner-content').first + + expect(description.inner_text).to eq "You won't be able to pull or push project code via SSH until you add an SSH key to your profile" + end + end + + context 'with an ssh key on the user' do + before do + create(:key, user: user) + end + + it 'there is no warning on the dropdown description' do + description = element.search('.dropdown-menu-inner-content').first + + expect(description).to eq nil + end + end + end + describe 'clipboard_button' do let(:user) { create(:user) } let(:project) { build_stubbed(:project) } diff --git a/spec/javascripts/behaviors/requires_input_spec.js b/spec/javascripts/behaviors/requires_input_spec.js index f9fa814b801..8287c58ac5a 100644 --- a/spec/javascripts/behaviors/requires_input_spec.js +++ b/spec/javascripts/behaviors/requires_input_spec.js @@ -1,39 +1,43 @@ -/* eslint-disable space-before-function-paren, no-var */ - import '~/behaviors/requires_input'; -(function() { - describe('requiresInput', function() { - preloadFixtures('branches/new_branch.html.raw'); - beforeEach(function() { - loadFixtures('branches/new_branch.html.raw'); - this.submitButton = $('button[type="submit"]'); - }); - it('disables submit when any field is required', function() { - $('.js-requires-input').requiresInput(); - return expect(this.submitButton).toBeDisabled(); - }); - it('enables submit when no field is required', function() { - $('*[required=required]').removeAttr('required'); - $('.js-requires-input').requiresInput(); - return expect(this.submitButton).not.toBeDisabled(); - }); - it('enables submit when all required fields are pre-filled', function() { - $('*[required=required]').remove(); - $('.js-requires-input').requiresInput(); - return expect($('.submit')).not.toBeDisabled(); - }); - it('enables submit when all required fields receive input', function() { - $('.js-requires-input').requiresInput(); - $('#required1').val('input1').change(); - expect(this.submitButton).toBeDisabled(); - $('#optional1').val('input1').change(); - expect(this.submitButton).toBeDisabled(); - $('#required2').val('input2').change(); - $('#required3').val('input3').change(); - $('#required4').val('input4').change(); - $('#required5').val('1').change(); - return expect($('.submit')).not.toBeDisabled(); - }); +describe('requiresInput', () => { + let submitButton; + preloadFixtures('branches/new_branch.html.raw'); + + beforeEach(() => { + loadFixtures('branches/new_branch.html.raw'); + submitButton = $('button[type="submit"]'); + }); + + it('disables submit when any field is required', () => { + $('.js-requires-input').requiresInput(); + expect(submitButton).toBeDisabled(); + }); + + it('enables submit when no field is required', () => { + $('*[required=required]').removeAttr('required'); + $('.js-requires-input').requiresInput(); + expect(submitButton).not.toBeDisabled(); + }); + + it('enables submit when all required fields are pre-filled', () => { + $('*[required=required]').remove(); + $('.js-requires-input').requiresInput(); + expect($('.submit')).not.toBeDisabled(); + }); + + it('enables submit when all required fields receive input', () => { + $('.js-requires-input').requiresInput(); + $('#required1').val('input1').change(); + expect(submitButton).toBeDisabled(); + + $('#optional1').val('input1').change(); + expect(submitButton).toBeDisabled(); + + $('#required2').val('input2').change(); + $('#required3').val('input3').change(); + $('#required4').val('input4').change(); + $('#required5').val('1').change(); + expect($('.submit')).not.toBeDisabled(); }); -}).call(window); +}); diff --git a/spec/javascripts/notes/components/issue_comment_form_spec.js b/spec/javascripts/notes/components/issue_comment_form_spec.js index db75262b562..04a7f8e32f1 100644 --- a/spec/javascripts/notes/components/issue_comment_form_spec.js +++ b/spec/javascripts/notes/components/issue_comment_form_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import Autosize from 'autosize'; import store from '~/notes/stores'; import issueCommentForm from '~/notes/components/issue_comment_form.vue'; -import { loggedOutIssueData, notesDataMock, userDataMock, issueDataMock } from '../mock_data'; +import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; import { keyboardDownEvent } from '../../issue_show/helpers'; describe('issue_comment_form component', () => { @@ -23,7 +23,7 @@ describe('issue_comment_form component', () => { describe('user is logged in', () => { beforeEach(() => { store.dispatch('setUserData', userDataMock); - store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); vm = mountComponent(); @@ -178,7 +178,7 @@ describe('issue_comment_form component', () => { describe('issue is confidential', () => { it('shows information warning', (done) => { - store.dispatch('setIssueData', Object.assign(issueDataMock, { confidential: true })); + store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true })); Vue.nextTick(() => { expect(vm.$el.querySelector('.confidential-issue-warning')).toBeDefined(); done(); @@ -190,7 +190,7 @@ describe('issue_comment_form component', () => { describe('user is not logged in', () => { beforeEach(() => { store.dispatch('setUserData', null); - store.dispatch('setIssueData', loggedOutIssueData); + store.dispatch('setNoteableData', loggedOutnoteableData); store.dispatch('setNotesData', notesDataMock); vm = mountComponent(); diff --git a/spec/javascripts/notes/components/issue_discussion_spec.js b/spec/javascripts/notes/components/issue_discussion_spec.js index 05c6b57f93e..b6ae55d44f5 100644 --- a/spec/javascripts/notes/components/issue_discussion_spec.js +++ b/spec/javascripts/notes/components/issue_discussion_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/notes/stores'; import issueDiscussion from '~/notes/components/issue_discussion.vue'; -import { issueDataMock, discussionMock, notesDataMock } from '../mock_data'; +import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; describe('issue_discussion component', () => { let vm; @@ -9,7 +9,7 @@ describe('issue_discussion component', () => { beforeEach(() => { const Component = Vue.extend(issueDiscussion); - store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); vm = new Component({ diff --git a/spec/javascripts/notes/components/issue_note_app_spec.js b/spec/javascripts/notes/components/issue_note_app_spec.js index 22e91c4c40f..8e43037f356 100644 --- a/spec/javascripts/notes/components/issue_note_app_spec.js +++ b/spec/javascripts/notes/components/issue_note_app_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import issueNotesApp from '~/notes/components/issue_notes_app.vue'; -import service from '~/notes/services/issue_notes_service'; +import service from '~/notes/services/notes_service'; import * as mockData from '../mock_data'; describe('issue_note_app', () => { @@ -24,7 +24,7 @@ describe('issue_note_app', () => { mountComponent = (data) => { const props = data || { - issueData: mockData.issueDataMock, + noteableData: mockData.noteableDataMock, notesData: mockData.notesDataMock, userData: mockData.userDataMock, }; @@ -60,7 +60,7 @@ describe('issue_note_app', () => { }); it('should set issue data', () => { - expect(vm.$store.state.issueData).toEqual(mockData.issueDataMock); + expect(vm.$store.state.noteableData).toEqual(mockData.noteableDataMock); }); it('should set user data', () => { diff --git a/spec/javascripts/notes/components/issue_note_awards_list_spec.js b/spec/javascripts/notes/components/issue_note_awards_list_spec.js index 3b6c34f1494..c689c452143 100644 --- a/spec/javascripts/notes/components/issue_note_awards_list_spec.js +++ b/spec/javascripts/notes/components/issue_note_awards_list_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/notes/stores'; import awardsNote from '~/notes/components/issue_note_awards_list.vue'; -import { issueDataMock, notesDataMock } from '../mock_data'; +import { noteableDataMock, notesDataMock } from '../mock_data'; describe('issue_note_awards_list component', () => { let vm; @@ -10,7 +10,7 @@ describe('issue_note_awards_list component', () => { beforeEach(() => { const Component = Vue.extend(awardsNote); - store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); awardsMock = [ { diff --git a/spec/javascripts/notes/components/issue_note_body_spec.js b/spec/javascripts/notes/components/issue_note_body_spec.js index 81f07ed47cc..37aad50737b 100644 --- a/spec/javascripts/notes/components/issue_note_body_spec.js +++ b/spec/javascripts/notes/components/issue_note_body_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import store from '~/notes/stores'; import noteBody from '~/notes/components/issue_note_body.vue'; -import { issueDataMock, notesDataMock, note } from '../mock_data'; +import { noteableDataMock, notesDataMock, note } from '../mock_data'; describe('issue_note_body component', () => { let vm; @@ -10,7 +10,7 @@ describe('issue_note_body component', () => { beforeEach(() => { const Component = Vue.extend(noteBody); - store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); vm = new Component({ diff --git a/spec/javascripts/notes/components/issue_note_form_spec.js b/spec/javascripts/notes/components/issue_note_form_spec.js index a90dbcb72b5..d42ef239711 100644 --- a/spec/javascripts/notes/components/issue_note_form_spec.js +++ b/spec/javascripts/notes/components/issue_note_form_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import store from '~/notes/stores'; import issueNoteForm from '~/notes/components/issue_note_form.vue'; -import { issueDataMock, notesDataMock } from '../mock_data'; +import { noteableDataMock, notesDataMock } from '../mock_data'; import { keyboardDownEvent } from '../../issue_show/helpers'; describe('issue_note_form component', () => { @@ -11,7 +11,7 @@ describe('issue_note_form component', () => { beforeEach(() => { const Component = Vue.extend(issueNoteForm); - store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); props = { diff --git a/spec/javascripts/notes/components/issue_note_spec.js b/spec/javascripts/notes/components/issue_note_spec.js index 7ef85d5b4f0..73fd188dbe5 100644 --- a/spec/javascripts/notes/components/issue_note_spec.js +++ b/spec/javascripts/notes/components/issue_note_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import store from '~/notes/stores'; import issueNote from '~/notes/components/issue_note.vue'; -import { issueDataMock, notesDataMock, note } from '../mock_data'; +import { noteableDataMock, notesDataMock, note } from '../mock_data'; describe('issue_note', () => { let vm; @@ -10,7 +10,7 @@ describe('issue_note', () => { beforeEach(() => { const Component = Vue.extend(issueNote); - store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); vm = new Component({ diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index 89ba3a002b7..42497de3c55 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -18,7 +18,7 @@ export const userDataMock = { username: 'root', }; -export const issueDataMock = { +export const noteableDataMock = { assignees: [], author_id: 1, branch_name: null, @@ -271,7 +271,7 @@ export const discussionMock = { individual_note: false, }; -export const loggedOutIssueData = { +export const loggedOutnoteableData = { "id": 98, "iid": 26, "author_id": 1, diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index 3d1ca870ca4..e092320f9a3 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -1,6 +1,6 @@ import * as actions from '~/notes/stores/actions'; import testAction from '../../helpers/vuex_action_helper'; -import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; +import { discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; describe('Actions Notes Store', () => { describe('setNotesData', () => { @@ -11,10 +11,10 @@ describe('Actions Notes Store', () => { }); }); - describe('setIssueData', () => { + describe('setNoteableData', () => { it('should set received issue data', (done) => { - testAction(actions.setIssueData, null, { issueData: {} }, [ - { type: 'SET_ISSUE_DATA', payload: issueDataMock }, + testAction(actions.setNoteableData, null, { noteableData: {} }, [ + { type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }, ], done); }); }); diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index 48ee1bf9a52..c5a84b71788 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -1,5 +1,5 @@ import * as getters from '~/notes/stores/getters'; -import { notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; +import { notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; describe('Getters Notes Store', () => { let state; @@ -11,7 +11,7 @@ describe('Getters Notes Store', () => { notesData: notesDataMock, userData: userDataMock, - issueData: issueDataMock, + noteableData: noteableDataMock, }; }); describe('notes', () => { @@ -32,9 +32,9 @@ describe('Getters Notes Store', () => { }); }); - describe('getIssueData', () => { - it('should return all data in `issueData`', () => { - expect(getters.getIssueData(state)).toEqual(issueDataMock); + describe('getNoteableData', () => { + it('should return all data in `noteableData`', () => { + expect(getters.getNoteableData(state)).toEqual(noteableDataMock); }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index 1e22e03e178..22d99998a7d 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -1,5 +1,5 @@ import mutations from '~/notes/stores/mutations'; -import { note, discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; +import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; describe('Mutation Notes Store', () => { describe('ADD_NEW_NOTE', () => { @@ -74,14 +74,14 @@ describe('Mutation Notes Store', () => { }); }); - describe('SET_ISSUE_DATA', () => { + describe('SET_NOTEABLE_DATA', () => { it('should set the issue data', () => { const state = { - issueData: {}, + noteableData: {}, }; - mutations.SET_ISSUE_DATA(state, issueDataMock); - expect(state.issueData).toEqual(issueDataMock); + mutations.SET_NOTEABLE_DATA(state, noteableDataMock); + expect(state.noteableData).toEqual(noteableDataMock); }); }); diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js index 979d2185076..81158cad639 100644 --- a/spec/javascripts/repo/components/repo_editor_spec.js +++ b/spec/javascripts/repo/components/repo_editor_spec.js @@ -1,12 +1,13 @@ import Vue from 'vue'; import store from '~/repo/stores'; import repoEditor from '~/repo/components/repo_editor.vue'; +import monacoLoader from '~/repo/monaco_loader'; import { file, resetStore } from '../helpers'; describe('RepoEditor', () => { let vm; - beforeEach(() => { + beforeEach((done) => { const f = file(); const RepoEditor = Vue.extend(repoEditor); @@ -21,6 +22,10 @@ describe('RepoEditor', () => { vm.monaco = true; vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); }); afterEach(() => { @@ -32,7 +37,6 @@ describe('RepoEditor', () => { it('renders an ide container', (done) => { Vue.nextTick(() => { expect(vm.shouldHideEditor).toBeFalsy(); - expect(vm.$el.textContent.trim()).toBe(''); done(); }); @@ -50,7 +54,7 @@ describe('RepoEditor', () => { }); it('shows activeFile html', () => { - expect(vm.$el.textContent.trim()).toBe('testing'); + expect(vm.$el.textContent).toContain('testing'); }); }); }); diff --git a/spec/javascripts/repo/lib/common/disposable_spec.js b/spec/javascripts/repo/lib/common/disposable_spec.js new file mode 100644 index 00000000000..62c3913bf4d --- /dev/null +++ b/spec/javascripts/repo/lib/common/disposable_spec.js @@ -0,0 +1,44 @@ +import Disposable from '~/repo/lib/common/disposable'; + +describe('Multi-file editor library disposable class', () => { + let instance; + let disposableClass; + + beforeEach(() => { + instance = new Disposable(); + + disposableClass = { + dispose: jasmine.createSpy('dispose'), + }; + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('add', () => { + it('adds disposable classes', () => { + instance.add(disposableClass); + + expect(instance.disposers.size).toBe(1); + }); + }); + + describe('dispose', () => { + beforeEach(() => { + instance.add(disposableClass); + }); + + it('calls dispose on all cached disposers', () => { + instance.dispose(); + + expect(disposableClass.dispose).toHaveBeenCalled(); + }); + + it('clears cached disposers', () => { + instance.dispose(); + + expect(instance.disposers.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/repo/lib/common/model_manager_spec.js b/spec/javascripts/repo/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..8c134f178c0 --- /dev/null +++ b/spec/javascripts/repo/lib/common/model_manager_spec.js @@ -0,0 +1,81 @@ +/* global monaco */ +import monacoLoader from '~/repo/monaco_loader'; +import ModelManager from '~/repo/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = new ModelManager(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + instance.addModel(file('path-name')); + + expect(instance.models.keys().next().value).toBe('path-name'); + }); + + it('adds model into disposable', () => { + spyOn(instance.disposable, 'add').and.callThrough(); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + spyOn(instance.models, 'get').and.callThrough(); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + instance.addModel(file('path-name')); + + expect(instance.hasCachedModel('path-name')).toBeTruthy(); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/repo/lib/common/model_spec.js b/spec/javascripts/repo/lib/common/model_spec.js new file mode 100644 index 00000000000..d41ade237ca --- /dev/null +++ b/spec/javascripts/repo/lib/common/model_spec.js @@ -0,0 +1,84 @@ +/* global monaco */ +import monacoLoader from '~/repo/monaco_loader'; +import Model from '~/repo/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe('path'); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('onChange', () => { + it('caches event by path', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + expect(model.events.keys().next().value).toBe('path'); + }); + + it('calls callback on change', (done) => { + const spy = jasmine.createSpy(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setTimeout(() => { + expect(spy).toHaveBeenCalledWith(model.getModel(), jasmine.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(model.disposable, 'dispose').and.callThrough(); + + model.dispose(); + + expect(model.disposable.dispose).toHaveBeenCalled(); + }); + + it('clears events', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + + model.dispose(); + + expect(model.events.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/repo/lib/decorations/controller_spec.js b/spec/javascripts/repo/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..2e32e8fa0bd --- /dev/null +++ b/spec/javascripts/repo/lib/decorations/controller_spec.js @@ -0,0 +1,120 @@ +/* global monaco */ +import monacoLoader from '~/repo/monaco_loader'; +import editor from '~/repo/lib/editor'; +import DecorationsController from '~/repo/lib/decorations/controller'; +import Model from '~/repo/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + editorInstance.dispose(); + controller.dispose(); + }); + + describe('getAllDecorationsForModel', () => { + it('returns empty array when no decorations exist for model', () => { + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations).toEqual([]); + }); + + it('returns decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations[0]).toEqual({ decoration: 'decorationValue' }); + }); + }); + + describe('addDecorations', () => { + it('caches decorations in a new map', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('does not create new cache model', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]); + + expect(controller.decorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorations.size).toBe(1); + expect(controller.decorations.keys().next().value).toBe('path'); + }); + + it('calls decorate method', () => { + spyOn(controller, 'decorate'); + + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + spyOn(controller.editor.instance, 'deltaDecorations'); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); + }); + + it('caches decorations', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('path'); + }); + }); + + describe('dispose', () => { + it('clears cached decorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.decorations.size).toBe(0); + }); + + it('clears cached editorDecorations', () => { + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + controller.dispose(); + + expect(controller.editorDecorations.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/repo/lib/diff/controller_spec.js b/spec/javascripts/repo/lib/diff/controller_spec.js new file mode 100644 index 00000000000..ed62e28d3a3 --- /dev/null +++ b/spec/javascripts/repo/lib/diff/controller_spec.js @@ -0,0 +1,176 @@ +/* global monaco */ +import monacoLoader from '~/repo/monaco_loader'; +import editor from '~/repo/lib/editor'; +import ModelManager from '~/repo/lib/common/model_manager'; +import DecorationsController from '~/repo/lib/decorations/controller'; +import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/repo/lib/diff/controller'; +import { computeDiff } from '~/repo/lib/diff/diff'; +import { file } from '../../helpers'; + +describe('Multi-file editor library dirty diff controller', () => { + let editorInstance; + let controller; + let modelManager; + let decorationsController; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(monaco); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file()); + + controller = new DirtyDiffController(modelManager, decorationsController); + + done(); + }); + }); + + afterEach(() => { + controller.dispose(); + model.dispose(); + decorationsController.dispose(); + editorInstance.dispose(); + }); + + describe('getDiffChangeType', () => { + ['added', 'removed', 'modified'].forEach((type) => { + it(`returns ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDiffChangeType(change)).toBe(type); + }); + }); + }); + + describe('getDecorator', () => { + ['added', 'removed', 'modified'].forEach((type) => { + it(`returns with linesDecorationsClassName for ${type}`, () => { + const change = { + [type]: true, + }; + + expect( + getDecorator(change).options.linesDecorationsClassName, + ).toBe(`dirty-diff dirty-diff-${type}`); + }); + + it('returns with line numbers', () => { + const change = { + lineNumber: 1, + endLineNumber: 2, + [type]: true, + }; + + const range = getDecorator(change).range; + + expect(range.startLineNumber).toBe(1); + expect(range.endLineNumber).toBe(2); + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe(1); + }); + }); + }); + + describe('attachModel', () => { + it('adds change event callback', () => { + spyOn(model, 'onChange'); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + spyOn(controller, 'throttledComputeDiff'); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + spyOn(controller.dirtyDiffWorker, 'postMessage'); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls decorations controller decorate', () => { + spyOn(controller.decorationsController, 'decorate'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + spyOn(controller.decorationsController, 'addDecorations'); + + controller.decorate({ data: { changes: [], path: 'path' } }); + + expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith('path', 'dirtyDiff', jasmine.anything()); + }); + + it('adds decorations into editor', () => { + const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations'); + + controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } }); + + expect(spy).toHaveBeenCalledWith([], [{ + range: new monaco.Range( + 1, 1, 1, 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }]); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(controller.disposable, 'dispose').and.callThrough(); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/repo/lib/diff/diff_spec.js b/spec/javascripts/repo/lib/diff/diff_spec.js new file mode 100644 index 00000000000..3269ec5d2c9 --- /dev/null +++ b/spec/javascripts/repo/lib/diff/diff_spec.js @@ -0,0 +1,80 @@ +import { computeDiff } from '~/repo/lib/diff/diff'; + +describe('Multi-file editor library diff calculator', () => { + describe('computeDiff', () => { + it('returns empty array if no changes', () => { + const diff = computeDiff('123', '123'); + + expect(diff).toEqual([]); + }); + + describe('modified', () => { + it('', () => { + const diff = computeDiff('123', '1234')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(2); + }); + }); + + describe('added', () => { + it('', () => { + const diff = computeDiff('123', '123\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(3); + }); + }); + + describe('removed', () => { + it('', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeTruthy(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeTruthy(); + expect(diff.lineNumber).toBe(2); + }); + }); + + it('includes line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.lineNumber).toBe(1); + }); + + it('includes end line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.endLineNumber).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/repo/lib/editor_options_spec.js b/spec/javascripts/repo/lib/editor_options_spec.js new file mode 100644 index 00000000000..b4887d063ed --- /dev/null +++ b/spec/javascripts/repo/lib/editor_options_spec.js @@ -0,0 +1,7 @@ +import editorOptions from '~/repo/lib/editor_options'; + +describe('Multi-file editor library editor options', () => { + it('returns an array', () => { + expect(editorOptions).toEqual(jasmine.any(Array)); + }); +}); diff --git a/spec/javascripts/repo/lib/editor_spec.js b/spec/javascripts/repo/lib/editor_spec.js new file mode 100644 index 00000000000..cd32832a232 --- /dev/null +++ b/spec/javascripts/repo/lib/editor_spec.js @@ -0,0 +1,128 @@ +/* global monaco */ +import monacoLoader from '~/repo/monaco_loader'; +import editor from '~/repo/lib/editor'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = editor.create(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + it('creates instance of editor', () => { + expect(editor.editorInstance).not.toBeNull(); + }); + + describe('createInstance', () => { + let el; + + beforeEach(() => { + el = document.createElement('div'); + }); + + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'create').and.callThrough(); + + instance.createInstance(el); + + expect(instance.monaco.editor.create).toHaveBeenCalled(); + }); + + it('creates dirty diff controller', () => { + instance.createInstance(el); + + expect(instance.dirtyDiffController).not.toBeNull(); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + spyOn(instance.modelManager, 'addModel'); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); + }); + }); + + describe('attachModel', () => { + let model; + + beforeEach(() => { + instance.createInstance(document.createElement('div')); + + model = instance.createModel(file()); + }); + + it('sets the current model on the instance', () => { + instance.attachModel(model); + + expect(instance.currentModel).toBe(model); + }); + + it('attaches the model to the current instance', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('attaches the model to the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'attachModel'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model); + }); + + it('re-decorates with the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'reDecorate'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + spyOn(instance.instance, 'setModel'); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + + it('resets instance', () => { + instance.createInstance(document.createElement('div')); + + expect(instance.instance).not.toBeNull(); + + instance.dispose(); + + expect(instance.instance).toBeNull(); + }); + }); +}); diff --git a/spec/lib/gitlab/ci/build/policy/kubernetes_spec.rb b/spec/lib/gitlab/ci/build/policy/kubernetes_spec.rb index 15eb01eb472..4884d5f8ba4 100644 --- a/spec/lib/gitlab/ci/build/policy/kubernetes_spec.rb +++ b/spec/lib/gitlab/ci/build/policy/kubernetes_spec.rb @@ -4,11 +4,24 @@ describe Gitlab::Ci::Build::Policy::Kubernetes do let(:pipeline) { create(:ci_pipeline, project: project) } context 'when kubernetes service is active' do - set(:project) { create(:kubernetes_project) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'is satisfied by a kubernetes pipeline' do + expect(described_class.new('active')) + .to be_satisfied_by(pipeline) + end + end - it 'is satisfied by a kubernetes pipeline' do - expect(described_class.new('active')) - .to be_satisfied_by(pipeline) + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index d72f8553f55..98880fe9f28 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -178,15 +178,29 @@ module Gitlab end context 'when kubernetes is active' do - let(:project) { create(:kubernetes_project) } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'returns seeds for kubernetes dependent job' do + seeds = subject.stage_seeds(pipeline) - it 'returns seeds for kubernetes dependent job' do - seeds = subject.stage_seeds(pipeline) + expect(seeds.size).to eq 2 + expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' + expect(seeds.second.builds.dig(0, :name)).to eq 'production' + end + end - expect(seeds.size).to eq 2 - expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' - expect(seeds.second.builds.dig(0, :name)).to eq 'production' + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 9c3e7d7e9ba..a424f0f5cfe 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -70,6 +70,15 @@ describe Gitlab::ProjectSearchResults do subject { described_class.parse_search_result(search_result) } + it 'can correctly parse filenames including ":"' do + special_char_result = "\nmaster:testdata/project::function1.yaml-1----\nmaster:testdata/project::function1.yaml:2:test: data1\n" + + blob = described_class.parse_search_result(special_char_result) + + expect(blob.ref).to eq('master') + expect(blob.filename).to eq('testdata/project::function1.yaml') + end + it "returns a valid FoundBlob" do is_expected.to be_an Gitlab::SearchResults::FoundBlob expect(subject.id).to be_nil diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 3a19a0753e2..4cf0088ac9c 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -557,10 +557,23 @@ describe Ci::Pipeline, :mailer do describe '#has_kubernetes_active?' do context 'when kubernetes is active' do - let(:project) { create(:kubernetes_project) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'returns true' do + expect(pipeline).to have_kubernetes_active + end + end - it 'returns true' do - expect(pipeline).to have_kubernetes_active + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index b91a5e7a272..7f43e747000 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -9,7 +9,6 @@ describe Clusters::Cluster do it { is_expected.to delegate_method(:status_reason).to(:provider) } it { is_expected.to delegate_method(:status_name).to(:provider) } it { is_expected.to delegate_method(:on_creation?).to(:provider) } - it { is_expected.to delegate_method(:update_kubernetes_integration!).to(:platform) } it { is_expected.to respond_to :project } describe '.enabled' do diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index ed76be703a5..53a4e545ff6 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -5,6 +5,8 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching include ReactiveCachingHelpers it { is_expected.to belong_to(:cluster) } + it { is_expected.to be_kind_of(Gitlab::Kubernetes) } + it { is_expected.to be_kind_of(ReactiveCaching) } it { is_expected.to respond_to :ca_pem } describe 'before_validation' do @@ -90,99 +92,175 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching end end - describe 'after_save from Clusters::Cluster' do - context 'when platform_kubernetes is being cerated' do - let(:enabled) { true } - let(:project) { create(:project) } - let(:cluster) { build(:cluster, provider_type: :gcp, platform_type: :kubernetes, platform_kubernetes: platform, provider_gcp: provider, enabled: enabled, projects: [project]) } - let(:platform) { build(:cluster_platform_kubernetes, :configured) } - let(:provider) { build(:cluster_provider_gcp) } - let(:kubernetes_service) { project.kubernetes_service } + describe '#actual_namespace' do + subject { kubernetes.actual_namespace } - it 'updates KubernetesService' do - cluster.save! + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:project) { cluster.project } + let(:kubernetes) { create(:cluster_platform_kubernetes, :configured, namespace: namespace) } - expect(kubernetes_service.active).to eq(enabled) - expect(kubernetes_service.api_url).to eq(platform.api_url) - expect(kubernetes_service.namespace).to eq(platform.namespace) - expect(kubernetes_service.ca_pem).to eq(platform.ca_cert) - end + context 'when namespace is present' do + let(:namespace) { 'namespace-123' } + + it { is_expected.to eq(namespace) } end - context 'when platform_kubernetes has been created' do - let(:enabled) { false } - let!(:project) { create(:project) } - let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } - let(:platform) { cluster.platform } - let(:kubernetes_service) { project.kubernetes_service } + context 'when namespace is not present' do + let(:namespace) { nil } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + end - it 'updates KubernetesService' do - cluster.update(enabled: enabled) + describe '#default_namespace' do + subject { kubernetes.send(:default_namespace) } - expect(kubernetes_service.active).to eq(enabled) + let(:kubernetes) { create(:cluster_platform_kubernetes, :configured) } + + context 'when cluster belongs to a project' do + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:project) { cluster.project } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + + context 'when cluster belongs to nothing' do + let!(:cluster) { create(:cluster, platform_kubernetes: kubernetes) } + + it { is_expected.to be_nil } + end + end + + describe '#predefined_variables' do + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:kubernetes) { create(:cluster_platform_kubernetes, api_url: api_url, ca_cert: ca_pem, token: token) } + let(:api_url) { 'https://kube.domain.com' } + let(:ca_pem) { 'CA PEM DATA' } + let(:token) { 'token' } + + let(:kubeconfig) do + config_file = expand_fixture_path('config/kubeconfig.yml') + config = YAML.load(File.read(config_file)) + config.dig('users', 0, 'user')['token'] = token + config.dig('contexts', 0, 'context')['namespace'] = namespace + config.dig('clusters', 0, 'cluster')['certificate-authority-data'] = + Base64.strict_encode64(ca_pem) + + YAML.dump(config) + end + + shared_examples 'setting variables' do + it 'sets the variables' do + expect(kubernetes.predefined_variables).to include( + { key: 'KUBE_URL', value: api_url, public: true }, + { key: 'KUBE_TOKEN', value: token, public: false }, + { key: 'KUBE_NAMESPACE', value: namespace, public: true }, + { key: 'KUBECONFIG', value: kubeconfig, public: false, file: true }, + { key: 'KUBE_CA_PEM', value: ca_pem, public: true }, + { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + ) end end - context 'when kubernetes_service has been configured without cluster integration' do - let!(:project) { create(:project) } - let(:cluster) { build(:cluster, provider_type: :gcp, platform_type: :kubernetes, platform_kubernetes: platform, provider_gcp: provider, projects: [project]) } - let(:platform) { build(:cluster_platform_kubernetes, :configured, api_url: 'https://111.111.111.111') } - let(:provider) { build(:cluster_provider_gcp) } + context 'namespace is provided' do + let(:namespace) { 'my-project' } before do - create(:kubernetes_service, project: project) + kubernetes.namespace = namespace end - it 'raises an error' do - expect { cluster.save! }.to raise_error('Kubernetes service already configured') + it_behaves_like 'setting variables' + end + + context 'no namespace provided' do + let(:namespace) { kubernetes.actual_namespace } + + it_behaves_like 'setting variables' + + it 'sets the KUBE_NAMESPACE' do + kube_namespace = kubernetes.predefined_variables.find { |h| h[:key] == 'KUBE_NAMESPACE' } + + expect(kube_namespace).not_to be_nil + expect(kube_namespace[:value]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/) end end end - describe '#actual_namespace' do - subject { kubernetes.actual_namespace } + describe '#terminals' do + subject { service.terminals(environment) } - let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let!(:cluster) { create(:cluster, :project, platform_kubernetes: service) } let(:project) { cluster.project } - let(:kubernetes) { create(:cluster_platform_kubernetes, :configured, namespace: namespace) } + let(:service) { create(:cluster_platform_kubernetes, :configured) } + let(:environment) { build(:environment, project: project, name: "env", slug: "env-000000") } - context 'when namespace is present' do - let(:namespace) { 'namespace-123' } + context 'with invalid pods' do + it 'returns no terminals' do + stub_reactive_cache(service, pods: [{ "bad" => "pod" }]) - it { is_expected.to eq(namespace) } + is_expected.to be_empty + end end - context 'when namespace is not present' do - let(:namespace) { nil } + context 'with valid pods' do + let(:pod) { kube_pod(app: environment.slug) } + let(:terminals) { kube_terminals(service, pod) } - it { is_expected.to eq("#{project.path}-#{project.id}") } + before do + stub_reactive_cache( + service, + pods: [pod, pod, kube_pod(app: "should-be-filtered-out")] + ) + end + + it 'returns terminals' do + is_expected.to eq(terminals + terminals) + end + + it 'uses max session time from settings' do + stub_application_setting(terminal_max_session_time: 600) + + times = subject.map { |terminal| terminal[:max_session_time] } + expect(times).to eq [600, 600, 600, 600] + end end end - describe '.namespace_for_project' do - subject { described_class.namespace_for_project(project) } + describe '#calculate_reactive_cache' do + subject { service.calculate_reactive_cache } - let(:project) { create(:project) } + let!(:cluster) { create(:cluster, :project, enabled: enabled, platform_kubernetes: service) } + let(:service) { create(:cluster_platform_kubernetes, :configured) } + let(:enabled) { true } - it { is_expected.to eq("#{project.path}-#{project.id}") } - end + context 'when cluster is disabled' do + let(:enabled) { false } - describe '#default_namespace' do - subject { kubernetes.default_namespace } + it { is_expected.to be_nil } + end - let(:kubernetes) { create(:cluster_platform_kubernetes, :configured) } + context 'when kubernetes responds with valid pods' do + before do + stub_kubeclient_pods + end - context 'when cluster belongs to a project' do - let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } - let(:project) { cluster.project } + it { is_expected.to eq(pods: [kube_pod]) } + end - it { is_expected.to eq("#{project.path}-#{project.id}") } + context 'when kubernetes responds with 500s' do + before do + stub_kubeclient_pods(status: 500) + end + + it { expect { subject }.to raise_error(KubeException) } end - context 'when cluster belongs to nothing' do - let!(:cluster) { create(:cluster, platform_kubernetes: kubernetes) } + context 'when kubernetes responds with 404s' do + before do + stub_kubeclient_pods(status: 404) + end - it { is_expected.to be_nil } + it { is_expected.to eq(pods: []) } end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 1ce1d595c60..6f24a039998 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -327,15 +327,28 @@ describe Environment do context 'when the enviroment is available' do context 'with a deployment service' do - let(:project) { create(:kubernetes_project) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + context 'and a deployment' do + let!(:deployment) { create(:deployment, environment: environment) } + it { is_expected.to be_truthy } + end - context 'and a deployment' do - let!(:deployment) { create(:deployment, environment: environment) } - it { is_expected.to be_truthy } + context 'but no deployments' do + it { is_expected.to be_falsy } + end end - context 'but no deployments' do - it { is_expected.to be_falsy } + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end @@ -356,7 +369,6 @@ describe Environment do end describe '#terminals' do - let(:project) { create(:kubernetes_project) } subject { environment.terminals } context 'when the environment has terminals' do @@ -364,12 +376,27 @@ describe Environment do allow(environment).to receive(:has_terminals?).and_return(true) end - it 'returns the terminals from the deployment service' do - expect(project.deployment_service) - .to receive(:terminals).with(environment) - .and_return(:fake_terminals) + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'returns the terminals from the deployment service' do + expect(project.deployment_platform) + .to receive(:terminals).with(environment) + .and_return(:fake_terminals) + + is_expected.to eq(:fake_terminals) + end + end + + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - is_expected.to eq(:fake_terminals) + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 1c629155e1e..f037ee77a94 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -4,8 +4,8 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do include KubernetesHelpers include ReactiveCachingHelpers - let(:project) { build_stubbed(:kubernetes_project) } - let(:service) { project.kubernetes_service } + let(:project) { create(:kubernetes_project) } + let(:service) { project.deployment_platform } describe 'Associations' do it { is_expected.to belong_to :project } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 549c97a9afd..a4abcc49a0d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -2002,12 +2002,25 @@ describe Project do end context 'when project has a deployment service' do - let(:project) { create(:kubernetes_project) } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + it 'returns variables from this service' do + expect(project.deployment_variables).to include( + { key: 'KUBE_TOKEN', value: project.deployment_platform.token, public: false } + ) + end + end + + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - it 'returns variables from this service' do - expect(project.deployment_variables).to include( - { key: 'KUBE_TOKEN', value: project.kubernetes_service.token, public: false } - ) + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end end @@ -3083,4 +3096,23 @@ describe Project do expect(project.wiki_repository_exists?).to eq(false) end end + + describe '#deployment_platform' do + subject { project.deployment_platform } + + let(:project) { create(:project) } + + context 'when user configured kubernetes from Integration > Kubernetes' do + let!(:kubernetes_service) { create(:kubernetes_service, project: project) } + + it { is_expected.to eq(kubernetes_service) } + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + let(:platform_kubernetes) { cluster.platform_kubernetes } + + it { is_expected.to eq(platform_kubernetes) } + end + end end diff --git a/spec/services/base_count_service_spec.rb b/spec/services/base_count_service_spec.rb index 5ec8ed0976d..090b2dcdd43 100644 --- a/spec/services/base_count_service_spec.rb +++ b/spec/services/base_count_service_spec.rb @@ -77,4 +77,10 @@ describe BaseCountService, :use_clean_rails_memory_store_caching do expect { service.cache_key }.to raise_error(NotImplementedError) end end + + describe '#cache_options' do + it 'returns the default in options' do + expect(service.cache_options).to eq({ raw: false }) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6310ea1b52b..242a2230b67 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -207,3 +207,6 @@ Shoulda::Matchers.configure do |config| with.library :rails end end + +# Prevent Rugged from picking up local developer gitconfig. +Rugged::Settings['search_path_global'] = Rails.root.join('tmp/tests').to_s diff --git a/spec/support/prometheus/additional_metrics_shared_examples.rb b/spec/support/prometheus/additional_metrics_shared_examples.rb index 620fa37d455..dbbd4ad4d40 100644 --- a/spec/support/prometheus/additional_metrics_shared_examples.rb +++ b/spec/support/prometheus/additional_metrics_shared_examples.rb @@ -41,16 +41,30 @@ RSpec.shared_examples 'additional metrics query' do end describe 'project has Kubernetes service' do - let(:project) { create(:kubernetes_project) } - let(:environment) { create(:environment, slug: 'environment-slug', project: project) } - let(:kube_namespace) { project.kubernetes_service.actual_namespace } + shared_examples 'same behavior between KubernetesService and Platform::Kubernetes' do + let(:environment) { create(:environment, slug: 'environment-slug', project: project) } + let(:kube_namespace) { project.deployment_platform.actual_namespace } - it_behaves_like 'query context containing environment slug and filter' + it_behaves_like 'query context containing environment slug and filter' - it 'query context contains kube_namespace' do - expect(subject).to receive(:query_metrics).with(hash_including(kube_namespace: kube_namespace)) + it 'query context contains kube_namespace' do + expect(subject).to receive(:query_metrics).with(hash_including(kube_namespace: kube_namespace)) - subject.query(*query_params) + subject.query(*query_params) + end + end + + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it_behaves_like 'same behavior between KubernetesService and Platform::Kubernetes' end end diff --git a/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb b/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb index c757ccf02d3..95f0be49412 100644 --- a/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb +++ b/spec/views/projects/pipelines_settings/_show.html.haml_spec.rb @@ -35,7 +35,7 @@ describe 'projects/pipelines_settings/_show' do context 'when kubernetes is active' do before do - project.build_kubernetes_service(active: true) + create(:kubernetes_service, project: project) end context 'when auto devops domain is not defined' do diff --git a/spec/workers/reactive_caching_worker_spec.rb b/spec/workers/reactive_caching_worker_spec.rb index 5f4453c15d6..3da851de067 100644 --- a/spec/workers/reactive_caching_worker_spec.rb +++ b/spec/workers/reactive_caching_worker_spec.rb @@ -1,15 +1,28 @@ require 'spec_helper' describe ReactiveCachingWorker do - let(:project) { create(:kubernetes_project) } - let(:service) { project.deployment_service } - subject { described_class.new.perform("KubernetesService", service.id) } + let(:service) { project.deployment_platform } describe '#perform' do - it 'calls #exclusively_update_reactive_cache!' do - expect_any_instance_of(KubernetesService).to receive(:exclusively_update_reactive_cache!) + context 'when user configured kubernetes from Integration > Kubernetes' do + let(:project) { create(:kubernetes_project) } - subject + it 'calls #exclusively_update_reactive_cache!' do + expect_any_instance_of(KubernetesService).to receive(:exclusively_update_reactive_cache!) + + described_class.new.perform("KubernetesService", service.id) + end + end + + context 'when user configured kubernetes from CI/CD > Clusters' do + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it 'calls #exclusively_update_reactive_cache!' do + expect_any_instance_of(Clusters::Platforms::Kubernetes).to receive(:exclusively_update_reactive_cache!) + + described_class.new.perform("Clusters::Platforms::Kubernetes", service.id) + end end end end diff --git a/vendor/assets/javascripts/clipboard.js b/vendor/assets/javascripts/clipboard.js deleted file mode 100644 index 39d7d2306f8..00000000000 --- a/vendor/assets/javascripts/clipboard.js +++ /dev/null @@ -1,621 +0,0 @@ -/*! - * clipboard.js v1.4.2 - * https://zenorocha.github.io/clipboard.js - * - * Licensed MIT © Zeno Rocha - */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Clipboard = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/** - * Module dependencies. - */ - -var closest = require('closest') - , event = require('component-event'); - -/** - * Delegate event `type` to `selector` - * and invoke `fn(e)`. A callback function - * is returned which may be passed to `.unbind()`. - * - * @param {Element} el - * @param {String} selector - * @param {String} type - * @param {Function} fn - * @param {Boolean} capture - * @return {Function} - * @api public - */ - -// Some events don't bubble, so we want to bind to the capture phase instead -// when delegating. -var forceCaptureEvents = ['focus', 'blur']; - -exports.bind = function(el, selector, type, fn, capture){ - if (forceCaptureEvents.indexOf(type) !== -1) capture = true; - - return event.bind(el, type, function(e){ - var target = e.target || e.srcElement; - e.delegateTarget = closest(target, selector, true, el); - if (e.delegateTarget) fn.call(el, e); - }, capture); -}; - -/** - * Unbind event `type`'s callback `fn`. - * - * @param {Element} el - * @param {String} type - * @param {Function} fn - * @param {Boolean} capture - * @api public - */ - -exports.unbind = function(el, type, fn, capture){ - if (forceCaptureEvents.indexOf(type) !== -1) capture = true; - - event.unbind(el, type, fn, capture); -}; - -},{"closest":2,"component-event":4}],2:[function(require,module,exports){ -var matches = require('matches-selector') - -module.exports = function (element, selector, checkYoSelf) { - var parent = checkYoSelf ? element : element.parentNode - - while (parent && parent !== document) { - if (matches(parent, selector)) return parent; - parent = parent.parentNode - } -} - -},{"matches-selector":3}],3:[function(require,module,exports){ - -/** - * Element prototype. - */ - -var proto = Element.prototype; - -/** - * Vendor function. - */ - -var vendor = proto.matchesSelector - || proto.webkitMatchesSelector - || proto.mozMatchesSelector - || proto.msMatchesSelector - || proto.oMatchesSelector; - -/** - * Expose `match()`. - */ - -module.exports = match; - -/** - * Match `el` to `selector`. - * - * @param {Element} el - * @param {String} selector - * @return {Boolean} - * @api public - */ - -function match(el, selector) { - if (vendor) return vendor.call(el, selector); - var nodes = el.parentNode.querySelectorAll(selector); - for (var i = 0; i < nodes.length; ++i) { - if (nodes[i] == el) return true; - } - return false; -} -},{}],4:[function(require,module,exports){ -var bind = window.addEventListener ? 'addEventListener' : 'attachEvent', - unbind = window.removeEventListener ? 'removeEventListener' : 'detachEvent', - prefix = bind !== 'addEventListener' ? 'on' : ''; - -/** - * Bind `el` event `type` to `fn`. - * - * @param {Element} el - * @param {String} type - * @param {Function} fn - * @param {Boolean} capture - * @return {Function} - * @api public - */ - -exports.bind = function(el, type, fn, capture){ - el[bind](prefix + type, fn, capture || false); - return fn; -}; - -/** - * Unbind `el` event `type`'s callback `fn`. - * - * @param {Element} el - * @param {String} type - * @param {Function} fn - * @param {Boolean} capture - * @return {Function} - * @api public - */ - -exports.unbind = function(el, type, fn, capture){ - el[unbind](prefix + type, fn, capture || false); - return fn; -}; -},{}],5:[function(require,module,exports){ -function E () { - // Keep this empty so it's easier to inherit from - // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3) -} - -E.prototype = { - on: function (name, callback, ctx) { - var e = this.e || (this.e = {}); - - (e[name] || (e[name] = [])).push({ - fn: callback, - ctx: ctx - }); - - return this; - }, - - once: function (name, callback, ctx) { - var self = this; - var fn = function () { - self.off(name, fn); - callback.apply(ctx, arguments); - }; - - return this.on(name, fn, ctx); - }, - - emit: function (name) { - var data = [].slice.call(arguments, 1); - var evtArr = ((this.e || (this.e = {}))[name] || []).slice(); - var i = 0; - var len = evtArr.length; - - for (i; i < len; i++) { - evtArr[i].fn.apply(evtArr[i].ctx, data); - } - - return this; - }, - - off: function (name, callback) { - var e = this.e || (this.e = {}); - var evts = e[name]; - var liveEvents = []; - - if (evts && callback) { - for (var i = 0, len = evts.length; i < len; i++) { - if (evts[i].fn !== callback) liveEvents.push(evts[i]); - } - } - - // Remove event from queue to prevent memory leak - // Suggested by https://github.com/lazd - // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910 - - (liveEvents.length) - ? e[name] = liveEvents - : delete e[name]; - - return this; - } -}; - -module.exports = E; - -},{}],6:[function(require,module,exports){ -/** - * Inner class which performs selection from either `text` or `target` - * properties and then executes copy or cut operations. - */ -'use strict'; - -exports.__esModule = true; - -var _createClass = (function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ('value' in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; })(); - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -var ClipboardAction = (function () { - /** - * @param {Object} options - */ - - function ClipboardAction(options) { - _classCallCheck(this, ClipboardAction); - - this.resolveOptions(options); - this.initSelection(); - } - - /** - * Defines base properties passed from constructor. - * @param {Object} options - */ - - ClipboardAction.prototype.resolveOptions = function resolveOptions() { - var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; - - this.action = options.action; - this.emitter = options.emitter; - this.target = options.target; - this.text = options.text; - this.trigger = options.trigger; - - this.selectedText = ''; - }; - - /** - * Decides which selection strategy is going to be applied based - * on the existence of `text` and `target` properties. - */ - - ClipboardAction.prototype.initSelection = function initSelection() { - if (this.text && this.target) { - throw new Error('Multiple attributes declared, use either "target" or "text"'); - } else if (this.text) { - this.selectFake(); - } else if (this.target) { - this.selectTarget(); - } else { - throw new Error('Missing required attributes, use either "target" or "text"'); - } - }; - - /** - * Creates a fake textarea element, sets its value from `text` property, - * and makes a selection on it. - */ - - ClipboardAction.prototype.selectFake = function selectFake() { - var _this = this; - - this.removeFake(); - - this.fakeHandler = document.body.addEventListener('click', function () { - return _this.removeFake(); - }); - - this.fakeElem = document.createElement('textarea'); - this.fakeElem.style.position = 'absolute'; - this.fakeElem.style.left = '-9999px'; - this.fakeElem.style.top = (window.pageYOffset || document.documentElement.scrollTop) + 'px'; - this.fakeElem.setAttribute('readonly', ''); - this.fakeElem.value = this.text; - this.selectedText = this.text; - - document.body.appendChild(this.fakeElem); - - this.fakeElem.select(); - this.copyText(); - }; - - /** - * Only removes the fake element after another click event, that way - * a user can hit `Ctrl+C` to copy because selection still exists. - */ - - ClipboardAction.prototype.removeFake = function removeFake() { - if (this.fakeHandler) { - document.body.removeEventListener('click'); - this.fakeHandler = null; - } - - if (this.fakeElem) { - document.body.removeChild(this.fakeElem); - this.fakeElem = null; - } - }; - - /** - * Selects the content from element passed on `target` property. - */ - - ClipboardAction.prototype.selectTarget = function selectTarget() { - if (this.target.nodeName === 'INPUT' || this.target.nodeName === 'TEXTAREA') { - this.target.select(); - this.selectedText = this.target.value; - } else { - var range = document.createRange(); - var selection = window.getSelection(); - - selection.removeAllRanges(); - range.selectNodeContents(this.target); - selection.addRange(range); - this.selectedText = selection.toString(); - } - - this.copyText(); - }; - - /** - * Executes the copy operation based on the current selection. - */ - - ClipboardAction.prototype.copyText = function copyText() { - var succeeded = undefined; - - try { - succeeded = document.execCommand(this.action); - } catch (err) { - succeeded = false; - } - - this.handleResult(succeeded); - }; - - /** - * Fires an event based on the copy operation result. - * @param {Boolean} succeeded - */ - - ClipboardAction.prototype.handleResult = function handleResult(succeeded) { - if (succeeded) { - this.emitter.emit('success', { - action: this.action, - text: this.selectedText, - trigger: this.trigger, - clearSelection: this.clearSelection.bind(this) - }); - } else { - this.emitter.emit('error', { - action: this.action, - trigger: this.trigger, - clearSelection: this.clearSelection.bind(this) - }); - } - }; - - /** - * Removes current selection and focus from `target` element. - */ - - ClipboardAction.prototype.clearSelection = function clearSelection() { - if (this.target) { - this.target.blur(); - } - - window.getSelection().removeAllRanges(); - }; - - /** - * Sets the `action` to be performed which can be either 'copy' or 'cut'. - * @param {String} action - */ - - /** - * Destroy lifecycle. - */ - - ClipboardAction.prototype.destroy = function destroy() { - this.removeFake(); - }; - - _createClass(ClipboardAction, [{ - key: 'action', - set: function set() { - var action = arguments.length <= 0 || arguments[0] === undefined ? 'copy' : arguments[0]; - - this._action = action; - - if (this._action !== 'copy' && this._action !== 'cut') { - throw new Error('Invalid "action" value, use either "copy" or "cut"'); - } - }, - - /** - * Gets the `action` property. - * @return {String} - */ - get: function get() { - return this._action; - } - - /** - * Sets the `target` property using an element - * that will be have its content copied. - * @param {Element} target - */ - }, { - key: 'target', - set: function set(target) { - if (target !== undefined) { - if (target && typeof target === 'object' && target.nodeType === 1) { - this._target = target; - } else { - throw new Error('Invalid "target" value, use a valid Element'); - } - } - }, - - /** - * Gets the `target` property. - * @return {String|HTMLElement} - */ - get: function get() { - return this._target; - } - }]); - - return ClipboardAction; -})(); - -exports['default'] = ClipboardAction; -module.exports = exports['default']; - -},{}],7:[function(require,module,exports){ -'use strict'; - -exports.__esModule = true; - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError('Cannot call a class as a function'); } } - -function _inherits(subClass, superClass) { if (typeof superClass !== 'function' && superClass !== null) { throw new TypeError('Super expression must either be null or a function, not ' + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var _clipboardAction = require('./clipboard-action'); - -var _clipboardAction2 = _interopRequireDefault(_clipboardAction); - -var _delegateEvents = require('delegate-events'); - -var _delegateEvents2 = _interopRequireDefault(_delegateEvents); - -var _tinyEmitter = require('tiny-emitter'); - -var _tinyEmitter2 = _interopRequireDefault(_tinyEmitter); - -/** - * Base class which takes a selector, delegates a click event to it, - * and instantiates a new `ClipboardAction` on each click. - */ - -var Clipboard = (function (_Emitter) { - _inherits(Clipboard, _Emitter); - - /** - * @param {String} selector - * @param {Object} options - */ - - function Clipboard(selector, options) { - _classCallCheck(this, Clipboard); - - _Emitter.call(this); - - this.resolveOptions(options); - this.delegateClick(selector); - } - - /** - * Helper function to retrieve attribute value. - * @param {String} suffix - * @param {Element} element - */ - - /** - * Defines if attributes would be resolved using internal setter functions - * or custom functions that were passed in the constructor. - * @param {Object} options - */ - - Clipboard.prototype.resolveOptions = function resolveOptions() { - var options = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; - - this.action = typeof options.action === 'function' ? options.action : this.defaultAction; - this.target = typeof options.target === 'function' ? options.target : this.defaultTarget; - this.text = typeof options.text === 'function' ? options.text : this.defaultText; - }; - - /** - * Delegates a click event on the passed selector. - * @param {String} selector - */ - - Clipboard.prototype.delegateClick = function delegateClick(selector) { - var _this = this; - - this.binding = _delegateEvents2['default'].bind(document.body, selector, 'click', function (e) { - return _this.onClick(e); - }); - }; - - /** - * Undelegates a click event on body. - * @param {String} selector - */ - - Clipboard.prototype.undelegateClick = function undelegateClick() { - _delegateEvents2['default'].unbind(document.body, 'click', this.binding); - }; - - /** - * Defines a new `ClipboardAction` on each click event. - * @param {Event} e - */ - - Clipboard.prototype.onClick = function onClick(e) { - if (this.clipboardAction) { - this.clipboardAction = null; - } - - this.clipboardAction = new _clipboardAction2['default']({ - action: this.action(e.delegateTarget), - target: this.target(e.delegateTarget), - text: this.text(e.delegateTarget), - trigger: e.delegateTarget, - emitter: this - }); - }; - - /** - * Default `action` lookup function. - * @param {Element} trigger - */ - - Clipboard.prototype.defaultAction = function defaultAction(trigger) { - return getAttributeValue('action', trigger); - }; - - /** - * Default `target` lookup function. - * @param {Element} trigger - */ - - Clipboard.prototype.defaultTarget = function defaultTarget(trigger) { - var selector = getAttributeValue('target', trigger); - - if (selector) { - return document.querySelector(selector); - } - }; - - /** - * Default `text` lookup function. - * @param {Element} trigger - */ - - Clipboard.prototype.defaultText = function defaultText(trigger) { - return getAttributeValue('text', trigger); - }; - - /** - * Destroy lifecycle. - */ - - Clipboard.prototype.destroy = function destroy() { - this.undelegateClick(); - - if (this.clipboardAction) { - this.clipboardAction.destroy(); - this.clipboardAction = null; - } - }; - - return Clipboard; -})(_tinyEmitter2['default']); - -function getAttributeValue(suffix, element) { - var attribute = 'data-clipboard-' + suffix; - - if (!element.hasAttribute(attribute)) { - return; - } - - return element.getAttribute(attribute); -} - -exports['default'] = Clipboard; -module.exports = exports['default']; - -},{"./clipboard-action":6,"delegate-events":1,"tiny-emitter":5}]},{},[7])(7) -}); diff --git a/yarn.lock b/yarn.lock index 73cc4f11500..88897476a15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -116,6 +116,15 @@ ajv@^4.7.0, ajv@^4.9.1: co "^4.6.0" json-stable-stringify "^1.0.1" +ajv@^5.0.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.4.0.tgz#32d1cf08dbc80c432f426f12e10b2511f6b46474" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + ajv@^5.1.5: version "5.2.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39" @@ -1288,13 +1297,13 @@ cli-width@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" -clipboard@^1.5.5: - version "1.6.1" - resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.6.1.tgz#65c5b654812466b0faab82dc6ba0f1d2f8e4be53" +clipboard@^1.5.5, clipboard@^1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b" dependencies: - good-listener "^1.2.0" + good-listener "^1.2.2" select "^1.1.2" - tiny-emitter "^1.0.0" + tiny-emitter "^2.0.0" cliui@^2.1.0: version "2.1.0" @@ -1895,6 +1904,10 @@ di@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" +diff@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c" + diffie-hellman@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" @@ -2527,6 +2540,10 @@ fast-deep-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + fast-levenshtein@~2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -2883,7 +2900,7 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" -good-listener@^1.2.0: +good-listener@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" dependencies: @@ -3843,7 +3860,7 @@ loader-utils@^0.2.15, loader-utils@^0.2.5: json5 "^0.5.0" object-assign "^4.0.1" -loader-utils@^1.0.2, loader-utils@^1.1.0: +loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" dependencies: @@ -5534,6 +5551,12 @@ sax@~1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.2.tgz#fd8631a23bc7826bef5d871bdb87378c95647828" +schema-utils@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf" + dependencies: + ajv "^5.0.0" + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -6115,9 +6138,9 @@ timers-browserify@^2.0.2: dependencies: setimmediate "^1.0.4" -tiny-emitter@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.1.0.tgz#ab405a21ffed814a76c19739648093d70654fecb" +tiny-emitter@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" tmp@0.0.31, tmp@0.0.x: version "0.0.31" @@ -6601,6 +6624,13 @@ wordwrap@~0.0.2: version "0.0.3" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" +worker-loader@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-1.1.0.tgz#8cf21869a07add84d66f821d948d23c1eb98e809" + dependencies: + loader-utils "^1.0.0" + schema-utils "^0.3.0" + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" |