diff options
109 files changed, 2190 insertions, 600 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aa62a86d31d..7f665f19132 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -264,11 +264,15 @@ spinach mysql 9 10: *spinach-knapsack-mysql static-analysis: <<: *ruby-static-analysis <<: *dedicated-runner + <<: *except-docs stage: test script: - scripts/static-analysis -docs:check:links: +# Documentation checks: +# - Check validity of relative links +# - Make sure cURL examples in API docs use the full switches +docs lint: image: "registry.gitlab.com/gitlab-org/gitlab-build-images:nanoc-bootstrap-ruby-2.4-alpine" stage: test <<: *dedicated-runner @@ -276,6 +280,7 @@ docs:check:links: dependencies: [] before_script: [] script: + - scripts/lint-doc.sh - mv doc/ /nanoc/content/ - cd /nanoc # Build HTML from Markdown diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js new file mode 100644 index 00000000000..ff2f2c81971 --- /dev/null +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -0,0 +1,193 @@ +/* eslint-disable no-new */ +/* global Flash */ +import DropLab from './droplab/drop_lab'; +import ISetter from './droplab/plugins/input_setter'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = Object.assign({}, ISetter); + +const CREATE_MERGE_REQUEST = 'create-mr'; +const CREATE_BRANCH = 'create-branch'; + +export default class CreateMergeRequestDropdown { + constructor(wrapperEl) { + this.wrapperEl = wrapperEl; + this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); + this.availableButton = this.wrapperEl.querySelector('.available'); + this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); + this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); + this.unavailableButtonText = this.unavailableButton.querySelector('.text'); + + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + this.droplabInitialized = false; + this.isCreatingMergeRequest = false; + this.mergeRequestCreated = false; + this.isCreatingBranch = false; + this.branchCreated = false; + + this.init(); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + available() { + this.availableButton.classList.remove('hide'); + this.unavailableButton.classList.add('hide'); + } + + unavailable() { + this.availableButton.classList.add('hide'); + this.unavailableButton.classList.remove('hide'); + } + + enable() { + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.dropdownToggle.classList.remove('disabled'); + this.dropdownToggle.removeAttribute('disabled'); + } + + disable() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.dropdownToggle.classList.add('disabled'); + this.dropdownToggle.setAttribute('disabled', 'disabled'); + } + + hide() { + this.wrapperEl.classList.add('hide'); + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'Checking branch availability…'; + } else { + this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin'); + this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = 'New branch unavailable'; + } + } + + checkAbilityToCreateBranch() { + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.canCreatePath, + beforeSend: () => this.setUnavailableButtonState(), + }) + .done((data) => { + this.setUnavailableButtonState(false); + + if (data.can_create_branch) { + this.available(); + this.enable(); + + if (!this.droplabInitialized) { + this.droplabInitialized = true; + this.initDroplab(); + this.bindEvents(); + } + } else if (data.has_related_branch) { + this.hide(); + } + }).fail(() => { + this.unavailable(); + this.disable(); + new Flash('Failed to check if a new branch can be created.'); + }); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], + this.getDroplabConfig()); + } + + getDroplabConfig() { + return { + InputSetter: [{ + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }], + }; + } + + bindEvents() { + this.createMergeRequestButton + .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + } + + isBusy() { + return this.isCreatingMergeRequest || + this.mergeRequestCreated || + this.isCreatingBranch || + this.branchCreated; + } + + onClickCreateMergeRequestButton(e) { + let xhr = null; + e.preventDefault(); + + if (this.isBusy()) { + return; + } + + if (e.target.dataset.action === CREATE_MERGE_REQUEST) { + xhr = this.createMergeRequest(); + } else if (e.target.dataset.action === CREATE_BRANCH) { + xhr = this.createBranch(); + } + + xhr.fail(() => { + this.isCreatingMergeRequest = false; + this.isCreatingBranch = false; + }); + + xhr.always(() => this.enable()); + + this.disable(); + } + + createMergeRequest() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createMrPath, + beforeSend: () => (this.isCreatingMergeRequest = true), + }) + .done((data) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + } + + createBranch() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createBranchPath, + beforeSend: () => (this.isCreatingBranch = true), + }) + .done((data) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + } +} diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index ff10f19a4fe..ff06092e4d6 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -34,9 +34,9 @@ GLForm.prototype.setupForm = function() { gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); - // form and textarea event listeners - this.addEventListeners(); } + // form and textarea event listeners + this.addEventListeners(); gl.text.init(this.form); // hide discard button this.form.find('.js-note-discard').hide(); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 011043e992f..694c6177a07 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, no-underscore-dangle, one-var-declaration-per-line, object-shorthand, no-unused-vars, no-new, comma-dangle, consistent-return, quotes, dot-notation, quote-props, prefer-arrow-callback, max-len */ -/* global Flash */ + /* global Flash */ +import CreateMergeRequestDropdown from './create_merge_request_dropdown'; require('./flash'); require('~/lib/utils/text_utility'); @@ -18,48 +19,49 @@ class Issue { document.querySelector('#task_status_short').innerText = result.task_status_short; } }); - Issue.initIssueBtnEventListeners(); + this.initIssueBtnEventListeners(); } Issue.$btnNewBranch = $('#new-branch'); + Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); Issue.initMergeRequests(); Issue.initRelatedBranches(); - Issue.initCanCreateBranch(); + + if (Issue.createMrDropdownWrap) { + this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); + } } - static initIssueBtnEventListeners() { + initIssueBtnEventListeners() { const issueFailMessage = 'Unable to update this issue at this time.'; - const closeButtons = $('a.btn-close'); const isClosedBadge = $('div.status-box-closed'); const isOpenBadge = $('div.status-box-open'); const projectIssuesCounter = $('.issue_counter'); const reopenButtons = $('a.btn-reopen'); - return closeButtons.add(reopenButtons).on('click', function(e) { - var $this, shouldSubmit, url; + return closeButtons.add(reopenButtons).on('click', (e) => { + var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); + $button = $(e.currentTarget); + shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { - Issue.submitNoteForm($this.closest('form')); + Issue.submitNoteForm($button.closest('form')); } - $this.prop('disabled', true); - Issue.setNewBranchButtonState(true, null); - url = $this.attr('href'); + $button.prop('disabled', true); + url = $button.attr('href'); return $.ajax({ type: 'PUT', url: url - }).fail(function(jqXHR, textStatus, errorThrown) { - new Flash(issueFailMessage); - Issue.initCanCreateBranch(); - }).done(function(data, textStatus, jqXHR) { + }) + .fail(() => new Flash(issueFailMessage)) + .done((data) => { if ('id' in data) { $(document).trigger('issuable:change'); - const isClosed = $this.hasClass('btn-close'); + const isClosed = $button.hasClass('btn-close'); closeButtons.toggleClass('hidden', isClosed); reopenButtons.toggleClass('hidden', !isClosed); isClosedBadge.toggleClass('hidden', !isClosed); @@ -68,12 +70,21 @@ class Issue { let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); + + if (this.createMergeRequestDropdown) { + if (isClosed) { + this.createMergeRequestDropdown.unavailable(); + this.createMergeRequestDropdown.disable(); + } else { + // We should check in case a branch was created in another tab + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); + } + } } else { new Flash(issueFailMessage); } - $this.prop('disabled', false); - Issue.initCanCreateBranch(); + $button.prop('disabled', false); }); }); } @@ -109,29 +120,6 @@ class Issue { } }); } - - static initCanCreateBranch() { - // If the user doesn't have the required permissions the container isn't - // rendered at all. - if (Issue.$btnNewBranch.length === 0) { - return; - } - return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() { - Issue.setNewBranchButtonState(false, false); - new Flash('Failed to check if a new branch can be created.'); - }).done(function(data) { - Issue.setNewBranchButtonState(false, data.can_create_branch); - }); - } - - static setNewBranchButtonState(isPending, canCreate) { - if (Issue.$btnNewBranch.length === 0) { - return; - } - - Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate); - Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate); - } } export default Issue; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 974fb0d83da..87f03a40eba 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -4,6 +4,7 @@ /* global ResolveService */ /* global mrRefreshWidgetUrl */ +import $ from 'jquery'; import Cookies from 'js-cookie'; import CommentTypeToggle from './comment_type_toggle'; @@ -16,6 +17,10 @@ require('vendor/jquery.caret'); // required by jquery.atwho require('vendor/jquery.atwho'); require('./task_list'); +const normalizeNewlines = function(str) { + return str.replace(/\r\n/g, '\n'); +}; + (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; @@ -42,13 +47,17 @@ require('./task_list'); this.refresh = bind(this.refresh, this); this.keydownNoteText = bind(this.keydownNoteText, this); this.toggleCommitList = bind(this.toggleCommitList, this); + this.notes_url = notes_url; this.note_ids = note_ids; + // Used to keep track of updated notes while people are editing things + this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); this.basePollingInterval = 15000; this.maxPollingSteps = 4; + this.cleanBinding(); this.addBinding(); this.setPollingInterval(); @@ -128,7 +137,7 @@ require('./task_list'); $(document).off("click", ".js-discussion-reply-button"); $(document).off("click", ".js-add-diff-note-button"); $(document).off("visibilitychange"); - $(document).off("keyup", ".js-note-text"); + $(document).off("keyup input", ".js-note-text"); $(document).off("click", ".js-note-target-reopen"); $(document).off("click", ".js-note-target-close"); $(document).off("click", ".js-note-discard"); @@ -267,20 +276,20 @@ require('./task_list'); return this.initRefresh(); }; - Notes.prototype.handleCreateChanges = function(note) { + Notes.prototype.handleCreateChanges = function(noteEntity) { var votesBlock; - if (typeof note === 'undefined') { + if (typeof noteEntity === 'undefined') { return; } - if (note.commands_changes) { - if ('merge' in note.commands_changes) { + if (noteEntity.commands_changes) { + if ('merge' in noteEntity.commands_changes) { $.get(mrRefreshWidgetUrl); } - if ('emoji_award' in note.commands_changes) { + if ('emoji_award' in noteEntity.commands_changes) { votesBlock = $('.js-awards-block').eq(0); - gl.awardsHandler.addAwardToEmojiBar(votesBlock, note.commands_changes.emoji_award); + gl.awardsHandler.addAwardToEmojiBar(votesBlock, noteEntity.commands_changes.emoji_award); return gl.awardsHandler.scrollToAwards(); } } @@ -292,41 +301,76 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note, $form) { - var $notesList; - if (note.discussion_html != null) { - return this.renderDiscussionNote(note, $form); + Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { + if (noteEntity.discussion_html != null) { + return this.renderDiscussionNote(noteEntity, $form); } - if (!note.valid) { - if (note.errors.commands_only) { - new Flash(note.errors.commands_only, 'notice', this.parentTimeline); + if (!noteEntity.valid) { + if (noteEntity.errors.commands_only) { + new Flash(noteEntity.errors.commands_only, 'notice', this.parentTimeline); this.refresh(); } return; } - if (this.isNewNote(note)) { - this.note_ids.push(note.id); + const $note = $notesList.find(`#note_${noteEntity.id}`); + if (this.isNewNote(noteEntity)) { + this.note_ids.push(noteEntity.id); - $notesList = window.$('ul.main-notes-list'); - Notes.animateAppendNote(note.html, $notesList); + const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); // Update datetime format on the recent note - gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false); + gl.utils.localTimeAgo($newNote.find('.js-timeago'), false); this.collapseLongCommitList(); this.taskList.init(); this.refresh(); return this.updateNotesCount(1); } + // The server can send the same update multiple times so we need to make sure to only update once per actual update. + else if (this.isUpdatedNote(noteEntity, $note)) { + const isEditing = $note.hasClass('is-editing'); + const initialContent = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + const $textarea = $note.find('.js-note-text'); + const currentContent = $textarea.val(); + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const isTextareaUntouched = currentContent === initialContent || currentContent === sanitizedNoteNote; + + if (isEditing && isTextareaUntouched) { + $textarea.val(noteEntity.note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else if (isEditing && !isTextareaUntouched) { + this.putConflictEditWarningInPlace(noteEntity, $note); + this.updatedNotesTrackingMap[noteEntity.id] = noteEntity; + } + else { + const $updatedNote = Notes.animateUpdateNote(noteEntity.html, $note); + + // Update datetime format on the recent note + gl.utils.localTimeAgo($updatedNote.find('.js-timeago'), false); + } + } }; /* Check if note does not exists on page */ - Notes.prototype.isNewNote = function(note) { - return $.inArray(note.id, this.note_ids) === -1; + Notes.prototype.isNewNote = function(noteEntity) { + return $.inArray(noteEntity.id, this.note_ids) === -1; + }; + + Notes.prototype.isUpdatedNote = function(noteEntity, $note) { + // There can be CRLF vs LF mismatches if we don't sanitize and compare the same way + const sanitizedNoteNote = normalizeNewlines(noteEntity.note); + const currentNoteText = normalizeNewlines( + $note.find('.original-note-content').text().trim() + ); + return sanitizedNoteNote !== currentNoteText; }; Notes.prototype.isParallelView = function() { @@ -339,31 +383,31 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note, $form) { + Notes.prototype.renderDiscussionNote = function(noteEntity, $form) { var discussionContainer, form, row, lineType, diffAvatarContainer; - if (!this.isNewNote(note)) { + if (!this.isNewNote(noteEntity)) { return; } - this.note_ids.push(note.id); - form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']"); + this.note_ids.push(noteEntity.id); + form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']"); row = form.closest("tr"); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? - discussionContainer = window.$(`.notes[data-discussion-id="${note.discussion_id}"]`); + discussionContainer = $(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); if (!discussionContainer.length) { discussionContainer = form.closest('.discussion').find('.notes'); } if (discussionContainer.length === 0) { - if (note.diff_discussion_html) { - var $discussion = $(note.diff_discussion_html).renderGFM(); + if (noteEntity.diff_discussion_html) { + var $discussion = $(noteEntity.diff_discussion_html).renderGFM(); if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { // insert the note and the reply button after the temp row row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]'); + var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]'); var contentContainerClass = '.' + $notes.closest('.notes_content') .attr('class') .split(' ') @@ -373,17 +417,18 @@ require('./task_list'); } } // Init discussion on 'Discussion' page if it is merge request page - if (window.$('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { - Notes.animateAppendNote(note.discussion_html, window.$('ul.main-notes-list')); + const page = $('body').attr('data-page'); + if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { // append new note to all matching discussions - Notes.animateAppendNote(note.html, discussionContainer); + Notes.animateAppendNote(noteEntity.html, discussionContainer); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && noteEntity.discussion_resolvable) { gl.diffNotesCompileComponents(); - this.renderDiscussionAvatar(diffAvatarContainer, note); + this.renderDiscussionAvatar(diffAvatarContainer, noteEntity); } gl.utils.localTimeAgo($('.js-timeago'), false); @@ -397,13 +442,13 @@ require('./task_list'); .get(0); }; - Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, note) { + Notes.prototype.renderDiscussionAvatar = function(diffAvatarContainer, noteEntity) { var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { avatarHolder = document.createElement('diff-note-avatars'); - avatarHolder.setAttribute('discussion-id', note.discussion_id); + avatarHolder.setAttribute('discussion-id', noteEntity.discussion_id); diffAvatarContainer.append(avatarHolder); @@ -550,16 +595,16 @@ require('./task_list'); Updates the current note field. */ - Notes.prototype.updateNote = function(_xhr, note, _status) { + Notes.prototype.updateNote = function(_xhr, noteEntity, _status) { var $html, $note_li; // Convert returned HTML to a jQuery object so we can modify it further - $html = $(note.html); + $html = $(noteEntity.html); this.revertNoteEditForm(); gl.utils.localTimeAgo($('.js-timeago', $html)); $html.renderGFM(); $html.find('.js-task-list-container').taskList('enable'); // Find the note's `li` element by ID and replace it with the updated HTML - $note_li = $('.note-row-' + note.id); + $note_li = $('.note-row-' + noteEntity.id); $note_li.replaceWith($html); @@ -570,7 +615,7 @@ require('./task_list'); Notes.prototype.checkContentToAllowEditing = function($el) { var initialContent = $el.find('.original-note-content').text().trim(); - var currentContent = $el.find('.note-textarea').val(); + var currentContent = $el.find('.js-note-text').val(); var isAllowed = true; if (currentContent === initialContent) { @@ -584,7 +629,7 @@ require('./task_list'); gl.utils.scrollToElement($el); } - $el.find('.js-edit-warning').show(); + $el.find('.js-finish-edit-warning').show(); isAllowed = false; } @@ -603,7 +648,7 @@ require('./task_list'); var $target = $(e.target); var $editForm = $(this.getEditFormSelector($target)); var $note = $target.closest('.note'); - var $currentlyEditing = $('.note.is-editting:visible'); + var $currentlyEditing = $('.note.is-editing:visible'); if ($currentlyEditing.length) { var isEditAllowed = this.checkContentToAllowEditing($currentlyEditing); @@ -615,7 +660,7 @@ require('./task_list'); $note.find('.js-note-attachment-delete').show(); $editForm.addClass('current-note-edit-form'); - $note.addClass('is-editting'); + $note.addClass('is-editing'); this.putEditFormInPlace($target); }; @@ -627,21 +672,34 @@ require('./task_list'); Notes.prototype.cancelEdit = function(e) { e.preventDefault(); - var $target = $(e.target); - var note = $target.closest('.note'); - note.find('.js-edit-warning').hide(); + const $target = $(e.target); + const $note = $target.closest('.note'); + const noteId = $note.attr('data-note-id'); + this.revertNoteEditForm($target); - return this.removeNoteEditForm(note); + + if (this.updatedNotesTrackingMap[noteId]) { + const $newNote = $(this.updatedNotesTrackingMap[noteId].html); + $note.replaceWith($newNote); + this.updatedNotesTrackingMap[noteId] = null; + + // Update datetime format on the recent note + gl.utils.localTimeAgo($newNote.find('.js-timeago'), false); + } + else { + $note.find('.js-finish-edit-warning').hide(); + this.removeNoteEditForm($note); + } }; Notes.prototype.revertNoteEditForm = function($target) { - $target = $target || $('.note.is-editting:visible'); + $target = $target || $('.note.is-editing:visible'); var selector = this.getEditFormSelector($target); var $editForm = $(selector); $editForm.insertBefore('.notes-form'); $editForm.find('.js-comment-button').enable(); - $editForm.find('.js-edit-warning').hide(); + $editForm.find('.js-finish-edit-warning').hide(); }; Notes.prototype.getEditFormSelector = function($el) { @@ -654,11 +712,11 @@ require('./task_list'); return selector; }; - Notes.prototype.removeNoteEditForm = function(note) { - var form = note.find('.current-note-edit-form'); - note.removeClass('is-editting'); + Notes.prototype.removeNoteEditForm = function($note) { + var form = $note.find('.current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); - form.find('.js-edit-warning').hide(); + form.find('.js-finish-edit-warning').hide(); // Replace markdown textarea text with original note text. return form.find('.js-note-text').val(form.find('form.edit-note').data('original-note')); }; @@ -683,9 +741,9 @@ require('./task_list'); // to remove all. Using $(".note[id='noteId']") ensure we get all the notes, // where $("#noteId") would return only one. return function(i, el) { - var note, notes; - note = $(el); - notes = note.closest(".discussion-notes"); + var $note, $notes; + $note = $(el); + $notes = $note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -693,18 +751,18 @@ require('./task_list'); } } - note.remove(); + $note.remove(); // check if this is the last note for this line - if (notes.find(".note").length === 0) { - var notesTr = notes.closest("tr"); + if ($notes.find(".note").length === 0) { + var notesTr = $notes.closest("tr"); // "Discussions" tab - notes.closest(".timeline-entry").remove(); + $notes.closest(".timeline-entry").remove(); // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { - notes.remove(); + $notes.remove(); } else { notesTr.remove(); } @@ -723,12 +781,11 @@ require('./task_list'); */ Notes.prototype.removeAttachment = function() { - var note; - note = $(this).closest(".note"); - note.find(".note-attachment").remove(); - note.find(".note-body > .note-text").show(); - note.find(".note-header").show(); - return note.find(".current-note-edit-form").remove(); + const $note = $(this).closest(".note"); + $note.find(".note-attachment").remove(); + $note.find(".note-body > .note-text").show(); + $note.find(".note-header").show(); + return $note.find(".current-note-edit-form").remove(); }; /* @@ -1004,6 +1061,19 @@ require('./task_list'); $editForm.find('.referenced-users').hide(); }; + Notes.prototype.putConflictEditWarningInPlace = function(noteEntity, $note) { + if ($note.find('.js-conflict-edit-warning').length === 0) { + const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer"> + updated comment + </a> + to ensure information is not lost + </div>`); + $alert.insertAfter($note.find('.note-text')); + } + }; + Notes.prototype.updateNotesCount = function(updateCount) { return this.notesCountBadge.text(parseInt(this.notesCountBadge.text(), 10) + updateCount); }; @@ -1064,11 +1134,20 @@ require('./task_list'); return $form; }; - Notes.animateAppendNote = function(noteHTML, $notesList) { - const $note = window.$(noteHTML); + Notes.animateAppendNote = function(noteHtml, $notesList) { + const $note = $(noteHtml); $note.addClass('fade-in').renderGFM(); $notesList.append($note); + return $note; + }; + + Notes.animateUpdateNote = function(noteHtml, $note) { + const $updatedNote = $(noteHtml); + + $updatedNote.addClass('fade-in').renderGFM(); + $note.replaceWith($updatedNote); + return $updatedNote; }; return Notes; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 1b4694377b3..feefaad8a15 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -425,12 +425,6 @@ float: right; } -.diffs { - .content-block { - border-bottom: none; - } -} - .files-changed { border-bottom: none; } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 2aa52986e0a..b18bbc329c3 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -161,3 +161,86 @@ ul.related-merge-requests > li { .recaptcha { margin-bottom: 30px; } + +.new-branch-col { + padding-top: 10px; +} + +.create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: flex; + } + + .js-create-merge-request { + flex-grow: 1; + flex-shrink: 0; + } + + .dropdown-menu { + width: 300px; + opacity: 1; + visibility: visible; + transform: translateY(0); + display: none; + } + + .dropdown-toggle { + .fa-caret-down { + pointer-events: none; + margin-left: 0; + color: inherit; + margin-left: 0; + } + } + + li:not(.divider) { + padding: 6px; + cursor: pointer; + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected { + .icon-container { + i { + visibility: visible; + } + } + } + + .icon-container { + float: left; + padding-left: 6px; + + i { + visibility: hidden; + } + } + + .description { + padding-left: 30px; + font-size: 13px; + + strong { + display: block; + font-weight: 600; + } + } + } +} + +@media (min-width: $screen-sm-min) { + .new-branch-col { + padding-top: 0; + text-align: right; + } + + .create-mr-dropdown-wrap { + .btn-group:not(.hide) { + display: inline-block; + } + } +} diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index be7193bae04..8dbac76e30a 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -133,3 +133,55 @@ right: 160px; } } + +.flex-project-members-panel { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + + @media (max-width: $screen-sm-min) { + display: block; + + .flex-project-title { + vertical-align: top; + display: inline-block; + max-width: 90%; + } + } + + .flex-project-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .badge { + height: 17px; + line-height: 16px; + margin-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + } + + .flex-project-members-form { + flex-wrap: nowrap; + white-space: nowrap; + margin-left: auto; + } +} + +.panel { + .panel-heading { + .badge { + margin-top: 0; + } + + @media (max-width: $screen-sm-min) { + .badge { + margin-right: 0; + margin-left: 0; + } + } + } +}
\ No newline at end of file diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6a419384a34..bca62b7fc31 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -511,7 +511,6 @@ .mr-version-controls { background: $gray-light; - border-bottom: 1px solid $border-color; color: $gl-text-color; .mr-version-menus-container { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 7cf74502a3a..f89150ebead 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -67,7 +67,7 @@ ul.notes { } } - &.is-editting { + &.is-editing { .note-header, .note-text, .edited-text { diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 59222637961..a13588b4218 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -16,7 +16,8 @@ class Projects::ArtifactsController < Projects::ApplicationController end def browse - directory = params[:path] ? "#{params[:path]}/" : '' + @path = params[:path] + directory = @path ? "#{@path}/" : '' @entry = build.artifacts_metadata_entry(directory) render_404 unless @entry.exists? @@ -60,7 +61,10 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build - @build ||= build_from_id || build_from_ref + @build ||= begin + build = build_from_id || build_from_ref + build&.present(current_user: current_user) + end end def build_from_id diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 840405f38cb..f0f031303d8 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -46,20 +46,28 @@ class Projects::BranchesController < Projects::ApplicationController SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue end - if result[:status] == :success - @branch = result[:branch] - - if redirect_to_autodeploy - redirect_to( - url_to_autodeploy_setup(project, branch_name), - notice: view_context.autodeploy_flash_notice(branch_name)) - else - redirect_to namespace_project_tree_path(@project.namespace, @project, - @branch.name) + respond_to do |format| + format.html do + if result[:status] == :success + if redirect_to_autodeploy + redirect_to url_to_autodeploy_setup(project, branch_name), + notice: view_context.autodeploy_flash_notice(branch_name) + else + redirect_to namespace_project_tree_path(@project.namespace, @project, branch_name) + end + else + @error = result[:message] + render action: 'new' + end + end + + format.json do + if result[:status] == :success + render json: { name: branch_name, url: namespace_project_tree_url(@project.namespace, @project, branch_name) } + else + render json: result[:messsage], status: :unprocessable_entity + end end - else - @error = result[:message] - render action: 'new' end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cbf67137261..af9157bfbb5 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :redirect_to_external_issue_tracker, only: [:index, :new] before_action :module_enabled before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests, - :related_branches, :can_create_branch, :rendered_title] + :related_branches, :can_create_branch, :rendered_title, :create_merge_request] # Allow read any issue before_action :authorize_read_issue!, only: [:show, :rendered_title] @@ -22,6 +22,9 @@ class Projects::IssuesController < Projects::ApplicationController # Allow modify issue before_action :authorize_update_issue!, only: [:edit, :update] + # Allow create a new branch and empty WIP merge request from current issue + before_action :authorize_create_merge_request!, only: [:create_merge_request] + respond_to :html def index @@ -191,7 +194,7 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.json do - render json: { can_create_branch: can_create } + render json: { can_create_branch: can_create, has_related_branch: @issue.has_related_branch? } end end end @@ -201,6 +204,16 @@ class Projects::IssuesController < Projects::ApplicationController render json: { title: view_context.markdown_field(@issue, :title) } end + def create_merge_request + result = MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute + + if result[:status] == :success + render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) + else + render json: result[:messsage], status: :unprocessable_entity + end + end + protected def issue @@ -224,6 +237,10 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_issue, @project) end + def authorize_create_merge_request! + return render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) + end + def module_enabled return render_404 unless @project.feature_available?(:issues, current_user) && @project.default_issues_tracker? end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 09dc8b38229..a63b7ff0bed 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -120,7 +120,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController define_diff_comment_vars else build_merge_request - @diffs = @merge_request.diffs(diff_options) + @compare = @merge_request + @diffs = @compare.diffs(diff_options) @diff_notes_disabled = true end @@ -584,12 +585,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - @diffs = + @compare = if @start_sha - @merge_request_diff.compare_with(@start_sha).diffs(diff_options) + @merge_request_diff.compare_with(@start_sha) else - @merge_request_diff.diffs(diff_options) + @merge_request_diff end + + @diffs = @compare.diffs(diff_options) end def define_diff_comment_vars @@ -598,11 +601,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController noteable_id: @merge_request.id } - @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha + @diff_notes_disabled = false @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@merge_request_diff.diff_refs) + @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index a0b08ad130f..a02cc477e08 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController return if cached_blob? - if @blob.valid_lfs_pointer? + if @blob.stored_externally? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 377b080b3c6..37b6f4ad5cc 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -52,7 +52,7 @@ module BlobHelper if !on_top_of_branch?(project, ref) button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } - elsif blob.valid_lfs_pointer? + elsif blob.stored_externally? button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } elsif can_modify_blob?(blob, project, ref) button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' @@ -95,7 +95,7 @@ module BlobHelper end def can_modify_blob?(blob, project = @project, ref = @ref) - !blob.valid_lfs_pointer? && can_edit_tree?(project, ref) + !blob.stored_externally? && can_edit_tree?(project, ref) end def leave_edit_message @@ -223,7 +223,9 @@ module BlobHelper end def open_raw_blob_button(blob) - if blob.raw_binary? + return if blob.empty? + + if blob.raw_binary? || blob.stored_externally? icon = icon('download') title = 'Download' else @@ -244,19 +246,27 @@ module BlobHelper viewer.max_size end "it is larger than #{number_to_human_size(max_size)}" - when :server_side_but_stored_in_lfs - "it is stored in LFS" + when :server_side_but_stored_externally + case viewer.blob.external_storage + when :lfs + 'it is stored in LFS' + else + 'it is stored externally' + end end end def blob_render_error_options(viewer) + error = viewer.render_error options = [] - if viewer.render_error == :too_large && viewer.can_override_max_size? + if error == :too_large && viewer.can_override_max_size? options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) end - if viewer.rich? && viewer.blob.rendered_as_text? + # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, + # so don't bother switching. + if viewer.rich? && viewer.blob.rendered_as_text? && error != :server_side_but_stored_externally options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index eab0738a368..08180883eb9 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -60,20 +60,16 @@ module NotesHelper note.project.team.human_max_access(note.author_id) end - def discussion_diff_path(discussion) - if discussion.for_merge_request? && discussion.diff_discussion? - if discussion.active? - # Without a diff ID, the link always points to the latest diff version - diff_id = nil - elsif merge_request_diff = discussion.latest_merge_request_diff - diff_id = merge_request_diff.id - else - # If the discussion is not active, and we cannot find the latest - # merge request diff for this discussion, we return no path at all. - return - end - - diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code) + def discussion_path(discussion) + if discussion.for_merge_request? + return unless discussion.diff_discussion? + + version_params = discussion.merge_request_version_params + return unless version_params + + path_params = version_params.merge(anchor: discussion.line_code) + + diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, path_params) elsif discussion.for_commit? anchor = discussion.line_code if discussion.diff_discussion? diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f7b5a5f4dfc..a91e3da309c 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -76,7 +76,7 @@ module TreeHelper "A new branch will be created in your fork and a new merge request will be started." end - def tree_breadcrumbs(tree, max_links = 2) + def path_breadcrumbs(max_links = 6) if @path.present? part_path = "" parts = @path.split('/') @@ -88,7 +88,7 @@ module TreeHelper part_path = part if part_path.empty? next if parts.count > max_links && !parts.last(2).include?(part) - yield(part, tree_join(@ref, part_path)) + yield(part, part_path) end end end diff --git a/app/models/blob.rb b/app/models/blob.rb index 1cdb8811cff..a4fae22a0c4 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -28,7 +28,7 @@ class Blob < SimpleDelegator BlobViewer::Sketch, BlobViewer::Video, - + BlobViewer::PDF, BlobViewer::BinarySTL, @@ -75,19 +75,37 @@ class Blob < SimpleDelegator end def no_highlighting? - size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE + raw_size && raw_size > MAXIMUM_TEXT_HIGHLIGHT_SIZE + end + + def empty? + raw_size == 0 end def too_large? size && truncated? end + def external_storage_error? + if external_storage == :lfs + !project&.lfs_enabled? + else + false + end + end + + def stored_externally? + return @stored_externally if defined?(@stored_externally) + + @stored_externally = external_storage && !external_storage_error? + end + # Returns the size of the file that this blob represents. If this blob is an # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is # the size of the blob itself. def raw_size - if valid_lfs_pointer? - lfs_size + if stored_externally? + external_size else size end @@ -98,9 +116,13 @@ class Blob < SimpleDelegator # text-based rich blob viewer matched on the file's extension. Otherwise, this # depends on the type of the blob itself. def raw_binary? - if valid_lfs_pointer? + if stored_externally? if rich_viewer rich_viewer.binary? + elsif Linguist::Language.find_by_filename(name).any? + false + elsif _mime_type + _mime_type.binary? else true end @@ -118,15 +140,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !valid_lfs_pointer? && !too_large? - end - - def valid_lfs_pointer? - lfs_pointer? && project&.lfs_enabled? - end - - def invalid_lfs_pointer? - lfs_pointer? && !project&.lfs_enabled? + text? && !stored_externally? && !too_large? end def simple_viewer @@ -165,10 +179,10 @@ class Blob < SimpleDelegator end def rich_viewer_class - return if invalid_lfs_pointer? || empty? + return if empty? || external_storage_error? classes = - if valid_lfs_pointer? + if stored_externally? BINARY_VIEWERS + TEXT_VIEWERS elsif binary? BINARY_VIEWERS diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index f944b00c9d3..a8b91d8d6bc 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -70,12 +70,13 @@ module BlobViewer return @render_error if defined?(@render_error) @render_error = - if server_side_but_stored_in_lfs? - # Files stored in LFS can only be rendered using a client-side viewer, + if server_side_but_stored_externally? + # Files that are not stored in the repository, like LFS files and + # 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. - :server_side_but_stored_in_lfs + :server_side_but_stored_externally elsif override_max_size ? absolutely_too_large? : too_large? :too_large end @@ -89,8 +90,8 @@ module BlobViewer private - def server_side_but_stored_in_lfs? - server_side? && blob.valid_lfs_pointer? + def server_side_but_stored_externally? + server_side? && blob.stored_externally? end end end diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb new file mode 100644 index 00000000000..adb81561000 --- /dev/null +++ b/app/models/concerns/blob_like.rb @@ -0,0 +1,48 @@ +module BlobLike + extend ActiveSupport::Concern + include Linguist::BlobHelper + + def id + raise NotImplementedError + end + + def name + raise NotImplementedError + end + + def path + raise NotImplementedError + end + + def size + 0 + end + + def data + nil + end + + def mode + nil + end + + def binary? + false + end + + def load_all_data!(repository) + # No-op + end + + def truncated? + false + end + + def external_storage + nil + end + + def external_size + nil + end +end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index 8ee42875670..a7bdf5587b2 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -11,6 +11,7 @@ module DiscussionOnDiff :diff_line, :for_line?, :active?, + :created_at_diff?, to: :first_note diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 6c27dd5aa5c..6359f7596b1 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -30,6 +30,10 @@ module NoteOnDiff raise NotImplementedError end + def created_at_diff?(diff_refs) + false + end + private def noteable_diff_refs diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 6a6466b493b..d627fbe327f 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -10,7 +10,6 @@ class DiffDiscussion < Discussion delegate :position, :original_position, - :latest_merge_request_diff, to: :first_note @@ -18,6 +17,25 @@ class DiffDiscussion < Discussion false end + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + diff_refs = position.diff_refs + + if diff = noteable.merge_request_diff_for(diff_refs) + { diff_id: diff.id } + elsif diff = noteable.merge_request_diff_for(diff_refs.head_sha) + { + diff_id: diff.id, + start_sha: diff_refs.start_sha + } + end + end + end + def reply_attributes super.merge( original_position: original_position.to_json, diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index abe4518d62a..76c59199afd 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -65,10 +65,11 @@ class DiffNote < Note self.position.diff_refs == diff_refs end - def latest_merge_request_diff - return unless for_merge_request? + def created_at_diff?(diff_refs) + return false unless supported? + return true if for_commit? - self.noteable.merge_request_diff_for(self.position.diff_refs) + self.original_position.diff_refs == diff_refs end private diff --git a/app/models/issue.rb b/app/models/issue.rb index 305fc01f041..78bde6820da 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -143,6 +143,14 @@ class Issue < ActiveRecord::Base branches_with_iid - branches_with_merge_request end + # Returns boolean if a related branch exists for the current issue + # ignores merge requests branchs + def has_related_branch? + project.repository.branch_names.any? do |branch| + /\A#{iid}-(?!\d+-stable)/i =~ branch + end + end + # To allow polymorphism with MergeRequest. def source_project project diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb index e617ce36f56..3c1d34db5fa 100644 --- a/app/models/legacy_diff_discussion.rb +++ b/app/models/legacy_diff_discussion.rb @@ -9,14 +9,14 @@ class LegacyDiffDiscussion < Discussion memoized_values << :active - def legacy_diff_discussion? - true - end - def self.note_class LegacyDiffNote end + def legacy_diff_discussion? + true + end + def active?(*args) return @active if @active.present? @@ -27,6 +27,16 @@ class LegacyDiffDiscussion < Discussion !active? end + def merge_request_version_params + return unless for_merge_request? + + if active? + {} + else + nil + end + end + def reply_attributes super.merge(line_code: line_code) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 365fa4f1e70..12c5481cd6d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -374,12 +374,18 @@ class MergeRequest < ActiveRecord::Base merge_request_diff(true) end - def merge_request_diff_for(diff_refs) - @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs| - h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs) - end - - @merge_request_diffs_by_diff_refs[diff_refs] + def merge_request_diff_for(diff_refs_or_sha) + @merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha| + diffs = merge_request_diffs.viewable.select_without_diff + h[diff_refs_or_sha] = + if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs) + diffs.find_by_diff_refs(diff_refs_or_sha) + else + diffs.find_by(head_commit_sha: diff_refs_or_sha) + end + end + + @merge_request_diffs_by_diff_refs_or_sha[diff_refs_or_sha] end def reload_diff_if_branch_changed diff --git a/app/models/note.rb b/app/models/note.rb index e720bfba030..b06985b4a6f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -115,11 +115,19 @@ class Note < ActiveRecord::Base end def grouped_diff_discussions(diff_refs = nil) - diff_notes. - fresh. - discussions. - select { |n| n.active?(diff_refs) }. - group_by(&:line_code) + groups = {} + + diff_notes.fresh.discussions.each do |discussion| + if discussion.active?(diff_refs) + discussions = groups[discussion.line_code] ||= [] + elsif diff_refs && discussion.created_at_diff?(diff_refs) + discussions = groups[discussion.original_line_code] ||= [] + end + + discussions << discussion if discussions + end + + groups end def count_for_collection(ids, type) @@ -141,10 +149,6 @@ class Note < ActiveRecord::Base true end - def latest_merge_request_diff - nil - end - def max_attachment_size current_application_settings.max_attachment_size.megabytes.to_i end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb index 7621a5fa2d8..e2ad586aea7 100644 --- a/app/models/project_services/chat_message/base_message.rb +++ b/app/models/project_services/chat_message/base_message.rb @@ -50,5 +50,16 @@ module ChatMessage def link(text, url) "[#{text}](#{url})" end + + def pretty_duration(seconds) + parse_string = + if duration < 1.hour + '%M:%S' + else + '%H:%M:%S' + end + + Time.at(seconds).utc.strftime(parse_string) + end end end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index 4628d9b1a7b..47b68f00cff 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -15,7 +15,7 @@ module ChatMessage @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' @ref = pipeline_attributes[:ref] @status = pipeline_attributes[:status] - @duration = pipeline_attributes[:duration] + @duration = pipeline_attributes[:duration].to_i @pipeline_id = pipeline_attributes[:id] end @@ -37,7 +37,7 @@ module ChatMessage { title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}", subtitle: "in #{project_link}", - text: "in #{duration} #{time_measure}", + text: "in #{pretty_duration(duration)}", image: user_avatar || '' } end @@ -45,7 +45,7 @@ module ChatMessage private def message - "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}" + "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{pretty_duration(duration)}" end def humanized_status @@ -84,9 +84,5 @@ module ChatMessage def pipeline_link "[##{pipeline_id}](#{pipeline_url})" end - - def time_measure - 'second'.pluralize(duration) - end end end diff --git a/app/models/repository.rb b/app/models/repository.rb index ba34d570dbd..0c797dd5814 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -789,7 +789,7 @@ class Repository } options.merge!(get_committer_and_author(user, email: author_email, name: author_name)) - Rugged::Commit.create(rugged, options) + create_commit(options) end end # rubocop:enable Metrics/ParameterLists @@ -836,7 +836,7 @@ class Repository tree: merge_index.write_tree(rugged), ) - commit_id = Rugged::Commit.create(rugged, actual_options) + commit_id = create_commit(actual_options) merge_request.update(in_progress_merge_commit_sha: commit_id) commit_id end @@ -859,12 +859,11 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.revert_message(user), - author: committer, - committer: committer, - tree: revert_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.revert_message(user), + author: committer, + committer: committer, + tree: revert_tree_id, + parents: [start_commit.sha]) end end @@ -883,16 +882,15 @@ class Repository committer = user_to_committer(user) - Rugged::Commit.create(rugged, - message: commit.message, - author: { - email: commit.author_email, - name: commit.author_name, - time: commit.authored_date - }, - committer: committer, - tree: cherry_pick_tree_id, - parents: [start_commit.sha]) + create_commit(message: commit.message, + author: { + email: commit.author_email, + name: commit.author_name, + time: commit.authored_date + }, + committer: committer, + tree: cherry_pick_tree_id, + parents: [start_commit.sha]) end end @@ -900,7 +898,7 @@ class Repository GitOperationService.new(user, self).with_branch(branch_name) do committer = user_to_committer(user) - Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer)) + create_commit(params.merge(author: committer, committer: committer)) end end @@ -1142,6 +1140,12 @@ class Repository Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags)) end + def create_commit(params = {}) + params[:message].delete!("\r") + + Rugged::Commit.create(rugged, params) + end + def repository_storage_path @project.repository_storage_path end diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb index d6cab74eb1a..fa5fa151607 100644 --- a/app/models/snippet_blob.rb +++ b/app/models/snippet_blob.rb @@ -1,5 +1,5 @@ class SnippetBlob - include Linguist::BlobHelper + include BlobLike attr_reader :snippet @@ -28,32 +28,4 @@ class SnippetBlob Banzai.render_field(snippet, :content) end - - def mode - nil - end - - def binary? - false - end - - def load_all_data!(repository) - # No-op - end - - def lfs_pointer? - false - end - - def lfs_oid - nil - end - - def lfs_size - nil - end - - def truncated? - false - end end diff --git a/app/serializers/merge_request_create_entity.rb b/app/serializers/merge_request_create_entity.rb new file mode 100644 index 00000000000..11234313293 --- /dev/null +++ b/app/serializers/merge_request_create_entity.rb @@ -0,0 +1,7 @@ +class MergeRequestCreateEntity < Grape::Entity + expose :iid + + expose :url do |merge_request| + Gitlab::UrlBuilder.build(merge_request) + end +end diff --git a/app/serializers/merge_request_create_serializer.rb b/app/serializers/merge_request_create_serializer.rb new file mode 100644 index 00000000000..08daf473319 --- /dev/null +++ b/app/serializers/merge_request_create_serializer.rb @@ -0,0 +1,3 @@ +class MergeRequestCreateSerializer < BaseSerializer + entity MergeRequestCreateEntity +end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb new file mode 100644 index 00000000000..738cedbaed7 --- /dev/null +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -0,0 +1,54 @@ +module MergeRequests + class CreateFromIssueService < MergeRequests::CreateService + def execute + return error('Invalid issue iid') unless issue_iid.present? && issue.present? + + result = CreateBranchService.new(project, current_user).execute(branch_name, ref) + return result if result[:status] == :error + + SystemNoteService.new_issue_branch(issue, project, current_user, branch_name) + + new_merge_request = create(merge_request) + + if new_merge_request.valid? + success(new_merge_request) + else + error(new_merge_request.errors) + end + end + + private + + def issue_iid + @isssue_iid ||= params.delete(:issue_iid) + end + + def issue + @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid) + end + + def branch_name + @branch_name ||= issue.to_branch_name + end + + def ref + project.default_branch || 'master' + end + + def merge_request + MergeRequests::BuildService.new(project, current_user, merge_request_params).execute + end + + def merge_request_params + { + source_project_id: project.id, + source_branch: branch_name, + target_project_id: project.id + } + end + + def success(merge_request) + super().merge(merge_request: merge_request) + end + end +end diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 549364761e6..78c5b0c1dda 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_diff_path(discussion) + = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion) .diff-content.code.js-syntax-highlight %table diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 8440fb3d785..38e85168f40 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -20,7 +20,7 @@ = discussion.author.to_reference started a discussion - - url = discussion_diff_path(discussion) + - url = discussion_path(discussion) - if discussion.for_commit? && @noteable != discussion.noteable on - commit = discussion.noteable diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 8ab9747efc5..cdcac7e4264 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -38,7 +38,7 @@ %span.badge.count.merge_counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :environments]) do + = nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines diff --git a/app/views/projects/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 9e49c93388a..34d5c3b7285 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -3,6 +3,6 @@ %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name = tree_icon('folder', '755', directory.name) - %span.str-truncated - = link_to directory.name, path_to_directory + = link_to path_to_directory do + %span.str-truncated= directory.name %td diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index de8c173f26f..9fbb30f7c7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,13 +1,23 @@ -- page_title 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' +- page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" -.top-block.row-content-block.clearfix - .pull-right - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), - rel: 'nofollow', download: '', class: 'btn btn-default download' do - = icon('download') - Download artifacts archive += render "projects/builds/header", show_controls: false .tree-holder + .nav-block + .tree-controls + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + rel: 'nofollow', download: '', class: 'btn btn-default download' do + = icon('download') + Download artifacts archive + + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + %li + = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + .tree-content-holder %table.table.tree-table %thead diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 3f12d64d044..f04df441ccb 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -6,17 +6,14 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(@tree, 6) do |title, path| + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) %li - - if path - - if path.end_with?(@path) - = link_to namespace_project_blob_path(@project.namespace, @project, path) do - %strong - = truncate(title, length: 40) - - else - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) + - if path == @path + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@ref, path)) do + %strong= title - else - = link_to title, '#' + = link_to title, namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) %ul.blob-commit-info.hidden-xs - blob_commit = @repository.last_commit_for_path(@commit.id, blob.path) @@ -25,5 +22,4 @@ #blob-content-holder.blob-content-holder %article.file-holder = render "projects/blob/header", blob: blob - = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 104db85809c..a0f8f105d9a 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -1,10 +1,13 @@ +- show_controls = local_assigns.fetch(:show_controls, true) - pipeline = @build.pipeline .content-block.build-header.top-area .header-content = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title - Job - %strong.js-build-id ##{@build.id} + %strong + Job + = link_to namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' do + \##{@build.id} in pipeline = link_to pipeline_path(pipeline) do %strong ##{pipeline.id} @@ -15,13 +18,16 @@ = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do %code = @build.ref - - if @build.user - = render "user" + + = render "projects/builds/user" if @build.user + = time_ago_with_tooltip(@build.created_at) - .nav-controls - - if can?(current_user, :create_issue, @project) && @build.failed? - = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post - %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } - = icon('angle-double-left') + + - if show_controls + .nav-controls + - if can?(current_user, :create_issue, @project) && @build.failed? + = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' + - if can?(current_user, :update_build, @build) && @build.retryable? + = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } + = icon('angle-double-left') diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 3e426ee9e7d..7439b8a66f7 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -35,6 +35,6 @@ - else = diff_line_content(line.text) -- if line_discussions +- if line_discussions&.any? - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?)) = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 13e2150f997..6bc6bf76e18 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,9 +1,29 @@ - if can?(current_user, :push_code, @project) - .pull-right - #new-branch.new-branch{ 'data-path' => can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue) } - = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), - method: :post, class: 'btn btn-new btn-inverted btn-grouped has-tooltip available hide', title: @issue.to_branch_name do - New branch - = link_to '#', class: 'unavailable btn btn-grouped hide', disabled: 'disabled' do - = icon('exclamation-triangle') - New branch unavailable + .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_namespace_project_issue_path(@project.namespace, @project, @issue), create_mr_path: create_merge_request_namespace_project_issue_path(@project.namespace, @project, @issue), create_branch_path: namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } } + .btn-group.unavailable + %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } + = icon('spinner', class: 'fa-spin') + %span.text + Checking branch availability… + .btn-group.available.hide + %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } } + %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } } + = icon('caret-down') + %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } + %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a merge request + %span + Creates a branch named after this issue and a merge request. The source branch is '#{@project.default_branch}' by default. + %li.divider.droplab-item-ignore + %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a branch + %span + Creates a branch named after this issue. The source branch is '#{@project.default_branch}' by default. diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 2a871966aa8..1418ad73553 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -70,8 +70,11 @@ // This element is filled in using JavaScript. .content-block.content-block-small - = render 'new_branch' unless @issue.confidential? - = render 'award_emoji/awards_block', awardable: @issue, inline: true + .row + .col-sm-6 + = render 'award_emoji/awards_block', awardable: @issue, inline: true + .col-sm-6.new-branch-col + = render 'new_branch' unless @issue.confidential? %section.issuable-discussion = render 'projects/issues/discussion' diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml index 547be78992e..11b0c55be0b 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/show/_versions.html.haml @@ -35,7 +35,7 @@ %span.dropdown.inline.mr-version-compare-dropdown %a.btn.btn-default.dropdown-toggle{ data: {toggle: :dropdown} } %span - - if @start_sha + - if @start_version version #{version_index(@start_version)} - else #{@merge_request.target_branch} @@ -59,7 +59,7 @@ %small = time_ago_with_tooltip(merge_request_diff.created_at) %li - = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_sha) do + = link_to merge_request_version_path(@project, @merge_request, @merge_request_diff), class: ('is-active' unless @start_version) do %strong #{@merge_request.target_branch} (base) .monospace= short_sha(@merge_request_diff.base_commit_sha) @@ -75,13 +75,15 @@ = succeed '.' do %code= @merge_request.target_branch - - if @diff_notes_disabled + - if @start_version || !@merge_request_diff.latest? .comments-disabled-notif.content-block = icon('info-circle') - - if @start_sha - Comments are disabled because you're comparing two versions of this merge request. + Not all comments are displayed because you're + - if @start_version + comparing two versions - else - Discussions on this version of the merge request are displayed but comment creation is disabled. + viewing an old version + of this merge request. .pull-right = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml index 8b4e5928e0d..a1efc0b051a 100644 --- a/app/views/projects/notes/_edit_form.html.haml +++ b/app/views/projects/notes/_edit_form.html.haml @@ -7,7 +7,7 @@ = render 'projects/notes/hints' .note-form-actions.clearfix - .settings-message.note-edit-warning.js-edit-warning + .settings-message.note-edit-warning.js-finish-edit-warning Finish editing this message first! = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button' %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' } diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index bc57f7f1c46..b0dac9de1c6 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -4,13 +4,13 @@ .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } - if project_nav_tab? :pipelines - = nav_link(path: 'pipelines#index', controller: :pipelines) do + = nav_link(path: ['pipelines#index', 'pipelines#show']) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines - if project_nav_tab? :builds - = nav_link(controller: :builds) do + = nav_link(controller: [:builds, :artifacts]) do = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs diff --git a/app/views/projects/project_members/_index.html.haml b/app/views/projects/project_members/_index.html.haml index ab0771b5751..f83521052ed 100644 --- a/app/views/projects/project_members/_index.html.haml +++ b/app/views/projects/project_members/_index.html.haml @@ -6,6 +6,12 @@ %p Add a new member to %strong= @project.name + - else + %p + Members can be added by project + %i Masters + or + %i Owners .col-lg-9 .light.prepend-top-default - if can?(current_user, :admin_project_member, @project) diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 81d57c77edf..7b1a26043e1 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -1,9 +1,11 @@ .panel.panel-default - .panel-heading - Members with access to - %strong= @project.name + .panel-heading.flex-project-members-panel + %span.flex-project-title + Members of + %strong + #{@project.name} %span.badge= @project_members.total_count - = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + = form_tag namespace_project_settings_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do .form-group = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index 6e187b54a59..af9a080f0a2 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :name, class: 'col-md-2 text-right' do Tag: - .col-md-10 + .col-md-10.protected-tags-dropdown = render partial: "projects/protected_tags/dropdown", locals: { f: f } .help-block = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags') diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index e7b3fe3ffda..396d1ecd77b 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -9,12 +9,9 @@ %li = link_to namespace_project_tree_path(@project.namespace, @project, @ref) do = @project.path - - tree_breadcrumbs(tree, 6) do |title, path| + - path_breadcrumbs do |title, path| %li - - if path - = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, path) - - else - = link_to title, '#' + = link_to truncate(title, length: 40), namespace_project_tree_path(@project.namespace, @project, tree_join(@ref, path)) - if current_user %li diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 731270d4127..9657b4eea82 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -2,7 +2,11 @@ - return if note.cross_reference_not_visible_for?(current_user) - note_editable = note_editable?(note) -%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} } +%li.timeline-entry{ id: dom_id(note), + class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], + data: { author_id: note.author.id, + editable: note_editable, + note_id: note.id } } .timeline-entry-inner .timeline-icon - if note.system diff --git a/changelogs/unreleased/26883-members-page-layout-looks-broken.yml b/changelogs/unreleased/26883-members-page-layout-looks-broken.yml new file mode 100644 index 00000000000..e0e3a529c3e --- /dev/null +++ b/changelogs/unreleased/26883-members-page-layout-looks-broken.yml @@ -0,0 +1,4 @@ +--- +title: Improved UX on project members settings view +merge_request: +author: diff --git a/changelogs/unreleased/28558-create-new-branch-from-issue-page.yml b/changelogs/unreleased/28558-create-new-branch-from-issue-page.yml new file mode 100644 index 00000000000..e43b043d6c5 --- /dev/null +++ b/changelogs/unreleased/28558-create-new-branch-from-issue-page.yml @@ -0,0 +1,4 @@ +--- +title: Allow to create new branch and empty WIP merge request from issue page +merge_request: +author: diff --git a/changelogs/unreleased/30458-real-time-note-edits.yml b/changelogs/unreleased/30458-real-time-note-edits.yml new file mode 100644 index 00000000000..f67348c5302 --- /dev/null +++ b/changelogs/unreleased/30458-real-time-note-edits.yml @@ -0,0 +1,4 @@ +--- +title: Update note edits in real-time +merge_request: +author: diff --git a/changelogs/unreleased/31544-size-of-project-from-api.yml b/changelogs/unreleased/31544-size-of-project-from-api.yml new file mode 100644 index 00000000000..a707d49aecd --- /dev/null +++ b/changelogs/unreleased/31544-size-of-project-from-api.yml @@ -0,0 +1,4 @@ +--- +title: Expose project statistics on single requests via the API +merge_request: +author: diff --git a/changelogs/unreleased/31647-fix-snippet-content_html.yml b/changelogs/unreleased/31647-fix-snippet-content_html.yml new file mode 100644 index 00000000000..db6d45926fd --- /dev/null +++ b/changelogs/unreleased/31647-fix-snippet-content_html.yml @@ -0,0 +1,4 @@ +--- +title: Fix caching large snippet HTML content on MySQL databases +merge_request: 11024 +author: diff --git a/changelogs/unreleased/31671-merge-request-message-contains-carriage-returns.yml b/changelogs/unreleased/31671-merge-request-message-contains-carriage-returns.yml new file mode 100644 index 00000000000..c33fa944a83 --- /dev/null +++ b/changelogs/unreleased/31671-merge-request-message-contains-carriage-returns.yml @@ -0,0 +1,4 @@ +--- +title: Remove carriage returns from commit messages +merge_request: 11077 +author: diff --git a/changelogs/unreleased/dm-artifact-browser-header.yml b/changelogs/unreleased/dm-artifact-browser-header.yml new file mode 100644 index 00000000000..b88ab2ac7e5 --- /dev/null +++ b/changelogs/unreleased/dm-artifact-browser-header.yml @@ -0,0 +1,4 @@ +--- +title: Add breadcrumb, build header and pipelines submenu to artifacts browser +merge_request: +author: diff --git a/changelogs/unreleased/dm-comment-on-diff-versions.yml b/changelogs/unreleased/dm-comment-on-diff-versions.yml new file mode 100644 index 00000000000..af299713ad3 --- /dev/null +++ b/changelogs/unreleased/dm-comment-on-diff-versions.yml @@ -0,0 +1,4 @@ +--- +title: Allow commenting on older versions of the diff and comparisons between diff versions +merge_request: +author: diff --git a/changelogs/unreleased/zj-chat-message-pretty-time.yml b/changelogs/unreleased/zj-chat-message-pretty-time.yml new file mode 100644 index 00000000000..68bc647bab2 --- /dev/null +++ b/changelogs/unreleased/zj-chat-message-pretty-time.yml @@ -0,0 +1,4 @@ +--- +title: Pipeline chat notifications convert seconds to minutes and hours +merge_request: +author: diff --git a/config/routes/project.rb b/config/routes/project.rb index a15e365cc2f..956afe4faa3 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -234,6 +234,7 @@ constraints(ProjectUrlConstrainer.new) do get :related_branches get :can_create_branch get :rendered_title + post :create_merge_request end collection do post :bulk_update diff --git a/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb b/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb new file mode 100644 index 00000000000..008a94d8334 --- /dev/null +++ b/db/migrate/20170502091007_markdown_cache_limits_to_mysql.rb @@ -0,0 +1,2 @@ +# rubocop:disable all +require_relative 'markdown_cache_limits_to_mysql' diff --git a/db/migrate/markdown_cache_limits_to_mysql.rb b/db/migrate/markdown_cache_limits_to_mysql.rb new file mode 100644 index 00000000000..f6686db3dc0 --- /dev/null +++ b/db/migrate/markdown_cache_limits_to_mysql.rb @@ -0,0 +1,13 @@ +class MarkdownCacheLimitsToMysql < ActiveRecord::Migration + DOWNTIME = false + + def up + return unless Gitlab::Database.mysql? + + change_column :snippets, :content_html, :text, limit: 2147483647 + end + + def down + # no-op + end +end diff --git a/db/schema.rb b/db/schema.rb index be6684f3a6b..01c0f00c924 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: 20170426181740) do +ActiveRecord::Schema.define(version: 20170502091007) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/doc/api/projects.md b/doc/api/projects.md index 51de4fef7ff..188fbe7447d 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -40,6 +40,7 @@ Parameters: | `owned` | boolean | no | Limit by projects owned by the current user | | `membership` | boolean | no | Limit by projects that the current user is a member of | | `starred` | boolean | no | Limit by projects starred by the current user | +| `statistics` | boolean | no | Include project statistics | ```json [ @@ -91,7 +92,14 @@ Parameters: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, - "request_access_enabled": false + "request_access_enabled": false, + "statistics": { + "commit_count": 37, + "storage_size": 1038090, + "repository_size": 1038090, + "lfs_objects_size": 0, + "job_artifacts_size": 0 + } }, { "id": 6, @@ -151,7 +159,14 @@ Parameters: "shared_with_groups": [], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, - "request_access_enabled": false + "request_access_enabled": false, + "statistics": { + "commit_count": 12, + "storage_size": 2066080, + "repository_size": 2066080, + "lfs_objects_size": 0, + "job_artifacts_size": 0 + } } ] ``` @@ -170,6 +185,7 @@ Parameters: | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +| `statistics` | boolean | no | Include project statistics | ```json { @@ -241,7 +257,14 @@ Parameters: ], "only_allow_merge_if_pipeline_succeeds": false, "only_allow_merge_if_all_discussions_are_resolved": false, - "request_access_enabled": false + "request_access_enabled": false, + "statistics": { + "commit_count": 37, + "storage_size": 1038090, + "repository_size": 1038090, + "lfs_objects_size": 0, + "job_artifacts_size": 0 + } } ``` diff --git a/doc/workflow/img/todos_icon.png b/doc/workflow/img/todos_icon.png Binary files differindex 1ed16b09669..9fee4337a75 100644 --- a/doc/workflow/img/todos_icon.png +++ b/doc/workflow/img/todos_icon.png diff --git a/doc/workflow/img/todos_index.png b/doc/workflow/img/todos_index.png Binary files differindex 902a5aa6bd3..99c1575d157 100644 --- a/doc/workflow/img/todos_index.png +++ b/doc/workflow/img/todos_index.png diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature index 52dc15f2eb6..09094d638c9 100644 --- a/features/project/builds/artifacts.feature +++ b/features/project/builds/artifacts.feature @@ -17,6 +17,7 @@ Feature: Project Builds Artifacts When I visit recent build details page And I click artifacts browse button Then I should see content of artifacts archive + And I should see the build header Scenario: I browse subdirectory of build artifacts Given recent build has artifacts available @@ -25,6 +26,7 @@ Feature: Project Builds Artifacts And I click artifacts browse button And I click link to subdirectory within build artifacts Then I should see content of subdirectory within artifacts archive + And I should see the directory name in the breadcrumb Scenario: I browse directory with UTF-8 characters in name Given recent build has artifacts available diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index be0f6eee55a..3ec5c8e2f4f 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -22,6 +22,12 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps end end + step 'I should see the build header' do + page.within('.build-header') do + expect(page).to have_content "Job ##{@build.id} in pipeline ##{@pipeline.id} for commit #{@pipeline.short_sha}" + end + end + step 'I click link to subdirectory within build artifacts' do page.within('.tree-table') { click_link 'other_artifacts_0.1.2' } end @@ -34,6 +40,12 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps end end + step 'I should see the directory name in the breadcrumb' do + page.within('.repo-breadcrumb') do + expect(page).to have_content 'other_artifacts_0.1.2' + end + end + step 'recent build artifacts contain directory with UTF-8 characters' do # metadata fixture contains relevant directory end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index db4b31b55bc..1ba691cbea1 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -26,6 +26,10 @@ module API params :optional_params do use :optional_params_ce end + + params :statistics_params do + optional :statistics, type: Boolean, default: false, desc: 'Include project statistics' + end end resource :projects do @@ -56,10 +60,6 @@ module API optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of' end - params :statistics_params do - optional :statistics, type: Boolean, default: false, desc: 'Include project statistics' - end - params :create_params do optional :namespace_id, type: Integer, desc: 'Namespace ID for the new project. Default to the user namespace.' optional :import_url, type: String, desc: 'URL from which the project is imported' @@ -85,6 +85,7 @@ module API end params do use :collection_params + use :statistics_params end get do entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails @@ -151,10 +152,13 @@ module API desc 'Get a single project' do success Entities::ProjectWithAccess end + params do + use :statistics_params + end get ":id" do entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails present user_project, with: entity, current_user: current_user, - user_can_admin_project: can?(current_user, :admin_project, user_project) + user_can_admin_project: can?(current_user, :admin_project, user_project), statistics: params[:statistics] end desc 'Get events for a single project' do diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index e8bb9e1f805..12458f9f410 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -128,6 +128,10 @@ module Gitlab encode! @name end + def truncated? + size && (size > loaded_size) + end + # Valid LFS object pointer is a text file consisting of # version # oid @@ -155,10 +159,14 @@ module Gitlab nil end - def truncated? - size && (size > loaded_size) + def external_storage + return unless lfs_pointer? + + :lfs end + alias_method :external_size, :lfs_size + private def has_lfs_version_key? diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index acd0037ee4f..e7485c22039 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -876,27 +876,6 @@ module Gitlab rugged.remotes[remote_name].push(refspecs) end - # Merge the +source_name+ branch into the +target_name+ branch. This is - # equivalent to `git merge --no_ff +source_name+`, since a merge commit - # is always created. - def merge(source_name, target_name, options = {}) - our_commit = rugged.branches[target_name].target - their_commit = rugged.branches[source_name].target - - raise "Invalid merge target" if our_commit.nil? - raise "Invalid merge source" if their_commit.nil? - - merge_index = rugged.merge_commits(our_commit, their_commit) - return false if merge_index.conflicts? - - actual_options = options.merge( - parents: [our_commit, their_commit], - tree: merge_index.write_tree(rugged), - update_ref: "refs/heads/#{target_name}" - ) - Rugged::Commit.create(rugged, actual_options) - end - AUTOCRLF_VALUES = { "true" => true, "false" => false, diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index b95cea371b9..3aac731e844 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -84,6 +84,7 @@ excluded_attributes: - :import_jid - :id - :star_count + - :last_activity_at snippets: - :expired_at merge_request_diff: diff --git a/lib/tasks/gitlab/db.rake b/lib/tasks/gitlab/db.rake index 5476438b8fa..139ab70e125 100644 --- a/lib/tasks/gitlab/db.rake +++ b/lib/tasks/gitlab/db.rake @@ -65,6 +65,7 @@ namespace :gitlab do migrations = `git diff #{ref}.. --diff-filter=A --name-only -- db/migrate`.lines .map { |file| Rails.root.join(file.strip).to_s } .select { |file| File.file?(file) } + .select { |file| /\A[0-9]+.*\.rb\z/ =~ File.basename(file) } Gitlab::DowntimeCheck.new.check_and_print(migrations) end diff --git a/lib/tasks/migrate/add_limits_mysql.rake b/lib/tasks/migrate/add_limits_mysql.rake index 6ded519aff2..761f275d42a 100644 --- a/lib/tasks/migrate/add_limits_mysql.rake +++ b/lib/tasks/migrate/add_limits_mysql.rake @@ -1,7 +1,9 @@ require Rails.root.join('db/migrate/limits_to_mysql') +require Rails.root.join('db/migrate/markdown_cache_limits_to_mysql') desc "GitLab | Add limits to strings in mysql database" task add_limits_mysql: :environment do puts "Adding limits to schema.rb for mysql" LimitsToMysql.new.up + MarkdownCacheLimitsToMysql.new.up end diff --git a/scripts/static-analysis b/scripts/static-analysis index 192d9d4c3ba..1bd6b339830 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -9,7 +9,6 @@ tasks = [ %w[bundle exec rake scss_lint], %w[bundle exec rake brakeman], %w[bundle exec license_finder], - %w[scripts/lint-doc.sh], %w[yarn run eslint], %w[bundle exec rubocop --require rubocop-rspec] ] diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index d20e7368086..8f915d9d210 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -14,7 +14,7 @@ describe Projects::BranchesController do controller.instance_variable_set(:@project, project) end - describe "POST create" do + describe "POST create with HTML format" do render_views context "on creation of a new branch" do @@ -152,6 +152,42 @@ describe Projects::BranchesController do end end + describe 'POST create with JSON format' do + before do + sign_in(user) + end + + context 'with valid params' do + it 'returns a successful 200 response' do + create_branch name: 'my-branch', ref: 'master' + + expect(response).to have_http_status(200) + end + + it 'returns the created branch' do + create_branch name: 'my-branch', ref: 'master' + + expect(response).to match_response_schema('branch') + end + end + + context 'with invalid params' do + it 'returns an unprocessable entity 422 response' do + create_branch name: "<script>alert('merge');</script>", ref: "<script>alert('ref');</script>" + + expect(response).to have_http_status(422) + end + end + + def create_branch(name:, ref:) + post :create, namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: name, + ref: ref, + format: :json + end + end + describe "POST destroy with HTML format" do render_views diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 79034b8d24d..5f1f892821a 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -756,4 +756,28 @@ describe Projects::IssuesController do expect(response).to have_http_status(200) end end + + describe 'POST create_merge_request' do + before do + project.add_developer(user) + sign_in(user) + end + + it 'creates a new merge request' do + expect { create_merge_request }.to change(project.merge_requests, :count).by(1) + end + + it 'render merge request as json' do + create_merge_request + + expect(response).to match_response_schema('merge_request') + end + + def create_merge_request + post :create_merge_request, namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: issue.to_param, + format: :json + end + end end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb index 01b1aee4fd3..f5b54463df8 100644 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ b/spec/features/gitlab_flavored_markdown_spec.rb @@ -62,6 +62,8 @@ describe "GitLab Flavored Markdown", feature: true do project: project, title: "fix #{@other_issue.to_reference}", description: "ask #{fred.to_reference} for details") + + @note = create(:note_on_issue, noteable: @issue, project: @issue.project, note: "Hello world") end it "renders subject in issues#index" do @@ -81,14 +83,6 @@ describe "GitLab Flavored Markdown", feature: true do expect(page).to have_link(fred.to_reference) end - - it "renders updated subject once edited somewhere else in issues#show" do - visit namespace_project_issue_path(project.namespace, project, @issue) - @issue.update(title: "fix #{@other_issue.to_reference} and update") - - wait_for_vue_resource - expect(page).to have_text("fix #{@other_issue.to_reference} and update") - end end describe "for merge requests" do diff --git a/spec/features/issues/create_branch_merge_request_spec.rb b/spec/features/issues/create_branch_merge_request_spec.rb new file mode 100644 index 00000000000..44c19275ae5 --- /dev/null +++ b/spec/features/issues/create_branch_merge_request_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +feature 'Create Branch/Merge Request Dropdown on issue page', feature: true, js: true do + let(:user) { create(:user) } + let!(:project) { create(:project) } + let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') } + + context 'for team members' do + before do + project.team << [user, :developer] + login_as(user) + end + + it 'allows creating a merge request from the issue page' do + visit namespace_project_issue_path(project.namespace, project, issue) + + select_dropdown_option('create-mr') + + wait_for_ajax + + expect(page).to have_content("created branch 1-cherry-coloured-funk") + expect(page).to have_content("mentioned in merge request !1") + + visit namespace_project_merge_request_path(project.namespace, project, MergeRequest.first) + + expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"') + expect(current_path).to eq(namespace_project_merge_request_path(project.namespace, project, MergeRequest.first)) + end + + it 'allows creating a branch from the issue page' do + visit namespace_project_issue_path(project.namespace, project, issue) + + select_dropdown_option('create-branch') + + wait_for_ajax + + expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk') + expect(current_path).to eq namespace_project_tree_path(project.namespace, project, '1-cherry-coloured-funk') + end + + context "when there is a referenced merge request" do + let!(:note) do + create(:note, :on_issue, :system, project: project, noteable: issue, + note: "mentioned in #{referenced_mr.to_reference}") + end + + let(:referenced_mr) do + create(:merge_request, :simple, source_project: project, target_project: project, + description: "Fixes #{issue.to_reference}", author: user) + end + + before do + referenced_mr.cache_merge_request_closes_issues!(user) + + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'disables the create branch button' do + expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)') + expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false) + expect(page).to have_content /1 Related Merge Request/ + end + end + + context 'when issue is confidential' do + it 'disables the create branch button' do + issue = create(:issue, :confidential, project: project) + + visit namespace_project_issue_path(project.namespace, project, issue) + + expect(page).not_to have_css('.create-mr-dropdown-wrap') + end + end + end + + context 'for visitors' do + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'shows no buttons' do + expect(page).not_to have_selector('.create-mr-dropdown-wrap') + end + end + + def select_dropdown_option(option) + find('.create-mr-dropdown-wrap .dropdown-toggle').click + find("li[data-value='#{option}']").click + find('.js-create-merge-request').click + end +end diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb deleted file mode 100644 index c0ab42c6822..00000000000 --- a/spec/features/issues/new_branch_button_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -require 'rails_helper' - -feature 'Start new branch from an issue', feature: true, js: true do - let!(:project) { create(:project) } - let!(:issue) { create(:issue, project: project) } - let!(:user) { create(:user)} - - context "for team members" do - before do - project.team << [user, :master] - login_as(user) - end - - it 'shows the new branch button' do - visit namespace_project_issue_path(project.namespace, project, issue) - - expect(page).to have_css('#new-branch .available') - end - - context "when there is a referenced merge request" do - let!(:note) do - create(:note, :on_issue, :system, project: project, noteable: issue, - note: "mentioned in #{referenced_mr.to_reference}") - end - - let(:referenced_mr) do - create(:merge_request, :simple, source_project: project, target_project: project, - description: "Fixes #{issue.to_reference}", author: user) - end - - before do - referenced_mr.cache_merge_request_closes_issues!(user) - - visit namespace_project_issue_path(project.namespace, project, issue) - end - - it "hides the new branch button" do - expect(page).to have_css('#new-branch .unavailable') - expect(page).not_to have_css('#new-branch .available') - expect(page).to have_content /1 Related Merge Request/ - end - end - - context 'when issue is confidential' do - it 'hides the new branch button' do - issue = create(:issue, :confidential, project: project) - - visit namespace_project_issue_path(project.namespace, project, issue) - - expect(page).not_to have_css('#new-branch') - end - end - end - - context 'for visitors' do - it 'shows no buttons' do - visit namespace_project_issue_path(project.namespace, project, issue) - - expect(page).not_to have_css('#new-branch') - end - end -end diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb index 378f6de1a78..58b3215f14c 100644 --- a/spec/features/issues/note_polling_spec.rb +++ b/spec/features/issues/note_polling_spec.rb @@ -4,14 +4,77 @@ feature 'Issue notes polling', :feature, :js do let(:project) { create(:empty_project, :public) } let(:issue) { create(:issue, project: project) } - before do - visit namespace_project_issue_path(project.namespace, project, issue) + describe 'creates' do + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'displays the new comment' do + note = create(:note, noteable: issue, project: project, note: 'Looks good!') + page.execute_script('notes.refresh();') + + expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') + end end - it 'should display the new comment' do - note = create(:note, noteable: issue, project: project, note: 'Looks good!') - page.execute_script('notes.refresh();') + describe 'updates' do + let(:user) { create(:user) } + let(:note_text) { "Hello World" } + let(:updated_text) { "Bye World" } + let!(:existing_note) { create(:note, noteable: issue, project: project, author: user, note: note_text) } + + before do + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'displays the updated content' do + expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) + + update_note(existing_note, updated_text) + + expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) + end + + it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do + find("#note_#{existing_note.id} .js-note-edit").click + + expect(page).to have_field("note[note]", with: note_text) + + update_note(existing_note, updated_text) + + expect(page).to have_field("note[note]", with: updated_text) + end + + it 'when editing but you changed some things, and an update comes in, show a warning' do + find("#note_#{existing_note.id} .js-note-edit").click - expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') + expect(page).to have_field("note[note]", with: note_text) + + find("#note_#{existing_note.id} .js-note-text").set('something random') + + update_note(existing_note, updated_text) + + expect(page).to have_selector(".alert") + end + + it 'when editing but you changed some things, an update comes in, and you press cancel, show the updated content' do + find("#note_#{existing_note.id} .js-note-edit").click + + expect(page).to have_field("note[note]", with: note_text) + + find("#note_#{existing_note.id} .js-note-text").set('something random') + + update_note(existing_note, updated_text) + + find("#note_#{existing_note.id} .note-edit-cancel").click + + expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) + end + end + + def update_note(note, new_text) + note.update(note: new_text) + page.execute_script('notes.refresh();') end end diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb index 7a2da623c58..2b5b803946c 100644 --- a/spec/features/merge_requests/versions_spec.rb +++ b/spec/features/merge_requests/versions_spec.rb @@ -24,7 +24,12 @@ feature 'Merge Request versions', js: true, feature: true do before do page.within '.mr-version-dropdown' do find('.btn-default').click - find(:link, 'version 1').trigger('click') + click_link 'version 1' + end + + # Wait for the page to load + page.within '.mr-version-dropdown' do + expect(page).to have_content 'version 1' end end @@ -36,8 +41,8 @@ feature 'Merge Request versions', js: true, feature: true do expect(page).to have_content '5 changed files' end - it 'show the message about disabled comment creation' do - expect(page).to have_content 'comment creation is disabled' + it 'show the message about comments' do + expect(page).to have_content 'Not all comments are displayed' end it 'shows comments that were last relevant at that version' do @@ -52,15 +57,41 @@ feature 'Merge Request versions', js: true, feature: true do outdated_diff_note.position = outdated_diff_note.original_position outdated_diff_note.save! + visit current_url + expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") end + + it 'allows commenting' do + diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']" + line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_2_2' + + page.within(diff_file_selector) do + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover' + find(".line_holder[id='#{line_code}'] button").trigger 'click' + + page.within("form[data-line-code='#{line_code}']") do + fill_in "note[note]", with: "Typo, please fix" + find(".js-comment-button").click + end + + wait_for_ajax + + expect(page).to have_content("Typo, please fix") + end + end end describe 'compare with older version' do before do page.within '.mr-version-compare-dropdown' do find('.btn-default').click - find(:link, 'version 1').trigger('click') + click_link 'version 1' + end + + # Wait for the page to load + page.within '.mr-version-compare-dropdown' do + expect(page).to have_content 'version 1' end end @@ -80,8 +111,43 @@ feature 'Merge Request versions', js: true, feature: true do end end - it 'show the message about disabled comments' do - expect(page).to have_content 'Comments are disabled' + it 'show the message about comments' do + expect(page).to have_content 'Not all comments are displayed' + end + + it 'shows comments that were last relevant at that version' do + position = Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: 4, + new_line: 4, + diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs + ) + outdated_diff_note = create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + + visit current_url + wait_for_ajax + + expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") + end + + it 'allows commenting' do + diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']" + line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_4' + + page.within(diff_file_selector) do + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").trigger 'mouseover' + find(".line_holder[id='#{line_code}'] button").trigger 'click' + + page.within("form[data-line-code='#{line_code}']") do + fill_in "note[note]", with: "Typo, please fix" + find(".js-comment-button").click + end + + wait_for_ajax + + expect(page).to have_content("Typo, please fix") + end end it 'show diff between new and old version' do diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 8dba2ccbafa..5955623f565 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -159,7 +159,7 @@ feature 'File blob', :js, feature: true do expect(page).to have_selector('.blob-viewer[data-type="rich"]') # shows an error message - expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can view the source or download it instead.') + expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can download it instead.') # shows a viewer switcher expect(page).to have_selector('.js-blob-viewer-switcher') @@ -167,8 +167,8 @@ feature 'File blob', :js, feature: true do # does not show a copy button expect(page).not_to have_selector('.js-copy-blob-source-btn') - # shows a raw button - expect(page).to have_link('Open raw') + # shows a download button + expect(page).to have_link('Download') end end @@ -332,4 +332,41 @@ feature 'File blob', :js, feature: true do end end end + + context 'empty file' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add empty file", + file_path: 'files/empty.md', + file_content: '' + ).execute + + visit_blob('files/empty.md') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('Empty file') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # does not show a download or raw button + expect(page).not_to have_link('Download') + expect(page).not_to have_link('Open raw') + end + end + end end diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/features/protected_tags/access_control_ce_spec.rb index 5b24ac0292b..a04fbcdd15f 100644 --- a/spec/features/protected_tags/access_control_ce_spec.rb +++ b/spec/features/protected_tags/access_control_ce_spec.rb @@ -10,8 +10,8 @@ RSpec.shared_examples "protected tags > access control > CE" do unless allowed_to_create_button.text == access_type_name allowed_to_create_button.click - find('.dropdown.open .dropdown-menu li', match: :first) - within(".dropdown.open .dropdown-menu") { click_on access_type_name } + find('.create_access_levels-container .dropdown-menu li', match: :first) + within('.create_access_levels-container .dropdown-menu') { click_on access_type_name } end end diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb index e3aa87ded28..e68448467b0 100644 --- a/spec/features/protected_tags_spec.rb +++ b/spec/features/protected_tags_spec.rb @@ -11,6 +11,7 @@ feature 'Projected Tags', feature: true, js: true do find(".js-protected-tag-select").click find(".dropdown-input-field").set(tag_name) click_on("Create wildcard #{tag_name}") + find('.protected-tags-dropdown .dropdown-menu', visible: false) end describe "explicit protected tags" do diff --git a/spec/fixtures/api/schemas/branch.json b/spec/fixtures/api/schemas/branch.json new file mode 100644 index 00000000000..0bb74577010 --- /dev/null +++ b/spec/fixtures/api/schemas/branch.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required" : [ + "name", + "url" + ], + "properties" : { + "name": { "type": "string" }, + "url": { "type": "uri" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/merge_request.json b/spec/fixtures/api/schemas/merge_request.json new file mode 100644 index 00000000000..36962660cd9 --- /dev/null +++ b/spec/fixtures/api/schemas/merge_request.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required" : [ + "iid", + "url" + ], + "properties" : { + "iid": { "type": "integer" }, + "url": { "type": "uri" } + }, + "additionalProperties": false +} diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 075f1887d91..1b4393e6167 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -145,7 +145,7 @@ describe BlobHelper do end end - context 'for error :server_side_but_stored_in_lfs' do + context 'for error :server_side_but_stored_externally' do let(:blob) { fake_blob(lfs: true) } it 'returns an error message' do @@ -183,40 +183,56 @@ describe BlobHelper do expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) end end - end - context 'when the viewer is rich' do - context 'the blob is rendered as text' do - let(:blob) { fake_blob(path: 'file.md', lfs: true) } + context 'when the viewer is rich' do + context 'the blob is rendered as text' do + let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) } + + it 'includes a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).to include(/view the source/) + end + end + + context 'the blob is not rendered as text' do + let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 2.megabytes) } - it 'includes a "view the source" link' do - expect(helper.blob_render_error_options(viewer)).to include(/view the source/) + it 'does not include a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) + end end end - context 'the blob is not rendered as text' do - let(:blob) { fake_blob(path: 'file.pdf', binary: true, lfs: true) } + context 'when the viewer is not rich' do + before do + viewer_class.type = :simple + end + + let(:blob) { fake_blob(path: 'file.md', size: 2.megabytes) } it 'does not include a "view the source" link' do expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) end end - end - context 'when the viewer is not rich' do - before do - viewer_class.type = :simple + it 'includes a "download it" link' do + expect(helper.blob_render_error_options(viewer)).to include(/download it/) end + end + context 'for error :server_side_but_stored_externally' do let(:blob) { fake_blob(path: 'file.md', lfs: true) } + it 'does not include a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) + end + it 'does not include a "view the source" link' do expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) end - end - it 'includes a "download it" link' do - expect(helper.blob_render_error_options(viewer)).to include(/download it/) + it 'includes a "download it" link' do + expect(helper.blob_render_error_options(viewer)).to include(/download it/) + end end end end diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index a427de32c4c..6c990f94175 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -1,6 +1,8 @@ require "spec_helper" describe NotesHelper do + include RepoHelpers + let(:owner) { create(:owner) } let(:group) { create(:group) } let(:project) { create(:empty_project, namespace: group) } @@ -36,4 +38,141 @@ describe NotesHelper do expect(helper.note_max_access_for_user(other_note)).to eq('Reporter') end end + + describe '#discussion_path' do + let(:project) { create(:project) } + + context 'for a merge request discusion' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project, importing: true) } + let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'for a diff discussion' do + context 'when the discussion is active' do + let(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } + + it 'returns the diff path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: discussion.line_code)) + end + end + + context 'when the discussion is on an older merge request version' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: nil, + new_line: 4, + diff_refs: merge_request_diff1.diff_refs + ) + end + + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + let(:discussion) { diff_note.to_discussion } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + + it 'returns the diff version path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: merge_request_diff1, anchor: discussion.line_code)) + end + end + + context 'when the discussion is on a comparison between merge request versions' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: 4, + new_line: 4, + diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs + ) + end + + let(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position).to_discussion } + + it 'returns the diff version comparison path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, diff_id: merge_request_diff3, start_sha: merge_request_diff1.head_commit_sha, anchor: discussion.line_code)) + end + end + + context 'when the discussion does not have a merge request version' do + let(:outdated_diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, diff_refs: project.commit(sample_commit.id).diff_refs) } + let(:discussion) { outdated_diff_note.to_discussion } + + before do + outdated_diff_note.position = outdated_diff_note.original_position + outdated_diff_note.save! + end + + it 'returns nil' do + expect(helper.discussion_path(discussion)).to be_nil + end + end + end + + context 'for a legacy diff discussion' do + let(:discussion) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } + + context 'when the discussion is active' do + before do + allow(discussion).to receive(:active?).and_return(true) + end + + it 'returns the diff path with the line code' do + expect(helper.discussion_path(discussion)).to eq(diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: discussion.line_code)) + end + end + + context 'when the discussion is outdated' do + before do + allow(discussion).to receive(:active?).and_return(false) + end + + it 'returns nil' do + expect(helper.discussion_path(discussion)).to be_nil + end + end + end + + context 'for a non-diff discussion' do + let(:discussion) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project).to_discussion } + + it 'returns nil' do + expect(helper.discussion_path(discussion)).to be_nil + end + end + end + + context 'for a commit discussion' do + let(:commit) { discussion.noteable } + + context 'for a diff discussion' do + let(:discussion) { create(:diff_note_on_commit, project: project).to_discussion } + + it 'returns the commit path with the line code' do + expect(helper.discussion_path(discussion)).to eq(namespace_project_commit_path(project.namespace, project, commit, anchor: discussion.line_code)) + end + end + + context 'for a legacy diff discussion' do + let(:discussion) { create(:legacy_diff_note_on_commit, project: project).to_discussion } + + it 'returns the commit path with the line code' do + expect(helper.discussion_path(discussion)).to eq(namespace_project_commit_path(project.namespace, project, commit, anchor: discussion.line_code)) + end + end + + context 'for a non-diff discussion' do + let(:discussion) { create(:discussion_note_on_commit, project: project).to_discussion } + + it 'returns the commit path' do + expect(helper.discussion_path(discussion)).to eq(namespace_project_commit_path(project.namespace, project, commit)) + end + end + end + end end diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 9a2570ef7e9..0fd573eae3f 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -108,8 +108,8 @@ describe('Issue', function() { expect(this.$triggeredButton).toHaveProp('disabled', true); expectNewBranchButtonState(true, false); return this.issueStateDeferred; - } else if (req.url === Issue.$btnNewBranch.data('path')) { - expect(req.type).toBe('get'); + } else if (req.url === Issue.createMrDropdownWrap.dataset.canCreatePath) { + expect(req.type).toBe('GET'); expectNewBranchButtonState(true, false); return this.canCreateBranchDeferred; } diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index ca8ee04d955..cdc5c4510ff 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -1,10 +1,12 @@ /* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */ /* global Notes */ -require('~/notes'); -require('vendor/autosize'); -require('~/gl_form'); -require('~/lib/utils/text_utility'); +import 'vendor/autosize'; +import '~/gl_form'; +import '~/lib/utils/text_utility'; +import '~/render_gfm'; +import '~/render_math'; +import '~/notes'; (function() { window.gon || (window.gon = {}); @@ -80,35 +82,78 @@ require('~/lib/utils/text_utility'); beforeEach(() => { note = { + id: 1, discussion_html: null, valid: true, - html: '<div></div>', + note: 'heya', + html: '<div>heya</div>', }; - $notesList = jasmine.createSpyObj('$notesList', ['find']); + $notesList = jasmine.createSpyObj('$notesList', [ + 'find', + 'append', + ]); notes = jasmine.createSpyObj('notes', [ 'refresh', 'isNewNote', + 'isUpdatedNote', 'collapseLongCommitList', 'updateNotesCount', + 'putConflictEditWarningInPlace' ]); notes.taskList = jasmine.createSpyObj('tasklist', ['init']); notes.note_ids = []; + notes.updatedNotesTrackingMap = {}; - spyOn(window, '$').and.returnValue($notesList); spyOn(gl.utils, 'localTimeAgo'); - spyOn(Notes, 'animateAppendNote'); - notes.isNewNote.and.returnValue(true); - - Notes.prototype.renderNote.call(notes, note); + spyOn(Notes, 'animateAppendNote').and.callThrough(); + spyOn(Notes, 'animateUpdateNote').and.callThrough(); }); - it('should query for the notes list', () => { - expect(window.$).toHaveBeenCalledWith('ul.main-notes-list'); + describe('when adding note', () => { + it('should call .animateAppendNote', () => { + notes.isNewNote.and.returnValue(true); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + }); }); - it('should call .animateAppendNote', () => { - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, $notesList); + describe('when note was edited', () => { + it('should call .animateUpdateNote', () => { + notes.isUpdatedNote.and.returnValue(true); + const $note = $('<div>'); + $notesList.find.and.returnValue($note); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect(Notes.animateUpdateNote).toHaveBeenCalledWith(note.html, $note); + }); + + describe('while editing', () => { + it('should update textarea if nothing has been touched', () => { + notes.isUpdatedNote.and.returnValue(true); + const $note = $(`<div class="is-editing"> + <div class="original-note-content">initial</div> + <textarea class="js-note-text">initial</textarea> + </div>`); + $notesList.find.and.returnValue($note); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect($note.find('.js-note-text').val()).toEqual(note.note); + }); + + it('should call .putConflictEditWarningInPlace', () => { + notes.isUpdatedNote.and.returnValue(true); + const $note = $(`<div class="is-editing"> + <div class="original-note-content">initial</div> + <textarea class="js-note-text">different</textarea> + </div>`); + $notesList.find.and.returnValue($note); + Notes.prototype.renderNote.call(notes, note, null, $notesList); + + expect(notes.putConflictEditWarningInPlace).toHaveBeenCalledWith(note, $note); + }); + }); }); }); @@ -147,14 +192,12 @@ require('~/lib/utils/text_utility'); }); describe('Discussion root note', () => { - let $notesList; let body; beforeEach(() => { body = jasmine.createSpyObj('body', ['attr']); discussionContainer = { length: 0 }; - spyOn(window, '$').and.returnValues(discussionContainer, body, $notesList); $form.closest.and.returnValues(row, $form); $form.find.and.returnValues(discussionContainer); body.attr.and.returnValue(''); @@ -162,12 +205,8 @@ require('~/lib/utils/text_utility'); Notes.prototype.renderDiscussionNote.call(notes, note, $form); }); - it('should query for the notes list', () => { - expect(window.$.calls.argsFor(2)).toEqual(['ul.main-notes-list']); - }); - it('should call Notes.animateAppendNote', () => { - expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $notesList); + expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.discussion_html, $('.main-notes-list')); }); }); @@ -175,16 +214,12 @@ require('~/lib/utils/text_utility'); beforeEach(() => { discussionContainer = { length: 1 }; - spyOn(window, '$').and.returnValues(discussionContainer); - $form.closest.and.returnValues(row); + $form.closest.and.returnValues(row, $form); + $form.find.and.returnValues(discussionContainer); Notes.prototype.renderDiscussionNote.call(notes, note, $form); }); - it('should query foor the discussion container', () => { - expect(window.$).toHaveBeenCalledWith(`.notes[data-discussion-id="${note.discussion_id}"]`); - }); - it('should call Notes.animateAppendNote', () => { expect(Notes.animateAppendNote).toHaveBeenCalledWith(note.html, discussionContainer); }); @@ -193,35 +228,45 @@ require('~/lib/utils/text_utility'); describe('animateAppendNote', () => { let noteHTML; - let $note; let $notesList; + let $resultantNote; beforeEach(() => { noteHTML = '<div></div>'; - $note = jasmine.createSpyObj('$note', ['addClass', 'renderGFM', 'removeClass']); $notesList = jasmine.createSpyObj('$notesList', ['append']); - spyOn(window, '$').and.returnValue($note); - spyOn(window, 'setTimeout').and.callThrough(); - $note.addClass.and.returnValue($note); - $note.renderGFM.and.returnValue($note); + $resultantNote = Notes.animateAppendNote(noteHTML, $notesList); + }); - Notes.animateAppendNote(noteHTML, $notesList); + it('should have `fade-in` class', () => { + expect($resultantNote.hasClass('fade-in')).toEqual(true); }); - it('should init the note jquery object', () => { - expect(window.$).toHaveBeenCalledWith(noteHTML); + it('should append note to the notes list', () => { + expect($notesList.append).toHaveBeenCalledWith($resultantNote); }); + }); + + describe('animateUpdateNote', () => { + let noteHTML; + let $note; + let $updatedNote; - it('should call addClass', () => { - expect($note.addClass).toHaveBeenCalledWith('fade-in'); + beforeEach(() => { + noteHTML = '<div></div>'; + $note = jasmine.createSpyObj('$note', [ + 'replaceWith' + ]); + + $updatedNote = Notes.animateUpdateNote(noteHTML, $note); }); - it('should call renderGFM', () => { - expect($note.renderGFM).toHaveBeenCalledWith(); + + it('should have `fade-in` class', () => { + expect($updatedNote.hasClass('fade-in')).toEqual(true); }); - it('should append note to the notes list', () => { - expect($notesList.append).toHaveBeenCalledWith($note); + it('should call replaceWith on $note', () => { + expect($note.replaceWith).toHaveBeenCalledWith($updatedNote); }); }); }); diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 6e145947104..1035428b2e7 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared) } let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:user) { create(:user) } - let(:project) { setup_project } + let!(:project) { setup_project } before do project.team << [user, :master] @@ -219,7 +219,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do releases: [release], group: group ) - project.update(description_html: 'description') + project.update_column(:description_html, 'description') project_label = create(:label, project: project) group_label = create(:group_label, group: group) create(:label_link, label: project_label, target: issue) diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 7e8a1c8add7..f84c6b48173 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -35,8 +35,68 @@ describe Blob do end end + describe '#external_storage_error?' do + context 'if the blob is stored in LFS' do + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + context 'when the project has LFS enabled' do + it 'returns false' do + expect(blob.external_storage_error?).to be_falsey + end + end + + context 'when the project does not have LFS enabled' do + before do + project.lfs_enabled = false + end + + it 'returns true' do + expect(blob.external_storage_error?).to be_truthy + end + end + end + + context 'if the blob is not stored in LFS' do + let(:blob) { fake_blob(path: 'file.md') } + + it 'returns false' do + expect(blob.external_storage_error?).to be_falsey + end + end + end + + describe '#stored_externally?' do + context 'if the blob is stored in LFS' do + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + context 'when the project has LFS enabled' do + it 'returns true' do + expect(blob.stored_externally?).to be_truthy + end + end + + context 'when the project does not have LFS enabled' do + before do + project.lfs_enabled = false + end + + it 'returns false' do + expect(blob.stored_externally?).to be_falsey + end + end + end + + context 'if the blob is not stored in LFS' do + let(:blob) { fake_blob(path: 'file.md') } + + it 'returns false' do + expect(blob.stored_externally?).to be_falsey + end + end + end + describe '#raw_binary?' do - context 'if the blob is a valid LFS pointer' do + context 'if the blob is stored externally' do context 'if the extension has a rich viewer' do context 'if the viewer is binary' do it 'returns true' do @@ -56,15 +116,63 @@ describe Blob do end context "if the extension doesn't have a rich viewer" do - it 'returns true' do - blob = fake_blob(path: 'file.exe', lfs: true) + context 'if the extension has a text mime type' do + context 'if the extension is for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.txt', lfs: true) - expect(blob.raw_binary?).to be_truthy + expect(blob.raw_binary?).to be_falsey + end + end + + context 'if the extension is not for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.ics', lfs: true) + + expect(blob.raw_binary?).to be_falsey + end + end + end + + context 'if the extension has a binary mime type' do + context 'if the extension is for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.rb', lfs: true) + + expect(blob.raw_binary?).to be_falsey + end + end + + context 'if the extension is not for a programming language' do + it 'returns true' do + blob = fake_blob(path: 'file.exe', lfs: true) + + expect(blob.raw_binary?).to be_truthy + end + end + end + + context 'if the extension has an unknown mime type' do + context 'if the extension is for a programming language' do + it 'returns false' do + blob = fake_blob(path: 'file.ini', lfs: true) + + expect(blob.raw_binary?).to be_falsey + end + end + + context 'if the extension is not for a programming language' do + it 'returns true' do + blob = fake_blob(path: 'file.wtf', lfs: true) + + expect(blob.raw_binary?).to be_truthy + end + end end end end - context 'if the blob is not an LFS pointer' do + context 'if the blob is not stored externally' do context 'if the blob is binary' do it 'returns true' do blob = fake_blob(path: 'file.pdf', binary: true) @@ -94,7 +202,7 @@ describe Blob do describe '#simple_viewer' do context 'when the blob is empty' do it 'returns an empty viewer' do - blob = fake_blob(data: '') + blob = fake_blob(data: '', size: 0) expect(blob.simple_viewer).to be_a(BlobViewer::Empty) end @@ -118,7 +226,7 @@ describe Blob do end describe '#rich_viewer' do - context 'when the blob is an invalid LFS pointer' do + context 'when the blob has an external storage error' do before do project.lfs_enabled = false end @@ -138,7 +246,7 @@ describe Blob do end end - context 'when the blob is a valid LFS pointer' do + context 'when the blob is stored externally' do it 'returns a matching viewer' do blob = fake_blob(path: 'file.pdf', lfs: true) diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb index a3e598de56d..740ad9d275e 100644 --- a/spec/models/blob_viewer/base_spec.rb +++ b/spec/models/blob_viewer/base_spec.rb @@ -139,7 +139,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the viewer is server side but the blob is stored in LFS' do + context 'when the viewer is server side but the blob is stored externally' do let(:project) { build(:empty_project, lfs_enabled: true) } let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } @@ -148,8 +148,8 @@ describe BlobViewer::Base, model: true do allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) end - it 'return :server_side_but_stored_in_lfs' do - expect(viewer.render_error).to eq(:server_side_but_stored_in_lfs) + it 'return :server_side_but_stored_externally' do + expect(viewer.render_error).to eq(:server_side_but_stored_externally) end end end diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb index 48e7c0a822c..81f338745b1 100644 --- a/spec/models/diff_discussion_spec.rb +++ b/spec/models/diff_discussion_spec.rb @@ -1,19 +1,86 @@ require 'spec_helper' describe DiffDiscussion, model: true do - subject { described_class.new([first_note, second_note, third_note]) } + include RepoHelpers - let(:first_note) { create(:diff_note_on_merge_request) } - let(:merge_request) { first_note.noteable } - let(:project) { first_note.project } - let(:second_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } - let(:third_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } + subject { described_class.new([diff_note]) } + + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } describe '#reply_attributes' do it 'includes position and original_position' do attributes = subject.reply_attributes - expect(attributes[:position]).to eq(first_note.position.to_json) - expect(attributes[:original_position]).to eq(first_note.original_position.to_json) + expect(attributes[:position]).to eq(diff_note.position.to_json) + expect(attributes[:original_position]).to eq(diff_note.original_position.to_json) + end + end + + describe '#merge_request_version_params' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project, importing: true) } + let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'when the discussion is active' do + it 'returns an empty hash, which will end up showing the latest version' do + expect(subject.merge_request_version_params).to eq({}) + end + end + + context 'when the discussion is on an older merge request version' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: nil, + new_line: 4, + diff_refs: merge_request_diff1.diff_refs + ) + end + + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + + it 'returns the diff ID for the version to show' do + expect(diff_id: merge_request_diff1.id) + end + end + + context 'when the discussion is on a comparison between merge request versions' do + let(:position) do + Gitlab::Diff::Position.new( + old_path: ".gitmodules", + new_path: ".gitmodules", + old_line: 4, + new_line: 4, + diff_refs: merge_request_diff3.compare_with(merge_request_diff1.head_commit_sha).diff_refs + ) + end + + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, position: position) } + + it 'returns the diff ID and start sha of the versions to compare' do + expect(subject.merge_request_version_params).to eq(diff_id: merge_request_diff3.id, start_sha: merge_request_diff1.head_commit_sha) + end + end + + context 'when the discussion does not have a merge request version' do + let(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project, diff_refs: project.commit(sample_commit.id).diff_refs) } + + before do + diff_note.position = diff_note.original_position + diff_note.save! + end + + it 'returns nil' do + expect(subject.merge_request_version_params).to be_nil + end end end end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index f32b6b99b3d..ab4c51a87b0 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -155,23 +155,6 @@ describe DiffNote, models: true do end end - describe '#latest_merge_request_diff' do - context 'when active' do - it 'returns the current merge request diff' do - expect(subject.latest_merge_request_diff).to eq(merge_request.merge_request_diff) - end - end - - context 'when outdated' do - let!(:old_merge_request_diff) { merge_request.merge_request_diff } - let!(:new_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: commit.diff_refs) } - - it 'returns the latest merge request diff that this diff note applied to' do - expect(subject.latest_merge_request_diff).to eq(old_merge_request_diff) - end - end - end - describe "creation" do describe "updating of position" do context "when noteable is a commit" do @@ -256,4 +239,39 @@ describe DiffNote, models: true do end end end + + describe '#created_at_diff?' do + let(:diff_refs) { project.commit(sample_commit.id).diff_refs } + let(:position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: diff_refs + ) + end + + context "when noteable is a commit" do + subject { build(:diff_note_on_commit, project: project, position: position) } + + it "returns true" do + expect(subject.created_at_diff?(diff_refs)).to be true + end + end + + context "when noteable is a merge request" do + context "when the diff refs match the original one of the diff note" do + it "returns true" do + expect(subject.created_at_diff?(diff_refs)).to be true + end + end + + context "when the diff refs don't match the original one of the diff note" do + it "returns false" do + expect(subject.created_at_diff?(merge_request.diff_refs)).to be false + end + end + end + end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 11befd4edfe..8748b98a4e3 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -291,6 +291,27 @@ describe Issue, models: true do end end + describe '#has_related_branch?' do + let(:issue) { create(:issue, title: "Blue Bell Knoll") } + subject { issue.has_related_branch? } + + context 'branch found' do + before do + allow(issue.project.repository).to receive(:branch_names).and_return(["iceblink-luck", issue.to_branch_name]) + end + + it { is_expected.to eq true } + end + + context 'branch not found' do + before do + allow(issue.project.repository).to receive(:branch_names).and_return(["lazy-calm"]) + end + + it { is_expected.to eq false } + end + end + it_behaves_like 'an editable mentionable' do subject { create(:issue, project: create(:project, :repository)) } diff --git a/spec/models/legacy_diff_discussion_spec.rb b/spec/models/legacy_diff_discussion_spec.rb index 153e757a0ef..6eb4a2aaf39 100644 --- a/spec/models/legacy_diff_discussion_spec.rb +++ b/spec/models/legacy_diff_discussion_spec.rb @@ -8,4 +8,26 @@ describe LegacyDiffDiscussion, models: true do expect(subject.reply_attributes[:line_code]).to eq(subject.line_code) end end + + describe '#merge_request_version_params' do + context 'when the discussion is active' do + before do + allow(subject).to receive(:active?).and_return(true) + end + + it 'returns an empty hash, which will end up showing the latest version' do + expect(subject.merge_request_version_params).to eq({}) + end + end + + context 'when the discussion is outdated' do + before do + allow(subject).to receive(:active?).and_return(false) + end + + it 'returns nil' do + expect(subject.merge_request_version_params).to be_nil + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index be08b96641a..8b72125dd5d 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1554,4 +1554,23 @@ describe MergeRequest, models: true do expect(subject.has_no_commits?).to be_truthy end end + + describe '#merge_request_diff_for' do + subject { create(:merge_request, importing: true) } + let!(:merge_request_diff1) { subject.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + let!(:merge_request_diff2) { subject.merge_request_diffs.create(head_commit_sha: nil) } + let!(:merge_request_diff3) { subject.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + + context 'with diff refs' do + it 'returns the diffs' do + expect(subject.merge_request_diff_for(merge_request_diff1.diff_refs)).to eq(merge_request_diff1) + end + end + + context 'with a commit SHA' do + it 'returns the diffs' do + expect(subject.merge_request_diff_for(merge_request_diff3.head_commit_sha)).to eq(merge_request_diff3) + end + end + end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 557ea97b008..7a01cef9b4b 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -272,9 +272,9 @@ describe Note, models: true do Gitlab::Diff::Position.new( old_path: "files/ruby/popen.rb", new_path: "files/ruby/popen.rb", - old_line: 16, - new_line: 22, - diff_refs: merge_request.diff_refs + old_line: nil, + new_line: 13, + diff_refs: project.commit(sample_commit.id).diff_refs ) end @@ -288,26 +288,78 @@ describe Note, models: true do ) end - subject { merge_request.notes.grouped_diff_discussions } + context 'active diff discussions' do + subject { merge_request.notes.grouped_diff_discussions } - it "includes active discussions" do - discussions = subject.values.flatten + it "includes active discussions" do + discussions = subject.values.flatten - expect(discussions.count).to eq(2) - expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) - expect(discussions.all?(&:active?)).to be true + expect(discussions.count).to eq(2) + expect(discussions.map(&:id)).to eq([active_diff_note1.discussion_id, active_diff_note3.discussion_id]) + expect(discussions.all?(&:active?)).to be true - expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) - expect(discussions.last.notes).to eq([active_diff_note3]) - end + expect(discussions.first.notes).to eq([active_diff_note1, active_diff_note2]) + expect(discussions.last.notes).to eq([active_diff_note3]) + end - it "doesn't include outdated discussions" do - expect(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + it "doesn't include outdated discussions" do + expect(subject.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + end + + it "groups the discussions by line code" do + expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) + expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) + end end - it "groups the discussions by line code" do - expect(subject[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) - expect(subject[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) + context 'diff discussions for older diff refs' do + subject { merge_request.notes.grouped_diff_discussions(diff_refs) } + + context 'for diff refs a discussion was created at' do + let(:diff_refs) { active_position2.diff_refs } + + it "includes discussions that were created then" do + discussions = subject.values.flatten + + expect(discussions.count).to eq(1) + + discussion = discussions.first + + expect(discussion.id).to eq(active_diff_note3.discussion_id) + expect(discussion.active?).to be true + expect(discussion.active?(diff_refs)).to be false + expect(discussion.created_at_diff?(diff_refs)).to be true + + expect(discussion.notes).to eq([active_diff_note3]) + end + + it "groups the discussions by original line code" do + expect(subject[active_diff_note3.original_line_code].first.id).to eq(active_diff_note3.discussion_id) + end + end + + context 'for diff refs a discussion was last active at' do + let(:diff_refs) { outdated_position.diff_refs } + + it "includes discussions that were last active" do + discussions = subject.values.flatten + + expect(discussions.count).to eq(1) + + discussion = discussions.first + + expect(discussion.id).to eq(outdated_diff_note1.discussion_id) + expect(discussion.active?).to be false + expect(discussion.active?(diff_refs)).to be true + expect(discussion.created_at_diff?(diff_refs)).to be true + + expect(discussion.notes).to eq([outdated_diff_note1, outdated_diff_note2]) + end + + it "groups the discussions by line code" do + expect(subject[outdated_diff_note1.line_code].first.id).to eq(outdated_diff_note1.discussion_id) + end + end end end diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index ec5c6c5e0ed..e005be42b0d 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -4,6 +4,7 @@ describe ChatMessage::PipelineMessage do subject { described_class.new(args) } let(:user) { { name: 'hacker' } } + let(:duration) { 7210 } let(:args) do { object_attributes: { @@ -26,7 +27,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline succeeded' do let(:status) { 'success' } let(:color) { 'good' } - let(:duration) { 10 } let(:message) { build_message('passed') } it 'returns a message with information about succeeded build' do @@ -39,7 +39,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline failed' do let(:status) { 'failed' } let(:color) { 'danger' } - let(:duration) { 10 } let(:message) { build_message } it 'returns a message with information about failed build' do @@ -64,7 +63,7 @@ describe ChatMessage::PipelineMessage do "<http://example.gitlab.com|project_name>:" \ " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ " of <http://example.gitlab.com/commits/develop|develop> branch" \ - " by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + " by #{name} #{status_text} in 02:00:10" end end @@ -76,7 +75,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline succeeded' do let(:status) { 'success' } let(:color) { 'good' } - let(:duration) { 10 } let(:message) { build_markdown_message('passed') } it 'returns a message with information about succeeded build' do @@ -85,7 +83,7 @@ describe ChatMessage::PipelineMessage do expect(subject.activity).to eq({ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker passed', subtitle: 'in [project_name](http://example.gitlab.com)', - text: 'in 10 seconds', + text: 'in 02:00:10', image: '' }) end @@ -94,7 +92,6 @@ describe ChatMessage::PipelineMessage do context 'pipeline failed' do let(:status) { 'failed' } let(:color) { 'danger' } - let(:duration) { 10 } let(:message) { build_markdown_message } it 'returns a message with information about failed build' do @@ -103,7 +100,7 @@ describe ChatMessage::PipelineMessage do expect(subject.activity).to eq({ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by hacker failed', subtitle: 'in [project_name](http://example.gitlab.com)', - text: 'in 10 seconds', + text: 'in 02:00:10', image: '' }) end @@ -118,7 +115,7 @@ describe ChatMessage::PipelineMessage do expect(subject.activity).to eq({ title: 'Pipeline [#123](http://example.gitlab.com/pipelines/123) of [develop](http://example.gitlab.com/commits/develop) branch by API failed', subtitle: 'in [project_name](http://example.gitlab.com)', - text: 'in 10 seconds', + text: 'in 02:00:10', image: '' }) end @@ -129,7 +126,7 @@ describe ChatMessage::PipelineMessage do "[project_name](http://example.gitlab.com):" \ " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ " of [develop](http://example.gitlab.com/commits/develop)" \ - " branch by #{name} #{status_text} in #{duration} #{'second'.pluralize(duration)}" + " branch by #{name} #{status_text} in 02:00:10" end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 5216764a82d..dd6514b3b50 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1098,21 +1098,33 @@ describe Repository, models: true do end describe '#merge' do - it 'merges the code and return the commit id' do + let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) } + + let(:commit_options) do + author = repository.user_to_committer(user) + { message: 'Test \r\n\r\n message', committer: author, author: author } + end + + it 'merges the code and returns the commit id' do expect(merge_commit).to be_present expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present end it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do - merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) - - merge_commit_id = repository.merge(user, - merge_request.diff_head_sha, - merge_request, - commit_options) + merge_commit_id = merge(repository, user, merge_request, commit_options) expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id) end + + it 'removes carriage returns from commit message' do + merge_commit_id = merge(repository, user, merge_request, commit_options) + + expect(repository.commit(merge_commit_id).message).to eq(commit_options[:message].delete("\r")) + end + + def merge(repository, user, merge_request, options = {}) + repository.merge(user, merge_request.diff_head_sha, merge_request, options) + end end describe '#revert' do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index cc03d7a933b..ab70ce5cd2f 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -665,6 +665,20 @@ describe API::Projects do }) end + it "does not include statistics by default" do + get api("/projects/#{project.id}", user) + + expect(response).to have_http_status(200) + expect(json_response).not_to include 'statistics' + end + + it "includes statistics if requested" do + get api("/projects/#{project.id}", user), statistics: true + + expect(response).to have_http_status(200) + expect(json_response).to include 'statistics' + end + describe 'permissions' do context 'all projects' do before { project.team << [user, :master] } diff --git a/spec/services/merge_requests/create_from_issue_service_spec.rb b/spec/services/merge_requests/create_from_issue_service_spec.rb new file mode 100644 index 00000000000..1588d30c394 --- /dev/null +++ b/spec/services/merge_requests/create_from_issue_service_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe MergeRequests::CreateFromIssueService, services: true do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + let(:issue) { create(:issue, project: project) } + + subject(:service) { described_class.new(project, user, issue_iid: issue.iid) } + + before do + project.add_developer(user) + end + + describe '#execute' do + it 'returns an error with invalid issue iid' do + result = described_class.new(project, user, issue_iid: -1).execute + + expect(result[:status]).to eq :error + expect(result[:message]).to eq 'Invalid issue iid' + end + + it 'delegates issue search to IssuesFinder' do + expect_any_instance_of(IssuesFinder).to receive(:execute).once.and_call_original + + described_class.new(project, user, issue_iid: -1).execute + end + + it 'delegates the branch creation to CreateBranchService' do + expect_any_instance_of(CreateBranchService).to receive(:execute).once.and_call_original + + service.execute + end + + it 'creates a branch based on issue title' do + service.execute + + expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy + end + + it 'creates a system note' do + expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name) + + service.execute + end + + it 'creates a merge request' do + expect { service.execute }.to change(project.merge_requests, :count).by(1) + end + + it 'sets the merge request title to: "WIP: Resolves "$issue-title"' do + result = service.execute + + expect(result[:merge_request].title).to eq("WIP: Resolve \"#{issue.title}\"") + end + + it 'sets the merge request author to current user' do + result = service.execute + + expect(result[:merge_request].author).to eq user + end + + it 'sets the merge request source branch to the new issue branch' do + result = service.execute + + expect(result[:merge_request].source_branch).to eq issue.to_branch_name + end + + it 'sets the merge request target branch to the project default branch' do + result = service.execute + + expect(result[:merge_request].target_branch).to eq project.default_branch + end + end +end diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb index b29af732ad3..bc9686ed9cf 100644 --- a/spec/support/helpers/fake_blob_helpers.rb +++ b/spec/support/helpers/fake_blob_helpers.rb @@ -1,6 +1,6 @@ module FakeBlobHelpers class FakeBlob - include Linguist::BlobHelper + include BlobLike attr_reader :path, :size, :data, :lfs_oid, :lfs_size @@ -19,10 +19,6 @@ module FakeBlobHelpers alias_method :name, :path - def mode - nil - end - def id 0 end @@ -31,17 +27,11 @@ module FakeBlobHelpers @binary end - def load_all_data!(repository) - # No-op + def external_storage + :lfs if @lfs_pointer end - def lfs_pointer? - @lfs_pointer - end - - def truncated? - false - end + alias_method :external_size, :lfs_size end def fake_blob(**kwargs) |