diff options
34 files changed, 381 insertions, 139 deletions
diff --git a/CHANGELOG b/CHANGELOG index 1bb9409bb2d..84cce36a910 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -31,6 +31,7 @@ v 8.11.0 (unreleased) - Fix CI status icon link underline (ClemMakesApps) - The Repository class is now instrumented - Fix commit mention font inconsistency (ClemMakesApps) + - Do not escape URI when extracting path !5878 (winniehell) - Fix filter label tooltip HTML rendering (ClemMakesApps) - Cache the commit author in RequestStore to avoid extra lookups in PostReceive - Expand commit message width in repo view (ClemMakesApps) @@ -40,6 +41,7 @@ v 8.11.0 (unreleased) - API: Add deployment endpoints - API: Add Play endpoint on Builds - Fix of 'Commits being passed to custom hooks are already reachable when using the UI' + - Show wall clock time when showing a pipeline. !5734 - Show member roles to all users on members page - Project.visible_to_user is instrumented again - Fix awardable button mutuality loading spinners (ClemMakesApps) diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6 index e81e91fe972..583829552cd 100644 --- a/app/assets/javascripts/boards/models/label.js.es6 +++ b/app/assets/javascripts/boards/models/label.js.es6 @@ -3,6 +3,7 @@ class ListLabel { this.id = obj.id; this.title = obj.title; this.color = obj.color; + this.textColor = obj.text_color; this.description = obj.description; this.priority = (obj.priority !== null) ? obj.priority : Infinity; } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index d3394fae3f9..f85aa6dd7b7 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -31,9 +31,8 @@ this.input .on('keydown', function (e) { var keyCode = e.which; - if (keyCode === 13) { - e.preventDefault() + e.preventDefault(); } }) .on('keyup', function(e) { @@ -111,9 +110,9 @@ matches = fuzzaldrinPlus.match($el.text().trim(), search_text); if (!$el.is('.dropdown-header')) { if (matches.length) { - return $el.show(); + return $el.show().removeClass('option-hidden'); } else { - return $el.hide(); + return $el.hide().addClass('option-hidden'); } } }); @@ -179,7 +178,7 @@ })(); GitLabDropdown = (function() { - var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, currentIndex; + var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, currentIndex; LOADING_CLASS = "is-loading"; @@ -191,6 +190,12 @@ currentIndex = -1; + NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link, .option-hidden'; + + SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ")"; + + CURSOR_SELECT_SCROLL_PADDING = 5 + FILTER_INPUT = '.dropdown-input .dropdown-input-field'; function GitLabDropdown(el1, options) { @@ -213,6 +218,7 @@ if (this.options.data) { if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { this.fullData = this.options.data; + currentIndex = -1; this.parseData(this.options.data); } else { this.remote = new GitLabDropdownRemote(this.options.data, { @@ -240,7 +246,7 @@ keys: searchFields, elements: (function(_this) { return function() { - selector = '.dropdown-content li:not(.divider)'; + selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; if (_this.dropdown.find('.dropdown-toggle-page').length) { selector = ".dropdown-page-one " + selector; } @@ -256,7 +262,7 @@ return function(data) { _this.parseData(data); if (_this.filterInput.val() !== '') { - selector = '.dropdown-content li:not(.divider):visible'; + selector = SELECTABLE_CLASSES; if (_this.dropdown.find('.dropdown-toggle-page').length) { selector = ".dropdown-page-one " + selector; } @@ -376,7 +382,7 @@ var $target; if (this.options.multiSelect) { $target = $(e.target); - if (!$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) { + if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) { e.stopPropagation(); return false; } else { @@ -387,7 +393,7 @@ GitLabDropdown.prototype.opened = function() { var contentHtml; - currentIndex = -1; + this.resetRows(); this.addArrowKeyEvent(); if (this.options.setIndeterminateIds) { this.options.setIndeterminateIds.call(this); @@ -410,6 +416,7 @@ GitLabDropdown.prototype.hidden = function(e) { var $input; + this.resetRows(); this.removeArrayKeyEvent(); $input = this.dropdown.find(".dropdown-input-field"); if (this.options.filterable) { @@ -463,7 +470,7 @@ return "<li class='separator'></li>"; } if (data.header != null) { - return "<li class='dropdown-header'>" + data.header + "</li>"; + return _.template('<li class="dropdown-header"><%- header %></li>')({ header: data.header }); } if (this.options.renderRow) { html = this.options.renderRow.call(this.options, data, this); @@ -494,11 +501,16 @@ text = this.highlightTextMatches(text, this.filterInput.val()); } if (group) { - groupAttrs = "data-group='" + group + "' data-index='" + index + "'"; + groupAttrs = 'data-group=' + group + ' data-index=' + index; } else { groupAttrs = ''; } - html = "<li> <a href='" + url + "' " + groupAttrs + " class='" + cssClass + "'> " + text + " </a> </li>"; + html = _.template('<li><a href="<%- url %>" <%- groupAttrs %> class="<%- cssClass %>"><%= text %></a></li>')({ + url: url, + groupAttrs: groupAttrs, + cssClass: cssClass, + text: text + }); } return html; }; @@ -520,17 +532,6 @@ return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>"; }; - GitLabDropdown.prototype.highlightRow = function(index) { - var selector; - if (this.filterInput.val() !== "") { - selector = '.dropdown-content li:first-child a'; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one .dropdown-content li:first-child a"; - } - return this.getElement(selector).addClass('is-focused'); - } - }; - GitLabDropdown.prototype.rowClicked = function(el) { var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value; fieldName = this.options.fieldName; @@ -609,13 +610,15 @@ GitLabDropdown.prototype.selectRowAtIndex = function(index) { var $el, selector; - selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a"; + selector = SELECTABLE_CLASSES + ":eq(" + index + ") a"; if (this.dropdown.find(".dropdown-toggle-page").length) { selector = ".dropdown-page-one " + selector; } $el = $(selector, this.dropdown); if ($el.length) { - return $el.first().trigger('click'); + $el.first().trigger('click'); + var href = $el.attr('href'); + if (href && href !== '#') Turbolinks.visit(href); } }; @@ -623,7 +626,7 @@ var $input, ARROW_KEY_CODES, selector; ARROW_KEY_CODES = [38, 40]; $input = this.dropdown.find(".dropdown-input-field"); - selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator):visible'; + selector = SELECTABLE_CLASSES; if (this.dropdown.find(".dropdown-toggle-page").length) { selector = ".dropdown-page-one " + selector; } @@ -651,7 +654,7 @@ return false; } if (currentKeyCode === 13 && currentIndex !== -1) { - return _this.selectRowAtIndex($('.is-focused', _this.dropdown).closest('li').index() - 1); + return _this.selectRowAtIndex(currentIndex); } }; })(this)); @@ -661,6 +664,11 @@ return $('body').off('keydown'); }; + GitLabDropdown.prototype.resetRows = function resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); + }; + GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop; $('.is-focused', this.dropdown).removeClass('is-focused'); @@ -674,10 +682,14 @@ listItemHeight = $listItem.outerHeight(); listItemTop = $listItem.prop('offsetTop'); listItemBottom = listItemTop + listItemHeight; - if (listItemBottom > dropdownContentBottom + dropdownScrollTop) { - return $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom); - } else if (listItemTop < dropdownContentTop + dropdownScrollTop) { - return $dropdownContent.scrollTop(listItemTop - dropdownContentTop); + if (!index) { + $dropdownContent.scrollTop(0) + } else if (index === ($listItems.length - 1)) { + $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); + } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) { + $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING); + } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) { + return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING); } }; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 990f6536eb2..227e8c696b4 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -7,7 +7,9 @@ KEYCODE = { ESCAPE: 27, BACKSPACE: 8, - ENTER: 13 + ENTER: 13, + UP: 38, + DOWN: 40 }; function SearchAutocomplete(opts) { @@ -223,6 +225,12 @@ case KEYCODE.ESCAPE: this.restoreOriginalState(); break; + case KEYCODE.ENTER: + this.disableAutocomplete(); + break; + case KEYCODE.UP: + case KEYCODE.DOWN: + return; default: if (this.searchInput.val() === '') { this.disableAutocomplete(); @@ -319,9 +327,11 @@ }; SearchAutocomplete.prototype.disableAutocomplete = function() { - this.searchInput.addClass('disabled'); - this.dropdown.removeClass('open'); - return this.restoreMenu(); + if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) { + this.searchInput.addClass('disabled'); + this.dropdown.removeClass('open').trigger('hidden.bs.dropdown'); + this.restoreMenu(); + } }; SearchAutocomplete.prototype.restoreMenu = function() { diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb index 2d894b3dd4a..1a4f6b50e8f 100644 --- a/app/controllers/projects/boards/issues_controller.rb +++ b/app/controllers/projects/boards/issues_controller.rb @@ -12,7 +12,7 @@ module Projects only: [:iid, :title, :confidential], include: { assignee: { only: [:id, :name, :username], methods: [:avatar_url] }, - labels: { only: [:id, :title, :description, :color, :priority] } + labels: { only: [:id, :title, :description, :color, :priority], methods: [:text_color] } }) end diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index 790001222f1..271e839692a 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -15,20 +15,9 @@ module TimeHelper "#{from.to_s(:short)} - #{to.to_s(:short)}" end - def duration_in_numbers(finished_at, started_at) - interval = interval_in_seconds(started_at, finished_at) - time_format = interval < 1.hour ? "%M:%S" : "%H:%M:%S" + def duration_in_numbers(duration) + time_format = duration < 1.hour ? "%M:%S" : "%H:%M:%S" - Time.at(interval).utc.strftime(time_format) - end - - private - - def interval_in_seconds(started_at, finished_at = nil) - if started_at && finished_at - finished_at.to_i - started_at.to_i - elsif started_at - Time.now.to_i - started_at.to_i - end + Time.at(duration).utc.strftime(time_format) end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index ed056a07a49..096b3b801af 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -62,6 +62,7 @@ module Ci status_event: 'enqueue' ) MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) + build.pipeline.mark_as_processable_after_stage(build.stage_idx) new_build end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index c360a6ff729..087abe4cbb1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -78,6 +78,10 @@ module Ci CommitStatus.where(pipeline: pluck(:id)).stages end + def self.total_duration + where.not(duration: nil).sum(:duration) + end + def stages_with_latest_statuses statuses.latest.order(:stage_idx).group_by(&:stage) end @@ -146,6 +150,10 @@ module Ci end end + def mark_as_processable_after_stage(stage_idx) + builds.skipped.where('stage_idx > ?', stage_idx).find_each(&:process) + end + def latest? return false unless ref commit = project.commit(ref) @@ -250,7 +258,7 @@ module Ci end def update_duration - self.duration = statuses.latest.duration + self.duration = calculate_duration end def execute_hooks diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 703ca90edb6..84ceeac7d3e 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -21,6 +21,7 @@ class CommitStatus < ActiveRecord::Base where(id: max_id.group(:name, :commit_id)) end + scope :retried, -> { where.not(id: latest) } scope :ordered, -> { order(:name) } scope :ignored, -> { where(allow_failure: true, status: [:failed, :canceled]) } @@ -30,6 +31,10 @@ class CommitStatus < ActiveRecord::Base transition [:created, :skipped] => :pending end + event :process do + transition skipped: :created + end + event :run do transition pending: :running end @@ -107,13 +112,7 @@ class CommitStatus < ActiveRecord::Base end def duration - duration = - if started_at && finished_at - finished_at - started_at - elsif started_at - Time.now - started_at - end - duration + calculate_duration end def stuck? diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 4be6a2f621b..a881fb83b7f 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -17,6 +17,10 @@ module NoteOnDiff raise NotImplementedError end + def original_line_code + raise NotImplementedError + end + def diff_attributes raise NotImplementedError end diff --git a/app/models/concerns/statuseable.rb b/app/models/concerns/statuseable.rb index 5d4b0a86899..750f937b724 100644 --- a/app/models/concerns/statuseable.rb +++ b/app/models/concerns/statuseable.rb @@ -35,11 +35,6 @@ module Statuseable all.pluck(self.status_sql).first end - def duration - duration_array = all.map(&:duration).compact - duration_array.reduce(:+) - end - def started_at all.minimum(:started_at) end @@ -85,4 +80,14 @@ module Statuseable def complete? COMPLETED_STATUSES.include?(status) end + + private + + def calculate_duration + if started_at && finished_at + finished_at - started_at + elsif started_at + Time.now - started_at + end + end end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index f56c3d74ae3..aa54189fea9 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -57,6 +57,10 @@ class DiffNote < Note diff_file.position(line) == self.original_position end + def original_line_code + self.diff_file.line_code(self.diff_line) + end + def active?(diff_refs = nil) return false unless supported? return true if for_commit? diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 3fddc084af2..9676bc03470 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -12,6 +12,7 @@ class Discussion :for_merge_request?, :line_code, + :original_line_code, :diff_file, :for_line?, :active?, diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 8e26cbe9835..40277a9b139 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -49,6 +49,10 @@ class LegacyDiffNote < Note !line.meta? && diff_file.line_code(line) == self.line_code end + def original_line_code + self.line_code + end + # Check if this note is part of an "active" discussion # # This will always return true for anything except MergeRequest noteables, diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index 352adbedee4..f29d9c94441 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -51,7 +51,7 @@ - if build.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(build.finished_at, build.started_at) + = duration_in_numbers(build.duration) - if build.finished_at %p.finished-at diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index b2e55f7647a..3a95a652810 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -7,7 +7,7 @@ .diff-content.code.js-syntax-highlight %table - - discussions = { discussion.line_code => discussion } + - discussions = { discussion.original_line_code => discussion } = render partial: "projects/diffs/line", collection: discussion.truncated_diff_lines, as: :line, diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 91081435220..1fdf32466f2 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -63,7 +63,7 @@ - if build.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(build.finished_at, build.started_at) + = duration_in_numbers(build.duration) - if build.finished_at %p.finished-at = icon("calendar") diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index be387201f8d..9a672b23341 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -48,10 +48,10 @@ \- %td - - if pipeline.started_at && pipeline.finished_at + - if pipeline.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(pipeline.finished_at, pipeline.started_at) + = duration_in_numbers(pipeline.duration) - if pipeline.finished_at %p.finished-at = icon("calendar") diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 14adee7a6e9..29d767e7769 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -58,9 +58,8 @@ = ci_icon_for_status(@commit.status) %span.ci-status-label = ci_label_for_status(@commit.status) - - if @commit.pipelines.duration - in - = time_interval_in_words @commit.pipelines.duration + in + = time_interval_in_words @commit.pipelines.total_duration .commit-box.content-block %h3.commit-title diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 8289aefcde7..063e83a407a 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -9,7 +9,7 @@ = link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace" - if @pipeline.duration in - = time_interval_in_words @pipeline.duration + = time_interval_in_words(@pipeline.duration) .pull-right = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index c6a5af2809a..1dc7e0adef7 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -33,13 +33,13 @@ class EmailsOnPushWorker reverse_compare = false if action == :push - compare = CompareService.new.execute(project, before_sha, project, after_sha) + compare = CompareService.new.execute(project, after_sha, project, before_sha) diff_refs = compare.diff_refs return false if compare.same if compare.commits.empty? - compare = CompareService.new.execute(project, after_sha, project, before_sha) + compare = CompareService.new.execute(project, before_sha, project, after_sha) diff_refs = compare.diff_refs reverse_compare = true diff --git a/lib/extracts_path.rb b/lib/extracts_path.rb index 84688f6646e..a293fa2752f 100644 --- a/lib/extracts_path.rb +++ b/lib/extracts_path.rb @@ -94,7 +94,9 @@ module ExtractsPath @options = params.select {|key, value| allowed_options.include?(key) && !value.blank? } @options = HashWithIndifferentAccess.new(@options) - @id = Addressable::URI.normalize_component(get_id) + @id = params[:id] || params[:ref] + @id += "/" + params[:path] unless params[:path].blank? + @ref, @path = extract_ref(@id) @repo = @project.repository if @options[:extended_sha1].blank? @@ -116,12 +118,4 @@ module ExtractsPath def tree @tree ||= @repo.tree(@commit.id, @path) end - - private - - def get_id - id = params[:id] || params[:ref] - id += "/" + params[:path] unless params[:path].blank? - id - end end diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index d13fe0ef8a9..e59ead5d76c 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -7,7 +7,7 @@ module Gitlab # @param cmd [Array<String>] # @return [Boolean] def system_silent(cmd) - Popen::popen(cmd).last.zero? + Popen.popen(cmd).last.zero? end def force_utf8(str) diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 299e4675d6f..532ebb9640e 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -10,23 +10,31 @@ "title": { "type": "string" }, "confidential": { "type": "boolean" }, "labels": { - "type": ["array"], - "required": [ - "id", - "color", - "description", - "title", - "priority" - ], - "properties": { - "id": { "type": "integer" }, - "color": { - "type": "string", - "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "color", + "description", + "title", + "priority" + ], + "properties": { + "id": { "type": "integer" }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "description": { "type": ["string", "null"] }, + "text_color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{3}{1,2}+$" + }, + "title": { "type": "string" }, + "priority": { "type": ["integer", "null"] } }, - "description": { "type": ["string", "null"] }, - "title": { "type": "string" }, - "priority": { "type": ["integer", "null"] } + "additionalProperties": false } }, "assignee": { diff --git a/spec/helpers/time_helper_spec.rb b/spec/helpers/time_helper_spec.rb index bf3ed5c094c..21f35585367 100644 --- a/spec/helpers/time_helper_spec.rb +++ b/spec/helpers/time_helper_spec.rb @@ -19,16 +19,16 @@ describe TimeHelper do describe "#duration_in_numbers" do it "returns minutes and seconds" do - duration_in_numbers = { - [100, 0] => "01:40", - [121, 0] => "02:01", - [3721, 0] => "01:02:01", - [0, 0] => "00:00", - [nil, Time.now.to_i - 42] => "00:42" + durations_and_expectations = { + 100 => "01:40", + 121 => "02:01", + 3721 => "01:02:01", + 0 => "00:00", + 42 => "00:42" } - duration_in_numbers.each do |interval, expectation| - expect(duration_in_numbers(*interval)).to eq(expectation) + durations_and_expectations.each do |duration, expectation| + expect(duration_in_numbers(duration)).to eq(expectation) end end end diff --git a/spec/javascripts/fixtures/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml new file mode 100644 index 00000000000..a20390c08ee --- /dev/null +++ b/spec/javascripts/fixtures/gl_dropdown.html.haml @@ -0,0 +1,16 @@ +%div + .dropdown.inline + %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}} + Projects + %i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle + .dropdown-menu.dropdown-select.dropdown-menu-selectable + .dropdown-title + %span Go to project + %button.dropdown-title-button.dropdown-menu-close{aria: {label: 'Close'}} + %i.fa.fa-times.dropdown-menu-close-icon + .dropdown-input + %input.dropdown-input-field{type: 'search', placeholder: 'Filter results'} + %i.fa.fa-search.dropdown-input-search + .dropdown-content + .dropdown-loading + %i.fa.fa-spinner.fa-spin diff --git a/spec/javascripts/gl_dropdown_spec.js.es6 b/spec/javascripts/gl_dropdown_spec.js.es6 new file mode 100644 index 00000000000..b529ea6458d --- /dev/null +++ b/spec/javascripts/gl_dropdown_spec.js.es6 @@ -0,0 +1,119 @@ +/*= require jquery */ +/*= require gl_dropdown */ +/*= require turbolinks */ +/*= require lib/utils/common_utils */ +/*= require lib/utils/type_utility */ + +(() => { + const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'; + const ITEM_SELECTOR = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`; + const FOCUSED_ITEM_SELECTOR = `${ITEM_SELECTOR} a.is-focused`; + + const ARROW_KEYS = { + DOWN: 40, + UP: 38, + ENTER: 13, + ESC: 27 + }; + + let navigateWithKeys = function navigateWithKeys(direction, steps, cb, i) { + i = i || 0; + if (!i) direction = direction.toUpperCase(); + $('body').trigger({ + type: 'keydown', + which: ARROW_KEYS[direction], + keyCode: ARROW_KEYS[direction] + }); + i++; + if (i <= steps) { + navigateWithKeys(direction, steps, cb, i); + } else { + cb(); + } + }; + + describe('Dropdown', function describeDropdown() { + fixture.preload('gl_dropdown.html'); + fixture.preload('projects.json'); + + beforeEach(() => { + fixture.load('gl_dropdown.html'); + this.dropdownContainerElement = $('.dropdown.inline'); + this.dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement); + this.projectsData = fixture.load('projects.json')[0]; + this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown({ + selectable: true, + data: this.projectsData, + text: (project) => { + (project.name_with_namespace || project.name); + }, + id: (project) => { + project.id; + } + }); + }); + + afterEach(() => { + $('body').unbind('keydown'); + this.dropdownContainerElement.unbind('keyup'); + }); + + it('should open on click', () => { + expect(this.dropdownContainerElement).not.toHaveClass('open'); + this.dropdownButtonElement.click(); + expect(this.dropdownContainerElement).toHaveClass('open'); + }); + + describe('that is open', () => { + beforeEach(() => { + this.dropdownButtonElement.click(); + }); + + it('should select a following item on DOWN keypress', () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0); + let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 1)) + 0); + navigateWithKeys('down', randomIndex, () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1); + expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement)).toHaveClass('is-focused'); + }); + }); + + it('should select a previous item on UP keypress', () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(0); + navigateWithKeys('down', (this.projectsData.length - 1), () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1); + let randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 2)) + 0); + navigateWithKeys('up', randomIndex, () => { + expect($(FOCUSED_ITEM_SELECTOR, this.dropdownMenuElement).length).toBe(1); + expect($(`${ITEM_SELECTOR}:eq(${((this.projectsData.length - 2) - randomIndex)}) a`, this.dropdownMenuElement)).toHaveClass('is-focused'); + }); + }); + }); + + it('should click the selected item on ENTER keypress', () => { + expect(this.dropdownContainerElement).toHaveClass('open') + let randomIndex = Math.floor(Math.random() * (this.projectsData.length - 1)) + 0 + navigateWithKeys('down', randomIndex, () => { + spyOn(Turbolinks, 'visit').and.stub(); + navigateWithKeys('enter', null, () => { + expect(this.dropdownContainerElement).not.toHaveClass('open'); + let link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.dropdownMenuElement); + expect(link).toHaveClass('is-active'); + let linkedLocation = link.attr('href'); + if (linkedLocation && linkedLocation !== '#') expect(Turbolinks.visit).toHaveBeenCalledWith(linkedLocation); + }); + }); + }); + + it('should close on ESC keypress', () => { + expect(this.dropdownContainerElement).toHaveClass('open'); + this.dropdownContainerElement.trigger({ + type: 'keyup', + which: ARROW_KEYS.ESC, + keyCode: ARROW_KEYS.ESC + }); + expect(this.dropdownContainerElement).not.toHaveClass('open'); + }); + }); + }); +})(); diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index 68d64483d67..324f5152780 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -105,13 +105,13 @@ a3 = "a[href='" + mrsAssignedToMeLink + "']"; a4 = "a[href='" + mrsIHaveCreatedLink + "']"; expect(list.find(a1).length).toBe(1); - expect(list.find(a1).text()).toBe(' Issues assigned to me '); + expect(list.find(a1).text()).toBe('Issues assigned to me'); expect(list.find(a2).length).toBe(1); - expect(list.find(a2).text()).toBe(" Issues I've created "); + expect(list.find(a2).text()).toBe("Issues I've created"); expect(list.find(a3).length).toBe(1); - expect(list.find(a3).text()).toBe(' Merge requests assigned to me '); + expect(list.find(a3).text()).toBe('Merge requests assigned to me'); expect(list.find(a4).length).toBe(1); - return expect(list.find(a4).text()).toBe(" Merge requests I've created "); + return expect(list.find(a4).text()).toBe("Merge requests I've created"); }; describe('Search autocomplete dropdown', function() { diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb index 36c77206a3f..86d04ecfa36 100644 --- a/spec/lib/extracts_path_spec.rb +++ b/spec/lib/extracts_path_spec.rb @@ -30,17 +30,6 @@ describe ExtractsPath, lib: true do expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb") end - context 'escaped slash character in ref' do - let(:ref) { 'improve%2Fawesome' } - - it 'has no escape sequences in @ref or @logs_path' do - assign_ref_vars - - expect(@ref).to eq('improve/awesome') - expect(@logs_path).to eq("/#{@project.path_with_namespace}/refs/#{ref}/logs_tree/files/ruby/popen.rb") - end - end - context 'ref contains %20' do let(:ref) { 'foo%20bar' } @@ -52,6 +41,16 @@ describe ExtractsPath, lib: true do expect(@id).to start_with('foo%20bar/') end end + + context 'path contains space' do + let(:params) { { path: 'with space', ref: '38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e' } } + + it 'is not converted to %20 in @path' do + assign_ref_vars + + expect(@path).to eq(params[:path]) + end + end end describe '#extract_ref' do diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb index 72688137f08..02d6263094a 100644 --- a/spec/models/broadcast_message_spec.rb +++ b/spec/models/broadcast_message_spec.rb @@ -1,8 +1,6 @@ require 'spec_helper' describe BroadcastMessage, models: true do - include ActiveSupport::Testing::TimeHelpers - subject { create(:broadcast_message) } it { is_expected.to be_valid } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 8137e9f8f71..721b20e0cb2 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -124,17 +124,21 @@ describe Ci::Pipeline, models: true do describe 'state machine' do let(:current) { Time.now.change(usec: 0) } - let(:build) { create :ci_build, name: 'build1', pipeline: pipeline, started_at: current - 60, finished_at: current } - let(:build2) { create :ci_build, name: 'build2', pipeline: pipeline, started_at: current - 60, finished_at: current } + let(:build) { create :ci_build, name: 'build1', pipeline: pipeline } describe '#duration' do before do - build.skip - build2.skip + travel_to(current - 120) do + pipeline.run + end + + travel_to(current) do + pipeline.succeed + end end it 'matches sum of builds duration' do - expect(pipeline.reload.duration).to eq(build.duration + build2.duration) + expect(pipeline.reload.duration).to eq(120) end end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index ad8c2485888..8326e5cd313 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -3,8 +3,6 @@ require 'spec_helper' describe Ci::ProcessPipelineService, services: true do let(:pipeline) { create(:ci_pipeline, ref: 'master') } let(:user) { create(:user) } - let(:all_builds) { pipeline.builds } - let(:builds) { all_builds.where.not(status: [:created, :skipped]) } let(:config) { nil } before do @@ -12,6 +10,14 @@ describe Ci::ProcessPipelineService, services: true do end describe '#execute' do + def all_builds + pipeline.builds + end + + def builds + all_builds.where.not(status: [:created, :skipped]) + end + def create_builds described_class.new(pipeline.project, user).execute(pipeline) end @@ -48,7 +54,7 @@ describe Ci::ProcessPipelineService, services: true do it 'does not process pipeline if existing stage is running' do expect(create_builds).to be_truthy expect(builds.pending.count).to eq(2) - + expect(create_builds).to be_falsey expect(builds.pending.count).to eq(2) end @@ -224,6 +230,40 @@ describe Ci::ProcessPipelineService, services: true do end end + context 'when failed build in the middle stage is retried' do + context 'when failed build is the only unsuccessful build in the stage' do + before do + create(:ci_build, :created, pipeline: pipeline, name: 'build:1', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'build:2', stage_idx: 0) + create(:ci_build, :created, pipeline: pipeline, name: 'test:1', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'test:2', stage_idx: 1) + create(:ci_build, :created, pipeline: pipeline, name: 'deploy:1', stage_idx: 2) + create(:ci_build, :created, pipeline: pipeline, name: 'deploy:2', stage_idx: 2) + end + + it 'does trigger builds in the next stage' do + expect(create_builds).to be_truthy + expect(builds.pluck(:name)).to contain_exactly('build:1', 'build:2') + + pipeline.builds.running_or_pending.each(&:success) + + expect(builds.pluck(:name)) + .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2') + + pipeline.builds.find_by(name: 'test:1').success + pipeline.builds.find_by(name: 'test:2').drop + + expect(builds.pluck(:name)) + .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2') + + Ci::Build.retry(pipeline.builds.find_by(name: 'test:2')).success + + expect(builds.pluck(:name)).to contain_exactly( + 'build:1', 'build:2', 'test:1', 'test:2', 'test:2', 'deploy:1', 'deploy:2') + end + end + end + context 'creates a builds from .gitlab-ci.yml' do let(:config) do YAML.dump({ diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2e2aa7c4fc0..c144cd85487 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -33,6 +33,7 @@ RSpec.configure do |config| config.include EmailHelpers config.include TestEnv config.include ActiveJob::TestHelper + config.include ActiveSupport::Testing::TimeHelpers config.include StubGitlabCalls config.include StubGitlabData diff --git a/spec/workers/emails_on_push_worker_spec.rb b/spec/workers/emails_on_push_worker_spec.rb index eecc32875a5..7ca2c29da1c 100644 --- a/spec/workers/emails_on_push_worker_spec.rb +++ b/spec/workers/emails_on_push_worker_spec.rb @@ -2,19 +2,19 @@ require 'spec_helper' describe EmailsOnPushWorker do include RepoHelpers + include EmailSpec::Matchers let(:project) { create(:project) } let(:user) { create(:user) } let(:data) { Gitlab::DataBuilder::Push.build_sample(project, user) } let(:recipients) { user.email } let(:perform) { subject.perform(project.id, recipients, data.stringify_keys) } + let(:email) { ActionMailer::Base.deliveries.last } subject { EmailsOnPushWorker.new } describe "#perform" do context "when push is a new branch" do - let(:email) { ActionMailer::Base.deliveries.last } - before do data_new_branch = data.stringify_keys.merge("before" => Gitlab::Git::BLANK_SHA) @@ -31,8 +31,6 @@ describe EmailsOnPushWorker do end context "when push is a deleted branch" do - let(:email) { ActionMailer::Base.deliveries.last } - before do data_deleted_branch = data.stringify_keys.merge("after" => Gitlab::Git::BLANK_SHA) @@ -48,15 +46,40 @@ describe EmailsOnPushWorker do end end - context "when there are no errors in sending" do - let(:email) { ActionMailer::Base.deliveries.last } + context "when push is a force push to delete commits" do + before do + data_force_push = data.stringify_keys.merge( + "after" => data[:before], + "before" => data[:after] + ) + + subject.perform(project.id, recipients, data_force_push) + end + + it "sends a mail with the correct subject" do + expect(email.subject).to include('Change some files') + end + it "mentions force pushing in the body" do + expect(email).to have_body_text("force push") + end + + it "sends the mail to the correct recipient" do + expect(email.to).to eq([user.email]) + end + end + + context "when there are no errors in sending" do before { perform } it "sends a mail with the correct subject" do expect(email.subject).to include('Change some files') end + it "does not mention force pushing in the body" do + expect(email).not_to have_body_text("force push") + end + it "sends the mail to the correct recipient" do expect(email.to).to eq([user.email]) end @@ -66,6 +89,7 @@ describe EmailsOnPushWorker do before do ActionMailer::Base.deliveries.clear allow(Notify).to receive(:repository_push_email).and_raise(Net::SMTPFatalError) + allow(subject).to receive_message_chain(:logger, :info) perform end |