diff options
204 files changed, 5489 insertions, 900 deletions
diff --git a/.eslintrc b/.eslintrc index c72a5e0335b..3e07edbccfe 100644 --- a/.eslintrc +++ b/.eslintrc @@ -30,6 +30,7 @@ "filenames/match-regex": [2, "^[a-z0-9_]+$"], "import/no-commonjs": "error", "no-multiple-empty-lines": ["error", { "max": 1 }], - "promise/catch-or-return": "error" + "promise/catch-or-return": "error", + "no-underscore-dangle": ["error", { "allow": ["__"]}] } } diff --git a/.flayignore b/.flayignore index e2d0a2e50c5..b63ce4c4df0 100644 --- a/.flayignore +++ b/.flayignore @@ -3,4 +3,5 @@ lib/gitlab/sanitizers/svg/whitelist.rb lib/gitlab/diff/position_tracer.rb app/policies/project_policy.rb app/models/concerns/relative_positioning.rb +app/workers/stuck_merge_jobs_worker.rb lib/gitlab/redis/*.rb diff --git a/app/assets/images/new_repo.png b/app/assets/images/new_repo.png Binary files differnew file mode 100644 index 00000000000..ed3af06ab1d --- /dev/null +++ b/app/assets/images/new_repo.png diff --git a/app/assets/images/old_repo.png b/app/assets/images/old_repo.png Binary files differnew file mode 100644 index 00000000000..c3c3b791ad9 --- /dev/null +++ b/app/assets/images/old_repo.png diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 56fa0d71a9a..76b724e1bcb 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -13,6 +13,7 @@ const Api = { dockerfilePath: '/api/:version/templates/dockerfiles/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', usersPath: '/api/:version/users.json', + commitPath: '/api/:version/projects/:id/repository/commits', group(groupId, callback) { const url = Api.buildUrl(Api.groupPath) @@ -95,6 +96,21 @@ const Api = { .done(projects => callback(projects)); }, + commitMultiple(id, data, callback) { + // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions + const url = Api.buildUrl(Api.commitPath) + .replace(':id', id); + return $.ajax({ + url, + type: 'POST', + contentType: 'application/json; charset=utf-8', + data: JSON.stringify(data), + dataType: 'json', + }) + .done(commitData => callback(commitData)) + .fail(message => callback(message.responseJSON)); + }, + // Return text for a specific license licenseText(key, data, callback) { const url = Api.buildUrl(Api.licensePath) diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index dc636050221..26d3419a162 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -1,9 +1,24 @@ /* eslint-disable func-names, object-shorthand, prefer-arrow-callback */ /* global Dropzone */ +import '../lib/utils/url_utility'; +import { HIDDEN_CLASS } from '../lib/utils/constants'; + +function toggleLoading($el, $icon, loading) { + if (loading) { + $el.disable(); + $icon.removeClass(HIDDEN_CLASS); + } else { + $el.enable(); + $icon.addClass(HIDDEN_CLASS); + } +} export default class BlobFileDropzone { constructor(form, method) { const formDropzone = form.find('.dropzone'); + const submitButton = form.find('#submit-all'); + const submitButtonLoadingIcon = submitButton.find('.js-loading-icon'); + const dropzoneMessage = form.find('.dz-message'); Dropzone.autoDiscover = false; const dropzone = formDropzone.dropzone({ @@ -26,12 +41,20 @@ export default class BlobFileDropzone { }, init: function () { this.on('addedfile', function () { + toggleLoading(submitButton, submitButtonLoadingIcon, false); + dropzoneMessage.addClass(HIDDEN_CLASS); $('.dropzone-alerts').html('').hide(); }); + this.on('removedfile', function () { + toggleLoading(submitButton, submitButtonLoadingIcon, false); + dropzoneMessage.removeClass(HIDDEN_CLASS); + }); this.on('success', function (header, response) { - window.location.href = response.filePath; + $('#modal-upload-blob').modal('hide'); + window.gl.utils.visitUrl(response.filePath); }); this.on('maxfilesexceeded', function (file) { + dropzoneMessage.addClass(HIDDEN_CLASS); this.removeFile(file); }); this.on('sending', function (file, xhr, formData) { @@ -48,14 +71,15 @@ export default class BlobFileDropzone { }, }); - const submitButton = form.find('#submit-all')[0]; - submitButton.addEventListener('click', function (e) { + submitButton.on('click', (e) => { e.preventDefault(); e.stopPropagation(); if (dropzone[0].dropzone.getQueuedFiles().length === 0) { // eslint-disable-next-line no-alert alert('Please select a file'); + return false; } + toggleLoading(submitButton, submitButtonLoadingIcon, true); dropzone[0].dropzone.processQueue(); return false; }); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index daef01bc93d..d3de1830895 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -97,9 +97,8 @@ gl.issueBoards.IssueCardInner = Vue.extend({ return `Avatar for ${assignee.name}`; }, showLabel(label) { - if (!this.list) return true; - - return !this.list.label || label.id !== this.list.label.id; + if (!this.list || !label) return true; + return true; }, filterByLabel(label, e) { if (!this.updateFilters) return; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index e95892a6189..7cc7636cca3 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -75,6 +75,7 @@ import initNotes from './init_notes'; import initLegacyFilters from './init_legacy_filters'; import initIssuableSidebar from './init_issuable_sidebar'; import GpgBadges from './gpg_badges'; +import UserFeatureHelper from './helpers/user_feature_helper'; (function() { var Dispatcher; @@ -92,6 +93,7 @@ import GpgBadges from './gpg_badges'; if (!page) { return false; } + path = page.split(':'); shortcut_handler = null; @@ -338,12 +340,10 @@ import GpgBadges from './gpg_badges'; case 'projects:show': shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); - if ($('#tree-slider').length) { - new TreeView(); - } - if ($('.blob-viewer').length) { - new BlobViewer(); - } + + if ($('#tree-slider').length) new TreeView(); + if ($('.blob-viewer').length) new BlobViewer(); + if ($('.project-show-activity').length) new gl.Activities(); break; case 'projects:edit': setupProjectEdit(); @@ -407,6 +407,9 @@ import GpgBadges from './gpg_badges'; break; case 'projects:tree:show': shortcut_handler = new ShortcutsNavigation(); + + if (UserFeatureHelper.isNewRepo()) break; + new TreeView(); new BlobViewer(); new NewCommitForm($('.js-create-dir-form')); @@ -425,6 +428,7 @@ import GpgBadges from './gpg_badges'; shortcut_handler = true; break; case 'projects:blob:show': + if (UserFeatureHelper.isNewRepo()) break; new BlobViewer(); initBlob(); break; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 7d11cd0b6b2..b62acfcd445 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,9 +1,53 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ +/* eslint-disable func-names, no-underscore-dangle, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ /* global fuzzaldrinPlus */ import _ from 'underscore'; import { isObject } from './lib/utils/type_utility'; -var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote; +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput; + +GitLabDropdownInput = (function() { + function GitLabDropdownInput(input, options) { + var $inputContainer, $clearButton; + var _this = this; + this.input = input; + this.options = options; + this.fieldName = this.options.fieldName || 'field-name'; + $inputContainer = this.input.parent(); + $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', (function(_this) { + // Clear click + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.input.val('').trigger('input').focus(); + }; + })(this)); + + this.input + .on('keydown', function (e) { + var keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { + e.preventDefault(); + } + }) + .on('input', function(e) { + var val = e.currentTarget.value || _this.options.inputFieldName; + val = val.split(' ').join('-') // replaces space with dash + .replace(/[^a-zA-Z0-9 -]/g, '').toLowerCase() // replace non alphanumeric + .replace(/(-)\1+/g, '-'); // replace repeated dashes + _this.cb(_this.options.fieldName, val, {}, true); + _this.input.closest('.dropdown') + .find('.dropdown-toggle-text') + .text(val); + }); + } + + GitLabDropdownInput.prototype.onInput = function(cb) { + this.cb = cb; + }; + + return GitLabDropdownInput; +})(); GitLabDropdownFilter = (function() { var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; @@ -191,7 +235,7 @@ GitLabDropdownRemote = (function() { })(); GitLabDropdown = (function() { - var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; + var ACTIVE_CLASS, FILTER_INPUT, NO_FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; LOADING_CLASS = "is-loading"; @@ -209,7 +253,9 @@ GitLabDropdown = (function() { CURSOR_SELECT_SCROLL_PADDING = 5; - FILTER_INPUT = '.dropdown-input .dropdown-input-field'; + FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-filter)'; + + NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; function GitLabDropdown(el1, options) { var searchFields, selector, self; @@ -224,6 +270,7 @@ GitLabDropdown = (function() { this.dropdown = selector != null ? $(selector) : $(this.el).parent(); // Set Defaults this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); + this.noFilterInput = this.options.noFilterInput || this.getElement(NO_FILTER_INPUT); this.highlight = !!this.options.highlight; this.filterInputBlur = this.options.filterInputBlur != null ? this.options.filterInputBlur @@ -262,6 +309,10 @@ GitLabDropdown = (function() { }); } } + if (this.noFilterInput.length) { + this.plainInput = new GitLabDropdownInput(this.noFilterInput, this.options); + this.plainInput.onInput(this.addInput.bind(this)); + } // Init filterable if (this.options.filterable) { this.filter = new GitLabDropdownFilter(this.filterInput, { @@ -753,9 +804,13 @@ GitLabDropdown = (function() { } }; - GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) { + GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject, single) { var $input; // Create hidden input for form + if (single) { + $('input[name="' + fieldName + '"]').remove(); + } + $input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value); if (this.options.inputId != null) { $input.attr('id', this.options.inputId); @@ -771,7 +826,7 @@ GitLabDropdown = (function() { $input.attr('data-meta', selectedObject[this.options.inputMeta]); } - return this.dropdown.before($input); + this.dropdown.before($input).trigger('change'); }; GitLabDropdown.prototype.selectRowAtIndex = function(index) { diff --git a/app/assets/javascripts/graphs/graphs_charts.js b/app/assets/javascripts/graphs/graphs_charts.js index 279ffef770f..ec6eab34989 100644 --- a/app/assets/javascripts/graphs/graphs_charts.js +++ b/app/assets/javascripts/graphs/graphs_charts.js @@ -1,4 +1,5 @@ import Chart from 'vendor/Chart'; +import _ from 'underscore'; document.addEventListener('DOMContentLoaded', () => { const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML); @@ -27,28 +28,25 @@ document.addEventListener('DOMContentLoaded', () => { return generateChart(); }; - const chartData = (keys, values) => { - const data = { - labels: keys, - datasets: [{ - fillColor: 'rgba(220,220,220,0.5)', - strokeColor: 'rgba(220,220,220,1)', - barStrokeWidth: 1, - barValueSpacing: 1, - barDatasetSpacing: 1, - data: values, - }], - }; - return data; - }; - - const hourData = chartData(projectChartData.hour.keys, projectChartData.hour.values); + const chartData = data => ({ + labels: Object.keys(data), + datasets: [{ + fillColor: 'rgba(220,220,220,0.5)', + strokeColor: 'rgba(220,220,220,1)', + barStrokeWidth: 1, + barValueSpacing: 1, + barDatasetSpacing: 1, + data: _.values(data), + }], + }); + + const hourData = chartData(projectChartData.hour); responsiveChart($('#hour-chart'), hourData); - const dayData = chartData(projectChartData.weekDays.keys, projectChartData.weekDays.values); + const dayData = chartData(projectChartData.weekDays); responsiveChart($('#weekday-chart'), dayData); - const monthData = chartData(projectChartData.month.keys, projectChartData.month.values); + const monthData = chartData(projectChartData.month); responsiveChart($('#month-chart'), monthData); const data = projectChartData.languages; diff --git a/app/assets/javascripts/helpers/user_feature_helper.js b/app/assets/javascripts/helpers/user_feature_helper.js new file mode 100644 index 00000000000..fcd8569819c --- /dev/null +++ b/app/assets/javascripts/helpers/user_feature_helper.js @@ -0,0 +1,11 @@ +import Cookies from 'js-cookie'; + +function isNewRepo() { + return Cookies.get('new_repo') === 'true'; +} + +const UserFeatureHelper = { + isNewRepo, +}; + +export default UserFeatureHelper; diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 1e96c7ab5cd..7a72509d234 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,2 +1,3 @@ /* eslint-disable import/prefer-default-export */ export const BYTES_IN_KIB = 1024; +export const HIDDEN_CLASS = 'hidden'; diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index 6e1744e8e72..1c2100a1c25 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -90,6 +90,7 @@ import Cookies from 'js-cookie'; filterable: true, filterRemote: true, filterByText: true, + inputFieldName: $dropdown.data('input-field-name'), fieldName: $dropdown.data('field-name'), renderRow: function(ref) { var li = refListItem.cloneNode(false); @@ -123,9 +124,14 @@ import Cookies from 'js-cookie'; e.preventDefault(); if ($('input[name="ref"]').length) { var $form = $dropdown.closest('form'); + + var $visit = $dropdown.data('visit'); + var shouldVisit = typeof $visit === 'undefined' ? true : $visit; var action = $form.attr('action'); var divider = action.indexOf('?') === -1 ? '?' : '&'; - gl.utils.visitUrl(action + '' + divider + '' + $form.serialize()); + if (shouldVisit) { + gl.utils.visitUrl(action + '' + divider + '' + $form.serialize()); + } } } }); diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue new file mode 100644 index 00000000000..703da749ad3 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo.vue @@ -0,0 +1,63 @@ +<script> +import RepoSidebar from './repo_sidebar.vue'; +import RepoCommitSection from './repo_commit_section.vue'; +import RepoTabs from './repo_tabs.vue'; +import RepoFileButtons from './repo_file_buttons.vue'; +import RepoPreview from './repo_preview.vue'; +import RepoMixin from '../mixins/repo_mixin'; +import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; +import Store from '../stores/repo_store'; +import Helper from '../helpers/repo_helper'; +import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; + +export default { + data: () => Store, + mixins: [RepoMixin], + components: { + 'repo-sidebar': RepoSidebar, + 'repo-tabs': RepoTabs, + 'repo-file-buttons': RepoFileButtons, + 'repo-editor': MonacoLoaderHelper.repoEditorLoader, + 'repo-commit-section': RepoCommitSection, + 'popup-dialog': PopupDialog, + 'repo-preview': RepoPreview, + }, + + mounted() { + Helper.getContent().catch(Helper.loadingError); + }, + + methods: { + dialogToggled(toggle) { + this.dialog.open = toggle; + }, + + dialogSubmitted(status) { + this.dialog.open = false; + this.dialog.status = status; + }, + + toggleBlobView: Store.toggleBlobView, + }, +}; +</script> + +<template> +<div class="repository-view tree-content-holder"> + <repo-sidebar/><div class="panel-right" :class="{'edit-mode': editMode}"> + <repo-tabs/> + <component :is="currentBlobView" class="blob-viewer-container"></component> + <repo-file-buttons/> + </div> + <repo-commit-section/> + <popup-dialog + :primary-button-label="__('Discard changes')" + :open="dialog.open" + kind="warning" + :title="__('Are you sure?')" + :body="__('Are you sure you want to discard your changes?')" + @toggle="dialogToggled" + @submit="dialogSubmitted" + /> +</div> +</template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue new file mode 100644 index 00000000000..bd83f80c928 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -0,0 +1,100 @@ +<script> +/* global Flash */ +import Store from '../stores/repo_store'; +import RepoMixin from '../mixins/repo_mixin'; +import Helper from '../helpers/repo_helper'; +import Service from '../services/repo_service'; + +const RepoCommitSection = { + data: () => Store, + + mixins: [RepoMixin], + + computed: { + branchPaths() { + const branch = Helper.getBranch(); + return this.changedFiles.map(f => Helper.getFilePathFromFullPath(f.url, branch)); + }, + + cantCommitYet() { + return !this.commitMessage || this.submitCommitsLoading; + }, + + filePluralize() { + return this.changedFiles.length > 1 ? 'files' : 'file'; + }, + }, + + methods: { + makeCommit() { + // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions + const branch = Helper.getBranch(); + const commitMessage = this.commitMessage; + const actions = this.changedFiles.map(f => ({ + action: 'update', + file_path: Helper.getFilePathFromFullPath(f.url, branch), + content: f.newContent, + })); + const payload = { + branch: Store.targetBranch, + commit_message: commitMessage, + actions, + }; + Store.submitCommitsLoading = true; + Service.commitFiles(payload, this.resetCommitState); + }, + + resetCommitState() { + this.submitCommitsLoading = false; + this.changedFiles = []; + this.openedFiles = []; + this.commitMessage = ''; + this.editMode = false; + $('html, body').animate({ scrollTop: 0 }, 'fast'); + }, + }, +}; + +export default RepoCommitSection; +</script> + +<template> +<div id="commit-area" v-if="isCommitable && changedFiles.length" > + <form class="form-horizontal"> + <fieldset> + <div class="form-group"> + <label class="col-md-4 control-label staged-files">Staged files ({{changedFiles.length}})</label> + <div class="col-md-4"> + <ul class="list-unstyled changed-files"> + <li v-for="file in branchPaths" :key="file.id"> + <span class="help-block">{{file}}</span> + </li> + </ul> + </div> + </div> + <!-- Textarea + --> + <div class="form-group"> + <label class="col-md-4 control-label" for="commit-message">Commit message</label> + <div class="col-md-4"> + <textarea class="form-control" id="commit-message" name="commit-message" v-model="commitMessage"></textarea> + </div> + </div> + <!-- Button Drop Down + --> + <div class="form-group target-branch"> + <label class="col-md-4 control-label" for="target-branch">Target branch</label> + <div class="col-md-4"> + <span class="help-block">{{targetBranch}}</span> + </div> + </div> + <div class="col-md-offset-4 col-md-4"> + <button type="submit" :disabled="cantCommitYet" class="btn btn-success submit-commit" @click.prevent="makeCommit"> + <i class="fa fa-spinner fa-spin" v-if="submitCommitsLoading"></i> + <span class="commit-summary">Commit {{changedFiles.length}} {{filePluralize}}</span> + </button> + </div> + </fieldset> + </form> +</div> +</template> diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue new file mode 100644 index 00000000000..e954fd38fc9 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_edit_button.vue @@ -0,0 +1,49 @@ +<script> +import Store from '../stores/repo_store'; +import RepoMixin from '../mixins/repo_mixin'; + +export default { + data: () => Store, + mixins: [RepoMixin], + computed: { + buttonLabel() { + return this.editMode ? this.__('Cancel edit') : this.__('Edit'); + }, + + buttonIcon() { + return this.editMode ? [] : ['fa', 'fa-pencil']; + }, + }, + methods: { + editClicked() { + if (this.changedFiles.length) { + this.dialog.open = true; + return; + } + this.editMode = !this.editMode; + Store.toggleBlobView(); + }, + }, + + watch: { + editMode() { + if (this.editMode) { + $('.project-refs-form').addClass('disabled'); + $('.fa-long-arrow-right').show(); + $('.project-refs-target-form').show(); + } else { + $('.project-refs-form').removeClass('disabled'); + $('.fa-long-arrow-right').hide(); + $('.project-refs-target-form').hide(); + } + }, + }, +}; +</script> + +<template> +<button class="btn btn-default" @click.prevent="editClicked" v-cloak v-if="isCommitable && !activeFile.render_error" :disabled="binary"> + <i :class="buttonIcon"></i> + <span>{{buttonLabel}}</span> +</button> +</template> diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue new file mode 100644 index 00000000000..fd1a21e15b4 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -0,0 +1,135 @@ +<script> +/* global monaco */ +import Store from '../stores/repo_store'; +import Service from '../services/repo_service'; +import Helper from '../helpers/repo_helper'; + +const RepoEditor = { + data: () => Store, + + destroyed() { + // this.monacoInstance.getModels().forEach((m) => { + // m.dispose(); + // }); + this.monacoInstance.destroy(); + }, + + mounted() { + Service.getRaw(this.activeFile.raw_path) + .then((rawResponse) => { + Store.blobRaw = rawResponse.data; + Helper.findOpenedFileFromActive().plain = rawResponse.data; + + const monacoInstance = this.monaco.editor.create(this.$el, { + model: null, + readOnly: false, + contextmenu: false, + }); + + Store.monacoInstance = monacoInstance; + + this.addMonacoEvents(); + + const languages = this.monaco.languages.getLanguages(); + const languageID = Helper.getLanguageIDForFile(this.activeFile, languages); + this.showHide(); + const newModel = this.monaco.editor.createModel(this.blobRaw, languageID); + + this.monacoInstance.setModel(newModel); + }).catch(Helper.loadingError); + }, + + methods: { + showHide() { + if (!this.openedFiles.length || (this.binary && !this.activeFile.raw)) { + this.$el.style.display = 'none'; + } else { + this.$el.style.display = 'inline-block'; + } + }, + + addMonacoEvents() { + this.monacoInstance.onMouseUp(this.onMonacoEditorMouseUp); + this.monacoInstance.onKeyUp(this.onMonacoEditorKeysPressed.bind(this)); + }, + + onMonacoEditorKeysPressed() { + Store.setActiveFileContents(this.monacoInstance.getValue()); + }, + + onMonacoEditorMouseUp(e) { + const lineNumber = e.target.position.lineNumber; + if (e.target.element.className === 'line-numbers') { + location.hash = `L${lineNumber}`; + Store.activeLine = lineNumber; + } + }, + }, + + watch: { + activeLine() { + this.monacoInstance.setPosition({ + lineNumber: this.activeLine, + column: 1, + }); + }, + + activeFileLabel() { + this.showHide(); + }, + + dialog: { + handler(obj) { + const newObj = obj; + if (newObj.status) { + newObj.status = false; + this.openedFiles.map((file) => { + const f = file; + if (f.active) { + this.blobRaw = f.plain; + } + f.changed = false; + delete f.newContent; + + return f; + }); + this.editMode = false; + } + }, + deep: true, + }, + + isTree() { + this.showHide(); + }, + + openedFiles() { + this.showHide(); + }, + + binary() { + this.showHide(); + }, + + blobRaw() { + this.showHide(); + + if (this.isTree) return; + + this.monacoInstance.setModel(null); + + const languages = this.monaco.languages.getLanguages(); + const languageID = Helper.getLanguageIDForFile(this.activeFile, languages); + const newModel = this.monaco.editor.createModel(this.blobRaw, languageID); + + this.monacoInstance.setModel(newModel); + }, + }, +}; + +export default RepoEditor; +</script> + +<template> +<div id="ide"></div> +</template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue new file mode 100644 index 00000000000..f604bc22a26 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -0,0 +1,66 @@ +<script> +import TimeAgoMixin from '../../vue_shared/mixins/timeago'; + +const RepoFile = { + mixins: [TimeAgoMixin], + props: { + file: { + type: Object, + required: true, + }, + isMini: { + type: Boolean, + required: false, + default: false, + }, + loading: { + type: Object, + required: false, + default() { return { tree: false }; }, + }, + hasFiles: { + type: Boolean, + required: false, + default: false, + }, + activeFile: { + type: Object, + required: true, + }, + }, + + computed: { + canShowFile() { + return !this.loading.tree || this.hasFiles; + }, + }, + + methods: { + linkClicked(file) { + this.$emit('linkclicked', file); + }, + }, +}; + +export default RepoFile; +</script> + +<template> +<tr class="file" v-if="canShowFile" :class="{'active': activeFile.url === file.url}"> + <td @click.prevent="linkClicked(file)"> + <i class="fa file-icon" v-if="!file.loading" :class="file.icon" :style="{'margin-left': file.level * 10 + 'px'}"></i> + <i class="fa fa-spinner fa-spin" v-if="file.loading" :style="{'margin-left': file.level * 10 + 'px'}"></i> + <a :href="file.url" class="repo-file-name" :title="file.url">{{file.name}}</a> + </td> + + <td v-if="!isMini" class="hidden-sm hidden-xs"> + <div class="commit-message"> + <a :href="file.lastCommitUrl">{{file.lastCommitMessage}}</a> + </div> + </td> + + <td v-if="!isMini" class="hidden-xs"> + <span class="commit-update" :title="tooltipTitle(file.lastCommitUpdate)">{{timeFormated(file.lastCommitUpdate)}}</span> + </td> +</tr> +</template> diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue new file mode 100644 index 00000000000..628d02ca704 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -0,0 +1,42 @@ +<script> +import Store from '../stores/repo_store'; +import Helper from '../helpers/repo_helper'; +import RepoMixin from '../mixins/repo_mixin'; + +const RepoFileButtons = { + data: () => Store, + + mixins: [RepoMixin], + + computed: { + + rawDownloadButtonLabel() { + return this.binary ? 'Download' : 'Raw'; + }, + + canPreview() { + return Helper.isKindaBinary(); + }, + }, + + methods: { + rawPreviewToggle: Store.toggleRawPreview, + }, +}; + +export default RepoFileButtons; +</script> + +<template> +<div id="repo-file-buttons" v-if="isMini"> + <a :href="activeFile.raw_path" target="_blank" class="btn btn-default raw" rel="noopener noreferrer">{{rawDownloadButtonLabel}}</a> + + <div class="btn-group" role="group" aria-label="File actions"> + <a :href="activeFile.blame_path" class="btn btn-default blame">Blame</a> + <a :href="activeFile.commits_path" class="btn btn-default history">History</a> + <a :href="activeFile.permalink" class="btn btn-default permalink">Permalink</a> + </div> + + <a href="#" v-if="canPreview" @click.prevent="rawPreviewToggle" class="btn btn-default preview">{{activeFileLabel}}</a> +</div> +</template> diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue new file mode 100644 index 00000000000..ba53ce0eecc --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_file_options.vue @@ -0,0 +1,25 @@ +<script> +const RepoFileOptions = { + props: { + isMini: { + type: Boolean, + required: false, + default: false, + }, + projectName: { + type: String, + required: true, + }, + }, +}; + +export default RepoFileOptions; +</script> + +<template> +<tr v-if="isMini" class="repo-file-options"> + <td> + <span class="title">{{projectName}}</span> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue new file mode 100644 index 00000000000..38e9f16d041 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -0,0 +1,51 @@ +<script> +const RepoLoadingFile = { + props: { + loading: { + type: Object, + required: false, + default: {}, + }, + hasFiles: { + type: Boolean, + required: false, + default: false, + }, + isMini: { + type: Boolean, + required: false, + default: false, + }, + }, + + methods: { + lineOfCode(n) { + return `line-of-code-${n}`; + }, + }, +}; + +export default RepoLoadingFile; +</script> + +<template> +<tr v-if="loading.tree && !hasFiles" class="loading-file"> + <td> + <div class="animation-container animation-container-small"> + <div v-for="n in 6" :class="lineOfCode(n)" :key="n"></div> + </div> + </td> + + <td v-if="!isMini" class="hidden-sm hidden-xs"> + <div class="animation-container"> + <div v-for="n in 6" :class="lineOfCode(n)" :key="n"></div> + </div> + </td> + + <td v-if="!isMini" class="hidden-xs"> + <div class="animation-container animation-container-small"> + <div v-for="n in 6" :class="lineOfCode(n)" :key="n"></div> + </div> + </td> +</tr> +</template> diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue new file mode 100644 index 00000000000..6a0d684052f --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue @@ -0,0 +1,26 @@ +<script> +const RepoPreviousDirectory = { + props: { + prevUrl: { + type: String, + required: true, + }, + }, + + methods: { + linkClicked(file) { + this.$emit('linkclicked', file); + }, + }, +}; + +export default RepoPreviousDirectory; +</script> + +<template> +<tr class="prev-directory"> + <td colspan="3"> + <a :href="prevUrl" @click.prevent="linkClicked(prevUrl)">..</a> + </td> +</tr> +</template> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue new file mode 100644 index 00000000000..d8de022335b --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -0,0 +1,32 @@ +<script> +import Store from '../stores/repo_store'; + +export default { + data: () => Store, + mounted() { + $(this.$el).find('.file-content').syntaxHighlight(); + }, + computed: { + html() { + return this.activeFile.html; + }, + }, + + watch: { + html() { + this.$nextTick(() => { + $(this.$el).find('.file-content').syntaxHighlight(); + }); + }, + }, +}; +</script> + +<template> +<div> + <div v-if="!activeFile.render_error" v-html="activeFile.html"></div> + <div v-if="activeFile.render_error" class="vertical-center render-error"> + <p class="text-center">The source could not be displayed because it is too large. You can <a :href="activeFile.raw_path">download</a> it instead.</p> + </div> +</div> +</template> diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue new file mode 100644 index 00000000000..d6d832efc49 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -0,0 +1,104 @@ +<script> +import Service from '../services/repo_service'; +import Helper from '../helpers/repo_helper'; +import Store from '../stores/repo_store'; +import RepoPreviousDirectory from './repo_prev_directory.vue'; +import RepoFileOptions from './repo_file_options.vue'; +import RepoFile from './repo_file.vue'; +import RepoLoadingFile from './repo_loading_file.vue'; +import RepoMixin from '../mixins/repo_mixin'; + +const RepoSidebar = { + mixins: [RepoMixin], + components: { + 'repo-file-options': RepoFileOptions, + 'repo-previous-directory': RepoPreviousDirectory, + 'repo-file': RepoFile, + 'repo-loading-file': RepoLoadingFile, + }, + + created() { + this.addPopEventListener(); + }, + + data: () => Store, + + methods: { + addPopEventListener() { + window.addEventListener('popstate', () => { + if (location.href.indexOf('#') > -1) return; + this.linkClicked({ + url: location.href, + }); + }); + }, + + linkClicked(clickedFile) { + let url = ''; + let file = clickedFile; + if (typeof file === 'object') { + file.loading = true; + if (file.type === 'tree' && file.opened) { + file = Store.removeChildFilesOfTree(file); + file.loading = false; + } else { + url = file.url; + Service.url = url; + // I need to refactor this to do the `then` here. + // Not a callback. For now this is good enough. + // it works. + Helper.getContent(file, () => { + file.loading = false; + Helper.scrollTabsRight(); + }); + } + } else if (typeof file === 'string') { + // go back + url = file; + Service.url = url; + Helper.getContent(null, () => Helper.scrollTabsRight()); + } + }, + }, +}; + +export default RepoSidebar; +</script> + +<template> +<div id="sidebar" :class="{'sidebar-mini' : isMini}" v-cloak> + <table class="table"> + <thead v-if="!isMini"> + <tr> + <th class="name">Name</th> + <th class="hidden-sm hidden-xs last-commit">Last Commit</th> + <th class="hidden-xs last-update">Last Update</th> + </tr> + </thead> + <tbody> + <repo-file-options + :is-mini="isMini" + :project-name="projectName"/> + <repo-previous-directory + v-if="isRoot" + :prev-url="prevURL" + @linkclicked="linkClicked(prevURL)"/> + <repo-loading-file + v-for="n in 5" + :key="n" + :loading="loading" + :has-files="!!files.length" + :is-mini="isMini"/> + <repo-file + v-for="file in files" + :key="file.id" + :file="file" + :is-mini="isMini" + @linkclicked="linkClicked(file)" + :is-tree="isTree" + :has-files="!!files.length" + :active-file="activeFile"/> + </tbody> + </table> +</div> +</template> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue new file mode 100644 index 00000000000..712d64c236f --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -0,0 +1,45 @@ +<script> +import Store from '../stores/repo_store'; + +const RepoTab = { + props: { + tab: { + type: Object, + required: true, + }, + }, + + computed: { + changedClass() { + const tabChangedObj = { + 'fa-times': !this.tab.changed, + 'fa-circle': this.tab.changed, + }; + return tabChangedObj; + }, + }, + + methods: { + tabClicked: Store.setActiveFiles, + + xClicked(file) { + if (file.changed) return; + this.$emit('xclicked', file); + }, + }, +}; + +export default RepoTab; +</script> + +<template> +<li> + <a href="#" class="close" @click.prevent="xClicked(tab)" v-if="!tab.loading"> + <i class="fa" :class="changedClass"></i> + </a> + + <a href="#" class="repo-tab" v-if="!tab.loading" :title="tab.url" @click.prevent="tabClicked(tab)">{{tab.name}}</a> + + <i v-if="tab.loading" class="fa fa-spinner fa-spin"></i> +</li> +</template> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue new file mode 100644 index 00000000000..907a03e1601 --- /dev/null +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -0,0 +1,43 @@ +<script> +import Vue from 'vue'; +import Store from '../stores/repo_store'; +import RepoTab from './repo_tab.vue'; +import RepoMixin from '../mixins/repo_mixin'; + +const RepoTabs = { + mixins: [RepoMixin], + + components: { + 'repo-tab': RepoTab, + }, + + data: () => Store, + + methods: { + isOverflow() { + return this.$el.scrollWidth > this.$el.offsetWidth; + }, + + xClicked(file) { + Store.removeFromOpenedFiles(file); + }, + }, + + watch: { + openedFiles() { + Vue.nextTick(() => { + this.tabsOverflow = this.isOverflow(); + }); + }, + }, +}; + +export default RepoTabs; +</script> + +<template> +<ul id="tabs" v-if="isMini" v-cloak :class="{'overflown': tabsOverflow}"> + <repo-tab v-for="tab in openedFiles" :key="tab.id" :tab="tab" :class="{'active' : tab.active}" @xclicked="xClicked"/> + <li class="tabs-divider" /> +</ul> +</template> diff --git a/app/assets/javascripts/repo/helpers/monaco_loader_helper.js b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js new file mode 100644 index 00000000000..8ee2df5c879 --- /dev/null +++ b/app/assets/javascripts/repo/helpers/monaco_loader_helper.js @@ -0,0 +1,21 @@ +/* global monaco */ +import RepoEditor from '../components/repo_editor.vue'; +import Store from '../stores/repo_store'; +import monacoLoader from '../monaco_loader'; + +function repoEditorLoader() { + Store.monacoLoading = true; + return new Promise((resolve, reject) => { + monacoLoader(['vs/editor/editor.main'], () => { + Store.monaco = monaco; + Store.monacoLoading = false; + resolve(RepoEditor); + }, reject); + }); +} + +const MonacoLoaderHelper = { + repoEditorLoader, +}; + +export default MonacoLoaderHelper; diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js new file mode 100644 index 00000000000..fee98c12592 --- /dev/null +++ b/app/assets/javascripts/repo/helpers/repo_helper.js @@ -0,0 +1,303 @@ +/* global Flash */ +import Service from '../services/repo_service'; +import Store from '../stores/repo_store'; +import '../../flash'; + +const RepoHelper = { + getDefaultActiveFile() { + return { + active: true, + binary: false, + extension: '', + html: '', + mime_type: '', + name: '', + plain: '', + size: 0, + url: '', + raw: false, + newContent: '', + changed: false, + loading: false, + }; + }, + + key: '', + + isTree(data) { + return Object.hasOwnProperty.call(data, 'blobs'); + }, + + Time: window.performance + && window.performance.now + ? window.performance + : Date, + + getBranch() { + return $('button.dropdown-menu-toggle').attr('data-ref'); + }, + + getLanguageIDForFile(file, langs) { + const ext = file.name.split('.').pop(); + const foundLang = RepoHelper.findLanguage(ext, langs); + + return foundLang ? foundLang.id : 'plaintext'; + }, + + getFilePathFromFullPath(fullPath, branch) { + return fullPath.split(`${Store.projectUrl}/blob/${branch}`)[1]; + }, + + findLanguage(ext, langs) { + return langs.find(lang => lang.extensions && lang.extensions.indexOf(`.${ext}`) > -1); + }, + + setDirectoryOpen(tree) { + const file = tree; + if (!file) return undefined; + + file.opened = true; + file.icon = 'fa-folder-open'; + RepoHelper.toURL(file.url, file.name); + return file; + }, + + isKindaBinary() { + const okExts = ['md', 'svg']; + return okExts.indexOf(Store.activeFile.extension) > -1; + }, + + setBinaryDataAsBase64(file) { + Service.getBase64Content(file.raw_path) + .then((response) => { + Store.blobRaw = response; + file.base64 = response; // eslint-disable-line no-param-reassign + }) + .catch(RepoHelper.loadingError); + }, + + toggleFakeTab(loading, file) { + if (loading) return Store.addPlaceholderFile(); + return Store.removeFromOpenedFiles(file); + }, + + setLoading(loading, file) { + if (Service.url.indexOf('blob') > -1) { + Store.loading.blob = loading; + return RepoHelper.toggleFakeTab(loading, file); + } + + if (Service.url.indexOf('tree') > -1) Store.loading.tree = loading; + + return undefined; + }, + + getNewMergedList(inDirectory, currentList, newList) { + const newListSorted = newList.sort(this.compareFilesCaseInsensitive); + if (!inDirectory) return newListSorted; + const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url); + if (!indexOfFile) return newListSorted; + return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile); + }, + + mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) { + newList.reverse().forEach((newFile) => { + const fileIndex = indexOfFile + 1; + const file = newFile; + file.level = inDirectory.level + 1; + oldList.splice(fileIndex, 0, file); + }); + + return oldList; + }, + + compareFilesCaseInsensitive(a, b) { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (a.level > 0) return 0; + if (aName < bName) { return -1; } + if (aName > bName) { return 1; } + return 0; + }, + + isRoot(url) { + // the url we are requesting -> split by the project URL. Grab the right side. + const isRoot = !!url.split(Store.projectUrl)[1] + // remove the first "/" + .slice(1) + // split this by "/" + .split('/') + // remove the first two items of the array... usually /tree/master. + .slice(2) + // we want to know the length of the array. + // If greater than 0 not root. + .length; + return isRoot; + }, + + getContent(treeOrFile, cb) { + let file = treeOrFile; + // const loadingData = RepoHelper.setLoading(true); + return Service.getContent() + .then((response) => { + const data = response.data; + // RepoHelper.setLoading(false, loadingData); + if (cb) cb(); + Store.isTree = RepoHelper.isTree(data); + if (!Store.isTree) { + if (!file) file = data; + Store.binary = data.binary; + + if (data.binary) { + Store.binaryMimeType = data.mime_type; + // file might be undefined + RepoHelper.setBinaryDataAsBase64(data); + Store.setViewToPreview(); + } else if (!Store.isPreviewView()) { + if (!data.render_error) { + Service.getRaw(data.raw_path) + .then((rawResponse) => { + Store.blobRaw = rawResponse.data; + data.plain = rawResponse.data; + RepoHelper.setFile(data, file); + }).catch(RepoHelper.loadingError); + } + } + + if (Store.isPreviewView()) { + RepoHelper.setFile(data, file); + } + + // if the file tree is empty + if (Store.files.length === 0) { + const parentURL = Service.blobURLtoParentTree(Service.url); + Service.url = parentURL; + RepoHelper.getContent(); + } + } else { + // it's a tree + if (!file) Store.isRoot = RepoHelper.isRoot(Service.url); + file = RepoHelper.setDirectoryOpen(file); + const newDirectory = RepoHelper.dataToListOfFiles(data); + Store.addFilesToDirectory(file, Store.files, newDirectory); + Store.prevURL = Service.blobURLtoParentTree(Service.url); + } + }).catch(RepoHelper.loadingError); + }, + + setFile(data, file) { + const newFile = data; + + newFile.url = file.url || location.pathname; + newFile.url = file.url; + if (newFile.render_error === 'too_large') { + newFile.tooLarge = true; + } + newFile.newContent = ''; + + Store.addToOpenedFiles(newFile); + Store.setActiveFiles(newFile); + }, + + toFA(icon) { + return `fa-${icon}`; + }, + + serializeBlob(blob) { + const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob); + simpleBlob.lastCommitMessage = blob.last_commit.message; + simpleBlob.lastCommitUpdate = blob.last_commit.committed_date; + simpleBlob.loading = false; + + return simpleBlob; + }, + + serializeTree(tree) { + return RepoHelper.serializeRepoEntity('tree', tree); + }, + + serializeSubmodule(submodule) { + return RepoHelper.serializeRepoEntity('submodule', submodule); + }, + + serializeRepoEntity(type, entity) { + const { url, name, icon, last_commit } = entity; + const returnObj = { + type, + name, + url, + icon: RepoHelper.toFA(icon), + level: 0, + loading: false, + }; + + if (entity.last_commit) { + returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`; + } else { + returnObj.lastCommitUrl = ''; + } + return returnObj; + }, + + scrollTabsRight() { + // wait for the transition. 0.1 seconds. + setTimeout(() => { + const tabs = document.getElementById('tabs'); + if (!tabs) return; + tabs.scrollLeft = 12000; + }, 200); + }, + + dataToListOfFiles(data) { + const a = []; + + // push in blobs + data.blobs.forEach((blob) => { + a.push(RepoHelper.serializeBlob(blob)); + }); + + data.trees.forEach((tree) => { + a.push(RepoHelper.serializeTree(tree)); + }); + + data.submodules.forEach((submodule) => { + a.push(RepoHelper.serializeSubmodule(submodule)); + }); + + return a; + }, + + genKey() { + return RepoHelper.Time.now().toFixed(3); + }, + + getStateKey() { + return RepoHelper.key; + }, + + setStateKey(key) { + RepoHelper.key = key; + }, + + toURL(url, title) { + const history = window.history; + + RepoHelper.key = RepoHelper.genKey(); + + history.pushState({ key: RepoHelper.key }, '', url); + + if (title) { + document.title = `${title} · GitLab`; + } + }, + + findOpenedFileFromActive() { + return Store.openedFiles.find(openedFile => Store.activeFile.url === openedFile.url); + }, + + loadingError() { + Flash('Unable to load the file at this time.'); + }, +}; + +export default RepoHelper; diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js new file mode 100644 index 00000000000..67c03680fca --- /dev/null +++ b/app/assets/javascripts/repo/index.js @@ -0,0 +1,74 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import Service from './services/repo_service'; +import Store from './stores/repo_store'; +import Repo from './components/repo.vue'; +import RepoEditButton from './components/repo_edit_button.vue'; +import Translate from '../vue_shared/translate'; + +function initDropdowns() { + $('.project-refs-target-form').hide(); + $('.fa-long-arrow-right').hide(); +} + +function addEventsForNonVueEls() { + $(document).on('change', '.dropdown', () => { + Store.targetBranch = $('.project-refs-target-form input[name="ref"]').val(); + }); + + window.onbeforeunload = function confirmUnload(e) { + const hasChanged = Store.openedFiles + .some(file => file.changed); + if (!hasChanged) return undefined; + const event = e || window.event; + if (event) event.returnValue = 'Are you sure you want to lose unsaved changes?'; + // For Safari + return 'Are you sure you want to lose unsaved changes?'; + }; +} + +function setInitialStore(data) { + Store.service = Service; + Store.service.url = data.url; + Store.service.refsUrl = data.refsUrl; + Store.projectId = data.projectId; + Store.projectName = data.projectName; + Store.projectUrl = data.projectUrl; + Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); + Store.checkIsCommitable(); +} + +function initRepo(el) { + return new Vue({ + el, + components: { + repo: Repo, + }, + }); +} + +function initRepoEditButton(el) { + return new Vue({ + el, + components: { + repoEditButton: RepoEditButton, + }, + }); +} + +function initRepoBundle() { + const repo = document.getElementById('repo'); + const editButton = document.querySelector('.editable-mode'); + setInitialStore(repo.dataset); + addEventsForNonVueEls(); + initDropdowns(); + + Vue.use(Translate); + + initRepo(repo); + initRepoEditButton(editButton); +} + +$(initRepoBundle); + +export default initRepoBundle; diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js new file mode 100644 index 00000000000..c8e8238a0d3 --- /dev/null +++ b/app/assets/javascripts/repo/mixins/repo_mixin.js @@ -0,0 +1,17 @@ +import Store from '../stores/repo_store'; + +const RepoMixin = { + computed: { + isMini() { + return !!Store.openedFiles.length; + }, + + changedFiles() { + const changedFileList = this.openedFiles + .filter(file => file.changed); + return changedFileList; + }, + }, +}; + +export default RepoMixin; diff --git a/app/assets/javascripts/repo/monaco_loader.js b/app/assets/javascripts/repo/monaco_loader.js new file mode 100644 index 00000000000..ad1370a7730 --- /dev/null +++ b/app/assets/javascripts/repo/monaco_loader.js @@ -0,0 +1,13 @@ +/* eslint-disable no-underscore-dangle, camelcase */ +/* global __webpack_public_path__ */ + +import monacoContext from 'monaco-editor/dev/vs/loader'; + +monacoContext.require.config({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, + }, +}); + +window.__monaco_context__ = monacoContext; +export default monacoContext.require; diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js new file mode 100644 index 00000000000..8fba928e456 --- /dev/null +++ b/app/assets/javascripts/repo/services/repo_service.js @@ -0,0 +1,82 @@ +/* global Flash */ +import axios from 'axios'; +import Store from '../stores/repo_store'; +import Api from '../../api'; + +const RepoService = { + url: '', + options: { + params: { + format: 'json', + }, + }, + richExtensionRegExp: /md/, + + checkCurrentBranchIsCommitable() { + const url = Store.service.refsUrl; + return axios.get(url, { params: { + ref: Store.currentBranch, + search: Store.currentBranch, + } }); + }, + + getRaw(url) { + return axios.get(url, { + transformResponse: [res => res], + }); + }, + + buildParams(url = this.url) { + // shallow clone object without reference + const params = Object.assign({}, this.options.params); + + if (this.urlIsRichBlob(url)) params.viewer = 'rich'; + + return params; + }, + + urlIsRichBlob(url = this.url) { + const extension = url.split('.').pop(); + + return this.richExtensionRegExp.test(extension); + }, + + getContent(url = this.url) { + const params = this.buildParams(url); + + return axios.get(url, { + params, + }); + }, + + getBase64Content(url = this.url) { + const request = axios.get(url, { + responseType: 'arraybuffer', + }); + + return request.then(response => this.bufferToBase64(response.data)); + }, + + bufferToBase64(data) { + return new Buffer(data, 'binary').toString('base64'); + }, + + blobURLtoParentTree(url) { + const urlArray = url.split('/'); + urlArray.pop(); + const blobIndex = urlArray.lastIndexOf('blob'); + + if (blobIndex > -1) urlArray[blobIndex] = 'tree'; + + return urlArray.join('/'); + }, + + commitFiles(payload, cb) { + Api.commitMultiple(Store.projectId, payload, (data) => { + Flash(`Your changes have been committed. Commit ${data.short_id} with ${data.stats.additions} additions, ${data.stats.deletions} deletions.`, 'notice'); + cb(); + }); + }, +}; + +export default RepoService; diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js new file mode 100644 index 00000000000..06ca391ed0c --- /dev/null +++ b/app/assets/javascripts/repo/stores/repo_store.js @@ -0,0 +1,241 @@ +/* global Flash */ +import Helper from '../helpers/repo_helper'; +import Service from '../services/repo_service'; + +const RepoStore = { + ideEl: {}, + monaco: {}, + monacoLoading: false, + monacoInstance: {}, + service: '', + editor: '', + sidebar: '', + editMode: false, + isTree: false, + isRoot: false, + prevURL: '', + projectId: '', + projectName: '', + projectUrl: '', + trees: [], + blobs: [], + submodules: [], + blobRaw: '', + blobRendered: '', + currentBlobView: 'repo-preview', + openedFiles: [], + tabSize: 100, + defaultTabSize: 100, + minTabSize: 30, + tabsOverflow: 41, + submitCommitsLoading: false, + binaryLoaded: false, + dialog: { + open: false, + title: '', + status: false, + }, + activeFile: Helper.getDefaultActiveFile(), + activeFileIndex: 0, + activeLine: 0, + activeFileLabel: 'Raw', + files: [], + isCommitable: false, + binary: false, + currentBranch: '', + targetBranch: 'new-branch', + commitMessage: '', + binaryMimeType: '', + // scroll bar space for windows + scrollWidth: 0, + binaryTypes: { + png: false, + md: false, + svg: false, + unknown: false, + }, + loading: { + tree: false, + blob: false, + }, + readOnly: true, + + resetBinaryTypes() { + Object.keys(RepoStore.binaryTypes).forEach((key) => { + RepoStore.binaryTypes[key] = false; + }); + }, + + // mutations + checkIsCommitable() { + RepoStore.service.checkCurrentBranchIsCommitable() + .then((data) => { + // you shouldn't be able to make commits on commits or tags. + const { Branches, Commits, Tags } = data.data; + if (Branches && Branches.length) RepoStore.isCommitable = true; + if (Commits && Commits.length) RepoStore.isCommitable = false; + if (Tags && Tags.length) RepoStore.isCommitable = false; + }).catch(() => Flash('Failed to check if branch can be committed to.')); + }, + + addFilesToDirectory(inDirectory, currentList, newList) { + RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList); + }, + + toggleRawPreview() { + RepoStore.activeFile.raw = !RepoStore.activeFile.raw; + RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; + }, + + setActiveFiles(file) { + if (RepoStore.isActiveFile(file)) return; + RepoStore.openedFiles = RepoStore.openedFiles + .map((openedFile, i) => RepoStore.setFileActivity(file, openedFile, i)); + + RepoStore.setActiveToRaw(); + + if (file.binary) { + RepoStore.blobRaw = file.base64; + RepoStore.binaryMimeType = file.mime_type; + } else if (file.newContent || file.plain) { + RepoStore.blobRaw = file.newContent || file.plain; + } else { + Service.getRaw(file.raw_path) + .then((rawResponse) => { + RepoStore.blobRaw = rawResponse.data; + Helper.findOpenedFileFromActive().plain = rawResponse.data; + }).catch(Helper.loadingError); + } + + if (!file.loading) Helper.toURL(file.url, file.name); + RepoStore.binary = file.binary; + }, + + setFileActivity(file, openedFile, i) { + const activeFile = openedFile; + activeFile.active = file.url === activeFile.url; + + if (activeFile.active) RepoStore.setActiveFile(activeFile, i); + + return activeFile; + }, + + setActiveFile(activeFile, i) { + RepoStore.activeFile = Object.assign({}, RepoStore.activeFile, activeFile); + RepoStore.activeFileIndex = i; + }, + + setActiveToRaw() { + RepoStore.activeFile.raw = false; + // can't get vue to listen to raw for some reason so RepoStore for now. + RepoStore.activeFileLabel = 'Display source'; + }, + + removeChildFilesOfTree(tree) { + let foundTree = false; + const treeToClose = tree; + let wereDone = false; + RepoStore.files = RepoStore.files.filter((file) => { + const isItTheTreeWeWant = file.url === treeToClose.url; + // if it's the next tree + if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) { + wereDone = true; + return true; + } + if (wereDone) return true; + + if (isItTheTreeWeWant) foundTree = true; + + if (foundTree) return file.level <= treeToClose.level; + return true; + }); + + treeToClose.opened = false; + treeToClose.icon = 'fa-folder'; + return treeToClose; + }, + + removeFromOpenedFiles(file) { + if (file.type === 'tree') return; + let foundIndex; + RepoStore.openedFiles = RepoStore.openedFiles.filter((openedFile, i) => { + if (openedFile.url === file.url) foundIndex = i; + return openedFile.url !== file.url; + }); + + // now activate the right tab based on what you closed. + if (RepoStore.openedFiles.length === 0) { + RepoStore.activeFile = {}; + return; + } + + if (RepoStore.openedFiles.length === 1 || foundIndex === 0) { + RepoStore.setActiveFiles(RepoStore.openedFiles[0]); + return; + } + + if (foundIndex) { + if (foundIndex > 0) { + RepoStore.setActiveFiles(RepoStore.openedFiles[foundIndex - 1]); + } + } + }, + + addPlaceholderFile() { + const randomURL = Helper.Time.now(); + const newFakeFile = { + active: false, + binary: true, + type: 'blob', + loading: true, + mime_type: 'loading', + name: 'loading', + url: randomURL, + fake: true, + }; + + RepoStore.openedFiles.push(newFakeFile); + + return newFakeFile; + }, + + addToOpenedFiles(file) { + const openFile = file; + + const openedFilesAlreadyExists = RepoStore.openedFiles + .some(openedFile => openedFile.url === openFile.url); + + if (openedFilesAlreadyExists) return; + + openFile.changed = false; + RepoStore.openedFiles.push(openFile); + }, + + setActiveFileContents(contents) { + if (!RepoStore.editMode) return; + const currentFile = RepoStore.openedFiles[RepoStore.activeFileIndex]; + RepoStore.activeFile.newContent = contents; + RepoStore.activeFile.changed = RepoStore.activeFile.plain !== RepoStore.activeFile.newContent; + currentFile.changed = RepoStore.activeFile.changed; + currentFile.newContent = contents; + }, + + toggleBlobView() { + RepoStore.currentBlobView = RepoStore.isPreviewView() ? 'repo-editor' : 'repo-preview'; + }, + + setViewToPreview() { + RepoStore.currentBlobView = 'repo-preview'; + }, + + // getters + + isActiveFile(file) { + return file && file.url === RepoStore.activeFile.url; + }, + + isPreviewView() { + return RepoStore.currentBlobView === 'repo-preview'; + }, +}; +export default RepoStore; diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js index ef401abce2d..8875590f0f2 100644 --- a/app/assets/javascripts/test_utils/index.js +++ b/app/assets/javascripts/test_utils/index.js @@ -1,3 +1,5 @@ +import 'core-js/es6/map'; +import 'core-js/es6/set'; import simulateDrag from './simulate_drag'; // Export to global space for rspec to use diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js index a12f418e1af..f6d1a4feeb2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_locked.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.js @@ -1,7 +1,7 @@ import statusIcon from '../mr_widget_status_icon'; export default { - name: 'MRWidgetLocked', + name: 'MRWidgetMerging', props: { mr: { type: Object, required: true }, }, @@ -13,7 +13,7 @@ export default { <status-icon status="loading" /> <div class="media-body"> <h4> - This merge request is in the process of being merged, during which time it is locked and cannot be closed + This merge request is in the process of being merged </h4> <section class="mr-info-list"> <p> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 546a3f625c7..49340c232c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -19,7 +19,7 @@ export { default as WidgetRelatedLinks } from './components/mr_widget_related_li export { default as MergedState } from './components/states/mr_widget_merged'; export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge'; export { default as ClosedState } from './components/states/mr_widget_closed'; -export { default as LockedState } from './components/states/mr_widget_locked'; +export { default as MergingState } from './components/states/mr_widget_merging'; export { default as WipState } from './components/states/mr_widget_wip'; export { default as ArchivedState } from './components/states/mr_widget_archived'; export { default as ConflictsState } from './components/states/mr_widget_conflicts'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 577d77f09a6..0042c48816f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -8,7 +8,7 @@ import { WidgetRelatedLinks, MergedState, ClosedState, - LockedState, + MergingState, WipState, ArchivedState, ConflictsState, @@ -212,7 +212,7 @@ export default { 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, 'mr-widget-closed': ClosedState, - 'mr-widget-locked': LockedState, + 'mr-widget-merging': MergingState, 'mr-widget-failed-to-merge': FailedToMerge, 'mr-widget-wip': WipState, 'mr-widget-archived': ArchivedState, diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index fddafb0ddfa..fbea764b739 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -73,6 +73,7 @@ export default class MergeRequestStore { this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; this.hasSHAChanged = this.sha !== data.diff_head_sha; this.canBeMerged = data.can_be_merged || false; + this.mergeOngoing = data.merge_ongoing; // Cherry-pick and Revert actions related this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; @@ -94,6 +95,11 @@ export default class MergeRequestStore { } setState(data) { + if (this.mergeOngoing) { + this.state = 'merging'; + return; + } + if (this.isOpen) { this.state = getStateKey.call(this, data); } else { @@ -104,9 +110,6 @@ export default class MergeRequestStore { case 'closed': this.state = 'closed'; break; - case 'locked': - this.state = 'locked'; - break; default: this.state = null; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 605dd3a1ff4..9074a064a6d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -1,7 +1,7 @@ const stateToComponentMap = { merged: 'mr-widget-merged', closed: 'mr-widget-closed', - locked: 'mr-widget-locked', + merging: 'mr-widget-merging', conflicts: 'mr-widget-conflicts', missingBranch: 'mr-widget-missing-branch', workInProgress: 'mr-widget-wip', @@ -20,7 +20,7 @@ const stateToComponentMap = { }; const statesToShowHelpWidget = [ - 'locked', + 'merging', 'conflicts', 'workInProgress', 'readyToMerge', diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue new file mode 100644 index 00000000000..7d339c0e753 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue @@ -0,0 +1,67 @@ +<script> +const PopupDialog = { + name: 'popup-dialog', + + props: { + open: Boolean, + title: String, + body: String, + kind: { + type: String, + default: 'primary', + }, + closeButtonLabel: { + type: String, + default: 'Cancel', + }, + primaryButtonLabel: { + type: String, + default: 'Save changes', + }, + }, + + computed: { + typeOfClass() { + const className = `btn-${this.kind}`; + const returnObj = {}; + returnObj[className] = true; + return returnObj; + }, + }, + + methods: { + close() { + this.$emit('toggle', false); + }, + + yesClick() { + this.$emit('submit', true); + }, + + noClick() { + this.$emit('submit', false); + }, + }, +}; + +export default PopupDialog; +</script> +<template> +<div class="modal popup-dialog" tabindex="-1" v-show="open" role="dialog"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" @click="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title">{{this.title}}</h4> + </div> + <div class="modal-body"> + <p>{{this.body}}</p> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal" @click="noClick">{{closeButtonLabel}}</button> + <button type="button" class="btn" :class="typeOfClass" @click="yesClick">{{primaryButtonLabel}}</button> + </div> + </div> + </div> +</div> +</template> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 5e374360359..293aa194528 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -372,6 +372,10 @@ table { background: $gl-success !important; } +.dz-message { + margin: 0; +} + .space-right { margin-right: 10px; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index d9f92e93160..b677882eba4 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -26,7 +26,7 @@ header { &.navbar-gitlab { padding: 0 16px; - z-index: 2000; + z-index: 1000; margin-bottom: 0; min-height: $header-height; background-color: $gray-light; diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 0d8827bec11..bd0367f86dd 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -116,3 +116,13 @@ body { .with-performance-bar .page-with-sidebar { margin-top: $header-height + $performance-bar-height; } + +[v-cloak] { + display: none; +} + +.vertical-center { + min-height: 100vh; + display: flex; + align-items: center; +} diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss index b666223b120..4c35e3a9c3c 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss @@ -162,3 +162,5 @@ $pre-color: $gl-text-color !default; $pre-border-color: $border-color; $table-bg-accent: $gray-light; + +$zindex-popover: 900; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 7a1a89cd2f9..3c109a5a929 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -88,6 +88,7 @@ $indigo-950: #1a1a40; $black: #000; $black-transparent: rgba(0, 0, 0, 0.3); +$almost-black: #242424; $border-white-light: darken($white-light, $darken-border-factor); $border-white-normal: darken($white-normal, $darken-border-factor); @@ -619,6 +620,13 @@ $color-average-score: $orange-400; $color-low-score: $red-400; /* +Repo editor +*/ +$repo-editor-grey: #f6f7f9; +$repo-editor-grey-darker: #e9ebee; +$repo-editor-linear-gradient: linear-gradient(to right, $repo-editor-grey 0%, $repo-editor-grey-darker, 20%, $repo-editor-grey 40%, $repo-editor-grey 100%); + +/* Performance Bar */ $perf-bar-text: #999; diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss index 76dccd2df56..609bc9a7dfc 100644 --- a/app/assets/stylesheets/new_sidebar.scss +++ b/app/assets/stylesheets/new_sidebar.scss @@ -229,7 +229,7 @@ $new-sidebar-collapsed-width: 50px; position: fixed; top: 0; left: $new-sidebar-width; - width: 150px; + min-width: 150px; margin-top: -1px; padding: 8px 1px; background-color: $white-light; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 6039cda96d8..e5b467a2691 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -165,6 +165,7 @@ .board-title { padding-top: ($gl-padding - 3px); + padding-bottom: $gl-padding; } } } @@ -178,6 +179,7 @@ position: relative; margin: 0; padding: $gl-padding; + padding-bottom: ($gl-padding + 3px); font-size: 1em; border-bottom: 1px solid $border-color; } diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss new file mode 100644 index 00000000000..ad17078c98a --- /dev/null +++ b/app/assets/stylesheets/pages/repo.scss @@ -0,0 +1,413 @@ +.fade-enter-active, +.fade-leave-active { + transition: opacity .5s; +} + +.monaco-loader { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $black-transparent; +} + +.modal.popup-dialog { + display: block; + background-color: $black-transparent; + z-index: 2100; + + @media (min-width: $screen-md-min) { + .modal-dialog { + width: 600px; + margin: 30px auto; + } + } +} + +.project-refs-form, +.project-refs-target-form { + display: inline-block; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } +} + +.fade-enter, +.fade-leave-to { + opacity: 0; +} + +.commit-message { + @include str-truncated(250px); +} + +.editable-mode { + display: inline-block; +} + +.blob-viewer[data-type="rich"] { + margin: 20px; +} + +.repository-view.tree-content-holder { + border: 1px solid $border-color; + border-radius: $border-radius-default; + color: $almost-black; + + .panel-right { + display: inline-block; + width: 80%; + + .monaco-editor.vs { + .line-numbers { + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .cursor { + display: none !important; + } + } + + &.edit-mode { + .blob-viewer-container { + overflow: hidden; + } + + .monaco-editor.vs { + .cursor { + background: $black; + border-color: $black; + display: block !important; + } + } + } + + .blob-viewer-container { + height: calc(100vh - 63px); + overflow: auto; + } + + #tabs { + padding-left: 0; + margin-bottom: 0; + display: flex; + white-space: nowrap; + width: 100%; + overflow-y: hidden; + overflow-x: auto; + + li { + animation: swipeRightAppear ease-in 0.1s; + animation-iteration-count: 1; + transform-origin: 0% 50%; + list-style-type: none; + background: $gray-normal; + display: inline-block; + padding: 10px 18px; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + white-space: nowrap; + + &.remove { + animation: swipeRightDissapear ease-in 0.1s; + animation-iteration-count: 1; + transform-origin: 0% 50%; + + a { + width: 0; + } + } + + &.active { + background: $white-light; + border-bottom: none; + } + + a { + @include str-truncated(100px); + color: $black; + display: inline-block; + width: 100px; + text-align: center; + vertical-align: middle; + + &.close { + width: auto; + font-size: 15px; + opacity: 1; + margin-right: -6px; + } + } + + i.fa.fa-times, + i.fa.fa-circle { + float: right; + margin-top: 3px; + margin-left: 15px; + color: $gray-darkest; + } + + i.fa.fa-circle { + color: $brand-success; + } + + &.tabs-divider { + width: 100%; + background-color: $white-light; + border-right: none; + border-top-right-radius: 2px; + } + } + } + + #repo-file-buttons { + background-color: $white-light; + border-bottom: 1px solid $white-normal; + padding: 5px 10px; + position: relative; + border-top: 1px solid $white-normal; + margin-top: -5px; + } + + #binary-viewer { + height: 80vh; + overflow: auto; + margin: 0; + + .blob-viewer { + padding-top: 20px; + padding-left: 20px; + } + + .binary-unknown { + text-align: center; + padding-top: 100px; + background: $gray-light; + height: 100%; + font-size: 17px; + + span { + display: block; + } + } + } + } + + #commit-area { + background: $gray-light; + padding: 20px; + + span.help-block { + padding-top: 7px; + margin-top: 0; + } + } + + #view-toggler { + height: 41px; + position: relative; + display: block; + border-bottom: 1px solid $white-normal; + background: $white-light; + margin-top: -5px; + } + + #binary-viewer { + img { + max-width: 100%; + } + } + + #sidebar { + + &.sidebar-mini { + display: inline-block; + vertical-align: top; + width: 20%; + border-right: 1px solid $white-normal; + height: calc(100vh + 20px); + overflow: auto; + } + + table { + margin-bottom: 0; + } + + tr { + animation: fadein 0.5s; + cursor: pointer; + + &.repo-file-options td { + padding: 0; + border-top: none; + background: $gray-light; + width: 100%; + display: inline-block; + + &:first-child { + border-top-left-radius: 2px; + } + + .title { + display: inline-block; + font-size: 10px; + text-transform: uppercase; + font-weight: bold; + color: $gray-darkest; + width: 185px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + padding: 2px 16px; + } + } + + .fa { + margin-right: 5px; + } + + td { + white-space: nowrap; + } + } + + a { + color: $almost-black; + display: inline-block; + vertical-align: middle; + } + + ul { + list-style-type: none; + padding: 0; + + li { + border-bottom: 1px solid $border-gray-normal; + padding: 10px 20px; + + a { + color: $almost-black; + } + + .fa { + font-size: $code_font_size; + margin-right: 5px; + } + } + } + } + +} + +.animation-container { + background: $repo-editor-grey; + height: 40px; + overflow: hidden; + position: relative; + + &.animation-container-small { + height: 12px; + } + + &::before { + animation-duration: 1s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: blockTextShine; + animation-timing-function: linear; + background-image: $repo-editor-linear-gradient; + background-repeat: no-repeat; + background-size: 800px 45px; + content: ' '; + display: block; + height: 100%; + position: relative; + } + + div { + background: $white-light; + height: 6px; + left: 0; + position: absolute; + right: 0; + } + + .line-of-code-1 { + left: 0; + top: 8px; + } + + .line-of-code-2 { + left: 150px; + top: 0; + height: 10px; + } + + .line-of-code-3 { + left: 0; + top: 23px; + } + + .line-of-code-4 { + left: 0; + top: 38px; + } + + .line-of-code-5 { + left: 200px; + top: 28px; + height: 10px; + } + + .line-of-code-6 { + top: 14px; + left: 230px; + height: 10px; + } +} + +.render-error { + min-height: calc(100vh - 63px); + + p { + width: 100%; + } +} + +@keyframes blockTextShine { + 0% { + transform: translateX(-468px); + } + + 100% { + transform: translateX(468px); + } +} + +@keyframes swipeRightAppear { + 0% { + transform: scaleX(0.00); + } + + 100% { + transform: scaleX(1.00); + } +} + +@keyframes swipeRightDissapear { + 0% { + transform: scaleX(1.00); + } + + 100% { + transform: scaleX(0.00); + } +} diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 44ab07a4367..11236cbf2e7 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -87,7 +87,7 @@ } .add-to-tree { - vertical-align: top; + vertical-align: middle; padding: 6px 10px; } @@ -216,6 +216,9 @@ } .blob-upload-dropzone-previews { + display: flex; + justify-content: center; + align-items: center; text-align: center; border: 2px; border-style: dashed; diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index 54dcd7c61ce..ba7adcfea86 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -1,7 +1,7 @@ module RendersBlob extend ActiveSupport::Concern - def render_blob_json(blob) + def blob_json(blob) viewer = case params[:viewer] when 'rich' @@ -11,13 +11,21 @@ module RendersBlob else blob.simple_viewer end - return render_404 unless viewer - render json: { + return unless viewer + + { html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_async: false) } end + def render_blob_json(blob) + json = blob_json(blob) + return render_404 unless json + + render json: json + end + def conditionally_expand_blob(blob) blob.expand! if params[:expanded] == 'true' end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 49ea2945675..a2e8c10857d 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -37,16 +37,11 @@ class Projects::BlobController < Projects::ApplicationController respond_to do |format| format.html do - environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } - @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last - - @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) - - render 'show' + show_html end format.json do - render_blob_json(@blob) + show_json end end end @@ -190,4 +185,34 @@ class Projects::BlobController < Projects::ApplicationController @last_commit_sha = Gitlab::Git::Commit .last_for_path(@repository, @ref, @path).sha end + + def show_html + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path) + + render 'show' + end + + def show_json + json = blob_json(@blob) + return render_404 unless json + + render json: json.merge( + path: blob.path, + name: blob.name, + extension: blob.extension, + size: blob.raw_size, + mime_type: blob.mime_type, + binary: blob.raw_binary?, + simple_viewer: blob.simple_viewer&.class&.partial_name, + rich_viewer: blob.rich_viewer&.class&.partial_name, + show_viewer_switcher: !!blob.show_viewer_switcher?, + render_error: blob.simple_viewer&.render_error || blob.rich_viewer&.render_error, + raw_path: project_raw_path(project, @id), + blame_path: project_blame_path(project, @id), + commits_path: project_commits_path(project, @id), + permalink: project_blob_path(project, File.join(@commit.id, @path)) + ) + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index d361e661d0e..4de814d0ca8 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -67,11 +67,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @noteable = @merge_request @commits_count = @merge_request.commits_count - if @merge_request.locked_long_ago? - @merge_request.unlock_mr - @merge_request.close - end - labels set_pipeline_variables diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 30181ac3bdf..1fc276b8c03 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -24,12 +24,19 @@ class Projects::TreeController < Projects::ApplicationController end end - @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit - respond_to do |format| - format.html - # Disable cache so browser history works - format.js { no_cache_headers } + format.html do + @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit + end + + format.js do + # Disable cache so browser history works + no_cache_headers + end + + format.json do + render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree) + end end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 0bffae6decd..8dfe0f51709 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -220,21 +220,34 @@ class ProjectsController < Projects::ApplicationController end def refs - branches = BranchesFinder.new(@repository, params).execute.map(&:name) + find_refs = params['find'] - options = { - s_('RefSwitcher|Branches') => branches.take(100) - } + find_branches = true + find_tags = true + find_commits = true + + unless find_refs.nil? + find_branches = find_refs.include?('branches') + find_tags = find_refs.include?('tags') + find_commits = find_refs.include?('commits') + end + + options = {} + + if find_branches + branches = BranchesFinder.new(@repository, params).execute.take(100).map(&:name) + options[s_('RefSwitcher|Branches')] = branches + end - unless @repository.tag_count.zero? - tags = TagsFinder.new(@repository, params).execute.map(&:name) + if find_tags && @repository.tag_count.nonzero? + tags = TagsFinder.new(@repository, params).execute.take(100).map(&:name) - options[s_('RefSwitcher|Tags')] = tags.take(100) + options[s_('RefSwitcher|Tags')] = tags end # If reference is commit id - we should add it to branch/tag selectbox ref = Addressable::URI.unescape(params[:ref]) - if ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/ + if find_commits && ref && options.flatten(2).exclude?(ref) && ref =~ /\A[0-9a-zA-Z]{6,52}\z/ options['Commits'] = [ref] end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0cfd7822d05..bcee81bdc15 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -309,4 +309,8 @@ module ApplicationHelper def collapsed_sidebar? cookies["sidebar_collapsed"] == "true" end + + def show_new_repo? + cookies["new_repo"] == "true" && body_data_page != 'projects:show' + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index e964d7a5e16..18075ee8be7 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -118,7 +118,7 @@ module BlobHelper icon("#{file_type_icon_class('file', mode, name)} fw") end - def blob_raw_url + def blob_raw_path if @build && @entry raw_project_job_artifacts_path(@project, @build, path: @entry.path) elsif @snippet @@ -235,7 +235,7 @@ module BlobHelper title = 'Open raw' end - link_to icon, blob_raw_url, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } + link_to icon, blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } end def blob_render_error_reason(viewer) @@ -270,7 +270,7 @@ module BlobHelper options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) end - options << link_to('download it', blob_raw_url, target: '_blank', rel: 'noopener noreferrer') + options << link_to('download it', blob_raw_path, target: '_blank', rel: 'noopener noreferrer') options end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 087f7f88fb5..28f591a4e22 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -88,15 +88,15 @@ module DiffHelper end def submodule_link(blob, ref, repository = @repository) - tree, commit = submodule_links(blob, ref, repository) - commit_id = if commit.nil? + project_url, tree_url = submodule_links(blob, ref, repository) + commit_id = if tree_url.nil? Commit.truncate_sha(blob.id) else - link_to Commit.truncate_sha(blob.id), commit + link_to Commit.truncate_sha(blob.id), tree_url end [ - content_tag(:span, link_to(truncate(blob.name, length: 40), tree)), + content_tag(:span, link_to(truncate(blob.name, length: 40), project_url)), '@', content_tag(:span, commit_id, class: 'commit-sha') ].join(' ').html_safe diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index ac8c518ac84..ff305fa39b4 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -48,11 +48,11 @@ module DropdownsHelper end end - def dropdown_title(title, back: false) + def dropdown_title(title, options: {}) content_tag :div, class: "dropdown-title" do title_output = "" - if back + if options.fetch(:back, false) title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-back", aria: { label: "Go back" }, type: "button") do icon('arrow-left') end @@ -60,14 +60,25 @@ module DropdownsHelper title_output << content_tag(:span, title) - title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do - icon('times', class: 'dropdown-menu-close-icon') + if options.fetch(:close, true) + title_output << content_tag(:button, class: "dropdown-title-button dropdown-menu-close", aria: { label: "Close" }, type: "button") do + icon('times', class: 'dropdown-menu-close-icon') + end end title_output.html_safe end end + def dropdown_input(placeholder, input_id: nil) + content_tag :div, class: "dropdown-input" do + filter_output = text_field_tag input_id, nil, class: "dropdown-input-field dropdown-no-filter", placeholder: placeholder, autocomplete: 'off' + filter_output << icon('times', class: "dropdown-input-clear js-dropdown-input-clear", role: "button") + + filter_output.html_safe + end + end + def dropdown_filter(placeholder, search_id: nil) content_tag :div, class: "dropdown-input" do filter_output = search_field_tag search_id, nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index f29faeca22d..9a404832423 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -1,4 +1,5 @@ module IconsHelper + extend self include FontAwesome::Rails::IconHelper # Creates an icon tag given icon name(s) and possible icon modifiers. diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index b24039fb349..88f7702db1e 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -1,5 +1,5 @@ module SubmoduleHelper - include Gitlab::ShellAdapter + extend self VALID_SUBMODULE_PROTOCOLS = %w[http https git ssh].freeze @@ -59,7 +59,7 @@ module SubmoduleHelper return true if url_no_dotgit == [Gitlab.config.gitlab.url, '/', namespace, '/', project].join('') url_with_dotgit = url_no_dotgit + '.git' - url_with_dotgit == gitlab_shell.url_to_repo([namespace, '/', project].join('')) + url_with_dotgit == Gitlab::Shell.new.url_to_repo([namespace, '/', project].join('')) end def relative_self_url?(url) diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index 35965d01692..bf3453b3063 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -82,7 +82,7 @@ module BlobViewer # format of the blob. # # Prefer to implement a client-side viewer, where the JS component loads the - # binary from `blob_raw_url` and does its own format validation and error + # binary from `blob_raw_path` and does its own format validation and error # rendering, especially for potentially large binary formats. def render_error if too_large? diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb index fbc1b520c01..86afcc86aa0 100644 --- a/app/models/blob_viewer/server_side.rb +++ b/app/models/blob_viewer/server_side.rb @@ -17,7 +17,7 @@ module BlobViewer # build artifacts, can only be rendered using a client-side viewer, # since we do not want to read large amounts of data into memory on the # server side. Client-side viewers use JS and can fetch the file from - # `blob_raw_url` using AJAX. + # `blob_raw_path` using AJAX. return :server_side_but_stored_externally if blob.stored_externally? super diff --git a/app/models/commit.rb b/app/models/commit.rb index 7940733f557..638fddc5d3d 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -55,7 +55,8 @@ class Commit end def from_hash(hash, project) - new(Gitlab::Git::Commit.new(hash), project) + raw_commit = Gitlab::Git::Commit.new(project.repository.raw, hash) + new(raw_commit, project) end def valid_hash?(key) @@ -320,21 +321,11 @@ class Commit end def raw_diffs(*args) - if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - Gitlab::GitalyClient::CommitService.new(project.repository).diff_from_parent(self, *args) - else - raw.diffs(*args) - end + raw.diffs(*args) end def raw_deltas - @deltas ||= Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled| - if is_enabled - Gitlab::GitalyClient::CommitService.new(project.repository).commit_deltas(self) - else - raw.deltas - end - end + @deltas ||= raw.deltas end def diffs(diff_options = nil) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 8ca850b6d96..e83b11f7668 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -8,6 +8,7 @@ class MergeRequest < ActiveRecord::Base include CreatedAtFilterable ignore_column :position + ignore_column :locked_at belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" @@ -61,16 +62,6 @@ class MergeRequest < ActiveRecord::Base transition locked: :opened end - after_transition any => :locked do |merge_request, transition| - merge_request.locked_at = Time.now - merge_request.save - end - - after_transition locked: (any - :locked) do |merge_request, transition| - merge_request.locked_at = nil - merge_request.save - end - state :opened state :closed state :merged @@ -392,6 +383,12 @@ class MergeRequest < ActiveRecord::Base 'Source project is not a fork of the target project' end + def merge_ongoing? + return false unless merge_jid + + Gitlab::SidekiqStatus.num_running([merge_jid]) > 0 + end + def closed_without_fork? closed? && source_project_missing? end @@ -725,12 +722,6 @@ class MergeRequest < ActiveRecord::Base end end - def locked_long_ago? - return false unless locked? - - locked_at.nil? || locked_at < (Time.now - 1.day) - end - def has_ci? has_ci_integration = source_project.try(:ci_service) uses_gitlab_ci = all_pipelines.any? diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index d9d746ccf41..58050e1f438 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -282,9 +282,7 @@ class MergeRequestDiff < ActiveRecord::Base def load_commits commits = st_commits.presence || merge_request_diff_commits - commits.map do |commit| - Commit.new(Gitlab::Git::Commit.new(commit.to_hash), merge_request.source_project) - end + commits.map { |commit| Commit.from_hash(commit.to_hash, project) } end def save_diffs diff --git a/app/models/repository.rb b/app/models/repository.rb index 3b5d0e00c70..ff82b958255 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -64,6 +64,8 @@ class Repository @raw_repository ||= initialize_raw_repository end + alias_method :raw, :raw_repository + # Return absolute path to repository def path_to_repo @path_to_repo ||= File.expand_path( @@ -763,7 +765,7 @@ class Repository index = Gitlab::Git::Index.new(raw_repository) if start_commit - index.read_tree(start_commit.raw_commit.tree) + index.read_tree(start_commit.rugged_commit.tree) parents = [start_commit.sha] else parents = [] diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb new file mode 100644 index 00000000000..56f173e5a27 --- /dev/null +++ b/app/serializers/blob_entity.rb @@ -0,0 +1,17 @@ +class BlobEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :name, :mode + + expose :last_commit do |blob| + request.project.repository.last_commit_for_path(blob.commit_id, blob.path) + end + + expose :icon do |blob| + IconsHelper.file_type_icon_class('file', blob.mode, blob.name) + end + + expose :url do |blob| + project_blob_path(request.project, File.join(request.ref, blob.path)) + end +end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index 7f17f2bf604..07650ce6f20 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -2,7 +2,6 @@ class MergeRequestEntity < IssuableEntity include RequestAwareEntity expose :in_progress_merge_commit_sha - expose :locked_at expose :merge_commit_sha expose :merge_error expose :merge_params @@ -32,6 +31,7 @@ class MergeRequestEntity < IssuableEntity expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline # Booleans + expose :merge_ongoing?, as: :merge_ongoing expose :work_in_progress?, as: :work_in_progress expose :source_branch_exists?, as: :source_branch_exists expose :mergeable_discussions_state?, as: :mergeable_discussions_state diff --git a/app/serializers/submodule_entity.rb b/app/serializers/submodule_entity.rb new file mode 100644 index 00000000000..9a7eb5e7880 --- /dev/null +++ b/app/serializers/submodule_entity.rb @@ -0,0 +1,23 @@ +class SubmoduleEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :name, :mode + + expose :icon do |blob| + 'archive' + end + + expose :project_url do |blob| + submodule_links(blob, request).first + end + + expose :tree_url do |blob| + submodule_links(blob, request).last + end + + private + + def submodule_links(blob, request) + @submodule_links ||= SubmoduleHelper.submodule_links(blob, request.ref, request.repository) + end +end diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb new file mode 100644 index 00000000000..555e5cf83bd --- /dev/null +++ b/app/serializers/tree_entity.rb @@ -0,0 +1,17 @@ +class TreeEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :name, :mode + + expose :last_commit do |tree| + request.project.repository.last_commit_for_path(tree.commit_id, tree.path) + end + + expose :icon do |tree| + IconsHelper.file_type_icon_class('folder', tree.mode, tree.name) + end + + expose :url do |tree| + project_tree_path(request.project, File.join(request.ref, tree.path)) + end +end diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb new file mode 100644 index 00000000000..23b65aa4a4c --- /dev/null +++ b/app/serializers/tree_root_entity.rb @@ -0,0 +1,8 @@ +# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`. +class TreeRootEntity < Grape::Entity + expose :path + + expose :trees, using: TreeEntity + expose :blobs, using: BlobEntity + expose :submodules, using: SubmoduleEntity +end diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb new file mode 100644 index 00000000000..713ade23bc9 --- /dev/null +++ b/app/serializers/tree_serializer.rb @@ -0,0 +1,3 @@ +class TreeSerializer < BaseSerializer + entity TreeRootEntity +end diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b32cfe158bb..df1dc736571 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -74,8 +74,7 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation") + = render 'shared/user_dropdown_experimental_features' %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml index 2c1c23d6ea9..fa94925d537 100644 --- a/app/views/layouts/header/_new.html.haml +++ b/app/views/layouts/header/_new.html.haml @@ -68,8 +68,7 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation") + = render 'shared/user_dropdown_experimental_features' %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index f08dcc0c242..9bd8bf91d1c 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -18,6 +18,8 @@ = scheme.name .col-sm-12 %hr + %h3#experimental-features Experimental features + %hr .col-lg-4.profile-settings-sidebar#new-navigation %h4.prepend-top-0 New Navigation @@ -40,6 +42,28 @@ New .col-sm-12 %hr + .col-lg-4.profile-settings-sidebar#new-repository + %h4.prepend-top-0 + New Repository + %p + This setting allows you to turn on or off the new upcoming repository concept. + .col-lg-8.syntax-theme + .nav-wip + %p + The new repository is currently a work-in-progress concept and only usable on wide-screens. There are a number of improvements that we are working on in order to further refine the repository view. + %p + %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/31890', target: 'blank' } Learn more + about the improvements that are coming soon! + = label_tag do + .preview= image_tag "old_repo.png" + %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_repo", checked: !show_new_repo? } + Old + = label_tag do + .preview= image_tag "new_repo.png" + %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_repo", checked: show_new_repo? } + New + .col-sm-12 + %hr .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Behavior diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 398d9306bed..3a7a99462a6 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -1,14 +1,13 @@ - commit = local_assigns.fetch(:commit) { @repository.commit } - ref = local_assigns.fetch(:ref) { current_ref } - project = local_assigns.fetch(:project) { @project } -.tree-holder.clearfix +- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) } + +#tree-holder.tree-holder.clearfix .nav-block = render 'projects/tree/tree_header', tree: @tree - - if commit - .info-well.hidden-xs.project-last-commit.append-bottom-default - .well-segment - %ul.blob-commit-info - = render 'projects/commits/commit', commit: commit, ref: ref, project: project + - if !show_new_repo? && commit + = render 'shared/commit_well', commit: commit, ref: ref, project: project - = render 'projects/tree/tree_content', tree: @tree + = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 8bd336269ff..849716a679b 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -9,5 +9,5 @@ #blob-content-holder.blob-content-holder %article.file-holder - = render "projects/blob/header", blob: blob + = render 'projects/blob/header', blob: blob = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index 32dbc1b3417..05b7dfe2872 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -19,7 +19,9 @@ = render 'shared/new_commit_form', placeholder: placeholder .form-actions - = button_tag button_title, class: 'btn btn-small btn-create btn-upload-file', id: 'submit-all' + = button_tag class: 'btn btn-create btn-upload-file', id: 'submit-all', type: 'button' do + = icon('spin spinner', class: 'js-loading-icon hidden' ) + = button_title = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" - unless can?(current_user, :push_code, @project) diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml index 013f1c267c8..cc85e5de40f 100644 --- a/app/views/projects/blob/_viewer.html.haml +++ b/app/views/projects/blob/_viewer.html.haml @@ -17,3 +17,4 @@ - viewer = BlobViewer::Download.new(viewer.blob) if viewer.binary_detected_after_load? = render viewer.partial_path, viewer: viewer + diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 278f4201bba..240e62d5ac5 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -5,16 +5,23 @@ = render "projects/commits/head" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('blob') + = webpack_bundle_tag 'blob' + + - if show_new_repo? + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'repo' = render 'projects/last_push' %div{ class: container_class } - .tree-holder - = render 'blob', blob: @blob + - if show_new_repo? + = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_blob_path(@project, @id) + - else + #tree-holder.tree-holder + = render 'blob', blob: @blob - - if can_modify_blob?(@blob) - = render 'projects/blob/remove' + - if can_modify_blob?(@blob) + = render 'projects/blob/remove' - - title = "Replace #{@blob.name}" - = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put + - title = "Replace #{@blob.name}" + = render 'projects/blob/upload', title: title, placeholder: title, button_title: 'Replace file', form_path: project_update_blob_path(@project, @id), method: :put diff --git a/app/views/projects/blob/viewers/_balsamiq.html.haml b/app/views/projects/blob/viewers/_balsamiq.html.haml index 28670e7de97..1e7c461f02e 100644 --- a/app/views/projects/blob/viewers/_balsamiq.html.haml +++ b/app/views/projects/blob/viewers/_balsamiq.html.haml @@ -1,4 +1,4 @@ - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('balsamiq_viewer') -.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_url } } +.file-content.balsamiq-viewer#js-balsamiq-viewer{ data: { endpoint: blob_raw_path } } diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml index 684240d02c7..6d1138f7959 100644 --- a/app/views/projects/blob/viewers/_download.html.haml +++ b/app/views/projects/blob/viewers/_download.html.haml @@ -1,6 +1,6 @@ .file-content.blob_file.blob-no-preview .center - = link_to blob_raw_url do + = link_to blob_raw_path do %h1.light = icon('download') %h4 diff --git a/app/views/projects/blob/viewers/_image.html.haml b/app/views/projects/blob/viewers/_image.html.haml index 5fd22a59217..26ea028c5d7 100644 --- a/app/views/projects/blob/viewers/_image.html.haml +++ b/app/views/projects/blob/viewers/_image.html.haml @@ -1,2 +1,2 @@ .file-content.image_file - = image_tag(blob_raw_url, alt: viewer.blob.name) + = image_tag(blob_raw_path, alt: viewer.blob.name) diff --git a/app/views/projects/blob/viewers/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml index 2399fb16265..8a41bc53004 100644 --- a/app/views/projects/blob/viewers/_notebook.html.haml +++ b/app/views/projects/blob/viewers/_notebook.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('notebook_viewer') -.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_url } } +.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path } } diff --git a/app/views/projects/blob/viewers/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml index 1dd179c4fdc..ec2b18bd4ab 100644 --- a/app/views/projects/blob/viewers/_pdf.html.haml +++ b/app/views/projects/blob/viewers/_pdf.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('pdf_viewer') -.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_url } } +.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_path } } diff --git a/app/views/projects/blob/viewers/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml index 49f716c2c59..775e4584f77 100644 --- a/app/views/projects/blob/viewers/_sketch.html.haml +++ b/app/views/projects/blob/viewers/_sketch.html.haml @@ -2,6 +2,6 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('sketch_viewer') -.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_url } } +.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_path } } .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } = icon('spinner spin 2x', 'aria-hidden' => 'true'); diff --git a/app/views/projects/blob/viewers/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml index e4e9d746176..6578d826ace 100644 --- a/app/views/projects/blob/viewers/_stl.html.haml +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -2,7 +2,7 @@ = page_specific_javascript_bundle_tag('stl_viewer') .file-content.is-stl-loading - .text-center#js-stl-viewer{ data: { endpoint: blob_raw_url } } + .text-center#js-stl-viewer{ data: { endpoint: blob_raw_path } } = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading') .text-center.prepend-top-default.append-bottom-default.stl-controls .btn-group diff --git a/app/views/projects/blob/viewers/_video.html.haml b/app/views/projects/blob/viewers/_video.html.haml index 595a890a27d..36039c08d52 100644 --- a/app/views/projects/blob/viewers/_video.html.haml +++ b/app/views/projects/blob/viewers/_video.html.haml @@ -1,2 +1,2 @@ .file-content.video - %video{ src: blob_raw_url, controls: true, data: { setup: '{}' } } + %video{ src: blob_raw_path, controls: true, data: { setup: '{}' } } diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml index 539ee087b14..64f5f6d7ba0 100644 --- a/app/views/projects/boards/components/_board.html.haml +++ b/app/views/projects/boards/components/_board.html.haml @@ -6,8 +6,16 @@ %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable", ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded && list.position === -1, \"fa-caret-left\": !list.isExpanded && list.position !== -1 }", "aria-hidden": "true" } - %span.has-tooltip{ ":title" => '(list.label ? list.label.description : "")', - data: { container: "body", placement: "bottom" } } + + %span.has-tooltip{ "v-if": "list.type !== \"label\"", + ":title" => '(list.label ? list.label.description : "")' } + {{ list.title }} + + %span.has-tooltip{ "v-if": "list.type === \"label\"", + ":title" => '(list.label ? list.label.description : "")', + data: { container: "body", placement: "bottom" }, + class: "label color-label title", + ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.color ? list.label.text_color : \"#2e2e2e\") }" } {{ list.title }} .issue-count-badge.pull-right.clearfix{ "v-if" => 'list.type !== "blank"' } %span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' } diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index 228c8c84792..9f5a1239a82 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -78,8 +78,8 @@ %script#projectChartData{ type: "application/json" } - projectChartData = {}; - - projectChartData['hour'] = { 'keys' => @commits_per_time.keys, 'values' => @commits_per_time.values } - - projectChartData['weekDays'] = { 'keys' => @commits_per_week_days.keys, 'values' => @commits_per_week_days.values } - - projectChartData['month'] = { 'keys' => @commits_per_month.keys, 'values' => @commits_per_month.values } + - projectChartData['hour'] = @commits_per_time + - projectChartData['weekDays'] = @commits_per_week_days + - projectChartData['month'] = @commits_per_month - projectChartData['languages'] = @languages = projectChartData.to_json.html_safe diff --git a/app/views/projects/tree/_old_tree_content.html.haml b/app/views/projects/tree/_old_tree_content.html.haml new file mode 100644 index 00000000000..820b947804e --- /dev/null +++ b/app/views/projects/tree/_old_tree_content.html.haml @@ -0,0 +1,24 @@ +.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } + .table-holder + %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } + %thead + %tr + %th= s_('ProjectFileTree|Name') + %th.hidden-xs + .pull-left= _('Last commit') + %th.text-right= _('Last Update') + - if @path.present? + %tr.tree-item + %td.tree-item-file-name + = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' + %td + %td.hidden-xs + + = render_tree(tree) + + - if tree.readme + = render "projects/tree/readme", readme: tree.readme + +- if can_edit_tree? + = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post + = render 'projects/blob/new_dir' diff --git a/app/views/projects/tree/_old_tree_header.html.haml b/app/views/projects/tree/_old_tree_header.html.haml new file mode 100644 index 00000000000..13705ca303b --- /dev/null +++ b/app/views/projects/tree/_old_tree_header.html.haml @@ -0,0 +1,70 @@ +%ul.breadcrumb.repo-breadcrumb + %li + = link_to project_tree_path(@project, @ref) do + = @project.path + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) + + - if current_user + %li + - if !on_top_of_branch? + %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } } + = icon('plus') + - else + %span.dropdown + %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown", "data-target" => ".add-to-tree-dropdown" } + = icon('plus') + .add-to-tree-dropdown + %ul.dropdown-menu + - if can_edit_tree? + %li + = link_to project_new_blob_path(@project, @id) do + = icon('pencil fw') + #{ _('New file') } + %li + = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do + = icon('file fw') + #{ _('Upload file') } + %li + = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do + = icon('folder fw') + #{ _('New directory') } + - elsif can?(current_user, :fork_project, @project) + %li + - continue_params = { to: project_new_blob_path(@project, @id), + notice: edit_in_new_fork_notice, + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + = icon('pencil fw') + #{ _('New file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to upload a file again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + = icon('file fw') + #{ _('Upload file') } + %li + - continue_params = { to: request.fullpath, + notice: edit_in_new_fork_notice + " Try to create a new directory again.", + notice_now: edit_in_new_fork_notice_now } + - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, + continue: continue_params) + = link_to fork_path, method: :post do + = icon('folder fw') + #{ _('New directory') } + + %li.divider + %li + = link_to new_project_branch_path(@project) do + = icon('code-fork fw') + #{ _('New branch') } + %li + = link_to new_project_tag_path(@project) do + = icon('tags fw') + #{ _('New tag') } diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml index 820b947804e..a4bdd67209d 100644 --- a/app/views/projects/tree/_tree_content.html.haml +++ b/app/views/projects/tree/_tree_content.html.haml @@ -1,24 +1,5 @@ -.tree-content-holder.js-tree-content{ 'data-logs-path': @logs_path } - .table-holder - %table.table#tree-slider{ class: "table_#{@hex_path} tree-table" } - %thead - %tr - %th= s_('ProjectFileTree|Name') - %th.hidden-xs - .pull-left= _('Last commit') - %th.text-right= _('Last Update') - - if @path.present? - %tr.tree-item - %td.tree-item-file-name - = link_to "..", project_tree_path(@project, up_dir_path), class: 'prepend-left-10' - %td - %td.hidden-xs - - = render_tree(tree) - - - if tree.readme - = render "projects/tree/readme", readme: tree.readme - -- if can_edit_tree? - = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post - = render 'projects/blob/new_dir' +- content_url = local_assigns.fetch(:content_url, nil) +- if show_new_repo? + = render 'shared/repo/repo', project: @project, content_url: content_url +- else + = render 'projects/tree/old_tree_content', tree: tree diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 858418ff8df..427b059cb82 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,81 +1,19 @@ .tree-ref-container .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path + - if show_new_repo? + = icon('long-arrow-right', title: 'to target branch') + = render 'shared/target_switcher', destination: 'tree', path: @path - %ul.breadcrumb.repo-breadcrumb - %li - = link_to project_tree_path(@project, @ref) do - = @project.path - - path_breadcrumbs do |title, path| - %li - = link_to truncate(title, length: 40), project_tree_path(@project, tree_join(@ref, path)) - - - if current_user - %li - - if !on_top_of_branch? - %span.btn.add-to-tree.disabled.has-tooltip{ title: _("You can only add files when you are on a branch"), data: { container: 'body' } } - = icon('plus') - - else - %span.dropdown - %a.dropdown-toggle.btn.add-to-tree{ href: '#', "data-toggle" => "dropdown", "data-target" => ".add-to-tree-dropdown" } - = icon('plus') - .add-to-tree-dropdown - %ul.dropdown-menu - - if can_edit_tree? - %li - = link_to project_new_blob_path(@project, @id) do - = icon('pencil fw') - #{ _('New file') } - %li - = link_to '#modal-upload-blob', { 'data-target' => '#modal-upload-blob', 'data-toggle' => 'modal' } do - = icon('file fw') - #{ _('Upload file') } - %li - = link_to '#modal-create-new-dir', { 'data-target' => '#modal-create-new-dir', 'data-toggle' => 'modal' } do - = icon('folder fw') - #{ _('New directory') } - - elsif can?(current_user, :fork_project, @project) - %li - - continue_params = { to: project_new_blob_path(@project, @id), - notice: edit_in_new_fork_notice, - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - = icon('pencil fw') - #{ _('New file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to upload a file again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - = icon('file fw') - #{ _('Upload file') } - %li - - continue_params = { to: request.fullpath, - notice: edit_in_new_fork_notice + " Try to create a new directory again.", - notice_now: edit_in_new_fork_notice_now } - - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, - continue: continue_params) - = link_to fork_path, method: :post do - = icon('folder fw') - #{ _('New directory') } - - %li.divider - %li - = link_to new_project_branch_path(@project) do - = icon('code-fork fw') - #{ _('New branch') } - %li - = link_to new_project_tag_path(@project) do - = icon('tags fw') - #{ _('New tag') } + - unless show_new_repo? + = render 'projects/tree/old_tree_header' .tree-controls - = render 'projects/find_file_link' + - if show_new_repo? + = render 'shared/repo/editable_mode' + - else + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + = render 'projects/find_file_link' = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index c8587245f88..375e6764add 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -5,8 +5,14 @@ - page_title @path.presence || _("Files"), @ref = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") + +- if show_new_repo? + - content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'repo' + = render "projects/commits/head" %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } = render 'projects/last_push' - = render 'projects/files', commit: @last_commit, project: @project, ref: @ref + = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/shared/_commit_well.html.haml b/app/views/shared/_commit_well.html.haml new file mode 100644 index 00000000000..50e3d80a84d --- /dev/null +++ b/app/views/shared/_commit_well.html.haml @@ -0,0 +1,4 @@ +.info-well.hidden-xs.project-last-commit.append-bottom-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: commit, ref: ref, project: project diff --git a/app/views/shared/_target_switcher.html.haml b/app/views/shared/_target_switcher.html.haml new file mode 100644 index 00000000000..3672b552f10 --- /dev/null +++ b/app/views/shared/_target_switcher.html.haml @@ -0,0 +1,20 @@ +- dropdown_toggle_text = @ref || @project.default_branch += form_tag nil, method: :get, class: "project-refs-target-form" do + = hidden_field_tag :destination, destination + - if defined?(path) + = hidden_field_tag :path, path + - @options && @options.each do |key, value| + = hidden_field_tag key, value, id: nil + .dropdown + = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_project_path(@project, find: ['branches']), field_name: 'ref', input_field_name: 'new-branch', submit_form_on_click: true, visit: false }, { toggle_class: "js-project-refs-dropdown" } + %ul.dropdown-menu.dropdown-menu-selectable.git-revision-dropdown{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) } + %li + = dropdown_title _("Create a new branch") + %li + = dropdown_input _("Create a new branch") + %li + = dropdown_title _("Select existing branch"), options: {close: false} + %li + = dropdown_filter _("Search branches and tags") + = dropdown_content + = dropdown_loading diff --git a/app/views/shared/_user_dropdown_experimental_features.html.haml b/app/views/shared/_user_dropdown_experimental_features.html.haml new file mode 100644 index 00000000000..8e71407b748 --- /dev/null +++ b/app/views/shared/_user_dropdown_experimental_features.html.haml @@ -0,0 +1 @@ +%li= link_to 'Experimental features', profile_preferences_path(anchor: 'experimental-features') diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml index bd66f39fa59..0a692d9653f 100644 --- a/app/views/shared/issuable/_label_page_create.html.haml +++ b/app/views/shared/issuable/_label_page_create.html.haml @@ -1,5 +1,5 @@ .dropdown-page-two.dropdown-new-label - = dropdown_title("Create new label", back: true) + = dropdown_title("Create new label", options: { back: true }) = dropdown_content do .dropdown-labels-error.js-label-error %input#new_label_name.default-dropdown-input{ type: "text", placeholder: "Name new label" } diff --git a/app/views/shared/repo/_editable_mode.html.haml b/app/views/shared/repo/_editable_mode.html.haml new file mode 100644 index 00000000000..73fdb8b523f --- /dev/null +++ b/app/views/shared/repo/_editable_mode.html.haml @@ -0,0 +1,2 @@ +.editable-mode + %repo-edit-button diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml new file mode 100644 index 00000000000..0fc40cf0801 --- /dev/null +++ b/app/views/shared/repo/_repo.html.haml @@ -0,0 +1,2 @@ +#repo{ data: { url: content_url, project_name: project.name, refs_url: refs_project_path(project, format: :json), project_url: project_path(project), project_id: project.id, can_commit: (!!can_push_branch?(project, @ref)).to_s } } + %repo diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 48e2da338f6..c3b58df92c1 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -7,6 +7,8 @@ class MergeWorker current_user = User.find(current_user_id) merge_request = MergeRequest.find(merge_request_id) + merge_request.update_column(:merge_jid, jid) + MergeRequests::MergeService.new(merge_request.target_project, current_user, params) .execute(merge_request) end diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb new file mode 100644 index 00000000000..7843179d77c --- /dev/null +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -0,0 +1,34 @@ +class StuckMergeJobsWorker + include Sidekiq::Worker + include CronjobQueue + + def perform + stuck_merge_requests.find_in_batches(batch_size: 100) do |group| + jids = group.map(&:merge_jid) + + # Find the jobs that aren't currently running or that exceeded the threshold. + completed_jids = Gitlab::SidekiqStatus.completed_jids(jids) + + if completed_jids.any? + completed_ids = group.select { |merge_request| completed_jids.include?(merge_request.merge_jid) }.map(&:id) + + apply_current_state!(completed_jids, completed_ids) + end + end + end + + private + + def apply_current_state!(completed_jids, completed_ids) + merge_requests = MergeRequest.where(id: completed_ids) + + merge_requests.where.not(merge_commit_sha: nil).update_all(state: :merged) + merge_requests.where(merge_commit_sha: nil).update_all(state: :opened) + + Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}") + end + + def stuck_merge_requests + MergeRequest.select('id, merge_jid').with_state(:locked).where.not(merge_jid: nil).reorder(nil) + end +end diff --git a/changelogs/unreleased/13265-project_events_noteable_iid.yml b/changelogs/unreleased/13265-project_events_noteable_iid.yml new file mode 100644 index 00000000000..54d538bb548 --- /dev/null +++ b/changelogs/unreleased/13265-project_events_noteable_iid.yml @@ -0,0 +1,4 @@ +--- +title: Expose noteable_iid in Note +merge_request: 13265 +author: sue445 diff --git a/changelogs/unreleased/31207-clean-locked-merge-requests.yml b/changelogs/unreleased/31207-clean-locked-merge-requests.yml new file mode 100644 index 00000000000..1f52987baef --- /dev/null +++ b/changelogs/unreleased/31207-clean-locked-merge-requests.yml @@ -0,0 +1,4 @@ +--- +title: Unlock stuck merge request and set the proper state +merge_request: 13207 +author: diff --git a/changelogs/unreleased/35052-please-select-a-file-when-attempting-to-upload-or-replace-from-the-ui.yml b/changelogs/unreleased/35052-please-select-a-file-when-attempting-to-upload-or-replace-from-the-ui.yml new file mode 100644 index 00000000000..5925da14f89 --- /dev/null +++ b/changelogs/unreleased/35052-please-select-a-file-when-attempting-to-upload-or-replace-from-the-ui.yml @@ -0,0 +1,4 @@ +--- +title: improve file upload/replace experience +merge_request: +author: diff --git a/changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml b/changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml new file mode 100644 index 00000000000..3cdb3011f5b --- /dev/null +++ b/changelogs/unreleased/35098-raise-encoding-confidence-threshold.yml @@ -0,0 +1,4 @@ +--- +title: Raise guessed encoding confidence threshold to 50 +merge_request: 12990 +author: diff --git a/changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml b/changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml new file mode 100644 index 00000000000..ea8f31cca9d --- /dev/null +++ b/changelogs/unreleased/35136-barchart-not-display-label-at-0-hour.yml @@ -0,0 +1,4 @@ +--- +title: Fix bar chart does not display label at 0 hour +merge_request: 35136 +author: Jason Dai diff --git a/changelogs/unreleased/rc-fix-branches-api-endpoint.yml b/changelogs/unreleased/rc-fix-branches-api-endpoint.yml index a8f49298258..b36663bbe91 100644 --- a/changelogs/unreleased/rc-fix-branches-api-endpoint.yml +++ b/changelogs/unreleased/rc-fix-branches-api-endpoint.yml @@ -1,5 +1,5 @@ --- title: Fix the /projects/:id/repository/branches endpoint to handle dots in the branch - name when the project full patch contains a `/` + name when the project full path contains a `/` merge_request: 13115 author: diff --git a/changelogs/unreleased/rc-fix-commits-api.yml b/changelogs/unreleased/rc-fix-commits-api.yml new file mode 100644 index 00000000000..215429eaf6b --- /dev/null +++ b/changelogs/unreleased/rc-fix-commits-api.yml @@ -0,0 +1,5 @@ +--- +title: Fix the /projects/:id/repository/commits endpoint to handle dots in the ref + name when the project full path contains a `/` +merge_request: 13370 +author: diff --git a/changelogs/unreleased/rc-fix-tags-api.yml b/changelogs/unreleased/rc-fix-tags-api.yml new file mode 100644 index 00000000000..0a7dd5ca6ab --- /dev/null +++ b/changelogs/unreleased/rc-fix-tags-api.yml @@ -0,0 +1,5 @@ +--- +title: Fix the /projects/:id/repository/tags endpoint to handle dots in the tag name + when the project full path contains a `/` +merge_request: 13368 +author: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index e24cf33adb5..2699173fc61 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -395,6 +395,10 @@ Settings.cron_jobs['remove_old_web_hook_logs_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['remove_old_web_hook_logs_worker']['cron'] ||= '40 0 * * *' Settings.cron_jobs['remove_old_web_hook_logs_worker']['job_class'] = 'RemoveOldWebHookLogsWorker' +Settings.cron_jobs['stuck_merge_jobs_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['stuck_merge_jobs_worker']['cron'] ||= '0 */2 * * *' +Settings.cron_jobs['stuck_merge_jobs_worker']['job_class'] = 'StuckMergeJobsWorker' + # # GitLab Shell # diff --git a/config/webpack.config.js b/config/webpack.config.js index d856806e5bd..8e1b80cd39f 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -4,6 +4,7 @@ var fs = require('fs'); var path = require('path'); var webpack = require('webpack'); var StatsWriterPlugin = require('webpack-stats-plugin').StatsWriterPlugin; +var CopyWebpackPlugin = require('copy-webpack-plugin'); var CompressionPlugin = require('compression-webpack-plugin'); var NameAllModulesPlugin = require('name-all-modules-plugin'); var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; @@ -65,6 +66,7 @@ var config = { prometheus_metrics: './prometheus_metrics', protected_branches: './protected_branches', protected_tags: './protected_tags', + repo: './repo/index.js', sidebar: './sidebar/sidebar_bundle.js', schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', schedules_index: './pipeline_schedules/pipeline_schedules_index_bundle.js', @@ -122,7 +124,16 @@ var config = { test: /locale\/\w+\/(.*)\.js$/, loader: 'exports-loader?locales', }, - ] + { + test: /monaco-editor\/\w+\/vs\/loader\.js$/, + use: [ + { loader: 'exports-loader', options: 'l.global' }, + { loader: 'imports-loader', options: 'l=>{},this=>l,AMDLoader=>this,module=>undefined' }, + ], + } + ], + + noParse: [/monaco-editor\/\w+\/vs\//], }, plugins: [ @@ -187,6 +198,7 @@ var config = { 'pdf_viewer', 'pipelines', 'pipelines_details', + 'repo', 'schedule_form', 'schedules_index', 'sidebar', @@ -210,6 +222,26 @@ var config = { new webpack.optimize.CommonsChunkPlugin({ names: ['main', 'locale', 'common', 'webpack_runtime'], }), + + // copy pre-compiled vendor libraries verbatim + new CopyWebpackPlugin([ + { + from: path.join(ROOT_PATH, `node_modules/monaco-editor/${IS_PRODUCTION ? 'min' : 'dev'}/vs`), + to: 'monaco-editor/vs', + transform: function(content, path) { + if (/\.js$/.test(path) && !/worker/i.test(path)) { + return ( + '(function(){\n' + + 'var define = this.define, require = this.require;\n' + + 'window.define = define; window.require = require;\n' + + content + + '\n}.call(window.__monaco_context__ || (window.__monaco_context__ = {})));' + ); + } + return content; + } + } + ]), ], resolve: { diff --git a/db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb b/db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb new file mode 100644 index 00000000000..a7d8f2f3604 --- /dev/null +++ b/db/migrate/20170731183033_add_merge_jid_to_merge_requests.rb @@ -0,0 +1,7 @@ +class AddMergeJidToMergeRequests < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :merge_requests, :merge_jid, :string + end +end diff --git a/db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb b/db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb new file mode 100644 index 00000000000..ea3d1fb3e02 --- /dev/null +++ b/db/post_migrate/20170807160457_remove_locked_at_column_from_merge_requests.rb @@ -0,0 +1,11 @@ +class RemoveLockedAtColumnFromMergeRequests < ActiveRecord::Migration + DOWNTIME = false + + def up + remove_column :merge_requests, :locked_at + end + + def down + add_column :merge_requests, :locked_at, :datetime_with_timezone + end +end diff --git a/db/schema.rb b/db/schema.rb index dc5640ad2ca..ed3cf70bcdd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170803130232) do +ActiveRecord::Schema.define(version: 20170807160457) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -850,7 +850,6 @@ ActiveRecord::Schema.define(version: 20170803130232) do t.integer "target_project_id", null: false t.integer "iid" t.text "description" - t.datetime "locked_at" t.integer "updated_by_id" t.text "merge_error" t.text "merge_params" @@ -868,6 +867,7 @@ ActiveRecord::Schema.define(version: 20170803130232) do t.integer "last_edited_by_id" t.integer "head_pipeline_id" t.boolean "ref_fetched" + t.string "merge_jid" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree diff --git a/doc/api/commits.md b/doc/api/commits.md index c91f9ecbdaf..2a78553782f 100644 --- a/doc/api/commits.md +++ b/doc/api/commits.md @@ -69,8 +69,9 @@ POST /projects/:id/repository/commits | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | -| `branch` | string | yes | The name of a branch | +| `branch` | string | yes | Name of the branch to commit into. To create a new branch, also provide `start_branch`. | | `commit_message` | string | yes | Commit message | +| `start_branch` | string | no | Name of the branch to start the new commit from | | `actions[]` | array | yes | An array of action hashes to commit as a batch. See the next table for what attributes it can take. | | `author_email` | string | no | Specify the commit author's email address | | `author_name` | string | no | Specify the commit author's name | diff --git a/doc/api/events.md b/doc/api/events.md index 6e530317f6c..3d5170f3f1e 100644 --- a/doc/api/events.md +++ b/doc/api/events.md @@ -338,6 +338,45 @@ Example response: "web_url":"https://gitlab.example.com/ted" }, "author_username":"ted" + }, + { + "title": null, + "project_id": 1, + "action_name": "commented on", + "target_id": 1312, + "target_iid": 1312, + "target_type": "Note", + "author_id": 1, + "data": null, + "target_title": null, + "created_at": "2015-12-04T10:33:58.089Z", + "note": { + "id": 1312, + "body": "What an awesome day!", + "attachment": null, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "created_at": "2015-12-04T10:33:56.698Z", + "system": false, + "noteable_id": 377, + "noteable_type": "Issue", + "noteable_iid": 377 + }, + "author": { + "name": "Dmitriy Zaporozhets", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://localhost:3000/uploads/user/avatar/1/fox_avatar.png", + "web_url": "http://localhost:3000/root" + }, + "author_username": "root" } ] ``` diff --git a/doc/api/group_milestones.md b/doc/api/group_milestones.md index 086fba7e91d..dbfc7529125 100644 --- a/doc/api/group_milestones.md +++ b/doc/api/group_milestones.md @@ -6,7 +6,7 @@ Returns a list of group milestones. ``` GET /groups/:id/milestones -GET /groups/:id/milestones?iids=42 +GET /groups/:id/milestones?iids[]=42 GET /groups/:id/milestones?iids[]=42&iids[]=43 GET /groups/:id/milestones?state=active GET /groups/:id/milestones?state=closed @@ -18,7 +18,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | -| `iids` | Array[integer] | optional | Return only the milestones having the given `iids` | +| `iids[]` | Array[integer] | optional | Return only the milestones having the given `iid` | | `state` | string | optional | Return only `active` or `closed` milestones` | | `search` | string | optional | Return only milestones with a title or description matching the provided string | diff --git a/doc/api/issues.md b/doc/api/issues.md index 6bac2927339..f30ed08d0fa 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -40,7 +40,7 @@ GET /issues?assignee_id=5 | `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ | | `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ | | `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `iids` | Array[integer] | no | Return only the issues having the given `iid` | +| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search issues against their `title` and `description` | @@ -132,7 +132,7 @@ GET /groups/:id/issues?assignee_id=5 | `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | | `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | -| `iids` | Array[integer] | no | Return only the issues having the given `iid` | +| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | | `milestone` | string | no | The milestone title | | `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | | `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | @@ -227,7 +227,7 @@ GET /projects/:id/issues?assignee_id=5 | Attribute | Type | Required | Description | |-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `iids` | Array[integer] | no | Return only the milestone having the given `iid` | +| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` | | `state` | string | no | Return all issues or just those that are `opened` or `closed` | | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | | `milestone` | string | no | The milestone title | diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index d0725b5e06e..802e5362d70 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -117,7 +117,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer | yes | The ID of a project | -| `iids` | Array[integer] | no | Return the request having the given `iid` | +| `iids[]` | Array[integer] | no | Return the request having the given `iid` | | `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`| | `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | diff --git a/doc/api/milestones.md b/doc/api/milestones.md index a082d548499..84930f0bdc9 100644 --- a/doc/api/milestones.md +++ b/doc/api/milestones.md @@ -6,7 +6,7 @@ Returns a list of project milestones. ``` GET /projects/:id/milestones -GET /projects/:id/milestones?iids=42 +GET /projects/:id/milestones?iids[]=42 GET /projects/:id/milestones?iids[]=42&iids[]=43 GET /projects/:id/milestones?state=active GET /projects/:id/milestones?state=closed @@ -18,7 +18,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `iids` | Array[integer] | optional | Return only the milestones having the given `iids` | +| `iids[]` | Array[integer] | optional | Return only the milestones having the given `iid` | | `state` | string | optional | Return only `active` or `closed` milestones` | | `search` | string | optional | Return only milestones with a title or description matching the provided string | diff --git a/doc/api/notes.md b/doc/api/notes.md index 388e6989df2..e627369e17b 100644 --- a/doc/api/notes.md +++ b/doc/api/notes.md @@ -35,7 +35,8 @@ Parameters: "updated_at": "2013-10-02T10:22:45Z", "system": true, "noteable_id": 377, - "noteable_type": "Issue" + "noteable_type": "Issue", + "noteable_iid": 377 }, { "id": 305, @@ -53,7 +54,8 @@ Parameters: "updated_at": "2013-10-02T09:56:03Z", "system": true, "noteable_id": 121, - "noteable_type": "Issue" + "noteable_type": "Issue", + "noteable_iid": 121 } ] ``` @@ -267,7 +269,8 @@ Parameters: "updated_at": "2013-10-02T08:57:14Z", "system": false, "noteable_id": 2, - "noteable_type": "MergeRequest" + "noteable_type": "MergeRequest", + "noteable_iid": 2 } ``` diff --git a/doc/api/repository_files.md b/doc/api/repository_files.md index 1fc577561a0..c517a38a8ba 100644 --- a/doc/api/repository_files.md +++ b/doc/api/repository_files.md @@ -76,7 +76,8 @@ Example response: Parameters: - `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb -- `branch` (required) - The name of branch +- `branch` (required) - Name of the branch +- `start_branch` (optional) - Name of the branch to start the new commit from - `encoding` (optional) - Change encoding to 'base64'. Default is text. - `author_email` (optional) - Specify the commit author's email address - `author_name` (optional) - Specify the commit author's name @@ -105,7 +106,8 @@ Example response: Parameters: - `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb -- `branch` (required) - The name of branch +- `branch` (required) - Name of the branch +- `start_branch` (optional) - Name of the branch to start the new commit from - `encoding` (optional) - Change encoding to 'base64'. Default is text. - `author_email` (optional) - Specify the commit author's email address - `author_name` (optional) - Specify the commit author's name @@ -144,7 +146,8 @@ Example response: Parameters: - `file_path` (required) - Url encoded full path to new file. Ex. lib%2Fclass%2Erb -- `branch` (required) - The name of branch +- `branch` (required) - Name of the branch +- `start_branch` (optional) - Name of the branch to start the new commit from - `author_email` (optional) - Specify the commit author's email address - `author_name` (optional) - Specify the commit author's name - `commit_message` (required) - Commit message diff --git a/doc/api/tags.md b/doc/api/tags.md index 54f092d1d30..32fe5eea692 100644 --- a/doc/api/tags.md +++ b/doc/api/tags.md @@ -18,17 +18,20 @@ Parameters: [ { "commit": { + "id": "2695effb5807a22ff3d138d593fd856244e155e7", + "short_id": "2695effb", + "title": "Initial commit", + "created_at": "2017-07-26T11:08:53.000+02:00", + "parent_ids": [ + "2a4b78934375d7f53875269ffd4f45fd83a84ebe" + ], + "message": "Initial commit", "author_name": "John Smith", "author_email": "john@example.com", "authored_date": "2012-05-28T04:42:42-07:00", - "committed_date": "2012-05-28T04:42:42-07:00", "committer_name": "Jack Smith", "committer_email": "jack@example.com", - "id": "2695effb5807a22ff3d138d593fd856244e155e7", - "message": "Initial commit", - "parent_ids": [ - "2a4b78934375d7f53875269ffd4f45fd83a84ebe" - ] + "committed_date": "2012-05-28T04:42:42-07:00" }, "release": { "tag_name": "1.0.0", @@ -68,16 +71,19 @@ Example Response: "message": null, "commit": { "id": "60a8ff033665e1207714d6670fcd7b65304ec02f", - "message": "v5.0.0\n", + "short_id": "60a8ff03", + "title": "Initial commit", + "created_at": "2017-07-26T11:08:53.000+02:00", "parent_ids": [ "f61c062ff8bcbdb00e0a1b3317a91aed6ceee06b" ], - "authored_date": "2015-02-01T21:56:31.000+01:00", + "message": "v5.0.0\n", "author_name": "Arthur Verschaeve", "author_email": "contact@arthurverschaeve.be", - "committed_date": "2015-02-01T21:56:31.000+01:00", + "authored_date": "2015-02-01T21:56:31.000+01:00", "committer_name": "Arthur Verschaeve", - "committer_email": "contact@arthurverschaeve.be" + "committer_email": "contact@arthurverschaeve.be", + "committed_date": "2015-02-01T21:56:31.000+01:00" }, "release": null } @@ -102,17 +108,20 @@ Parameters: ```json { "commit": { + "id": "2695effb5807a22ff3d138d593fd856244e155e7", + "short_id": "2695effb", + "title": "Initial commit", + "created_at": "2017-07-26T11:08:53.000+02:00", + "parent_ids": [ + "2a4b78934375d7f53875269ffd4f45fd83a84ebe" + ], + "message": "Initial commit", "author_name": "John Smith", "author_email": "john@example.com", "authored_date": "2012-05-28T04:42:42-07:00", - "committed_date": "2012-05-28T04:42:42-07:00", "committer_name": "Jack Smith", "committer_email": "jack@example.com", - "id": "2695effb5807a22ff3d138d593fd856244e155e7", - "message": "Initial commit", - "parent_ids": [ - "2a4b78934375d7f53875269ffd4f45fd83a84ebe" - ] + "committed_date": "2012-05-28T04:42:42-07:00" }, "release": { "tag_name": "1.0.0", diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index c03a2df9a72..47eb0b34f66 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -438,7 +438,6 @@ X-Gitlab-Event: Note Hook "iid": 1, "description": "Et voluptas corrupti assumenda temporibus. Architecto cum animi eveniet amet asperiores. Vitae numquam voluptate est natus sit et ad id.", "position": 0, - "locked_at": null, "source":{ "name":"Gitlab Test", "description":"Aut reprehenderit ut est.", diff --git a/lib/api/commits.rb b/lib/api/commits.rb index bcb842b9211..ea78737288a 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -4,13 +4,14 @@ module API class Commits < Grape::API include PaginationParams - before { authenticate! } + COMMIT_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(sha: API::NO_SLASH_URL_PART_REGEX) + before { authorize! :download_code, user_project } params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a project repository commits' do success Entities::RepoCommit end @@ -21,7 +22,7 @@ module API optional :path, type: String, desc: 'The file path' use :pagination end - get ":id/repository/commits" do + get ':id/repository/commits' do path = params[:path] before = params[:until] after = params[:since] @@ -53,16 +54,19 @@ module API detail 'This feature was introduced in GitLab 8.13' end params do - requires :branch, type: String, desc: 'The name of branch' + requires :branch, type: String, desc: 'Name of the branch to commit into. To create a new branch, also provide `start_branch`.' requires :commit_message, type: String, desc: 'Commit message' requires :actions, type: Array[Hash], desc: 'Actions to perform in commit' + optional :start_branch, type: String, desc: 'Name of the branch to start the new commit from' optional :author_email, type: String, desc: 'Author email for commit' optional :author_name, type: String, desc: 'Author name for commit' end - post ":id/repository/commits" do + post ':id/repository/commits' do authorize! :push_code, user_project - attrs = declared_params.merge(start_branch: declared_params[:branch], branch_name: declared_params[:branch]) + attrs = declared_params + attrs[:branch_name] = attrs.delete(:branch) + attrs[:start_branch] ||= attrs[:branch_name] result = ::Files::MultiService.new(user_project, current_user, attrs).execute @@ -76,42 +80,42 @@ module API desc 'Get a specific commit of a project' do success Entities::RepoCommitDetail - failure [[404, 'Not Found']] + failure [[404, 'Commit Not Found']] end params do requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag' end - get ":id/repository/commits/:sha" do + get ':id/repository/commits/:sha', requirements: COMMIT_ENDPOINT_REQUIREMENTS do commit = user_project.commit(params[:sha]) - not_found! "Commit" unless commit + not_found! 'Commit' unless commit present commit, with: Entities::RepoCommitDetail end desc 'Get the diff for a specific commit of a project' do - failure [[404, 'Not Found']] + failure [[404, 'Commit Not Found']] end params do requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag' end - get ":id/repository/commits/:sha/diff" do + get ':id/repository/commits/:sha/diff', requirements: COMMIT_ENDPOINT_REQUIREMENTS do commit = user_project.commit(params[:sha]) - not_found! "Commit" unless commit + not_found! 'Commit' unless commit commit.raw_diffs.to_a end desc "Get a commit's comments" do success Entities::CommitNote - failure [[404, 'Not Found']] + failure [[404, 'Commit Not Found']] end params do use :pagination requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag' end - get ':id/repository/commits/:sha/comments' do + get ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do commit = user_project.commit(params[:sha]) not_found! 'Commit' unless commit @@ -125,10 +129,10 @@ module API success Entities::RepoCommit end params do - requires :sha, type: String, desc: 'A commit sha to be cherry picked' + requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag to be cherry picked' requires :branch, type: String, desc: 'The name of the branch' end - post ':id/repository/commits/:sha/cherry_pick' do + post ':id/repository/commits/:sha/cherry_pick', requirements: COMMIT_ENDPOINT_REQUIREMENTS do authorize! :push_code, user_project commit = user_project.commit(params[:sha]) @@ -157,7 +161,7 @@ module API success Entities::CommitNote end params do - requires :sha, type: String, regexp: /\A\h{6,40}\z/, desc: "The commit's SHA" + requires :sha, type: String, desc: 'A commit sha, or the name of a branch or tag on which to post a comment' requires :note, type: String, desc: 'The text of the comment' optional :path, type: String, desc: 'The file path' given :path do @@ -165,7 +169,7 @@ module API requires :line_type, type: String, values: %w(new old), default: 'new', desc: 'The type of the line' end end - post ':id/repository/commits/:sha/comments' do + post ':id/repository/commits/:sha/comments', requirements: COMMIT_ENDPOINT_REQUIREMENTS do commit = user_project.commit(params[:sha]) not_found! 'Commit' unless commit diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 94b438db499..6ba4005dd0b 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -454,6 +454,9 @@ module API end class Note < Grape::Entity + # Only Issue and MergeRequest have iid + NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze + expose :id expose :note, as: :body expose :attachment_identifier, as: :attachment @@ -461,6 +464,9 @@ module API expose :created_at, :updated_at expose :system?, as: :system expose :noteable_id, :noteable_type + + # Avoid N+1 queries as much as possible + expose(:noteable_iid) { |note| note.noteable.iid if NOTEABLE_TYPES_WITH_IID.include?(note.noteable_type) } end class AwardEmoji < Grape::Entity @@ -699,7 +705,7 @@ module API class RepoTag < Grape::Entity expose :name, :message - expose :commit do |repo_tag, options| + expose :commit, using: Entities::RepoCommit do |repo_tag, options| options[:project].repository.commit(repo_tag.dereferenced_target) end diff --git a/lib/api/files.rb b/lib/api/files.rb index 521287ee2b4..450334fee84 100644 --- a/lib/api/files.rb +++ b/lib/api/files.rb @@ -4,7 +4,7 @@ module API def commit_params(attrs) { file_path: attrs[:file_path], - start_branch: attrs[:branch], + start_branch: attrs[:start_branch] || attrs[:branch], branch_name: attrs[:branch], commit_message: attrs[:commit_message], file_content: attrs[:content], @@ -37,8 +37,9 @@ module API params :simple_file_params do requires :file_path, type: String, desc: 'The url encoded path to the file. Ex. lib%2Fclass%2Erb' - requires :branch, type: String, desc: 'The name of branch' - requires :commit_message, type: String, desc: 'Commit Message' + requires :branch, type: String, desc: 'Name of the branch to commit into. To create a new branch, also provide `start_branch`.' + requires :commit_message, type: String, desc: 'Commit message' + optional :start_branch, type: String, desc: 'Name of the branch to start the new commit from' optional :author_email, type: String, desc: 'The email of the author' optional :author_name, type: String, desc: 'The name of the author' end diff --git a/lib/api/tags.rb b/lib/api/tags.rb index 633a858f8c7..1333747cced 100644 --- a/lib/api/tags.rb +++ b/lib/api/tags.rb @@ -2,19 +2,21 @@ module API class Tags < Grape::API include PaginationParams + TAG_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(tag_name: API::NO_SLASH_URL_PART_REGEX) + before { authorize! :download_code, user_project } params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a project repository tags' do success Entities::RepoTag end params do use :pagination end - get ":id/repository/tags" do + get ':id/repository/tags' do tags = ::Kaminari.paginate_array(user_project.repository.tags.sort_by(&:name).reverse) present paginate(tags), with: Entities::RepoTag, project: user_project end @@ -25,7 +27,7 @@ module API params do requires :tag_name, type: String, desc: 'The name of the tag' end - get ":id/repository/tags/:tag_name", requirements: { tag_name: /.+/ } do + get ':id/repository/tags/:tag_name', requirements: TAG_ENDPOINT_REQUIREMENTS do tag = user_project.repository.find_tag(params[:tag_name]) not_found!('Tag') unless tag @@ -60,7 +62,7 @@ module API params do requires :tag_name, type: String, desc: 'The name of the tag' end - delete ":id/repository/tags/:tag_name", requirements: { tag_name: /.+/ } do + delete ':id/repository/tags/:tag_name', requirements: TAG_ENDPOINT_REQUIREMENTS do authorize_push_project result = ::Tags::DestroyService.new(user_project, current_user) @@ -78,7 +80,7 @@ module API requires :tag_name, type: String, desc: 'The name of the tag' requires :description, type: String, desc: 'Release notes with markdown support' end - post ':id/repository/tags/:tag_name/release', requirements: { tag_name: /.+/ } do + post ':id/repository/tags/:tag_name/release', requirements: TAG_ENDPOINT_REQUIREMENTS do authorize_push_project result = CreateReleaseService.new(user_project, current_user) @@ -98,7 +100,7 @@ module API requires :tag_name, type: String, desc: 'The name of the tag' requires :description, type: String, desc: 'Release notes with markdown support' end - put ':id/repository/tags/:tag_name/release', requirements: { tag_name: /.+/ } do + put ':id/repository/tags/:tag_name/release', requirements: TAG_ENDPOINT_REQUIREMENTS do authorize_push_project result = UpdateReleaseService.new(user_project, current_user) diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb index 1611eba31da..d671867e7c7 100644 --- a/lib/gitlab/conflict/file_collection.rb +++ b/lib/gitlab/conflict/file_collection.rb @@ -77,8 +77,8 @@ EOM def initialize(merge_request, project) @merge_request = merge_request - @our_commit = merge_request.source_branch_head.raw.raw_commit - @their_commit = merge_request.target_branch_head.raw.raw_commit + @our_commit = merge_request.source_branch_head.raw.rugged_commit + @their_commit = merge_request.target_branch_head.raw.rugged_commit @project = project end end diff --git a/lib/gitlab/cycle_analytics/plan_event_fetcher.rb b/lib/gitlab/cycle_analytics/plan_event_fetcher.rb index b260822788d..2479b4a7706 100644 --- a/lib/gitlab/cycle_analytics/plan_event_fetcher.rb +++ b/lib/gitlab/cycle_analytics/plan_event_fetcher.rb @@ -54,7 +54,7 @@ module Gitlab end def serialize_commit(event, commit, query) - commit = Commit.new(Gitlab::Git::Commit.new(commit.to_hash), @project) + commit = Commit.from_hash(commit.to_hash, @project) AnalyticsCommitSerializer.new(project: @project, total_time: event['total_time']).represent(commit) end diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb index 781f9c56a42..8ddc91e341d 100644 --- a/lib/gitlab/encoding_helper.rb +++ b/lib/gitlab/encoding_helper.rb @@ -11,7 +11,7 @@ module Gitlab # obscure encoding with low confidence. # There is a lot more info with this merge request: # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193 - ENCODING_CONFIDENCE_THRESHOLD = 40 + ENCODING_CONFIDENCE_THRESHOLD = 50 def encode!(message) return nil unless message.respond_to? :force_encoding diff --git a/lib/gitlab/git/blame.rb b/lib/gitlab/git/blame.rb index 8dbe25e55f6..31effdba292 100644 --- a/lib/gitlab/git/blame.rb +++ b/lib/gitlab/git/blame.rb @@ -16,7 +16,7 @@ module Gitlab def each @blames.each do |blame| yield( - Gitlab::Git::Commit.new(blame.commit), + Gitlab::Git::Commit.new(@repo, blame.commit), blame.line ) end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 600d886e818..9256663f454 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -14,7 +14,7 @@ module Gitlab attr_accessor *SERIALIZE_KEYS # rubocop:disable Lint/AmbiguousOperator - delegate :tree, to: :raw_commit + delegate :tree, to: :rugged_commit def ==(other) return false unless other.is_a?(Gitlab::Git::Commit) @@ -50,19 +50,29 @@ module Gitlab # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/321 def find(repo, commit_id = "HEAD") + # Already a commit? return commit_id if commit_id.is_a?(Gitlab::Git::Commit) - return decorate(commit_id) if commit_id.is_a?(Rugged::Commit) - obj = if commit_id.is_a?(String) - repo.rev_parse_target(commit_id) - else - Gitlab::Git::Ref.dereference_object(commit_id) - end + # A rugged reference? + commit_id = Gitlab::Git::Ref.dereference_object(commit_id) + return decorate(repo, commit_id) if commit_id.is_a?(Rugged::Commit) - return nil unless obj.is_a?(Rugged::Commit) + # Some weird thing? + return nil unless commit_id.is_a?(String) - decorate(obj) - rescue Rugged::ReferenceError, Rugged::InvalidError, Rugged::ObjectError, Gitlab::Git::Repository::NoRepository + commit = repo.gitaly_migrate(:find_commit) do |is_enabled| + if is_enabled + repo.gitaly_commit_client.find_commit(commit_id) + else + obj = repo.rev_parse_target(commit_id) + + obj.is_a?(Rugged::Commit) ? obj : nil + end + end + + decorate(repo, commit) if commit + rescue Rugged::ReferenceError, Rugged::InvalidError, Rugged::ObjectError, + Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository nil end @@ -102,7 +112,7 @@ module Gitlab if is_enabled repo.gitaly_commit_client.between(base, head) else - repo.rugged_commits_between(base, head).map { |c| decorate(c) } + repo.rugged_commits_between(base, head).map { |c| decorate(repo, c) } end end rescue Rugged::ReferenceError @@ -169,7 +179,7 @@ module Gitlab offset = actual_options[:skip] limit = actual_options[:max_count] walker.each(offset: offset, limit: limit) do |commit| - commits.push(decorate(commit)) + commits.push(decorate(repo, commit)) end walker.reset @@ -183,27 +193,8 @@ module Gitlab Gitlab::GitalyClient::CommitService.new(repo).find_all_commits(options) end - def decorate(commit, ref = nil) - Gitlab::Git::Commit.new(commit, ref) - end - - # Returns a diff object for the changes introduced by +rugged_commit+. - # If +rugged_commit+ doesn't have a parent, then the diff is between - # this commit and an empty repo. See Repository#diff for the keys - # allowed in the +options+ hash. - def diff_from_parent(rugged_commit, options = {}) - options ||= {} - break_rewrites = options[:break_rewrites] - actual_options = Gitlab::Git::Diff.filter_diff_options(options) - - diff = if rugged_commit.parents.empty? - rugged_commit.diff(actual_options.merge(reverse: true)) - else - rugged_commit.parents[0].diff(rugged_commit, actual_options) - end - - diff.find_similar!(break_rewrites: break_rewrites) - diff + def decorate(repository, commit, ref = nil) + Gitlab::Git::Commit.new(repository, commit, ref) end # Returns the `Rugged` sorting type constant for one or more given @@ -221,7 +212,7 @@ module Gitlab end end - def initialize(raw_commit, head = nil) + def initialize(repository, raw_commit, head = nil) raise "Nil as raw commit passed" unless raw_commit case raw_commit @@ -229,12 +220,13 @@ module Gitlab init_from_hash(raw_commit) when Rugged::Commit init_from_rugged(raw_commit) - when Gitlab::GitalyClient::Commit + when Gitaly::GitCommit init_from_gitaly(raw_commit) else raise "Invalid raw commit type: #{raw_commit.class}" end + @repository = repository @head = head end @@ -269,19 +261,50 @@ module Gitlab # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/324 def to_diff - diff_from_parent.patch + rugged_diff_from_parent.patch end # Returns a diff object for the changes from this commit's first parent. # If there is no parent, then the diff is between this commit and an - # empty repo. See Repository#diff for keys allowed in the +options+ + # empty repo. See Repository#diff for keys allowed in the +options+ # hash. def diff_from_parent(options = {}) - Commit.diff_from_parent(raw_commit, options) + Gitlab::GitalyClient.migrate(:commit_raw_diffs) do |is_enabled| + if is_enabled + @repository.gitaly_commit_client.diff_from_parent(self, options) + else + rugged_diff_from_parent(options) + end + end + end + + def rugged_diff_from_parent(options = {}) + options ||= {} + break_rewrites = options[:break_rewrites] + actual_options = Gitlab::Git::Diff.filter_diff_options(options) + + diff = if rugged_commit.parents.empty? + rugged_commit.diff(actual_options.merge(reverse: true)) + else + rugged_commit.parents[0].diff(rugged_commit, actual_options) + end + + diff.find_similar!(break_rewrites: break_rewrites) + diff end def deltas - @deltas ||= diff_from_parent.each_delta.map { |d| Gitlab::Git::Diff.new(d) } + @deltas ||= begin + deltas = Gitlab::GitalyClient.migrate(:commit_deltas) do |is_enabled| + if is_enabled + @repository.gitaly_commit_client.commit_deltas(self) + else + rugged_diff_from_parent.each_delta + end + end + + deltas.map { |delta| Gitlab::Git::Diff.new(delta) } + end end def has_zero_stats? @@ -309,14 +332,7 @@ module Gitlab end def parents - case raw_commit - when Rugged::Commit - raw_commit.parents.map { |c| Gitlab::Git::Commit.new(c) } - when Gitlab::GitalyClient::Commit - parent_ids.map { |oid| self.class.find(raw_commit.repository, oid) }.compact - else - raise NotImplementedError, "commit source doesn't support #parents" - end + parent_ids.map { |oid| self.class.find(@repository, oid) }.compact end # Get the gpg signature of this commit. @@ -334,7 +350,7 @@ module Gitlab def to_patch(options = {}) begin - raw_commit.to_mbox(options) + rugged_commit.to_mbox(options) rescue Rugged::InvalidError => ex if ex.message =~ /commit \w+ is a merge commit/i 'Patch format is not currently supported for merge commits.' @@ -382,6 +398,14 @@ module Gitlab encode! @committer_email end + def rugged_commit + @rugged_commit ||= if raw_commit.is_a?(Rugged::Commit) + raw_commit + else + @repository.rev_parse_target(id) + end + end + private def init_from_hash(hash) @@ -415,10 +439,10 @@ module Gitlab # subject from the message to make it clearer when there's one # available but not the other. @message = (commit.body.presence || commit.subject).dup - @authored_date = Time.at(commit.author.date.seconds) + @authored_date = Time.at(commit.author.date.seconds).utc @author_name = commit.author.name.dup @author_email = commit.author.email.dup - @committed_date = Time.at(commit.committer.date.seconds) + @committed_date = Time.at(commit.committer.date.seconds).utc @committer_name = commit.committer.name.dup @committer_email = commit.committer.email.dup @parent_ids = commit.parent_ids diff --git a/lib/gitlab/git/commit_stats.rb b/lib/gitlab/git/commit_stats.rb index 57c29ad112c..00acb4763e9 100644 --- a/lib/gitlab/git/commit_stats.rb +++ b/lib/gitlab/git/commit_stats.rb @@ -16,7 +16,7 @@ module Gitlab @deletions = 0 @total = 0 - diff = commit.diff_from_parent + diff = commit.rugged_diff_from_parent diff.each_patch do |p| # TODO: Use the new Rugged convenience methods when they're released diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index 87ed9c3ea26..6a601561c2a 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -28,7 +28,6 @@ module Gitlab @limits = self.class.collection_limits(options) @enforce_limits = !!options.fetch(:limits, true) @expanded = !!options.fetch(:expanded, true) - @from_gitaly = options.fetch(:from_gitaly, false) @line_count = 0 @byte_count = 0 @@ -44,7 +43,7 @@ module Gitlab return if @iterator.nil? Gitlab::GitalyClient.migrate(:commit_raw_diffs) do |is_enabled| - if is_enabled && @from_gitaly + if is_enabled && @iterator.is_a?(Gitlab::GitalyClient::DiffStitcher) each_gitaly_patch(&block) else each_rugged_patch(&block) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index f246393cfbc..371f8797ff2 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -321,7 +321,7 @@ module Gitlab options[:limit] ||= 0 options[:offset] ||= 0 - raw_log(options).map { |c| Commit.decorate(c) } + raw_log(options).map { |c| Commit.decorate(self, c) } end def count_commits(options) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index c90ef282fdd..70177cd0fec 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -100,5 +100,9 @@ module Gitlab path = Rails.root.join(SERVER_VERSION_FILE) path.read.chomp end + + def self.encode(s) + s.dup.force_encoding(Encoding::ASCII_8BIT) + end end end diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb deleted file mode 100644 index 61fe462d762..00000000000 --- a/lib/gitlab/gitaly_client/commit.rb +++ /dev/null @@ -1,14 +0,0 @@ -module Gitlab - module GitalyClient - class Commit - attr_reader :repository, :gitaly_commit - - delegate :id, :subject, :body, :author, :committer, :parent_ids, to: :gitaly_commit - - def initialize(repository, gitaly_commit) - @repository = repository - @gitaly_commit = gitaly_commit - end - end - end -end diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 3f577ac8530..692d7e02eef 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -29,22 +29,21 @@ module Gitlab request = Gitaly::CommitDiffRequest.new(request_params) response = GitalyClient.call(@repository.storage, :diff_service, :commit_diff, request) - Gitlab::Git::DiffCollection.new(GitalyClient::DiffStitcher.new(response), options.merge(from_gitaly: true)) + GitalyClient::DiffStitcher.new(response) end def commit_deltas(commit) request = Gitaly::CommitDeltaRequest.new(commit_diff_request_params(commit)) response = GitalyClient.call(@repository.storage, :diff_service, :commit_delta, request) - response.flat_map do |msg| - msg.deltas.map { |d| Gitlab::Git::Diff.new(d) } - end + + response.flat_map { |msg| msg.deltas } end def tree_entry(ref, path, limit = nil) request = Gitaly::TreeEntryRequest.new( repository: @gitaly_repo, revision: ref, - path: path.dup.force_encoding(Encoding::ASCII_8BIT), + path: GitalyClient.encode(path), limit: limit.to_i ) @@ -100,15 +99,14 @@ module Gitlab def last_commit_for_path(revision, path) request = Gitaly::LastCommitForPathRequest.new( repository: @gitaly_repo, - revision: revision.force_encoding(Encoding::ASCII_8BIT), - path: path.to_s.force_encoding(Encoding::ASCII_8BIT) + revision: GitalyClient.encode(revision), + path: GitalyClient.encode(path.to_s) ) gitaly_commit = GitalyClient.call(@repository.storage, :commit_service, :last_commit_for_path, request).commit return unless gitaly_commit - commit = GitalyClient::Commit.new(@repository, gitaly_commit) - Gitlab::Git::Commit.new(commit) + Gitlab::Git::Commit.new(@repository, gitaly_commit) end def between(from, to) @@ -167,10 +165,21 @@ module Gitlab response.reduce("") { |memo, msg| memo << msg.data } end + def find_commit(revision) + request = Gitaly::FindCommitRequest.new( + repository: @gitaly_repo, + revision: GitalyClient.encode(revision) + ) + + response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request) + + response.commit + end + private def commit_diff_request_params(commit, options = {}) - parent_id = commit.parents[0]&.id || EMPTY_TREE_ID + parent_id = commit.parent_ids.first || EMPTY_TREE_ID { repository: @gitaly_repo, @@ -183,8 +192,7 @@ module Gitlab def consume_commits_response(response) response.flat_map do |message| message.commits.map do |gitaly_commit| - commit = GitalyClient::Commit.new(@repository, gitaly_commit) - Gitlab::Git::Commit.new(commit) + Gitlab::Git::Commit.new(@repository, gitaly_commit) end end end diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb index b0f7548b7dc..919fb68b8c7 100644 --- a/lib/gitlab/gitaly_client/ref_service.rb +++ b/lib/gitlab/gitaly_client/ref_service.rb @@ -16,8 +16,7 @@ module Gitlab response.flat_map do |message| message.branches.map do |branch| - gitaly_commit = GitalyClient::Commit.new(@repository, branch.target) - target_commit = Gitlab::Git::Commit.decorate(gitaly_commit) + target_commit = Gitlab::Git::Commit.decorate(@repository, branch.target) Gitlab::Git::Branch.new(@repository, branch.name, branch.target.id, target_commit) end end @@ -102,8 +101,7 @@ module Gitlab response.flat_map do |message| message.tags.map do |gitaly_tag| if gitaly_tag.target_commit.present? - commit = GitalyClient::Commit.new(@repository, gitaly_tag.target_commit) - gitaly_commit = Gitlab::Git::Commit.new(commit) + gitaly_commit = Gitlab::Git::Commit.decorate(@repository, gitaly_tag.target_commit) end Gitlab::Git::Tag.new( @@ -141,7 +139,7 @@ module Gitlab committer_email: response.commit_committer.email.dup } - Gitlab::Git::Commit.decorate(hash) + Gitlab::Git::Commit.decorate(@repository, hash) end end end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index c8ad3a7a5e0..c5c05bfe2fb 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -101,6 +101,7 @@ excluded_attributes: merge_requests: - :milestone_id - :ref_fetched + - :merge_jid award_emoji: - :awardable_id statuses: diff --git a/lib/haml_lint/inline_javascript.rb b/lib/haml_lint/inline_javascript.rb index f3ddcbb9c95..05668c69006 100644 --- a/lib/haml_lint/inline_javascript.rb +++ b/lib/haml_lint/inline_javascript.rb @@ -1,14 +1,16 @@ -require 'haml_lint/haml_visitor' -require 'haml_lint/linter' -require 'haml_lint/linter_registry' +unless Rails.env.production? + require 'haml_lint/haml_visitor' + require 'haml_lint/linter' + require 'haml_lint/linter_registry' -module HamlLint - class Linter::InlineJavaScript < Linter - include LinterRegistry + module HamlLint + class Linter::InlineJavaScript < Linter + include LinterRegistry - def visit_filter(node) - return unless node.filter_type == 'javascript' - record_lint(node, 'Inline JavaScript is discouraged (https://docs.gitlab.com/ee/development/gotchas.html#do-not-use-inline-javascript-in-views)') + def visit_filter(node) + return unless node.filter_type == 'javascript' + record_lint(node, 'Inline JavaScript is discouraged (https://docs.gitlab.com/ee/development/gotchas.html#do-not-use-inline-javascript-in-views)') + end end end end diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake index 7d35e0df53a..aaf00bd703a 100644 --- a/lib/tasks/gitlab/gitaly.rake +++ b/lib/tasks/gitlab/gitaly.rake @@ -21,7 +21,7 @@ namespace :gitlab do create_gitaly_configuration # In CI we run scripts/gitaly-test-build instead of this command unless ENV['CI'].present? - Bundler.with_original_env { run_command!([command]) } + Bundler.with_original_env { run_command!(%w[/usr/bin/env -u RUBYOPT] + [command]) } end end end diff --git a/locale/it/gitlab.po b/locale/it/gitlab.po index d4fac6ab34e..7ba23d84405 100644 --- a/locale/it/gitlab.po +++ b/locale/it/gitlab.po @@ -4,13 +4,13 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-06-28 13:32+0200\n" +"POT-Creation-Date: 2017-07-13 12:07-0500\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2017-07-12 05:45-0400\n" -"Last-Translator: Paolo Falomo <info@paolofalomo.it>\n" "Language-Team: Italian (https://translate.zanata.org/project/view/GitLab)\n" +"PO-Revision-Date: 2017-08-07 10:15-0400\n" +"Last-Translator: Paolo Falomo <info@paolofalomo.it>\n" "Language: it\n" "X-Generator: Zanata 3.9.6\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" @@ -647,6 +647,12 @@ msgstr "Tutto" msgid "PipelineSchedules|Inactive" msgstr "Inattiva" +msgid "PipelineSchedules|Input variable key" +msgstr "Chiave della variabile" + +msgid "PipelineSchedules|Input variable value" +msgstr "Valore della variabile" + msgid "PipelineSchedules|Next Run" msgstr "Prossima esecuzione" @@ -656,12 +662,18 @@ msgstr "Nessuna" msgid "PipelineSchedules|Provide a short description for this pipeline" msgstr "Fornisci una breve descrizione per questa pipeline" +msgid "PipelineSchedules|Remove variable row" +msgstr "Rimuovi riga della variabile" + msgid "PipelineSchedules|Take ownership" msgstr "Prendi possesso" msgid "PipelineSchedules|Target" msgstr "Target" +msgid "PipelineSchedules|Variables" +msgstr "Variabili" + msgid "PipelineSheduleIntervalPattern|Custom" msgstr "Personalizzato" @@ -1144,6 +1156,9 @@ msgstr "Privato" msgid "VisibilityLevel|Public" msgstr "Pubblico" +msgid "VisibilityLevel|Unknown" +msgstr "Sconosciuto" + msgid "Want to see the data? Please ask an administrator for access." msgstr "" "Vuoi visualizzare i dati? Richiedi l'accesso ad un amministratore, grazie." @@ -1155,6 +1170,15 @@ msgid "Withdraw Access Request" msgstr "Ritira richiesta d'accesso" msgid "" +"You are going to remove %{group_name}.\n" +"Removed groups CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" +"Stai per rimuovere il gruppo %{group_name}.\n" +"I gruppi rimossi NON possono esser ripristinati!\n" +"Sei ASSOLUTAMENTE sicuro?" + +msgid "" "You are going to remove %{project_name_with_namespace}.\n" "Removed project CANNOT be restored!\n" "Are you ABSOLUTELY sure?" diff --git a/package.json b/package.json index b50efee72ff..c5247a63e67 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "webpack-prod": "NODE_ENV=production webpack --config config/webpack.config.js" }, "dependencies": { + "axios": "^0.16.2", "babel-core": "^6.22.1", "babel-eslint": "^7.2.1", "babel-loader": "^7.1.1", @@ -20,6 +21,7 @@ "babel-preset-stage-2": "^6.22.0", "bootstrap-sass": "^3.3.6", "compression-webpack-plugin": "^0.3.2", + "copy-webpack-plugin": "^4.0.1", "core-js": "^2.4.1", "cropper": "^2.3.0", "css-loader": "^0.28.0", @@ -31,6 +33,7 @@ "eslint-plugin-html": "^2.0.1", "exports-loader": "^0.6.4", "file-loader": "^0.11.1", + "imports-loader": "^0.7.1", "jed": "^1.1.1", "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", @@ -38,6 +41,7 @@ "jszip": "^3.1.3", "jszip-utils": "^0.0.2", "marked": "^0.3.6", + "monaco-editor": "0.8.3", "mousetrap": "^1.4.6", "name-all-modules-plugin": "^1.0.1", "pikaday": "^1.5.1", diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 59f33197e8f..64b9af7b845 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -35,6 +35,26 @@ describe Projects::BlobController do end end + context 'with file path and JSON format' do + context "valid branch, valid file" do + let(:id) { 'master/README.md' } + + before do + get(:show, + namespace_id: project.namespace, + project_id: project, + id: id, + format: :json) + end + + it do + expect(response).to be_ok + expect(json_response).to have_key 'html' + expect(json_response).to have_key 'raw_path' + end + end + end + context 'with tree path' do before do get(:show, diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index c51b81c1cff..ce458431c55 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -233,7 +233,7 @@ describe 'Issue Boards', js: true do wait_for_board_cards(4, 1) expect(find('.board:nth-child(3)')).to have_content(issue6.title) - expect(find('.board:nth-child(3)').all('.card').last).not_to have_content(development.title) + expect(find('.board:nth-child(3)').all('.card').last).to have_content(development.title) end it 'issue moves between lists' do @@ -244,7 +244,7 @@ describe 'Issue Boards', js: true do wait_for_board_cards(4, 1) expect(find('.board:nth-child(2)')).to have_content(issue7.title) - expect(find('.board:nth-child(2)').all('.card').first).not_to have_content(planning.title) + expect(find('.board:nth-child(2)').all('.card').first).to have_content(planning.title) end it 'issue moves from closed' do diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 373cd92793e..8d3d4ff8773 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -257,7 +257,7 @@ describe 'Issue Boards', js: true do end end - expect(card).to have_selector('.label', count: 2) + expect(card).to have_selector('.label', count: 3) expect(card).to have_content(bug.title) end @@ -283,7 +283,7 @@ describe 'Issue Boards', js: true do end end - expect(card).to have_selector('.label', count: 3) + expect(card).to have_selector('.label', count: 4) expect(card).to have_content(bug.title) expect(card).to have_content(regression.title) end @@ -308,7 +308,7 @@ describe 'Issue Boards', js: true do end end - expect(card).not_to have_selector('.label') + expect(card).to have_selector('.label', count: 1) expect(card).not_to have_content(stretch.title) end end diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index 69e31c7481f..fd991293ee9 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -219,4 +219,17 @@ describe 'Merge request', :js do expect(page).to have_field('remove-source-branch-input', disabled: true) end end + + context 'ongoing merge process' do + it 'shows Merging state' do + allow_any_instance_of(MergeRequest).to receive(:merge_ongoing?).and_return(true) + + visit project_merge_request_path(project, merge_request) + + wait_for_requests + + expect(page).not_to have_button('Merge') + expect(page).to have_content('This merge request is in the process of being merged') + end + end end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 7e4d53332e5..d3d7915bebf 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -167,6 +167,21 @@ feature 'Project' do end end + describe 'activity view' do + let(:user) { create(:user, project_view: 'activity') } + let(:project) { create(:project, :repository) } + + before do + project.team << [user, :master] + sign_in user + visit project_path(project) + end + + it 'loads activity', :js do + expect(page).to have_selector('.event-item') + end + end + def remove_with_confirm(button_text, confirm_with) click_button button_text fill_in 'confirm_name_input', with: confirm_with diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json index 7ffa82fc4bd..2f12b671dec 100644 --- a/spec/fixtures/api/schemas/entities/merge_request.json +++ b/spec/fixtures/api/schemas/entities/merge_request.json @@ -19,7 +19,6 @@ "human_time_estimate": { "type": ["integer", "null"] }, "human_total_time_spent": { "type": ["integer", "null"] }, "in_progress_merge_commit_sha": { "type": ["string", "null"] }, - "locked_at": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, "merge_commit_sha": { "type": ["string", "null"] }, "merge_params": { "type": ["object", "null"] }, @@ -94,7 +93,8 @@ "commit_change_content_path": { "type": "string" }, "remove_wip_path": { "type": "string" }, "commits_count": { "type": "integer" }, - "remove_source_branch": { "type": ["boolean", "null"] } + "remove_source_branch": { "type": ["boolean", "null"] }, + "merge_ongoing": { "type": "boolean" } }, "additionalProperties": false } diff --git a/spec/fixtures/api/schemas/public_api/v4/comment.json b/spec/fixtures/api/schemas/public_api/v4/comment.json new file mode 100644 index 00000000000..52cfe86aeeb --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/comment.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required" : [ + "name", + "message", + "commit", + "release" + ], + "properties" : { + "name": { "type": "string" }, + "message": { "type": ["string", "null"] }, + "commit": { "$ref": "commit/basic.json" }, + "release": { + "oneOf": [ + { "type": "null" }, + { "$ref": "release.json" } + ] + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/commit/detail.json b/spec/fixtures/api/schemas/public_api/v4/commit/detail.json new file mode 100644 index 00000000000..b7b2535c204 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/commit/detail.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "allOf": [ + { "$ref": "basic.json" }, + { + "required" : [ + "stats", + "status" + ], + "properties": { + "stats": { "$ref": "../commit_stats.json" }, + "status": { "type": ["string", "null"] } + } + } + ] +} diff --git a/spec/fixtures/api/schemas/public_api/v4/commit_note.json b/spec/fixtures/api/schemas/public_api/v4/commit_note.json new file mode 100644 index 00000000000..02081989271 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/commit_note.json @@ -0,0 +1,19 @@ +{ + "type": "object", + "required" : [ + "note", + "path", + "line", + "line_type", + "author", + "created_at" + ], + "properties" : { + "note": { "type": ["string", "null"] }, + "path": { "type": ["string", "null"] }, + "line": { "type": ["integer", "null"] }, + "line_type": { "type": ["string", "null"] }, + "author": { "$ref": "user/basic.json" }, + "created_at": { "type": "date" } + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/commit_notes.json b/spec/fixtures/api/schemas/public_api/v4/commit_notes.json new file mode 100644 index 00000000000..d65a7d677ea --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/commit_notes.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "commit_note.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/commit_stats.json b/spec/fixtures/api/schemas/public_api/v4/commit_stats.json new file mode 100644 index 00000000000..779384c62e6 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/commit_stats.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "required" : [ + "additions", + "deletions", + "total" + ], + "properties" : { + "additions": { "type": "integer" }, + "deletions": { "type": "integer" }, + "total": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/commits.json b/spec/fixtures/api/schemas/public_api/v4/commits.json new file mode 100644 index 00000000000..98b17a96071 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/commits.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "commit/basic.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/release.json b/spec/fixtures/api/schemas/public_api/v4/release.json new file mode 100644 index 00000000000..6612c2a9911 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/release.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required" : [ + "tag_name", + "description" + ], + "properties" : { + "tag_name": { "type": ["string", "null"] }, + "description": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/tag.json b/spec/fixtures/api/schemas/public_api/v4/tag.json new file mode 100644 index 00000000000..52cfe86aeeb --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/tag.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required" : [ + "name", + "message", + "commit", + "release" + ], + "properties" : { + "name": { "type": "string" }, + "message": { "type": ["string", "null"] }, + "commit": { "$ref": "commit/basic.json" }, + "release": { + "oneOf": [ + { "type": "null" }, + { "$ref": "release.json" } + ] + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/tags.json b/spec/fixtures/api/schemas/public_api/v4/tags.json new file mode 100644 index 00000000000..eae352e7f87 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/tags.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "tag.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/user/basic.json b/spec/fixtures/api/schemas/public_api/v4/user/basic.json new file mode 100644 index 00000000000..9f69d31971c --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/user/basic.json @@ -0,0 +1,15 @@ +{ + "type": "object", + "required": [ + "id", + "state", + "avatar_url", + "web_url" + ], + "properties": { + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "string" }, + "web_url": { "type": "string" } + } +} diff --git a/spec/fixtures/encoding/Japanese.md b/spec/fixtures/encoding/Japanese.md new file mode 100644 index 00000000000..dd469c9f232 --- /dev/null +++ b/spec/fixtures/encoding/Japanese.md @@ -0,0 +1,42 @@ ++++ +date = "2017-05-21T13:05:07+09:00" +title = "レイヤ" +weight = 10 + ++++ + +## このチュートリアルで扱う内容 +1. Redactedにおける2D開発でのレイヤの基本的な概要 +2. スクリーン上のスプライトの順序付け方法 + +### Redactedにおける2D開発でのレイヤの基本的な概要 +2Dにおいてはz軸が存在しないため、シーン内要素の描画順を制御するためには代替となる仕組みが必要です。 +Redactedでは**レイヤ**における**zIndex**属性を制御可能にすることで、この課題を解決しています。 +**デフォルトでは、zIndexは0となりオブジェクトはレイヤに追加された順番に描画されます。** + +レイヤにはいくつかの重要な特性があります。 + +* レイヤにはレイヤ化されたオブジェクトのみを含めることができます。(**3Dモデルは絶対に追加しないでください**) +* レイヤはレイヤ化されたオブジェクトです。(したがって、レイヤには他のレイヤを含めることができます) +* レイヤ化されたオブジェクトは、最大で1つのレイヤに属すことができます。 + +レイヤを直接初期化することはできませんが、その派生クラスは初期化することが可能です。**Scene2D**と**コンテナ**は、**レイヤ**から派生する2つの主なオブジェクトです。すべての初期化(createContainer、instantiate、...)はレイヤ上で行われます。つまり、2Dで初期化されるすべてのオブジェクトは、zIndexプロパティを持つレイヤ化されたオブジェクトです。 + +**zIndexはグローバルではありません!** + +CSSとは異なり、zIndexはすべてのオブジェクトに対してグローバルではありません。zIndexプロパティは親レイヤに対してローカルです。詳細につきましては、コンテナチュートリアルで説明しています。 [TODO: Link]。 + +### スクリーン上のスプライトの順序付け方法 +これまで学んだことを生かして、画面にスプライトを表示して、zIndexの設定をしてみましょう! + +* まず、最初に (A,B,C) スプライトを生成します。 +* スプライトAをシーンに追加します(zIndex = 0、標準色) +* スプライトBをシーン2に追加すると、**スプライトAの上に**表示されます(zIndex = 0、赤色) +* 最後にスプライトCをシーンに追加します(青色)が、スプライトのzIndexを-1に設定すると、スプライトはAとBの後側に表示されます。 + +{{< code "static/tutorials/layers.html" >}} + +### ソースコード全体 +```js +{{< snippet "static/tutorials/layers.html" >}} +``` diff --git a/spec/javascripts/blob/blob_file_dropzone_spec.js b/spec/javascripts/blob/blob_file_dropzone_spec.js new file mode 100644 index 00000000000..2c8183ff77b --- /dev/null +++ b/spec/javascripts/blob/blob_file_dropzone_spec.js @@ -0,0 +1,42 @@ +import 'dropzone'; +import BlobFileDropzone from '~/blob/blob_file_dropzone'; + +describe('BlobFileDropzone', () => { + preloadFixtures('blob/show.html.raw'); + + beforeEach(() => { + loadFixtures('blob/show.html.raw'); + const form = $('.js-upload-blob-form'); + this.blobFileDropzone = new BlobFileDropzone(form, 'POST'); + this.dropzone = $('.js-upload-blob-form .dropzone').get(0).dropzone; + this.replaceFileButton = $('#submit-all'); + }); + + describe('submit button', () => { + it('requires file', () => { + spyOn(window, 'alert'); + + this.replaceFileButton.click(); + + expect(window.alert).toHaveBeenCalled(); + }); + + it('is disabled while uploading', () => { + spyOn(window, 'alert'); + + const file = { + name: 'some-file.jpg', + type: 'jpg', + }; + const fakeEvent = jQuery.Event('drop', { + dataTransfer: { files: [file] }, + }); + + this.dropzone.listeners[0].events.drop(fakeEvent); + this.replaceFileButton.click(); + + expect(window.alert).not.toHaveBeenCalled(); + expect(this.replaceFileButton.is(':disabled')).toEqual(true); + }); + }); +}); diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js index af04e7c1e72..cfa6650d85f 100644 --- a/spec/javascripts/blob/viewer/index_spec.js +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -3,10 +3,10 @@ import BlobViewer from '~/blob/viewer/index'; describe('Blob viewer', () => { let blob; - preloadFixtures('blob/show.html.raw'); + preloadFixtures('snippets/show.html.raw'); beforeEach(() => { - loadFixtures('blob/show.html.raw'); + loadFixtures('snippets/show.html.raw'); $('#modal-upload-blob').remove(); blob = new BlobViewer(); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index bd9b4fbfdd3..69cfcbbce5a 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -238,12 +238,6 @@ describe('Issue card component', () => { }); describe('labels', () => { - it('does not render any', () => { - expect( - component.$el.querySelector('.label'), - ).toBeNull(); - }); - describe('exists', () => { beforeEach((done) => { component.issue.addLabel(label1); @@ -251,16 +245,21 @@ describe('Issue card component', () => { Vue.nextTick(() => done()); }); - it('does not render list label', () => { + it('renders list label', () => { expect( component.$el.querySelectorAll('.label').length, - ).toBe(1); + ).toBe(2); }); it('renders label', () => { + const nodes = []; + component.$el.querySelectorAll('.label').forEach((label) => { + nodes.push(label.title); + }); + expect( - component.$el.querySelector('.label').textContent, - ).toContain(label1.title); + nodes.includes(label1.description), + ).toBe(true); }); it('sets label description as title', () => { @@ -270,9 +269,14 @@ describe('Issue card component', () => { }); it('sets background color of button', () => { + const nodes = []; + component.$el.querySelectorAll('.label').forEach((label) => { + nodes.push(label.style.backgroundColor); + }); + expect( - component.$el.querySelector('.label').style.backgroundColor, - ).toContain(label1.color); + nodes.includes(label1.color), + ).toBe(true); }); }); }); diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/snippet.rb index 16490ad5039..cc825c82190 100644 --- a/spec/javascripts/fixtures/blob.rb +++ b/spec/javascripts/fixtures/snippet.rb @@ -1,27 +1,25 @@ require 'spec_helper' -describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do +describe SnippetsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } + let(:snippet) { create(:personal_snippet, title: 'snippet.md', content: '# snippet', file_name: 'snippet.md', author: admin) } render_views before(:all) do - clean_frontend_fixtures('blob/') + clean_frontend_fixtures('snippets/') end before(:each) do sign_in(admin) end - it 'blob/show.html.raw' do |example| - get(:show, - namespace_id: project.namespace, - project_id: project, - id: 'add-ipython-files/files/ipython/basic.ipynb') + it 'snippets/show.html.raw' do |example| + get(:show, id: snippet.to_param) expect(response).to be_success store_frontend_fixture(response, example.description) diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js new file mode 100644 index 00000000000..db2b7d51626 --- /dev/null +++ b/spec/javascripts/repo/components/repo_commit_section_spec.js @@ -0,0 +1,158 @@ +import Vue from 'vue'; +import repoCommitSection from '~/repo/components/repo_commit_section.vue'; +import RepoStore from '~/repo/stores/repo_store'; +import RepoHelper from '~/repo/helpers/repo_helper'; +import Api from '~/api'; + +describe('RepoCommitSection', () => { + const branch = 'master'; + const projectUrl = 'projectUrl'; + const openedFiles = [{ + id: 0, + changed: true, + url: `/namespace/${projectUrl}/blob/${branch}/dir/file0.ext`, + newContent: 'a', + }, { + id: 1, + changed: true, + url: `/namespace/${projectUrl}/blob/${branch}/dir/file1.ext`, + newContent: 'b', + }, { + id: 2, + url: `/namespace/${projectUrl}/blob/${branch}/dir/file2.ext`, + changed: false, + }]; + + RepoStore.projectUrl = projectUrl; + + function createComponent() { + const RepoCommitSection = Vue.extend(repoCommitSection); + + return new RepoCommitSection().$mount(); + } + + it('renders a commit section', () => { + RepoStore.isCommitable = true; + RepoStore.targetBranch = branch; + RepoStore.openedFiles = openedFiles; + + spyOn(RepoHelper, 'getBranch').and.returnValue(branch); + + const vm = createComponent(); + const changedFiles = [...vm.$el.querySelectorAll('.changed-files > li')]; + const commitMessage = vm.$el.querySelector('#commit-message'); + const submitCommit = vm.$el.querySelector('.submit-commit'); + const targetBranch = vm.$el.querySelector('.target-branch'); + + expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); + expect(vm.$el.querySelector('.staged-files').textContent).toEqual('Staged files (2)'); + expect(changedFiles.length).toEqual(2); + + changedFiles.forEach((changedFile, i) => { + const filePath = RepoHelper.getFilePathFromFullPath(openedFiles[i].url, branch); + + expect(changedFile.textContent).toEqual(filePath); + }); + + expect(commitMessage.tagName).toEqual('TEXTAREA'); + expect(commitMessage.name).toEqual('commit-message'); + expect(submitCommit.type).toEqual('submit'); + expect(submitCommit.disabled).toBeTruthy(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy(); + expect(vm.$el.querySelector('.commit-summary').textContent).toEqual('Commit 2 files'); + expect(targetBranch.querySelector(':scope > label').textContent).toEqual('Target branch'); + expect(targetBranch.querySelector('.help-block').textContent).toEqual(branch); + }); + + it('does not render if not isCommitable', () => { + RepoStore.isCommitable = false; + RepoStore.openedFiles = [{ + id: 0, + changed: true, + }]; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not render if no changedFiles', () => { + RepoStore.isCommitable = true; + RepoStore.openedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('shows commit submit and summary if commitMessage and spinner if submitCommitsLoading', (done) => { + const projectId = 'projectId'; + const commitMessage = 'commitMessage'; + RepoStore.isCommitable = true; + RepoStore.openedFiles = openedFiles; + RepoStore.projectId = projectId; + + spyOn(RepoHelper, 'getBranch').and.returnValue(branch); + + const vm = createComponent(); + const commitMessageEl = vm.$el.querySelector('#commit-message'); + const submitCommit = vm.$el.querySelector('.submit-commit'); + + vm.commitMessage = commitMessage; + + Vue.nextTick(() => { + expect(commitMessageEl.value).toBe(commitMessage); + expect(submitCommit.disabled).toBeFalsy(); + + spyOn(vm, 'makeCommit').and.callThrough(); + spyOn(Api, 'commitMultiple'); + + submitCommit.click(); + + Vue.nextTick(() => { + expect(vm.makeCommit).toHaveBeenCalled(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeTruthy(); + + const args = Api.commitMultiple.calls.allArgs()[0]; + const { commit_message, actions, branch: payloadBranch } = args[1]; + + expect(args[0]).toBe(projectId); + expect(commit_message).toBe(commitMessage); + expect(actions.length).toEqual(2); + expect(payloadBranch).toEqual(branch); + expect(actions[0].action).toEqual('update'); + expect(actions[1].action).toEqual('update'); + expect(actions[0].content).toEqual(openedFiles[0].newContent); + expect(actions[1].content).toEqual(openedFiles[1].newContent); + expect(actions[0].file_path) + .toEqual(RepoHelper.getFilePathFromFullPath(openedFiles[0].url, branch)); + expect(actions[1].file_path) + .toEqual(RepoHelper.getFilePathFromFullPath(openedFiles[1].url, branch)); + + done(); + }); + }); + }); + + describe('methods', () => { + describe('resetCommitState', () => { + it('should reset store vars and scroll to top', () => { + const vm = { + submitCommitsLoading: true, + changedFiles: new Array(10), + openedFiles: new Array(10), + commitMessage: 'commitMessage', + editMode: true, + }; + + repoCommitSection.methods.resetCommitState.call(vm); + + expect(vm.submitCommitsLoading).toEqual(false); + expect(vm.changedFiles).toEqual([]); + expect(vm.openedFiles).toEqual([]); + expect(vm.commitMessage).toEqual(''); + expect(vm.editMode).toEqual(false); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_edit_button_spec.js b/spec/javascripts/repo/components/repo_edit_button_spec.js new file mode 100644 index 00000000000..df2f9697acc --- /dev/null +++ b/spec/javascripts/repo/components/repo_edit_button_spec.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import repoEditButton from '~/repo/components/repo_edit_button.vue'; +import RepoStore from '~/repo/stores/repo_store'; + +describe('RepoEditButton', () => { + function createComponent() { + const RepoEditButton = Vue.extend(repoEditButton); + + return new RepoEditButton().$mount(); + } + + it('renders an edit button that toggles the view state', (done) => { + RepoStore.isCommitable = true; + RepoStore.changedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.tagName).toEqual('BUTTON'); + expect(vm.$el.textContent).toMatch('Edit'); + + spyOn(vm, 'editClicked').and.callThrough(); + + vm.$el.click(); + + Vue.nextTick(() => { + expect(vm.editClicked).toHaveBeenCalled(); + expect(vm.$el.textContent).toMatch('Cancel edit'); + done(); + }); + }); + + it('does not render if not isCommitable', () => { + RepoStore.isCommitable = false; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeUndefined(); + }); + + describe('methods', () => { + describe('editClicked', () => { + it('sets dialog to open when there are changedFiles', () => { + + }); + + it('toggles editMode and calls toggleBlobView', () => { + + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_editor_spec.js b/spec/javascripts/repo/components/repo_editor_spec.js new file mode 100644 index 00000000000..35e0c995163 --- /dev/null +++ b/spec/javascripts/repo/components/repo_editor_spec.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import repoEditor from '~/repo/components/repo_editor.vue'; +import RepoStore from '~/repo/stores/repo_store'; + +describe('RepoEditor', () => { + function createComponent() { + const RepoEditor = Vue.extend(repoEditor); + + return new RepoEditor().$mount(); + } + + it('renders an ide container', () => { + const monacoInstance = jasmine.createSpyObj('monacoInstance', ['onMouseUp', 'onKeyUp', 'setModel', 'updateOptions']); + const monaco = { + editor: jasmine.createSpyObj('editor', ['create']), + }; + RepoStore.monaco = monaco; + + monaco.editor.create.and.returnValue(monacoInstance); + spyOn(repoEditor.watch, 'blobRaw'); + + const vm = createComponent(); + + expect(vm.$el.id).toEqual('ide'); + }); +}); diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js new file mode 100644 index 00000000000..e1f25e4485f --- /dev/null +++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import repoFileButtons from '~/repo/components/repo_file_buttons.vue'; +import RepoStore from '~/repo/stores/repo_store'; + +describe('RepoFileButtons', () => { + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + return new RepoFileButtons().$mount(); + } + + it('renders Raw, Blame, History, Permalink and Preview toggle', () => { + const activeFile = { + extension: 'md', + url: 'url', + raw_path: 'raw_path', + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + }; + const activeFileLabel = 'activeFileLabel'; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + RepoStore.activeFileLabel = activeFileLabel; + RepoStore.editMode = true; + + const vm = createComponent(); + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(vm.$el.id).toEqual('repo-file-buttons'); + expect(raw.href).toMatch(`/${activeFile.raw_path}`); + expect(raw.textContent).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blame_path}`); + expect(blame.textContent).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commits_path}`); + expect(history.textContent).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent).toEqual('Permalink'); + expect(vm.$el.querySelector('.preview').textContent).toEqual(activeFileLabel); + }); + + it('triggers rawPreviewToggle on preview click', () => { + const activeFile = { + extension: 'md', + url: 'url', + }; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + RepoStore.editMode = true; + + const vm = createComponent(); + const preview = vm.$el.querySelector('.preview'); + + spyOn(vm, 'rawPreviewToggle'); + + preview.click(); + + expect(vm.rawPreviewToggle).toHaveBeenCalled(); + }); + + it('does not render preview toggle if not canPreview', () => { + const activeFile = { + extension: 'abcd', + url: 'url', + }; + RepoStore.openedFiles = new Array(1); + RepoStore.activeFile = activeFile; + + const vm = createComponent(); + + expect(vm.$el.querySelector('.preview')).toBeFalsy(); + }); + + it('does not render if not isMini', () => { + RepoStore.openedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/components/repo_file_options_spec.js b/spec/javascripts/repo/components/repo_file_options_spec.js new file mode 100644 index 00000000000..9759b4bf12d --- /dev/null +++ b/spec/javascripts/repo/components/repo_file_options_spec.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import repoFileOptions from '~/repo/components/repo_file_options.vue'; + +describe('RepoFileOptions', () => { + const projectName = 'projectName'; + + function createComponent(propsData) { + const RepoFileOptions = Vue.extend(repoFileOptions); + + return new RepoFileOptions({ + propsData, + }).$mount(); + } + + it('renders the title and new file/folder buttons if isMini is true', () => { + const vm = createComponent({ + isMini: true, + projectName, + }); + + expect(vm.$el.classList.contains('repo-file-options')).toBeTruthy(); + expect(vm.$el.querySelector('.title').textContent).toEqual(projectName); + }); + + it('does not render if isMini is false', () => { + const vm = createComponent({ + isMini: false, + projectName, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js new file mode 100644 index 00000000000..90616ae13ca --- /dev/null +++ b/spec/javascripts/repo/components/repo_file_spec.js @@ -0,0 +1,136 @@ +import Vue from 'vue'; +import repoFile from '~/repo/components/repo_file.vue'; + +describe('RepoFile', () => { + const updated = 'updated'; + const file = { + icon: 'icon', + url: 'url', + name: 'name', + lastCommitMessage: 'message', + lastCommitUpdate: Date.now(), + level: 10, + }; + const activeFile = { + url: 'url', + }; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + return new RepoFile({ + propsData, + }).$mount(); + } + + beforeEach(() => { + spyOn(repoFile.mixins[0].methods, 'timeFormated').and.returnValue(updated); + }); + + it('renders link, icon, name and last commit details', () => { + const vm = createComponent({ + file, + activeFile, + }); + const name = vm.$el.querySelector('.repo-file-name'); + const fileIcon = vm.$el.querySelector('.file-icon'); + + expect(vm.$el.classList.contains('active')).toBeTruthy(); + expect(vm.$el.querySelector(`.${file.icon}`).style.marginLeft).toEqual('100px'); + expect(name.title).toEqual(file.url); + expect(name.href).toMatch(`/${file.url}`); + expect(name.textContent).toEqual(file.name); + expect(vm.$el.querySelector('.commit-message').textContent).toBe(file.lastCommitMessage); + expect(vm.$el.querySelector('.commit-update').textContent).toBe(updated); + expect(fileIcon.classList.contains(file.icon)).toBeTruthy(); + expect(fileIcon.style.marginLeft).toEqual(`${file.level * 10}px`); + }); + + it('does render if hasFiles is true and is loading tree', () => { + const vm = createComponent({ + file, + activeFile, + loading: { + tree: true, + }, + hasFiles: true, + }); + + expect(vm.$el.innerHTML).toBeTruthy(); + expect(vm.$el.querySelector('.fa-spin.fa-spinner')).toBeFalsy(); + }); + + it('renders a spinner if the file is loading', () => { + file.loading = true; + const vm = createComponent({ + file, + activeFile, + loading: { + tree: true, + }, + hasFiles: true, + }); + + expect(vm.$el.innerHTML).toBeTruthy(); + expect(vm.$el.querySelector('.fa-spin.fa-spinner').style.marginLeft).toEqual(`${file.level * 10}px`); + }); + + it('does not render if loading tree', () => { + const vm = createComponent({ + file, + activeFile, + loading: { + tree: true, + }, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not render commit message and datetime if mini', () => { + const vm = createComponent({ + file, + activeFile, + isMini: true, + }); + + expect(vm.$el.querySelector('.commit-message')).toBeFalsy(); + expect(vm.$el.querySelector('.commit-update')).toBeFalsy(); + }); + + it('does not set active class if file is active file', () => { + const vm = createComponent({ + file, + activeFile: {}, + }); + + expect(vm.$el.classList.contains('active')).toBeFalsy(); + }); + + it('fires linkClicked when the link is clicked', () => { + const vm = createComponent({ + file, + activeFile, + }); + + spyOn(vm, 'linkClicked'); + + vm.$el.querySelector('.repo-file-name').click(); + + expect(vm.linkClicked).toHaveBeenCalledWith(file); + }); + + describe('methods', () => { + describe('linkClicked', () => { + const vm = jasmine.createSpyObj('vm', ['$emit']); + + it('$emits linkclicked with file obj', () => { + const theFile = {}; + + repoFile.methods.linkClicked.call(vm, theFile); + + expect(vm.$emit).toHaveBeenCalledWith('linkclicked', theFile); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_loading_file_spec.js b/spec/javascripts/repo/components/repo_loading_file_spec.js new file mode 100644 index 00000000000..d84f4c5609e --- /dev/null +++ b/spec/javascripts/repo/components/repo_loading_file_spec.js @@ -0,0 +1,79 @@ +import Vue from 'vue'; +import repoLoadingFile from '~/repo/components/repo_loading_file.vue'; + +describe('RepoLoadingFile', () => { + function createComponent(propsData) { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + propsData, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`line-of-code-${index}`)).toBeTruthy(); + }); + } + + function assertColumns(columns) { + columns.forEach((column) => { + const container = column.querySelector('.animation-container'); + const lines = [...container.querySelectorAll(':scope > div')]; + + expect(container).toBeTruthy(); + expect(lines.length).toEqual(6); + assertLines(lines); + }); + } + + it('renders 3 columns of animated LoC', () => { + const vm = createComponent({ + loading: { + tree: true, + }, + hasFiles: false, + }); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', () => { + const vm = createComponent({ + loading: { + tree: true, + }, + hasFiles: false, + isMini: true, + }); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + }); + + it('does not render if tree is not loading', () => { + const vm = createComponent({ + loading: { + tree: false, + }, + hasFiles: false, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not render if hasFiles is true', () => { + const vm = createComponent({ + loading: { + tree: true, + }, + hasFiles: true, + }); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/repo/components/repo_prev_directory_spec.js b/spec/javascripts/repo/components/repo_prev_directory_spec.js new file mode 100644 index 00000000000..34dde545e6a --- /dev/null +++ b/spec/javascripts/repo/components/repo_prev_directory_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import repoPrevDirectory from '~/repo/components/repo_prev_directory.vue'; + +describe('RepoPrevDirectory', () => { + function createComponent(propsData) { + const RepoPrevDirectory = Vue.extend(repoPrevDirectory); + + return new RepoPrevDirectory({ + propsData, + }).$mount(); + } + + it('renders a prev dir link', () => { + const prevUrl = 'prevUrl'; + const vm = createComponent({ + prevUrl, + }); + const link = vm.$el.querySelector('a'); + + spyOn(vm, 'linkClicked'); + + expect(link.href).toMatch(`/${prevUrl}`); + expect(link.textContent).toEqual('..'); + + link.click(); + + expect(vm.linkClicked).toHaveBeenCalledWith(prevUrl); + }); + + describe('methods', () => { + describe('linkClicked', () => { + const vm = jasmine.createSpyObj('vm', ['$emit']); + + it('$emits linkclicked with file obj', () => { + const file = {}; + + repoPrevDirectory.methods.linkClicked.call(vm, file); + + expect(vm.$emit).toHaveBeenCalledWith('linkclicked', file); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_preview_spec.js b/spec/javascripts/repo/components/repo_preview_spec.js new file mode 100644 index 00000000000..4920cf02083 --- /dev/null +++ b/spec/javascripts/repo/components/repo_preview_spec.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import repoPreview from '~/repo/components/repo_preview.vue'; +import RepoStore from '~/repo/stores/repo_store'; + +describe('RepoPreview', () => { + function createComponent() { + const RepoPreview = Vue.extend(repoPreview); + + return new RepoPreview().$mount(); + } + + it('renders a div with the activeFile html', () => { + const activeFile = { + html: '<p class="file-content">html</p>', + }; + RepoStore.activeFile = activeFile; + + const vm = createComponent(); + + expect(vm.$el.tagName).toEqual('DIV'); + expect(vm.$el.innerHTML).toContain(activeFile.html); + }); +}); diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js new file mode 100644 index 00000000000..0d216c9c026 --- /dev/null +++ b/spec/javascripts/repo/components/repo_sidebar_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import RepoStore from '~/repo/stores/repo_store'; +import repoSidebar from '~/repo/components/repo_sidebar.vue'; + +describe('RepoSidebar', () => { + function createComponent() { + const RepoSidebar = Vue.extend(repoSidebar); + + return new RepoSidebar().$mount(); + } + + it('renders a sidebar', () => { + RepoStore.files = [{ + id: 0, + }]; + const vm = createComponent(); + const thead = vm.$el.querySelector('thead'); + const tbody = vm.$el.querySelector('tbody'); + + expect(vm.$el.id).toEqual('sidebar'); + expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); + expect(thead.querySelector('.name').textContent).toEqual('Name'); + expect(thead.querySelector('.last-commit').textContent).toEqual('Last Commit'); + expect(thead.querySelector('.last-update').textContent).toEqual('Last Update'); + expect(tbody.querySelector('.repo-file-options')).toBeFalsy(); + expect(tbody.querySelector('.prev-directory')).toBeFalsy(); + expect(tbody.querySelector('.loading-file')).toBeFalsy(); + expect(tbody.querySelector('.file')).toBeTruthy(); + }); + + it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', () => { + RepoStore.openedFiles = [{ + id: 0, + }]; + const vm = createComponent(); + + expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy(); + expect(vm.$el.querySelector('thead')).toBeFalsy(); + expect(vm.$el.querySelector('tbody .repo-file-options')).toBeTruthy(); + }); + + it('renders 5 loading files if tree is loading and not hasFiles', () => { + RepoStore.loading = { + tree: true, + }; + RepoStore.files = []; + const vm = createComponent(); + + expect(vm.$el.querySelectorAll('tbody .loading-file').length).toEqual(5); + }); + + it('renders a prev directory if isRoot', () => { + RepoStore.files = [{ + id: 0, + }]; + RepoStore.isRoot = true; + const vm = createComponent(); + + expect(vm.$el.querySelector('tbody .prev-directory')).toBeTruthy(); + }); +}); diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js new file mode 100644 index 00000000000..f3572804b4a --- /dev/null +++ b/spec/javascripts/repo/components/repo_tab_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import repoTab from '~/repo/components/repo_tab.vue'; + +describe('RepoTab', () => { + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + propsData, + }).$mount(); + } + + it('renders a close link and a name link', () => { + const tab = { + loading: false, + url: 'url', + name: 'name', + }; + const vm = createComponent({ + tab, + }); + const close = vm.$el.querySelector('.close'); + const name = vm.$el.querySelector(`a[title="${tab.url}"]`); + + spyOn(vm, 'xClicked'); + spyOn(vm, 'tabClicked'); + + expect(close.querySelector('.fa-times')).toBeTruthy(); + expect(name.textContent).toEqual(tab.name); + + close.click(); + name.click(); + + expect(vm.xClicked).toHaveBeenCalledWith(tab); + expect(vm.tabClicked).toHaveBeenCalledWith(tab); + }); + + it('renders a spinner if tab is loading', () => { + const tab = { + loading: true, + url: 'url', + }; + const vm = createComponent({ + tab, + }); + const close = vm.$el.querySelector('.close'); + const name = vm.$el.querySelector(`a[title="${tab.url}"]`); + + expect(close).toBeFalsy(); + expect(name).toBeFalsy(); + expect(vm.$el.querySelector('.fa.fa-spinner.fa-spin')).toBeTruthy(); + }); + + it('renders an fa-circle icon if tab is changed', () => { + const tab = { + loading: false, + url: 'url', + name: 'name', + changed: true, + }; + const vm = createComponent({ + tab, + }); + + expect(vm.$el.querySelector('.close .fa-circle')).toBeTruthy(); + }); + + describe('methods', () => { + describe('xClicked', () => { + const vm = jasmine.createSpyObj('vm', ['$emit']); + + it('returns undefined and does not $emit if file is changed', () => { + const file = { changed: true }; + const returnVal = repoTab.methods.xClicked.call(vm, file); + + expect(returnVal).toBeUndefined(); + expect(vm.$emit).not.toHaveBeenCalled(); + }); + + it('$emits xclicked event with file obj', () => { + const file = { changed: false }; + repoTab.methods.xClicked.call(vm, file); + + expect(vm.$emit).toHaveBeenCalledWith('xclicked', file); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js new file mode 100644 index 00000000000..fdb12cfc00f --- /dev/null +++ b/spec/javascripts/repo/components/repo_tabs_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import RepoStore from '~/repo/stores/repo_store'; +import repoTabs from '~/repo/components/repo_tabs.vue'; + +describe('RepoTabs', () => { + const openedFiles = [{ + id: 0, + active: true, + }, { + id: 1, + }]; + + function createComponent() { + const RepoTabs = Vue.extend(repoTabs); + + return new RepoTabs().$mount(); + } + + it('renders a list of tabs', () => { + RepoStore.openedFiles = openedFiles; + RepoStore.tabsOverflow = true; + + const vm = createComponent(); + const tabs = [...vm.$el.querySelectorAll(':scope > li')]; + + expect(vm.$el.id).toEqual('tabs'); + expect(vm.$el.classList.contains('overflown')).toBeTruthy(); + expect(tabs.length).toEqual(3); + expect(tabs[0].classList.contains('active')).toBeTruthy(); + expect(tabs[1].classList.contains('active')).toBeFalsy(); + expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy(); + }); + + it('does not render a tabs list if not isMini', () => { + RepoStore.openedFiles = []; + + const vm = createComponent(); + + expect(vm.$el.innerHTML).toBeFalsy(); + }); + + it('does not apply overflown class if not tabsOverflow', () => { + RepoStore.openedFiles = openedFiles; + RepoStore.tabsOverflow = false; + + const vm = createComponent(); + + expect(vm.$el.classList.contains('overflown')).toBeFalsy(); + }); + + describe('methods', () => { + describe('xClicked', () => { + it('calls removeFromOpenedFiles with file obj', () => { + const file = {}; + + spyOn(RepoStore, 'removeFromOpenedFiles'); + + repoTabs.methods.xClicked(file); + + expect(RepoStore.removeFromOpenedFiles).toHaveBeenCalledWith(file); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/monaco_loader_spec.js b/spec/javascripts/repo/monaco_loader_spec.js new file mode 100644 index 00000000000..be6e779c50f --- /dev/null +++ b/spec/javascripts/repo/monaco_loader_spec.js @@ -0,0 +1,17 @@ +/* global __webpack_public_path__ */ +import monacoContext from 'monaco-editor/dev/vs/loader'; + +describe('MonacoLoader', () => { + it('calls require.config and exports require', () => { + spyOn(monacoContext.require, 'config'); + + const monacoLoader = require('~/repo/monaco_loader'); // eslint-disable-line global-require + + expect(monacoContext.require.config).toHaveBeenCalledWith({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + }); + expect(monacoLoader.default).toBe(monacoContext.require); + }); +}); diff --git a/spec/javascripts/repo/services/repo_service_spec.js b/spec/javascripts/repo/services/repo_service_spec.js new file mode 100644 index 00000000000..d74e6a67b1e --- /dev/null +++ b/spec/javascripts/repo/services/repo_service_spec.js @@ -0,0 +1,121 @@ +import axios from 'axios'; +import RepoService from '~/repo/services/repo_service'; + +describe('RepoService', () => { + it('has default json format param', () => { + expect(RepoService.options.params.format).toBe('json'); + }); + + describe('buildParams', () => { + let newParams; + const url = 'url'; + + beforeEach(() => { + newParams = {}; + + spyOn(Object, 'assign').and.returnValue(newParams); + }); + + it('clones params', () => { + const params = RepoService.buildParams(url); + + expect(Object.assign).toHaveBeenCalledWith({}, RepoService.options.params); + + expect(params).toBe(newParams); + }); + + it('sets and returns viewer params to richif urlIsRichBlob is true', () => { + spyOn(RepoService, 'urlIsRichBlob').and.returnValue(true); + + const params = RepoService.buildParams(url); + + expect(params.viewer).toEqual('rich'); + }); + + it('returns params urlIsRichBlob is false', () => { + spyOn(RepoService, 'urlIsRichBlob').and.returnValue(false); + + const params = RepoService.buildParams(url); + + expect(params.viewer).toBeUndefined(); + }); + + it('calls urlIsRichBlob with the objects url prop if no url arg is provided', () => { + spyOn(RepoService, 'urlIsRichBlob'); + RepoService.url = url; + + RepoService.buildParams(); + + expect(RepoService.urlIsRichBlob).toHaveBeenCalledWith(url); + }); + }); + + describe('urlIsRichBlob', () => { + it('returns true for md extension', () => { + const isRichBlob = RepoService.urlIsRichBlob('url.md'); + + expect(isRichBlob).toBeTruthy(); + }); + + it('returns false for js extension', () => { + const isRichBlob = RepoService.urlIsRichBlob('url.js'); + + expect(isRichBlob).toBeFalsy(); + }); + }); + + describe('getContent', () => { + const params = {}; + const url = 'url'; + const requestPromise = Promise.resolve(); + + beforeEach(() => { + spyOn(RepoService, 'buildParams').and.returnValue(params); + spyOn(axios, 'get').and.returnValue(requestPromise); + }); + + it('calls buildParams and axios.get', () => { + const request = RepoService.getContent(url); + + expect(RepoService.buildParams).toHaveBeenCalledWith(url); + expect(axios.get).toHaveBeenCalledWith(url, { + params, + }); + expect(request).toBe(requestPromise); + }); + + it('uses object url prop if no url arg is provided', () => { + RepoService.url = url; + + RepoService.getContent(); + + expect(axios.get).toHaveBeenCalledWith(url, { + params, + }); + }); + }); + + describe('getBase64Content', () => { + const url = 'url'; + const response = { data: 'data' }; + + beforeEach(() => { + spyOn(RepoService, 'bufferToBase64'); + spyOn(axios, 'get').and.returnValue(Promise.resolve(response)); + }); + + it('calls axios.get and bufferToBase64 on completion', (done) => { + const request = RepoService.getBase64Content(url); + + expect(axios.get).toHaveBeenCalledWith(url, { + responseType: 'arraybuffer', + }); + expect(request).toEqual(jasmine.any(Promise)); + + request.then(() => { + expect(RepoService.bufferToBase64).toHaveBeenCalledWith(response.data); + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js index fb2ef606604..237035648cf 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_locked_spec.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import lockedComponent from '~/vue_merge_request_widget/components/states/mr_widget_locked'; +import mergingComponent from '~/vue_merge_request_widget/components/states/mr_widget_merging'; -describe('MRWidgetLocked', () => { +describe('MRWidgetMerging', () => { describe('props', () => { it('should have props', () => { - const { mr } = lockedComponent.props; + const { mr } = mergingComponent.props; expect(mr.type instanceof Object).toBeTruthy(); expect(mr.required).toBeTruthy(); @@ -13,7 +13,7 @@ describe('MRWidgetLocked', () => { describe('template', () => { it('should have correct elements', () => { - const Component = Vue.extend(lockedComponent); + const Component = Vue.extend(mergingComponent); const mr = { targetBranchPath: '/branch-path', targetBranch: 'branch', @@ -24,7 +24,7 @@ describe('MRWidgetLocked', () => { }).$el; expect(el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(el.innerText).toContain('it is locked'); + expect(el.innerText).toContain('This merge request is in the process of being merged'); expect(el.innerText).toContain('changes will be merged into'); expect(el.querySelector('.label-branch a').getAttribute('href')).toEqual(mr.targetBranchPath); expect(el.querySelector('.label-branch a').textContent).toContain(mr.targetBranch); diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index ad2f28b24f0..0795d0aaa82 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -20,7 +20,6 @@ export default { "human_time_estimate": null, "human_total_time_spent": null, "in_progress_merge_commit_sha": null, - "locked_at": null, "merge_commit_sha": "53027d060246c8f47e4a9310fb332aa52f221775", "merge_error": null, "merge_params": { diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 3a0c50b750f..669ee248bf1 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -342,7 +342,7 @@ describe('mrWidgetOptions', () => { expect(comps['mr-widget-related-links']).toBeDefined(); expect(comps['mr-widget-merged']).toBeDefined(); expect(comps['mr-widget-closed']).toBeDefined(); - expect(comps['mr-widget-locked']).toBeDefined(); + expect(comps['mr-widget-merging']).toBeDefined(); expect(comps['mr-widget-failed-to-merge']).toBeDefined(); expect(comps['mr-widget-wip']).toBeDefined(); expect(comps['mr-widget-archived']).toBeDefined(); diff --git a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb index 18843cbe992..f4dfa53f050 100644 --- a/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb +++ b/spec/lib/gitlab/background_migration/deserialize_merge_request_diffs_and_commits_spec.rb @@ -170,7 +170,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits do context 'when the merge request diffs are Rugged::Patch instances' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) } - let(:diffs) { first_commit.diff_from_parent.patches } + let(:diffs) { first_commit.rugged_diff_from_parent.patches } let(:expected_diffs) { [] } include_examples 'updated MR diff' @@ -179,7 +179,7 @@ describe Gitlab::BackgroundMigration::DeserializeMergeRequestDiffsAndCommits do context 'when the merge request diffs are Rugged::Diff::Delta instances' do let(:commits) { merge_request_diff.commits.map(&:to_hash) } let(:first_commit) { merge_request.project.repository.commit(merge_request_diff.head_commit_sha) } - let(:diffs) { first_commit.diff_from_parent.deltas } + let(:diffs) { first_commit.rugged_diff_from_parent.deltas } let(:expected_diffs) { [] } include_examples 'updated MR diff' diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 1482ef7132d..8b14b227e65 100644 --- a/spec/lib/gitlab/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -30,6 +30,53 @@ describe Gitlab::EncodingHelper do it 'leaves binary string as is' do expect(ext_class.encode!(binary_string)).to eq(binary_string) end + + context 'with corrupted diff' do + let(:corrupted_diff) do + with_empty_bare_repository do |repo| + content = File.read(Rails.root.join( + 'spec/fixtures/encoding/Japanese.md').to_s) + commit_a = commit(repo, 'Japanese.md', content) + commit_b = commit(repo, 'Japanese.md', + content.sub('[TODO: Link]', '[現在作業中です: Link]')) + + repo.diff(commit_a, commit_b).each_line.map(&:content).join + end + end + + let(:cleaned_diff) do + corrupted_diff.dup.force_encoding('UTF-8') + .encode!('UTF-8', invalid: :replace, replace: '') + end + + let(:encoded_diff) do + described_class.encode!(corrupted_diff.dup) + end + + it 'does not corrupt data but remove invalid characters' do + expect(encoded_diff).to eq(cleaned_diff) + end + + def commit(repo, path, content) + oid = repo.write(content, :blob) + index = repo.index + + index.read_tree(repo.head.target.tree) unless repo.empty? + + index.add(path: path, oid: oid, mode: 0100644) + user = { name: 'Test', email: 'test@example.com' } + + Rugged::Commit.create( + repo, + tree: index.write_tree(repo), + author: user, + committer: user, + message: "Update #{path}", + parents: repo.empty? ? [] : [repo.head.target].compact, + update_ref: 'HEAD' + ) + end + end end describe '#encode_utf8' do diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index 26d7a364f5b..c531d4b055f 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" describe Gitlab::Git::Commit, seed_helper: true do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) } - let(:commit) { Gitlab::Git::Commit.find(repository, SeedRepo::Commit::ID) } + let(:commit) { described_class.find(repository, SeedRepo::Commit::ID) } let(:rugged_commit) do repository.rugged.lookup(SeedRepo::Commit::ID) end @@ -24,7 +24,7 @@ describe Gitlab::Git::Commit, seed_helper: true do } @parents = [repo.head.target] - @gitlab_parents = @parents.map { |c| Gitlab::Git::Commit.decorate(c) } + @gitlab_parents = @parents.map { |c| described_class.decorate(repository, c) } @tree = @parents.first.tree sha = Rugged::Commit.create( @@ -38,7 +38,7 @@ describe Gitlab::Git::Commit, seed_helper: true do ) @raw_commit = repo.lookup(sha) - @commit = Gitlab::Git::Commit.new(@raw_commit) + @commit = described_class.new(repository, @raw_commit) end it { expect(@commit.short_id).to eq(@raw_commit.oid[0..10]) } @@ -66,6 +66,7 @@ describe Gitlab::Git::Commit, seed_helper: true do describe "Commit info from gitaly commit" do let(:id) { 'f00' } + let(:parent_ids) { %w(b45 b46) } let(:subject) { "My commit".force_encoding('ASCII-8BIT') } let(:body) { subject + "My body".force_encoding('ASCII-8BIT') } let(:committer) do @@ -88,10 +89,11 @@ describe Gitlab::Git::Commit, seed_helper: true do subject: subject, body: body, author: author, - committer: committer + committer: committer, + parent_ids: parent_ids ) end - let(:commit) { described_class.new(Gitlab::GitalyClient::Commit.new(repository, gitaly_commit)) } + let(:commit) { described_class.new(repository, gitaly_commit) } it { expect(commit.short_id).to eq(id[0..10]) } it { expect(commit.id).to eq(id) } @@ -102,6 +104,7 @@ describe Gitlab::Git::Commit, seed_helper: true do it { expect(commit.author_name).to eq(author.name) } it { expect(commit.committer_name).to eq(committer.name) } it { expect(commit.committer_email).to eq(committer.email) } + it { expect(commit.parent_ids).to eq(parent_ids) } context 'no body' do let(:body) { "".force_encoding('ASCII-8BIT') } @@ -113,45 +116,45 @@ describe Gitlab::Git::Commit, seed_helper: true do context 'Class methods' do describe '.find' do it "should return first head commit if without params" do - expect(Gitlab::Git::Commit.last(repository).id).to eq( + expect(described_class.last(repository).id).to eq( repository.rugged.head.target.oid ) end it "should return valid commit" do - expect(Gitlab::Git::Commit.find(repository, SeedRepo::Commit::ID)).to be_valid_commit + expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_valid_commit end it "should return valid commit for tag" do - expect(Gitlab::Git::Commit.find(repository, 'v1.0.0').id).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') + expect(described_class.find(repository, 'v1.0.0').id).to eq('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') end it "should return nil for non-commit ids" do blob = Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "files/ruby/popen.rb") - expect(Gitlab::Git::Commit.find(repository, blob.id)).to be_nil + expect(described_class.find(repository, blob.id)).to be_nil end it "should return nil for parent of non-commit object" do blob = Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "files/ruby/popen.rb") - expect(Gitlab::Git::Commit.find(repository, "#{blob.id}^")).to be_nil + expect(described_class.find(repository, "#{blob.id}^")).to be_nil end it "should return nil for nonexisting ids" do - expect(Gitlab::Git::Commit.find(repository, "+123_4532530XYZ")).to be_nil + expect(described_class.find(repository, "+123_4532530XYZ")).to be_nil end context 'with broken repo' do let(:repository) { Gitlab::Git::Repository.new('default', TEST_BROKEN_REPO_PATH) } it 'returns nil' do - expect(Gitlab::Git::Commit.find(repository, SeedRepo::Commit::ID)).to be_nil + expect(described_class.find(repository, SeedRepo::Commit::ID)).to be_nil end end end describe '.last_for_path' do context 'no path' do - subject { Gitlab::Git::Commit.last_for_path(repository, 'master') } + subject { described_class.last_for_path(repository, 'master') } describe '#id' do subject { super().id } @@ -160,7 +163,7 @@ describe Gitlab::Git::Commit, seed_helper: true do end context 'path' do - subject { Gitlab::Git::Commit.last_for_path(repository, 'master', 'files/ruby') } + subject { described_class.last_for_path(repository, 'master', 'files/ruby') } describe '#id' do subject { super().id } @@ -169,7 +172,7 @@ describe Gitlab::Git::Commit, seed_helper: true do end context 'ref + path' do - subject { Gitlab::Git::Commit.last_for_path(repository, SeedRepo::Commit::ID, 'encoding') } + subject { described_class.last_for_path(repository, SeedRepo::Commit::ID, 'encoding') } describe '#id' do subject { super().id } @@ -181,7 +184,7 @@ describe Gitlab::Git::Commit, seed_helper: true do describe '.where' do context 'path is empty string' do subject do - commits = Gitlab::Git::Commit.where( + commits = described_class.where( repo: repository, ref: 'master', path: '', @@ -199,7 +202,7 @@ describe Gitlab::Git::Commit, seed_helper: true do context 'path is nil' do subject do - commits = Gitlab::Git::Commit.where( + commits = described_class.where( repo: repository, ref: 'master', path: nil, @@ -217,7 +220,7 @@ describe Gitlab::Git::Commit, seed_helper: true do context 'ref is branch name' do subject do - commits = Gitlab::Git::Commit.where( + commits = described_class.where( repo: repository, ref: 'master', path: 'files', @@ -237,7 +240,7 @@ describe Gitlab::Git::Commit, seed_helper: true do context 'ref is commit id' do subject do - commits = Gitlab::Git::Commit.where( + commits = described_class.where( repo: repository, ref: "874797c3a73b60d2187ed6e2fcabd289ff75171e", path: 'files', @@ -257,7 +260,7 @@ describe Gitlab::Git::Commit, seed_helper: true do context 'ref is tag' do subject do - commits = Gitlab::Git::Commit.where( + commits = described_class.where( repo: repository, ref: 'v1.0.0', path: 'files', @@ -278,7 +281,7 @@ describe Gitlab::Git::Commit, seed_helper: true do describe '.between' do subject do - commits = Gitlab::Git::Commit.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID) + commits = described_class.between(repository, SeedRepo::Commit::PARENT_ID, SeedRepo::Commit::ID) commits.map { |c| c.id } end @@ -294,12 +297,12 @@ describe Gitlab::Git::Commit, seed_helper: true do it 'should return a return a collection of commits' do commits = described_class.find_all(repository) - expect(commits).to all( be_a_kind_of(Gitlab::Git::Commit) ) + expect(commits).to all( be_a_kind_of(described_class) ) end context 'max_count' do subject do - commits = Gitlab::Git::Commit.find_all( + commits = described_class.find_all( repository, max_count: 50 ) @@ -322,7 +325,7 @@ describe Gitlab::Git::Commit, seed_helper: true do context 'ref + max_count + skip' do subject do - commits = Gitlab::Git::Commit.find_all( + commits = described_class.find_all( repository, ref: 'master', max_count: 50, @@ -374,7 +377,7 @@ describe Gitlab::Git::Commit, seed_helper: true do end describe '#init_from_rugged' do - let(:gitlab_commit) { Gitlab::Git::Commit.new(rugged_commit) } + let(:gitlab_commit) { described_class.new(repository, rugged_commit) } subject { gitlab_commit } describe '#id' do @@ -384,7 +387,7 @@ describe Gitlab::Git::Commit, seed_helper: true do end describe '#init_from_hash' do - let(:commit) { Gitlab::Git::Commit.new(sample_commit_hash) } + let(:commit) { described_class.new(repository, sample_commit_hash) } subject { commit } describe '#id' do @@ -451,7 +454,7 @@ describe Gitlab::Git::Commit, seed_helper: true do end describe '#ref_names' do - let(:commit) { Gitlab::Git::Commit.find(repository, 'master') } + let(:commit) { described_class.find(repository, 'master') } subject { commit.ref_names(repository) } it 'has 1 element' do diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 20d6b58d6d1..858616117d5 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -505,17 +505,22 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe "#log" do - commit_with_old_name = nil - commit_with_new_name = nil - rename_commit = nil + let(:commit_with_old_name) do + Gitlab::Git::Commit.decorate(repository, @commit_with_old_name_id) + end + let(:commit_with_new_name) do + Gitlab::Git::Commit.decorate(repository, @commit_with_new_name_id) + end + let(:rename_commit) do + Gitlab::Git::Commit.decorate(repository, @rename_commit_id) + end before(:context) do # Add new commits so that there's a renamed file in the commit history repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH).rugged - - commit_with_old_name = Gitlab::Git::Commit.decorate(new_commit_edit_old_file(repo)) - rename_commit = Gitlab::Git::Commit.decorate(new_commit_move_file(repo)) - commit_with_new_name = Gitlab::Git::Commit.decorate(new_commit_edit_new_file(repo)) + @commit_with_old_name_id = new_commit_edit_old_file(repo) + @rename_commit_id = new_commit_move_file(repo) + @commit_with_new_name_id = new_commit_edit_new_file(repo) end after(:context) do @@ -754,7 +759,7 @@ describe Gitlab::Git::Repository, seed_helper: true do let(:options) { { ref: 'master', path: ['PROCESS.md', 'README.md'] } } def commit_files(commit) - commit.diff_from_parent.deltas.flat_map do |delta| + commit.rugged_diff_from_parent.deltas.flat_map do |delta| [delta.old_file[:path], delta.new_file[:path]].uniq.compact end end diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index d71e0f84c65..7fe698fcb18 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -30,7 +30,7 @@ describe Gitlab::GitalyClient::CommitService do context 'when a commit does not have a parent' do it 'sends an RPC request with empty tree ref as left commit' do - initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') + initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863').raw request = Gitaly::CommitDiffRequest.new( repository: repository_message, left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', @@ -46,18 +46,10 @@ describe Gitlab::GitalyClient::CommitService do end end - it 'returns a Gitlab::Git::DiffCollection' do + it 'returns a Gitlab::GitalyClient::DiffStitcher' do ret = client.diff_from_parent(commit) - expect(ret).to be_kind_of(Gitlab::Git::DiffCollection) - end - - it 'passes options to Gitlab::Git::DiffCollection' do - options = { max_files: 31, max_lines: 13, from_gitaly: true } - - expect(Gitlab::Git::DiffCollection).to receive(:new).with(kind_of(Enumerable), options) - - client.diff_from_parent(commit, options) + expect(ret).to be_kind_of(Gitlab::GitalyClient::DiffStitcher) end end @@ -120,4 +112,18 @@ describe Gitlab::GitalyClient::CommitService do client.tree_entries(repository, revision, path) end end + + describe '#find_commit' do + let(:revision) { '4b825dc642cb6eb9a060e54bf8d69288fbee4904' } + it 'sends an RPC request' do + request = Gitaly::FindCommitRequest.new( + repository: repository_message, revision: revision + ) + + expect_any_instance_of(Gitaly::CommitService::Stub).to receive(:find_commit) + .with(request, kind_of(Hash)).and_return(double(commit: nil)) + + described_class.new(repository).find_commit(revision) + end + end end diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 469a014e4d2..4e631e13410 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -2534,7 +2534,6 @@ "iid": 9, "description": null, "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -2983,7 +2982,6 @@ "iid": 8, "description": null, "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -3267,7 +3265,6 @@ "iid": 7, "description": "Et commodi deserunt aspernatur vero rerum. Ut non dolorum alias in odit est libero. Voluptatibus eos in et vitae repudiandae facilis ex mollitia.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -3551,7 +3548,6 @@ "iid": 6, "description": "Dicta magnam non voluptates nam dignissimos nostrum deserunt. Dolorum et suscipit iure quae doloremque. Necessitatibus saepe aut labore sed.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -4241,7 +4237,6 @@ "iid": 5, "description": "Est eaque quasi qui qui. Similique voluptatem impedit iusto ratione reprehenderit. Itaque est illum ut nulla aut.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -4789,7 +4784,6 @@ "iid": 4, "description": "Nam magnam odit velit rerum. Sapiente dolore sunt saepe debitis. Culpa maiores ut ad dolores dolorem et.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -5288,7 +5282,6 @@ "iid": 3, "description": "Libero nesciunt mollitia quis odit eos vero quasi. Iure voluptatem ut sint pariatur voluptates ut aut. Laborum possimus unde illum ipsum eum.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -5548,7 +5541,6 @@ "iid": 2, "description": "Ut dolor quia aliquid dolore et nisi. Est minus suscipit enim quaerat sapiente consequatur rerum. Eveniet provident consequatur dolor accusantium reiciendis.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { @@ -6238,7 +6230,6 @@ "iid": 1, "description": "Eveniet nihil ratione veniam similique qui aut sapiente tempora. Sed praesentium iusto dignissimos possimus id repudiandae quo nihil. Qui doloremque autem et iure fugit.", "position": 0, - "locked_at": null, "updated_by_id": null, "merge_error": null, "merge_params": { diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 11f4c16ff96..4dce48f8079 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -145,7 +145,6 @@ MergeRequest: - iid - description - position -- locked_at - updated_by_id - merge_error - merge_params diff --git a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb index cf2d5827306..e5793a3c0ee 100644 --- a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb +++ b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb @@ -6,7 +6,7 @@ require Rails.root.join('db', 'migrate', '20161124141322_migrate_process_commit_ describe MigrateProcessCommitWorkerJobs do let(:project) { create(:project, :repository) } let(:user) { create(:user) } - let(:commit) { project.commit.raw.raw_commit } + let(:commit) { project.commit.raw.rugged_commit } describe 'Project' do describe 'find_including_path' do diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 08693b5da33..c18c635d811 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -33,7 +33,6 @@ describe Commit do describe '#to_reference' do let(:project) { create(:project, :repository, path: 'sample-project') } - let(:commit) { project.commit } it 'returns a String reference to the object' do expect(commit.to_reference).to eq commit.id @@ -47,7 +46,6 @@ describe Commit do describe '#reference_link_text' do let(:project) { create(:project, :repository, path: 'sample-project') } - let(:commit) { project.commit } it 'returns a String reference to the object' do expect(commit.reference_link_text).to eq commit.short_id @@ -191,7 +189,7 @@ eos it { expect(data).to be_a(Hash) } it { expect(data[:message]).to include('adds bar folder and branch-test text file to check Repository merged_to_root_ref method') } - it { expect(data[:timestamp]).to eq('2016-09-27T14:37:46+00:00') } + it { expect(data[:timestamp]).to eq('2016-09-27T14:37:46Z') } it { expect(data[:added]).to eq(["bar/branch-test.txt"]) } it { expect(data[:modified]).to eq([]) } it { expect(data[:removed]).to eq([]) } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 3402c260f27..a1a3e70a7d2 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1369,6 +1369,32 @@ describe MergeRequest do end end + describe '#merge_ongoing?' do + it 'returns true when merge process is ongoing for merge_jid' do + merge_request = create(:merge_request, merge_jid: 'foo') + + allow(Gitlab::SidekiqStatus).to receive(:num_running).with(['foo']).and_return(1) + + expect(merge_request.merge_ongoing?).to be(true) + end + + it 'returns false when no merge process running for merge_jid' do + merge_request = build(:merge_request, merge_jid: 'foo') + + allow(Gitlab::SidekiqStatus).to receive(:num_running).with(['foo']).and_return(0) + + expect(merge_request.merge_ongoing?).to be(false) + end + + it 'returns false when merge_jid is nil' do + merge_request = build(:merge_request, merge_jid: nil) + + expect(Gitlab::SidekiqStatus).not_to receive(:num_running) + + expect(merge_request.merge_ongoing?).to be(false) + end + end + describe "#closed_without_fork?" do let(:project) { create(:project) } let(:fork_project) { create(:project, forked_from_project: project) } diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 0dad547735d..992a6e8d76a 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -3,29 +3,27 @@ require 'mime/types' describe API::Commits do let(:user) { create(:user) } - let(:user2) { create(:user) } - let!(:project) { create(:project, :repository, creator: user, namespace: user.namespace) } - let!(:guest) { create(:project_member, :guest, user: user2, project: project) } - let!(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'a comment on a commit') } - let!(:another_note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'another comment on a commit') } + let(:guest) { create(:user).tap { |u| project.add_guest(u) } } + let(:project) { create(:project, :repository, creator: user, path: 'my.project') } + let(:branch_with_dot) { project.repository.find_branch('ends-with.json') } + let(:branch_with_slash) { project.repository.find_branch('improve/awesome') } + + let(:project_id) { project.id } + let(:current_user) { nil } before do - project.team << [user, :reporter] + project.add_master(user) end - describe "List repository commits" do - context "authorized user" do - before do - project.team << [user2, :reporter] - end - + describe 'GET /projects/:id/repository/commits' do + context 'authorized user' do it "returns project commits" do commit = project.repository.commit - get api("/projects/#{project.id}/repository/commits", user) + get api("/projects/#{project_id}/repository/commits", user) expect(response).to have_http_status(200) - expect(json_response).to be_an Array + expect(response).to match_response_schema('public_api/v4/commits') expect(json_response.first['id']).to eq(commit.id) expect(json_response.first['committer_name']).to eq(commit.committer_name) expect(json_response.first['committer_email']).to eq(commit.committer_email) @@ -34,7 +32,7 @@ describe API::Commits do it 'include correct pagination headers' do commit_count = project.repository.count_commits(ref: 'master').to_s - get api("/projects/#{project.id}/repository/commits", user) + get api("/projects/#{project_id}/repository/commits", user) expect(response).to include_pagination_headers expect(response.headers['X-Total']).to eq(commit_count) @@ -44,8 +42,9 @@ describe API::Commits do context "unauthorized user" do it "does not return project commits" do - get api("/projects/#{project.id}/repository/commits") - expect(response).to have_http_status(401) + get api("/projects/#{project_id}/repository/commits") + + expect(response).to have_http_status(404) end end @@ -54,7 +53,7 @@ describe API::Commits do commits = project.repository.commits("master") after = commits.second.created_at - get api("/projects/#{project.id}/repository/commits?since=#{after.utc.iso8601}", user) + get api("/projects/#{project_id}/repository/commits?since=#{after.utc.iso8601}", user) expect(json_response.size).to eq 2 expect(json_response.first["id"]).to eq(commits.first.id) @@ -66,7 +65,7 @@ describe API::Commits do after = commits.second.created_at commit_count = project.repository.count_commits(ref: 'master', after: after).to_s - get api("/projects/#{project.id}/repository/commits?since=#{after.utc.iso8601}", user) + get api("/projects/#{project_id}/repository/commits?since=#{after.utc.iso8601}", user) expect(response).to include_pagination_headers expect(response.headers['X-Total']).to eq(commit_count) @@ -79,7 +78,7 @@ describe API::Commits do commits = project.repository.commits("master") before = commits.second.created_at - get api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user) + get api("/projects/#{project_id}/repository/commits?until=#{before.utc.iso8601}", user) if commits.size >= 20 expect(json_response.size).to eq(20) @@ -96,7 +95,7 @@ describe API::Commits do before = commits.second.created_at commit_count = project.repository.count_commits(ref: 'master', before: before).to_s - get api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user) + get api("/projects/#{project_id}/repository/commits?until=#{before.utc.iso8601}", user) expect(response).to include_pagination_headers expect(response.headers['X-Total']).to eq(commit_count) @@ -106,7 +105,7 @@ describe API::Commits do context "invalid xmlschema date parameters" do it "returns an invalid parameter error message" do - get api("/projects/#{project.id}/repository/commits?since=invalid-date", user) + get api("/projects/#{project_id}/repository/commits?since=invalid-date", user) expect(response).to have_http_status(400) expect(json_response['error']).to eq('since is invalid') @@ -118,7 +117,7 @@ describe API::Commits do path = 'files/ruby/popen.rb' commit_count = project.repository.count_commits(ref: 'master', path: path).to_s - get api("/projects/#{project.id}/repository/commits?path=#{path}", user) + get api("/projects/#{project_id}/repository/commits?path=#{path}", user) expect(json_response.size).to eq(3) expect(json_response.first["id"]).to eq("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") @@ -130,7 +129,7 @@ describe API::Commits do path = 'files/ruby/popen.rb' commit_count = project.repository.count_commits(ref: 'master', path: path).to_s - get api("/projects/#{project.id}/repository/commits?path=#{path}", user) + get api("/projects/#{project_id}/repository/commits?path=#{path}", user) expect(response).to include_pagination_headers expect(response.headers['X-Total']).to eq(commit_count) @@ -143,7 +142,7 @@ describe API::Commits do let(:per_page) { 5 } let(:ref_name) { 'master' } let!(:request) do - get api("/projects/#{project.id}/repository/commits?page=#{page}&per_page=#{per_page}&ref_name=#{ref_name}", user) + get api("/projects/#{project_id}/repository/commits?page=#{page}&per_page=#{per_page}&ref_name=#{ref_name}", user) end it 'returns correct headers' do @@ -181,10 +180,10 @@ describe API::Commits do end describe "POST /projects/:id/repository/commits" do - let!(:url) { "/projects/#{project.id}/repository/commits" } + let!(:url) { "/projects/#{project_id}/repository/commits" } it 'returns a 403 unauthorized for user without permissions' do - post api(url, user2) + post api(url, guest) expect(response).to have_http_status(403) end @@ -227,7 +226,7 @@ describe API::Commits do it 'a new file in project repo' do post api(url, user), valid_c_params - expect(response).to have_http_status(201) + expect(response).to have_gitlab_http_status(201) expect(json_response['title']).to eq(message) expect(json_response['committer_name']).to eq(user.name) expect(json_response['committer_email']).to eq(user.email) @@ -453,13 +452,17 @@ describe API::Commits do end end - describe "Get a single commit" do - context "authorized user" do - it "returns a commit by sha" do - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + describe 'GET /projects/:id/repository/commits/:sha' do + let(:commit) { project.repository.commit } + let(:commit_id) { commit.id } + let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}" } - expect(response).to have_http_status(200) - commit = project.repository.commit + shared_examples_for 'ref commit' do + it 'returns the ref last commit' do + get api(route, current_user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/commit/detail') expect(json_response['id']).to eq(commit.id) expect(json_response['short_id']).to eq(commit.short_id) expect(json_response['title']).to eq(commit.title) @@ -474,222 +477,539 @@ describe API::Commits do expect(json_response['stats']['additions']).to eq(commit.stats.additions) expect(json_response['stats']['deletions']).to eq(commit.stats.deletions) expect(json_response['stats']['total']).to eq(commit.stats.total) + expect(json_response['status']).to be_nil end - it "returns a 404 error if not found" do - get api("/projects/#{project.id}/repository/commits/invalid_sha", user) - expect(response).to have_http_status(404) + context 'when ref does not exist' do + let(:commit_id) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + let(:message) { '404 Commit Not Found' } + end end - it "returns nil for commit without CI" do - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + context 'when repository is disabled' do + include_context 'disabled repository' - expect(response).to have_http_status(200) - expect(json_response['status']).to be_nil + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end end + end - it "returns status for CI" do - pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) - pipeline.update(status: 'success') + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + it_behaves_like 'ref commit' + end - expect(response).to have_http_status(200) - expect(json_response['status']).to eq(pipeline.status) + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } end + end - it "returns status for CI when pipeline is created" do - project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) + context 'when authenticated', 'as a master' do + let(:current_user) { user } - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) + it_behaves_like 'ref commit' - expect(response).to have_http_status(200) - expect(json_response['status']).to eq("created") + context 'when branch contains a dot' do + let(:commit) { project.repository.commit(branch_with_dot.name) } + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref commit' end - end - context "unauthorized user" do - it "does not return the selected commit" do - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}") - expect(response).to have_http_status(401) + context 'when branch contains a slash' do + let(:commit_id) { branch_with_slash.name } + + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + end + end + + context 'when branch contains an escaped slash' do + let(:commit) { project.repository.commit(branch_with_slash.name) } + let(:commit_id) { CGI.escape(branch_with_slash.name) } + + it_behaves_like 'ref commit' + end + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'ref commit' + + context 'when branch contains a dot' do + let(:commit) { project.repository.commit(branch_with_dot.name) } + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref commit' + end + end + + context 'when the ref has a pipeline' do + let!(:pipeline) { project.pipelines.create(source: :push, ref: 'master', sha: commit.sha) } + + it 'includes a "created" status' do + get api(route, current_user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v4/commit/detail') + expect(json_response['status']).to eq('created') + end + + context 'when pipeline succeeds' do + before do + pipeline.update(status: 'success') + end + + it 'includes a "success" status' do + get api(route, current_user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v4/commit/detail') + expect(json_response['status']).to eq('success') + end + end end end end - describe "Get the diff of a commit" do - context "authorized user" do - before do - project.team << [user2, :reporter] + describe 'GET /projects/:id/repository/commits/:sha/diff' do + let(:commit) { project.repository.commit } + let(:commit_id) { commit.id } + let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/diff" } + + shared_examples_for 'ref diff' do + it 'returns the diff of the selected commit' do + get api(route, current_user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response.size).to be >= 1 + expect(json_response.first.keys).to include 'diff' end - it "returns the diff of the selected commit" do - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff", user) - expect(response).to have_http_status(200) + context 'when ref does not exist' do + let(:commit_id) { 'unknown' } - expect(json_response).to be_an Array - expect(json_response.length).to be >= 1 - expect(json_response.first.keys).to include "diff" + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + let(:message) { '404 Commit Not Found' } + end end - it "returns a 404 error if invalid commit" do - get api("/projects/#{project.id}/repository/commits/invalid_sha/diff", user) - expect(response).to have_http_status(404) + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end end end - context "unauthorized user" do - it "does not return the diff of the selected commit" do - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/diff") - expect(response).to have_http_status(401) + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } + + it_behaves_like 'ref diff' + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } + end + end + + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + it_behaves_like 'ref diff' + + context 'when branch contains a dot' do + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref diff' + end + + context 'when branch contains a slash' do + let(:commit_id) { branch_with_slash.name } + + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + end + end + + context 'when branch contains an escaped slash' do + let(:commit_id) { CGI.escape(branch_with_slash.name) } + + it_behaves_like 'ref diff' + end + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'ref diff' + + context 'when branch contains a dot' do + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref diff' + end end end end - describe 'Get the comments of a commit' do - context 'authorized user' do - it 'returns merge_request comments' do - get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user) - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.length).to eq(2) - expect(json_response.first['note']).to eq('a comment on a commit') - expect(json_response.first['author']['id']).to eq(user.id) + describe 'GET /projects/:id/repository/commits/:sha/comments' do + let(:commit) { project.repository.commit } + let(:commit_id) { commit.id } + let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/comments" } + + shared_examples_for 'ref comments' do + context 'when ref exists' do + before do + create(:note_on_commit, author: user, project: project, commit_id: commit.id, note: 'a comment on a commit') + create(:note_on_commit, author: user, project: project, commit_id: commit.id, note: 'another comment on a commit') + end + + it 'returns the diff of the selected commit' do + get api(route, current_user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/commit_notes') + expect(json_response.size).to eq(2) + expect(json_response.first['note']).to eq('a comment on a commit') + expect(json_response.first['author']['id']).to eq(user.id) + end end - it 'returns a 404 error if merge_request_id not found' do - get api("/projects/#{project.id}/repository/commits/1234ab/comments", user) - expect(response).to have_http_status(404) + context 'when ref does not exist' do + let(:commit_id) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + let(:message) { '404 Commit Not Found' } + end + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end + end + end + + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } + + it_behaves_like 'ref comments' + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } end end - context 'unauthorized user' do - it 'does not return the diff of the selected commit' do - get api("/projects/#{project.id}/repository/commits/1234ab/comments") - expect(response).to have_http_status(401) + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + it_behaves_like 'ref comments' + + context 'when branch contains a dot' do + let(:commit) { project.repository.commit(branch_with_dot.name) } + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref comments' + end + + context 'when branch contains a slash' do + let(:commit) { project.repository.commit(branch_with_slash.name) } + let(:commit_id) { branch_with_slash.name } + + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + end + end + + context 'when branch contains an escaped slash' do + let(:commit) { project.repository.commit(branch_with_slash.name) } + let(:commit_id) { CGI.escape(branch_with_slash.name) } + + it_behaves_like 'ref comments' + end + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'ref comments' + + context 'when branch contains a dot' do + let(:commit) { project.repository.commit(branch_with_dot.name) } + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref comments' + end end end context 'when the commit is present on two projects' do - let(:forked_project) { create(:project, :repository, creator: user2, namespace: user2.namespace) } - let!(:forked_project_note) { create(:note_on_commit, author: user2, project: forked_project, commit_id: forked_project.repository.commit.id, note: 'a comment on a commit for fork') } + let(:forked_project) { create(:project, :repository, creator: guest, namespace: guest.namespace) } + let!(:forked_project_note) { create(:note_on_commit, author: guest, project: forked_project, commit_id: forked_project.repository.commit.id, note: 'a comment on a commit for fork') } + let(:project_id) { forked_project.id } + let(:commit_id) { forked_project.repository.commit.id } it 'returns the comments for the target project' do - get api("/projects/#{forked_project.id}/repository/commits/#{forked_project.repository.commit.id}/comments", user2) + get api(route, guest) - expect(response).to have_http_status(200) - expect(json_response.length).to eq(1) + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/commit_notes') + expect(json_response.size).to eq(1) expect(json_response.first['note']).to eq('a comment on a commit for fork') - expect(json_response.first['author']['id']).to eq(user2.id) + expect(json_response.first['author']['id']).to eq(guest.id) end end end describe 'POST :id/repository/commits/:sha/cherry_pick' do - let(:master_pickable_commit) { project.commit('7d3b0f7cff5f37573aea97cebfd5692ea1689924') } + let(:commit) { project.commit('7d3b0f7cff5f37573aea97cebfd5692ea1689924') } + let(:commit_id) { commit.id } + let(:branch) { 'master' } + let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/cherry_pick" } + + shared_examples_for 'ref cherry-pick' do + context 'when ref exists' do + it 'cherry-picks the ref commit' do + post api(route, current_user), branch: branch + + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/commit/basic') + expect(json_response['title']).to eq(commit.title) + expect(json_response['message']).to eq(commit.message) + expect(json_response['author_name']).to eq(commit.author_name) + expect(json_response['committer_name']).to eq(user.name) + end + end - context 'authorized user' do - it 'cherry picks a commit' do - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'master' + context 'when repository is disabled' do + include_context 'disabled repository' - expect(response).to have_http_status(201) - expect(json_response['title']).to eq(master_pickable_commit.title) - expect(json_response['message']).to eq(master_pickable_commit.message) - expect(json_response['author_name']).to eq(master_pickable_commit.author_name) - expect(json_response['committer_name']).to eq(user.name) + it_behaves_like '403 response' do + let(:request) { post api(route, current_user), branch: 'master' } + end end + end - it 'returns 400 if commit is already included in the target branch' do - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'markdown' + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } - expect(response).to have_http_status(400) - expect(json_response['message']).to include('Sorry, we cannot cherry-pick this commit automatically.') + it_behaves_like '403 response' do + let(:request) { post api(route), branch: 'master' } + end + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { post api(route), branch: 'master' } + let(:message) { '404 Project Not Found' } end + end - it 'returns 400 if you are not allowed to push to the target branch' do - project.team << [user2, :developer] - protected_branch = create(:protected_branch, project: project, name: 'feature') + context 'when authenticated', 'as an owner' do + let(:current_user) { user } - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user2), branch: protected_branch.name + it_behaves_like 'ref cherry-pick' - expect(response).to have_http_status(400) - expect(json_response['message']).to eq('You are not allowed to push into this branch') + context 'when ref does not exist' do + let(:commit_id) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { post api(route, current_user), branch: 'master' } + let(:message) { '404 Commit Not Found' } + end + end + + context 'when branch is missing' do + it_behaves_like '400 response' do + let(:request) { post api(route, current_user) } + end end - it 'returns 400 for missing parameters' do - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user) + context 'when branch does not exist' do + it_behaves_like '404 response' do + let(:request) { post api(route, current_user), branch: 'foo' } + let(:message) { '404 Branch Not Found' } + end + end - expect(response).to have_http_status(400) - expect(json_response['error']).to eq('branch is missing') + context 'when commit is already included in the target branch' do + it_behaves_like '400 response' do + let(:request) { post api(route, current_user), branch: 'markdown' } + end end - it 'returns 404 if commit is not found' do - post api("/projects/#{project.id}/repository/commits/abcd0123/cherry_pick", user), branch: 'master' + context 'when ref contains a dot' do + let(:commit) { project.repository.commit(branch_with_dot.name) } + let(:commit_id) { branch_with_dot.name } - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Commit Not Found') + it_behaves_like 'ref cherry-pick' end - it 'returns 404 if branch is not found' do - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user), branch: 'foo' + context 'when ref contains a slash' do + let(:commit_id) { branch_with_slash.name } - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('404 Branch Not Found') + it_behaves_like '404 response' do + let(:request) { post api(route, current_user), branch: 'master' } + end end - it 'returns 400 for missing parameters' do - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick", user) + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } - expect(response).to have_http_status(400) - expect(json_response['error']).to eq('branch is missing') + it_behaves_like 'ref cherry-pick' + + context 'when ref contains a dot' do + let(:commit) { project.repository.commit(branch_with_dot.name) } + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref cherry-pick' + end end end - context 'unauthorized user' do - it 'does not cherry pick the commit' do - post api("/projects/#{project.id}/repository/commits/#{master_pickable_commit.id}/cherry_pick"), branch: 'master' + context 'when authenticated', 'as a developer' do + let(:current_user) { guest } + + before do + project.add_developer(guest) + end + + context 'when branch is protected' do + before do + create(:protected_branch, project: project, name: 'feature') + end + + it 'returns 400 if you are not allowed to push to the target branch' do + post api(route, current_user), branch: 'feature' - expect(response).to have_http_status(401) + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq('You are not allowed to push into this branch') + end end end end - describe 'Post comment to commit' do - context 'authorized user' do - it 'returns comment' do - post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment' - expect(response).to have_http_status(201) - expect(json_response['note']).to eq('My comment') - expect(json_response['path']).to be_nil - expect(json_response['line']).to be_nil - expect(json_response['line_type']).to be_nil + describe 'POST /projects/:id/repository/commits/:sha/comments' do + let(:commit) { project.repository.commit } + let(:commit_id) { commit.id } + let(:note) { 'My comment' } + let(:route) { "/projects/#{project_id}/repository/commits/#{commit_id}/comments" } + + shared_examples_for 'ref new comment' do + context 'when ref exists' do + it 'creates the comment' do + post api(route, current_user), note: note + + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/commit_note') + expect(json_response['note']).to eq('My comment') + expect(json_response['path']).to be_nil + expect(json_response['line']).to be_nil + expect(json_response['line_type']).to be_nil + end end + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { post api(route, current_user), note: 'My comment' } + end + end + end + + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } + + it_behaves_like '400 response' do + let(:request) { post api(route), note: 'My comment' } + end + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { post api(route), note: 'My comment' } + let(:message) { '404 Project Not Found' } + end + end + + context 'when authenticated', 'as an owner' do + let(:current_user) { user } + + it_behaves_like 'ref new comment' + it 'returns the inline comment' do - post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 1, line_type: 'new' + post api(route, current_user), note: 'My comment', path: project.repository.commit.raw_diffs.first.new_path, line: 1, line_type: 'new' - expect(response).to have_http_status(201) + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/commit_note') expect(json_response['note']).to eq('My comment') expect(json_response['path']).to eq(project.repository.commit.raw_diffs.first.new_path) expect(json_response['line']).to eq(1) expect(json_response['line_type']).to eq('new') end + context 'when ref does not exist' do + let(:commit_id) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { post api(route, current_user), note: 'My comment' } + let(:message) { '404 Commit Not Found' } + end + end + it 'returns 400 if note is missing' do - post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments", user) - expect(response).to have_http_status(400) + post api(route, current_user) + + expect(response).to have_gitlab_http_status(400) end - it 'returns 404 if note is attached to non existent commit' do - post api("/projects/#{project.id}/repository/commits/1234ab/comments", user), note: 'My comment' - expect(response).to have_http_status(404) + context 'when ref contains a dot' do + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref new comment' end - end - context 'unauthorized user' do - it 'does not return the diff of the selected commit' do - post api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}/comments") - expect(response).to have_http_status(401) + context 'when ref contains a slash' do + let(:commit_id) { branch_with_slash.name } + + it_behaves_like '404 response' do + let(:request) { post api(route, current_user), note: 'My comment' } + end + end + + context 'when ref contains an escaped slash' do + let(:commit_id) { CGI.escape(branch_with_slash.name) } + + it_behaves_like 'ref new comment' + end + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'ref new comment' + + context 'when ref contains a dot' do + let(:commit_id) { branch_with_dot.name } + + it_behaves_like 'ref new comment' + end end end end diff --git a/spec/requests/api/events_spec.rb b/spec/requests/api/events_spec.rb index 7a847442469..48db964d782 100644 --- a/spec/requests/api/events_spec.rb +++ b/spec/requests/api/events_spec.rb @@ -138,5 +138,40 @@ describe API::Events do expect(response).to have_http_status(404) end end + + context 'when exists some events' do + before do + create_event(note1) + create_event(note2) + create_event(merge_request1) + end + + let(:note1) { create(:note_on_merge_request, project: private_project, author: user) } + let(:note2) { create(:note_on_issue, project: private_project, author: user) } + let(:merge_request1) { create(:merge_request, state: 'closed', author: user, assignee: user, source_project: private_project, title: 'Test') } + let(:merge_request2) { create(:merge_request, state: 'closed', author: user, assignee: user, source_project: private_project, title: 'Test') } + + it 'avoids N+1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + get api("/projects/#{private_project.id}/events", user) + end.count + + create_event(merge_request2) + + expect do + get api("/projects/#{private_project.id}/events", user) + end.not_to exceed_query_limit(control_count) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response[0]).to include('target_type' => 'MergeRequest', 'target_id' => merge_request2.id) + expect(json_response[1]).to include('target_type' => 'MergeRequest', 'target_id' => merge_request1.id) + end + + def create_event(target) + create(:event, project: private_project, author: user, target: target) + end + end end end diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb index ef7d0c3ee41..9884c1ec206 100644 --- a/spec/requests/api/tags_spec.rb +++ b/spec/requests/api/tags_spec.rb @@ -1,66 +1,85 @@ require 'spec_helper' -require 'mime/types' describe API::Tags do - include RepoHelpers - let(:user) { create(:user) } - let(:user2) { create(:user) } - let!(:project) { create(:project, :repository, creator: user) } - let!(:master) { create(:project_member, :master, user: user, project: project) } - let!(:guest) { create(:project_member, :guest, user: user2, project: project) } + let(:guest) { create(:user).tap { |u| project.add_guest(u) } } + let(:project) { create(:project, :repository, creator: user, path: 'my.project') } + let(:tag_name) { project.repository.find_tag('v1.1.0').name } - describe "GET /projects/:id/repository/tags" do - let(:tag_name) { project.repository.tag_names.sort.reverse.first } - let(:description) { 'Awesome release!' } + let(:project_id) { project.id } + let(:current_user) { nil } + + before do + project.add_master(user) + end + + describe 'GET /projects/:id/repository/tags' do + let(:route) { "/projects/#{project_id}/repository/tags" } shared_examples_for 'repository tags' do it 'returns the repository tags' do - get api("/projects/#{project.id}/repository/tags", current_user) + get api(route, current_user) - expect(response).to have_http_status(200) + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/tags') expect(response).to include_pagination_headers - expect(json_response).to be_an Array expect(json_response.first['name']).to eq(tag_name) end - end - context 'when unauthenticated' do - it_behaves_like 'repository tags' do - let(:project) { create(:project, :public, :repository) } - let(:current_user) { nil } + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end end end - context 'when authenticated' do - it_behaves_like 'repository tags' do - let(:current_user) { user } + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } + + it_behaves_like 'repository tags' + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } end end - context 'without releases' do - it "returns an array of project tags" do - get api("/projects/#{project.id}/repository/tags", user) + context 'when authenticated', 'as a master' do + let(:current_user) { user } - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.first['name']).to eq(tag_name) + it_behaves_like 'repository tags' + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'repository tags' + end + end + + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route, guest) } end end context 'with releases' do + let(:description) { 'Awesome release!' } + before do release = project.releases.find_or_initialize_by(tag: tag_name) release.update_attributes(description: description) end - it "returns an array of project tags with release info" do - get api("/projects/#{project.id}/repository/tags", user) + it 'returns an array of project tags with release info' do + get api(route, user) - expect(response).to have_http_status(200) + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/tags') expect(response).to include_pagination_headers - expect(json_response).to be_an Array expect(json_response.first['name']).to eq(tag_name) expect(json_response.first['message']).to eq('Version 1.1.0') expect(json_response.first['release']['description']).to eq(description) @@ -69,210 +88,342 @@ describe API::Tags do end describe 'GET /projects/:id/repository/tags/:tag_name' do - let(:tag_name) { project.repository.tag_names.sort.reverse.first } + let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}" } shared_examples_for 'repository tag' do - it 'returns the repository tag' do - get api("/projects/#{project.id}/repository/tags/#{tag_name}", current_user) - - expect(response).to have_http_status(200) + it 'returns the repository branch' do + get api(route, current_user) + expect(response).to have_gitlab_http_status(200) + expect(response).to match_response_schema('public_api/v4/tag') expect(json_response['name']).to eq(tag_name) end - it 'returns 404 for an invalid tag name' do - get api("/projects/#{project.id}/repository/tags/foobar", current_user) + context 'when tag does not exist' do + let(:tag_name) { 'unknown' } - expect(response).to have_http_status(404) + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + let(:message) { '404 Tag Not Found' } + end + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end end end - context 'when unauthenticated' do - it_behaves_like 'repository tag' do - let(:project) { create(:project, :public, :repository) } - let(:current_user) { nil } + context 'when unauthenticated', 'and project is public' do + let(:project) { create(:project, :public, :repository) } + + it_behaves_like 'repository tag' + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } end end - context 'when authenticated' do - it_behaves_like 'repository tag' do - let(:current_user) { user } + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + it_behaves_like 'repository tag' + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'repository tag' + end + end + + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route, guest) } end end end describe 'POST /projects/:id/repository/tags' do - context 'lightweight tags' do + let(:tag_name) { 'new_tag' } + let(:route) { "/projects/#{project_id}/repository/tags" } + + shared_examples_for 'repository new tag' do it 'creates a new tag' do - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'v7.0.1', - ref: 'master' + post api(route, current_user), tag_name: tag_name, ref: 'master' - expect(response).to have_http_status(201) - expect(json_response['name']).to eq('v7.0.1') + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/tag') + expect(json_response['name']).to eq(tag_name) end - end - context 'lightweight tags with release notes' do - it 'creates a new tag' do - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'v7.0.1', - ref: 'master', - release_description: 'Wow' + context 'when repository is disabled' do + include_context 'disabled repository' - expect(response).to have_http_status(201) - expect(json_response['name']).to eq('v7.0.1') - expect(json_response['release']['description']).to eq('Wow') + it_behaves_like '403 response' do + let(:request) { post api(route, current_user) } + end end end - describe 'DELETE /projects/:id/repository/tags/:tag_name' do - let(:tag_name) { project.repository.tag_names.sort.reverse.first } + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { post api(route) } + let(:message) { '404 Project Not Found' } + end + end - before do - allow_any_instance_of(Repository).to receive(:rm_tag).and_return(true) + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { post api(route, guest) } end + end + + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + context "when a protected branch doesn't already exist" do + it_behaves_like 'repository new tag' - context 'delete tag' do - it 'deletes an existing tag' do - delete api("/projects/#{project.id}/repository/tags/#{tag_name}", user) + context 'when tag contains a dot' do + let(:tag_name) { 'v7.0.1' } - expect(response).to have_http_status(204) + it_behaves_like 'repository new tag' end - it 'raises 404 if the tag does not exist' do - delete api("/projects/#{project.id}/repository/tags/foobar", user) - expect(response).to have_http_status(404) + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'repository new tag' + + context 'when tag contains a dot' do + let(:tag_name) { 'v7.0.1' } + + it_behaves_like 'repository new tag' + end end end - end - context 'annotated tag' do - it 'creates a new annotated tag' do - # Identity must be set in .gitconfig to create annotated tag. - repo_path = project.repository.path_to_repo - system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.name #{user.name})) - system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.email #{user.email})) + it 'returns 400 if tag name is invalid' do + post api(route, current_user), tag_name: 'new design', ref: 'master' + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq('Tag name invalid') + end + + it 'returns 400 if tag already exists' do + post api(route, current_user), tag_name: 'new_design1', ref: 'master' - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'v7.1.0', - ref: 'master', - message: 'Release 7.1.0' + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/tag') - expect(response).to have_http_status(201) - expect(json_response['name']).to eq('v7.1.0') - expect(json_response['message']).to eq('Release 7.1.0') + post api(route, current_user), tag_name: 'new_design1', ref: 'master' + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq('Tag new_design1 already exists') end - end - it 'denies for user without push access' do - post api("/projects/#{project.id}/repository/tags", user2), - tag_name: 'v1.9.0', - ref: '621491c677087aa243f165eab467bfdfbee00be1' - expect(response).to have_http_status(403) + it 'returns 400 if ref name is invalid' do + post api(route, current_user), tag_name: 'new_design3', ref: 'foo' + + expect(response).to have_gitlab_http_status(400) + expect(json_response['message']).to eq('Target foo is invalid') + end + + context 'lightweight tags with release notes' do + it 'creates a new tag' do + post api(route, current_user), tag_name: tag_name, ref: 'master', release_description: 'Wow' + + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/tag') + expect(json_response['name']).to eq(tag_name) + expect(json_response['release']['description']).to eq('Wow') + end + end + + context 'annotated tag' do + it 'creates a new annotated tag' do + # Identity must be set in .gitconfig to create annotated tag. + repo_path = project.repository.path_to_repo + system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.name #{user.name})) + system(*%W(#{Gitlab.config.git.bin_path} --git-dir=#{repo_path} config user.email #{user.email})) + + post api(route, current_user), tag_name: 'v7.1.0', ref: 'master', message: 'Release 7.1.0' + + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/tag') + expect(json_response['name']).to eq('v7.1.0') + expect(json_response['message']).to eq('Release 7.1.0') + end + end end + end + + describe 'DELETE /projects/:id/repository/tags/:tag_name' do + let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}" } - it 'returns 400 if tag name is invalid' do - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'v 1.0.0', - ref: 'master' - expect(response).to have_http_status(400) - expect(json_response['message']).to eq('Tag name invalid') + before do + allow_any_instance_of(Repository).to receive(:rm_tag).and_return(true) end - it 'returns 400 if tag already exists' do - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'v8.0.0', - ref: 'master' - expect(response).to have_http_status(201) - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'v8.0.0', - ref: 'master' - expect(response).to have_http_status(400) - expect(json_response['message']).to eq('Tag v8.0.0 already exists') + shared_examples_for 'repository delete tag' do + it 'deletes a tag' do + delete api(route, current_user) + + expect(response).to have_gitlab_http_status(204) + end + + context 'when tag does not exist' do + let(:tag_name) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { delete api(route, current_user) } + let(:message) { 'No such tag' } + end + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { delete api(route, current_user) } + end + end end - it 'returns 400 if ref name is invalid' do - post api("/projects/#{project.id}/repository/tags", user), - tag_name: 'mytag', - ref: 'foo' - expect(response).to have_http_status(400) - expect(json_response['message']).to eq('Target foo is invalid') + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + it_behaves_like 'repository delete tag' + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'repository delete tag' + end end end describe 'POST /projects/:id/repository/tags/:tag_name/release' do - let(:tag_name) { project.repository.tag_names.first } + let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}/release" } let(:description) { 'Awesome release!' } - it 'creates description for existing git tag' do - post api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), - description: description + shared_examples_for 'repository new release' do + it 'creates description for existing git tag' do + post api(route, user), description: description - expect(response).to have_http_status(201) - expect(json_response['tag_name']).to eq(tag_name) - expect(json_response['description']).to eq(description) - end + expect(response).to have_gitlab_http_status(201) + expect(response).to match_response_schema('public_api/v4/release') + expect(json_response['tag_name']).to eq(tag_name) + expect(json_response['description']).to eq(description) + end + + context 'when tag does not exist' do + let(:tag_name) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { post api(route, current_user), description: description } + let(:message) { 'Tag does not exist' } + end + end - it 'returns 404 if the tag does not exist' do - post api("/projects/#{project.id}/repository/tags/foobar/release", user), - description: description + context 'when repository is disabled' do + include_context 'disabled repository' - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('Tag does not exist') + it_behaves_like '403 response' do + let(:request) { post api(route, current_user), description: description } + end + end end - context 'on tag with existing release' do - before do - release = project.releases.find_or_initialize_by(tag: tag_name) - release.update_attributes(description: description) + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + it_behaves_like 'repository new release' + + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'repository new release' end - it 'returns 409 if there is already a release' do - post api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), - description: description + context 'on tag with existing release' do + before do + release = project.releases.find_or_initialize_by(tag: tag_name) + release.update_attributes(description: description) + end + + it 'returns 409 if there is already a release' do + post api(route, user), description: description - expect(response).to have_http_status(409) - expect(json_response['message']).to eq('Release already exists') + expect(response).to have_gitlab_http_status(409) + expect(json_response['message']).to eq('Release already exists') + end end end end describe 'PUT id/repository/tags/:tag_name/release' do - let(:tag_name) { project.repository.tag_names.first } + let(:route) { "/projects/#{project_id}/repository/tags/#{tag_name}/release" } let(:description) { 'Awesome release!' } let(:new_description) { 'The best release!' } - context 'on tag with existing release' do - before do - release = project.releases.find_or_initialize_by(tag: tag_name) - release.update_attributes(description: description) + shared_examples_for 'repository update release' do + context 'on tag with existing release' do + before do + release = project.releases.find_or_initialize_by(tag: tag_name) + release.update_attributes(description: description) + end + + it 'updates the release description' do + put api(route, current_user), description: new_description + + expect(response).to have_gitlab_http_status(200) + expect(json_response['tag_name']).to eq(tag_name) + expect(json_response['description']).to eq(new_description) + end end - it 'updates the release description' do - put api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), - description: new_description + context 'when tag does not exist' do + let(:tag_name) { 'unknown' } - expect(response).to have_http_status(200) - expect(json_response['tag_name']).to eq(tag_name) - expect(json_response['description']).to eq(new_description) + it_behaves_like '404 response' do + let(:request) { put api(route, current_user), description: new_description } + let(:message) { 'Tag does not exist' } + end end - end - it 'returns 404 if the tag does not exist' do - put api("/projects/#{project.id}/repository/tags/foobar/release", user), - description: new_description + context 'when repository is disabled' do + include_context 'disabled repository' - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('Tag does not exist') + it_behaves_like '403 response' do + let(:request) { put api(route, current_user), description: new_description } + end + end end - it 'returns 404 if the release does not exist' do - put api("/projects/#{project.id}/repository/tags/#{tag_name}/release", user), - description: new_description + context 'when authenticated', 'as a master' do + let(:current_user) { user } + + it_behaves_like 'repository update release' - expect(response).to have_http_status(404) - expect(json_response['message']).to eq('Release does not exist') + context 'requesting with the escaped project full path' do + let(:project_id) { CGI.escape(project.full_path) } + + it_behaves_like 'repository update release' + end + + context 'when release does not exist' do + it_behaves_like '404 response' do + let(:request) { put api(route, current_user), description: new_description } + let(:message) { 'Release does not exist' } + end + end end end end diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb index 18cd9e9c006..a2fd5b7daae 100644 --- a/spec/serializers/merge_request_entity_spec.rb +++ b/spec/serializers/merge_request_entity_spec.rb @@ -47,7 +47,7 @@ describe MergeRequestEntity do :cancel_merge_when_pipeline_succeeds_path, :create_issue_to_resolve_discussions_path, :source_branch_path, :target_branch_commits_path, - :target_branch_tree_path, :commits_count) + :target_branch_tree_path, :commits_count, :merge_ongoing) end it 'has email_patches_path' do diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index c1298ed9cae..1e39f80699c 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -63,8 +63,6 @@ module TestEnv # See gitlab.yml.example test section for paths # def init(opts = {}) - Rake.application.rake_require 'tasks/gitlab/helpers' - Rake::Task.define_task :environment # Disable mailer for spinach tests disable_mailer if opts[:mailer] == false @@ -124,41 +122,50 @@ module TestEnv end def setup_gitlab_shell - gitlab_shell_dir = File.dirname(Gitlab.config.gitlab_shell.path) - gitlab_shell_needs_update = component_needs_update?(gitlab_shell_dir, + puts "\n==> Setting up Gitlab Shell..." + start = Time.now + gitlab_shell_dir = Gitlab.config.gitlab_shell.path + shell_needs_update = component_needs_update?(gitlab_shell_dir, Gitlab::Shell.version_required) - Rake.application.rake_require 'tasks/gitlab/shell' - unless !gitlab_shell_needs_update || Rake.application.invoke_task('gitlab:shell:install') + unless !shell_needs_update || system('rake', 'gitlab:shell:install') + puts "\nGitLab Shell failed to install, cleaning up #{gitlab_shell_dir}!\n" FileUtils.rm_rf(gitlab_shell_dir) - raise "Can't install gitlab-shell" + exit 1 end + + puts " GitLab Shell setup in #{Time.now - start} seconds...\n" end def setup_gitaly + puts "\n==> Setting up Gitaly..." + start = Time.now socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') gitaly_dir = File.dirname(socket_path) if gitaly_dir_stale?(gitaly_dir) - puts "rm -rf #{gitaly_dir}" - FileUtils.rm_rf(gitaly_dir) + puts " Gitaly is outdated, cleaning up #{gitaly_dir}!" + FileUtils.rm_rf(gitaly_dir) end gitaly_needs_update = component_needs_update?(gitaly_dir, Gitlab::GitalyClient.expected_server_version) - Rake.application.rake_require 'tasks/gitlab/gitaly' - unless !gitaly_needs_update || Rake.application.invoke_task("gitlab:gitaly:install[#{gitaly_dir}]") + unless !gitaly_needs_update || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") + puts "\nGitaly failed to install, cleaning up #{gitaly_dir}!\n" FileUtils.rm_rf(gitaly_dir) - raise "Can't install gitaly" + exit 1 end start_gitaly(gitaly_dir) + puts " Gitaly setup in #{Time.now - start} seconds...\n" end def gitaly_dir_stale?(dir) gitaly_executable = File.join(dir, 'gitaly') - !File.exist?(gitaly_executable) || (File.mtime(gitaly_executable) < File.mtime(Rails.root.join('GITALY_SERVER_VERSION'))) + return false unless File.exist?(gitaly_executable) + + File.mtime(gitaly_executable) < File.mtime(Rails.root.join('GITALY_SERVER_VERSION')) end def start_gitaly(gitaly_dir) @@ -243,6 +250,14 @@ module TestEnv "#{forked_repo_path}_bare" end + def with_empty_bare_repository(name = nil) + path = Rails.root.join('tmp/tests', name || 'empty-bare-repository').to_s + + yield(Rugged::Repository.init_at(path, :bare)) + ensure + FileUtils.rm_rf(path) + end + private def factory_repo_path diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index 6d453c19fc3..cc932a4ec4c 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -41,6 +41,8 @@ describe 'gitlab:gitaly namespace rake task' do end describe 'gmake/make' do + let(:command_preamble) { %w[/usr/bin/env -u RUBYOPT] } + before(:all) do @old_env_ci = ENV.delete('CI') end @@ -57,12 +59,12 @@ describe 'gitlab:gitaly namespace rake task' do context 'gmake is available' do before do expect_any_instance_of(Object).to receive(:checkout_or_clone_version) - allow_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true) + allow_any_instance_of(Object).to receive(:run_command!).with(command_preamble + ['gmake']).and_return(true) end it 'calls gmake in the gitaly directory' do expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['/usr/bin/gmake', 0]) - expect_any_instance_of(Object).to receive(:run_command!).with(['gmake']).and_return(true) + expect_any_instance_of(Object).to receive(:run_command!).with(command_preamble + ['gmake']).and_return(true) run_rake_task('gitlab:gitaly:install', clone_path) end @@ -71,12 +73,12 @@ describe 'gitlab:gitaly namespace rake task' do context 'gmake is not available' do before do expect_any_instance_of(Object).to receive(:checkout_or_clone_version) - allow_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true) + allow_any_instance_of(Object).to receive(:run_command!).with(command_preamble + ['make']).and_return(true) end it 'calls make in the gitaly directory' do expect(Gitlab::Popen).to receive(:popen).with(%w[which gmake]).and_return(['', 42]) - expect_any_instance_of(Object).to receive(:run_command!).with(['make']).and_return(true) + expect_any_instance_of(Object).to receive(:run_command!).with(command_preamble + ['make']).and_return(true) run_rake_task('gitlab:gitaly:install', clone_path) end diff --git a/spec/workers/merge_worker_spec.rb b/spec/workers/merge_worker_spec.rb index 303193bab9b..ee51000161a 100644 --- a/spec/workers/merge_worker_spec.rb +++ b/spec/workers/merge_worker_spec.rb @@ -27,4 +27,15 @@ describe MergeWorker do expect(source_project.repository.branch_names).not_to include('markdown') end end + + it 'persists merge_jid' do + merge_request = create(:merge_request, merge_jid: nil) + user = create(:user) + worker = described_class.new + + allow(worker).to receive(:jid) { '999' } + + expect { worker.perform(merge_request.id, user.id, {}) } + .to change { merge_request.reload.merge_jid }.from(nil).to('999') + end end diff --git a/spec/workers/stuck_merge_jobs_worker_spec.rb b/spec/workers/stuck_merge_jobs_worker_spec.rb new file mode 100644 index 00000000000..a5ad78393c9 --- /dev/null +++ b/spec/workers/stuck_merge_jobs_worker_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe StuckMergeJobsWorker do + describe 'perform' do + let(:worker) { described_class.new } + + context 'merge job identified as completed' do + it 'updates merge request to merged when locked but has merge_commit_sha' do + allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123 456)) + mr_with_sha = create(:merge_request, :locked, merge_jid: '123', state: :locked, merge_commit_sha: 'foo-bar-baz') + mr_without_sha = create(:merge_request, :locked, merge_jid: '123', state: :locked, merge_commit_sha: nil) + + worker.perform + + expect(mr_with_sha.reload).to be_merged + expect(mr_without_sha.reload).to be_opened + end + + it 'updates merge request to opened when locked but has not been merged' do + allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123)) + merge_request = create(:merge_request, :locked, merge_jid: '123', state: :locked) + + worker.perform + + expect(merge_request.reload).to be_opened + end + + it 'logs updated stuck merge job ids' do + allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return(%w(123 456)) + + create(:merge_request, :locked, merge_jid: '123') + create(:merge_request, :locked, merge_jid: '456') + + expect(Rails).to receive_message_chain(:logger, :info).with('Updated state of locked merge jobs. JIDs: 123, 456') + + worker.perform + end + end + + context 'merge job not identified as completed' do + it 'does not change merge request state when job is not completed yet' do + allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([]) + + merge_request = create(:merge_request, :locked, merge_jid: '123') + + expect { worker.perform }.not_to change { merge_request.reload.state }.from('locked') + end + end + end +end diff --git a/yarn.lock b/yarn.lock index b1cd07a8e9c..c9e1b630a9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -254,6 +254,13 @@ aws4@^1.2.1: version "1.6.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" +axios@^0.16.2: + version "0.16.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.16.2.tgz#ba4f92f17167dfbab40983785454b9ac149c3c6d" + dependencies: + follow-redirects "^1.2.3" + is-buffer "^1.1.5" + babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0: version "6.22.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4" @@ -903,6 +910,10 @@ block-stream@*: dependencies: inherits "~2.0.0" +bluebird@^2.10.2: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + bluebird@^3.0.5, bluebird@^3.1.1, bluebird@^3.3.0: version "3.4.7" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3" @@ -1424,6 +1435,19 @@ cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" +copy-webpack-plugin@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.0.1.tgz#9728e383b94316050d0c7463958f2b85c0aa8200" + dependencies: + bluebird "^2.10.2" + fs-extra "^0.26.4" + glob "^6.0.4" + is-glob "^3.1.0" + loader-utils "^0.2.15" + lodash "^4.3.0" + minimatch "^3.0.0" + node-dir "^0.1.10" + core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" @@ -1604,7 +1628,13 @@ d3@^3.5.11: version "3.5.11" resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.11.tgz#d130750eed0554db70e8432102f920a12407b69c" -d@^0.1.1, d@~0.1.1: +d@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" + dependencies: + es5-ext "^0.10.9" + +d@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/d/-/d-0.1.1.tgz#da184c535d18d8ee7ba2aa229b914009fae11309" dependencies: @@ -1642,7 +1672,7 @@ debug@2.6.7: dependencies: ms "2.0.0" -debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.6.6, debug@^2.6.8: +debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.4.5, debug@^2.6.6, debug@^2.6.8: version "2.6.8" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" dependencies: @@ -1978,52 +2008,52 @@ error-ex@^1.2.0: dependencies: is-arrayish "^0.2.1" -es5-ext@^0.10.7, es5-ext@^0.10.8, es5-ext@~0.10.11, es5-ext@~0.10.2, es5-ext@~0.10.7: - version "0.10.12" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.12.tgz#aa84641d4db76b62abba5e45fd805ecbab140047" +es5-ext@^0.10.14, es5-ext@^0.10.8, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.24" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.24.tgz#a55877c9924bc0c8d9bd3c2cbe17495ac1709b14" dependencies: es6-iterator "2" es6-symbol "~3.1" -es6-iterator@2: - version "2.0.0" - resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.0.tgz#bd968567d61635e33c0b80727613c9cb4b096bac" +es6-iterator@2, es6-iterator@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" dependencies: - d "^0.1.1" - es5-ext "^0.10.7" - es6-symbol "3" + d "1" + es5-ext "^0.10.14" + es6-symbol "^3.1" es6-map@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.4.tgz#a34b147be224773a4d7da8072794cefa3632b897" + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-set "~0.1.3" - es6-symbol "~3.1.0" - event-emitter "~0.3.4" + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-set "~0.1.5" + es6-symbol "~3.1.1" + event-emitter "~0.3.5" es6-promise@^3.0.2, es6-promise@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6" -es6-set@~0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.4.tgz#9516b6761c2964b92ff479456233a247dc707ce8" +es6-set@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" dependencies: - d "~0.1.1" - es5-ext "~0.10.11" - es6-iterator "2" - es6-symbol "3" - event-emitter "~0.3.4" + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-symbol "3.1.1" + event-emitter "~0.3.5" -es6-symbol@3, es6-symbol@~3.1, es6-symbol@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.0.tgz#94481c655e7a7cad82eba832d97d5433496d7ffa" +es6-symbol@3, es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@~3.1, es6-symbol@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" dependencies: - d "~0.1.1" - es5-ext "~0.10.11" + d "1" + es5-ext "~0.10.14" es6-weak-map@^2.0.1: version "2.0.1" @@ -2219,12 +2249,12 @@ eve-raphael@0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" -event-emitter@~0.3.4: - version "0.3.4" - resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.4.tgz#8d63ddfb4cfe1fae3b32ca265c4c720222080bb5" +event-emitter@~0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" dependencies: - d "~0.1.1" - es5-ext "~0.10.7" + d "1" + es5-ext "~0.10.14" event-stream@~3.3.0: version "3.3.4" @@ -2484,6 +2514,12 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +follow-redirects@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.2.3.tgz#01abaeca85e3609837d9fcda3167a7e42fdaca21" + dependencies: + debug "^2.4.5" + for-in@^0.1.5: version "0.1.6" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8" @@ -2524,6 +2560,16 @@ fs-access@^1.0.0: dependencies: null-check "^1.0.0" +fs-extra@^0.26.4: + version "0.26.7" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.26.7.tgz#9ae1fdd94897798edab76d0918cf42d0c3184fa9" + dependencies: + graceful-fs "^4.1.2" + jsonfile "^2.1.0" + klaw "^1.0.0" + path-is-absolute "^1.0.0" + rimraf "^2.2.8" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2621,6 +2667,16 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "2 || 3" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" @@ -2697,7 +2753,7 @@ got@^7.0.0: url-parse-lax "^1.0.0" url-to-options "^1.0.1" -graceful-fs@^4.1.11, graceful-fs@^4.1.2: +graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -2917,6 +2973,13 @@ immediate@~3.0.5: version "3.0.6" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" +imports-loader@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-0.7.1.tgz#f204b5f34702a32c1db7d48d89d5e867a0441253" + dependencies: + loader-utils "^1.0.2" + source-map "^0.5.6" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -3025,9 +3088,9 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" -is-buffer@^1.0.2: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.4.tgz#cfc86ccd5dc5a52fa80489111c6920c457e2d98b" +is-buffer@^1.0.2, is-buffer@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.5.tgz#1f3b26ef613b214b88cbca23cc6c01d87961eecc" is-builtin-module@^1.0.0: version "1.0.0" @@ -3413,6 +3476,12 @@ json5@^0.5.0, json5@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" +jsonfile@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-2.4.0.tgz#3736a2b428b87bbda0cc83b53fa3d633a35c2ae8" + optionalDependencies: + graceful-fs "^4.1.6" + jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" @@ -3520,6 +3589,12 @@ kind-of@^3.0.2: dependencies: is-buffer "^1.0.2" +klaw@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" + optionalDependencies: + graceful-fs "^4.1.9" + latest-version@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-1.0.1.tgz#72cfc46e3e8d1be651e1ebb54ea9f6ea96f374bb" @@ -3572,7 +3647,7 @@ loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" -loader-utils@^0.2.5: +loader-utils@^0.2.15, loader-utils@^0.2.5: version "0.2.16" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.16.tgz#f08632066ed8282835dff88dfb52704765adee6d" dependencies: @@ -3935,6 +4010,10 @@ moment@2.x: version "2.17.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82" +monaco-editor@0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.8.3.tgz#523bdf2d1524db2c2dfc3cae0a7b6edc48d6dea6" + mousetrap@^1.4.6: version "1.4.6" resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.4.6.tgz#eaca72e22e56d5b769b7555873b688c3332e390a" @@ -3988,6 +4067,12 @@ nested-error-stacks@^1.0.0: dependencies: inherits "~2.0.1" +node-dir@^0.1.10: + version "0.1.17" + resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5" + dependencies: + minimatch "^3.0.2" + node-forge@0.6.33: version "0.6.33" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.6.33.tgz#463811879f573d45155ad6a9f43dc296e8e85ebc" |