diff options
462 files changed, 10582 insertions, 4822 deletions
@@ -8,7 +8,6 @@ "plugins": [ ["istanbul", { "exclude": [ - "app/assets/javascripts/droplab/**/*", "spec/javascripts/**/*" ] }], diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3ceb0b4ba79..a900825b3de 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -434,7 +434,7 @@ bundler:audit: - master@gitlab/gitlabhq - master@gitlab/gitlab-ee script: - - git fetch origin v8.5.9 + - git fetch origin v8.14.10 - git checkout -f FETCH_HEAD - cp config/resque.yml.example config/resque.yml - sed -i 's/localhost/redis/g' config/resque.yml diff --git a/.rubocop.yml b/.rubocop.yml index ac6b141cea3..e5549b64503 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -954,6 +954,10 @@ RSpec/DescribeClass: RSpec/DescribeMethod: Enabled: false +# Avoid describing symbols. +RSpec/DescribeSymbol: + Enabled: true + # Checks that the second argument to top level describe is the tested method # name. RSpec/DescribedClass: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 8588988dc87..38b22afdf82 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 0` -# on 2017-02-22 13:02:35 -0600 using RuboCop version 0.47.1. +# on 2017-04-07 20:17:35 -0400 using RuboCop version 0.47.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 51 +# Offense count: 54 RSpec/BeforeAfterAll: Enabled: false @@ -15,11 +15,19 @@ RSpec/BeforeAfterAll: RSpec/EmptyExampleGroup: Enabled: false -# Offense count: 1 +# Offense count: 233 +RSpec/EmptyLineAfterFinalLet: + Enabled: false + +# Offense count: 167 +RSpec/EmptyLineAfterSubject: + Enabled: false + +# Offense count: 3 RSpec/ExpectOutput: Enabled: false -# Offense count: 63 +# Offense count: 72 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: implicit, each, example RSpec/HookArgument: @@ -31,19 +39,37 @@ RSpec/HookArgument: RSpec/ImplicitExpect: Enabled: false -# Offense count: 36 -RSpec/RepeatedExample: +# Offense count: 11 +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: it_behaves_like, it_should_behave_like +RSpec/ItBehavesLike: + Enabled: false + +# Offense count: 4 +RSpec/IteratedExpectation: + Enabled: false + +# Offense count: 3 +RSpec/OverwritingSetup: Enabled: false # Offense count: 34 +RSpec/RepeatedExample: + Enabled: false + +# Offense count: 43 +RSpec/ScatteredLet: + Enabled: false + +# Offense count: 32 RSpec/ScatteredSetup: Enabled: false # Offense count: 1 -RSpec/SingleArgumentMessageChain: +RSpec/SharedContext: Enabled: false -# Offense count: 163 +# Offense count: 150 Rails/FilePath: Enabled: false @@ -53,7 +79,7 @@ Rails/FilePath: Rails/ReversibleMigration: Enabled: false -# Offense count: 278 +# Offense count: 302 # Configuration parameters: Blacklist. # Blacklist: decrement!, decrement_counter, increment!, increment_counter, toggle!, touch, update_all, update_attribute, update_column, update_columns, update_counters Rails/SkipsModelValidations: @@ -64,26 +90,26 @@ Rails/SkipsModelValidations: Security/YAMLLoad: Enabled: false -# Offense count: 55 +# Offense count: 59 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: percent_q, bare_percent Style/BarePercentLiterals: Enabled: false -# Offense count: 1304 +# Offense count: 1403 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: leading, trailing Style/DotPosition: Enabled: false -# Offense count: 6 +# Offense count: 5 # Cop supports --auto-correct. Style/EachWithObject: Enabled: false -# Offense count: 25 +# Offense count: 28 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: empty, nil, both @@ -95,72 +121,72 @@ Style/EmptyElse: Style/EmptyLiteral: Enabled: false -# Offense count: 56 +# Offense count: 59 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: compact, expanded Style/EmptyMethod: Enabled: false -# Offense count: 184 +# Offense count: 214 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment, ForceEqualSignAlignment. Style/ExtraSpacing: Enabled: false -# Offense count: 8 +# Offense count: 9 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: format, sprintf, percent Style/FormatString: Enabled: false -# Offense count: 268 +# Offense count: 285 # Configuration parameters: MinBodyLength. Style/GuardClause: Enabled: false -# Offense count: 14 +# Offense count: 16 Style/IfInsideElse: Enabled: false -# Offense count: 179 +# Offense count: 186 # Cop supports --auto-correct. # Configuration parameters: MaxLineLength. Style/IfUnlessModifier: Enabled: false -# Offense count: 57 +# Offense count: 99 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_brackets Style/IndentArray: Enabled: false -# Offense count: 120 +# Offense count: 160 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_braces Style/IndentHash: Enabled: false -# Offense count: 45 +# Offense count: 50 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: line_count_dependent, lambda, literal Style/Lambda: Enabled: false -# Offense count: 7 +# Offense count: 6 # Cop supports --auto-correct. Style/LineEndConcatenation: Enabled: false -# Offense count: 22 +# Offense count: 34 # Cop supports --auto-correct. Style/MethodCallWithoutArgsParentheses: Enabled: false -# Offense count: 9 +# Offense count: 10 Style/MethodMissing: Enabled: false @@ -169,26 +195,26 @@ Style/MethodMissing: Style/MultilineIfModifier: Enabled: false -# Offense count: 22 +# Offense count: 24 # Cop supports --auto-correct. Style/NestedParenthesizedCalls: Enabled: false -# Offense count: 17 +# Offense count: 18 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles. # SupportedStyles: skip_modifier_ifs, always Style/Next: Enabled: false -# Offense count: 31 +# Offense count: 37 # Cop supports --auto-correct. # Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles. # SupportedOctalStyles: zero_with_o, zero_only Style/NumericLiteralPrefix: Enabled: false -# Offense count: 77 +# Offense count: 88 # Cop supports --auto-correct. # Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles. # SupportedStyles: predicate, comparison @@ -200,7 +226,7 @@ Style/NumericPredicate: Style/ParallelAssignment: Enabled: false -# Offense count: 477 +# Offense count: 570 # Cop supports --auto-correct. # Configuration parameters: PreferredDelimiters. Style/PercentLiteralDelimiters: @@ -211,7 +237,7 @@ Style/PercentLiteralDelimiters: Style/PerlBackrefs: Enabled: false -# Offense count: 72 +# Offense count: 83 # Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. # NamePrefix: is_, has_, have_ # NamePrefixBlacklist: is_, has_, have_ @@ -219,21 +245,21 @@ Style/PerlBackrefs: Style/PredicateName: Enabled: false -# Offense count: 39 +# Offense count: 45 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: short, verbose Style/PreferredHashMethods: Enabled: false -# Offense count: 62 +# Offense count: 65 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: compact, exploded Style/RaiseArgs: Enabled: false -# Offense count: 4 +# Offense count: 5 # Cop supports --auto-correct. Style/RedundantBegin: Enabled: false @@ -249,19 +275,19 @@ Style/RedundantFreeze: Style/RedundantReturn: Enabled: false -# Offense count: 365 +# Offense count: 382 # Cop supports --auto-correct. Style/RedundantSelf: Enabled: false -# Offense count: 108 +# Offense count: 111 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes. # SupportedStyles: slashes, percent_r, mixed Style/RegexpLiteral: Enabled: false -# Offense count: 22 +# Offense count: 24 # Cop supports --auto-correct. Style/RescueModifier: Enabled: false @@ -277,7 +303,7 @@ Style/SelfAssignment: Style/SingleLineMethods: Enabled: false -# Offense count: 155 +# Offense count: 168 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: space, no_space @@ -290,14 +316,14 @@ Style/SpaceBeforeBlockBraces: Style/SpaceBeforeFirstArg: Enabled: false -# Offense count: 38 +# Offense count: 46 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: require_no_space, require_space Style/SpaceInLambdaLiteral: Enabled: false -# Offense count: 203 +# Offense count: 229 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SupportedStylesForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space @@ -305,58 +331,58 @@ Style/SpaceInLambdaLiteral: Style/SpaceInsideBlockBraces: Enabled: false -# Offense count: 91 +# Offense count: 116 # Cop supports --auto-correct. Style/SpaceInsideParens: Enabled: false -# Offense count: 4 +# Offense count: 12 # Cop supports --auto-correct. Style/SpaceInsidePercentLiteralDelimiters: Enabled: false -# Offense count: 55 +# Offense count: 57 # Cop supports --auto-correct. # Configuration parameters: SupportedStyles. # SupportedStyles: use_perl_names, use_english_names Style/SpecialGlobalVars: EnforcedStyle: use_perl_names -# Offense count: 40 +# Offense count: 42 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: single_quotes, double_quotes Style/StringLiteralsInInterpolation: Enabled: false -# Offense count: 57 +# Offense count: 64 # Cop supports --auto-correct. # Configuration parameters: IgnoredMethods. # IgnoredMethods: respond_to, define_method Style/SymbolProc: Enabled: false -# Offense count: 5 +# Offense count: 6 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment. # SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex Style/TernaryParentheses: Enabled: false -# Offense count: 43 +# Offense count: 53 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyleForMultiline, SupportedStylesForMultiline. # SupportedStylesForMultiline: comma, consistent_comma, no_comma Style/TrailingCommaInArguments: Enabled: false -# Offense count: 13 +# Offense count: 18 # Cop supports --auto-correct. # Configuration parameters: AllowNamedUnderscoreVariables. Style/TrailingUnderscoreVariable: Enabled: false -# Offense count: 70 +# Offense count: 78 # Cop supports --auto-correct. Style/TrailingWhitespace: Enabled: false @@ -373,7 +399,7 @@ Style/TrivialAccessors: Style/UnlessElse: Enabled: false -# Offense count: 22 +# Offense count: 24 # Cop supports --auto-correct. Style/UnneededInterpolation: Enabled: false @@ -304,7 +304,7 @@ group :development, :test do gem 'spring-commands-spinach', '~> 1.1.0' gem 'rubocop', '~> 0.47.1', require: false - gem 'rubocop-rspec', '~> 1.12.0', require: false + gem 'rubocop-rspec', '~> 1.15.0', require: false gem 'scss_lint', '~> 0.47.0', require: false gem 'haml_lint', '~> 0.21.0', require: false gem 'simplecov', '~> 0.14.0', require: false @@ -356,3 +356,5 @@ gem 'sys-filesystem', '~> 1.1.6' # Gitaly GRPC client gem 'gitaly', '~> 0.5.0' + +gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index e4603df5f7f..d7e3f7343d0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -117,6 +117,7 @@ GEM chronic_duration (0.10.6) numerizer (~> 0.1.1) chunky_png (1.3.5) + citrus (3.0.2) cliver (0.3.2) coderay (1.1.1) coercible (1.0.0) @@ -668,7 +669,7 @@ GEM rainbow (>= 1.99.1, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) - rubocop-rspec (1.12.0) + rubocop-rspec (1.15.0) rubocop (>= 0.42.0) ruby-fogbugz (0.2.1) crack (~> 0.4) @@ -784,6 +785,8 @@ GEM tilt (2.0.6) timecop (0.8.1) timfel-krb5-auth (0.8.3) + toml-rb (0.3.15) + citrus (~> 3.0, > 3.0) tool (0.2.3) truncato (0.7.8) htmlentities (~> 4.3.1) @@ -984,7 +987,7 @@ DEPENDENCIES rspec-retry (~> 0.4.5) rspec_profiling (~> 0.0.5) rubocop (~> 0.47.1) - rubocop-rspec (~> 1.12.0) + rubocop-rspec (~> 1.15.0) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) rufus-scheduler (~> 3.1.10) @@ -1015,6 +1018,7 @@ DEPENDENCIES test_after_commit (~> 1.1) thin (~> 1.7.0) timecop (~> 0.8.0) + toml-rb (~> 0.3.15) truncato (~> 0.7.8) u2f (~> 0.2.1) uglifier (~> 2.7.2) diff --git a/PROCESS.md b/PROCESS.md index 2f331ee9169..cfa841dc13d 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -73,11 +73,20 @@ These types of merge requests need special consideration: and a dedicated team with front-end, back-end, and UX. * **Small features**: any other feature request. -**Large features** must be with a maintainer **by the 1st**. It's OK if they -aren't completely done, but this allows the maintainer enough time to make the -decision about whether this can make it in before the freeze. If the maintainer -doesn't think it will make it, they should inform the developers working on it -and the Product Manager responsible for the feature. +**Large features** must be with a maintainer **by the 1st**. This means that: + +* There is a merge request (even if it's WIP). +* The person (or people, if it needs a frontend and backend maintainer) who will + ultimately be responsible for merging this have been pinged on the MR. + +It's OK if merge request isn't completely done, but this allows the maintainer +enough time to make the decision about whether this can make it in before the +freeze. If the maintainer doesn't think it will make it, they should inform the +developers working on it and the Product Manager responsible for the feature. + +The maintainer can also choose to assign a reviewer to perform an initial +review, but this way the maintainer is unlikely to be surprised by receiving an +MR later in the cycle. **Small features** must be with a reviewer (not necessarily maintainer) **by the 3rd**. diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 67106e85a37..ce426741637 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -51,7 +51,7 @@ function renderCategory(name, emojiList, opts = {}) { <h5 class="emoji-menu-title"> ${name} </h5> - <ul class="clearfix emoji-menu-list ${opts.menuListClass}"> + <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}"> ${emojiList.map(emojiName => ` <li class="emoji-menu-list-item"> <button class="emoji-menu-btn text-center js-emoji-btn" type="button"> diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js index f7f41d55b52..3bea460dcc6 100644 --- a/app/assets/javascripts/behaviors/autosize.js +++ b/app/assets/javascripts/behaviors/autosize.js @@ -1,28 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, consistent-return, max-len */ -/* global autosize */ +import autosize from 'vendor/autosize'; -var autosize = require('vendor/autosize'); +$(() => { + const $fields = $('.js-autosize'); -(function() { - $(function() { - var $fields; - $fields = $('.js-autosize'); - $fields.on('autosize:resized', function() { - var $field; - $field = $(this); - return $field.data('height', $field.outerHeight()); - }); - $fields.on('resize.autosize', function() { - var $field; - $field = $(this); - if ($field.data('height') !== $field.outerHeight()) { - $field.data('height', $field.outerHeight()); - autosize.destroy($field); - return $field.css('max-height', window.outerHeight); - } - }); - autosize($fields); - autosize.update($fields); - return $fields.css('resize', 'vertical'); + $fields.on('autosize:resized', function resized() { + const $field = $(this); + $field.data('height', $field.outerHeight()); }); -}).call(window); + + $fields.on('resize.autosize', function resize() { + const $field = $(this); + if ($field.data('height') !== $field.outerHeight()) { + $field.data('height', $field.outerHeight()); + autosize.destroy($field); + $field.css('max-height', window.outerHeight); + } + }); + + autosize($fields); + autosize.update($fields); + $fields.css('resize', 'vertical'); +}); diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js index fd0840fa117..7c9dbcc8d6e 100644 --- a/app/assets/javascripts/behaviors/details_behavior.js +++ b/app/assets/javascripts/behaviors/details_behavior.js @@ -1,26 +1,23 @@ -/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, max-len */ -(function() { - $(function() { - $("body").on("click", ".js-details-target", function() { - var container; - container = $(this).closest(".js-details-container"); - return container.toggleClass("open"); - }); - // Show details content. Hides link after click. - // - // %div - // %a.js-details-expand - // %div.js-details-content - // - return $("body").on("click", ".js-details-expand", function(e) { - $(this).next('.js-details-content').removeClass("hide"); - $(this).hide(); - var truncatedItem = $(this).siblings('.js-details-short'); - if (truncatedItem.length) { - truncatedItem.addClass("hide"); - } - return e.preventDefault(); - }); +$(() => { + $('body').on('click', '.js-details-target', function target() { + $(this).closest('.js-details-container').toggleClass('open'); }); -}).call(window); + + // Show details content. Hides link after click. + // + // %div + // %a.js-details-expand + // %div.js-details-content + // + $('body').on('click', '.js-details-expand', function expand(e) { + e.preventDefault(); + $(this).next('.js-details-content').removeClass('hide'); + $(this).hide(); + + const truncatedItem = $(this).siblings('.js-details-short'); + if (truncatedItem.length) { + truncatedItem.addClass('hide'); + } + }); +}); diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js new file mode 100644 index 00000000000..5b931e6cfa6 --- /dev/null +++ b/app/assets/javascripts/behaviors/index.js @@ -0,0 +1,9 @@ +import './autosize'; +import './bind_in_out'; +import './details_behavior'; +import { installGlEmojiElement } from './gl_emoji'; +import './quick_submit'; +import './requires_input'; +import './toggler_behavior'; + +installGlEmojiElement(); diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 626f3503c91..3d162b24413 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-arrow-callback, camelcase, consistent-return, quotes, object-shorthand, comma-dangle, max-len */ +import '../commons/bootstrap'; // Quick Submit behavior // @@ -6,9 +6,6 @@ // "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form // is submitted. // -import '../commons/bootstrap'; - -// // ### Example Markup // // <form action="/foo" class="js-quick-submit"> @@ -17,61 +14,59 @@ import '../commons/bootstrap'; // <input type="submit" value="Submit" /> // </form> // -(function() { - var isMac, keyCodeIs; - isMac = function() { - return navigator.userAgent.match(/Macintosh/); - }; +function isMac() { + return navigator.userAgent.match(/Macintosh/); +} - keyCodeIs = function(e, keyCode) { - if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) { - return false; - } - return e.keyCode === keyCode; - }; +function keyCodeIs(e, keyCode) { + if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) { + return false; + } + return e.keyCode === keyCode; +} - $(document).on('keydown.quick_submit', '.js-quick-submit', function(e) { - var $form, $submit_button; - // Enter - if (!keyCodeIs(e, 13)) { - return; - } - if (!((e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey) || (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey))) { - return; - } - e.preventDefault(); - $form = $(e.target).closest('form'); - $submit_button = $form.find('input[type=submit], button[type=submit]'); - if ($submit_button.attr('disabled')) { - return; - } - $submit_button.disable(); - return $form.submit(); - }); +$(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { + // Enter + if (!keyCodeIs(e, 13)) { + return; + } + + const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey; + const onlyCtrl = e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; + if (!onlyMeta && !onlyCtrl) { + return; + } + + e.preventDefault(); + const $form = $(e.target).closest('form'); + const $submitButton = $form.find('input[type=submit], button[type=submit]'); + + if (!$submitButton.attr('disabled')) { + $submitButton.disable(); + $form.submit(); + } +}); + +// If the user tabs to a submit button on a `js-quick-submit` form, display a +// tooltip to let them know they could've used the hotkey +$(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function displayTooltip(e) { + // Tab + if (!keyCodeIs(e, 9)) { + return; + } + + const $this = $(this); + const title = isMac() ? + 'You can also press ⌘-Enter' : + 'You can also press Ctrl-Enter'; - // If the user tabs to a submit button on a `js-quick-submit` form, display a - // tooltip to let them know they could've used the hotkey - $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function(e) { - var $this, title; - // Tab - if (!keyCodeIs(e, 9)) { - return; - } - if (isMac()) { - title = "You can also press ⌘-Enter"; - } else { - title = "You can also press Ctrl-Enter"; - } - $this = $(this); - return $this.tooltip({ - container: 'body', - html: 'true', - placement: 'auto top', - title: title, - trigger: 'manual' - }).tooltip('show').one('blur', function() { - return $this.tooltip('hide'); - }); + $this.tooltip({ + container: 'body', + html: 'true', + placement: 'auto top', + title, + trigger: 'manual', }); -}).call(window); + $this.tooltip('show').one('blur', () => $this.tooltip('hide')); +}); diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index eb7143f5b1a..b20d108aa25 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -1,12 +1,10 @@ -/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, no-else-return, consistent-return, max-len */ +import '../commons/bootstrap'; + // Requires Input behavior // // When called on a form with input fields with the `required` attribute, the // form's submit button will be disabled until all required fields have values. // -import '../commons/bootstrap'; - -// // ### Example Markup // // <form class="js-requires-input"> @@ -14,49 +12,43 @@ import '../commons/bootstrap'; // <input type="submit" value="Submit"> // </form> // -(function() { - $.fn.requiresInput = function() { - var $button, $form, fieldSelector, requireInput, required; - $form = $(this); - $button = $('button[type=submit], input[type=submit]', $form); - required = '[required=required]'; - fieldSelector = "input" + required + ", select" + required + ", textarea" + required; - requireInput = function() { - var values; - values = _.map($(fieldSelector, $form), function(field) { - // Collect the input values of *all* required fields - return field.value; - }); - // Disable the button if any required fields are empty - if (values.length && _.any(values, _.isEmpty)) { - return $button.disable(); - } else { - return $button.enable(); - } - }; - // Set initial button state - requireInput(); - return $form.on('change input', fieldSelector, requireInput); - }; - $(function() { - var $form, hideOrShowHelpBlock; - $form = $('form.js-requires-input'); - $form.requiresInput(); - // Hide or Show the help block when creating a new project - // based on the option selected - hideOrShowHelpBlock = function(form) { - var selected; - selected = $('.js-select-namespace option:selected'); - if (selected.length && selected.data('options-parent') === 'groups') { - return form.find('.help-block').hide(); - } else if (selected.length) { - return form.find('.help-block').show(); - } - }; - hideOrShowHelpBlock($form); - return $('.select2.js-select-namespace').change(function() { - return hideOrShowHelpBlock($form); - }); - }); -}).call(window); +$.fn.requiresInput = function requiresInput() { + const $form = $(this); + const $button = $('button[type=submit], input[type=submit]', $form); + const fieldSelector = 'input[required=required], select[required=required], textarea[required=required]'; + + function requireInput() { + // Collect the input values of *all* required fields + const values = _.map($(fieldSelector, $form), field => field.value); + + // Disable the button if any required fields are empty + if (values.length && _.any(values, _.isEmpty)) { + $button.disable(); + } else { + $button.enable(); + } + } + + // Set initial button state + requireInput(); + $form.on('change input', fieldSelector, requireInput); +}; + +// Hide or Show the help block when creating a new project +// based on the option selected +function hideOrShowHelpBlock(form) { + const selected = $('.js-select-namespace option:selected'); + if (selected.length && selected.data('options-parent') === 'groups') { + form.find('.help-block').hide(); + } else if (selected.length) { + form.find('.help-block').show(); + } +} + +$(() => { + const $form = $('form.js-requires-input'); + $form.requiresInput(); + hideOrShowHelpBlock($form); + $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form)); +}); diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 576b8a0425f..4c9ad128e6c 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -1,44 +1,43 @@ -/* eslint-disable wrap-iife, func-names, space-before-function-paren, prefer-arrow-callback, vars-on-top, no-var, max-len */ -(function(w) { - $(function() { - var toggleContainer = function(container, /* optional */toggleState) { - var $container = $(container); - - $container - .find('.js-toggle-button .fa') - .toggleClass('fa-chevron-up', toggleState) - .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined); - - $container - .find('.js-toggle-content') - .toggle(toggleState); - }; - - // Toggle button. Show/hide content inside parent container. - // Button does not change visibility. If button has icon - it changes chevron style. - // - // %div.js-toggle-container - // %button.js-toggle-button - // %div.js-toggle-content - // - $('body').on('click', '.js-toggle-button', function(e) { - toggleContainer($(this).closest('.js-toggle-container')); - - const targetTag = e.currentTarget.tagName.toLowerCase(); - if (targetTag === 'a' || targetTag === 'button') { - e.preventDefault(); - } - }); - - // If we're accessing a permalink, ensure it is not inside a - // closed js-toggle-container! - var hash = w.gl.utils.getLocationHash(); - var anchor = hash && document.getElementById(hash); - var container = anchor && $(anchor).closest('.js-toggle-container'); - - if (container) { - toggleContainer(container, true); - anchor.scrollIntoView(); + +// Toggle button. Show/hide content inside parent container. +// Button does not change visibility. If button has icon - it changes chevron style. +// +// %div.js-toggle-container +// %button.js-toggle-button +// %div.js-toggle-content +// + +$(() => { + function toggleContainer(container, toggleState) { + const $container = $(container); + + $container + .find('.js-toggle-button .fa') + .toggleClass('fa-chevron-up', toggleState) + .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined); + + $container + .find('.js-toggle-content') + .toggle(toggleState); + } + + $('body').on('click', '.js-toggle-button', function toggleButton(e) { + toggleContainer($(this).closest('.js-toggle-container')); + + const targetTag = e.currentTarget.tagName.toLowerCase(); + if (targetTag === 'a' || targetTag === 'button') { + e.preventDefault(); } }); -})(window); + + // If we're accessing a permalink, ensure it is not inside a + // closed js-toggle-container! + const hash = window.gl.utils.getLocationHash(); + const anchor = hash && document.getElementById(hash); + const container = anchor && $(anchor).closest('.js-toggle-container'); + + if (container) { + toggleContainer(container, true); + anchor.scrollIntoView(); + } +}); diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index e057ac8df02..37094c5c9be 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -38,6 +38,10 @@ $(() => { Store.create(); + // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore + gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args); + gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args); + gl.IssueBoardsApp = new Vue({ el: $boardApp, components: { diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 7cb022c848a..9e7df9ad350 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -1,24 +1,28 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-param-reassign, quotes, yoda, no-else-return, consistent-return, comma-dangle, object-shorthand, prefer-template, one-var, one-var-declaration-per-line, no-unused-vars, max-len, vars-on-top */ +/* eslint-disable func-names, wrap-iife, no-use-before-define, +consistent-return, prefer-rest-params */ /* global Breakpoints */ -var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; -var AUTO_SCROLL_OFFSET = 75; -var DOWN_BUILD_TRACE = '#down-build-trace'; +const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; }; +const AUTO_SCROLL_OFFSET = 75; +const DOWN_BUILD_TRACE = '#down-build-trace'; -window.Build = (function() { +window.Build = (function () { Build.timeout = null; Build.state = null; function Build(options) { - options = options || $('.js-build-options').data(); - this.pageUrl = options.pageUrl; - this.buildUrl = options.buildUrl; - this.buildStatus = options.buildStatus; - this.state = options.logState; - this.buildStage = options.buildStage; - this.updateDropdown = bind(this.updateDropdown, this); + this.options = options || $('.js-build-options').data(); + + this.pageUrl = this.options.pageUrl; + this.buildUrl = this.options.buildUrl; + this.buildStatus = this.options.buildStatus; + this.state = this.options.logState; + this.buildStage = this.options.buildStage; this.$document = $(document); + + this.updateDropdown = bind(this.updateDropdown, this); + this.$body = $('body'); this.$buildTrace = $('#build-trace'); this.$autoScrollContainer = $('.autoscroll-container'); @@ -29,112 +33,110 @@ window.Build = (function() { this.$scrollTopBtn = $('#scroll-top'); this.$scrollBottomBtn = $('#scroll-bottom'); this.$buildRefreshAnimation = $('.js-build-refresh'); + this.$buildScroll = $('#js-build-scroll'); + this.$truncatedInfo = $('.js-truncated-info'); clearTimeout(Build.timeout); // Init breakpoint checker this.bp = Breakpoints.get(); this.initSidebar(); - this.$buildScroll = $('#js-build-scroll'); - this.populateJobs(this.buildStage); this.updateStageDropdownText(this.buildStage); this.sidebarOnResize(); - this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); - this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); + this.$document + .off('click', '.js-sidebar-build-toggle') + .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); + + this.$document + .off('click', '.stage-item') + .on('click', '.stage-item', this.updateDropdown); + this.$document.on('scroll', this.initScrollMonitor.bind(this)); - $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this)); - $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace); + + $(window) + .off('resize.build') + .on('resize.build', this.sidebarOnResize.bind(this)); + + $('a', this.$buildScroll) + .off('click.stepTrace') + .on('click.stepTrace', this.stepTrace); + this.updateArtifactRemoveDate(); - if ($('#build-trace').length) { - this.getInitialBuildTrace(); - this.initScrollButtonAffix(); - } + this.initScrollButtonAffix(); this.invokeBuildTrace(); } - Build.prototype.initSidebar = function() { + Build.prototype.initSidebar = function () { this.$sidebar = $('.js-build-sidebar'); this.$sidebar.niceScroll(); - this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); + this.$document + .off('click', '.js-sidebar-build-toggle') + .on('click', '.js-sidebar-build-toggle', this.toggleSidebar); }; - Build.prototype.location = function() { - return window.location.href.split("#")[0]; + Build.prototype.invokeBuildTrace = function () { + return this.getBuildTrace(); }; - Build.prototype.invokeBuildTrace = function() { - var continueRefreshStatuses = ['running', 'pending']; - // Continue to update build trace when build is running or pending - if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) { - // Check for new build output if user still watching build page - // Only valid for runnig build when output changes during time - Build.timeout = setTimeout((function(_this) { - return function() { - if (_this.location() === _this.pageUrl) { - return _this.getBuildTrace(); - } - }; - })(this), 4000); - } - }; - - Build.prototype.getInitialBuildTrace = function() { - var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; - + Build.prototype.getBuildTrace = function () { return $.ajax({ - url: this.pageUrl + "/trace.json", + url: `${this.pageUrl}/trace.json`, dataType: 'json', - success: function(buildData) { - $('.js-build-output').html(buildData.html); - gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); - if (window.location.hash === DOWN_BUILD_TRACE) { - $("html,body").scrollTop(this.$buildTrace.height()); + data: { + state: this.state, + }, + success: ((log) => { + const $buildContainer = $('.js-build-output'); + + if (log.state) { + this.state = log.state; } - if (removeRefreshStatuses.indexOf(buildData.status) !== -1) { + + if (log.append) { + $buildContainer.append(log.html); + } else { + $buildContainer.html(log.html); + if (log.truncated) { + $('.js-truncated-info-size').html(` ${log.size} `); + this.$truncatedInfo.removeClass('hidden'); + this.initAffixTruncatedInfo(); + } else { + this.$truncatedInfo.addClass('hidden'); + } + } + + this.checkAutoscroll(); + + if (!log.complete) { + Build.timeout = setTimeout(() => { + this.invokeBuildTrace(); + }, 4000); + } else { this.$buildRefreshAnimation.remove(); - return this.initScrollMonitor(); } - }.bind(this) - }); - }; - Build.prototype.getBuildTrace = function() { - return $.ajax({ - url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)), - dataType: "json", - success: (function(_this) { - return function(log) { - var pageUrl; - - if (log.state) { - _this.state = log.state; - } - _this.invokeBuildTrace(); - if (log.status === "running") { - if (log.append) { - $('.js-build-output').append(log.html); - } else { - $('.js-build-output').html(log.html); - } - return _this.checkAutoscroll(); - } else if (log.status !== _this.buildStatus) { - pageUrl = _this.pageUrl; - if (_this.$autoScrollStatus.data('state') === 'enabled') { - pageUrl += DOWN_BUILD_TRACE; - } - - return gl.utils.visitUrl(pageUrl); + if (log.status !== this.buildStatus) { + let pageUrl = this.pageUrl; + + if (this.$autoScrollStatus.data('state') === 'enabled') { + pageUrl += DOWN_BUILD_TRACE; } - }; - })(this) + + gl.utils.visitUrl(pageUrl); + } + }), + error: () => { + this.$buildRefreshAnimation.remove(); + return this.initScrollMonitor(); + }, }); }; - Build.prototype.checkAutoscroll = function() { - if (this.$autoScrollStatus.data("state") === "enabled") { - return $("html,body").scrollTop(this.$buildTrace.height()); + Build.prototype.checkAutoscroll = function () { + if (this.$autoScrollStatus.data('state') === 'enabled') { + return $('html,body').scrollTop(this.$buildTrace.height()); } // Handle a situation where user started new build @@ -146,7 +148,7 @@ window.Build = (function() { } }; - Build.prototype.initScrollButtonAffix = function() { + Build.prototype.initScrollButtonAffix = function () { // Hide everything initially this.$scrollTopBtn.hide(); this.$scrollBottomBtn.hide(); @@ -167,15 +169,17 @@ window.Build = (function() { // - Show Top Arrow button // - Show Bottom Arrow button // - Disable Autoscroll and hide indicator (when build is running) - Build.prototype.initScrollMonitor = function() { - if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + Build.prototype.initScrollMonitor = function () { + if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { // User is somewhere in middle of Build Log this.$scrollTopBtn.show(); if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed this.$scrollBottomBtn.show(); - } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { + } else if (this.$buildRefreshAnimation.is(':visible') && + !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { this.$scrollBottomBtn.show(); } else { this.$scrollBottomBtn.hide(); @@ -186,10 +190,13 @@ window.Build = (function() { this.$autoScrollContainer.hide(); this.$autoScrollStatusText.removeClass('animate'); } else { - this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollContainer.css({ + top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, + }).show(); this.$autoScrollStatusText.addClass('animate'); } - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { // User is at Top of Build Log this.$scrollTopBtn.hide(); @@ -197,17 +204,22 @@ window.Build = (function() { this.$autoScrollContainer.hide(); this.$autoScrollStatusText.removeClass('animate'); - } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) || - (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { + } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + gl.utils.isInViewport(this.$downBuildTrace.get(0))) || + (this.$buildRefreshAnimation.is(':visible') && + gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { // User is at Bottom of Build Log this.$scrollTopBtn.show(); this.$scrollBottomBtn.hide(); // Show and Reposition Autoscroll Status Indicator - this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollContainer.css({ + top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, + }).show(); this.$autoScrollStatusText.addClass('animate'); - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && + gl.utils.isInViewport(this.$downBuildTrace.get(0))) { // Build Log height is small this.$scrollTopBtn.hide(); @@ -218,65 +230,81 @@ window.Build = (function() { this.$autoScrollStatusText.removeClass('animate'); } - if (this.buildStatus === "running" || this.buildStatus === "pending") { + if (this.buildStatus === 'running' || this.buildStatus === 'pending') { // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. - this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled'); + this.$autoScrollStatus.data( + 'state', + gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled', + ); } }; - Build.prototype.shouldHideSidebarForViewport = function() { - var bootstrapBreakpoint; - bootstrapBreakpoint = this.bp.getBreakpointSize(); + Build.prototype.shouldHideSidebarForViewport = function () { + const bootstrapBreakpoint = this.bp.getBreakpointSize(); return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; }; - Build.prototype.toggleSidebar = function(shouldHide) { - var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + Build.prototype.toggleSidebar = function (shouldHide) { + const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) .toggleClass('sidebar-collapsed', shouldHide); + this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow) + .toggleClass('sidebar-collapsed', shouldHide); this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow) .toggleClass('right-sidebar-collapsed', shouldHide); }; - Build.prototype.sidebarOnResize = function() { + Build.prototype.sidebarOnResize = function () { this.toggleSidebar(this.shouldHideSidebarForViewport()); }; - Build.prototype.sidebarOnClick = function() { + Build.prototype.sidebarOnClick = function () { if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); }; - Build.prototype.updateArtifactRemoveDate = function() { - var $date, date; - $date = $('.js-artifacts-remove'); + Build.prototype.updateArtifactRemoveDate = function () { + const $date = $('.js-artifacts-remove'); if ($date.length) { - date = $date.text(); - return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); + const date = $date.text(); + return $date.text( + gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '), + ); } }; - Build.prototype.populateJobs = function(stage) { + Build.prototype.populateJobs = function (stage) { $('.build-job').hide(); - $('.build-job[data-stage="' + stage + '"]').show(); + $(`.build-job[data-stage="${stage}"]`).show(); }; - Build.prototype.updateStageDropdownText = function(stage) { + Build.prototype.updateStageDropdownText = function (stage) { $('.stage-selection').text(stage); }; - Build.prototype.updateDropdown = function(e) { + Build.prototype.updateDropdown = function (e) { e.preventDefault(); - var stage = e.currentTarget.text; + const stage = e.currentTarget.text; this.updateStageDropdownText(stage); this.populateJobs(stage); }; - Build.prototype.stepTrace = function(e) { - var $currentTarget; + Build.prototype.stepTrace = function (e) { e.preventDefault(); - $currentTarget = $(e.currentTarget); + + const $currentTarget = $(e.currentTarget); $.scrollTo($currentTarget.attr('href'), { - offset: 0 + offset: 0, + }); + }; + + Build.prototype.initAffixTruncatedInfo = function () { + const offsetTop = this.$buildTrace.offset().top; + + this.$truncatedInfo.affix({ + offset: { + top: offsetTop, + }, }); }; diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js new file mode 100644 index 00000000000..df0ba86198c --- /dev/null +++ b/app/assets/javascripts/comment_type_toggle.js @@ -0,0 +1,60 @@ +import DropLab from './droplab/drop_lab'; +import InputSetter from './droplab/plugins/input_setter'; + +class CommentTypeToggle { + constructor(opts = {}) { + this.dropdownTrigger = opts.dropdownTrigger; + this.dropdownList = opts.dropdownList; + this.noteTypeInput = opts.noteTypeInput; + this.submitButton = opts.submitButton; + this.closeButton = opts.closeButton; + this.reopenButton = opts.reopenButton; + } + + initDroplab() { + this.droplab = new DropLab(); + + const config = this.setConfig(); + + this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config); + } + + setConfig() { + const config = { + InputSetter: [{ + input: this.noteTypeInput, + valueAttribute: 'data-value', + }, + { + input: this.submitButton, + valueAttribute: 'data-submit-text', + }], + }; + + if (this.closeButton) { + config.InputSetter.push({ + input: this.closeButton, + valueAttribute: 'data-close-text', + }, { + input: this.closeButton, + valueAttribute: 'data-close-text', + inputAttribute: 'data-alternative-text', + }); + } + + if (this.reopenButton) { + config.InputSetter.push({ + input: this.reopenButton, + valueAttribute: 'data-reopen-text', + }, { + input: this.reopenButton, + valueAttribute: 'data-reopen-text', + inputAttribute: 'data-alternative-text', + }); + } + + return config; + } +} + +export default CommentTypeToggle; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 5f3ed9374bf..86d99dd87da 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -12,20 +12,18 @@ Vue.use(VueResource); * Renders Pipelines table in pipelines tab in the commits show view. */ +// export for use in merge_request_tabs.js (TODO: remove this hack) +window.gl = window.gl || {}; +window.gl.CommitPipelinesTable = CommitPipelinesTable; + $(() => { - window.gl = window.gl || {}; gl.commits = gl.commits || {}; gl.commits.pipelines = gl.commits.pipelines || {}; - if (gl.commits.PipelinesTableBundle) { - document.querySelector('#commit-pipeline-table-view').removeChild(this.pipelinesTableBundle.$el); - gl.commits.PipelinesTableBundle.$destroy(true); - } - const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) { gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable().$mount(); - document.querySelector('#commit-pipeline-table-view').appendChild(gl.commits.pipelines.PipelinesTableBundle.$el); + pipelineTableViewEl.appendChild(gl.commits.pipelines.PipelinesTableBundle.$el); } }); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js index fc2f20e3bcb..eb76b7d15fd 100644 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -42,10 +42,14 @@ import Vue from 'vue'; } }, created() { - this.discussion = CommentsStore.state[this.discussionId]; + if (this.discussionId) { + this.discussion = CommentsStore.state[this.discussionId]; + } }, mounted: function () { - const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + if (!this.discussionId) return; + + const $textarea = $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`); this.textareaIsEmpty = $textarea.val() === ''; $textarea.on('input.comment-and-resolve-btn', () => { @@ -53,7 +57,9 @@ import Vue from 'vue'; }); }, destroyed: function () { - $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); + if (!this.discussionId) return; + + $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off('input.comment-and-resolve-btn'); } }); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0a1a62fb012..f277e1dddc7 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -37,6 +37,7 @@ import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; +import Group from './group'; import GroupName from './group_name'; import GroupsList from './groups_list'; import ProjectsList from './projects_list'; @@ -44,6 +45,7 @@ import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; +import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -270,8 +272,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'groups:create': case 'admin:groups:create': BindInOut.initAll(); - case 'groups:new': - case 'admin:groups:new': + new Group(); + new GroupAvatar(); + break; case 'groups:edit': case 'admin:groups:edit': new GroupAvatar(); @@ -329,8 +332,12 @@ const ShortcutsBlob = require('./shortcuts_blob'); new Search(); break; case 'projects:repository:show': + // Initialize Protected Branch Settings new gl.ProtectedBranchCreate(); new gl.ProtectedBranchEditList(); + // Initialize Protected Tag Settings + new ProtectedTagCreate(); + new ProtectedTagEditList(); break; case 'projects:ci_cd:show': new gl.ProjectVariables(); diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js new file mode 100644 index 00000000000..a23d914772a --- /dev/null +++ b/app/assets/javascripts/droplab/constants.js @@ -0,0 +1,11 @@ +const DATA_TRIGGER = 'data-dropdown-trigger'; +const DATA_DROPDOWN = 'data-dropdown'; +const SELECTED_CLASS = 'droplab-item-selected'; +const ACTIVE_CLASS = 'droplab-item-active'; + +export { + DATA_TRIGGER, + DATA_DROPDOWN, + SELECTED_CLASS, + ACTIVE_CLASS, +}; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js new file mode 100644 index 00000000000..9588921ebcd --- /dev/null +++ b/app/assets/javascripts/droplab/drop_down.js @@ -0,0 +1,139 @@ +/* eslint-disable */ + +import utils from './utils'; +import { SELECTED_CLASS } from './constants'; + +var DropDown = function(list) { + this.currentIndex = 0; + this.hidden = true; + this.list = typeof list === 'string' ? document.querySelector(list) : list; + this.items = []; + + this.eventWrapper = {}; + + this.getItems(); + this.initTemplateString(); + this.addEvents(); + + this.initialState = list.innerHTML; +}; + +Object.assign(DropDown.prototype, { + getItems: function() { + this.items = [].slice.call(this.list.querySelectorAll('li')); + return this.items; + }, + + initTemplateString: function() { + var items = this.items || this.getItems(); + + var templateString = ''; + if (items.length > 0) templateString = items[items.length - 1].outerHTML; + this.templateString = templateString; + + return this.templateString; + }, + + clickEvent: function(e) { + if (e.target.tagName === 'UL') return; + + var selected = utils.closest(e.target, 'LI'); + if (!selected) return; + + this.addSelectedClass(selected); + + e.preventDefault(); + this.hide(); + + var listEvent = new CustomEvent('click.dl', { + detail: { + list: this, + selected: selected, + data: e.target.dataset, + }, + }); + this.list.dispatchEvent(listEvent); + }, + + addSelectedClass: function (selected) { + this.removeSelectedClasses(); + selected.classList.add(SELECTED_CLASS); + }, + + removeSelectedClasses: function () { + const items = this.items || this.getItems(); + + items.forEach(item => item.classList.remove(SELECTED_CLASS)); + }, + + addEvents: function() { + this.eventWrapper.clickEvent = this.clickEvent.bind(this) + this.list.addEventListener('click', this.eventWrapper.clickEvent); + }, + + toggle: function() { + this.hidden ? this.show() : this.hide(); + }, + + setData: function(data) { + this.data = data; + this.render(data); + }, + + addData: function(data) { + this.data = (this.data || []).concat(data); + this.render(this.data); + }, + + render: function(data) { + const children = data ? data.map(this.renderChildren.bind(this)) : []; + const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; + + renderableList.innerHTML = children.join(''); + }, + + renderChildren: function(data) { + var html = utils.t(this.templateString, data); + var template = document.createElement('div'); + + template.innerHTML = html; + this.setImagesSrc(template); + template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block'; + + return template.firstChild.outerHTML; + }, + + setImagesSrc: function(template) { + const images = [].slice.call(template.querySelectorAll('img[data-src]')); + + images.forEach((image) => { + image.src = image.getAttribute('data-src'); + image.removeAttribute('data-src'); + }); + }, + + show: function() { + if (!this.hidden) return; + this.list.style.display = 'block'; + this.currentIndex = 0; + this.hidden = false; + }, + + hide: function() { + if (this.hidden) return; + this.list.style.display = 'none'; + this.currentIndex = 0; + this.hidden = true; + }, + + toggle: function () { + this.hidden ? this.show() : this.hide(); + }, + + destroy: function() { + this.hide(); + this.list.removeEventListener('click', this.eventWrapper.clickEvent); + } +}); + +export default DropDown; diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js new file mode 100644 index 00000000000..6eb9f314af7 --- /dev/null +++ b/app/assets/javascripts/droplab/drop_lab.js @@ -0,0 +1,152 @@ +/* eslint-disable */ + +import HookButton from './hook_button'; +import HookInput from './hook_input'; +import utils from './utils'; +import Keyboard from './keyboard'; +import { DATA_TRIGGER } from './constants'; + +var DropLab = function() { + this.ready = false; + this.hooks = []; + this.queuedData = []; + this.config = {}; + + this.eventWrapper = {}; +}; + +Object.assign(DropLab.prototype, { + loadStatic: function(){ + var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`)); + this.addHooks(dropdownTriggers); + }, + + addData: function () { + var args = [].slice.apply(arguments); + this.applyArgs(args, '_addData'); + }, + + setData: function() { + var args = [].slice.apply(arguments); + this.applyArgs(args, '_setData'); + }, + + destroy: function() { + this.hooks.forEach(hook => hook.destroy()); + this.hooks = []; + this.removeEvents(); + }, + + applyArgs: function(args, methodName) { + if (this.ready) return this[methodName].apply(this, args); + + this.queuedData = this.queuedData || []; + this.queuedData.push(args); + }, + + _addData: function(trigger, data) { + this._processData(trigger, data, 'addData'); + }, + + _setData: function(trigger, data) { + this._processData(trigger, data, 'setData'); + }, + + _processData: function(trigger, data, methodName) { + this.hooks.forEach((hook) => { + if (Array.isArray(trigger)) hook.list[methodName](trigger); + + if (hook.trigger.id === trigger) hook.list[methodName](data); + }); + }, + + addEvents: function() { + this.eventWrapper.documentClicked = this.documentClicked.bind(this) + document.addEventListener('click', this.eventWrapper.documentClicked); + }, + + documentClicked: function(e) { + let thisTag = e.target; + + if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL'); + if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return; + + this.hooks.forEach(hook => hook.list.hide()); + }, + + removeEvents: function(){ + document.removeEventListener('click', this.eventWrapper.documentClicked); + }, + + changeHookList: function(trigger, list, plugins, config) { + const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger; + + + this.hooks.forEach((hook, i) => { + hook.list.list.dataset.dropdownActive = false; + + if (hook.trigger !== availableTrigger) return; + + hook.destroy(); + this.hooks.splice(i, 1); + this.addHook(availableTrigger, list, plugins, config); + }); + }, + + addHook: function(hook, list, plugins, config) { + const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook; + let availableList; + + if (typeof list === 'string') { + availableList = document.querySelector(list); + } else if (list instanceof Element) { + availableList = list; + } else { + availableList = document.querySelector(hook.dataset[utils.toCamelCase(DATA_TRIGGER)]); + } + + availableList.dataset.dropdownActive = true; + + const HookObject = availableHook.tagName === 'INPUT' ? HookInput : HookButton; + this.hooks.push(new HookObject(availableHook, availableList, plugins, config)); + + return this; + }, + + addHooks: function(hooks, plugins, config) { + hooks.forEach(hook => this.addHook(hook, null, plugins, config)); + return this; + }, + + setConfig: function(obj){ + this.config = obj; + }, + + fireReady: function() { + const readyEvent = new CustomEvent('ready.dl', { + detail: { + dropdown: this, + }, + }); + document.dispatchEvent(readyEvent); + + this.ready = true; + }, + + init: function (hook, list, plugins, config) { + hook ? this.addHook(hook, list, plugins, config) : this.loadStatic(); + + this.addEvents(); + + Keyboard(); + + this.fireReady(); + + this.queuedData.forEach(data => this.addData(data)); + this.queuedData = []; + + return this; + }, +}); + +export default DropLab; diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js deleted file mode 100644 index 8b14191395b..00000000000 --- a/app/assets/javascripts/droplab/droplab.js +++ /dev/null @@ -1,741 +0,0 @@ -/* eslint-disable */ -// Determine where to place this -if (typeof Object.assign != 'function') { - Object.assign = function (target, varArgs) { // .length of function is 2 - 'use strict'; - if (target == null) { // TypeError if undefined or null - throw new TypeError('Cannot convert undefined or null to object'); - } - - var to = Object(target); - - for (var index = 1; index < arguments.length; index++) { - var nextSource = arguments[index]; - - if (nextSource != null) { // Skip over if undefined or null - for (var nextKey in nextSource) { - // Avoid bugs when hasOwnProperty is shadowed - if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { - to[nextKey] = nextSource[nextKey]; - } - } - } - } - return to; - }; -} - -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.droplab = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -var DATA_TRIGGER = 'data-dropdown-trigger'; -var DATA_DROPDOWN = 'data-dropdown'; - -module.exports = { - DATA_TRIGGER: DATA_TRIGGER, - DATA_DROPDOWN: DATA_DROPDOWN, -} - -},{}],2:[function(require,module,exports){ -// Custom event support for IE -if ( typeof CustomEvent === "function" ) { - module.exports = CustomEvent; -} else { - require('./window')(function(w){ - var CustomEvent = function ( event, params ) { - params = params || { bubbles: false, cancelable: false, detail: undefined }; - var evt = document.createEvent( 'CustomEvent' ); - evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail ); - return evt; - } - CustomEvent.prototype = w.Event.prototype; - - w.CustomEvent = CustomEvent; - }); - module.exports = CustomEvent; -} - -},{"./window":11}],3:[function(require,module,exports){ -var CustomEvent = require('./custom_event_polyfill'); -var utils = require('./utils'); - -var DropDown = function(list) { - this.currentIndex = 0; - this.hidden = true; - this.list = list; - this.items = []; - this.getItems(); - this.initTemplateString(); - this.addEvents(); - this.initialState = list.innerHTML; -}; - -Object.assign(DropDown.prototype, { - getItems: function() { - this.items = [].slice.call(this.list.querySelectorAll('li')); - return this.items; - }, - - initTemplateString: function() { - var items = this.items || this.getItems(); - - var templateString = ''; - if(items.length > 0) { - templateString = items[items.length - 1].outerHTML; - } - this.templateString = templateString; - return this.templateString; - }, - - clickEvent: function(e) { - // climb up the tree to find the LI - var selected = utils.closest(e.target, 'LI'); - - if(selected) { - e.preventDefault(); - this.hide(); - var listEvent = new CustomEvent('click.dl', { - detail: { - list: this, - selected: selected, - data: e.target.dataset, - }, - }); - this.list.dispatchEvent(listEvent); - } - }, - - addEvents: function() { - this.clickWrapper = this.clickEvent.bind(this); - // event delegation. - this.list.addEventListener('click', this.clickWrapper); - }, - - toggle: function() { - if(this.hidden) { - this.show(); - } else { - this.hide(); - } - }, - - setData: function(data) { - this.data = data; - this.render(data); - }, - - addData: function(data) { - this.data = (this.data || []).concat(data); - this.render(this.data); - }, - - // call render manually on data; - render: function(data){ - // debugger - // empty the list first - var templateString = this.templateString; - var newChildren = []; - var toAppend; - - newChildren = (data ||[]).map(function(dat){ - var html = utils.t(templateString, dat); - var template = document.createElement('div'); - template.innerHTML = html; - - // Help set the image src template - var imageTags = template.querySelectorAll('img[data-src]'); - // debugger - for(var i = 0; i < imageTags.length; i++) { - var imageTag = imageTags[i]; - imageTag.src = imageTag.getAttribute('data-src'); - imageTag.removeAttribute('data-src'); - } - - if(dat.hasOwnProperty('droplab_hidden') && dat.droplab_hidden){ - template.firstChild.style.display = 'none' - }else{ - template.firstChild.style.display = 'block'; - } - return template.firstChild.outerHTML; - }); - toAppend = this.list.querySelector('ul[data-dynamic]'); - if(toAppend) { - toAppend.innerHTML = newChildren.join(''); - } else { - this.list.innerHTML = newChildren.join(''); - } - }, - - show: function() { - if (this.hidden) { - // debugger - this.list.style.display = 'block'; - this.currentIndex = 0; - this.hidden = false; - } - }, - - hide: function() { - if (!this.hidden) { - // debugger - this.list.style.display = 'none'; - this.currentIndex = 0; - this.hidden = true; - } - }, - - destroy: function() { - this.hide(); - this.list.removeEventListener('click', this.clickWrapper); - } -}); - -module.exports = DropDown; - -},{"./custom_event_polyfill":2,"./utils":10}],4:[function(require,module,exports){ -require('./window')(function(w){ - module.exports = function(deps) { - deps = deps || {}; - var window = deps.window || w; - var document = deps.document || window.document; - var CustomEvent = deps.CustomEvent || require('./custom_event_polyfill'); - var HookButton = deps.HookButton || require('./hook_button'); - var HookInput = deps.HookInput || require('./hook_input'); - var utils = deps.utils || require('./utils'); - var DATA_TRIGGER = require('./constants').DATA_TRIGGER; - - var DropLab = function(hook){ - if (!(this instanceof DropLab)) return new DropLab(hook); - this.ready = false; - this.hooks = []; - this.queuedData = []; - this.config = {}; - this.loadWrapper; - if(typeof hook !== 'undefined'){ - this.addHook(hook); - } - }; - - - Object.assign(DropLab.prototype, { - load: function() { - this.loadWrapper(); - }, - - loadWrapper: function(){ - var dropdownTriggers = [].slice.apply(document.querySelectorAll('['+DATA_TRIGGER+']')); - this.addHooks(dropdownTriggers).init(); - }, - - addData: function () { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_addData'); - }, - - setData: function() { - var args = [].slice.apply(arguments); - this.applyArgs(args, '_setData'); - }, - - destroy: function() { - for(var i = 0; i < this.hooks.length; i++) { - this.hooks[i].destroy(); - } - this.hooks = []; - this.removeEvents(); - }, - - applyArgs: function(args, methodName) { - if(this.ready) { - this[methodName].apply(this, args); - } else { - this.queuedData = this.queuedData || []; - this.queuedData.push(args); - } - }, - - _addData: function(trigger, data) { - this._processData(trigger, data, 'addData'); - }, - - _setData: function(trigger, data) { - this._processData(trigger, data, 'setData'); - }, - - _processData: function(trigger, data, methodName) { - for(var i = 0; i < this.hooks.length; i++) { - var hook = this.hooks[i]; - if(hook.trigger.dataset.hasOwnProperty('id')) { - if(hook.trigger.dataset.id === trigger) { - hook.list[methodName](data); - } - } - } - }, - - addEvents: function() { - var self = this; - this.windowClickedWrapper = function(e){ - var thisTag = e.target; - if(thisTag.tagName !== 'UL'){ - // climb up the tree to find the UL - thisTag = utils.closest(thisTag, 'UL'); - } - if(utils.isDropDownParts(thisTag)){ return } - if(utils.isDropDownParts(e.target)){ return } - for(var i = 0; i < self.hooks.length; i++) { - self.hooks[i].list.hide(); - } - }.bind(this); - document.addEventListener('click', this.windowClickedWrapper); - }, - - removeEvents: function(){ - w.removeEventListener('click', this.windowClickedWrapper); - w.removeEventListener('load', this.loadWrapper); - }, - - changeHookList: function(trigger, list, plugins, config) { - trigger = document.querySelector('[data-id="'+trigger+'"]'); - // list = document.querySelector(list); - this.hooks.every(function(hook, i) { - if(hook.trigger === trigger) { - hook.destroy(); - this.hooks.splice(i, 1); - this.addHook(trigger, list, plugins, config); - return false; - } - return true - }.bind(this)); - }, - - addHook: function(hook, list, plugins, config) { - if(!(hook instanceof HTMLElement) && typeof hook === 'string'){ - hook = document.querySelector(hook); - } - if(!list){ - list = document.querySelector(hook.dataset[utils.toDataCamelCase(DATA_TRIGGER)]); - } - - if(hook) { - if(hook.tagName === 'A' || hook.tagName === 'BUTTON') { - this.hooks.push(new HookButton(hook, list, plugins, config)); - } else if(hook.tagName === 'INPUT') { - this.hooks.push(new HookInput(hook, list, plugins, config)); - } - } - return this; - }, - - addHooks: function(hooks, plugins, config) { - for(var i = 0; i < hooks.length; i++) { - var hook = hooks[i]; - this.addHook(hook, null, plugins, config); - } - return this; - }, - - setConfig: function(obj){ - this.config = obj; - }, - - init: function () { - this.addEvents(); - var readyEvent = new CustomEvent('ready.dl', { - detail: { - dropdown: this, - }, - }); - window.dispatchEvent(readyEvent); - this.ready = true; - for(var i = 0; i < this.queuedData.length; i++) { - this.addData.apply(this, this.queuedData[i]); - } - this.queuedData = []; - return this; - }, - }); - - return DropLab; - }; -}); - -},{"./constants":1,"./custom_event_polyfill":2,"./hook_button":6,"./hook_input":7,"./utils":10,"./window":11}],5:[function(require,module,exports){ -var DropDown = require('./dropdown'); - -var Hook = function(trigger, list, plugins, config){ - this.trigger = trigger; - this.list = new DropDown(list); - this.type = 'Hook'; - this.event = 'click'; - this.plugins = plugins || []; - this.config = config || {}; - this.id = trigger.dataset.id; -}; - -Object.assign(Hook.prototype, { - - addEvents: function(){}, - - constructor: Hook, -}); - -module.exports = Hook; - -},{"./dropdown":3}],6:[function(require,module,exports){ -var CustomEvent = require('./custom_event_polyfill'); -var Hook = require('./hook'); - -var HookButton = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); - this.type = 'button'; - this.event = 'click'; - this.addEvents(); - this.addPlugins(); -}; - -HookButton.prototype = Object.create(Hook.prototype); - -Object.assign(HookButton.prototype, { - addPlugins: function() { - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].init(this); - } - }, - - clicked: function(e){ - var buttonEvent = new CustomEvent('click.dl', { - detail: { - hook: this, - }, - bubbles: true, - cancelable: true - }); - this.list.show(); - e.target.dispatchEvent(buttonEvent); - }, - - addEvents: function(){ - this.clickedWrapper = this.clicked.bind(this); - this.trigger.addEventListener('click', this.clickedWrapper); - }, - - removeEvents: function(){ - this.trigger.removeEventListener('click', this.clickedWrapper); - }, - - restoreInitialState: function() { - this.list.list.innerHTML = this.list.initialState; - }, - - removePlugins: function() { - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].destroy(); - } - }, - - destroy: function() { - this.restoreInitialState(); - this.removeEvents(); - this.removePlugins(); - }, - - - constructor: HookButton, -}); - - -module.exports = HookButton; - -},{"./custom_event_polyfill":2,"./hook":5}],7:[function(require,module,exports){ -var CustomEvent = require('./custom_event_polyfill'); -var Hook = require('./hook'); - -var HookInput = function(trigger, list, plugins, config) { - Hook.call(this, trigger, list, plugins, config); - this.type = 'input'; - this.event = 'input'; - this.addPlugins(); - this.addEvents(); -}; - -Object.assign(HookInput.prototype, { - addPlugins: function() { - var self = this; - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].init(self); - } - }, - - addEvents: function(){ - var self = this; - - this.mousedown = function mousedown(e) { - if(self.hasRemovedEvents) return; - - var mouseEvent = new CustomEvent('mousedown.dl', { - detail: { - hook: self, - text: e.target.value, - }, - bubbles: true, - cancelable: true - }); - e.target.dispatchEvent(mouseEvent); - } - - this.input = function input(e) { - if(self.hasRemovedEvents) return; - - self.list.show(); - - var inputEvent = new CustomEvent('input.dl', { - detail: { - hook: self, - text: e.target.value, - }, - bubbles: true, - cancelable: true - }); - e.target.dispatchEvent(inputEvent); - } - - this.keyup = function keyup(e) { - if(self.hasRemovedEvents) return; - - keyEvent(e, 'keyup.dl'); - } - - this.keydown = function keydown(e) { - if(self.hasRemovedEvents) return; - - keyEvent(e, 'keydown.dl'); - } - - function keyEvent(e, keyEventName){ - self.list.show(); - - var keyEvent = new CustomEvent(keyEventName, { - detail: { - hook: self, - text: e.target.value, - which: e.which, - key: e.key, - }, - bubbles: true, - cancelable: true - }); - e.target.dispatchEvent(keyEvent); - } - - this.events = this.events || {}; - this.events.mousedown = this.mousedown; - this.events.input = this.input; - this.events.keyup = this.keyup; - this.events.keydown = this.keydown; - this.trigger.addEventListener('mousedown', this.mousedown); - this.trigger.addEventListener('input', this.input); - this.trigger.addEventListener('keyup', this.keyup); - this.trigger.addEventListener('keydown', this.keydown); - }, - - removeEvents: function() { - this.hasRemovedEvents = true; - this.trigger.removeEventListener('mousedown', this.mousedown); - this.trigger.removeEventListener('input', this.input); - this.trigger.removeEventListener('keyup', this.keyup); - this.trigger.removeEventListener('keydown', this.keydown); - }, - - restoreInitialState: function() { - this.list.list.innerHTML = this.list.initialState; - }, - - removePlugins: function() { - for(var i = 0; i < this.plugins.length; i++) { - this.plugins[i].destroy(); - } - }, - - destroy: function() { - this.restoreInitialState(); - this.removeEvents(); - this.removePlugins(); - this.list.destroy(); - } -}); - -module.exports = HookInput; - -},{"./custom_event_polyfill":2,"./hook":5}],8:[function(require,module,exports){ -var DropLab = require('./droplab')(); -var DATA_TRIGGER = require('./constants').DATA_TRIGGER; -var keyboard = require('./keyboard')(); -var setup = function() { - window.DropLab = DropLab; -}; - - -module.exports = setup(); - -},{"./constants":1,"./droplab":4,"./keyboard":9}],9:[function(require,module,exports){ -require('./window')(function(w){ - module.exports = function(){ - var currentKey; - var currentFocus; - var isUpArrow = false; - var isDownArrow = false; - var removeHighlight = function removeHighlight(list) { - var listItems = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); - var listItemsTmp = []; - for(var i = 0; i < listItems.length; i++) { - var listItem = listItems[i]; - listItem.classList.remove('dropdown-active'); - - if (listItem.style.display !== 'none') { - listItemsTmp.push(listItem); - } - } - return listItemsTmp; - }; - - var setMenuForArrows = function setMenuForArrows(list) { - var listItems = removeHighlight(list); - if(list.currentIndex>0){ - if(!listItems[list.currentIndex-1]){ - list.currentIndex = list.currentIndex-1; - } - - if (listItems[list.currentIndex-1]) { - var el = listItems[list.currentIndex-1]; - var filterDropdownEl = el.closest('.filter-dropdown'); - el.classList.add('dropdown-active'); - - if (filterDropdownEl) { - var filterDropdownBottom = filterDropdownEl.offsetHeight; - var elOffsetTop = el.offsetTop - 30; - - if (elOffsetTop > filterDropdownBottom) { - filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom; - } - } - } - } - }; - - var mousedown = function mousedown(e) { - var list = e.detail.hook.list; - removeHighlight(list); - list.show(); - list.currentIndex = 0; - isUpArrow = false; - isDownArrow = false; - }; - var selectItem = function selectItem(list) { - var listItems = removeHighlight(list); - var currentItem = listItems[list.currentIndex-1]; - var listEvent = new CustomEvent('click.dl', { - detail: { - list: list, - selected: currentItem, - data: currentItem.dataset, - }, - }); - list.list.dispatchEvent(listEvent); - list.hide(); - } - - var keydown = function keydown(e){ - var typedOn = e.target; - var list = e.detail.hook.list; - var currentIndex = list.currentIndex; - isUpArrow = false; - isDownArrow = false; - - if(e.detail.which){ - currentKey = e.detail.which; - if(currentKey === 13){ - selectItem(e.detail.hook.list); - return; - } - if(currentKey === 38) { - isUpArrow = true; - } - if(currentKey === 40) { - isDownArrow = true; - } - } else if(e.detail.key) { - currentKey = e.detail.key; - if(currentKey === 'Enter'){ - selectItem(e.detail.hook.list); - return; - } - if(currentKey === 'ArrowUp') { - isUpArrow = true; - } - if(currentKey === 'ArrowDown') { - isDownArrow = true; - } - } - if(isUpArrow){ currentIndex--; } - if(isDownArrow){ currentIndex++; } - if(currentIndex < 0){ currentIndex = 0; } - list.currentIndex = currentIndex; - setMenuForArrows(e.detail.hook.list); - }; - - w.addEventListener('mousedown.dl', mousedown); - w.addEventListener('keydown.dl', keydown); - }; -}); -},{"./window":11}],10:[function(require,module,exports){ -var DATA_TRIGGER = require('./constants').DATA_TRIGGER; -var DATA_DROPDOWN = require('./constants').DATA_DROPDOWN; - -var toDataCamelCase = function(attr){ - return this.camelize(attr.split('-').slice(1).join(' ')); -}; - -// the tiniest damn templating I can do -var t = function(s,d){ - for(var p in d) - s=s.replace(new RegExp('{{'+p+'}}','g'), d[p]); - return s; -}; - -var camelize = function(str) { - return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) { - return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); - }).replace(/\s+/g, ''); -}; - -var closest = function(thisTag, stopTag) { - while(thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML'){ - thisTag = thisTag.parentNode; - } - return thisTag; -}; - -var isDropDownParts = function(target) { - if(!target || target.tagName === 'HTML') { return false; } - return ( - target.hasAttribute(DATA_TRIGGER) || - target.hasAttribute(DATA_DROPDOWN) - ); -}; - -module.exports = { - toDataCamelCase: toDataCamelCase, - t: t, - camelize: camelize, - closest: closest, - isDropDownParts: isDropDownParts, -}; - -},{"./constants":1}],11:[function(require,module,exports){ -module.exports = function(callback) { - return (function() { - callback(this); - }).call(null); -}; - -},{}]},{},[8])(8) -}); diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js deleted file mode 100644 index 020f8b4ac65..00000000000 --- a/app/assets/javascripts/droplab/droplab_ajax.js +++ /dev/null @@ -1,103 +0,0 @@ -/* eslint-disable */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/* global droplab */ - -require('../window')(function(w){ - function droplabAjaxException(message) { - this.message = message; - } - - w.droplabAjax = { - _loadUrlData: function _loadUrlData(url) { - var self = this; - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if(xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } - } - }; - xhr.send(); - }); - }, - - _loadData: function _loadData(data, config, self) { - if (config.loadingTemplate) { - var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); - - if (dataLoadingTemplate) { - dataLoadingTemplate.outerHTML = self.listTemplate; - } - } - - if (!self.destroyed) { - self.hook.list[config.method].call(self.hook.list, data); - } - }, - - init: function init(hook) { - var self = this; - self.destroyed = false; - self.cache = self.cache || {}; - var config = hook.config.droplabAjax; - this.hook = hook; - - if (!config || !config.endpoint || !config.method) { - return; - } - - if (config.method !== 'setData' && config.method !== 'addData') { - return; - } - - if (config.loadingTemplate) { - var dynamicList = hook.list.list.querySelector('[data-dynamic]'); - - var loadingTemplate = document.createElement('div'); - loadingTemplate.innerHTML = config.loadingTemplate; - loadingTemplate.setAttribute('data-loading-template', ''); - - this.listTemplate = dynamicList.outerHTML; - dynamicList.outerHTML = loadingTemplate.outerHTML; - } - - if (self.cache[config.endpoint]) { - self._loadData(self.cache[config.endpoint], config, self); - } else { - this._loadUrlData(config.endpoint) - .then(function(d) { - self._loadData(d, config, self); - }, function(xhrError) { - // TODO: properly handle errors due to XHR cancellation - return; - }).catch(function(e) { - throw new droplabAjaxException(e.message || e); - }); - } - }, - - destroy: function() { - var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); - this.destroyed = true; - if (this.listTemplate && dynamicList) { - dynamicList.outerHTML = this.listTemplate; - } - } - }; -}); -},{"../window":2}],2:[function(require,module,exports){ -module.exports = function(callback) { - return (function() { - callback(this); - }).call(null); -}; - -},{}]},{},[1])(1) -}); diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js deleted file mode 100644 index 05eba7aef56..00000000000 --- a/app/assets/javascripts/droplab/droplab_ajax_filter.js +++ /dev/null @@ -1,164 +0,0 @@ -/* eslint-disable */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/* global droplab */ - -require('../window')(function(w){ - w.droplabAjaxFilter = { - init: function(hook) { - this.destroyed = false; - this.hook = hook; - this.notLoading(); - - this.debounceTriggerWrapper = this.debounceTrigger.bind(this); - this.hook.trigger.addEventListener('keydown.dl', this.debounceTriggerWrapper); - this.hook.trigger.addEventListener('focus', this.debounceTriggerWrapper); - this.trigger(true); - }, - - notLoading: function notLoading() { - this.loading = false; - }, - - debounceTrigger: function debounceTrigger(e) { - var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93]; - var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1; - var focusEvent = e.type === 'focus'; - - if (invalidKeyPressed || this.loading) { - return; - } - - if (this.timeout) { - clearTimeout(this.timeout); - } - - this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200); - }, - - trigger: function trigger(getEntireList) { - var config = this.hook.config.droplabAjaxFilter; - var searchValue = this.trigger.value; - - if (!config || !config.endpoint || !config.searchKey) { - return; - } - - if (config.searchValueFunction) { - searchValue = config.searchValueFunction(); - } - - if (config.loadingTemplate && this.hook.list.data === undefined || - this.hook.list.data.length === 0) { - var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); - - var loadingTemplate = document.createElement('div'); - loadingTemplate.innerHTML = config.loadingTemplate; - loadingTemplate.setAttribute('data-loading-template', true); - - this.listTemplate = dynamicList.outerHTML; - dynamicList.outerHTML = loadingTemplate.outerHTML; - } - - if (getEntireList) { - searchValue = ''; - } - - if (config.searchKey === searchValue) { - return this.list.show(); - } - - this.loading = true; - - var params = config.params || {}; - params[config.searchKey] = searchValue; - var self = this; - self.cache = self.cache || {}; - var url = config.endpoint + this.buildParams(params); - var urlCachedData = self.cache[url]; - - if (urlCachedData) { - self._loadData(urlCachedData, config, self); - } else { - this._loadUrlData(url) - .then(function(data) { - self._loadData(data, config, self); - }, function(xhrError) { - // TODO: properly handle errors due to XHR cancellation - return; - }); - } - }, - - _loadUrlData: function _loadUrlData(url) { - var self = this; - return new Promise(function(resolve, reject) { - var xhr = new XMLHttpRequest; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if(xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - var data = JSON.parse(xhr.responseText); - self.cache[url] = data; - return resolve(data); - } else { - return reject([xhr.responseText, xhr.status]); - } - } - }; - xhr.send(); - }); - }, - - _loadData: function _loadData(data, config, self) { - if (config.loadingTemplate && self.hook.list.data === undefined || - self.hook.list.data.length === 0) { - const dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); - - if (dataLoadingTemplate) { - dataLoadingTemplate.outerHTML = self.listTemplate; - } - } - - if (!self.destroyed) { - var hookListChildren = self.hook.list.list.children; - var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); - - if (onlyDynamicList && data.length === 0) { - self.hook.list.hide(); - } - - self.hook.list.setData.call(self.hook.list, data); - } - self.notLoading(); - self.hook.list.currentIndex = 0; - }, - - buildParams: function(params) { - if (!params) return ''; - var paramsArray = Object.keys(params).map(function(param) { - return param + '=' + (params[param] || ''); - }); - return '?' + paramsArray.join('&'); - }, - - destroy: function destroy() { - if (this.timeout) { - clearTimeout(this.timeout); - } - - this.destroyed = true; - - this.hook.trigger.removeEventListener('keydown.dl', this.debounceTriggerWrapper); - this.hook.trigger.removeEventListener('focus', this.debounceTriggerWrapper); - } - }; -}); -},{"../window":2}],2:[function(require,module,exports){ -module.exports = function(callback) { - return (function() { - callback(this); - }).call(null); -}; - -},{}]},{},[1])(1) -}); diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js deleted file mode 100644 index 7f7d93f3e27..00000000000 --- a/app/assets/javascripts/droplab/droplab_filter.js +++ /dev/null @@ -1,76 +0,0 @@ -/* eslint-disable */ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.filter||(g.filter = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ -/* global droplab */ - -require('../window')(function(w){ - w.droplabFilter = { - - keydownWrapper: function(e){ - var hiddenCount = 0; - var dataHiddenCount = 0; - var list = e.detail.hook.list; - var data = list.data; - var value = e.detail.hook.trigger.value.toLowerCase(); - var config = e.detail.hook.config.droplabFilter; - var matches = []; - var filterFunction; - // will only work on dynamically set data - if(!data){ - return; - } - - if (config && config.filterFunction && typeof config.filterFunction === 'function') { - filterFunction = config.filterFunction; - } else { - filterFunction = function(o){ - // cheap string search - o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1; - return o; - }; - } - - dataHiddenCount = data.filter(function(o) { - return !o.droplab_hidden; - }).length; - - matches = data.map(function(o) { - return filterFunction(o, value); - }); - - hiddenCount = matches.filter(function(o) { - return !o.droplab_hidden; - }).length; - - if (dataHiddenCount !== hiddenCount) { - list.render(matches); - list.currentIndex = 0; - } - }, - - init: function init(hookInput) { - var config = hookInput.config.droplabFilter; - - if (!config || (!config.template && !config.filterFunction)) { - return; - } - - this.hookInput = hookInput; - this.hookInput.trigger.addEventListener('keyup.dl', this.keydownWrapper); - this.hookInput.trigger.addEventListener('mousedown.dl', this.keydownWrapper); - }, - - destroy: function destroy(){ - this.hookInput.trigger.removeEventListener('keyup.dl', this.keydownWrapper); - this.hookInput.trigger.removeEventListener('mousedown.dl', this.keydownWrapper); - } - }; -}); -},{"../window":2}],2:[function(require,module,exports){ -module.exports = function(callback) { - return (function() { - callback(this); - }).call(null); -}; - -},{}]},{},[1])(1) -}); diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js new file mode 100644 index 00000000000..2f840083571 --- /dev/null +++ b/app/assets/javascripts/droplab/hook.js @@ -0,0 +1,22 @@ +/* eslint-disable */ + +import DropDown from './drop_down'; + +var Hook = function(trigger, list, plugins, config){ + this.trigger = trigger; + this.list = new DropDown(list); + this.type = 'Hook'; + this.event = 'click'; + this.plugins = plugins || []; + this.config = config || {}; + this.id = trigger.id; +}; + +Object.assign(Hook.prototype, { + + addEvents: function(){}, + + constructor: Hook, +}); + +export default Hook; diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js new file mode 100644 index 00000000000..be8aead1303 --- /dev/null +++ b/app/assets/javascripts/droplab/hook_button.js @@ -0,0 +1,65 @@ +/* eslint-disable */ + +import Hook from './hook'; + +var HookButton = function(trigger, list, plugins, config) { + Hook.call(this, trigger, list, plugins, config); + + this.type = 'button'; + this.event = 'click'; + + this.eventWrapper = {}; + + this.addEvents(); + this.addPlugins(); +}; + +HookButton.prototype = Object.create(Hook.prototype); + +Object.assign(HookButton.prototype, { + addPlugins: function() { + this.plugins.forEach(plugin => plugin.init(this)); + }, + + clicked: function(e){ + var buttonEvent = new CustomEvent('click.dl', { + detail: { + hook: this, + }, + bubbles: true, + cancelable: true + }); + e.target.dispatchEvent(buttonEvent); + + this.list.toggle(); + }, + + addEvents: function(){ + this.eventWrapper.clicked = this.clicked.bind(this); + this.trigger.addEventListener('click', this.eventWrapper.clicked); + }, + + removeEvents: function(){ + this.trigger.removeEventListener('click', this.eventWrapper.clicked); + }, + + restoreInitialState: function() { + this.list.list.innerHTML = this.list.initialState; + }, + + removePlugins: function() { + this.plugins.forEach(plugin => plugin.destroy()); + }, + + destroy: function() { + this.restoreInitialState(); + + this.removeEvents(); + this.removePlugins(); + }, + + constructor: HookButton, +}); + + +export default HookButton; diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js new file mode 100644 index 00000000000..05082334045 --- /dev/null +++ b/app/assets/javascripts/droplab/hook_input.js @@ -0,0 +1,119 @@ +/* eslint-disable */ + +import Hook from './hook'; + +var HookInput = function(trigger, list, plugins, config) { + Hook.call(this, trigger, list, plugins, config); + + this.type = 'input'; + this.event = 'input'; + + this.eventWrapper = {}; + + this.addEvents(); + this.addPlugins(); +}; + +Object.assign(HookInput.prototype, { + addPlugins: function() { + this.plugins.forEach(plugin => plugin.init(this)); + }, + + addEvents: function(){ + this.eventWrapper.mousedown = this.mousedown.bind(this); + this.eventWrapper.input = this.input.bind(this); + this.eventWrapper.keyup = this.keyup.bind(this); + this.eventWrapper.keydown = this.keydown.bind(this); + + this.trigger.addEventListener('mousedown', this.eventWrapper.mousedown); + this.trigger.addEventListener('input', this.eventWrapper.input); + this.trigger.addEventListener('keyup', this.eventWrapper.keyup); + this.trigger.addEventListener('keydown', this.eventWrapper.keydown); + }, + + removeEvents: function() { + this.hasRemovedEvents = true; + + this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown); + this.trigger.removeEventListener('input', this.eventWrapper.input); + this.trigger.removeEventListener('keyup', this.eventWrapper.keyup); + this.trigger.removeEventListener('keydown', this.eventWrapper.keydown); + }, + + input: function(e) { + if(this.hasRemovedEvents) return; + + this.list.show(); + + const inputEvent = new CustomEvent('input.dl', { + detail: { + hook: this, + text: e.target.value, + }, + bubbles: true, + cancelable: true + }); + e.target.dispatchEvent(inputEvent); + }, + + mousedown: function(e) { + if (this.hasRemovedEvents) return; + + const mouseEvent = new CustomEvent('mousedown.dl', { + detail: { + hook: this, + text: e.target.value, + }, + bubbles: true, + cancelable: true, + }); + e.target.dispatchEvent(mouseEvent); + }, + + keyup: function(e) { + if (this.hasRemovedEvents) return; + + this.keyEvent(e, 'keyup.dl'); + }, + + keydown: function(e) { + if (this.hasRemovedEvents) return; + + this.keyEvent(e, 'keydown.dl'); + }, + + keyEvent: function(e, eventName) { + this.list.show(); + + const keyEvent = new CustomEvent(eventName, { + detail: { + hook: this, + text: e.target.value, + which: e.which, + key: e.key, + }, + bubbles: true, + cancelable: true, + }); + e.target.dispatchEvent(keyEvent); + }, + + restoreInitialState: function() { + this.list.list.innerHTML = this.list.initialState; + }, + + removePlugins: function() { + this.plugins.forEach(plugin => plugin.destroy()); + }, + + destroy: function() { + this.restoreInitialState(); + + this.removeEvents(); + this.removePlugins(); + + this.list.destroy(); + } +}); + +export default HookInput; diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js new file mode 100644 index 00000000000..36740a430e1 --- /dev/null +++ b/app/assets/javascripts/droplab/keyboard.js @@ -0,0 +1,113 @@ +/* eslint-disable */ + +import { ACTIVE_CLASS } from './constants'; + +const Keyboard = function () { + var currentKey; + var currentFocus; + var isUpArrow = false; + var isDownArrow = false; + var removeHighlight = function removeHighlight(list) { + var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); + var listItems = []; + for(var i = 0; i < itemElements.length; i++) { + var listItem = itemElements[i]; + listItem.classList.remove(ACTIVE_CLASS); + + if (listItem.style.display !== 'none') { + listItems.push(listItem); + } + } + return listItems; + }; + + var setMenuForArrows = function setMenuForArrows(list) { + var listItems = removeHighlight(list); + if(list.currentIndex>0){ + if(!listItems[list.currentIndex-1]){ + list.currentIndex = list.currentIndex-1; + } + + if (listItems[list.currentIndex-1]) { + var el = listItems[list.currentIndex-1]; + var filterDropdownEl = el.closest('.filter-dropdown'); + el.classList.add(ACTIVE_CLASS); + + if (filterDropdownEl) { + var filterDropdownBottom = filterDropdownEl.offsetHeight; + var elOffsetTop = el.offsetTop - 30; + + if (elOffsetTop > filterDropdownBottom) { + filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom; + } + } + } + } + }; + + var mousedown = function mousedown(e) { + var list = e.detail.hook.list; + removeHighlight(list); + list.show(); + list.currentIndex = 0; + isUpArrow = false; + isDownArrow = false; + }; + var selectItem = function selectItem(list) { + var listItems = removeHighlight(list); + var currentItem = listItems[list.currentIndex-1]; + var listEvent = new CustomEvent('click.dl', { + detail: { + list: list, + selected: currentItem, + data: currentItem.dataset, + }, + }); + list.list.dispatchEvent(listEvent); + list.hide(); + } + + var keydown = function keydown(e){ + var typedOn = e.target; + var list = e.detail.hook.list; + var currentIndex = list.currentIndex; + isUpArrow = false; + isDownArrow = false; + + if(e.detail.which){ + currentKey = e.detail.which; + if(currentKey === 13){ + selectItem(e.detail.hook.list); + return; + } + if(currentKey === 38) { + isUpArrow = true; + } + if(currentKey === 40) { + isDownArrow = true; + } + } else if(e.detail.key) { + currentKey = e.detail.key; + if(currentKey === 'Enter'){ + selectItem(e.detail.hook.list); + return; + } + if(currentKey === 'ArrowUp') { + isUpArrow = true; + } + if(currentKey === 'ArrowDown') { + isDownArrow = true; + } + } + if(isUpArrow){ currentIndex--; } + if(isDownArrow){ currentIndex++; } + if(currentIndex < 0){ currentIndex = 0; } + list.currentIndex = currentIndex; + setMenuForArrows(e.detail.hook.list); + }; + + document.addEventListener('mousedown.dl', mousedown); + document.addEventListener('keydown.dl', keydown); +}; + +export default Keyboard; diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js new file mode 100644 index 00000000000..12afe53ed76 --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/ajax.js @@ -0,0 +1,65 @@ +/* eslint-disable */ + +const Ajax = { + _loadUrlData: function _loadUrlData(url) { + var self = this; + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest; + xhr.open('GET', url, true); + xhr.onreadystatechange = function () { + if(xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + var data = JSON.parse(xhr.responseText); + self.cache[url] = data; + return resolve(data); + } else { + return reject([xhr.responseText, xhr.status]); + } + } + }; + xhr.send(); + }); + }, + _loadData: function _loadData(data, config, self) { + if (config.loadingTemplate) { + var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]'); + if (dataLoadingTemplate) dataLoadingTemplate.outerHTML = self.listTemplate; + } + + if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data); + }, + init: function init(hook) { + var self = this; + self.destroyed = false; + self.cache = self.cache || {}; + var config = hook.config.Ajax; + this.hook = hook; + if (!config || !config.endpoint || !config.method) { + return; + } + if (config.method !== 'setData' && config.method !== 'addData') { + return; + } + if (config.loadingTemplate) { + var dynamicList = hook.list.list.querySelector('[data-dynamic]'); + var loadingTemplate = document.createElement('div'); + loadingTemplate.innerHTML = config.loadingTemplate; + loadingTemplate.setAttribute('data-loading-template', ''); + this.listTemplate = dynamicList.outerHTML; + dynamicList.outerHTML = loadingTemplate.outerHTML; + } + if (self.cache[config.endpoint]) { + self._loadData(self.cache[config.endpoint], config, self); + } else { + this._loadUrlData(config.endpoint) + .then(function(d) { + self._loadData(d, config, self); + }, config.onError).catch(config.onError); + } + }, + destroy: function() { + this.destroyed = true; + } +}; + +export default Ajax; diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js new file mode 100644 index 00000000000..cfd7e2ca189 --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js @@ -0,0 +1,133 @@ +/* eslint-disable */ + +const AjaxFilter = { + init: function(hook) { + this.destroyed = false; + this.hook = hook; + this.notLoading(); + + this.eventWrapper = {}; + this.eventWrapper.debounceTrigger = this.debounceTrigger.bind(this); + this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceTrigger); + this.hook.trigger.addEventListener('focus', this.eventWrapper.debounceTrigger); + + this.trigger(true); + }, + + notLoading: function notLoading() { + this.loading = false; + }, + + debounceTrigger: function debounceTrigger(e) { + var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93]; + var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1; + var focusEvent = e.type === 'focus'; + if (invalidKeyPressed || this.loading) { + return; + } + if (this.timeout) { + clearTimeout(this.timeout); + } + this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200); + }, + + trigger: function trigger(getEntireList) { + var config = this.hook.config.AjaxFilter; + var searchValue = this.trigger.value; + if (!config || !config.endpoint || !config.searchKey) { + return; + } + if (config.searchValueFunction) { + searchValue = config.searchValueFunction(); + } + if (config.loadingTemplate && this.hook.list.data === undefined || + this.hook.list.data.length === 0) { + var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); + var loadingTemplate = document.createElement('div'); + loadingTemplate.innerHTML = config.loadingTemplate; + loadingTemplate.setAttribute('data-loading-template', true); + this.listTemplate = dynamicList.outerHTML; + dynamicList.outerHTML = loadingTemplate.outerHTML; + } + if (getEntireList) { + searchValue = ''; + } + if (config.searchKey === searchValue) { + return this.list.show(); + } + this.loading = true; + var params = config.params || {}; + params[config.searchKey] = searchValue; + var self = this; + self.cache = self.cache || {}; + var url = config.endpoint + this.buildParams(params); + var urlCachedData = self.cache[url]; + if (urlCachedData) { + self._loadData(urlCachedData, config, self); + } else { + this._loadUrlData(url) + .then(function(data) { + self._loadData(data, config, self); + }, config.onError).catch(config.onError); + } + }, + + _loadUrlData: function _loadUrlData(url) { + var self = this; + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest; + xhr.open('GET', url, true); + xhr.onreadystatechange = function () { + if(xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + var data = JSON.parse(xhr.responseText); + self.cache[url] = data; + return resolve(data); + } else { + return reject([xhr.responseText, xhr.status]); + } + } + }; + xhr.send(); + }); + }, + + _loadData: function _loadData(data, config, self) { + const list = self.hook.list; + if (config.loadingTemplate && list.data === undefined || + list.data.length === 0) { + const dataLoadingTemplate = list.list.querySelector('[data-loading-template]'); + if (dataLoadingTemplate) { + dataLoadingTemplate.outerHTML = self.listTemplate; + } + } + if (!self.destroyed) { + var hookListChildren = list.list.children; + var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic'); + if (onlyDynamicList && data.length === 0) { + list.hide(); + } + list.setData.call(list, data); + } + self.notLoading(); + list.currentIndex = 0; + }, + + buildParams: function(params) { + if (!params) return ''; + var paramsArray = Object.keys(params).map(function(param) { + return param + '=' + (params[param] || ''); + }); + return '?' + paramsArray.join('&'); + }, + + destroy: function destroy() { + if (this.timeout)clearTimeout(this.timeout); + this.destroyed = true; + + this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceTrigger); + this.hook.trigger.removeEventListener('focus', this.eventWrapper.debounceTrigger); + } +}; + +export default AjaxFilter; diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/droplab/plugins/filter.js new file mode 100644 index 00000000000..d6a1aadd49c --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/filter.js @@ -0,0 +1,95 @@ +/* eslint-disable */ + +const Filter = { + keydown: function(e){ + if (this.destroyed) return; + + var hiddenCount = 0; + var dataHiddenCount = 0; + + var list = e.detail.hook.list; + var data = list.data; + var value = e.detail.hook.trigger.value.toLowerCase(); + var config = e.detail.hook.config.Filter; + var matches = []; + var filterFunction; + // will only work on dynamically set data + if(!data){ + return; + } + + if (config && config.filterFunction && typeof config.filterFunction === 'function') { + filterFunction = config.filterFunction; + } else { + filterFunction = function(o){ + // cheap string search + o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1; + return o; + }; + } + + dataHiddenCount = data.filter(function(o) { + return !o.droplab_hidden; + }).length; + + matches = data.map(function(o) { + return filterFunction(o, value); + }); + + hiddenCount = matches.filter(function(o) { + return !o.droplab_hidden; + }).length; + + if (dataHiddenCount !== hiddenCount) { + list.setData(matches); + list.currentIndex = 0; + } + }, + + debounceKeydown: function debounceKeydown(e) { + if ([ + 13, // enter + 16, // shift + 17, // ctrl + 18, // alt + 20, // caps lock + 37, // left arrow + 38, // up arrow + 39, // right arrow + 40, // down arrow + 91, // left window + 92, // right window + 93, // select + ].indexOf(e.detail.which || e.detail.keyCode) > -1) return; + + if (this.timeout) clearTimeout(this.timeout); + this.timeout = setTimeout(this.keydown.bind(this, e), 200); + }, + + init: function init(hook) { + var config = hook.config.Filter; + + if (!config || !config.template) return; + + this.hook = hook; + this.destroyed = false; + + this.eventWrapper = {}; + this.eventWrapper.debounceKeydown = this.debounceKeydown.bind(this); + + this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown); + this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown); + + this.debounceKeydown({ detail: { hook: this.hook } }); + }, + + destroy: function destroy() { + if (this.timeout) clearTimeout(this.timeout); + this.destroyed = true; + + this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceKeydown); + this.hook.trigger.removeEventListener('mousedown.dl', this.eventWrapper.debounceKeydown); + } +}; + +export default Filter; diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/droplab/plugins/input_setter.js new file mode 100644 index 00000000000..d01fbc5830d --- /dev/null +++ b/app/assets/javascripts/droplab/plugins/input_setter.js @@ -0,0 +1,50 @@ +/* eslint-disable */ + +const InputSetter = { + init(hook) { + this.hook = hook; + this.destroyed = false; + this.config = hook.config.InputSetter || (this.hook.config.InputSetter = {}); + + this.eventWrapper = {}; + + this.addEvents(); + }, + + addEvents() { + this.eventWrapper.setInputs = this.setInputs.bind(this); + this.hook.list.list.addEventListener('click.dl', this.eventWrapper.setInputs); + }, + + removeEvents() { + this.hook.list.list.removeEventListener('click.dl', this.eventWrapper.setInputs); + }, + + setInputs(e) { + if (this.destroyed) return; + + const selectedItem = e.detail.selected; + + if (!Array.isArray(this.config)) this.config = [this.config]; + + this.config.forEach(config => this.setInput(config, selectedItem)); + }, + + setInput(config, selectedItem) { + const input = config.input || this.hook.trigger; + const newValue = selectedItem.getAttribute(config.valueAttribute); + const inputAttribute = config.inputAttribute; + + if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue); + if (input.tagName === 'INPUT') return input.value = newValue; + return input.textContent = newValue; + }, + + destroy() { + this.destroyed = true; + + this.removeEvents(); + }, +}; + +export default InputSetter; diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js new file mode 100644 index 00000000000..c149a33a1e9 --- /dev/null +++ b/app/assets/javascripts/droplab/utils.js @@ -0,0 +1,38 @@ +/* eslint-disable */ + +import { DATA_TRIGGER, DATA_DROPDOWN } from './constants'; + +const utils = { + toCamelCase(attr) { + return this.camelize(attr.split('-').slice(1).join(' ')); + }, + + t(s, d) { + for (const p in d) { + if (Object.prototype.hasOwnProperty.call(d, p)) { + s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]); + } + } + return s; + }, + + camelize(str) { + return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => { + return index === 0 ? letter.toLowerCase() : letter.toUpperCase(); + }).replace(/\s+/g, ''); + }, + + closest(thisTag, stopTag) { + while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') { + thisTag = thisTag.parentNode; + } + return thisTag; + }, + + isDropDownParts(target) { + if (!target || target.tagName === 'HTML') return false; + return target.hasAttribute(DATA_TRIGGER) || target.hasAttribute(DATA_DROPDOWN); + }, +}; + +export default utils; diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index 3f041172ff3..59d6508fc02 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -55,14 +55,19 @@ window.FilesCommentButton = (function() { textFileElement = this.getTextFileElement($currentTarget); buttonParentElement.append(this.buildButton({ + discussionID: lineContentElement.attr('data-discussion-id'), + lineType: lineContentElement.attr('data-line-type'), + noteableType: textFileElement.attr('data-noteable-type'), noteableID: textFileElement.attr('data-noteable-id'), commitID: textFileElement.attr('data-commit-id'), noteType: lineContentElement.attr('data-note-type'), - position: lineContentElement.attr('data-position'), - lineType: lineContentElement.attr('data-line-type'), - discussionID: lineContentElement.attr('data-discussion-id'), - lineCode: lineContentElement.attr('data-line-code') + + // LegacyDiffNote + lineCode: lineContentElement.attr('data-line-code'), + + // DiffNote + position: lineContentElement.attr('data-position') })); }; @@ -76,14 +81,19 @@ window.FilesCommentButton = (function() { FilesCommentButton.prototype.buildButton = function(buttonAttributes) { return $commentButtonTemplate.clone().attr({ + 'data-discussion-id': buttonAttributes.discussionID, + 'data-line-type': buttonAttributes.lineType, + 'data-noteable-type': buttonAttributes.noteableType, 'data-noteable-id': buttonAttributes.noteableID, 'data-commit-id': buttonAttributes.commitID, 'data-note-type': buttonAttributes.noteType, + + // LegacyDiffNote 'data-line-code': buttonAttributes.lineCode, - 'data-position': buttonAttributes.position, - 'data-discussion-id': buttonAttributes.discussionID, - 'data-line-type': buttonAttributes.lineType + + // DiffNote + 'data-position': buttonAttributes.position }); }; @@ -121,7 +131,7 @@ window.FilesCommentButton = (function() { }; FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { - return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== ''; + return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== ''; }; return FilesCommentButton; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 64d7153e547..381c40c03d8 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -1,13 +1,13 @@ -require('./filtered_search_dropdown'); +import Filter from '~/droplab/plugins/filter'; -/* global droplabFilter */ +require('./filtered_search_dropdown'); (() => { class DropdownHint extends gl.FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { super(droplab, dropdown, input, filter); this.config = { - droplabFilter: { + Filter: { template: 'hint', filterFunction: gl.DropdownUtils.filterHint.bind(null, input), }, @@ -69,12 +69,12 @@ require('./filtered_search_dropdown'); } }); - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); + this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config); this.droplab.setData(this.hookId, dropdownData); } init() { - this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init(); + this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init(); } } diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index b3dc3e502c5..6296965b911 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,7 +1,9 @@ -require('./filtered_search_dropdown'); +/* global Flash */ + +import Ajax from '~/droplab/plugins/ajax'; +import Filter from '~/droplab/plugins/filter'; -/* global droplabAjax */ -/* global droplabFilter */ +require('./filtered_search_dropdown'); (() => { class DropdownNonUser extends gl.FilteredSearchDropdown { @@ -9,13 +11,19 @@ require('./filtered_search_dropdown'); super(droplab, dropdown, input, filter); this.symbol = symbol; this.config = { - droplabAjax: { + Ajax: { endpoint, method: 'setData', loadingTemplate: this.loadingTemplate, + onError() { + /* eslint-disable no-new */ + new Flash('An error occured fetching the dropdown data.'); + /* eslint-enable no-new */ + }, }, - droplabFilter: { + Filter: { filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input), + template: 'title', }, }; } @@ -29,13 +37,13 @@ require('./filtered_search_dropdown'); renderContent(forceShowList = false) { this.droplab - .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config); + .changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config); super.renderContent(forceShowList); } init() { this.droplab - .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init(); + .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init(); } } diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 04e2afad02f..38b5d315bcf 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -1,13 +1,15 @@ -require('./filtered_search_dropdown'); +/* global Flash */ + +import AjaxFilter from '~/droplab/plugins/ajax_filter'; -/* global droplabAjaxFilter */ +require('./filtered_search_dropdown'); (() => { class DropdownUser extends gl.FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { super(droplab, dropdown, input, filter); this.config = { - droplabAjaxFilter: { + AjaxFilter: { endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, searchKey: 'search', params: { @@ -18,6 +20,11 @@ require('./filtered_search_dropdown'); }, searchValueFunction: this.getSearchInput.bind(this), loadingTemplate: this.loadingTemplate, + onError() { + /* eslint-disable no-new */ + new Flash('An error occured fetching the dropdown data.'); + /* eslint-enable no-new */ + }, }, }; } @@ -28,7 +35,7 @@ require('./filtered_search_dropdown'); } renderContent(forceShowList = false) { - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config); + this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config); super.renderContent(forceShowList); } @@ -56,7 +63,7 @@ require('./filtered_search_dropdown'); } init() { - this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); + this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init(); } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index e7bf530d343..d58eeeebf81 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -4,7 +4,7 @@ class FilteredSearchDropdown { constructor(droplab, dropdown, input, filter) { this.droplab = droplab; - this.hookId = input && input.getAttribute('data-id'); + this.hookId = input && input.id; this.input = input; this.filter = filter; this.dropdown = dropdown; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 5fbe0450bb8..ec481b9ef97 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -1,4 +1,4 @@ -/* global DropLab */ +import DropLab from '~/droplab/drop_lab'; import FilteredSearchContainer from './container'; (() => { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 2a8a6b81b3f..b93a8f1d322 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -154,7 +154,7 @@ import eventHub from './event_hub'; if (e.keyCode === 13) { const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; const dropdownEl = dropdown.element; - const activeElements = dropdownEl.querySelectorAll('.dropdown-active'); + const activeElements = dropdownEl.querySelectorAll('.droplab-item-active'); e.preventDefault(); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index e7c98e16581..ff10f19a4fe 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -29,7 +29,8 @@ GLForm.prototype.setupForm = function() { this.form.find('.div-dropzone').remove(); this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes - gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); + gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); + gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js new file mode 100644 index 00000000000..7732edde1e7 --- /dev/null +++ b/app/assets/javascripts/group.js @@ -0,0 +1,21 @@ +export default class Group { + constructor() { + this.groupPath = $('#group_path'); + this.groupName = $('#group_name'); + this.updateHandler = this.update.bind(this); + this.resetHandler = this.reset.bind(this); + if (this.groupName.val() === '') { + this.groupPath.on('keyup', this.updateHandler); + this.groupName.on('keydown', this.resetHandler); + } + } + + update() { + this.groupName.val(this.groupPath.val()); + } + + reset() { + this.groupPath.off('keyup', this.updateHandler); + this.groupName.off('keydown', this.resetHandler); + } +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 5b50bc62876..c50ec24c818 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,14 +37,7 @@ import './shortcuts_issuable'; import './shortcuts_network'; // behaviors -import './behaviors/autosize'; -import './behaviors/details_behavior'; -import './behaviors/quick_submit'; -import './behaviors/requires_input'; -import './behaviors/toggler_behavior'; -import './behaviors/bind_in_out'; -import { installGlEmojiElement } from './behaviors/gl_emoji'; -installGlEmojiElement(); +import './behaviors/'; // blob import './blob/create_branch_dropdown'; @@ -75,12 +68,6 @@ import './u2f/error'; import './u2f/register'; import './u2f/util'; -// droplab -import './droplab/droplab'; -import './droplab/droplab_ajax'; -import './droplab/droplab_ajax_filter'; -import './droplab/droplab_filter'; - // everything else import './abuse_reports'; import './activities'; @@ -285,7 +272,7 @@ $(function () { // Disable form buttons while a form is submitting $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { var buttons; - buttons = $('[type="submit"]', this); + buttons = $('[type="submit"], .js-disable-on-submit', this); switch (e.type) { case 'ajax:beforeSend': case 'submit': diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 8528e0800ae..f7f6a773036 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -3,9 +3,6 @@ /* global Flash */ import Cookies from 'js-cookie'; - -import CommitPipelinesTable from './commit/pipelines/pipelines_table'; - import './breakpoints'; import './flash'; @@ -102,10 +99,10 @@ import './flash'; destroyPipelinesView() { if (this.commitPipelinesTable) { - document.querySelector('#commit-pipeline-table-view') - .removeChild(this.commitPipelinesTable.$el); - this.commitPipelinesTable.$destroy(); + this.commitPipelinesTable = null; + + document.querySelector('#commit-pipeline-table-view').innerHTML = ''; } } @@ -234,7 +231,7 @@ import './flash'; } mountPipelinesView() { - this.commitPipelinesTable = new CommitPipelinesTable().$mount(); + this.commitPipelinesTable = new gl.CommitPipelinesTable().$mount(); // $mount(el) replaces the el with the new rendered component. We need it in order to mount // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount document.querySelector('#commit-pipeline-table-view') diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index ac4fad88fe5..773fe3233a7 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -2,8 +2,6 @@ /* global Issuable */ /* global ListMilestone */ -import Vue from 'vue'; - (function() { this.MilestoneSelect = (function() { function MilestoneSelect(currentProject, els) { @@ -151,12 +149,12 @@ import Vue from 'vue'; return $dropdown.closest('form').submit(); } else if ($dropdown.hasClass('js-issue-board-sidebar')) { if (selected.id !== -1) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'milestone', new ListMilestone({ + gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({ id: selected.id, title: selected.name })); } else { - Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'milestone'); + gl.issueBoards.boardStoreIssueDelete('milestone'); } $dropdown.trigger('loading.gl.dropdown'); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 1d563c63f39..15f7a813626 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -5,6 +5,7 @@ /* global mrRefreshWidgetUrl */ import Cookies from 'js-cookie'; +import CommentTypeToggle from './comment_type_toggle'; require('./autosave'); window.autosize = require('vendor/autosize'); @@ -110,7 +111,6 @@ require('./task_list'); $(document).on("visibilitychange", this.visibilityChange); // when issue status changes, we need to refresh data $(document).on("issuable:change", this.refresh); - // when a key is clicked on the notes return $(document).on("keydown", ".js-note-text", this.keydownNoteText); }; @@ -137,6 +137,26 @@ require('./task_list'); $(document).off("click", '.system-note-commit-list-toggler'); }; + Notes.initCommentTypeToggle = function (form) { + const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle'); + const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu'); + const noteTypeInput = form.querySelector('#note_type'); + const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button'); + const closeButton = form.querySelector('.js-note-target-close'); + const reopenButton = form.querySelector('.js-note-target-reopen'); + + const commentTypeToggle = new CommentTypeToggle({ + dropdownTrigger, + dropdownList, + noteTypeInput, + submitButton, + closeButton, + reopenButton, + }); + + commentTypeToggle.initDroplab(); + }; + Notes.prototype.keydownNoteText = function(e) { var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText; if (gl.utils.isMetaKey(e)) { @@ -192,7 +212,7 @@ require('./task_list'); }; Notes.prototype.refresh = function() { - if (!document.hidden && document.URL.indexOf(this.noteable_url) === 0) { + if (!document.hidden) { return this.getContent(); } }; @@ -213,11 +233,7 @@ require('./task_list'); _this.last_fetched_at = data.last_fetched_at; _this.setPollingInterval(data.notes.length); return $.each(notes, function(i, note) { - if (note.discussion_html != null) { - return _this.renderDiscussionNote(note); - } else { - return _this.renderNote(note); - } + _this.renderNote(note); }); }; })(this) @@ -276,8 +292,12 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderNote = function(note) { + Notes.prototype.renderNote = function(note, $form) { var $notesList; + if (note.discussion_html != null) { + return this.renderDiscussionNote(note, $form); + } + if (!note.valid) { if (note.errors.commands_only) { new Flash(note.errors.commands_only, 'notice', this.parentTimeline); @@ -317,61 +337,50 @@ require('./task_list'); Note: for rendering inline notes use renderDiscussionNote */ - Notes.prototype.renderDiscussionNote = function(note) { - var discussionContainer, form, note_html, row, lineType, diffAvatarContainer; + Notes.prototype.renderDiscussionNote = function(note, $form) { + var discussionContainer, form, row, lineType, diffAvatarContainer; if (!this.isNewNote(note)) { return; } this.note_ids.push(note.id); - form = $("#new-discussion-note-form-" + note.discussion_id); - if ((note.original_discussion_id != null) && form.length === 0) { - form = $("#new-discussion-note-form-" + note.original_discussion_id); - } + form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.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'); - note_html = $(note.html); - note_html.renderGFM(); // is this the first note of discussion? discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); - if ((note.original_discussion_id != null) && discussionContainer.length === 0) { - discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']"); + if (!discussionContainer.length) { + discussionContainer = form.closest('.discussion').find('.notes'); } if (discussionContainer.length === 0) { - if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) { - // insert the note and the reply button after the temp row - row.after(note.diff_discussion_html); + if (note.diff_discussion_html) { + var $discussion = $(note.diff_discussion_html).renderGFM(); - // remove the note (will be added again below) - row.next().find(".note").remove(); - } else { - // Merge new discussion HTML in - var $discussion = $(note.diff_discussion_html); - var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]'); - var contentContainerClass = '.' + $notes.closest('.notes_content') - .attr('class') - .split(' ') - .join('.'); - - // remove the note (will be added again below) - $notes.find('.note').remove(); - - row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); + 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 contentContainerClass = '.' + $notes.closest('.notes_content') + .attr('class') + .split(' ') + .join('.'); + + row.find(contentContainerClass + ' .content').append($notes.closest('.content').children()); + } } - // Before that, the container didn't exist - discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); - // Add note to 'Changes' page discussions - discussionContainer.append(note_html); + // Init discussion on 'Discussion' page if it is merge request page - if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { - $('ul.main-notes-list').append(note.discussion_html).renderGFM(); + if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) { + $('ul.main-notes-list').append($(note.discussion_html).renderGFM()); } } else { // append new note to all matching discussions - discussionContainer.append(note_html); + discussionContainer.append($(note.html).renderGFM()); } - if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_id) { + if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) { gl.diffNotesCompileComponents(); this.renderDiscussionAvatar(diffAvatarContainer, note); } @@ -455,9 +464,14 @@ require('./task_list'); form.addClass("js-main-target-form"); form.find("#note_line_code").remove(); form.find("#note_position").remove(); - form.find("#note_type").remove(); + form.find("#note_type").val(''); + form.find("#in_reply_to_discussion_id").remove(); form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); - return this.parentTimeline = form.parents('.timeline'); + this.parentTimeline = form.parents('.timeline'); + + if (form.length) { + Notes.initCommentTypeToggle(form.get(0)); + } }; /* @@ -470,10 +484,24 @@ require('./task_list'); */ Notes.prototype.setupNoteForm = function(form) { - var textarea; + var textarea, key; new gl.GLForm(form); textarea = form.find(".js-note-text"); - return new Autosave(textarea, ["Note", form.find("#note_noteable_type").val(), form.find("#note_noteable_id").val(), form.find("#note_commit_id").val(), form.find("#note_type").val(), form.find("#note_line_code").val(), form.find("#note_position").val()]); + key = [ + "Note", + form.find("#note_noteable_type").val(), + form.find("#note_noteable_id").val(), + form.find("#note_commit_id").val(), + form.find("#note_type").val(), + form.find("#in_reply_to_discussion_id").val(), + + // LegacyDiffNote + form.find("#note_line_code").val(), + + // DiffNote + form.find("#note_position").val() + ]; + return new Autosave(textarea, key); }; /* @@ -510,7 +538,7 @@ require('./task_list'); } } - this.renderDiscussionNote(note); + this.renderNote(note, $form); // cleanup after successfully creating a diff/discussion note this.removeDiscussionNoteForm($form); }; @@ -656,7 +684,7 @@ require('./task_list'); return function(i, el) { var note, notes; note = $(el); - notes = note.closest(".notes"); + notes = note.closest(".discussion-notes"); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -673,14 +701,13 @@ require('./task_list'); // "Discussions" tab notes.closest(".timeline-entry").remove(); - if (!_this.isParallelView() || notesTr.find('.note').length === 0) { - // "Changes" tab / commit view - notesTr.remove(); + // The notes tr can contain multiple lists of notes, like on the parallel diff + if (notesTr.find('.discussion-notes').length > 1) { + notes.remove(); } else { - notes.closest('.content').empty(); + notesTr.remove(); } } - return note.remove(); }; })(this)); // Decrement the "Discussions" counter only once @@ -711,7 +738,7 @@ require('./task_list'); Notes.prototype.replyToDiscussionNote = function(e) { var form, replyLink; - form = this.formClone.clone(); + form = this.cleanForm(this.formClone.clone()); replyLink = $(e.target).closest(".js-discussion-reply-button"); // insert the form after the button replyLink @@ -727,29 +754,44 @@ require('./task_list'); Sets some hidden fields in the form. - Note: dataHolder must have the "discussionId", "lineCode", "noteableType" - and "noteableId" data attributes set. + Note: dataHolder must have the "discussionId" and "lineCode" data attributes set. */ Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { // setup note target - form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId"))); + var discussionID = dataHolder.data("discussionId"); + + if (discussionID) { + form.attr("data-discussion-id", discussionID); + form.find("#in_reply_to_discussion_id").val(discussionID); + } + form.attr("data-line-code", dataHolder.data("lineCode")); - form.find("#note_type").val(dataHolder.data("noteType")); form.find("#line_type").val(dataHolder.data("lineType")); + + form.find("#note_noteable_type").val(dataHolder.data("noteableType")); + form.find("#note_noteable_id").val(dataHolder.data("noteableId")); form.find("#note_commit_id").val(dataHolder.data("commitId")); + form.find("#note_type").val(dataHolder.data("noteType")); + + // LegacyDiffNote form.find("#note_line_code").val(dataHolder.data("lineCode")); + + // DiffNote form.find("#note_position").val(dataHolder.attr("data-position")); - form.find("#note_noteable_type").val(dataHolder.data("noteableType")); - form.find("#note_noteable_id").val(dataHolder.data("noteableId")); + form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); form.find('.js-note-target-close').remove(); + form.find('.js-note-new-discussion').remove(); this.setupNoteForm(form); + form + .removeClass('js-main-target-form') + .addClass("discussion-form js-discussion-note-form"); + if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); - $commentBtn - .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'"); + $commentBtn.attr(':discussion-id', `'${discussionID}'`); gl.diffNotesCompileComponents(); } @@ -757,10 +799,7 @@ require('./task_list'); form.find(".js-note-text").focus(); form .find('.js-comment-resolve-button') - .attr('data-discussion-id', dataHolder.data('discussionId')); - form - .removeClass('js-main-target-form') - .addClass("discussion-form js-discussion-note-form"); + .attr('data-discussion-id', discussionID); }; /* @@ -823,7 +862,7 @@ require('./task_list'); } if (addForm) { - newForm = this.formClone.clone(); + newForm = this.cleanForm(this.formClone.clone()); newForm.appendTo(notesContent); // show the form return this.setupDiscussionNoteForm($link, newForm); @@ -900,9 +939,10 @@ require('./task_list'); reopenbtn = form.find('.js-note-target-reopen'); closebtn = form.find('.js-note-target-close'); discardbtn = form.find('.js-note-discard'); + if (textarea.val().trim().length > 0) { - reopentext = reopenbtn.data('alternative-text'); - closetext = closebtn.data('alternative-text'); + reopentext = reopenbtn.attr('data-alternative-text'); + closetext = closebtn.attr('data-alternative-text'); if (reopenbtn.text() !== reopentext) { reopenbtn.text(reopentext); } @@ -1009,6 +1049,20 @@ require('./task_list'); }); }; + Notes.prototype.cleanForm = function($form) { + // Remove JS classes that are not needed here + $form + .find('.js-comment-type-dropdown') + .removeClass('btn-group'); + + // Remove dropdown + $form + .find('.dropdown-menu') + .remove(); + + return $form; + }; + return Notes; })(); }).call(window); diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js new file mode 100644 index 00000000000..61e7ba53862 --- /dev/null +++ b/app/assets/javascripts/protected_tags/index.js @@ -0,0 +1,2 @@ +export { default as ProtectedTagCreate } from './protected_tag_create'; +export { default as ProtectedTagEditList } from './protected_tag_edit_list'; diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js new file mode 100644 index 00000000000..fff83f3af3b --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js @@ -0,0 +1,26 @@ +export default class ProtectedTagAccessDropdown { + constructor(options) { + this.options = options; + this.initDropdown(); + } + + initDropdown() { + const { onSelect } = this.options; + this.options.$dropdown.glDropdown({ + data: this.options.data, + selectable: true, + inputId: this.options.$dropdown.data('input-id'), + fieldName: this.options.$dropdown.data('field-name'), + toggleLabel(item, $el) { + if ($el.is('.is-active')) { + return item.text; + } + return 'Select'; + }, + clicked(item, $el, e) { + e.preventDefault(); + onSelect(); + }, + }); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js new file mode 100644 index 00000000000..91bd140bd12 --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -0,0 +1,41 @@ +import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; +import ProtectedTagDropdown from './protected_tag_dropdown'; + +export default class ProtectedTagCreate { + constructor() { + this.$form = $('.js-new-protected-tag'); + this.buildDropdowns(); + } + + buildDropdowns() { + const $allowedToCreateDropdown = this.$form.find('.js-allowed-to-create'); + + // Cache callback + this.onSelectCallback = this.onSelect.bind(this); + + // Allowed to Create dropdown + this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ + $dropdown: $allowedToCreateDropdown, + data: gon.create_access_levels, + onSelect: this.onSelectCallback, + }); + + // Select default + $allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0); + + // Protected tag dropdown + this.protectedTagDropdown = new ProtectedTagDropdown({ + $dropdown: this.$form.find('.js-protected-tag-select'), + onSelect: this.onSelectCallback, + }); + } + + // This will run after clicked callback + onSelect() { + // Enable submit button + const $tagInput = this.$form.find('input[name="protected_tag[name]"]'); + const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes'); + + this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length)); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js new file mode 100644 index 00000000000..5ff4e443262 --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js @@ -0,0 +1,86 @@ +export default class ProtectedTagDropdown { + /** + * @param {Object} options containing + * `$dropdown` target element + * `onSelect` event callback + * $dropdown must be an element created using `dropdown_tag()` rails helper + */ + constructor(options) { + this.onSelect = options.onSelect; + this.$dropdown = options.$dropdown; + this.$dropdownContainer = this.$dropdown.parent(); + this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer'); + this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag'); + + this.buildDropdown(); + this.bindEvents(); + + // Hide footer + this.toggleFooter(true); + } + + buildDropdown() { + this.$dropdown.glDropdown({ + data: this.getProtectedTags.bind(this), + filterable: true, + remote: false, + search: { + fields: ['title'], + }, + selectable: true, + toggleLabel(selected) { + return (selected && 'id' in selected) ? selected.title : 'Protected Tag'; + }, + fieldName: 'protected_tag[name]', + text(protectedTag) { + return _.escape(protectedTag.title); + }, + id(protectedTag) { + return _.escape(protectedTag.id); + }, + onFilter: this.toggleCreateNewButton.bind(this), + clicked: (item, $el, e) => { + e.preventDefault(); + this.onSelect(); + }, + }); + } + + bindEvents() { + this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this)); + } + + onClickCreateWildcard(e) { + this.$dropdown.data('glDropdown').remote.execute(); + this.$dropdown.data('glDropdown').selectRowAtIndex(); + e.preventDefault(); + } + + getProtectedTags(term, callback) { + if (this.selectedTag) { + callback(gon.open_tags.concat(this.selectedTag)); + } else { + callback(gon.open_tags); + } + } + + toggleCreateNewButton(tagName) { + if (tagName) { + this.selectedTag = { + title: tagName, + id: tagName, + text: tagName, + }; + + this.$dropdownContainer + .find('.create-new-protected-tag code') + .text(tagName); + } + + this.toggleFooter(!tagName); + } + + toggleFooter(toggleState) { + this.$dropdownFooter.toggleClass('hidden', toggleState); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js new file mode 100644 index 00000000000..09a387c0f9e --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -0,0 +1,52 @@ +/* eslint-disable no-new */ +/* global Flash */ + +import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; + +export default class ProtectedTagEdit { + constructor(options) { + this.$wrap = options.$wrap; + this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create'); + this.onSelectCallback = this.onSelect.bind(this); + + this.buildDropdowns(); + } + + buildDropdowns() { + // Allowed to create dropdown + this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ + $dropdown: this.$allowedToCreateDropdownButton, + data: gon.create_access_levels, + onSelect: this.onSelectCallback, + }); + } + + onSelect() { + const $allowedToCreateInput = this.$wrap.find(`input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`); + + // Do not update if one dropdown has not selected any option + if (!$allowedToCreateInput.length) return; + + this.$allowedToCreateDropdownButton.disable(); + + $.ajax({ + type: 'POST', + url: this.$wrap.data('url'), + dataType: 'json', + data: { + _method: 'PATCH', + protected_tag: { + create_access_levels_attributes: [{ + id: this.$allowedToCreateDropdownButton.data('access-level-id'), + access_level: $allowedToCreateInput.val(), + }], + }, + }, + error() { + new Flash('Failed to update tag!', null, $('.js-protected-tags-list')); + }, + }).always(() => { + this.$allowedToCreateDropdownButton.enable(); + }); + } +} diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js new file mode 100644 index 00000000000..bd9fc872266 --- /dev/null +++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js @@ -0,0 +1,18 @@ +/* eslint-disable no-new */ + +import ProtectedTagEdit from './protected_tag_edit'; + +export default class ProtectedTagEditList { + constructor() { + this.$wrap = $('.protected-tags-list'); + this.initEditForm(); + } + + initEditForm() { + this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => { + new ProtectedTagEdit({ + $wrap: $(el), + }); + }); + } +} diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js index ea91aaa10a6..2c3a9cacd38 100644 --- a/app/assets/javascripts/render_gfm.js +++ b/app/assets/javascripts/render_gfm.js @@ -8,6 +8,7 @@ $.fn.renderGFM = function() { this.find('.js-syntax-highlight').syntaxHighlight(); this.find('.js-render-math').renderMath(); + return this; }; $(document).on('ready load', function() { diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js index 9c307915ec4..5f9a3e00c22 100644 --- a/app/assets/javascripts/subscription.js +++ b/app/assets/javascripts/subscription.js @@ -1,5 +1,3 @@ -import Vue from 'vue'; - (() => { class Subscription { constructor(containerElm) { @@ -29,8 +27,7 @@ import Vue from 'vue'; // hack to allow this to work with the issue boards Vue object if (document.querySelector('html').classList.contains('issue-boards-page')) { - Vue.set( - gl.issueBoards.BoardsStore.detail.issue, + gl.issueBoards.boardStoreIssueSet( 'subscribed', !gl.issueBoards.BoardsStore.detail.issue.subscribed, ); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 48e20cf501f..3325a7d429c 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -2,8 +2,6 @@ /* global Issuable */ /* global ListUser */ -import Vue from 'vue'; - (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, slice = [].slice; @@ -74,7 +72,7 @@ import Vue from 'vue'; e.preventDefault(); if ($dropdown.hasClass('js-issue-board-sidebar')) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({ + gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ id: _this.currentUser.id, username: _this.currentUser.username, name: _this.currentUser.name, @@ -225,14 +223,14 @@ import Vue from 'vue'; return $dropdown.closest('form').submit(); } else if ($dropdown.hasClass('js-issue-board-sidebar')) { if (user.id) { - Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({ + gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({ id: user.id, username: user.username, name: user.name, avatar_url: user.avatar_url })); } else { - Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'assignee'); + gl.issueBoards.boardStoreIssueDelete('assignee'); } updateIssueBoardsIssue(); diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 484df6214d3..12465d4a70b 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -446,10 +446,8 @@ } } -.filter-dropdown-item.dropdown-active { - .btn { - @extend %filter-dropdown-item-btn-hover; - } +.filter-dropdown-item.droplab-item-active .btn { + @extend %filter-dropdown-item-btn-hover; } .filter-dropdown-loading { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 969fc75c6eb..03fddaeb163 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -57,6 +57,37 @@ margin-right: 5px; } } + + .truncated-info { + text-align: center; + border-bottom: 1px solid; + background-color: $black-transparent; + height: 45px; + + &.affix { + top: 0; + } + + // with sidebar + &.affix.sidebar-expanded { + right: 312px; + left: 22px; + } + + // without sidebar + &.affix.sidebar-collapsed { + right: 20px; + left: 20px; + } + + &.affix-top { + position: absolute; + top: 0; + margin: 0 auto; + right: 5px; + left: 5px; + } + } } .scroll-controls { @@ -186,6 +217,7 @@ white-space: pre; overflow-x: auto; font-size: 12px; + position: relative; .fa-refresh { font-size: 24px; diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index e7f9bbbc62f..79da490675a 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -31,7 +31,7 @@ svg { width: 20px; - height: auto; + height: 20px; fill: $gl-text-color-secondary; } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e84a05e3e9e..0bca3e93e4c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -196,6 +196,7 @@ transition: width .3s; background: $gray-light; padding: 10px 20px; + z-index: 2; &.right-sidebar-expanded { width: $gutter_width; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 927bf9805ce..b637994adf8 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -310,3 +310,94 @@ margin-bottom: 10px; } } + +.comment-type-dropdown { + .comment-btn { + width: auto; + } + + .dropdown-toggle { + float: right; + + .toggle-icon { + color: $white-light; + padding-right: 2px; + margin-top: 2px; + pointer-events: none; + } + } + + .dropdown-menu { + top: initial; + bottom: 40px; + width: 298px; + } + + .description { + display: inline-block; + white-space: normal; + margin-left: 8px; + padding-right: 33px; + } + + li { + padding-top: 6px; + + & > a { + margin: 0; + padding: 0; + color: inherit; + border-radius: 0; + text-overflow: inherit; + + &:hover, + &:focus { + background-color: inherit; + color: inherit; + } + } + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected i { + visibility: visible; + } + + i { + visibility: hidden; + } + } + + i { + display: inline-block; + vertical-align: top; + padding-top: 2px; + } + + .divider { + margin: 0 8px; + padding: 0; + border-top: $gray-darkest; + } + + @media (max-width: $screen-xs-max) { + display: flex; + width: 100%; + + .comment-btn { + flex-grow: 1; + flex-shrink: 0; + width: auto; + } + + .dropdown-toggle { + flex-grow: 0; + flex-shrink: 1; + width: auto; + } + } +} diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 12ca20a1420..94ea4c5c8c6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -19,7 +19,7 @@ ul.notes { svg { width: 18px; - height: auto; + height: 18px; fill: $gray-darkest; position: absolute; left: 30px; @@ -294,6 +294,18 @@ ul.notes { border-width: 1px; } + .discussion-notes { + &:not(:first-child) { + border-top: 1px solid $white-normal; + margin-top: 20px; + } + + &:not(:last-child) { + border-bottom: 1px solid $white-normal; + margin-bottom: 20px; + } + } + .notes { background-color: $white-light; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0fa1f68e034..717ebb44a23 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -744,7 +744,8 @@ pre.light-well { text-align: left; } -.protected-branches-list { +.protected-branches-list, +.protected-tags-list { margin-bottom: 30px; a { @@ -776,6 +777,17 @@ pre.light-well { } } +.protected-tags-list { + .dropdown-menu-toggle { + width: 100%; + max-width: 300px; + } + + .flash-container { + padding: 0; + } +} + .custom-notifications-form { .is-loading { .custom-notification-event-loading { diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index cf795d977ce..a4648b33cfa 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -6,6 +6,6 @@ class Admin::ApplicationController < ApplicationController layout 'admin' def authenticate_admin! - render_404 unless current_user.is_admin? + render_404 unless current_user.admin? end end diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 9433da02f64..8e7adc06584 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -21,6 +21,6 @@ class Admin::ImpersonationsController < Admin::ApplicationController end def authenticate_impersonator! - render_404 unless impersonator && impersonator.is_admin? && !impersonator.blocked? + render_404 unless impersonator && impersonator.admin? && !impersonator.blocked? end end diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb new file mode 100644 index 00000000000..dd21066ac13 --- /dev/null +++ b/app/controllers/concerns/renders_notes.rb @@ -0,0 +1,20 @@ +module RendersNotes + def prepare_notes_for_rendering(notes) + preload_noteable_for_regular_notes(notes) + preload_max_access_for_authors(notes, @project) + Banzai::NoteRenderer.render(notes, @project, current_user) + + notes + end + + private + + def preload_max_access_for_authors(notes, project) + user_ids = notes.map(&:author_id) + project.team.max_member_access_for_user_ids(user_ids) + end + + def preload_noteable_for_regular_notes(notes) + ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable) + end +end diff --git a/app/controllers/concerns/requires_health_token.rb b/app/controllers/concerns/requires_health_token.rb new file mode 100644 index 00000000000..34ab1a97649 --- /dev/null +++ b/app/controllers/concerns/requires_health_token.rb @@ -0,0 +1,25 @@ +module RequiresHealthToken + extend ActiveSupport::Concern + included do + before_action :validate_health_check_access! + end + + private + + def validate_health_check_access! + render_404 unless token_valid? + end + + def token_valid? + token = params[:token].presence || request.headers['TOKEN'] + token.present? && + ActiveSupport::SecurityUtils.variable_size_secure_compare( + token, + current_application_settings.health_check_access_token + ) + end + + def render_404 + render file: Rails.root.join('public', '404'), layout: false, status: '404' + end +end diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb index 037da7d2bce..5d3109b7187 100644 --- a/app/controllers/health_check_controller.rb +++ b/app/controllers/health_check_controller.rb @@ -1,22 +1,3 @@ class HealthCheckController < HealthCheck::HealthCheckController - before_action :validate_health_check_access! - - private - - def validate_health_check_access! - render_404 unless token_valid? - end - - def token_valid? - token = params[:token].presence || request.headers['TOKEN'] - token.present? && - ActiveSupport::SecurityUtils.variable_size_secure_compare( - token, - current_application_settings.health_check_access_token - ) - end - - def render_404 - render file: Rails.root.join('public', '404'), layout: false, status: '404' - end + include RequiresHealthToken end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 00000000000..df0fc3132ed --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,60 @@ +class HealthController < ActionController::Base + protect_from_forgery with: :exception + include RequiresHealthToken + + CHECKS = [ + Gitlab::HealthChecks::DbCheck, + Gitlab::HealthChecks::RedisCheck, + Gitlab::HealthChecks::FsShardsCheck, + ].freeze + + def readiness + results = CHECKS.map { |check| [check.name, check.readiness] } + + render_check_results(results) + end + + def liveness + results = CHECKS.map { |check| [check.name, check.liveness] } + + render_check_results(results) + end + + def metrics + results = CHECKS.flat_map(&:metrics) + + response = results.map(&method(:metric_to_prom_line)).join("\n") + + render text: response, content_type: 'text/plain; version=0.0.4' + end + + private + + def metric_to_prom_line(metric) + labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || '' + if labels.empty? + "#{metric.name} #{metric.value}" + else + "#{metric.name}{#{labels}} #{metric.value}" + end + end + + def render_check_results(results) + flattened = results.flat_map do |name, result| + if result.is_a?(Gitlab::HealthChecks::Result) + [[name, result]] + else + result.map { |r| [name, r] } + end + end + success = flattened.all? { |name, r| r.success } + + response = flattened.map do |name, r| + info = { status: r.success ? 'ok' : 'failed' } + info['message'] = r.message if r.message + info[:labels] = r.labels if r.labels + [name, info] + end + render json: response.to_h, status: success ? :ok : :service_unavailable + end +end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index add66ce9f84..04e8cdf6256 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -19,6 +19,11 @@ class Projects::BuildsController < Projects::ApplicationController else @builds end + @builds = @builds.includes([ + { pipeline: :project }, + :project, + :tags + ]) @builds = @builds.page(params[:page]).per(30) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index f453822bed6..d25bbddd1bb 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -2,6 +2,7 @@ # # Not to be confused with CommitsController, plural. class Projects::CommitController < Projects::ApplicationController + include RendersNotes include CreatesCommit include DiffForPath include DiffHelper @@ -113,22 +114,19 @@ class Projects::CommitController < Projects::ApplicationController end def define_note_vars - @grouped_diff_discussions = commit.notes.grouped_diff_discussions - @notes = commit.notes.non_diff_notes.fresh - - Banzai::NoteRenderer.render( - @grouped_diff_discussions.values.flat_map(&:notes) + @notes, - @project, - current_user, - ) - + @noteable = @commit @note = @project.build_commit_note(commit) - @noteable = @commit - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'Commit', commit_id: @commit.id } + + @grouped_diff_discussions = commit.grouped_diff_discussions + @discussions = commit.discussions + + @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes) + @notes = prepare_notes_for_rendering(@notes) end def assign_change_commit_vars diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 1349b015a63..f4a18a5e8f7 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -28,7 +28,7 @@ class Projects::DiscussionsController < Projects::ApplicationController end def discussion - @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404 + @discussion ||= @merge_request.find_discussion(params[:id]) || render_404 end def authorize_resolve_discussion! diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index a50e16fa4ff..cbf67137261 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,5 +1,5 @@ class Projects::IssuesController < Projects::ApplicationController - include NotesHelper + include RendersNotes include ToggleSubscriptionAction include IssuableActions include ToggleAwardEmoji @@ -84,15 +84,11 @@ class Projects::IssuesController < Projects::ApplicationController end def show - raw_notes = @issue.notes.inc_relations_for_view.fresh - - @notes = Banzai::NoteRenderer. - render(raw_notes, @project, current_user, @path, @project_wiki, @ref) - - @note = @project.notes.new(noteable: @issue) @noteable = @issue + @note = @project.notes.new(noteable: @issue) - preload_max_access_for_authors(@notes, @project) + @discussions = @issue.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) respond_to do |format| format.html diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c107b3ffa88..5c1f7e69ee8 100755 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -3,7 +3,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController include DiffForPath include DiffHelper include IssuableActions - include NotesHelper + include RendersNotes include ToggleAwardEmoji include IssuableCollections @@ -574,20 +574,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController @note = @project.notes.new(noteable: @merge_request) @discussions = @merge_request.discussions - - preload_noteable_for_regular_notes(@discussions.flat_map(&:notes)) - - # This is not executed lazily - @notes = Banzai::NoteRenderer.render( - @discussions.flat_map(&:notes), - @project, - current_user, - @path, - @project_wiki, - @ref - ) - - preload_max_access_for_authors(@notes, @project) + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def define_widget_vars @@ -600,22 +587,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def define_diff_comment_vars - @comments_target = { + @new_diff_note_attrs = { noteable_type: 'MergeRequest', noteable_id: @merge_request.id } @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions - - Banzai::NoteRenderer.render( - @grouped_diff_discussions.values.flat_map(&:notes), - @project, - current_user, - @path, - @project_wiki, - @ref - ) + + @grouped_diff_discussions = @merge_request.grouped_diff_discussions + @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) end def define_pipelines_vars diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index d00177e7612..405ea3c0a4f 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,4 +1,5 @@ class Projects::NotesController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji # Authorize @@ -6,13 +7,15 @@ class Projects::NotesController < Projects::ApplicationController before_action :authorize_create_note!, only: [:create] before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_resolve_note!, only: [:resolve, :unresolve] - before_action :find_current_user_notes, only: [:index] def index current_fetched_at = Time.now.to_i notes_json = { notes: [], last_fetched_at: current_fetched_at } + @notes = notes_finder.execute.inc_relations_for_view + @notes = prepare_notes_for_rendering(@notes) + @notes.each do |note| next if note.cross_reference_not_visible_for?(current_user) @@ -23,7 +26,10 @@ class Projects::NotesController < Projects::ApplicationController end def create - create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha]) + create_params = note_params.merge( + merge_request_diff_head_sha: params[:merge_request_diff_head_sha], + in_reply_to_discussion_id: params[:in_reply_to_discussion_id] + ) @note = Notes::CreateService.new(project, current_user, create_params).execute if @note.is_a?(Note) @@ -111,6 +117,17 @@ class Projects::NotesController < Projects::ApplicationController ) end + def discussion_html(discussion) + return if discussion.individual_note? + + render_to_string( + "discussions/_discussion", + layout: false, + formats: [:html], + locals: { discussion: discussion } + ) + end + def diff_discussion_html(discussion) return unless discussion.diff_discussion? @@ -118,13 +135,13 @@ class Projects::NotesController < Projects::ApplicationController template = "discussions/_parallel_diff_discussion" locals = if params[:line_type] == 'old' - { discussion_left: discussion, discussion_right: nil } + { discussions_left: [discussion], discussions_right: nil } else - { discussion_left: nil, discussion_right: discussion } + { discussions_left: nil, discussions_right: [discussion] } end else template = "discussions/_diff_discussion" - locals = { discussion: discussion } + locals = { discussions: [discussion] } end render_to_string( @@ -135,54 +152,28 @@ class Projects::NotesController < Projects::ApplicationController ) end - def discussion_html(discussion) - return unless discussion.diff_discussion? - - render_to_string( - "discussions/_discussion", - layout: false, - formats: [:html], - locals: { discussion: discussion } - ) - end - def note_json(note) attrs = { - id: note.id + commands_changes: note.commands_changes } if note.persisted? - Banzai::NoteRenderer.render([note], @project, current_user) - attrs.merge!( valid: true, - discussion_id: note.discussion_id, + id: note.id, + discussion_id: note.discussion_id(noteable), html: note_html(note), note: note.note ) - if note.diff_note? - discussion = note.to_discussion - + discussion = note.to_discussion(noteable) + unless discussion.individual_note? attrs.merge!( + discussion_resolvable: discussion.resolvable?, + diff_discussion_html: diff_discussion_html(discussion), discussion_html: discussion_html(discussion) ) - - # The discussion_id is used to add the comment to the correct discussion - # element on the merge request page. Among other things, the discussion_id - # contains the sha of head commit of the merge request. - # When new commits are pushed into the merge request after the initial - # load of the merge request page, the discussion elements will still have - # the old discussion_ids, with the old head commit sha. The new comment, - # however, will have the new discussion_id with the new commit sha. - # To ensure that these new comments will still end up in the correct - # discussion element, we also send the original discussion_id, with the - # old commit sha, along, and fall back on this value when no discussion - # element with the new discussion_id could be found. - if note.new_diff_note? && note.position != note.original_position - attrs[:original_discussion_id] = note.original_discussion_id - end end else attrs.merge!( @@ -191,7 +182,6 @@ class Projects::NotesController < Projects::ApplicationController ) end - attrs[:commands_changes] = note.commands_changes attrs end @@ -205,14 +195,30 @@ class Projects::NotesController < Projects::ApplicationController def note_params params.require(:note).permit( - :note, :noteable, :noteable_id, :noteable_type, :project_id, - :attachment, :line_code, :commit_id, :type, :position + :project_id, + :noteable_type, + :noteable_id, + :commit_id, + :noteable, + :type, + + :note, + :attachment, + + # LegacyDiffNote + :line_code, + + # DiffNote + :position ) end - def find_current_user_notes - @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) - .execute.inc_author + def notes_finder + @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) + end + + def noteable + @noteable ||= notes_finder.target end def last_fetched_at diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 5de8301f74c..1780cc0233c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -116,7 +116,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def pipeline - @pipeline ||= project.pipelines.find_by!(id: params[:id]) + @pipeline ||= project.pipelines.find_by!(id: params[:id]).present(current_user: current_user) end def commit diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index c8c80551ac9..ff50602831c 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -23,7 +23,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController def update_params params.require(:project).permit( :runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex, - :public_builds + :public_builds, :auto_cancel_pending_pipelines ) end end diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index a8cb07eb67a..ba24fa9acfe 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -1,58 +1,23 @@ -class Projects::ProtectedBranchesController < Projects::ApplicationController - include RepositorySettingsRedirect - # Authorize - before_action :require_non_empty_project - before_action :authorize_admin_project! - before_action :load_protected_branch, only: [:show, :update, :destroy] +class Projects::ProtectedBranchesController < Projects::ProtectedRefsController + protected - layout "project_settings" - - def index - redirect_to_repository_settings(@project) - end - - def create - @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute - unless @protected_branch.persisted? - flash[:alert] = @protected_branches.errors.full_messages.join(', ').html_safe - end - redirect_to_repository_settings(@project) - end - - def show - @matching_branches = @protected_branch.matching(@project.repository.branches) + def project_refs + @project.repository.branches end - def update - @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch) - - if @protected_branch.valid? - respond_to do |format| - format.json { render json: @protected_branch, status: :ok } - end - else - respond_to do |format| - format.json { render json: @protected_branch.errors, status: :unprocessable_entity } - end - end + def create_service_class + ::ProtectedBranches::CreateService end - def destroy - @protected_branch.destroy - - respond_to do |format| - format.html { redirect_to_repository_settings(@project) } - format.js { head :ok } - end + def update_service_class + ::ProtectedBranches::UpdateService end - private - - def load_protected_branch - @protected_branch = @project.protected_branches.find(params[:id]) + def load_protected_ref + @protected_ref = @project.protected_branches.find(params[:id]) end - def protected_branch_params + def protected_ref_params params.require(:protected_branch).permit(:name, merge_access_levels_attributes: [:access_level, :id], push_access_levels_attributes: [:access_level, :id]) diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb new file mode 100644 index 00000000000..083a70968e5 --- /dev/null +++ b/app/controllers/projects/protected_refs_controller.rb @@ -0,0 +1,47 @@ +class Projects::ProtectedRefsController < Projects::ApplicationController + include RepositorySettingsRedirect + + # Authorize + before_action :require_non_empty_project + before_action :authorize_admin_project! + before_action :load_protected_ref, only: [:show, :update, :destroy] + + layout "project_settings" + + def index + redirect_to_repository_settings(@project) + end + + def create + protected_ref = create_service_class.new(@project, current_user, protected_ref_params).execute + + unless protected_ref.persisted? + flash[:alert] = protected_ref.errors.full_messages.join(', ').html_safe + end + + redirect_to_repository_settings(@project) + end + + def show + @matching_refs = @protected_ref.matching(project_refs) + end + + def update + @protected_ref = update_service_class.new(@project, current_user, protected_ref_params).execute(@protected_ref) + + if @protected_ref.valid? + render json: @protected_ref, status: :ok + else + render json: @protected_ref.errors, status: :unprocessable_entity + end + end + + def destroy + @protected_ref.destroy + + respond_to do |format| + format.html { redirect_to_repository_settings(@project) } + format.js { head :ok } + end + end +end diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb new file mode 100644 index 00000000000..c61ddf145e6 --- /dev/null +++ b/app/controllers/projects/protected_tags_controller.rb @@ -0,0 +1,23 @@ +class Projects::ProtectedTagsController < Projects::ProtectedRefsController + protected + + def project_refs + @project.repository.tags + end + + def create_service_class + ::ProtectedTags::CreateService + end + + def update_service_class + ::ProtectedTags::UpdateService + end + + def load_protected_ref + @protected_ref = @project.protected_tags.find(params[:id]) + end + + def protected_ref_params + params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id]) + end +end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index b6ce4abca45..44de8a49593 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -4,46 +4,48 @@ module Projects before_action :authorize_admin_project! def show - @deploy_keys = DeployKeysPresenter - .new(@project, current_user: current_user) + @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user) - define_protected_branches + define_protected_refs end private - def define_protected_branches - load_protected_branches + def define_protected_refs + @protected_branches = @project.protected_branches.order(:name).page(params[:page]) + @protected_tags = @project.protected_tags.order(:name).page(params[:page]) @protected_branch = @project.protected_branches.new + @protected_tag = @project.protected_tags.new load_gon_index end - def load_protected_branches - @protected_branches = @project.protected_branches.order(:name).page(params[:page]) - end - def access_levels_options { - push_access_levels: { - roles: ProtectedBranch::PushAccessLevel.human_access_levels.map do |id, text| - { id: id, text: text, before_divider: true } - end - }, - merge_access_levels: { - roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map do |id, text| - { id: id, text: text, before_divider: true } - end - } + create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel), + push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel), + merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel) } end - def open_branches - branches = @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } - { open_branches: branches } + def levels_for_dropdown(access_level_type) + roles = access_level_type.human_access_levels.map do |id, text| + { id: id, text: text, before_divider: true } + end + { roles: roles } + end + + def protectable_tags_for_dropdown + { open_tags: ProtectableDropdown.new(@project, :tags).hash } + end + + def protectable_branches_for_dropdown + { open_branches: ProtectableDropdown.new(@project, :branches).hash } end def load_gon_index - gon.push(open_branches.merge(access_levels_options)) + gon.push(protectable_tags_for_dropdown) + gon.push(protectable_branches_for_dropdown) + gon.push(access_levels_options) end end end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index ea1a97b7cf0..5c9e0d4d1a1 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -1,4 +1,5 @@ class Projects::SnippetsController < Projects::ApplicationController + include RendersNotes include ToggleAwardEmoji include SpammableActions include SnippetsActions @@ -55,8 +56,10 @@ class Projects::SnippetsController < Projects::ApplicationController def show @note = @project.notes.new(noteable: @snippet) - @notes = Banzai::NoteRenderer.render(@snippet.notes.fresh, @project, current_user) @noteable = @snippet + + @discussions = @snippet.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end def destroy diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb index c47198c5eb6..afa56de920b 100644 --- a/app/controllers/projects/triggers_controller.rb +++ b/app/controllers/projects/triggers_controller.rb @@ -11,7 +11,7 @@ class Projects::TriggersController < Projects::ApplicationController end def create - @trigger = project.triggers.create(create_params.merge(owner: current_user)) + @trigger = project.triggers.create(trigger_params.merge(owner: current_user)) if @trigger.valid? flash[:notice] = 'Trigger was created successfully.' @@ -36,7 +36,7 @@ class Projects::TriggersController < Projects::ApplicationController end def update - if trigger.update(update_params) + if trigger.update(trigger_params) redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.' else render action: "edit" @@ -67,11 +67,10 @@ class Projects::TriggersController < Projects::ApplicationController @trigger ||= project.triggers.find(params[:id]) || render_404 end - def create_params - params.require(:trigger).permit(:description) - end - - def update_params - params.require(:trigger).permit(:description) + def trigger_params + params.require(:trigger).permit( + :description, + trigger_schedule_attributes: [:id, :active, :cron, :cron_timezone, :ref] + ) end end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 6630c6384f2..3c499184b41 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -17,29 +17,46 @@ class NotesFinder @project = project @current_user = current_user @params = params - init_collection end def execute - @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at] - @notes + notes = init_collection + notes = since_fetch_at(notes) + notes.fresh end - private + def target + return @target if defined?(@target) - def init_collection - @notes = - if @params[:target_id] - on_target(@params[:target_type], @params[:target_id]) + target_type = @params[:target_type] + target_id = @params[:target_id] + + return @target = nil unless target_type && target_id + + @target = + if target_type == "commit" + if Ability.allowed?(@current_user, :download_code, @project) + @project.commit(target_id) + end else - notes_of_any_type + noteables_for_type(target_type).find(target_id) end end + private + + def init_collection + if target + notes_on_target + else + notes_of_any_type + end + end + def notes_of_any_type types = %w(commit issue merge_request snippet) note_relations = types.map { |t| notes_for_type(t) } - note_relations.map!{ |notes| search(@params[:search], notes) } if @params[:search] + note_relations.map! { |notes| search(notes) } UnionFinder.new.find_union(note_relations, Note) end @@ -69,17 +86,11 @@ class NotesFinder end end - def on_target(target_type, target_id) - if target_type == "commit" - notes_for_type('commit').for_commit_id(target_id) + def notes_on_target + if target.respond_to?(:related_notes) + target.related_notes else - target = noteables_for_type(target_type).find(target_id) - - if target.respond_to?(:related_notes) - target.related_notes - else - target.notes - end + target.notes end end @@ -87,17 +98,21 @@ class NotesFinder # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. # - def search(query, notes_relation = @notes) + def search(notes) + query = @params[:search] + return notes unless query + pattern = "%#{query}%" - notes_relation.where(Note.arel_table[:note].matches(pattern)) + notes.where(Note.arel_table[:note].matches(pattern)) end # Notes changed since last fetch # Uses overlapping intervals to avoid worrying about race conditions - def since_fetch_at(fetch_time) + def since_fetch_at(notes) + return notes unless @params[:last_fetched_at] + # Default to 0 to remain compatible with old clients last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i) - - @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh + notes.updated_after(last_fetched_at - FETCH_OVERLAP) end end diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 3fc85dc6b2b..b7a28b1b4a7 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -1,6 +1,6 @@ module BranchesHelper def can_remove_branch?(project, branch_name) - if project.protected_branch? branch_name + if ProtectedBranch.protected?(project, branch_name) false elsif branch_name == project.repository.root_ref false @@ -29,4 +29,8 @@ module BranchesHelper def project_branches options_for_select(@project.repository.branch_names, @project.default_branch) end + + def protected_branch?(project, branch) + ProtectedBranch.protected?(project, branch.name) + end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index aed1d7c839f..5e0886cc599 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -62,19 +62,19 @@ module DiffHelper end def parallel_diff_discussions(left, right, diff_file) - discussion_left = discussion_right = nil + discussions_left = discussions_right = nil if left && (left.unchanged? || left.removed?) line_code = diff_file.line_code(left) - discussion_left = @grouped_diff_discussions[line_code] + discussions_left = @grouped_diff_discussions[line_code] end if right && right.added? line_code = diff_file.line_code(right) - discussion_right = @grouped_diff_discussions[line_code] + discussions_right = @grouped_diff_discussions[line_code] end - [discussion_left, discussion_right] + [discussions_left, discussions_right] end def inline_diff_btn diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index b0331f36a2f..5f3d89cf6cb 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -24,57 +24,24 @@ module NotesHelper end def diff_view_data - return {} unless @comments_target + return {} unless @new_diff_note_attrs - @comments_target.slice(:noteable_id, :noteable_type, :commit_id) + @new_diff_note_attrs.slice(:noteable_id, :noteable_type, :commit_id) end def diff_view_line_data(line_code, position, line_type) return if @diff_notes_disabled - use_legacy_diff_note = @use_legacy_diff_notes - # If the controller doesn't force the use of legacy diff notes, we - # determine this on a line-by-line basis by seeing if there already exist - # active legacy diff notes at this line, in which case newly created notes - # will use the legacy technology as well. - # We do this because the discussion_id values of legacy and "new" diff - # notes, which are used to group notes on the merge request discussion tab, - # are incompatible. - # If we didn't, diff notes that would show for the same line on the changes - # tab, would show in different discussions on the discussion tab. - use_legacy_diff_note ||= begin - discussion = @grouped_diff_discussions[line_code] - discussion && discussion.legacy_diff_discussion? - end - data = { line_code: line_code, line_type: line_type, } - if use_legacy_diff_note - discussion_id = LegacyDiffNote.discussion_id( - @comments_target[:noteable_type], - @comments_target[:noteable_id] || @comments_target[:commit_id], - line_code - ) - - data.merge!( - note_type: LegacyDiffNote.name, - discussion_id: discussion_id - ) + if @use_legacy_diff_notes + data[:note_type] = LegacyDiffNote.name else - discussion_id = DiffNote.discussion_id( - @comments_target[:noteable_type], - @comments_target[:noteable_id] || @comments_target[:commit_id], - position - ) - - data.merge!( - position: position.to_json, - note_type: DiffNote.name, - discussion_id: discussion_id - ) + data[:note_type] = DiffNote.name + data[:position] = position.to_json end data @@ -83,21 +50,12 @@ module NotesHelper def link_to_reply_discussion(discussion, line_type = nil) return unless current_user - data = discussion.reply_attributes.merge(line_type: line_type) + data = { discussion_id: discussion.id, line_type: line_type } button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply' end - def preload_max_access_for_authors(notes, project) - user_ids = notes.map(&:author_id) - project.team.max_member_access_for_user_ids(user_ids) - end - - def preload_noteable_for_regular_notes(notes) - ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable) - end - def note_max_access_for_user(note) note.project.team.human_max_access(note.author_id) end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 3074921caff..1ea60e39386 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -7,7 +7,7 @@ module SystemNoteHelper 'closed' => 'icon_status_closed', 'time_tracking' => 'icon_stopwatch', 'assignee' => 'icon_user', - 'title' => 'icon_pencil', + 'title' => 'icon_edit', 'task' => 'icon_check_square_o', 'label' => 'icon_tags', 'cross_reference' => 'icon_random', diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb index c0ec1634cdb..31aaf9e5607 100644 --- a/app/helpers/tags_helper.rb +++ b/app/helpers/tags_helper.rb @@ -21,4 +21,8 @@ module TagsHelper html.html_safe end + + def protected_tag?(project, tag) + ProtectedTag.protected?(project, tag.name) + end end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 169cedeb796..b4aaf498068 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -85,7 +85,7 @@ module VisibilityLevelHelper end def restricted_visibility_levels(show_all = false) - return [] if current_user.is_admin? && !show_all + return [] if current_user.admin? && !show_all current_application_settings.restricted_visibility_levels || [] end diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 46fa6fd9f6d..00707a0023e 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -4,13 +4,8 @@ module Emails setup_note_mail(note_id, recipient_id) @commit = @note.noteable - @discussion = @note.to_discussion if @note.diff_note? @target_url = namespace_project_commit_url(*note_target_url_options) - - mail_answer_thread(@commit, - from: sender(@note.author_id), - to: recipient(recipient_id), - subject: subject("#{@commit.title} (#{@commit.short_id})")) + mail_answer_thread(@commit, note_thread_options(recipient_id)) end def note_issue_email(recipient_id, note_id) @@ -25,7 +20,6 @@ module Emails setup_note_mail(note_id, recipient_id) @merge_request = @note.noteable - @discussion = @note.to_discussion if @note.diff_note? @target_url = namespace_project_merge_request_url(*note_target_url_options) mail_answer_thread(@merge_request, note_thread_options(recipient_id)) end @@ -56,15 +50,18 @@ module Emails { from: sender(@note.author_id), to: recipient(recipient_id), - subject: subject("#{@note.noteable.title} (#{@note.noteable.to_reference})") + subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})") } end def setup_note_mail(note_id, recipient_id) - @note = Note.find(note_id) + # `note_id` is a `Note` when originating in `NotifyPreview` + @note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @project = @note.project - @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) + if @project && @note.persisted? + @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) + end end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 14df6f8f0a3..f315e38bcaa 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -111,7 +111,7 @@ class Notify < BaseMailer headers["X-GitLab-#{model.class.name}-ID"] = model.id headers['X-GitLab-Reply-Key'] = reply_key - if Gitlab::IncomingEmail.enabled? + if Gitlab::IncomingEmail.enabled? && @sent_notification address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) address.display_name = @project.name_with_namespace @@ -176,6 +176,6 @@ class Notify < BaseMailer end headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',') - @sent_notification_url = unsubscribe_sent_notification_url(@sent_notification) + @unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification) end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index c197e50ed69..445247f1b41 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -4,9 +4,14 @@ module Ci include HasStatus include Importable include AfterCommitQueue + include Presentable belongs_to :project belongs_to :user + belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' + + has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' + has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id has_many :builds, foreign_key: :commit_id @@ -26,7 +31,6 @@ module Ci validate :valid_commit_sha, unless: :importing? after_create :keep_around_commits, unless: :importing? - after_create :refresh_build_status_cache state_machine :status, initial: :created do event :enqueue do @@ -71,6 +75,10 @@ module Ci pipeline.update_duration end + before_transition canceled: any - [:canceled] do |pipeline| + pipeline.auto_canceled_by = nil + end + after_transition [:created, :pending] => :running do |pipeline| pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } end @@ -216,9 +224,24 @@ module Ci cancelable_statuses.any? end + def auto_canceled? + canceled? && auto_canceled_by_id? + end + def cancel_running Gitlab::OptimisticLocking.retry_lock(cancelable_statuses) do |cancelable| - cancelable.find_each(&:cancel) + cancelable.find_each do |job| + yield(job) if block_given? + job.cancel + end + end + end + + def auto_cancel_running(pipeline) + update(auto_canceled_by: pipeline) + + cancel_running do |job| + job.auto_canceled_by = pipeline end end @@ -327,7 +350,6 @@ module Ci when 'manual' then block end end - refresh_build_status_cache end def predefined_variables @@ -369,10 +391,6 @@ module Ci .fabricate! end - def refresh_build_status_cache - Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed - end - private def pipeline_data diff --git a/app/models/ci/pipeline_status.rb b/app/models/ci/pipeline_status.rb deleted file mode 100644 index 048047d0e34..00000000000 --- a/app/models/ci/pipeline_status.rb +++ /dev/null @@ -1,86 +0,0 @@ -# This class is not backed by a table in the main database. -# It loads the latest Pipeline for the HEAD of a repository, and caches that -# in Redis. -module Ci - class PipelineStatus - attr_accessor :sha, :status, :project, :loaded - - delegate :commit, to: :project - - def self.load_for_project(project) - new(project).tap do |status| - status.load_status - end - end - - def initialize(project, sha: nil, status: nil) - @project = project - @sha = sha - @status = status - end - - def has_status? - loaded? && sha.present? && status.present? - end - - def load_status - return if loaded? - - if has_cache? - load_from_cache - else - load_from_commit - store_in_cache - end - - self.loaded = true - end - - def load_from_commit - return unless commit - - self.sha = commit.sha - self.status = commit.status - end - - # We only cache the status for the HEAD commit of a project - # This status is rendered in project lists - def store_in_cache_if_needed - return unless sha - return delete_from_cache unless commit - store_in_cache if commit.sha == self.sha - end - - def load_from_cache - Gitlab::Redis.with do |redis| - self.sha, self.status = redis.hmget(cache_key, :sha, :status) - end - end - - def store_in_cache - Gitlab::Redis.with do |redis| - redis.mapped_hmset(cache_key, { sha: sha, status: status }) - end - end - - def delete_from_cache - Gitlab::Redis.with do |redis| - redis.del(cache_key) - end - end - - def has_cache? - Gitlab::Redis.with do |redis| - redis.exists(cache_key) - end - end - - def loaded? - self.loaded - end - - def cache_key - "projects/#{project.id}/build_status" - end - end -end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 0a89f3e0640..b59e235c425 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -14,6 +14,8 @@ module Ci before_validation :set_default_values + accepts_nested_attributes_for :trigger_schedule + def set_default_values self.token = SecureRandom.hex(15) if self.token.blank? end @@ -37,5 +39,9 @@ module Ci def can_access_project? self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project) end + + def trigger_schedule + super || build_trigger_schedule(project: project) + end end end diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb index 10ea381ee31..012a18eb439 100644 --- a/app/models/ci/trigger_schedule.rb +++ b/app/models/ci/trigger_schedule.rb @@ -8,15 +8,19 @@ module Ci belongs_to :project belongs_to :trigger - delegate :ref, to: :trigger - validates :trigger, presence: { unless: :importing? } - validates :cron, cron: true, presence: { unless: :importing? } - validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? } - validates :ref, presence: { unless: :importing? } + validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? } + validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? } + validates :ref, presence: { unless: :importing_or_inactive? } before_save :set_next_run_at + scope :active, -> { where(active: true) } + + def importing_or_inactive? + importing? || !active? + end + def set_next_run_at self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now) end @@ -26,5 +30,12 @@ module Ci rescue ActiveRecord::RecordInvalid update_attribute(:next_run_at, nil) # update without validation end + + def real_next_run( + worker_cron: Settings.cron_jobs['trigger_schedule_worker']['cron'], + worker_time_zone: Time.zone.name) + Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone) + .next_time_from(next_run_at) + end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index ce92cc369ad..5c452f78546 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -2,6 +2,7 @@ class Commit extend ActiveModel::Naming include ActiveModel::Conversion + include Noteable include Participable include Mentionable include Referable @@ -203,6 +204,10 @@ class Commit project.notes.for_commit_id(self.id) end + def discussion_notes + notes.non_diff_notes + end + def notes_with_associations notes.includes(:author) end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 17b322b5ae3..2c4033146bf 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -7,6 +7,7 @@ class CommitStatus < ActiveRecord::Base belongs_to :project belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id + belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline' belongs_to :user delegate :commit, to: :pipeline @@ -137,6 +138,10 @@ class CommitStatus < ActiveRecord::Base false end + def auto_canceled? + canceled? && auto_canceled_by_id? + end + # Added in 9.0 to keep backward compatibility for projects exported in 8.17 # and prior. def gl_project_id diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb new file mode 100644 index 00000000000..87db0c810c3 --- /dev/null +++ b/app/models/concerns/discussion_on_diff.rb @@ -0,0 +1,57 @@ +# Contains functionality shared between `DiffDiscussion` and `LegacyDiffDiscussion`. +module DiscussionOnDiff + extend ActiveSupport::Concern + + included do + NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + + memoized_values << :active + + delegate :line_code, + :original_line_code, + :diff_file, + :diff_line, + :for_line?, + :active?, + + to: :first_note + + delegate :file_path, + :blob, + :highlighted_diff_lines, + :diff_lines, + + to: :diff_file, + allow_nil: true + end + + def diff_discussion? + true + end + + def active? + return @active if @active.present? + + @active = first_note.active? + end + + # Returns an array of at most 16 highlighted lines above a diff note + def truncated_diff_lines(highlight: true) + lines = highlight ? highlighted_diff_lines : diff_lines + prev_lines = [] + + lines.each do |line| + if line.meta? + prev_lines.clear + else + prev_lines << line + + break if for_line?(line) + + prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES + end + end + + prev_lines + end +end diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb index 0a1a65da05a..dff7b6e3523 100644 --- a/app/models/concerns/has_status.rb +++ b/app/models/concerns/has_status.rb @@ -68,7 +68,7 @@ module HasStatus end scope :created, -> { where(status: 'created') } - scope :relevant, -> { where.not(status: 'created') } + scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) } scope :running, -> { where(status: 'running') } scope :pending, -> { where(status: 'pending') } scope :success, -> { where(status: 'success') } @@ -76,6 +76,7 @@ module HasStatus scope :canceled, -> { where(status: 'canceled') } scope :skipped, -> { where(status: 'skipped') } scope :manual, -> { where(status: 'manual') } + scope :created_or_pending, -> { where(status: [:created, :pending]) } scope :running_or_pending, -> { where(status: [:running, :pending]) } scope :finished, -> { where(status: [:success, :failed, :canceled]) } scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) } diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb new file mode 100644 index 00000000000..eb9f3423e48 --- /dev/null +++ b/app/models/concerns/ignorable_column.rb @@ -0,0 +1,28 @@ +# Module that can be included into a model to make it easier to ignore database +# columns. +# +# Example: +# +# class User < ActiveRecord::Base +# include IgnorableColumn +# +# ignore_column :updated_at +# end +# +module IgnorableColumn + extend ActiveSupport::Concern + + module ClassMethods + def columns + super.reject { |column| ignored_columns.include?(column.name) } + end + + def ignored_columns + @ignored_columns ||= Set.new + end + + def ignore_column(name) + ignored_columns << name.to_s + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index b4dded7e27e..3d2258d5e3e 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -292,17 +292,6 @@ module Issuable self.class.to_ability_name end - # Convert this Issuable class name to a format usable by notifications. - # - # Examples: - # - # issuable.class # => MergeRequest - # issuable.human_class_name # => "merge request" - - def human_class_name - @human_class_name ||= self.class.name.titleize.downcase - end - # Returns a Hash of attributes to be used for Twitter card metadata def card_attributes { diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index b8dd27a7afe..1a5a7007a2b 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -1,3 +1,4 @@ +# Contains functionality shared between `DiffNote` and `LegacyDiffNote`. module NoteOnDiff extend ActiveSupport::Concern @@ -24,12 +25,4 @@ module NoteOnDiff def diff_attributes raise NotImplementedError end - - def can_be_award_emoji? - false - end - - def to_discussion - Discussion.new([self]) - end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb new file mode 100644 index 00000000000..772ff6a6d2f --- /dev/null +++ b/app/models/concerns/noteable.rb @@ -0,0 +1,68 @@ +module Noteable + # Names of all implementers of `Noteable` that support resolvable notes. + RESOLVABLE_TYPES = %w(MergeRequest).freeze + + def base_class_name + self.class.base_class.name + end + + # Convert this Noteable class name to a format usable by notifications. + # + # Examples: + # + # noteable.class # => MergeRequest + # noteable.human_class_name # => "merge request" + def human_class_name + @human_class_name ||= base_class_name.titleize.downcase + end + + def supports_resolvable_notes? + RESOLVABLE_TYPES.include?(base_class_name) + end + + def supports_discussions? + DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) + end + + def discussion_notes + notes + end + + delegate :find_discussion, to: :discussion_notes + + def discussions + @discussions ||= discussion_notes + .inc_relations_for_view + .discussions(self) + end + + def grouped_diff_discussions + # Doesn't use `discussion_notes`, because this may include commit diff notes + # besides MR diff notes, that we do no want to display on the MR Changes tab. + notes.inc_relations_for_view.grouped_diff_discussions + end + + def resolvable_discussions + @resolvable_discussions ||= discussion_notes.resolvable.discussions(self) + end + + def discussions_resolvable? + resolvable_discussions.any?(&:resolvable?) + end + + def discussions_resolved? + discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?) + end + + def discussions_to_be_resolved? + discussions_resolvable? && !discussions_resolved? + end + + def discussions_to_be_resolved + @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?) + end + + def discussions_can_be_resolved_by?(user) + discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) } + end +end diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb index 9dd4d9c6f24..c41b807df8a 100644 --- a/app/models/concerns/protected_branch_access.rb +++ b/app/models/concerns/protected_branch_access.rb @@ -2,20 +2,10 @@ module ProtectedBranchAccess extend ActiveSupport::Concern included do - belongs_to :protected_branch - delegate :project, to: :protected_branch - - scope :master, -> { where(access_level: Gitlab::Access::MASTER) } - scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) } - end + include ProtectedRefAccess - def humanize - self.class.human_access_levels[self.access_level] - end - - def check_access(user) - return true if user.is_admin? + belongs_to :protected_branch - project.team.max_member_access(user.id) >= access_level + delegate :project, to: :protected_branch end end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb new file mode 100644 index 00000000000..62eaec2407f --- /dev/null +++ b/app/models/concerns/protected_ref.rb @@ -0,0 +1,42 @@ +module ProtectedRef + extend ActiveSupport::Concern + + included do + belongs_to :project + + validates :name, presence: true + validates :project, presence: true + + delegate :matching, :matches?, :wildcard?, to: :ref_matcher + + def self.protected_ref_accessible_to?(ref, user, action:) + access_levels_for_ref(ref, action: action).any? do |access_level| + access_level.check_access(user) + end + end + + def self.developers_can?(action, ref) + access_levels_for_ref(ref, action: action).any? do |access_level| + access_level.access_level == Gitlab::Access::DEVELOPER + end + end + + def self.access_levels_for_ref(ref, action:) + self.matching(ref).map(&:"#{action}_access_levels").flatten + end + + def self.matching(ref_name, protected_refs: nil) + ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs) + end + end + + def commit + project.commit(self.name) + end + + private + + def ref_matcher + @ref_matcher ||= ProtectedRefMatcher.new(self) + end +end diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb new file mode 100644 index 00000000000..c4f158e569a --- /dev/null +++ b/app/models/concerns/protected_ref_access.rb @@ -0,0 +1,18 @@ +module ProtectedRefAccess + extend ActiveSupport::Concern + + included do + scope :master, -> { where(access_level: Gitlab::Access::MASTER) } + scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) } + end + + def humanize + self.class.human_access_levels[self.access_level] + end + + def check_access(user) + return true if user.admin? + + project.team.max_member_access(user.id) >= access_level + end +end diff --git a/app/models/concerns/protected_tag_access.rb b/app/models/concerns/protected_tag_access.rb new file mode 100644 index 00000000000..ee65de24dd8 --- /dev/null +++ b/app/models/concerns/protected_tag_access.rb @@ -0,0 +1,11 @@ +module ProtectedTagAccess + extend ActiveSupport::Concern + + included do + include ProtectedRefAccess + + belongs_to :protected_tag + + delegate :project, to: :protected_tag + end +end diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb new file mode 100644 index 00000000000..dd979e7bb17 --- /dev/null +++ b/app/models/concerns/resolvable_discussion.rb @@ -0,0 +1,103 @@ +module ResolvableDiscussion + extend ActiveSupport::Concern + + included do + # A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized. + # When this discussion is resolved or unresolved, the values of these properties potentially change. + # To make sure all memoized values are reset when this happens, `update` resets all instance variables with names in + # `memoized_variables`. If you add a memoized method in `ResolvableDiscussion` or any `Discussion` subclass, + # please make sure the instance variable name is added to `memoized_values`, like below. + cattr_accessor :memoized_values, instance_accessor: false do + [] + end + + memoized_values.push( + :resolvable, + :resolved, + :first_note, + :first_note_to_resolve, + :last_resolved_note, + :last_note + ) + + delegate :potentially_resolvable?, to: :first_note + + delegate :resolved_at, + :resolved_by, + + to: :last_resolved_note, + allow_nil: true + end + + def resolvable? + return @resolvable if @resolvable.present? + + @resolvable = potentially_resolvable? && notes.any?(&:resolvable?) + end + + def resolved? + return @resolved if @resolved.present? + + @resolved = resolvable? && notes.none?(&:to_be_resolved?) + end + + def first_note + @first_note ||= notes.first + end + + def first_note_to_resolve + return unless resolvable? + + @first_note_to_resolve ||= notes.find(&:to_be_resolved?) + end + + def last_resolved_note + return unless resolved? + + @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + end + + def resolved_notes + notes.select(&:resolved?) + end + + def to_be_resolved? + resolvable? && !resolved? + end + + def can_resolve?(current_user) + return false unless current_user + return false unless resolvable? + + current_user == self.noteable.author || + current_user.can?(:resolve_note, self.project) + end + + def resolve!(current_user) + return unless resolvable? + + update { |notes| notes.resolve!(current_user) } + end + + def unresolve! + return unless resolvable? + + update { |notes| notes.unresolve! } + end + + private + + def update + # Do not select `Note.resolvable`, so that system notes remain in the collection + notes_relation = Note.where(id: notes.map(&:id)) + + yield(notes_relation) + + # Set the notes array to the updated notes + @notes = notes_relation.fresh.to_a + + self.class.memoized_values.each do |var| + instance_variable_set(:"@#{var}", nil) + end + end +end diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb new file mode 100644 index 00000000000..05eb6f86704 --- /dev/null +++ b/app/models/concerns/resolvable_note.rb @@ -0,0 +1,72 @@ +module ResolvableNote + extend ActiveSupport::Concern + + # Names of all subclasses of `Note` that can be resolvable. + RESOLVABLE_TYPES = %w(DiffNote DiscussionNote).freeze + + included do + belongs_to :resolved_by, class_name: "User" + + validates :resolved_by, presence: true, if: :resolved? + + # Keep this scope in sync with `#potentially_resolvable?` + scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) } + # Keep this scope in sync with `#resolvable?` + scope :resolvable, -> { potentially_resolvable.user } + + scope :resolved, -> { resolvable.where.not(resolved_at: nil) } + scope :unresolved, -> { resolvable.where(resolved_at: nil) } + end + + module ClassMethods + # This method must be kept in sync with `#resolve!` + def resolve!(current_user) + unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) + end + + # This method must be kept in sync with `#unresolve!` + def unresolve! + resolved.update_all(resolved_at: nil, resolved_by_id: nil) + end + end + + # Keep this method in sync with the `potentially_resolvable` scope + def potentially_resolvable? + RESOLVABLE_TYPES.include?(self.class.name) && noteable.supports_resolvable_notes? + end + + # Keep this method in sync with the `resolvable` scope + def resolvable? + potentially_resolvable? && !system? + end + + def resolved? + return false unless resolvable? + + self.resolved_at.present? + end + + def to_be_resolved? + resolvable? && !resolved? + end + + # If you update this method remember to also update `.resolve!` + def resolve!(current_user) + return unless resolvable? + return if resolved? + + self.resolved_at = Time.now + self.resolved_by = current_user + save! + end + + # If you update this method remember to also update `.unresolve!` + def unresolve! + return unless resolvable? + return unless resolved? + + self.resolved_at = nil + self.resolved_by = nil + save! + end +end diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb new file mode 100644 index 00000000000..d9b7e484e0f --- /dev/null +++ b/app/models/diff_discussion.rb @@ -0,0 +1,26 @@ +# A discussion on merge request or commit diffs consisting of `DiffNote` notes. +# +# A discussion of this type can be resolvable. +class DiffDiscussion < Discussion + include DiscussionOnDiff + + def self.note_class + DiffNote + end + + delegate :position, + :original_position, + + to: :first_note + + def legacy_diff_discussion? + false + end + + def reply_attributes + super.merge( + original_position: original_position.to_json, + position: position.to_json, + ) + end +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 895a91139c9..1523244f8a8 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -1,6 +1,11 @@ +# A note on merge request or commit diffs +# +# A note of this type can be resolvable. class DiffNote < Note include NoteOnDiff + NOTEABLE_TYPES = %w(MergeRequest Commit).freeze + serialize :original_position, Gitlab::Diff::Position serialize :position, Gitlab::Diff::Position @@ -8,59 +13,31 @@ class DiffNote < Note validates :position, presence: true validates :diff_line, presence: true validates :line_code, presence: true, line_code: true - validates :noteable_type, inclusion: { in: %w(Commit MergeRequest) } - validates :resolved_by, presence: true, if: :resolved? + validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } validate :positions_complete validate :verify_supported - # Keep this scope in sync with the logic in `#resolvable?` - scope :resolvable, -> { user.where(noteable_type: 'MergeRequest') } - scope :resolved, -> { resolvable.where.not(resolved_at: nil) } - scope :unresolved, -> { resolvable.where(resolved_at: nil) } - - after_initialize :ensure_original_discussion_id before_validation :set_original_position, :update_position, on: :create - before_validation :set_line_code, :set_original_discussion_id - # We need to do this again, because it's already in `Note`, but is affected by - # `update_position` and needs to run after that. - before_validation :set_discussion_id + before_validation :set_line_code after_save :keep_around_commits - class << self - def build_discussion_id(noteable_type, noteable_id, position) - [super(noteable_type, noteable_id), *position.key].join("-") - end - - # This method must be kept in sync with `#resolve!` - def resolve!(current_user) - unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id) - end - - # This method must be kept in sync with `#unresolve!` - def unresolve! - resolved.update_all(resolved_at: nil, resolved_by_id: nil) - end - end - - def new_diff_note? - true + def discussion_class(*) + DiffDiscussion end - def diff_attributes - { position: position.to_json } - end + %i(original_position position).each do |meth| + define_method "#{meth}=" do |new_position| + if new_position.is_a?(String) + new_position = JSON.parse(new_position) rescue nil + end - def position=(new_position) - if new_position.is_a?(String) - new_position = JSON.parse(new_position) rescue nil - end + if new_position.is_a?(Hash) + new_position = new_position.with_indifferent_access + new_position = Gitlab::Diff::Position.new(new_position) + end - if new_position.is_a?(Hash) - new_position = new_position.with_indifferent_access - new_position = Gitlab::Diff::Position.new(new_position) + super(new_position) end - - super(new_position) end def diff_file @@ -88,43 +65,6 @@ class DiffNote < Note self.position.diff_refs == diff_refs end - # If you update this method remember to also update the scope `resolvable` - def resolvable? - !system? && for_merge_request? - end - - def resolved? - return false unless resolvable? - - self.resolved_at.present? - end - - # If you update this method remember to also update `.resolve!` - def resolve!(current_user) - return unless resolvable? - return if resolved? - - self.resolved_at = Time.now - self.resolved_by = current_user - save! - end - - # If you update this method remember to also update `.unresolve!` - def unresolve! - return unless resolvable? - return unless resolved? - - self.resolved_at = nil - self.resolved_by = nil - save! - end - - def discussion - return unless resolvable? - - self.noteable.find_diff_discussion(self.discussion_id) - end - private def supported? @@ -140,33 +80,13 @@ class DiffNote < Note end def set_original_position - self.original_position = self.position.dup + self.original_position = self.position.dup unless self.original_position&.complete? end def set_line_code self.line_code = self.position.line_code(self.project.repository) end - def ensure_original_discussion_id - return unless self.persisted? - return if self.original_discussion_id - - set_original_discussion_id - update_column(:original_discussion_id, self.original_discussion_id) - end - - def set_original_discussion_id - self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id) - end - - def build_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position) - end - - def build_original_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position) - end - def update_position return unless supported? return if for_commit? diff --git a/app/models/discussion.rb b/app/models/discussion.rb index bbe813db823..0b6b920ed66 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -1,7 +1,10 @@ +# A non-diff discussion on an issue, merge request, commit, or snippet, consisting of `DiscussionNote` notes. +# +# A discussion of this type can be resolvable. class Discussion - NUMBER_OF_TRUNCATED_DIFF_LINES = 16 + include ResolvableDiscussion - attr_reader :notes + attr_reader :notes, :context_noteable delegate :created_at, :project, @@ -11,43 +14,62 @@ class Discussion :for_commit?, :for_merge_request?, - :line_code, - :original_line_code, - :diff_file, - :for_line?, - :active?, - to: :first_note - delegate :resolved_at, - :resolved_by, + def self.build(notes, context_noteable = nil) + notes.first.discussion_class(context_noteable).new(notes, context_noteable) + end - to: :last_resolved_note, - allow_nil: true + def self.build_collection(notes, context_noteable = nil) + notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) } + end - delegate :blob, - :highlighted_diff_lines, - :diff_lines, + # Returns an alphanumeric discussion ID based on `build_discussion_id` + def self.discussion_id(note) + Digest::SHA1.hexdigest(build_discussion_id(note).join("-")) + end - to: :diff_file, - allow_nil: true + # Returns an array of discussion ID components + def self.build_discussion_id(note) + [*base_discussion_id(note), SecureRandom.hex] + end - def self.for_notes(notes) - notes.group_by(&:discussion_id).values.map { |notes| new(notes) } + def self.base_discussion_id(note) + noteable_id = note.noteable_id || note.commit_id + [:discussion, note.noteable_type.try(:underscore), noteable_id] end - def self.for_diff_notes(notes) - notes.group_by(&:line_code).values.map { |notes| new(notes) } + # When notes on a commit are displayed in context of a merge request that contains that commit, + # these notes are to be displayed as if they were part of one discussion, even though they were actually + # individual notes on the commit with different discussion IDs, so that it's clear that these are not + # notes on the merge request itself. + # + # To turn a list of notes into a list of discussions, they are grouped by discussion ID, so to + # get these out-of-context notes to end up in the same discussion, we need to get them to return the same + # `discussion_id` when this grouping happens. To enable this, `Note#discussion_id` calls out + # to the `override_discussion_id` method on the appropriate `Discussion` subclass, as determined by + # the `discussion_class` method on `Note` or a subclass of `Note`. + # + # If no override is necessary, return `nil`. + # For the case described above, see `OutOfContextDiscussion.override_discussion_id`. + def self.override_discussion_id(note) + nil end - def initialize(notes) - @notes = notes + def self.note_class + DiscussionNote end - def last_resolved_note - return unless resolved? + def initialize(notes, context_noteable = nil) + @notes = notes + @context_noteable = context_noteable + end - @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last + def ==(other) + other.class == self.class && + other.context_noteable == self.context_noteable && + other.id == self.id && + other.notes == self.notes end def last_updated_at @@ -59,91 +81,29 @@ class Discussion end def id - first_note.discussion_id + first_note.discussion_id(context_noteable) end alias_method :to_param, :id def diff_discussion? - first_note.diff_note? - end - - def legacy_diff_discussion? - notes.any?(&:legacy_diff_note?) + false end - def resolvable? - return @resolvable if @resolvable.present? - - @resolvable = diff_discussion? && notes.any?(&:resolvable?) + def individual_note? + false end - def resolved? - return @resolved if @resolved.present? - - @resolved = resolvable? && notes.none?(&:to_be_resolved?) - end - - def first_note - @first_note ||= @notes.first - end - - def first_note_to_resolve - @first_note_to_resolve ||= notes.detect(&:to_be_resolved?) + def new_discussion? + notes.length == 1 end def last_note - @last_note ||= @notes.last - end - - def resolved_notes - notes.select(&:resolved?) - end - - def to_be_resolved? - resolvable? && !resolved? - end - - def can_resolve?(current_user) - return false unless current_user - return false unless resolvable? - - current_user == self.noteable.author || - current_user.can?(:resolve_note, self.project) - end - - def resolve!(current_user) - return unless resolvable? - - update { |notes| notes.resolve!(current_user) } - end - - def unresolve! - return unless resolvable? - - update { |notes| notes.unresolve! } - end - - def for_target?(target) - self.noteable == target && !diff_discussion? - end - - def active? - return @active if @active.present? - - @active = first_note.active? + @last_note ||= notes.last end def collapsed? - return false unless diff_discussion? - - if resolvable? - # New diff discussions only disappear once they are marked resolved - resolved? - else - # Old diff discussions disappear once they become outdated - !active? - end + resolved? end def expanded? @@ -151,52 +111,6 @@ class Discussion end def reply_attributes - data = { - noteable_type: first_note.noteable_type, - noteable_id: first_note.noteable_id, - commit_id: first_note.commit_id, - discussion_id: self.id, - } - - if diff_discussion? - data[:note_type] = first_note.type - - data.merge!(first_note.diff_attributes) - end - - data - end - - # Returns an array of at most 16 highlighted lines above a diff note - def truncated_diff_lines(highlight: true) - lines = highlight ? highlighted_diff_lines : diff_lines - prev_lines = [] - - lines.each do |line| - if line.meta? - prev_lines.clear - else - prev_lines << line - - break if for_line?(line) - - prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES - end - end - - prev_lines - end - - private - - def update - notes_relation = DiffNote.where(id: notes.map(&:id)).fresh - yield(notes_relation) - - # Set the notes array to the updated notes - @notes = notes_relation.to_a - - # Reset the memoized values - @last_resolved_note = @resolvable = @resolved = @first_note = @last_note = nil + first_note.slice(:type, :noteable_type, :noteable_id, :commit_id, :discussion_id) end end diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb new file mode 100644 index 00000000000..e660b024083 --- /dev/null +++ b/app/models/discussion_note.rb @@ -0,0 +1,13 @@ +# A note in a non-diff discussion on an issue, merge request, commit, or snippet. +# +# A note of this type can be resolvable. +class DiscussionNote < Note + # Names of all implementers of `Noteable` that support discussions. + NOTEABLE_TYPES = %w(MergeRequest Issue Commit Snippet).freeze + + validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } + + def discussion_class(*) + Discussion + end +end diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb new file mode 100644 index 00000000000..c3f21c55240 --- /dev/null +++ b/app/models/individual_note_discussion.rb @@ -0,0 +1,13 @@ +# A discussion to wrap a single `Note` note on the root of an issue, merge request, +# commit, or snippet, that is not displayed as a discussion. +# +# A discussion of this type is never resolvable. +class IndividualNoteDiscussion < Discussion + def self.note_class + Note + end + + def individual_note? + true + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index f9704b0d754..d8d9db477d2 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord' class Issue < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable include Spammable diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb new file mode 100644 index 00000000000..cb2651a03f8 --- /dev/null +++ b/app/models/legacy_diff_discussion.rb @@ -0,0 +1,25 @@ +# A discussion on merge request or commit diffs consisting of `LegacyDiffNote` notes. +# +# All new diff discussions are of the type `DiffDiscussion`, but any diff discussions created +# before the introduction of the new implementation still use `LegacyDiffDiscussion`. +# +# A discussion of this type is never resolvable. +class LegacyDiffDiscussion < Discussion + include DiscussionOnDiff + + def legacy_diff_discussion? + true + end + + def self.note_class + LegacyDiffNote + end + + def collapsed? + !active? + end + + def reply_attributes + super.merge(line_code: line_code) + end +end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index 40277a9b139..9a77557ebcd 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -1,3 +1,9 @@ +# A note on merge request or commit diffs, using the legacy implementation. +# +# All new diff notes are of the type `DiffNote`, but any diff notes created +# before the introduction of the new implementation still use `LegacyDiffNote`. +# +# A note of this type is never resolvable. class LegacyDiffNote < Note include NoteOnDiff @@ -7,18 +13,8 @@ class LegacyDiffNote < Note before_create :set_diff - class << self - def build_discussion_id(noteable_type, noteable_id, line_code) - [super(noteable_type, noteable_id), line_code].join("-") - end - end - - def legacy_diff_note? - true - end - - def diff_attributes - { line_code: line_code } + def discussion_class(*) + LegacyDiffDiscussion end def project_repository @@ -119,8 +115,4 @@ class LegacyDiffNote < Note diffs = noteable.raw_diffs(Commit.max_diff_options) diffs.find { |d| d.new_path == self.diff.new_path } end - - def build_discussion_id - self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code) - end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 8d740adb771..b71a9e17a93 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1,6 +1,7 @@ class MergeRequest < ActiveRecord::Base include InternalId include Issuable + include Noteable include Referable include Sortable @@ -442,7 +443,7 @@ class MergeRequest < ActiveRecord::Base end def can_remove_source_branch?(current_user) - !source_project.protected_branch?(source_branch) && + !ProtectedBranch.protected?(source_project, source_branch) && !source_project.root_ref?(source_branch) && Ability.allowed?(current_user, :push_code, source_project) && diff_head_commit == source_branch_head @@ -475,43 +476,7 @@ class MergeRequest < ActiveRecord::Base ) end - def discussions - @discussions ||= self.related_notes. - inc_relations_for_view. - fresh. - discussions - end - - def diff_discussions - @diff_discussions ||= self.notes.diff_notes.discussions - end - - def resolvable_discussions - @resolvable_discussions ||= diff_discussions.select(&:to_be_resolved?) - end - - def discussions_can_be_resolved_by?(user) - resolvable_discussions.all? { |discussion| discussion.can_resolve?(user) } - end - - def find_diff_discussion(discussion_id) - notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a - return if notes.empty? - - Discussion.new(notes) - end - - def discussions_resolvable? - diff_discussions.any?(&:resolvable?) - end - - def discussions_resolved? - discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?) - end - - def discussions_to_be_resolved? - discussions_resolvable? && !discussions_resolved? - end + alias_method :discussion_notes, :related_notes def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? @@ -857,8 +822,8 @@ class MergeRequest < ActiveRecord::Base return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs - active_diff_notes = self.notes.diff_notes.select do |note| - note.new_diff_note? && note.active?(old_diff_refs) + active_diff_notes = self.notes.new_diff_notes.select do |note| + note.active?(old_diff_refs) end return if active_diff_notes.empty? diff --git a/app/models/milestone.rb b/app/models/milestone.rb index ac205b9b738..652b1551928 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -153,10 +153,6 @@ class Milestone < ActiveRecord::Base active? && issues.opened.count.zero? end - def is_empty?(user = nil) - total_items_count(user).zero? - end - def author_id nil end diff --git a/app/models/note.rb b/app/models/note.rb index 16d66cb1427..1ea7b946061 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -1,3 +1,6 @@ +# A note on the root of an issue, merge request, commit, or snippet. +# +# A note of this type is never resolvable. class Note < ActiveRecord::Base extend ActiveModel::Naming include Gitlab::CurrentSettings @@ -8,6 +11,10 @@ class Note < ActiveRecord::Base include FasterCacheKeys include CacheMarkdownField include AfterCommitQueue + include ResolvableNote + include IgnorableColumn + + ignore_column :original_discussion_id cache_markdown_field :note, pipeline: :note @@ -32,9 +39,6 @@ class Note < ActiveRecord::Base belongs_to :author, class_name: "User" belongs_to :updated_by, class_name: "User" - # Only used by DiffNote, but defined here so that it can be used in `Note.includes` - belongs_to :resolved_by, class_name: "User" - has_many :todos, dependent: :destroy has_many :events, as: :target, dependent: :destroy has_one :system_note_metadata @@ -54,10 +58,11 @@ class Note < ActiveRecord::Base validates :noteable_id, presence: true, unless: [:for_commit?, :importing?] validates :commit_id, presence: true, if: :for_commit? validates :author, presence: true + validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ } validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note| unless note.noteable.try(:project) == note.project - errors.add(:invalid_project, 'Note and noteable project mismatch') + errors.add(:project, 'does not match noteable project') end end @@ -69,6 +74,7 @@ class Note < ActiveRecord::Base scope :user, ->{ where(system: false) } scope :common, ->{ where(noteable_type: ["", nil]) } scope :fresh, ->{ order(created_at: :asc, id: :asc) } + scope :updated_after, ->(time){ where('updated_at > ?', time) } scope :inc_author_project, ->{ includes(:project, :author) } scope :inc_author, ->{ includes(:author) } scope :inc_relations_for_view, -> do @@ -76,7 +82,8 @@ class Note < ActiveRecord::Base end scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) } - scope :non_diff_notes, ->{ where(type: ['Note', nil]) } + scope :new_diff_notes, ->{ where(type: 'DiffNote') } + scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) } scope :with_associations, -> do # FYI noteable cannot be loaded for LegacyDiffNote for commits @@ -86,7 +93,7 @@ class Note < ActiveRecord::Base after_initialize :ensure_discussion_id before_validation :nullify_blank_type, :nullify_blank_line_code - before_validation :set_discussion_id + before_validation :set_discussion_id, on: :create after_save :keep_around_commit, unless: :for_personal_snippet? after_save :expire_etag_cache @@ -95,22 +102,23 @@ class Note < ActiveRecord::Base ActiveModel::Name.new(self, nil, 'note') end - def build_discussion_id(noteable_type, noteable_id) - [:discussion, noteable_type.try(:underscore), noteable_id].join("-") + def discussions(context_noteable = nil) + Discussion.build_collection(fresh, context_noteable) end - def discussion_id(*args) - Digest::SHA1.hexdigest(build_discussion_id(*args)) - end + def find_discussion(discussion_id) + notes = where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? - def discussions - Discussion.for_notes(fresh) + Discussion.build(notes) end def grouped_diff_discussions - active_notes = diff_notes.fresh.select(&:active?) - Discussion.for_diff_notes(active_notes). - map { |d| [d.line_code, d] }.to_h + diff_notes. + fresh. + discussions. + select(&:active?). + group_by(&:line_code) end def count_for_collection(ids, type) @@ -121,37 +129,17 @@ class Note < ActiveRecord::Base end def cross_reference? - system && SystemNoteService.cross_reference?(note) + system? && SystemNoteService.cross_reference?(note) end def diff_note? false end - def legacy_diff_note? - false - end - - def new_diff_note? - false - end - def active? true end - def resolvable? - false - end - - def resolved? - false - end - - def to_be_resolved? - resolvable? && !resolved? - end - def max_attachment_size current_application_settings.max_attachment_size.megabytes.to_i end @@ -228,7 +216,7 @@ class Note < ActiveRecord::Base end def can_be_award_emoji? - noteable.is_a?(Awardable) + noteable.is_a?(Awardable) && !part_of_discussion? end def contains_emoji_only? @@ -239,6 +227,63 @@ class Note < ActiveRecord::Base for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore end + def can_be_discussion_note? + self.noteable.supports_discussions? && !part_of_discussion? + end + + def discussion_class(noteable = nil) + # When commit notes are rendered on an MR's Discussion page, they are + # displayed in one discussion instead of individually. + # See also `#discussion_id` and `Discussion.override_discussion_id`. + if noteable && noteable != self.noteable + OutOfContextDiscussion + else + IndividualNoteDiscussion + end + end + + # See `Discussion.override_discussion_id` for details. + def discussion_id(noteable = nil) + discussion_class(noteable).override_discussion_id(self) || super() + end + + # Returns a discussion containing just this note. + # This method exists as an alternative to `#discussion` to use when the methods + # we intend to call on the Discussion object don't require it to have all of its notes, + # and just depend on the first note or the type of discussion. This saves us a DB query. + def to_discussion(noteable = nil) + Discussion.build([self], noteable) + end + + # Returns the entire discussion this note is part of. + # Consider using `#to_discussion` if we do not need to render the discussion + # and all its notes and if we don't care about the discussion's resolvability status. + def discussion + full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion? + full_discussion || to_discussion + end + + def part_of_discussion? + !to_discussion.individual_note? + end + + def in_reply_to?(other) + case other + when Note + if part_of_discussion? + in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion) + else + in_reply_to?(other.noteable) + end + when Discussion + self.discussion_id == other.id + when Noteable + self.noteable == other + else + false + end + end + private def keep_around_commit @@ -264,17 +309,7 @@ class Note < ActiveRecord::Base end def set_discussion_id - self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id) - end - - def build_discussion_id - if for_merge_request? - # Notes on merge requests are always in a discussion of their own, - # so we generate a unique discussion ID. - [:discussion, :note, SecureRandom.hex].join("-") - else - self.class.build_discussion_id(noteable_type, noteable_id || commit_id) - end + self.discussion_id ||= discussion_class.discussion_id(self) end def expire_etag_cache diff --git a/app/models/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb new file mode 100644 index 00000000000..85794630f70 --- /dev/null +++ b/app/models/out_of_context_discussion.rb @@ -0,0 +1,22 @@ +# When notes on a commit are displayed in the context of a merge request that +# contains that commit, they are displayed as if they were a discussion. +# +# This represents one of those discussions, consisting of `Note` notes. +# +# A discussion of this type is never resolvable. +class OutOfContextDiscussion < Discussion + # Returns an array of discussion ID components + def self.build_discussion_id(note) + base_discussion_id(note) + end + + # To make sure all out-of-context notes end up grouped as one discussion, + # we override the discussion ID to be a newly generated but consistent ID. + def self.override_discussion_id(note) + discussion_id(note) + end + + def self.note_class + Note + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 2b467f95087..a160efba912 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -135,6 +135,7 @@ class Project < ActiveRecord::Base has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet' has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy + has_many :protected_tags, dependent: :destroy has_many :project_authorizations has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User' @@ -262,6 +263,8 @@ class Project < ActiveRecord::Base scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } + enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } + # project features may be "disabled", "internal" or "enabled". If "internal", # they are only available to team members. This scope returns projects where # the feature is either enabled, or internal with permission for the user. @@ -857,14 +860,6 @@ class Project < ActiveRecord::Base @repo_exists = false end - # Branches that are not _exactly_ matched by a protected branch. - def open_branches - exact_protected_branch_names = protected_branches.reject(&:wildcard?).map(&:name) - branch_names = repository.branches.map(&:name) - non_open_branch_names = Set.new(exact_protected_branch_names).intersection(Set.new(branch_names)) - repository.branches.reject { |branch| non_open_branch_names.include? branch.name } - end - def root_ref?(branch) repository.root_ref == branch end @@ -879,16 +874,8 @@ class Project < ActiveRecord::Base Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url end - # Check if current branch name is marked as protected in the system - def protected_branch?(branch_name) - return true if empty_repo? && default_branch_protected? - - @protected_branches ||= self.protected_branches.to_a - ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present? - end - def user_can_push_to_empty_repo?(user) - !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER + !ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER end def forked? @@ -1195,7 +1182,7 @@ class Project < ActiveRecord::Base end def pipeline_status - @pipeline_status ||= Ci::PipelineStatus.load_for_project(self) + @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self) end def mark_import_as_failed(error_message) @@ -1351,11 +1338,6 @@ class Project < ActiveRecord::Base "projects/#{id}/pushes_since_gc" end - def default_branch_protected? - current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || - current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE - end - # Similar to the normal callbacks that hook into the life cycle of an # Active Record object, you can also define callbacks that get triggered # when you add an object to an association collection. If any of these diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 3b90fd1c2c7..97e997d3899 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -91,7 +91,7 @@ class JiraService < IssueTrackerService { type: 'text', name: 'project_key', placeholder: 'Project Key' }, { type: 'text', name: 'username', placeholder: '' }, { type: 'password', name: 'password', placeholder: '' }, - { type: 'text', name: 'jira_issue_transition_id', placeholder: '2' } + { type: 'text', name: 'jira_issue_transition_id', placeholder: '' } ] end diff --git a/app/models/protectable_dropdown.rb b/app/models/protectable_dropdown.rb new file mode 100644 index 00000000000..122fbce257d --- /dev/null +++ b/app/models/protectable_dropdown.rb @@ -0,0 +1,33 @@ +class ProtectableDropdown + def initialize(project, ref_type) + @project = project + @ref_type = ref_type + end + + # Tags/branches which are yet to be individually protected + def protectable_ref_names + @protectable_ref_names ||= ref_names - non_wildcard_protected_ref_names + end + + def hash + protectable_ref_names.map { |ref_name| { text: ref_name, id: ref_name, title: ref_name } } + end + + private + + def refs + @project.repository.public_send(@ref_type) + end + + def ref_names + refs.map(&:name) + end + + def protections + @project.public_send("protected_#{@ref_type}") + end + + def non_wildcard_protected_ref_names + protections.reject(&:wildcard?).map(&:name) + end +end diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 39e979ef15b..28b7d5ad072 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -1,9 +1,6 @@ class ProtectedBranch < ActiveRecord::Base include Gitlab::ShellAdapter - - belongs_to :project - validates :name, presence: true - validates :project, presence: true + include ProtectedRef has_many :merge_access_levels, dependent: :destroy has_many :push_access_levels, dependent: :destroy @@ -14,54 +11,15 @@ class ProtectedBranch < ActiveRecord::Base accepts_nested_attributes_for :push_access_levels accepts_nested_attributes_for :merge_access_levels - def commit - project.commit(self.name) - end - - # Returns all protected branches that match the given branch name. - # This realizes all records from the scope built up so far, and does - # _not_ return a relation. - # - # This method optionally takes in a list of `protected_branches` to search - # through, to avoid calling out to the database. - def self.matching(branch_name, protected_branches: nil) - (protected_branches || all).select { |protected_branch| protected_branch.matches?(branch_name) } - end - - # Returns all branches (among the given list of branches [`Gitlab::Git::Branch`]) - # that match the current protected branch. - def matching(branches) - branches.select { |branch| self.matches?(branch.name) } - end - - # Checks if the protected branch matches the given branch name. - def matches?(branch_name) - return false if self.name.blank? - - exact_match?(branch_name) || wildcard_match?(branch_name) - end - - # Checks if this protected branch contains a wildcard - def wildcard? - self.name && self.name.include?('*') - end - - protected - - def exact_match?(branch_name) - self.name == branch_name - end + # Check if branch name is marked as protected in the system + def self.protected?(project, ref_name) + return true if project.empty_repo? && default_branch_protected? - def wildcard_match?(branch_name) - wildcard_regex === branch_name + self.matching(ref_name, protected_refs: project.protected_branches).present? end - def wildcard_regex - @wildcard_regex ||= begin - name = self.name.gsub('*', 'STAR_DONT_ESCAPE') - quoted_name = Regexp.quote(name) - regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?') - /\A#{regex_string}\z/ - end + def self.default_branch_protected? + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL || + current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE end end diff --git a/app/models/protected_ref_matcher.rb b/app/models/protected_ref_matcher.rb new file mode 100644 index 00000000000..d970f2b01fc --- /dev/null +++ b/app/models/protected_ref_matcher.rb @@ -0,0 +1,54 @@ +class ProtectedRefMatcher + def initialize(protected_ref) + @protected_ref = protected_ref + end + + # Returns all protected refs that match the given ref name. + # This checks all records from the scope built up so far, and does + # _not_ return a relation. + # + # This method optionally takes in a list of `protected_refs` to search + # through, to avoid calling out to the database. + def self.matching(type, ref_name, protected_refs: nil) + (protected_refs || type.all).select { |protected_ref| protected_ref.matches?(ref_name) } + end + + # Returns all branches/tags (among the given list of refs [`Gitlab::Git::Branch`]) + # that match the current protected ref. + def matching(refs) + refs.select { |ref| @protected_ref.matches?(ref.name) } + end + + # Checks if the protected ref matches the given ref name. + def matches?(ref_name) + return false if @protected_ref.name.blank? + + exact_match?(ref_name) || wildcard_match?(ref_name) + end + + # Checks if this protected ref contains a wildcard + def wildcard? + @protected_ref.name && @protected_ref.name.include?('*') + end + + protected + + def exact_match?(ref_name) + @protected_ref.name == ref_name + end + + def wildcard_match?(ref_name) + return false unless wildcard? + + wildcard_regex === ref_name + end + + def wildcard_regex + @wildcard_regex ||= begin + name = @protected_ref.name.gsub('*', 'STAR_DONT_ESCAPE') + quoted_name = Regexp.quote(name) + regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?') + /\A#{regex_string}\z/ + end + end +end diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb new file mode 100644 index 00000000000..83964095516 --- /dev/null +++ b/app/models/protected_tag.rb @@ -0,0 +1,14 @@ +class ProtectedTag < ActiveRecord::Base + include Gitlab::ShellAdapter + include ProtectedRef + + has_many :create_access_levels, dependent: :destroy + + validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." } + + accepts_nested_attributes_for :create_access_levels + + def self.protected?(project, ref_name) + self.matching(ref_name, protected_refs: project.protected_tags).present? + end +end diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb new file mode 100644 index 00000000000..c7e1319719d --- /dev/null +++ b/app/models/protected_tag/create_access_level.rb @@ -0,0 +1,21 @@ +class ProtectedTag::CreateAccessLevel < ActiveRecord::Base + include ProtectedTagAccess + + validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER, + Gitlab::Access::DEVELOPER, + Gitlab::Access::NO_ACCESS] } + + def self.human_access_levels + { + Gitlab::Access::MASTER => "Masters", + Gitlab::Access::DEVELOPER => "Developers + Masters", + Gitlab::Access::NO_ACCESS => "No one" + }.with_indifferent_access + end + + def check_access(user) + return false if access_level == Gitlab::Access::NO_ACCESS + + super + end +end diff --git a/app/models/repository.rb b/app/models/repository.rb index 1293cb1d486..f4c51cdfdf4 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1156,6 +1156,8 @@ class Repository @project.repository_storage_path end + delegate :gitaly_channel, :gitaly_repository, to: :raw_repository + def initialize_raw_repository Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git') end diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index f4bcb49b34d..bfaf0eb2fae 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -5,10 +5,11 @@ class SentNotification < ActiveRecord::Base belongs_to :noteable, polymorphic: true belongs_to :recipient, class_name: "User" - validates :project, :recipient, :reply_key, presence: true - validates :reply_key, uniqueness: true + validates :project, :recipient, presence: true + validates :reply_key, presence: true, uniqueness: true validates :noteable_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? + validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid after_save :keep_around_commit @@ -22,9 +23,7 @@ class SentNotification < ActiveRecord::Base find_by(reply_key: reply_key) end - def record(noteable, recipient_id, reply_key, attrs = {}) - return unless reply_key - + def record(noteable, recipient_id, reply_key = self.reply_key, attrs = {}) noteable_id = nil commit_id = nil if noteable.is_a?(Commit) @@ -34,23 +33,20 @@ class SentNotification < ActiveRecord::Base end attrs.reverse_merge!( - project: noteable.project, - noteable_type: noteable.class.name, - noteable_id: noteable_id, - commit_id: commit_id, - recipient_id: recipient_id, - reply_key: reply_key + project: noteable.project, + recipient_id: recipient_id, + reply_key: reply_key, + + noteable_type: noteable.class.name, + noteable_id: noteable_id, + commit_id: commit_id, ) create(attrs) end - def record_note(note, recipient_id, reply_key, attrs = {}) - if note.diff_note? - attrs[:note_type] = note.type - - attrs.merge!(note.diff_attributes) - end + def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {}) + attrs[:in_reply_to_discussion_id] = note.discussion_id record(note.noteable, recipient_id, reply_key, attrs) end @@ -89,31 +85,45 @@ class SentNotification < ActiveRecord::Base self.reply_key end - def note_attributes - { - project: self.project, - author: self.recipient, - type: self.note_type, - noteable_type: self.noteable_type, - noteable_id: self.noteable_id, - commit_id: self.commit_id, - line_code: self.line_code, - position: self.position.to_json - } - end - - def create_note(note) - Notes::CreateService.new( - self.project, - self.recipient, - self.note_attributes.merge(note: note) - ).execute + def create_reply(message, dryrun: false) + klass = dryrun ? Notes::BuildService : Notes::CreateService + klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute end private + def reply_params + attrs = { + noteable_type: self.noteable_type, + noteable_id: self.noteable_id, + commit_id: self.commit_id + } + + if self.in_reply_to_discussion_id.present? + attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id + else + # Remove in GitLab 10.0, when we will not support replying to SentNotifications + # that don't have `in_reply_to_discussion_id` anymore. + attrs.merge!( + type: self.note_type, + + # LegacyDiffNote + line_code: self.line_code, + + # DiffNote + position: self.position.to_json + ) + end + + attrs + end + def note_valid - Note.new(note_attributes.merge(note: "Test")).valid? + note = create_reply('Test', dryrun: true) + + unless note.valid? + self.errors.add(:base, "Note parameters are invalid: #{note.errors.full_messages.to_sentence}") + end end def keep_around_commit diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 30aca62499c..380835707e8 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -2,6 +2,7 @@ class Snippet < ActiveRecord::Base include Gitlab::VisibilityLevel include Linguist::BlobHelper include CacheMarkdownField + include Noteable include Participable include Referable include Sortable diff --git a/app/models/user.rb b/app/models/user.rb index 87eeee204f8..31e975b8e53 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -555,10 +555,6 @@ class User < ActiveRecord::Base authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled end - def is_admin? - admin - end - def require_ssh_key? keys.count == 0 && Gitlab::ProtocolAccess.allowed?('ssh') end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 7edd383530d..416d93ffe63 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -3,7 +3,7 @@ module Ci def rules return unless @user - can! :assign_runner if @user.is_admin? + can! :assign_runner if @user.admin? return if @subject.is_shared? || @subject.locked? diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index cb72c2b4590..4757ba71680 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -10,6 +10,7 @@ class GlobalPolicy < BasePolicy can! :access_api can! :access_git can! :receive_notifications + can! :use_slash_commands end end end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index ed72ed14d72..c495c3f39bb 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -11,5 +11,11 @@ module Ci def erased_by_name erased_by.name if erased_by_user? end + + def status_title + if auto_canceled? + "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" + end + end end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb new file mode 100644 index 00000000000..a542bdd8295 --- /dev/null +++ b/app/presenters/ci/pipeline_presenter.rb @@ -0,0 +1,11 @@ +module Ci + class PipelinePresenter < Gitlab::View::Presenter::Delegated + presents :pipeline + + def status_title + if auto_canceled? + "Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" + end + end + end +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 38a85e9fc42..21350be5557 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -53,6 +53,8 @@ module Ci .execute(pipeline) end + cancel_pending_pipelines if project.auto_cancel_pending_pipelines? + pipeline.tap(&:process!) end @@ -63,6 +65,22 @@ module Ci pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i end + def cancel_pending_pipelines + Gitlab::OptimisticLocking.retry_lock(auto_cancelable_pipelines) do |cancelables| + cancelables.find_each do |cancelable| + cancelable.auto_cancel_running(pipeline) + end + end + end + + def auto_cancelable_pipelines + project.pipelines + .where(ref: pipeline.ref) + .where.not(id: pipeline.id) + .where.not(sha: project.repository.sha_from_ref(pipeline.ref)) + .created_or_pending + end + def commit @commit ||= project.commit(origin_sha || origin_ref) end diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb index c0e4a798f6a..91d9c1d2ba1 100644 --- a/app/services/ci/expire_pipeline_cache_service.rb +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -10,6 +10,8 @@ module Ci store.touch(commit_pipelines_path) if pipeline.commit store.touch(new_merge_request_pipelines_path) merge_requests_pipelines_paths.each { |path| store.touch(path) } + + Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(@pipeline) end private diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb index 297c7d696c3..910a2a15e5d 100644 --- a/app/services/concerns/issues/resolve_discussions.rb +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -21,11 +21,11 @@ module Issues @discussions_to_resolve ||= if discussion_to_resolve_id discussion_or_nil = merge_request_to_resolve_discussions_of - .find_diff_discussion(discussion_to_resolve_id) + .find_discussion(discussion_to_resolve_id) Array(discussion_or_nil) else merge_request_to_resolve_discussions_of - .resolvable_discussions + .discussions_to_be_resolved end end end diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb index 11a045f4c31..38a113caec7 100644 --- a/app/services/delete_branch_service.rb +++ b/app/services/delete_branch_service.rb @@ -11,7 +11,7 @@ class DeleteBranchService < BaseService return error('Cannot remove HEAD branch', 405) end - if project.protected_branch?(branch_name) + if ProtectedBranch.protected?(project, branch_name) return error('Protected branch cant be removed', 405) end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bc7431c89a8..45411c779cc 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -127,7 +127,7 @@ class GitPushService < BaseService project.change_head(branch_name) # Set protection on the default branch if configured - if current_application_settings.default_branch_protection != PROTECTION_NONE && !@project.protected_branch?(@project.default_branch) + if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch) params = { name: @project.default_branch, diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 77bced4bd5c..3a4f7b159f1 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -35,14 +35,19 @@ module Issues end def item_for_discussion(discussion) - first_note = discussion.first_note_to_resolve || discussion.first_note + first_note_to_resolve = discussion.first_note_to_resolve || discussion.first_note + + is_very_first_note = first_note_to_resolve == discussion.first_note + action = is_very_first_note ? "started" : "commented on" + + note_url = Gitlab::UrlBuilder.build(first_note_to_resolve) + other_note_count = discussion.notes.size - 1 - note_url = Gitlab::UrlBuilder.build(first_note) - discussion_info = "- [ ] #{first_note.author.to_reference} commented on a [discussion](#{note_url}): " + discussion_info = "- [ ] #{first_note_to_resolve.author.to_reference} #{action} a [discussion](#{note_url}): " discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0 - note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call + note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note_to_resolve.note).call spaces = ' ' * 4 quote = note_without_block_quotes.lines.map { |line| "#{spaces}> #{line}" }.join diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb new file mode 100644 index 00000000000..ea7cacc956c --- /dev/null +++ b/app/services/notes/build_service.rb @@ -0,0 +1,25 @@ +module Notes + class BuildService < ::BaseService + def execute + in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id) + + if project && in_reply_to_discussion_id.present? + discussion = project.notes.find_discussion(in_reply_to_discussion_id) + + unless discussion + note = Note.new + note.errors.add(:base, 'Discussion to reply to cannot be found') + return note + end + + params.merge!(discussion.reply_attributes) + end + + note = Note.new(params) + note.project = project + note.author = current_user + + note + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 61d66a26932..f3954f6f8c4 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -1,12 +1,10 @@ module Notes - class CreateService < BaseService + class CreateService < ::BaseService def execute merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha) - note = Note.new(params) - note.project = project - note.author = current_user - note.system = false + note = Notes::BuildService.new(project, current_user, params).execute + return note unless note.valid? # We execute commands (extracted from `params[:note]`) on the noteable # **before** we save the note because if the note consists of commands diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb index 89d8ba60134..4b3337a5c9d 100644 --- a/app/services/protected_branches/update_service.rb +++ b/app/services/protected_branches/update_service.rb @@ -1,13 +1,10 @@ module ProtectedBranches class UpdateService < BaseService - attr_reader :protected_branch - def execute(protected_branch) raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) - @protected_branch = protected_branch - @protected_branch.update(params) - @protected_branch + protected_branch.update(params) + protected_branch end end end diff --git a/app/services/protected_tags/create_service.rb b/app/services/protected_tags/create_service.rb new file mode 100644 index 00000000000..faba7865a17 --- /dev/null +++ b/app/services/protected_tags/create_service.rb @@ -0,0 +1,11 @@ +module ProtectedTags + class CreateService < BaseService + attr_reader :protected_tag + + def execute + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + + project.protected_tags.create(params) + end + end +end diff --git a/app/services/protected_tags/update_service.rb b/app/services/protected_tags/update_service.rb new file mode 100644 index 00000000000..aea6a48968d --- /dev/null +++ b/app/services/protected_tags/update_service.rb @@ -0,0 +1,10 @@ +module ProtectedTags + class UpdateService < BaseService + def execute(protected_tag) + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project) + + protected_tag.update(params) + protected_tag + end + end +end diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 595653ea58a..49d45ec9dbd 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -7,6 +7,8 @@ module SlashCommands # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and hash of changes to be applied to a record. def execute(content, issuable) + return [content, {}] unless current_user.can?(:use_slash_commands) + @issuable = issuable @updates = {} diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 35cfcc3682e..c9e25c7aaa2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -228,12 +228,10 @@ module SystemNoteService def discussion_continued_in_issue(discussion, project, author, issue) body = "created #{issue.to_reference} to continue this discussion" + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - note_params = discussion.reply_attributes.merge(project: project, author: author, note: body) - note_params[:type] = note_params.delete(:note_type) - - note = Note.create(note_params.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new({ action: 'discussion' }) + note = Note.create(note_attributes.merge(system: true)) + note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') note end diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index a847a71a66a..93ca7b1141a 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -11,7 +11,7 @@ module Users user = User.new(build_user_params) - if current_user&.is_admin? + if current_user&.admin? if params[:reset_password] @reset_token = user.generate_reset_token params[:force_random_password] = true @@ -47,7 +47,7 @@ module Users private def can_create_user? - (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.is_admin? + (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin? end # Allowed params for creating a user (admins only) @@ -94,7 +94,7 @@ module Users end def build_user_params - if current_user&.is_admin? + if current_user&.admin? user_params = params.slice(*admin_create_params) user_params[:created_by_id] = current_user&.id diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb index d6ccf0dc92c..d2783ce5b2f 100644 --- a/app/uploaders/file_uploader.rb +++ b/app/uploaders/file_uploader.rb @@ -38,10 +38,6 @@ class FileUploader < GitlabUploader File.join(dynamic_path_segment, @secret) end - def cache_dir - File.join(base_dir, 'tmp', @project.path_with_namespace, @secret) - end - def model project end diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml index c00c7f7407e..39c7fb0eba2 100644 --- a/app/views/ci/status/_badge.html.haml +++ b/app/views/ci/status/_badge.html.haml @@ -1,12 +1,13 @@ - status = local_assigns.fetch(:status) -- link = local_assigns.fetch(:link, true) -- css_classes = "ci-status ci-#{status.group}" +- link = local_assigns.fetch(:link, true) +- title = local_assigns.fetch(:title, nil) +- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}" - if link && status.has_details? - = link_to status.details_path, class: css_classes do + = link_to status.details_path, class: css_classes, title: title do = custom_icon(status.icon) = status.text - else - %span{ class: css_classes } + %span{ class: css_classes, title: title } = custom_icon(status.icon) = status.text diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml index ee452add394..e6d307e5568 100644 --- a/app/views/discussions/_diff_discussion.html.haml +++ b/app/views/discussions/_diff_discussion.html.haml @@ -3,4 +3,4 @@ %td.notes_line{ colspan: 2 } %td.notes_content .content{ class: ('hide' unless expanded) } - = render "discussions/notes", discussion: discussion + = render partial: "discussions/notes", collection: discussions, as: :discussion diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index 94408b92374..549364761e6 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.original_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/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 2d78c55211e..e04958817e4 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -5,7 +5,7 @@ = link_to user_path(discussion.author) do = image_tag avatar_icon(discussion.author), class: "avatar s40" .timeline-content - .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } } + .discussion.js-toggle-container{ data: { discussion_id: discussion.id } } .discussion-header .discussion-actions %button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" } @@ -18,19 +18,21 @@ .inline.discussion-headline-light = discussion.author.to_reference - started a discussion on + started a discussion - - if discussion.for_commit? + - if discussion.for_commit? && @noteable != discussion.noteable + on - commit = discussion.noteable - if commit commit - = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code), class: 'monospace' + - anchor = discussion.line_code if discussion.diff_discussion? + = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor), class: 'monospace' - else a deleted commit - - else + - elsif discussion.diff_discussion? + on - if discussion.active? - = link_to diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) do - the diff + = link_to 'the diff', discussion_diff_path(discussion) - else an outdated diff diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 2789391819c..34789808f10 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,18 +1,20 @@ -%ul.notes{ data: { discussion_id: discussion.id } } - = render partial: "projects/notes/note", collection: discussion.notes, as: :note +.discussion-notes + %ul.notes{ data: { discussion_id: discussion.id } } + = render partial: "projects/notes/note", collection: discussion.notes, as: :note -- if current_user - .discussion-reply-holder - - if discussion.diff_discussion? - - line_type = local_assigns.fetch(:line_type, nil) + - if current_user + .discussion-reply-holder + - if discussion.potentially_resolvable? + - line_type = local_assigns.fetch(:line_type, nil) + + .btn-group-justified.discussion-with-resolve-btn{ role: "group" } + .btn-group{ role: "group" } + = link_to_reply_discussion(discussion, line_type) + + = render "discussions/resolve_all", discussion: discussion - .btn-group-justified.discussion-with-resolve-btn{ role: "group" } - .btn-group{ role: "group" } - = link_to_reply_discussion(discussion, line_type) - = render "discussions/resolve_all", discussion: discussion - - if discussion.for_merge_request? .btn-group.discussion-actions = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable = render "discussions/jump_to_next", discussion: discussion - - else - = link_to_reply_discussion(discussion) + - else + = link_to_reply_discussion(discussion) diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml index 3a19e021643..253cd336882 100644 --- a/app/views/discussions/_parallel_diff_discussion.html.haml +++ b/app/views/discussions/_parallel_diff_discussion.html.haml @@ -1,20 +1,20 @@ -- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?) +- expanded = [*discussions_left, *discussions_right].any?(&:expanded?) %tr.notes_holder{ class: ('hide' unless expanded) } - - if discussion_left + - if discussions_left %td.notes_line.old %td.notes_content.parallel.old - .content{ class: ('hide' unless discussion_left.expanded?) } - = render "discussions/notes", discussion: discussion_left, line_type: 'old' + .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) } + = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old' - else %td.notes_line.old= ("") %td.notes_content.parallel.old .content - - if discussion_right + - if discussions_right %td.notes_line.new %td.notes_content.parallel.new - .content{ class: ('hide' unless discussion_right.expanded?) } - = render "discussions/notes", discussion: discussion_right, line_type: 'new' + .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) } + = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new' - else %td.notes_line.new= ("") %td.notes_content.parallel.new diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml index e30ee1b0e05..689a22acd27 100644 --- a/app/views/discussions/_resolve_all.html.haml +++ b/app/views/discussions/_resolve_all.html.haml @@ -1,9 +1,8 @@ -- if discussion.for_merge_request? - %resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", - ":merge-request-id" => discussion.noteable.iid, - ":can-resolve" => discussion.can_resolve?(current_user), - "inline-template" => true } - .btn-group{ role: "group", "v-if" => "showButton" } - %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } - = icon("spinner spin", "v-show" => "loading") - {{ buttonText }} +%resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", + ":merge-request-id" => discussion.noteable.iid, + ":can-resolve" => discussion.can_resolve?(current_user), + "inline-template" => true } + .btn-group{ role: "group", "v-if" => "showButton" } + %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" } + = icon("spinner spin", "v-show" => "loading") + {{ buttonText }} diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index c0c11d00519..af97e9588a5 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -10,6 +10,7 @@ = custom_icon("icon_code_fork") .event-title + %span.author_name= link_to_author event %span{ class: event.action_name } - if event.target = event.action_name diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index 340d8c61026..fee85c94277 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -2,6 +2,7 @@ = custom_icon("icon_status_open") .event-title + %span.author_name= link_to_author event %span{ class: event.action_name } = event_action_name(event) diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index cbcfe0ff71f..83709f5e4d0 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -2,6 +2,7 @@ = custom_icon("icon_comment_o") .event-title + %span.author_name= link_to_author event = event.action_name = event_note_title_html(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 1583f380737..efdc8764acf 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -7,6 +7,7 @@ = custom_icon("icon_commit") .event-title + %span.author_name= link_to_author event %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name) diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 7408fe3f6d0..a9893dea68f 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -31,7 +31,7 @@ %li.impersonation = link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = icon('user-secret fw') - - if current_user.is_admin? + - if current_user.admin? %li = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('wrench fw') @@ -47,17 +47,19 @@ %li = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('hashtag fw') - %span.badge.issues-count - = number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened)) + - issues_count = cached_assigned_issuables_count(current_user, :issues, :opened) + %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } + = number_with_delimiter(issues_count) %li = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - %span.badge.merge-requests-count - = number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened)) + - merge_requests_count = cached_assigned_issuables_count(current_user, :merge_requests, :opened) + %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } + = number_with_delimiter(merge_requests_count) %li = link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('check-circle fw') - %span.badge.todos-count + %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) } = todos_count_format(todos_pending_count) %li.header-user.dropdown = link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 00000000000..198f30a1dc4 --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1,4 @@ +<%= yield -%> + +--- +You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml deleted file mode 100644 index 6a9c6ced9cc..00000000000 --- a/app/views/layouts/mailer.text.haml +++ /dev/null @@ -1,5 +0,0 @@ -= yield - -You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. -Manage all notifications: #{profile_notifications_url} -Help: #{help_url} diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 76268c1b705..40bf45cece7 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -25,8 +25,8 @@ - if @labels_url adjust your #{link_to 'label subscriptions', @labels_url}. - else - - if @sent_notification_url - = link_to "unsubscribe", @sent_notification_url + - if @unsubscribe_url + = link_to "unsubscribe", @unsubscribe_url from this thread or adjust your notification settings. diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb new file mode 100644 index 00000000000..b4ce02eead8 --- /dev/null +++ b/app/views/layouts/notify.text.erb @@ -0,0 +1,12 @@ +<%= yield -%> + +--- +<% if @target_url -%> +<% if @reply_by_email -%> +<%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%> +<% else -%> +<%= "View it on GitLab: #{@target_url}" -%> +<% end -%> +<% end -%> + +You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>. diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml new file mode 100644 index 00000000000..a80518f7986 --- /dev/null +++ b/app/views/notify/_note_email.html.haml @@ -0,0 +1,37 @@ +- discussion = @note.discussion if @note.part_of_discussion? +- if discussion + %p.details + = succeed ':' do + = link_to @note.author_name, user_url(@note.author) + + - if discussion.diff_discussion? + - if discussion.new_discussion? + started a new discussion + - else + commented on a discussion + + on #{link_to discussion.file_path, @target_url} + - else + - if discussion.new_discussion? + started a new discussion + - else + commented on a #{link_to 'discussion', @target_url} + +- elsif current_application_settings.email_author_in_body + %p.details + #{link_to @note.author_name, user_url(@note.author)} commented: + +- if discussion&.diff_discussion? + = content_for :head do + = stylesheet_link_tag 'mailers/highlighted_diff_email' + + %table + = render partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: discussion.diff_file, + plain: true, + email: true } + +%div + = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb new file mode 100644 index 00000000000..cb2e7fab6d5 --- /dev/null +++ b/app/views/notify/_note_email.text.erb @@ -0,0 +1,26 @@ +<% discussion = @note.discussion if @note.part_of_discussion? -%> +<% if discussion && !discussion.individual_note? -%> +<%= @note.author_name -%> +<% if discussion.new_discussion? -%> +<%= " started a new discussion" -%> +<% else -%> +<%= " commented on a discussion" -%> +<% end -%> +<% if discussion.diff_discussion? -%> +<%= " on #{discussion.file_path}" -%> +<% end -%> +<%= ":" -%> + + +<% elsif current_application_settings.email_author_in_body -%> +<%= "#{@note.author_name} commented:" -%> + + +<% end -%> +<% if discussion&.diff_discussion? -%> +<% discussion.truncated_diff_lines(highlight: false).each do |line| -%> +<%= "> #{line.text}\n" -%> +<% end -%> + +<% end -%> +<%= @note.note -%> diff --git a/app/views/notify/_note_message.html.haml b/app/views/notify/_note_message.html.haml deleted file mode 100644 index e9c66170877..00000000000 --- a/app/views/notify/_note_message.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -- if current_application_settings.email_author_in_body - %div - #{link_to @note.author_name, user_url(@note.author)} wrote: -%div - = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/_note_message.text.erb b/app/views/notify/_note_message.text.erb deleted file mode 100644 index f82cbc9a3fc..00000000000 --- a/app/views/notify/_note_message.text.erb +++ /dev/null @@ -1,5 +0,0 @@ -<% if current_application_settings.email_author_in_body %> - <%= @note.author_name %> wrote: -<% end -%> - -<%= @note.note %> diff --git a/app/views/notify/_note_mr_or_commit_email.html.haml b/app/views/notify/_note_mr_or_commit_email.html.haml deleted file mode 100644 index edf8dfe7e9e..00000000000 --- a/app/views/notify/_note_mr_or_commit_email.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -= content_for :head do - = stylesheet_link_tag 'mailers/highlighted_diff_email' - -New comment - -- if @discussion && @discussion.diff_file - on - = link_to @note.diff_file.file_path, @target_url, class: 'details' - \: - %table - = render partial: "projects/diffs/line", - collection: @discussion.truncated_diff_lines, - as: :line, - locals: { diff_file: @note.diff_file, - plain: true, - email: true } - -= render 'note_message' diff --git a/app/views/notify/_note_mr_or_commit_email.text.erb b/app/views/notify/_note_mr_or_commit_email.text.erb deleted file mode 100644 index b4fcdf6b1e9..00000000000 --- a/app/views/notify/_note_mr_or_commit_email.text.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% if @discussion && @discussion.diff_file -%> - on <%= @note.diff_file.file_path -%> -<% end -%>: - -<%= url %> - -<%= render 'simple_diff' if @discussion -%> -<%= render 'note_message' %> diff --git a/app/views/notify/_simple_diff.text.erb b/app/views/notify/_simple_diff.text.erb deleted file mode 100644 index c28d1cc34d3..00000000000 --- a/app/views/notify/_simple_diff.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% @discussion.truncated_diff_lines(highlight: false).each do |line| %> -> <%= line.text %> -<% end %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index d1855568215..c762578971a 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -1,9 +1,11 @@ - if current_application_settings.email_author_in_body - %div - #{link_to @issue.author_name, user_url(@issue.author)} wrote: -- if @issue.description - = markdown(@issue.description, pipeline: :email, author: @issue.author) + %p.details + #{link_to @issue.author_name, user_url(@issue.author)} created an issue: - if @issue.assignee_id.present? %p Assignee: #{@issue.assignee_name} + +- if @issue.description + %div + = markdown(@issue.description, pipeline: :email, author: @issue.author) diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml index 02f21baa368..6b45ac265f7 100644 --- a/app/views/notify/new_mention_in_issue_email.html.haml +++ b/app/views/notify/new_mention_in_issue_email.html.haml @@ -1,12 +1,4 @@ %p You have been mentioned in an issue. -- if current_application_settings.email_author_in_body - %div - #{link_to @issue.author_name, user_url(@issue.author)} wrote: -- if @issue.description - = markdown(@issue.description, pipeline: :email, author: @issue.author) - -- if @issue.assignee_id.present? - %p - Assignee: #{@issue.assignee_name} += render template: 'notify/new_issue_email' diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml index cbd434be02a..b061f9c106e 100644 --- a/app/views/notify/new_mention_in_merge_request_email.html.haml +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -1,15 +1,4 @@ %p You have been mentioned in Merge Request #{@merge_request.to_reference} -- if current_application_settings.email_author_in_body - %div - #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: -%p.details - != merge_path_description(@merge_request, '→') - -- if @merge_request.assignee_id.present? - %p - Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} - -- if @merge_request.description - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) += render template: 'notify/new_merge_request_email' diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 8890b300f7d..951c96bdb9c 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -1,12 +1,14 @@ - if current_application_settings.email_author_in_body - %div - #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote: + %p.details + #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request: + %p.details != merge_path_description(@merge_request, '→') - if @merge_request.assignee_id.present? %p - Assignee: #{@merge_request.author_name} → #{@merge_request.assignee_name} + Assignee: #{@merge_request.assignee_name} - if @merge_request.description - = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) + %div + = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author) diff --git a/app/views/notify/note_commit_email.html.haml b/app/views/notify/note_commit_email.html.haml index 0a650e3b2ca..5e69f01a486 100644 --- a/app/views/notify/note_commit_email.html.haml +++ b/app/views/notify/note_commit_email.html.haml @@ -1,2 +1 @@ -%p.details - = render 'note_mr_or_commit_email' += render 'note_email' diff --git a/app/views/notify/note_commit_email.text.erb b/app/views/notify/note_commit_email.text.erb index 6aa085a172e..413d9e6e9ac 100644 --- a/app/views/notify/note_commit_email.text.erb +++ b/app/views/notify/note_commit_email.text.erb @@ -1,2 +1 @@ -New comment for Commit <%= @commit.short_id -%> -<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url } %> +<%= render 'note_email' %> diff --git a/app/views/notify/note_issue_email.html.haml b/app/views/notify/note_issue_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_issue_email.html.haml +++ b/app/views/notify/note_issue_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_issue_email.text.erb b/app/views/notify/note_issue_email.text.erb index e33cbcd70f2..413d9e6e9ac 100644 --- a/app/views/notify/note_issue_email.text.erb +++ b/app/views/notify/note_issue_email.text.erb @@ -1,9 +1 @@ -New comment for Issue <%= @issue.iid %> - -<%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> - +<%= render 'note_email' %> diff --git a/app/views/notify/note_merge_request_email.html.haml b/app/views/notify/note_merge_request_email.html.haml index 0a650e3b2ca..5e69f01a486 100644 --- a/app/views/notify/note_merge_request_email.html.haml +++ b/app/views/notify/note_merge_request_email.html.haml @@ -1,2 +1 @@ -%p.details - = render 'note_mr_or_commit_email' += render 'note_email' diff --git a/app/views/notify/note_merge_request_email.text.erb b/app/views/notify/note_merge_request_email.text.erb index 2ce64c494cf..413d9e6e9ac 100644 --- a/app/views/notify/note_merge_request_email.text.erb +++ b/app/views/notify/note_merge_request_email.text.erb @@ -1,2 +1 @@ -New comment for Merge Request <%= @merge_request.to_reference -%> -<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url }%> +<%= render 'note_email' %> diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_personal_snippet_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_personal_snippet_email.html.haml +++ b/app/views/notify/note_personal_snippet_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_personal_snippet_email.text.erb index b2a8809a23b..413d9e6e9ac 100644 --- a/app/views/notify/note_personal_snippet_email.text.erb +++ b/app/views/notify/note_personal_snippet_email.text.erb @@ -1,8 +1 @@ -New comment for Snippet <%= @snippet.id %> - -<%= url_for(snippet_url(@snippet, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> +<%= render 'note_email' %> diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml index 2fa2f784661..5e69f01a486 100644 --- a/app/views/notify/note_snippet_email.html.haml +++ b/app/views/notify/note_snippet_email.html.haml @@ -1 +1 @@ -= render 'note_message' += render 'note_email' diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb index 4d5a406f4b0..413d9e6e9ac 100644 --- a/app/views/notify/note_snippet_email.text.erb +++ b/app/views/notify/note_snippet_email.text.erb @@ -1,8 +1 @@ -New comment for Snippet <%= @snippet.id %> - -<%= url_for(namespace_project_snippet_url(@snippet.project.namespace, @snippet.project, @snippet, anchor: "note_#{@note.id}")) %> - - -Author: <%= @note.author_name %> - -<%= @note.note %> +<%= render 'note_email' %> diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 4ad77b6266d..35885b2c7b4 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -7,7 +7,7 @@ #blob-content-holder.tree-holder .file-holder - = render "projects/blob/header", blob: @blob + = render "projects/blob/header", blob: @blob, blame: true .table-responsive.file-content.blame.code.js-syntax-highlight %table diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 828f8e073a9..6c7d389e707 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -1,3 +1,4 @@ +- blame = local_assigns.fetch(:blame, false) .js-file-title.file-title-flex-parent .file-header-content = blob_icon blob.mode, blob.name @@ -12,14 +13,14 @@ .file-actions.hidden-xs .btn-group{ role: "group" }< - = copy_blob_content_button(blob) if blob_text_viewable?(blob) + = copy_blob_content_button(blob) if !blame && blob_text_viewable?(blob) = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< -# only show normal/blame view links for text files - if blob_text_viewable?(blob) - - if current_page? namespace_project_blame_path(@project.namespace, @project, @id) + - if blame = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), class: 'btn btn-sm' - else diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 724675e659c..0f9ef3eded3 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -15,7 +15,7 @@ %span.label.label-info.has-tooltip{ title: "Merged into #{@repository.root_ref}" } merged - - if @project.protected_branch? branch.name + - if protected_branch?(@project, branch) %span.label.label-success protected .controls.hidden-xs< diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml index 7eb17e887e7..104db85809c 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/builds/_header.html.haml @@ -1,14 +1,16 @@ +- pipeline = @build.pipeline + .content-block.build-header.top-area .header-content - = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false + = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title Job %strong.js-build-id ##{@build.id} in pipeline - = link_to pipeline_path(@build.pipeline) do - %strong ##{@build.pipeline.id} + = link_to pipeline_path(pipeline) do + %strong ##{pipeline.id} for commit - = link_to namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha) do - %strong= @build.pipeline.short_sha + = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do + %strong= pipeline.short_sha from = link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do %code diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index d5fe771613c..0faad57a312 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -71,6 +71,11 @@ = custom_icon('scroll_down_hover_active') #up-build-trace %pre.build-trace#build-trace + .js-truncated-info.truncated-info.hidden + %span< + Showing last + %span.js-truncated-info-size>< + KiB of log %code.bash.js-build-output .build-loader-animation.js-build-refresh diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 508465cbfb7..4700b7a9a45 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -1,3 +1,5 @@ +- job = build.present(current_user: current_user) +- pipeline = job.pipeline - admin = local_assigns.fetch(:admin, false) - ref = local_assigns.fetch(:ref, nil) - commit_sha = local_assigns.fetch(:commit_sha, nil) @@ -8,101 +10,101 @@ %tr.build.commit{ class: ('retried' if retried) } %td.status - = render "ci/status/badge", status: build.detailed_status(current_user) + = render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title %td.branch-commit - - if can?(current_user, :read_build, build) - = link_to namespace_project_build_url(build.project.namespace, build.project, build) do - %span.build-link ##{build.id} + - if can?(current_user, :read_build, job) + = link_to namespace_project_build_url(job.project.namespace, job.project, job) do + %span.build-link ##{job.id} - else - %span.build-link ##{build.id} + %span.build-link ##{job.id} - if ref - - if build.ref + - if job.ref .icon-container - = build.tag? ? icon('tag') : icon('code-fork') - = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name" + = job.tag? ? icon('tag') : icon('code-fork') + = link_to job.ref, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-name" - else .light none .icon-container.commit-icon = custom_icon("icon_commit") - if commit_sha - = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace" + = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace" - - if build.stuck? + - if job.stuck? = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.') - if retried = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried') .label-container - - if build.tags.any? - - build.tags.each do |tag| + - if job.tags.any? + - job.tags.each do |tag| %span.label.label-primary = tag - - if build.try(:trigger_request) + - if job.try(:trigger_request) %span.label.label-info triggered - - if build.try(:allow_failure) + - if job.try(:allow_failure) %span.label.label-danger allowed to fail - - if build.action? + - if job.action? %span.label.label-info manual - if pipeline_link %td - = link_to pipeline_path(build.pipeline) do - %span.pipeline-id ##{build.pipeline.id} + = link_to pipeline_path(pipeline) do + %span.pipeline-id ##{pipeline.id} %span by - - if build.pipeline.user - = user_avatar(user: build.pipeline.user, size: 20) + - if pipeline.user + = user_avatar(user: pipeline.user, size: 20) - else %span.monospace API - if admin %td - - if build.project - = link_to build.project.name_with_namespace, admin_namespace_project_path(build.project.namespace, build.project) + - if job.project + = link_to job.project.name_with_namespace, admin_namespace_project_path(job.project.namespace, job.project) %td - - if build.try(:runner) - = runner_link(build.runner) + - if job.try(:runner) + = runner_link(job.runner) - else .light none - if stage %td - = build.stage + = job.stage %td - = build.name + = job.name %td - - if build.duration + - if job.duration %p.duration = custom_icon("icon_timer") - = duration_in_numbers(build.duration) + = duration_in_numbers(job.duration) - - if build.finished_at + - if job.finished_at %p.finished-at = icon("calendar") - %span= time_ago_with_tooltip(build.finished_at) + %span= time_ago_with_tooltip(job.finished_at) %td.coverage - - if build.try(:coverage) - #{build.coverage}% + - if job.try(:coverage) + #{job.coverage}% %td .pull-right - - if can?(current_user, :read_build, build) && build.artifacts? - = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do + - if can?(current_user, :read_build, job) && job.artifacts? + = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do = icon('download') - - if can?(current_user, :update_build, build) - - if build.active? - = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do + - if can?(current_user, :update_build, job) + - if job.active? + = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - - if build.playable? && !admin - = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + - if job.playable? && !admin + = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - - elsif build.retryable? - = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + - elsif job.retryable? + = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index d5fc283aa8d..0d11da2451a 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -10,6 +10,7 @@ - else .block-connector = render "projects/diffs/diffs", diffs: @diffs, environment: @environment + = render "projects/notes/notes_with_form" - if can_collaborate_with_project? - %w(revert cherry-pick).each do |type| diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index c09c7b87e24..3e426ee9e7d 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -4,7 +4,7 @@ - type = line.type - line_code = diff_file.line_code(line) - if discussions && !line.meta? - - discussion = discussions[line_code] + - line_discussions = discussions[line_code] %tr.line_holder{ class: type, id: (line_code unless plain) } - case type - when 'match' @@ -20,6 +20,7 @@ = link_text - else %a{ href: "##{line_code}", data: { linenumber: link_text } } + - discussion = line_discussions.try(:first) - if discussion && discussion.resolvable? && !plain %diff-note-avatars{ "discussion-id" => discussion.id } %td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } } @@ -34,6 +35,6 @@ - else = diff_line_content(line.text) -- if discussion - - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?) - = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded +- if line_discussions + - 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/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index b7346f27ddb..f920f359de2 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -6,7 +6,7 @@ - right = line[:right] - last_line = right.new_pos if right - unless @diff_notes_disabled - - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file) + - discussions_left, discussions_right = parallel_diff_discussions(left, right, diff_file) %tr.line_holder.parallel - if left - case left.type @@ -20,6 +20,7 @@ - left_position = diff_file.position(left) %td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } } %a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } } + - discussion_left = discussions_left.try(:first) - if discussion_left && discussion_left.resolvable? %diff-note-avatars{ "discussion-id" => discussion_left.id } %td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text) @@ -39,6 +40,7 @@ - right_position = diff_file.position(right) %td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } } %a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } } + - discussion_right = discussions_right.try(:first) - if discussion_right && discussion_right.resolvable? %diff-note-avatars{ "discussion-id" => discussion_right.id } %td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text) @@ -46,8 +48,8 @@ %td.old_line.diff-line-num.empty-cell %td.line_content.parallel - - if discussion_left || discussion_right - = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right + - if discussions_left || discussions_right + = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right - if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any? - last_line = diff_file.diff_lines.last - if last_line.new_pos < total_lines diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index ff6aaebda22..7315e671056 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -8,9 +8,9 @@ %h3.page-title= @environment.name .col-md-5 .nav-controls - = render 'projects/environments/metrics_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/external_url', environment: @environment + = render 'projects/environments/metrics_button', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' - if can?(current_user, :create_deployment, @environment) && @environment.can_stop? diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml index cfb44bd206c..15b5a51c1d0 100644 --- a/app/views/projects/merge_requests/_discussion.html.haml +++ b/app/views/projects/merge_requests/_discussion.html.haml @@ -1,9 +1,9 @@ - content_for :note_actions do - if can?(current_user, :update_merge_request, @merge_request) - if @merge_request.open? - = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"} + = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"} - if @merge_request.reopenable? - = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} + = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"} %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" } %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } } {{ buttonText }} diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml new file mode 100644 index 00000000000..6bb55f04b6e --- /dev/null +++ b/app/views/projects/notes/_comment_button.html.haml @@ -0,0 +1,30 @@ +- noteable_name = @note.noteable.human_class_name + +.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown + %input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' } + + - if @note.can_be_discussion_note? + = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do + = icon('caret-down', class: 'toggle-icon') + + %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } } + %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } } + %a{ href: '#' } + = icon('check') + .description + %strong Comment + %p + Add a general comment to this #{noteable_name}. + + %li.divider + + %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } } + %a{ href: '#' } + = icon('check') + .description + %strong Start discussion + %p + = succeed '.' do + Discuss a specific suggestion or question + - if @note.noteable.supports_resolvable_notes? + that needs to be resolved diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml index b561052e721..0d835a9e949 100644 --- a/app/views/projects/notes/_form.html.haml +++ b/app/views/projects/notes/_form.html.haml @@ -4,12 +4,18 @@ = hidden_field_tag :view, diff_view = hidden_field_tag :line_type = hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha) + = hidden_field_tag :in_reply_to_discussion_id + = note_target_fields(@note) - = f.hidden_field :commit_id - = f.hidden_field :line_code - = f.hidden_field :noteable_id = f.hidden_field :noteable_type + = f.hidden_field :noteable_id + = f.hidden_field :commit_id = f.hidden_field :type + + -# LegacyDiffNote + = f.hidden_field :line_code + + -# DiffNote = f.hidden_field :position = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do @@ -22,7 +28,9 @@ .error-alert .note-form-actions.clearfix - = f.submit 'Comment', class: "btn btn-nr btn-create append-right-10 comment-btn js-comment-button" + = render partial: 'projects/notes/comment_button' + = yield(:note_actions) + %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } } Discard draft diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 9130dc128fa..c12c05eeb73 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -34,7 +34,7 @@ - if note.resolvable? - can_resolve = can?(current_user, :resolve_note, note) %resolve-btn{ "project-path" => project_path(note.project), - "discussion-id" => note.discussion_id, + "discussion-id" => note.discussion_id(@noteable), ":note-id" => note.id, ":resolved" => note.resolved?, ":can-resolve" => can_resolve, diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml index 022578bd6db..2b2bab09c74 100644 --- a/app/views/projects/notes/_notes.html.haml +++ b/app/views/projects/notes/_notes.html.haml @@ -1,7 +1,7 @@ -- if @discussions.present? +- if defined?(@discussions) - @discussions.each do |discussion| - - if discussion.for_target?(@noteable) - = render partial: "projects/notes/note", object: discussion.first_note, as: :note + - if discussion.individual_note? + = render partial: "projects/notes/note", collection: discussion.notes, as: :note - else = render 'discussions/discussion', discussion: discussion - else diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index fc1d0989787..ab6baaf35b6 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,6 +1,6 @@ .page-content-header .header-main-content - = render 'ci/status/badge', status: @pipeline.detailed_status(current_user) + = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title %strong Pipeline ##{@pipeline.id} triggered #{time_ago_with_tooltip(@pipeline.created_at)} - if @pipeline.user diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 132f6372e40..a3f84476dea 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -21,7 +21,7 @@ Git strategy for pipelines %p Choose between <code>clone</code> or <code>fetch</code> to get the recent application code - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank' .radio = f.label :build_allow_git_fetch_false do = f.radio_button :build_allow_git_fetch, 'false' @@ -43,7 +43,7 @@ = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' %p.help-block Per job in minutes. If a job passes this threshold, it will be marked as failed. - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr .form-group @@ -53,7 +53,16 @@ %strong Public pipelines .help-block Allow everyone to access pipelines for public and internal projects - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank' + %hr + .form-group + .checkbox + = f.label :auto_cancel_pending_pipelines do + = f.check_box :auto_cancel_pending_pipelines, {}, 'enabled', 'disabled' + %strong Auto-cancel redundant, pending pipelines + .help-block + New pipelines will cancel older, pending pipelines on the same branch + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank' %hr .form-group @@ -65,7 +74,7 @@ %p.help-block A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable - = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing') + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' .bs-callout.bs-callout-info %p Below are examples of regex for existing tools: %ul diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml index 4d8169815b3..f8cfe5e4b11 100644 --- a/app/views/projects/protected_branches/show.html.haml +++ b/app/views/projects/protected_branches/show.html.haml @@ -1,13 +1,13 @@ -- page_title @protected_branch.name, "Protected Branches" +- page_title @protected_ref.name, "Protected Branches" .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 - = @protected_branch.name + = @protected_ref.name .col-lg-9 %h5 Matching Branches - - if @matching_branches.present? + - if @matching_refs.present? .table-responsive %table.table.protected-branches-list %colgroup @@ -18,7 +18,7 @@ %th Branch %th Last commit %tbody - - @matching_branches.each do |matching_branch| + - @matching_refs.each do |matching_branch| = render partial: "matching_branch", object: matching_branch - else %p.settings-message.text-center diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml new file mode 100644 index 00000000000..6e187b54a59 --- /dev/null +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -0,0 +1,32 @@ += form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f| + .panel.panel-default + .panel-heading + %h3.panel-title + Protect a tag + .panel-body + .form-horizontal + = form_errors(@protected_tag) + .form-group + = f.label :name, class: 'col-md-2 text-right' do + Tag: + .col-md-10 + = 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') + such as + %code v* + or + %code *-release + are supported + .form-group + %label.col-md-2.text-right{ for: 'create_access_levels_attributes' } + Allowed to create: + .col-md-10 + .create_access_levels-container + = dropdown_tag('Select', + options: { toggle_class: 'js-allowed-to-create wide', + dropdown_class: 'dropdown-menu-selectable', + data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }}) + + .panel-footer + = f.submit 'Protect', class: 'btn-create btn', disabled: true diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml new file mode 100644 index 00000000000..74851519077 --- /dev/null +++ b/app/views/projects/protected_tags/_dropdown.html.haml @@ -0,0 +1,15 @@ += f.hidden_field(:name) + += dropdown_tag('Select tag or create wildcard', + options: { toggle_class: 'js-protected-tag-select js-filter-submit wide', + filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag", + footer_content: true, + data: { show_no: true, show_any: true, show_upcoming: true, + selected: params[:protected_tag_name], + project_id: @project.try(:id) } }) do + + %ul.dropdown-footer-list + %li + = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do + Create wildcard + %code diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml new file mode 100644 index 00000000000..0bfb1ad191d --- /dev/null +++ b/app/views/projects/protected_tags/_index.html.haml @@ -0,0 +1,18 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('protected_tags') + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Protected tags + %p.prepend-top-20 + By default, Protected tags are designed to: + %ul + %li Prevent tag creation by everybody except Masters + %li Prevent <strong>anyone</strong> from updating the tag + %li Prevent <strong>anyone</strong> from deleting the tag + .col-lg-9 + - if can? current_user, :admin_project, @project + = render 'projects/protected_tags/create_protected_tag' + + = render "projects/protected_tags/tags_list" diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml new file mode 100644 index 00000000000..97e5cd6f9d2 --- /dev/null +++ b/app/views/projects/protected_tags/_matching_tag.html.haml @@ -0,0 +1,9 @@ +%tr + %td + = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.name) + - if @project.root_ref?(matching_tag.name) + %span.label.label-info.prepend-left-5 default + %td + - commit = @project.commit(matching_tag.name) + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = time_ago_with_tooltip(commit.committed_date) diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml new file mode 100644 index 00000000000..26bd3a1f5ed --- /dev/null +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -0,0 +1,21 @@ +%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } } + %td + = protected_tag.name + - if @project.root_ref?(protected_tag.name) + %span.label.label-info.prepend-left-5 default + %td + - if protected_tag.wildcard? + - matching_tags = protected_tag.matching(repository.tags) + = link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) + - else + - if commit = protected_tag.commit + = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id') + = time_ago_with_tooltip(commit.committed_date) + - else + (tag was removed from repository) + + = render partial: 'projects/protected_tags/update_protected_tag', locals: { protected_tag: protected_tag } + + - if can_admin_project + %td + = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml new file mode 100644 index 00000000000..728afd75b50 --- /dev/null +++ b/app/views/projects/protected_tags/_tags_list.html.haml @@ -0,0 +1,28 @@ +.panel.panel-default.protected-tags-list.js-protected-tags-list + - if @protected_tags.empty? + .panel-heading + %h3.panel-title + Protected tag (#{@protected_tags.size}) + %p.settings-message.text-center + There are currently no protected tags, protect a tag with the form above. + - else + - can_admin_project = can?(current_user, :admin_project, @project) + + %table.table.table-bordered + %colgroup + %col{ width: "25%" } + %col{ width: "25%" } + %col{ width: "50%" } + %thead + %tr + %th Protected tag (#{@protected_tags.size}) + %th Last commit + %th Allowed to create + - if can_admin_project + %th + %tbody + %tr + %td.flash-container{ colspan: 4 } + = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project} + + = paginate @protected_tags, theme: 'gitlab' diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml new file mode 100644 index 00000000000..62823bee46e --- /dev/null +++ b/app/views/projects/protected_tags/_update_protected_tag.haml @@ -0,0 +1,5 @@ +%td + = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level + = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container', + data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }}) diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml new file mode 100644 index 00000000000..63743f28b3c --- /dev/null +++ b/app/views/projects/protected_tags/show.html.haml @@ -0,0 +1,25 @@ +- page_title @protected_ref.name, "Protected Tags" + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + = @protected_ref.name + + .col-lg-9 + %h5 Matching Tags + - if @matching_refs.present? + .table-responsive + %table.table.protected-tags-list + %colgroup + %col{ width: "30%" } + %col{ width: "30%" } + %thead + %tr + %th Tag + %th Last commit + %tbody + - @matching_refs.each do |matching_tag| + = render partial: "matching_tag", object: matching_tag + - else + %p.settings-message.text-center + Couldn't find any matching tags. diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 4c02302e161..5402320cb66 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -3,3 +3,4 @@ = render @deploy_keys = render "projects/protected_branches/index" += render "projects/protected_tags/index" diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index dffe908e85a..451e011a4b8 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -6,6 +6,11 @@ %span.item-title = icon('tag') = tag.name + + - if protected_tag?(@project, tag) + %span.label.label-success + protected + - if tag.message.present? = strip_gpg_signature(tag.message) @@ -30,5 +35,5 @@ = icon("pencil") - if can?(current_user, :admin_project, @project) - = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do + = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do = icon("trash-o") diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index fad3c5c2173..1c4135c8a54 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -7,6 +7,9 @@ .nav-text .title %span.item-title= @tag.name + - if protected_tag?(@project, @tag) + %span.label.label-success + protected - if @commit = render 'projects/branches/commit', commit: @commit, project: @project - else @@ -24,7 +27,7 @@ = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_project, @project) .btn-container.controls-item-full - = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do + = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do %i.fa.fa-trash-o - if @tag.message.present? diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index 5f708b3a2ed..8582bcbb8cc 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -8,4 +8,26 @@ .form-group = f.label :key, "Description", class: "label-light" = f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description" + - if @trigger.persisted? + %hr + = f.fields_for :trigger_schedule do |schedule_fields| + = schedule_fields.hidden_field :id + .form-group + .checkbox + = schedule_fields.label :active do + = schedule_fields.check_box :active + %strong Schedule trigger (experimental) + .help-block + If checked, this trigger will be executed periodically according to cron and timezone. + = link_to icon('question-circle'), help_page_path('ci/triggers', anchor: 'schedule') + .form-group + = schedule_fields.label :cron, "Cron", class: "label-light" + = schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *" + .form-group + = schedule_fields.label :cron, "Timezone", class: "label-light" + = schedule_fields.text_field :cron_timezone, class: "form-control", title: 'Timezone is required.', placeholder: "UTC" + .form-group + = schedule_fields.label :ref, "Branch or tag", class: "label-light" + = schedule_fields.text_field :ref, class: "form-control", title: 'Branch or tag is required.', placeholder: "master" + .help-block Existing branch name, tag = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml index cc74e50a5e3..84e945ee0df 100644 --- a/app/views/projects/triggers/_index.html.haml +++ b/app/views/projects/triggers/_index.html.haml @@ -22,6 +22,8 @@ %th %strong Last used %th + %strong Next run at + %th = render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger - else %p.settings-message.text-center.append-bottom-default diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 9b5f63ae81a..ebd91a8e2af 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -29,6 +29,12 @@ - else Never + %td + - if trigger.trigger_schedule&.active? + = trigger.trigger_schedule.real_next_run + - else + Never + %td.text-right.trigger-actions - take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?" - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 8869d510aef..90ae3f06a98 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -1,12 +1,8 @@ +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('group') - parent = GroupFinder.new(current_user).execute(id: params[:parent_id] || @group.parent_id) - group_path = root_url - group_path << parent.full_path + '/' if parent -- if @group.persisted? - .form-group - = f.label :name, class: 'control-label' do - Group name - .col-sm-10 - = f.text_field :name, placeholder: 'open-source', class: 'form-control' .form-group = f.label :path, class: 'control-label' do @@ -20,7 +16,7 @@ = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, - title: 'Please choose a group name with no special characters.', + title: 'Please choose a group path with no special characters.', "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - if parent = f.hidden_field :parent_id, value: parent.id @@ -33,6 +29,14 @@ %li It will change web url for access group and group projects. %li It will change the git path to repositories under this group. +.form-group.group-name-holder + = f.label :name, class: 'control-label' do + Group name + .col-sm-10 + = f.text_field :name, class: 'form-control', + required: true, + title: 'You can choose a descriptive name different from the path.' + .form-group.group-description-holder = f.label :description, class: 'control-label' .col-sm-10 diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 9e241c3ea12..b447996a8ab 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -23,7 +23,7 @@ .scroll-container %ul.tokens-container.list-unstyled %li.input-token - %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } + %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } = icon('filter') %button.clear-search.hidden{ type: 'button' } = icon('times') diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb index 440c579b99d..9c1baf7e6c5 100644 --- a/app/workers/trigger_schedule_worker.rb +++ b/app/workers/trigger_schedule_worker.rb @@ -3,7 +3,7 @@ class TriggerScheduleWorker include CronjobQueue def perform - Ci::TriggerSchedule.where("next_run_at < ?", Time.now).find_each do |trigger_schedule| + Ci::TriggerSchedule.active.where("next_run_at < ?", Time.now).find_each do |trigger_schedule| begin Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project, trigger_schedule.trigger, diff --git a/changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml b/changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml new file mode 100644 index 00000000000..fabe24e485a --- /dev/null +++ b/changelogs/unreleased/18471-restrict-tag-pushes-protected-tags.yml @@ -0,0 +1,4 @@ +--- +title: Tags can be protected, restricting creation of matching tags by user role +merge_request: 10356 +author: diff --git a/changelogs/unreleased/24240-add-monitoring-endpoints.yml b/changelogs/unreleased/24240-add-monitoring-endpoints.yml new file mode 100644 index 00000000000..a22458965fc --- /dev/null +++ b/changelogs/unreleased/24240-add-monitoring-endpoints.yml @@ -0,0 +1,4 @@ +--- +title: Add /-/readiness /-/liveness and /-/metrics endpoints to track application health +merge_request: 10416 +author: diff --git a/changelogs/unreleased/27580-fix-show-go-back.yml b/changelogs/unreleased/27580-fix-show-go-back.yml new file mode 100644 index 00000000000..c7dbbe7a236 --- /dev/null +++ b/changelogs/unreleased/27580-fix-show-go-back.yml @@ -0,0 +1,4 @@ +--- +title: Shows 'Go Back' link only when browser history is available +merge_request: 9017 +author: diff --git a/changelogs/unreleased/28574-jira-trigers.yml b/changelogs/unreleased/28574-jira-trigers.yml new file mode 100644 index 00000000000..6ebd2c0c2c2 --- /dev/null +++ b/changelogs/unreleased/28574-jira-trigers.yml @@ -0,0 +1,4 @@ +--- +title: Remove confusing placeholder for JIRA transition_id +merge_request: +author: diff --git a/changelogs/unreleased/30056-rename-milestones-empty.yml b/changelogs/unreleased/30056-rename-milestones-empty.yml new file mode 100644 index 00000000000..85db342b6df --- /dev/null +++ b/changelogs/unreleased/30056-rename-milestones-empty.yml @@ -0,0 +1,4 @@ +--- +title: Removed Milestone#is_empty? +merge_request: 10523 +author: Jacopo Beschi @jacopo-beschi diff --git a/changelogs/unreleased/30587-pipeline-icon-z.yml b/changelogs/unreleased/30587-pipeline-icon-z.yml new file mode 100644 index 00000000000..548d16ce142 --- /dev/null +++ b/changelogs/unreleased/30587-pipeline-icon-z.yml @@ -0,0 +1,4 @@ +--- +title: fix Status icons overlapping sidebar on mobile +merge_request: +author: diff --git a/changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml b/changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml new file mode 100644 index 00000000000..9cff3c2776f --- /dev/null +++ b/changelogs/unreleased/30588-fix-javascript-sourcemaps-w-chrome-breakpoints.yml @@ -0,0 +1,4 @@ +--- +title: Upgrade webpack to v2.3.3 and webpack-dev-server to v2.4.2 +merge_request: 10552 +author: diff --git a/changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml b/changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml new file mode 100644 index 00000000000..9852cd6e4ff --- /dev/null +++ b/changelogs/unreleased/8998_skip_pending_commits_if_not_head.yml @@ -0,0 +1,4 @@ +--- +title: Cancel pending pipelines if commits not HEAD +merge_request: 9362 +author: Rydkin Maxim diff --git a/changelogs/unreleased/adam-finish-5993-closed-issuable.yml b/changelogs/unreleased/adam-finish-5993-closed-issuable.yml new file mode 100644 index 00000000000..b324566313f --- /dev/null +++ b/changelogs/unreleased/adam-finish-5993-closed-issuable.yml @@ -0,0 +1,4 @@ +--- +title: Add indication for closed or merged issuables in GFM +merge_request: 9462 +author: Adam Buckland diff --git a/changelogs/unreleased/add-field-for-group-name.yml b/changelogs/unreleased/add-field-for-group-name.yml new file mode 100644 index 00000000000..0fe511a4fa1 --- /dev/null +++ b/changelogs/unreleased/add-field-for-group-name.yml @@ -0,0 +1,4 @@ +--- +title: Add a name field to the group form +merge_request: 9891 +author: Douglas Lovell diff --git a/changelogs/unreleased/add-ui-for-trigger-schedule.yml b/changelogs/unreleased/add-ui-for-trigger-schedule.yml new file mode 100644 index 00000000000..9ca78983605 --- /dev/null +++ b/changelogs/unreleased/add-ui-for-trigger-schedule.yml @@ -0,0 +1,4 @@ +--- +title: Add UI for Trigger Schedule +merge_request: 10533 +author: dosuken123 diff --git a/changelogs/unreleased/clean_carrierwave_tempfiles.yml b/changelogs/unreleased/clean_carrierwave_tempfiles.yml new file mode 100644 index 00000000000..53fa69700ff --- /dev/null +++ b/changelogs/unreleased/clean_carrierwave_tempfiles.yml @@ -0,0 +1,4 @@ +--- +title: Periodically clean up temporary upload files to recover storage space +merge_request: 9466 +author: blackst0ne diff --git a/changelogs/unreleased/dz-hide-zero-counter.yml b/changelogs/unreleased/dz-hide-zero-counter.yml new file mode 100644 index 00000000000..45f35044c48 --- /dev/null +++ b/changelogs/unreleased/dz-hide-zero-counter.yml @@ -0,0 +1,4 @@ +--- +title: Hide header counters for issue/mr/todos if zero +merge_request: 10506 +author: diff --git a/changelogs/unreleased/metrics-button-misplaced.yml b/changelogs/unreleased/metrics-button-misplaced.yml new file mode 100644 index 00000000000..6c685ff32a5 --- /dev/null +++ b/changelogs/unreleased/metrics-button-misplaced.yml @@ -0,0 +1,4 @@ +--- +title: Moved the monitoring button inside the show view for the environments page +merge_request: +author: diff --git a/changelogs/unreleased/new-resolvable-discussion.yml b/changelogs/unreleased/new-resolvable-discussion.yml new file mode 100644 index 00000000000..f4dc4ea3ede --- /dev/null +++ b/changelogs/unreleased/new-resolvable-discussion.yml @@ -0,0 +1,4 @@ +--- +title: Add option to start a new resolvable discussion in an MR +merge_request: 7527 +author: diff --git a/changelogs/unreleased/optimise-builds-view.yml b/changelogs/unreleased/optimise-builds-view.yml new file mode 100644 index 00000000000..1d715ab4f47 --- /dev/null +++ b/changelogs/unreleased/optimise-builds-view.yml @@ -0,0 +1,4 @@ +--- +title: Optimise builds endpoint +merge_request: +author: diff --git a/changelogs/unreleased/remove_is_admin.yml b/changelogs/unreleased/remove_is_admin.yml new file mode 100644 index 00000000000..f6baf1942de --- /dev/null +++ b/changelogs/unreleased/remove_is_admin.yml @@ -0,0 +1,4 @@ +--- +title: Remove the User#is_admin? method +merge_request: 10520 +author: blackst0ne diff --git a/config/routes.rb b/config/routes.rb index 1a851da6203..1da226a3b57 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -39,6 +39,12 @@ Rails.application.routes.draw do # Health check get 'health_check(/:checks)' => 'health_check#index', as: :health_check + scope path: '-', controller: 'health' do + get :liveness + get :readiness + get :metrics + end + # Koding route get 'koding' => 'koding#index' diff --git a/config/routes/project.rb b/config/routes/project.rb index e38f0537143..f5009186344 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -135,6 +135,8 @@ constraints(ProjectUrlConstrainer.new) do end resources :protected_branches, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } + resources :protected_tags, only: [:index, :show, :create, :update, :destroy], constraints: { id: Gitlab::Regex.git_reference_regex } + resources :variables, only: [:index, :show, :update, :create, :destroy] resources :triggers, only: [:index, :create, :edit, :update, :destroy] do member do diff --git a/config/webpack.config.js b/config/webpack.config.js index dc431e4d566..e3bc939d578 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -41,6 +41,7 @@ var config = { pdf_viewer: './blob/pdf_viewer.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', + protected_tags: './protected_tags', snippet: './snippet/snippet_bundle.js', stl_viewer: './blob/stl_viewer.js', terminal: './terminal/terminal_bundle.js', @@ -48,6 +49,7 @@ var config = { users: './users/users_bundle.js', vue_pipelines: './vue_pipelines_index/index.js', issue_show: './issue_show/index.js', + group: './group.js', }, output: { diff --git a/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb b/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb new file mode 100644 index 00000000000..d56d83ca1d3 --- /dev/null +++ b/db/migrate/20161128095517_add_in_reply_to_discussion_id_to_sent_notifications.rb @@ -0,0 +1,29 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddInReplyToDiscussionIdToSentNotifications < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + add_column :sent_notifications, :in_reply_to_discussion_id, :string + end +end diff --git a/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb b/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb new file mode 100644 index 00000000000..aa64f2dddca --- /dev/null +++ b/db/migrate/20170305180853_add_auto_cancel_pending_pipelines_to_project.rb @@ -0,0 +1,15 @@ +class AddAutoCancelPendingPipelinesToProject < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:projects, :auto_cancel_pending_pipelines, :integer, default: 0) + end + + def down + remove_column(:projects, :auto_cancel_pending_pipelines) + end +end diff --git a/db/migrate/20170309173138_create_protected_tags.rb b/db/migrate/20170309173138_create_protected_tags.rb new file mode 100644 index 00000000000..796f3c90344 --- /dev/null +++ b/db/migrate/20170309173138_create_protected_tags.rb @@ -0,0 +1,27 @@ +class CreateProtectedTags < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + GITLAB_ACCESS_MASTER = 40 + + def change + create_table :protected_tags do |t| + t.integer :project_id, null: false + t.string :name, null: false + t.timestamps null: false + end + + add_index :protected_tags, :project_id + + create_table :protected_tag_create_access_levels do |t| + t.references :protected_tag, index: { name: "index_protected_tag_create_access" }, foreign_key: true, null: false + t.integer :access_level, default: GITLAB_ACCESS_MASTER, null: true + t.references :user, foreign_key: true, index: true + t.integer :group_id + t.timestamps null: false + end + + add_foreign_key :protected_tag_create_access_levels, :namespaces, column: :group_id # rubocop: disable Migration/AddConcurrentForeignKey + end +end diff --git a/db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb b/db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb new file mode 100644 index 00000000000..1690ce90564 --- /dev/null +++ b/db/migrate/20170312114329_add_auto_canceled_by_id_to_pipeline.rb @@ -0,0 +1,9 @@ +class AddAutoCanceledByIdToPipeline < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_pipelines, :auto_canceled_by_id, :integer + end +end diff --git a/db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb b/db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb new file mode 100644 index 00000000000..1e7b02ecf0e --- /dev/null +++ b/db/migrate/20170312114529_add_auto_canceled_by_id_foreign_key_to_pipeline.rb @@ -0,0 +1,22 @@ +class AddAutoCanceledByIdForeignKeyToPipeline < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + on_delete = + if Gitlab::Database.mysql? + :nullify + else + 'SET NULL' + end + + add_concurrent_foreign_key :ci_pipelines, :ci_pipelines, column: :auto_canceled_by_id, on_delete: on_delete + end + + def down + remove_foreign_key :ci_pipelines, column: :auto_canceled_by_id + end +end diff --git a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb index 0237c3189a5..9d4380ef960 100644 --- a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb +++ b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb @@ -15,7 +15,7 @@ class RemoveIndexForUsersCurrentSignInAt < ActiveRecord::Migration if Gitlab::Database.postgresql? execute 'DROP INDEX CONCURRENTLY index_users_on_current_sign_in_at;' else - remove_index :users, :current_sign_in_at + remove_concurrent_index :users, :current_sign_in_at end end end diff --git a/db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb b/db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb new file mode 100644 index 00000000000..c1d803b4308 --- /dev/null +++ b/db/migrate/20170406114958_add_auto_canceled_by_id_to_ci_builds.rb @@ -0,0 +1,9 @@ +class AddAutoCanceledByIdToCiBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_builds, :auto_canceled_by_id, :integer + end +end diff --git a/db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb b/db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb new file mode 100644 index 00000000000..3004683933b --- /dev/null +++ b/db/migrate/20170406115029_add_auto_canceled_by_id_foreign_key_to_ci_builds.rb @@ -0,0 +1,22 @@ +class AddAutoCanceledByIdForeignKeyToCiBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + on_delete = + if Gitlab::Database.mysql? + :nullify + else + 'SET NULL' + end + + add_concurrent_foreign_key :ci_builds, :ci_pipelines, column: :auto_canceled_by_id, on_delete: on_delete + end + + def down + remove_foreign_key :ci_builds, column: :auto_canceled_by_id + end +end diff --git a/db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb b/db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb new file mode 100644 index 00000000000..523a306f127 --- /dev/null +++ b/db/migrate/20170407114956_add_ref_to_ci_trigger_schedule.rb @@ -0,0 +1,9 @@ +class AddRefToCiTriggerSchedule < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_trigger_schedules, :ref, :string + end +end diff --git a/db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb b/db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb new file mode 100644 index 00000000000..36892118ac0 --- /dev/null +++ b/db/migrate/20170407122426_add_active_to_ci_trigger_schedule.rb @@ -0,0 +1,9 @@ +class AddActiveToCiTriggerSchedule < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_trigger_schedules, :active, :boolean + end +end diff --git a/db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb b/db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb new file mode 100644 index 00000000000..626c2a67fdc --- /dev/null +++ b/db/migrate/20170407140450_add_index_to_next_run_at_and_active.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexToNextRunAtAndActive < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_trigger_schedules, [:active, :next_run_at] + end + + def down + remove_concurrent_index :ci_trigger_schedules, [:active, :next_run_at] + end +end diff --git a/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb b/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb new file mode 100644 index 00000000000..0c3b3bd5eb3 --- /dev/null +++ b/db/post_migrate/20170404170532_remove_notes_original_discussion_id.rb @@ -0,0 +1,23 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveNotesOriginalDiscussionId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + remove_column :notes, :original_discussion_id, :string + end +end diff --git a/db/post_migrate/20170408033905_remove_old_cache_directories.rb b/db/post_migrate/20170408033905_remove_old_cache_directories.rb new file mode 100644 index 00000000000..b23b52896b9 --- /dev/null +++ b/db/post_migrate/20170408033905_remove_old_cache_directories.rb @@ -0,0 +1,23 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +# Remove all files from old custom carrierwave's cache directories. +# See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9466 + +class RemoveOldCacheDirectories < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + # FileUploader cache. + FileUtils.rm_rf(Dir[Rails.root.join('public', 'uploads', 'tmp', '*')]) + end + + def down + # Old cache is not supposed to be recoverable. + # So the down method is empty. + end +end diff --git a/db/schema.rb b/db/schema.rb index a7a8f5bd38f..3422847d729 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,8 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170405080720) do - +ActiveRecord::Schema.define(version: 20170408033905) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "pg_trgm" @@ -223,6 +222,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.string "token" t.integer "lock_version" t.string "coverage_regex" + t.integer "auto_canceled_by_id" end add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -251,6 +251,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.integer "duration" t.integer "user_id" t.integer "lock_version" + t.integer "auto_canceled_by_id" end add_index "ci_pipelines", ["project_id", "ref", "status"], name: "index_ci_pipelines_on_project_id_and_ref_and_status", using: :btree @@ -309,8 +310,11 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.string "cron" t.string "cron_timezone" t.datetime "next_run_at" + t.string "ref" + t.boolean "active" end + add_index "ci_trigger_schedules", ["active", "next_run_at"], name: "index_ci_trigger_schedules_on_active_and_next_run_at", using: :btree add_index "ci_trigger_schedules", ["next_run_at"], name: "index_ci_trigger_schedules_on_next_run_at", using: :btree add_index "ci_trigger_schedules", ["project_id"], name: "index_ci_trigger_schedules_on_project_id", using: :btree @@ -752,7 +756,6 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.datetime "resolved_at" t.integer "resolved_by_id" t.string "discussion_id" - t.string "original_discussion_id" t.text "note_html" end @@ -947,6 +950,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.boolean "lfs_enabled" t.text "description_html" t.boolean "only_allow_merge_if_all_discussions_are_resolved" + t.integer "auto_cancel_pending_pipelines", default: 0, null: false t.boolean "printing_merge_request_link_enabled", default: true, null: false t.string "import_jid" end @@ -993,6 +997,27 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_index "protected_branches", ["project_id"], name: "index_protected_branches_on_project_id", using: :btree + create_table "protected_tag_create_access_levels", force: :cascade do |t| + t.integer "protected_tag_id", null: false + t.integer "access_level", default: 40 + t.integer "user_id" + t.integer "group_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "protected_tag_create_access_levels", ["protected_tag_id"], name: "index_protected_tag_create_access", using: :btree + add_index "protected_tag_create_access_levels", ["user_id"], name: "index_protected_tag_create_access_levels_on_user_id", using: :btree + + create_table "protected_tags", force: :cascade do |t| + t.integer "project_id", null: false + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "protected_tags", ["project_id"], name: "index_protected_tags_on_project_id", using: :btree + create_table "releases", force: :cascade do |t| t.string "tag" t.text "description" @@ -1028,6 +1053,7 @@ ActiveRecord::Schema.define(version: 20170405080720) do t.string "line_code" t.string "note_type" t.text "position" + t.string "in_reply_to_discussion_id" end add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree @@ -1328,6 +1354,8 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_foreign_key "boards", "projects" add_foreign_key "chat_teams", "namespaces", on_delete: :cascade + add_foreign_key "ci_builds", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_a2141b1522", on_delete: :nullify + add_foreign_key "ci_pipelines", "ci_pipelines", column: "auto_canceled_by_id", name: "fk_262d4c2d19", on_delete: :nullify add_foreign_key "ci_trigger_schedules", "ci_triggers", column: "trigger_id", name: "fk_90a406cc94", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade add_foreign_key "container_repositories", "projects" @@ -1348,6 +1376,9 @@ ActiveRecord::Schema.define(version: 20170405080720) do add_foreign_key "project_statistics", "projects", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "protected_branches" add_foreign_key "protected_branch_push_access_levels", "protected_branches" + add_foreign_key "protected_tag_create_access_levels", "namespaces", column: "group_id" + add_foreign_key "protected_tag_create_access_levels", "protected_tags" + add_foreign_key "protected_tag_create_access_levels", "users" add_foreign_key "subscriptions", "projects", on_delete: :cascade add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade add_foreign_key "timelogs", "issues", name: "fk_timelogs_issues_issue_id", on_delete: :cascade diff --git a/doc/README.md b/doc/README.md index df11d5e49a8..b0b3d2156a7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,62 +18,62 @@ All technical content published by GitLab lives in the documentation, including: - [Account Security](user/profile/account/two_factor_authentication.md) Securing your account via two-factor authentication, etc. - [API](api/README.md) Automate GitLab via a simple and powerful API. - [CI/CD](ci/README.md) GitLab Continuous Integration (CI) and Continuous Delivery (CD) getting started, `.gitlab-ci.yml` options, and examples. -- [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab. - [Container Registry](user/project/container_registry.md) Learn how to use GitLab Container Registry. +- [Git Attributes](user/project/git_attributes.md) Managing Git attributes using a `.gitattributes` file. +- [Git cheatsheet](https://gitlab.com/gitlab-com/marketing/raw/master/design/print/git-cheatsheet/print-pdf/git-cheatsheet.pdf) Download a PDF describing the most used Git operations. +- [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab. - [GitLab basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. - [GitLab Pages](user/project/pages/index.md) Using GitLab Pages. -- [Importing to GitLab](workflow/importing/README.md) Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab. - [Importing and exporting projects between instances](user/project/settings/import_export.md). +- [Importing to GitLab](workflow/importing/README.md) Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab. - [Markdown](user/markdown.md) GitLab's advanced formatting system. - [Migrating from SVN](workflow/importing/migrating_from_svn.md) Convert a SVN repository to Git and GitLab. - [Permissions](user/permissions.md) Learn what each role in a project (external/guest/reporter/developer/master/owner) can do. - [Profile Settings](profile/README.md) - [Project Services](user/project/integrations/project_services.md) Integrate a project with external services, such as CI and chat. - [Public access](public_access/public_access.md) Learn how you can allow public and internal access to projects. +- [Search through GitLab](user/search/index.md): Search for issues, merge requests, projects, groups, todos, and issues in Issue Boards. - [Snippets](user/snippets.md) Snippets allow you to create little bits of code. - [SSH](ssh/README.md) Setup your ssh keys and deploy keys for secure access to your projects. - [Webhooks](user/project/integrations/webhooks.md) Let GitLab notify you when new code has been pushed to your project. - [Workflow](workflow/README.md) Using GitLab functionality and importing projects from GitHub and SVN. -- [Git Attributes](user/project/git_attributes.md) Managing Git attributes using a `.gitattributes` file. -- [Git cheatsheet](https://gitlab.com/gitlab-com/marketing/raw/master/design/print/git-cheatsheet/print-pdf/git-cheatsheet.pdf) Download a PDF describing the most used Git operations. ## Administrator documentation - [Access restrictions](user/admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols) Define which Git access protocols can be used to talk to GitLab -- [Authentication/Authorization](administration/auth/README.md) Configure - external authentication with LDAP, SAML, CAS and additional Omniauth providers. +- [Authentication/Authorization](administration/auth/README.md) Configure external authentication with LDAP, SAML, CAS and additional Omniauth providers. +- [Container Registry](administration/container_registry.md) Configure Docker Registry with GitLab. - [Custom Git hooks](administration/custom_hooks.md) Custom Git hooks (on the filesystem) for when webhooks aren't enough. +- [Debugging Tips](administration/troubleshooting/debug.md) Tips to debug problems when things go wrong +- [Environment Variables](administration/environment_variables.md) to configure GitLab. +- [Git LFS configuration](workflow/lfs/lfs_administration.md) +- [GitLab Pages configuration](administration/pages/index.md) Configure GitLab Pages. +- [GitLab performance monitoring with InfluxDB](administration/monitoring/performance/introduction.md) Configure GitLab and InfluxDB for measuring performance metrics. +- [GitLab performance monitoring with Prometheus](administration/monitoring/prometheus/index.md) Configure GitLab and Prometheus for measuring performance metrics. +- [Header logo](customization/branded_page_and_email_header.md) Change the logo on the overall page and email header. +- [High Availability](administration/high_availability/README.md) Configure multiple servers for scaling or high availability. +- [Housekeeping](administration/housekeeping.md) Keep your Git repository tidy and fast. - [Install](install/README.md) Requirements, directory structures and installation from source. -- [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components. - [Integration](integration/README.md) How to integrate with systems such as JIRA, Redmine, Twitter. - [Issue closing pattern](administration/issue_closing_pattern.md) Customize how to close an issue from commit messages. - [Koding](administration/integration/koding.md) Set up Koding to use with GitLab. -- [Web terminals](administration/integration/terminal.md) Provide terminal access to environments from within GitLab. - [Libravatar](customization/libravatar.md) Use Libravatar instead of Gravatar for user avatars. - [Log system](administration/logs.md) Log system. -- [Environment Variables](administration/environment_variables.md) to configure GitLab. +- [Migrate GitLab CI to CE/EE](migrate_ci_to_ce/README.md) Follow this guide to migrate your existing GitLab CI data to GitLab CE/EE. +- [Monitoring uptime](user/admin_area/monitoring/health_check.md) Check the server status using the health check endpoint. - [Operations](administration/operations.md) Keeping GitLab up and running. - [Raketasks](raketasks/README.md) Backups, maintenance, automatic webhook setup and the importing of projects. +- [Reply by email](administration/reply_by_email.md) Allow users to comment on issues and merge requests by replying to notification emails. - [Repository checks](administration/repository_checks.md) Periodic Git repository checks. - [Repository storage paths](administration/repository_storage_paths.md) Manage the paths used to store repositories. +- [Request Profiling](administration/monitoring/performance/request_profiling.md) Get a detailed profile on slow requests. +- [Restart GitLab](administration/restart_gitlab.md) Learn how to restart GitLab and its components. - [Security](security/README.md) Learn what you can do to further secure your GitLab instance. +- [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs. - [System hooks](system_hooks/system_hooks.md) Notifications when users, projects and keys are changed. - [Update](update/README.md) Update guides to upgrade your installation. +- [Web terminals](administration/integration/terminal.md) Provide terminal access to environments from within GitLab. - [Welcome message](customization/welcome_message.md) Add a custom welcome message to the sign-in page. -- [Header logo](customization/branded_page_and_email_header.md) Change the logo on the overall page and email header. -- [Reply by email](administration/reply_by_email.md) Allow users to comment on issues and merge requests by replying to notification emails. -- [Migrate GitLab CI to CE/EE](migrate_ci_to_ce/README.md) Follow this guide to migrate your existing GitLab CI data to GitLab CE/EE. -- [Git LFS configuration](workflow/lfs/lfs_administration.md) -- [Housekeeping](administration/housekeeping.md) Keep your Git repository tidy and fast. -- [GitLab Pages configuration](administration/pages/index.md) Configure GitLab Pages. -- [GitLab performance monitoring with InfluxDB](administration/monitoring/performance/introduction.md) Configure GitLab and InfluxDB for measuring performance metrics. -- [GitLab performance monitoring with Prometheus](administration/monitoring/prometheus/index.md) Configure GitLab and Prometheus for measuring performance metrics. -- [Request Profiling](administration/monitoring/performance/request_profiling.md) Get a detailed profile on slow requests. -- [Monitoring uptime](user/admin_area/monitoring/health_check.md) Check the server status using the health check endpoint. -- [Debugging Tips](administration/troubleshooting/debug.md) Tips to debug problems when things go wrong -- [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md) Debug when Sidekiq appears hung and is not processing jobs. -- [High Availability](administration/high_availability/README.md) Configure multiple servers for scaling or high availability. -- [Container Registry](administration/container_registry.md) Configure Docker Registry with GitLab. ## Contributor documentation diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 53c29a4fd98..045d3821f66 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -311,7 +311,7 @@ Running on runner-8a2f473d-project-1796893-concurrent-0 via runner-8a2f473d-mach ++ export GITLAB_USER_ID=42 ++ GITLAB_USER_ID=42 ++ export GITLAB_USER_EMAIL=user@example.com -++ GITLAB_USER_EMAIL=axilleas@axilleas.me +++ GITLAB_USER_EMAIL=user@example.com ++ export VERY_SECURE_VARIABLE=imaverysecurevariable ++ VERY_SECURE_VARIABLE=imaverysecurevariable ++ mkdir -p /builds/gitlab-examples/ci-debug-trace.tmp diff --git a/doc/gitlab-basics/create-group.md b/doc/gitlab-basics/create-group.md index 64274ccd5eb..b4889bb8818 100644 --- a/doc/gitlab-basics/create-group.md +++ b/doc/gitlab-basics/create-group.md @@ -25,6 +25,8 @@ To create a group: 1. Set the "Group path" which will be the namespace under which your projects will be hosted (path can contain only letters, digits, underscores, dashes and dots; it cannot start with dashes or end in dot). + 1. The "Group name" will populate with the path. Optionally, you can change + it. This is the name that will display in the group views. 1. Optionally, you can add a description so that others can briefly understand what this group is about. 1. Optionally, choose and avatar for your project. diff --git a/doc/gitlab-basics/img/create_new_group_info.png b/doc/gitlab-basics/img/create_new_group_info.png Binary files differindex 020b4ac00d6..8d2501d9f7a 100644 --- a/doc/gitlab-basics/img/create_new_group_info.png +++ b/doc/gitlab-basics/img/create_new_group_info.png diff --git a/doc/university/glossary/README.md b/doc/university/glossary/README.md index ec565c3e7bf..0b17e4ff7c1 100644 --- a/doc/university/glossary/README.md +++ b/doc/university/glossary/README.md @@ -411,6 +411,10 @@ An [object-relational](https://en.wikipedia.org/wiki/PostgreSQL) database. Toute A [feature](https://docs.gitlab.com/ce/user/project/protected_branches.html) that protects branches from unauthorized pushes, force pushing or deletion. +### Protected Tags + +A [feature](https://docs.gitlab.com/ce/user/project/protected_tags.html) that protects tags from unauthorized creation, update or deletion + ### Pull Git command to [synchronize](https://git-scm.com/docs/git-pull) the local repository with the remote repository, by fetching all remote changes and merging them into the local repository. diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 0ea6d01411f..3122e95fc0e 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -55,6 +55,7 @@ The following table depicts the various user permission levels in a project. | Push to protected branches | | | | ✓ | ✓ | | Enable/disable branch protection | | | | ✓ | ✓ | | Turn on/off protected branch push for devs| | | | ✓ | ✓ | +| Enable/disable tag protections | | | | ✓ | ✓ | | Rewrite/remove Git tags | | | | ✓ | ✓ | | Edit project | | | | ✓ | ✓ | | Add deploy keys to project | | | | ✓ | ✓ | diff --git a/doc/user/project/img/project_repository_settings.png b/doc/user/project/img/project_repository_settings.png Binary files differnew file mode 100644 index 00000000000..1aa7efc36f1 --- /dev/null +++ b/doc/user/project/img/project_repository_settings.png diff --git a/doc/user/project/img/protected_tag_matches.png b/doc/user/project/img/protected_tag_matches.png Binary files differnew file mode 100644 index 00000000000..a36a11a1271 --- /dev/null +++ b/doc/user/project/img/protected_tag_matches.png diff --git a/doc/user/project/img/protected_tags_list.png b/doc/user/project/img/protected_tags_list.png Binary files differnew file mode 100644 index 00000000000..c5e42dc0705 --- /dev/null +++ b/doc/user/project/img/protected_tags_list.png diff --git a/doc/user/project/img/protected_tags_page.png b/doc/user/project/img/protected_tags_page.png Binary files differnew file mode 100644 index 00000000000..3848d91ebd6 --- /dev/null +++ b/doc/user/project/img/protected_tags_page.png diff --git a/doc/user/project/img/protected_tags_permissions_dropdown.png b/doc/user/project/img/protected_tags_permissions_dropdown.png Binary files differnew file mode 100644 index 00000000000..9e0fc4e2a43 --- /dev/null +++ b/doc/user/project/img/protected_tags_permissions_dropdown.png diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index e02f81fd972..f611029afdc 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -101,7 +101,7 @@ in the table below. | `Project key` | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. | | `Username` | The user name created in [configuring JIRA step](#configuring-jira). | | `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). | -| `JIRA issue transition` | This is the ID of a transition that moves issues to a closed state. You can find this number under JIRA workflow administration ([see screenshot](img/jira_workflow_screenshot.png)). | +| `JIRA issue transition` | This is the ID of a transition that moves issues to a closed state. You can find this number under JIRA workflow administration ([see screenshot](img/jira_workflow_screenshot.png)). **Closing JIRA issues via commits or Merge Requests won't work if you don't set the ID correctly.** | After saving the configuration, your GitLab project will be able to interact with the linked JIRA project. diff --git a/doc/user/project/pipelines/settings.md b/doc/user/project/pipelines/settings.md index c398ac2eb25..88246e22391 100644 --- a/doc/user/project/pipelines/settings.md +++ b/doc/user/project/pipelines/settings.md @@ -60,6 +60,14 @@ anyone and those logged in respectively. If you wish to hide it so that only the members of the project or group have access to it, uncheck the **Public pipelines** checkbox and save the changes. +## Auto-cancel pending pipelines + +> [Introduced][ce-9362] in GitLab 9.1. + +If you want to auto-cancel all pending non-HEAD pipelines on branch, when +new pipeline will be created (after your git push or manually from UI), +check **Auto-cancel pending pipelines** checkbox and save the changes. + ## Badges In the pipelines settings page you can find pipeline status and test coverage @@ -111,3 +119,4 @@ into your `README.md`: [var]: ../../../ci/yaml/README.md#git-strategy [coverage report]: #test-coverage-parsing +[ce-9362]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9362 diff --git a/doc/user/project/protected_tags.md b/doc/user/project/protected_tags.md new file mode 100644 index 00000000000..0cb7aefdb2f --- /dev/null +++ b/doc/user/project/protected_tags.md @@ -0,0 +1,60 @@ +# Protected Tags + +> [Introduced][ce-10356] in GitLab 9.1. + +Protected Tags allow control over who has permission to create tags as well as preventing accidental update or deletion once created. Each rule allows you to match either an individual tag name, or use wildcards to control multiple tags at once. + +This feature evolved out of [Protected Branches](protected_branches.md) + +## Overview + +Protected tags will prevent anyone from updating or deleting the tag, as and will prevent creation of matching tags based on the permissions you have selected. By default, anyone without Master permission will be prevented from creating tags. + + +## Configuring protected tags + +To protect a tag, you need to have at least Master permission level. + +1. Navigate to the project's Settings -> Repository page + + ![Repository Settings](img/project_repository_settings.png) + +1. From the **Tag** dropdown menu, select the tag you want to protect or type and click `Create wildcard`. In the screenshot below, we chose to protect all tags matching `v*`. + + ![Protected tags page](img/protected_tags_page.png) + +1. From the `Allowed to create` dropdown, select who will have permission to create matching tags and then click `Protect`. + + ![Allowed to create tags dropdown](img/protected_tags_permissions_dropdown.png) + +1. Once done, the protected tag will appear in the "Protected tags" list. + + ![Protected tags list](img/protected_tags_list.png) + +## Wildcard protected tags + +You can specify a wildcard protected tag, which will protect all tags +matching the wildcard. For example: + +| Wildcard Protected Tag | Matching Tags | +|------------------------+-------------------------------| +| `v*` | `v1.0.0`, `version-9.1` | +| `*-deploy` | `march-deploy`, `1.0-deploy` | +| `*gitlab*` | `gitlab`, `gitlab/v1` | +| `*` | `v1.0.1rc2`, `accidental-tag` | + + +Two different wildcards can potentially match the same tag. For example, +`*-stable` and `production-*` would both match a `production-stable` tag. +In that case, if _any_ of these protected tags have a setting like +"Allowed to create", then `production-stable` will also inherit this setting. + +If you click on a protected tag's name, you will be presented with a list of +all matching tags: + +![Protected tag matches](img/protected_tag_matches.png) + + +--- + +[ce-10356]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10356 "Protected Tags" diff --git a/doc/user/search/img/filter_issues_project.gif b/doc/user/search/img/filter_issues_project.gif Binary files differnew file mode 100644 index 00000000000..d547588be5d --- /dev/null +++ b/doc/user/search/img/filter_issues_project.gif diff --git a/doc/user/search/img/issues_any_assignee.png b/doc/user/search/img/issues_any_assignee.png Binary files differnew file mode 100755 index 00000000000..2f902bcc66c --- /dev/null +++ b/doc/user/search/img/issues_any_assignee.png diff --git a/doc/user/search/img/issues_assigned_to_you.png b/doc/user/search/img/issues_assigned_to_you.png Binary files differnew file mode 100755 index 00000000000..36c670eedd5 --- /dev/null +++ b/doc/user/search/img/issues_assigned_to_you.png diff --git a/doc/user/search/img/issues_author.png b/doc/user/search/img/issues_author.png Binary files differnew file mode 100755 index 00000000000..792f9746db6 --- /dev/null +++ b/doc/user/search/img/issues_author.png diff --git a/doc/user/search/img/issues_mrs_shortcut.png b/doc/user/search/img/issues_mrs_shortcut.png Binary files differnew file mode 100755 index 00000000000..6380b337b54 --- /dev/null +++ b/doc/user/search/img/issues_mrs_shortcut.png diff --git a/doc/user/search/img/left_menu_bar.png b/doc/user/search/img/left_menu_bar.png Binary files differnew file mode 100755 index 00000000000..d68a71cba8e --- /dev/null +++ b/doc/user/search/img/left_menu_bar.png diff --git a/doc/user/search/img/project_search.png b/doc/user/search/img/project_search.png Binary files differnew file mode 100755 index 00000000000..3150b40de29 --- /dev/null +++ b/doc/user/search/img/project_search.png diff --git a/doc/user/search/img/search_issues_board.png b/doc/user/search/img/search_issues_board.png Binary files differnew file mode 100755 index 00000000000..84048ae6a02 --- /dev/null +++ b/doc/user/search/img/search_issues_board.png diff --git a/doc/user/search/img/sort_projects.png b/doc/user/search/img/sort_projects.png Binary files differnew file mode 100755 index 00000000000..9bf2770b299 --- /dev/null +++ b/doc/user/search/img/sort_projects.png diff --git a/doc/user/search/index.md b/doc/user/search/index.md new file mode 100644 index 00000000000..9d1ca1adcb2 --- /dev/null +++ b/doc/user/search/index.md @@ -0,0 +1,94 @@ +# Search through GitLab + +## Issues and merge requests + +To search through issues and merge requests in multiple projects, you can use the left-sidebar. + +Click the menu bar, then **Issues** or **Merge Requests**, which work in the same way, +therefore, the following notes are valid for both. + +The number displayed on their right represents the number of issues and merge requests assigned to you. + +![menu bar - issues and MRs assigned to you](img/left_menu_bar.png) + +When you click **Issues**, you'll see the opened issues assigned to you straight away: + +![Issues assigned to you](img/issues_assigned_to_you.png) + +You can filter them by **Author**, **Assignee**, **Milestone**, and **Labels**, +searching through **Open**, **Closed**, and **All** issues. + +Of course, you can combine all filters together. + +### Issues and MRs assigned to you or created by you + +You'll find a shortcut to issues and merge requests create by you or assigned to you +on the search field on the top-right of your screen: + +![shortcut to your issues and mrs](img/issues_mrs_shortcut.png) + +## Issues and merge requests per project + +If you want to search for issues present in a specific project, navigate to +a project's **Issues** tab, and click on the field **Search or filter results...**. It will +display a dropdown menu, from which you can add filters per author, assignee, milestone, label, +and weight. When done, press **Enter** on your keyboard to filter the issues. + +![filter issues in a project](img/filter_issues_project.gif) + +The same process is valid for merge requests. Navigate to your project's **Merge Requests** tab, +and click **Search or filter results...**. Merge requests can be filtered by author, assignee, +milestone, and label. + +### Shortcut + +You'll also find a shortcut on the search field on the top-right of the project's dashboard to +quickly access issues and merge requests created or assigned to you within that project: + +![search per project - shortcut](img/project_search.png) + +## Todos + +Your [todos](../../workflow/todos.md#gitlab-todos) can be searched by "to do" and "done". +You can [filter](../../workflow/todos.md#filtering-your-todos) them per project, +author, type, and action. Also, you can sort them by +[**Label priority**](../../user/project/labels.md#prioritize-labels), +**Last created** and **Oldest created**. + +## Projects + +You can search through your projects from the left menu, by clicking the menu bar, then **Projects**. +On the field **Filter by name**, type the project or group name you want to find, and GitLab +will filter them for you as you type. + +You can also look for the projects you starred (**Starred projects**), and **Explore** all +public and internal projects available in GitLab.com, from which you can filter by visibitily, +through **Trending**, best rated with **Most starts**, or **All** of them. + +You can also sort them by **Name**, **Last created**, **Oldest created**, **Last updated**, +**Oldest updated**, **Owner**, and choose to hide or show **archived projects**: + +![sort projects](img/sort_projects.png) + +## Groups + +Similarly to [projects search](#projects), you can search through your groups from +the left menu, by clicking the menu bar, then **Groups**. + +On the field **Filter by name**, type the group name you want to find, and GitLab +will filter them for you as you type. + +You can also **Explore** all public and internal groups available in GitLab.com, +and sort them by **Last created**, **Oldest created**, **Last updated**, or **Oldest updated**. + +## Issue Boards + +From an [Issue Board](../../user/project/issue_board.md), you can filter issues by **Author**, **Assignee**, **Milestone**, and **Labels**. +You can also filter them by name (issue title), from the field **Filter by name**, which is loaded as you type. + +When you want to search for issues to add to lists present in your Issue Board, click +the button **Add issues** on the top-right of your screen, opening a modal window from which +you'll be able to, besides filtering them by **Name**, **Author**, **Assignee**, **Milestone**, +and **Labels**, select multiple issues to add to a list of your choice: + +![search and select issues to add to board](img/search_issues_board.png) diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 6a8de51a199..a1852650cfb 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -20,6 +20,7 @@ - [Project forking workflow](forking_workflow.md) - [Project users](add-user/add-user.md) - [Protected branches](../user/project/protected_branches.md) +- [Protected tags](../user/project/protected_tags.md) - [Slash commands](../user/project/slash_commands.md) - [Sharing a project with a group](share_with_group.md) - [Share projects with other groups](share_projects_with_other_groups.md) diff --git a/doc/workflow/groups.md b/doc/workflow/groups.md index 6237a5d5e18..882747e14e9 100644 --- a/doc/workflow/groups.md +++ b/doc/workflow/groups.md @@ -11,9 +11,9 @@ You can create a group by going to the 'Groups' tab of the GitLab dashboard and ![Click the 'New group' button in the 'Groups' tab](groups/new_group_button.png) -Next, enter the name (required) and the optional description and group avatar. +Next, enter the path and name (required) and the optional description and group avatar. -![Fill in the name for your new group](groups/new_group_form.png) +![Fill in the path for your new group](groups/new_group_form.png) When your group has been created you are presented with the group dashboard feed, which will be empty. diff --git a/doc/workflow/groups/new_group_form.png b/doc/workflow/groups/new_group_form.png Binary files differindex 0d798cd4b84..91727ab5336 100644 --- a/doc/workflow/groups/new_group_form.png +++ b/doc/workflow/groups/new_group_form.png diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 263ec321a34..3985fe8f2f7 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -347,6 +347,9 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I should see a discussion by user "John Doe" has started on diff' do + # Trigger a refresh of notes + execute_script("$(document).trigger('visibilitychange');") + wait_for_ajax page.within(".notes .discussion") do page.should have_content "#{user_exists("John Doe").name} #{user_exists("John Doe").to_reference} started a discussion" page.should have_content sample_commit.line_code_path diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb index bdc7a616ba9..d7167352e02 100644 --- a/features/steps/project/merge_requests/acceptance.rb +++ b/features/steps/project/merge_requests/acceptance.rb @@ -1,6 +1,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps include LoginHelpers include GitlabRoutingHelper + include WaitForAjax step 'I am on the Merge Request detail page' do visit merge_request_path(@merge_request) @@ -20,10 +21,18 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps step 'I should see the Remove Source Branch button' do expect(page).to have_link('Remove source branch') + + # Wait for AJAX requests to complete so they don't blow up if they are + # only handled after `DatabaseCleaner` has already run + wait_for_ajax end step 'I should not see the Remove Source Branch button' do expect(page).not_to have_link('Remove source branch') + + # Wait for AJAX requests to complete so they don't blow up if they are + # only handled after `DatabaseCleaner` has already run + wait_for_ajax end step 'There is an open Merge Request' do diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index 4ee879fe922..15625e045f5 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -97,7 +97,7 @@ module SharedProject step 'I should see project "Shop" activity feed' do project = Project.find_by(name: "Shop") - expect(page).to have_content "pushed new branch fix at #{project.name_with_namespace}" + expect(page).to have_content "#{@user.name} pushed new branch fix at #{project.name_with_namespace}" end step 'I should see project settings' do @@ -251,7 +251,8 @@ module SharedProject step 'project "Shop" has CI build' do project = Project.find_by(name: "Shop") - create :ci_pipeline, project: project, sha: project.commit.sha, ref: 'master', status: 'skipped' + pipeline = create :ci_pipeline, project: project, sha: project.commit.sha, ref: 'master' + pipeline.skip end step 'I should see last commit with CI status' do diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 00d44821e3f..9919762cd82 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -14,7 +14,7 @@ module API class User < UserBasic expose :created_at - expose :is_admin?, as: :is_admin + expose :admin?, as: :is_admin expose :bio, :location, :skype, :linkedin, :twitter, :website_url, :organization end @@ -184,19 +184,15 @@ module API end expose :protected do |repo_branch, options| - options[:project].protected_branch?(repo_branch.name) + ProtectedBranch.protected?(options[:project], repo_branch.name) end expose :developers_can_push do |repo_branch, options| - project = options[:project] - access_levels = project.protected_branches.matching(repo_branch.name).map(&:push_access_levels).flatten - access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } + options[:project].protected_branches.developers_can?(:push, repo_branch.name) end expose :developers_can_merge do |repo_branch, options| - project = options[:project] - access_levels = project.protected_branches.matching(repo_branch.name).map(&:merge_access_levels).flatten - access_levels.any? { |access_level| access_level.access_level == Gitlab::Access::DEVELOPER } + options[:project].protected_branches.developers_can?(:merge, repo_branch.name) end end @@ -615,9 +611,9 @@ module API expose :locked expose :version, :revision, :platform, :architecture expose :contacted_at - expose :token, if: lambda { |runner, options| options[:current_user].is_admin? || !runner.is_shared? } + expose :token, if: lambda { |runner, options| options[:current_user].admin? || !runner.is_shared? } expose :projects, with: Entities::BasicProjectDetails do |runner, options| - if options[:current_user].is_admin? + if options[:current_user].admin? runner.projects else options[:current_user].authorized_projects.where(id: runner.projects) diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 605769eddde..32bbf956d7f 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -56,7 +56,7 @@ module API groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? groups = groups.reorder(params[:order_by] => params[:sort]) - present_groups groups, statistics: params[:statistics] && current_user.is_admin? + present_groups groups, statistics: params[:statistics] && current_user.admin? end desc 'Create a group. Available only for users who can create groups.' do diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 61527c1e20b..ddff3c8c1e8 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -118,7 +118,7 @@ module API def authenticated_as_admin! authenticate! - forbidden! unless current_user.is_admin? + forbidden! unless current_user.admin? end def authorize!(action, subject = :global) @@ -358,7 +358,7 @@ module API return unless sudo_identifier return unless initial_current_user - unless initial_current_user.is_admin? + unless initial_current_user.admin? forbidden!('Must be admin to use sudo') end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 56c597dffcb..70d0d57204d 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -142,7 +142,7 @@ module API project = Project.find_by_full_path(relative_path.sub(/\.(git|wiki)\z/, '')) begin - Gitlab::GitalyClient::Notifications.new(project.repository_storage, relative_path).post_receive + Gitlab::GitalyClient::Notifications.new(project.repository).post_receive rescue GRPC::Unavailable => e render_api_error(e, 500) end diff --git a/lib/api/notes.rb b/lib/api/notes.rb index de39e579ac3..e281e3230fd 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -78,7 +78,7 @@ module API } if can?(current_user, noteable_read_ability_name(noteable), noteable) - if params[:created_at] && (current_user.is_admin? || user_project.owner == current_user) + if params[:created_at] && (current_user.admin? || user_project.owner == current_user) opts[:created_at] = params[:created_at] end diff --git a/lib/api/runners.rb b/lib/api/runners.rb index a77c876a749..db6c7c59092 100644 --- a/lib/api/runners.rb +++ b/lib/api/runners.rb @@ -161,18 +161,18 @@ module API end def authenticate_show_runner!(runner) - return if runner.is_shared || current_user.is_admin? + return if runner.is_shared || current_user.admin? forbidden!("No access granted") unless user_can_access_runner?(runner) end def authenticate_update_runner!(runner) - return if current_user.is_admin? + return if current_user.admin? forbidden!("Runner is shared") if runner.is_shared? forbidden!("No access granted") unless user_can_access_runner?(runner) end def authenticate_delete_runner!(runner) - return if current_user.is_admin? + return if current_user.admin? forbidden!("Runner is shared") if runner.is_shared? forbidden!("Runner associated with more than one project") if runner.projects.count > 1 forbidden!("No access granted") unless user_can_access_runner?(runner) @@ -181,7 +181,7 @@ module API def authenticate_enable_runner!(runner) forbidden!("Runner is shared") if runner.is_shared? forbidden!("Runner is locked") if runner.locked? - return if current_user.is_admin? + return if current_user.admin? forbidden!("No access granted") unless user_can_access_runner?(runner) end diff --git a/lib/api/services.rb b/lib/api/services.rb index 65f86caaa51..23ef62c2258 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -642,7 +642,7 @@ module API service_params = declared_params(include_missing: false).merge(active: true) if service.update_attributes(service_params) - present service, with: Entities::ProjectService, include_passwords: current_user.is_admin? + present service, with: Entities::ProjectService, include_passwords: current_user.admin? else render_api_error!('400 Bad Request', 400) end @@ -673,7 +673,7 @@ module API end get ":id/services/:service_slug" do service = user_project.find_or_initialize_service(params[:service_slug].underscore) - present service, with: Entities::ProjectService, include_passwords: current_user.is_admin? + present service, with: Entities::ProjectService, include_passwords: current_user.admin? end end diff --git a/lib/api/users.rb b/lib/api/users.rb index 992a751b37d..6f40f92240a 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -56,10 +56,10 @@ module API users = users.active if params[:active] users = users.search(params[:search]) if params[:search].present? users = users.blocked if params[:blocked] - users = users.external if params[:external] && current_user.is_admin? + users = users.external if params[:external] && current_user.admin? end - entity = current_user.is_admin? ? Entities::UserPublic : Entities::UserBasic + entity = current_user.admin? ? Entities::UserPublic : Entities::UserBasic present paginate(users), with: entity end @@ -73,7 +73,7 @@ module API user = User.find_by(id: params[:id]) not_found!('User') unless user - if current_user && current_user.is_admin? + if current_user && current_user.admin? present user, with: Entities::UserPublic elsif can?(current_user, :read_user, user) present user, with: Entities::User diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb index 9b27411ae21..63d464b926b 100644 --- a/lib/api/v3/groups.rb +++ b/lib/api/v3/groups.rb @@ -54,7 +54,7 @@ module API groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present? groups = groups.reorder(params[:order_by] => params[:sort]) - present_groups groups, statistics: params[:statistics] && current_user.is_admin? + present_groups groups, statistics: params[:statistics] && current_user.admin? end desc 'Get list of owned groups for authenticated user' do diff --git a/lib/api/v3/notes.rb b/lib/api/v3/notes.rb index 4f8e0eff4ff..009ec5c6bbd 100644 --- a/lib/api/v3/notes.rb +++ b/lib/api/v3/notes.rb @@ -79,7 +79,7 @@ module API noteable = user_project.send(noteables_str.to_sym).find(params[:noteable_id]) if can?(current_user, noteable_read_ability_name(noteable), noteable) - if params[:created_at] && (current_user.is_admin? || user_project.owner == current_user) + if params[:created_at] && (current_user.admin? || user_project.owner == current_user) opts[:created_at] = params[:created_at] end diff --git a/lib/api/v3/runners.rb b/lib/api/v3/runners.rb index 1934d6e578c..faa265f3314 100644 --- a/lib/api/v3/runners.rb +++ b/lib/api/v3/runners.rb @@ -50,7 +50,7 @@ module API helpers do def authenticate_delete_runner!(runner) - return if current_user.is_admin? + return if current_user.admin? forbidden!("Runner is shared") if runner.is_shared? forbidden!("Runner associated with more than one project") if runner.projects.count > 1 forbidden!("No access granted") unless user_can_access_runner?(runner) diff --git a/lib/api/v3/services.rb b/lib/api/v3/services.rb index bbe07ed4212..61629a04174 100644 --- a/lib/api/v3/services.rb +++ b/lib/api/v3/services.rb @@ -602,7 +602,7 @@ module API end get ":id/services/:service_slug" do service = user_project.find_or_initialize_service(params[:service_slug].underscore) - present service, with: Entities::ProjectService, include_passwords: current_user.is_admin? + present service, with: Entities::ProjectService, include_passwords: current_user.admin? end end diff --git a/lib/banzai/filter/issuable_state_filter.rb b/lib/banzai/filter/issuable_state_filter.rb new file mode 100644 index 00000000000..6b78aa795b4 --- /dev/null +++ b/lib/banzai/filter/issuable_state_filter.rb @@ -0,0 +1,35 @@ +module Banzai + module Filter + # HTML filter that appends state information to issuable links. + # Runs as a post-process filter as issuable state might change whilst + # Markdown is in the cache. + # + # This filter supports cross-project references. + class IssuableStateFilter < HTML::Pipeline::Filter + VISIBLE_STATES = %w(closed merged).freeze + + def call + extractor = Banzai::IssuableExtractor.new(project, current_user) + issuables = extractor.extract([doc]) + + issuables.each do |node, issuable| + if VISIBLE_STATES.include?(issuable.state) + node.children.last.content += " [#{issuable.state}]" + end + end + + doc + end + + private + + def current_user + context[:current_user] + end + + def project + context[:project] + end + end + end +end diff --git a/lib/banzai/filter/redactor_filter.rb b/lib/banzai/filter/redactor_filter.rb index c59a80dd1c7..9f9882b3b40 100644 --- a/lib/banzai/filter/redactor_filter.rb +++ b/lib/banzai/filter/redactor_filter.rb @@ -7,7 +7,7 @@ module Banzai # class RedactorFilter < HTML::Pipeline::Filter def call - Redactor.new(project, current_user).redact([doc]) + Redactor.new(project, current_user).redact([doc]) unless context[:skip_redaction] doc end diff --git a/lib/banzai/issuable_extractor.rb b/lib/banzai/issuable_extractor.rb new file mode 100644 index 00000000000..c5ce360e172 --- /dev/null +++ b/lib/banzai/issuable_extractor.rb @@ -0,0 +1,36 @@ +module Banzai + # Extract references to issuables from multiple documents + + # This populates RequestStore cache used in Banzai::ReferenceParser::IssueParser + # and Banzai::ReferenceParser::MergeRequestParser + # Populating the cache should happen before processing documents one-by-one + # so we can avoid N+1 queries problem + + class IssuableExtractor + QUERY = %q( + descendant-or-self::a[contains(concat(" ", @class, " "), " gfm ")] + [@data-reference-type="issue" or @data-reference-type="merge_request"] + ).freeze + + attr_reader :project, :user + + def initialize(project, user) + @project = project + @user = user + end + + # Returns Hash in the form { node => issuable_instance } + def extract(documents) + nodes = documents.flat_map do |document| + document.xpath(QUERY) + end + + issue_parser = Banzai::ReferenceParser::IssueParser.new(project, user) + merge_request_parser = Banzai::ReferenceParser::MergeRequestParser.new(project, user) + + issue_parser.issues_for_nodes(nodes).merge( + merge_request_parser.merge_requests_for_nodes(nodes) + ) + end + end +end diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb index 9f8eb0931b8..002a3341ccd 100644 --- a/lib/banzai/object_renderer.rb +++ b/lib/banzai/object_renderer.rb @@ -31,7 +31,8 @@ module Banzai # # Returns the same input objects. def render(objects, attribute) - documents = render_objects(objects, attribute) + documents = render_documents(objects, attribute) + documents = post_process_documents(documents, objects, attribute) redacted = redact_documents(documents) objects.each_with_index do |object, index| @@ -41,9 +42,24 @@ module Banzai end end - # Renders the attribute of every given object. - def render_objects(objects, attribute) - render_attributes(objects, attribute) + private + + def render_documents(objects, attribute) + pipeline = HTML::Pipeline.new([]) + + objects.map do |object| + pipeline.to_document(Banzai.render_field(object, attribute)) + end + end + + def post_process_documents(documents, objects, attribute) + # Called here to populate cache, refer to IssuableExtractor docs + IssuableExtractor.new(project, user).extract(documents) + + documents.zip(objects).map do |document, object| + context = context_for(object, attribute) + Banzai::Pipeline[:post_process].to_document(document, context) + end end # Redacts the list of documents. @@ -57,25 +73,15 @@ module Banzai # Returns a Banzai context for the given object and attribute. def context_for(object, attribute) - context = base_context.dup - context = context.merge(object.banzai_render_context(attribute)) - context - end - - # Renders the attributes of a set of objects. - # - # Returns an Array of `Nokogiri::HTML::Document`. - def render_attributes(objects, attribute) - objects.map do |object| - string = Banzai.render_field(object, attribute) - context = context_for(object, attribute) - - Banzai::Pipeline[:relative_link].to_document(string, context) - end + base_context.merge(object.banzai_render_context(attribute)) end def base_context - @base_context ||= @redaction_context.merge(current_user: user, project: project) + @base_context ||= @redaction_context.merge( + current_user: user, + project: project, + skip_redaction: true + ) end end end diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb index ecff094b1e5..131ac3b0eec 100644 --- a/lib/banzai/pipeline/post_process_pipeline.rb +++ b/lib/banzai/pipeline/post_process_pipeline.rb @@ -4,6 +4,7 @@ module Banzai def self.filters FilterArray[ Filter::RelativeLinkFilter, + Filter::IssuableStateFilter, Filter::RedactorFilter ] end diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index 52fdb9a2140..dabf71d6aeb 100644 --- a/lib/banzai/reference_parser/base_parser.rb +++ b/lib/banzai/reference_parser/base_parser.rb @@ -62,8 +62,7 @@ module Banzai nodes.select do |node| if node.has_attribute?(project_attr) - node_id = node.attr(project_attr).to_i - can_read_reference?(user, projects[node_id]) + can_read_reference?(user, projects[node]) else true end @@ -112,12 +111,12 @@ module Banzai per_project end - # Returns a Hash containing objects for an attribute grouped per their - # IDs. + # Returns a Hash containing objects for an attribute grouped per the + # nodes that reference them. # # The returned Hash uses the following format: # - # { id value => row } + # { node => row } # # nodes - An Array of HTML nodes to process. # @@ -132,9 +131,14 @@ module Banzai return {} if nodes.empty? ids = unique_attribute_values(nodes, attribute) - rows = collection_objects_for_ids(collection, ids) + collection_objects = collection_objects_for_ids(collection, ids) + objects_by_id = collection_objects.index_by(&:id) - rows.index_by(&:id) + nodes.each_with_object({}) do |node, hash| + if node.has_attribute?(attribute) + hash[node] = objects_by_id[node.attr(attribute).to_i] + end + end end # Returns an Array containing all unique values of an attribute of the @@ -201,7 +205,7 @@ module Banzai # # The returned Hash uses the following format: # - # { project ID => project } + # { node => project } # def projects_for_nodes(nodes) @projects_for_nodes ||= diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index 6c20dec5734..e02b360924a 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -13,14 +13,14 @@ module Banzai issues_readable_by_user(issues.values, user).to_set nodes.select do |node| - readable_issues.include?(issue_for_node(issues, node)) + readable_issues.include?(issues[node]) end end def referenced_by(nodes) issues = issues_for_nodes(nodes) - nodes.map { |node| issue_for_node(issues, node) }.uniq + nodes.map { |node| issues[node] }.compact.uniq end def issues_for_nodes(nodes) @@ -44,12 +44,6 @@ module Banzai self.class.data_attribute ) end - - private - - def issue_for_node(issues, node) - issues[node.attr(self.class.data_attribute).to_i] - end end end end diff --git a/lib/banzai/reference_parser/merge_request_parser.rb b/lib/banzai/reference_parser/merge_request_parser.rb index 40451947e6c..7d7dcce017e 100644 --- a/lib/banzai/reference_parser/merge_request_parser.rb +++ b/lib/banzai/reference_parser/merge_request_parser.rb @@ -3,6 +3,14 @@ module Banzai class MergeRequestParser < BaseParser self.reference_type = :merge_request + def merge_requests_for_nodes(nodes) + @merge_requests_for_nodes ||= grouped_objects_for_nodes( + nodes, + MergeRequest.all, + self.class.data_attribute + ) + end + def references_relation MergeRequest.includes(:author, :assignee, :target_project) end diff --git a/lib/banzai/reference_parser/user_parser.rb b/lib/banzai/reference_parser/user_parser.rb index 7adaffa19c1..09b66cbd8fb 100644 --- a/lib/banzai/reference_parser/user_parser.rb +++ b/lib/banzai/reference_parser/user_parser.rb @@ -49,7 +49,7 @@ module Banzai # Check if project belongs to a group which # user can read. def can_read_group_reference?(node, user, groups) - node_group = groups[node.attr('data-group').to_i] + node_group = groups[node] node_group && can?(user, :read_group, node_group) end @@ -74,8 +74,8 @@ module Banzai if project && project_id && project.id == project_id.to_i true elsif project_id && user_id - project = projects[project_id.to_i] - user = users[user_id.to_i] + project = projects[node] + user = users[node] project && user ? project.team.member?(user) : false else diff --git a/lib/gitlab/cache/ci/project_pipeline_status.rb b/lib/gitlab/cache/ci/project_pipeline_status.rb new file mode 100644 index 00000000000..b358f2efa4f --- /dev/null +++ b/lib/gitlab/cache/ci/project_pipeline_status.rb @@ -0,0 +1,103 @@ +# This class is not backed by a table in the main database. +# It loads the latest Pipeline for the HEAD of a repository, and caches that +# in Redis. +module Gitlab + module Cache + module Ci + class ProjectPipelineStatus + attr_accessor :sha, :status, :ref, :project, :loaded + + delegate :commit, to: :project + + def self.load_for_project(project) + new(project).tap do |status| + status.load_status + end + end + + def self.update_for_pipeline(pipeline) + new(pipeline.project, + sha: pipeline.sha, + status: pipeline.status, + ref: pipeline.ref).store_in_cache_if_needed + end + + def initialize(project, sha: nil, status: nil, ref: nil) + @project = project + @sha = sha + @ref = ref + @status = status + end + + def has_status? + loaded? && sha.present? && status.present? + end + + def load_status + return if loaded? + + if has_cache? + load_from_cache + else + load_from_project + store_in_cache + end + + self.loaded = true + end + + def load_from_project + return unless commit + + self.sha = commit.sha + self.status = commit.status + self.ref = project.default_branch + end + + # We only cache the status for the HEAD commit of a project + # This status is rendered in project lists + def store_in_cache_if_needed + return delete_from_cache unless commit + return unless sha + return unless ref + + if commit.sha == sha && project.default_branch == ref + store_in_cache + end + end + + def load_from_cache + Gitlab::Redis.with do |redis| + self.sha, self.status, self.ref = redis.hmget(cache_key, :sha, :status, :ref) + end + end + + def store_in_cache + Gitlab::Redis.with do |redis| + redis.mapped_hmset(cache_key, { sha: sha, status: status, ref: ref }) + end + end + + def delete_from_cache + Gitlab::Redis.with do |redis| + redis.del(cache_key) + end + end + + def has_cache? + Gitlab::Redis.with do |redis| + redis.exists(cache_key) + end + end + + def loaded? + self.loaded + end + + def cache_key + "projects/#{project.id}/build_status" + end + end + end + end +end diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index c85f79127bc..eb2f2e144fd 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -10,6 +10,7 @@ module Gitlab ) @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) @branch_name = Gitlab::Git.branch_name(@ref) + @tag_name = Gitlab::Git.tag_name(@ref) @user_access = user_access @project = project @env = env @@ -32,11 +33,11 @@ module Gitlab def protected_branch_checks return if skip_authorization return unless @branch_name - return unless project.protected_branch?(@branch_name) + return unless ProtectedBranch.protected?(project, @branch_name) if forced_push? return "You are not allowed to force push code to a protected branch on this project." - elsif Gitlab::Git.blank_ref?(@newrev) + elsif deletion? return "You are not allowed to delete protected branches from this project." end @@ -58,13 +59,29 @@ module Gitlab def tag_checks return if skip_authorization - tag_ref = Gitlab::Git.tag_name(@ref) + return unless @tag_name - if tag_ref && protected_tag?(tag_ref) && user_access.cannot_do_action?(:admin_project) - "You are not allowed to change existing tags on this project." + if tag_exists? && user_access.cannot_do_action?(:admin_project) + return "You are not allowed to change existing tags on this project." + end + + protected_tag_checks + end + + def protected_tag_checks + return unless tag_protected? + return "Protected tags cannot be updated." if update? + return "Protected tags cannot be deleted." if deletion? + + unless user_access.can_create_tag?(@tag_name) + return "You are not allowed to create this tag as it is protected." end end + def tag_protected? + ProtectedTag.protected?(project, @tag_name) + end + def push_checks return if skip_authorization @@ -75,14 +92,22 @@ module Gitlab private - def protected_tag?(tag_name) - project.repository.tag_exists?(tag_name) + def tag_exists? + project.repository.tag_exists?(@tag_name) end def forced_push? Gitlab::Checks::ForcePush.force_push?(@project, @oldrev, @newrev, env: @env) end + def update? + !Gitlab::Git.blank_ref?(@oldrev) && !deletion? + end + + def deletion? + Gitlab::Git.blank_ref?(@newrev) + end + def matching_merge_request? Checks::MatchingMergeRequest.new(@newrev, @branch_name, @project).match? end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 114656958e3..0a15c6d9358 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -33,6 +33,10 @@ module Gitlab new_pos unless removed? || meta? end + def line + new_line || old_line + end + def unchanged? type.nil? end diff --git a/lib/gitlab/email/handler.rb b/lib/gitlab/email/handler.rb index 35ea2e0ef59..b07c68d1498 100644 --- a/lib/gitlab/email/handler.rb +++ b/lib/gitlab/email/handler.rb @@ -5,7 +5,11 @@ require 'gitlab/email/handler/unsubscribe_handler' module Gitlab module Email module Handler - HANDLERS = [UnsubscribeHandler, CreateNoteHandler, CreateIssueHandler].freeze + HANDLERS = [ + UnsubscribeHandler, + CreateNoteHandler, + CreateIssueHandler + ].freeze def self.for(mail, mail_key) HANDLERS.find do |klass| diff --git a/lib/gitlab/email/handler/create_note_handler.rb b/lib/gitlab/email/handler/create_note_handler.rb index d87ba427f4b..0e22f2189ee 100644 --- a/lib/gitlab/email/handler/create_note_handler.rb +++ b/lib/gitlab/email/handler/create_note_handler.rb @@ -1,4 +1,3 @@ - require 'gitlab/email/handler/base_handler' require 'gitlab/email/handler/reply_processing' @@ -42,17 +41,7 @@ module Gitlab end def create_note - Notes::CreateService.new( - project, - author, - note: message, - noteable_type: sent_notification.noteable_type, - noteable_id: sent_notification.noteable_id, - commit_id: sent_notification.commit_id, - line_code: sent_notification.line_code, - position: sent_notification.position, - type: sent_notification.note_type - ).execute + sent_notification.create_reply(message) end end end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 9e338282e96..fc473b2c21e 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -968,6 +968,14 @@ module Gitlab @attributes.attributes(path) end + def gitaly_repository + Gitlab::GitalyClient::Util.repository(@repository_storage, @relative_path) + end + + def gitaly_channel + Gitlab::GitalyClient.get_channel(@repository_storage) + end + private # Get the content of a blob for a given commit. If the blob is a commit @@ -1247,7 +1255,7 @@ module Gitlab end def gitaly_ref_client - @gitaly_ref_client ||= Gitlab::GitalyClient::Ref.new(@repository_storage, @relative_path) + @gitaly_ref_client ||= Gitlab::GitalyClient::Ref.new(self) end end end diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb index f15faebe27e..b7f39f3ef0b 100644 --- a/lib/gitlab/gitaly_client/commit.rb +++ b/lib/gitlab/gitaly_client/commit.rb @@ -7,14 +7,13 @@ module Gitlab class << self def diff_from_parent(commit, options = {}) - project = commit.project - channel = GitalyClient.get_channel(project.repository_storage) - stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: channel) - repo = Gitaly::Repository.new(path: project.repository.path_to_repo) - parent = commit.parents[0] + repository = commit.project.repository + gitaly_repo = repository.gitaly_repository + stub = Gitaly::Diff::Stub.new(nil, nil, channel_override: repository.gitaly_channel) + parent = commit.parents[0] parent_id = parent ? parent.id : EMPTY_TREE_ID - request = Gitaly::CommitDiffRequest.new( - repository: repo, + request = Gitaly::CommitDiffRequest.new( + repository: gitaly_repo, left_commit_id: parent_id, right_commit_id: commit.id ) @@ -23,12 +22,10 @@ module Gitlab end def is_ancestor(repository, ancestor_id, child_id) - project = Project.find_by_path(repository.path) - channel = GitalyClient.get_channel(project.repository_storage) - stub = Gitaly::Commit::Stub.new(nil, nil, channel_override: channel) - repo = Gitaly::Repository.new(path: repository.path_to_repo) + gitaly_repo = repository.gitaly_repository + stub = Gitaly::Commit::Stub.new(nil, nil, channel_override: repository.gitaly_channel) request = Gitaly::CommitIsAncestorRequest.new( - repository: repo, + repository: gitaly_repo, ancestor_id: ancestor_id, child_id: child_id ) diff --git a/lib/gitlab/gitaly_client/notifications.rb b/lib/gitlab/gitaly_client/notifications.rb index f0d93ded91b..a94a54883db 100644 --- a/lib/gitlab/gitaly_client/notifications.rb +++ b/lib/gitlab/gitaly_client/notifications.rb @@ -3,13 +3,14 @@ module Gitlab class Notifications attr_accessor :stub - def initialize(repository_storage, relative_path) - @channel, @repository = Util.process_path(repository_storage, relative_path) - @stub = Gitaly::Notifications::Stub.new(nil, nil, channel_override: @channel) + # 'repository' is a Gitlab::Git::Repository + def initialize(repository) + @gitaly_repo = repository.gitaly_repository + @stub = Gitaly::Notifications::Stub.new(nil, nil, channel_override: repository.gitaly_channel) end def post_receive - request = Gitaly::PostReceiveRequest.new(repository: @repository) + request = Gitaly::PostReceiveRequest.new(repository: @gitaly_repo) @stub.post_receive(request) end end diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index fcdf452d567..d3c0743db4e 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -3,23 +3,24 @@ module Gitlab class Ref attr_accessor :stub - def initialize(repository_storage, relative_path) - @channel, @repository = Util.process_path(repository_storage, relative_path) - @stub = Gitaly::Ref::Stub.new(nil, nil, channel_override: @channel) + # 'repository' is a Gitlab::Git::Repository + def initialize(repository) + @gitaly_repo = repository.gitaly_repository + @stub = Gitaly::Ref::Stub.new(nil, nil, channel_override: repository.gitaly_channel) end def default_branch_name - request = Gitaly::FindDefaultBranchNameRequest.new(repository: @repository) + request = Gitaly::FindDefaultBranchNameRequest.new(repository: @gitaly_repo) stub.find_default_branch_name(request).name.gsub(/^refs\/heads\//, '') end def branch_names - request = Gitaly::FindAllBranchNamesRequest.new(repository: @repository) + request = Gitaly::FindAllBranchNamesRequest.new(repository: @gitaly_repo) consume_refs_response(stub.find_all_branch_names(request), prefix: 'refs/heads/') end def tag_names - request = Gitaly::FindAllTagNamesRequest.new(repository: @repository) + request = Gitaly::FindAllTagNamesRequest.new(repository: @gitaly_repo) consume_refs_response(stub.find_all_tag_names(request), prefix: 'refs/tags/') end diff --git a/lib/gitlab/gitaly_client/util.rb b/lib/gitlab/gitaly_client/util.rb index d272c25d1f9..4acd297f5cb 100644 --- a/lib/gitlab/gitaly_client/util.rb +++ b/lib/gitlab/gitaly_client/util.rb @@ -1,12 +1,14 @@ module Gitlab module GitalyClient module Util - def self.process_path(repository_storage, relative_path) - channel = GitalyClient.get_channel(repository_storage) - storage_path = Gitlab.config.repositories.storages[repository_storage]['path'] - repository = Gitaly::Repository.new(path: File.join(storage_path, relative_path)) - - [channel, repository] + class << self + def repository(repository_storage, relative_path) + Gitaly::Repository.new( + path: File.join(Gitlab.config.repositories.storages[repository_storage]['path'], relative_path), + storage_name: repository_storage, + relative_path: relative_path, + ) + end end end end diff --git a/lib/gitlab/health_checks/base_abstract_check.rb b/lib/gitlab/health_checks/base_abstract_check.rb new file mode 100644 index 00000000000..7de6d4d9367 --- /dev/null +++ b/lib/gitlab/health_checks/base_abstract_check.rb @@ -0,0 +1,45 @@ +module Gitlab + module HealthChecks + module BaseAbstractCheck + def name + super.demodulize.underscore + end + + def human_name + name.sub(/_check$/, '').capitalize + end + + def readiness + raise NotImplementedError + end + + def liveness + HealthChecks::Result.new(true) + end + + def metrics + [] + end + + protected + + def metric(name, value, **labels) + Metric.new(name, value, labels) + end + + def with_timing(proc) + start = Time.now + result = proc.call + yield result, Time.now.to_f - start.to_f + end + + def catch_timeout(seconds, &block) + begin + Timeout.timeout(seconds.to_i, &block) + rescue Timeout::Error => ex + ex + end + end + end + end +end diff --git a/lib/gitlab/health_checks/db_check.rb b/lib/gitlab/health_checks/db_check.rb new file mode 100644 index 00000000000..fd94984f8a2 --- /dev/null +++ b/lib/gitlab/health_checks/db_check.rb @@ -0,0 +1,29 @@ +module Gitlab + module HealthChecks + class DbCheck + extend SimpleAbstractCheck + + class << self + private + + def metric_prefix + 'db_ping' + end + + def is_successful?(result) + result == '1' + end + + def check + catch_timeout 10.seconds do + if Gitlab::Database.postgresql? + ActiveRecord::Base.connection.execute('SELECT 1 as ping')&.first&.[]('ping') + else + ActiveRecord::Base.connection.execute('SELECT 1 as ping')&.first&.first&.to_s + end + end + end + end + end + end +end diff --git a/lib/gitlab/health_checks/fs_shards_check.rb b/lib/gitlab/health_checks/fs_shards_check.rb new file mode 100644 index 00000000000..df962d203b7 --- /dev/null +++ b/lib/gitlab/health_checks/fs_shards_check.rb @@ -0,0 +1,117 @@ +module Gitlab + module HealthChecks + class FsShardsCheck + extend BaseAbstractCheck + + class << self + def readiness + repository_storages.map do |storage_name| + begin + tmp_file_path = tmp_file_path(storage_name) + + if !storage_stat_test(storage_name) + HealthChecks::Result.new(false, 'cannot stat storage', shard: storage_name) + elsif !storage_write_test(tmp_file_path) + HealthChecks::Result.new(false, 'cannot write to storage', shard: storage_name) + elsif !storage_read_test(tmp_file_path) + HealthChecks::Result.new(false, 'cannot read from storage', shard: storage_name) + else + HealthChecks::Result.new(true, nil, shard: storage_name) + end + rescue RuntimeError => ex + message = "unexpected error #{ex} when checking storage #{storage_name}" + Rails.logger.error(message) + HealthChecks::Result.new(false, message, shard: storage_name) + ensure + delete_test_file(tmp_file_path) + end + end + end + + def metrics + repository_storages.flat_map do |storage_name| + tmp_file_path = tmp_file_path(storage_name) + [ + operation_metrics(:filesystem_accessible, :filesystem_access_latency, -> { storage_stat_test(storage_name) }, shard: storage_name), + operation_metrics(:filesystem_writable, :filesystem_write_latency, -> { storage_write_test(tmp_file_path) }, shard: storage_name), + operation_metrics(:filesystem_readable, :filesystem_read_latency, -> { storage_read_test(tmp_file_path) }, shard: storage_name) + ].flatten + end + end + + private + + RANDOM_STRING = SecureRandom.hex(1000).freeze + + def operation_metrics(ok_metric, latency_metric, operation, **labels) + with_timing operation do |result, elapsed| + [ + metric(latency_metric, elapsed, **labels), + metric(ok_metric, result ? 1 : 0, **labels) + ] + end + rescue RuntimeError => ex + Rails.logger("unexpected error #{ex} when checking #{ok_metric}") + [metric(ok_metric, 0, **labels)] + end + + def repository_storages + @repository_storage ||= Gitlab::CurrentSettings.current_application_settings.repository_storages + end + + def storages_paths + @storage_paths ||= Gitlab.config.repositories.storages + end + + def with_timeout(args) + %w{timeout 1}.concat(args) + end + + def tmp_file_path(storage_name) + Dir::Tmpname.create(%w(fs_shards_check +deleted), path(storage_name)) { |path| path } + end + + def path(storage_name) + storages_paths&.dig(storage_name, 'path') + end + + def storage_stat_test(storage_name) + stat_path = File.join(path(storage_name), '.') + begin + _, status = Gitlab::Popen.popen(with_timeout(%W{ stat #{stat_path} })) + status == 0 + rescue Errno::ENOENT + File.exist?(stat_path) && File::Stat.new(stat_path).readable? + end + end + + def storage_write_test(tmp_path) + _, status = Gitlab::Popen.popen(with_timeout(%W{ tee #{tmp_path} })) do |stdin| + stdin.write(RANDOM_STRING) + end + status == 0 + rescue Errno::ENOENT + written_bytes = File.write(tmp_path, RANDOM_STRING) rescue Errno::ENOENT + written_bytes == RANDOM_STRING.length + end + + def storage_read_test(tmp_path) + _, status = Gitlab::Popen.popen(with_timeout(%W{ diff #{tmp_path} - })) do |stdin| + stdin.write(RANDOM_STRING) + end + status == 0 + rescue Errno::ENOENT + file_contents = File.read(tmp_path) rescue Errno::ENOENT + file_contents == RANDOM_STRING + end + + def delete_test_file(tmp_path) + _, status = Gitlab::Popen.popen(with_timeout(%W{ rm -f #{tmp_path} })) + status == 0 + rescue Errno::ENOENT + File.delete(tmp_path) rescue Errno::ENOENT + end + end + end + end +end diff --git a/lib/gitlab/health_checks/metric.rb b/lib/gitlab/health_checks/metric.rb new file mode 100644 index 00000000000..1a2eab0b005 --- /dev/null +++ b/lib/gitlab/health_checks/metric.rb @@ -0,0 +1,3 @@ +module Gitlab::HealthChecks + Metric = Struct.new(:name, :value, :labels) +end diff --git a/lib/gitlab/health_checks/redis_check.rb b/lib/gitlab/health_checks/redis_check.rb new file mode 100644 index 00000000000..57bbe5b3ad0 --- /dev/null +++ b/lib/gitlab/health_checks/redis_check.rb @@ -0,0 +1,25 @@ +module Gitlab + module HealthChecks + class RedisCheck + extend SimpleAbstractCheck + + class << self + private + + def metric_prefix + 'redis_ping' + end + + def is_successful?(result) + result == 'PONG' + end + + def check + catch_timeout 10.seconds do + Gitlab::Redis.with(&:ping) + end + end + end + end + end +end diff --git a/lib/gitlab/health_checks/result.rb b/lib/gitlab/health_checks/result.rb new file mode 100644 index 00000000000..8086760023e --- /dev/null +++ b/lib/gitlab/health_checks/result.rb @@ -0,0 +1,3 @@ +module Gitlab::HealthChecks + Result = Struct.new(:success, :message, :labels) +end diff --git a/lib/gitlab/health_checks/simple_abstract_check.rb b/lib/gitlab/health_checks/simple_abstract_check.rb new file mode 100644 index 00000000000..fbe1645c1b1 --- /dev/null +++ b/lib/gitlab/health_checks/simple_abstract_check.rb @@ -0,0 +1,43 @@ +module Gitlab + module HealthChecks + module SimpleAbstractCheck + include BaseAbstractCheck + + def readiness + check_result = check + if is_successful?(check_result) + HealthChecks::Result.new(true) + elsif check_result.is_a?(Timeout::Error) + HealthChecks::Result.new(false, "#{human_name} check timed out") + else + HealthChecks::Result.new(false, "unexpected #{human_name} check result: #{check_result}") + end + end + + def metrics + with_timing method(:check) do |result, elapsed| + Rails.logger.error("#{human_name} check returned unexpected result #{result}") unless is_successful?(result) + [ + metric("#{metric_prefix}_timeout", result.is_a?(Timeout::Error) ? 1 : 0), + metric("#{metric_prefix}_success", is_successful?(result) ? 1 : 0), + metric("#{metric_prefix}_latency", elapsed) + ] + end + end + + private + + def metric_prefix + raise NotImplementedError + end + + def is_successful?(result) + raise NotImplementedError + end + + def check + raise NotImplementedError + end + end + end +end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index f5e1e385ff9..899a6567768 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -47,6 +47,8 @@ project_tree: - protected_branches: - :merge_access_levels - :push_access_levels + - protected_tags: + - :create_access_levels - :project_feature # Only include the following attributes for the models specified. diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index df21ff22216..2e349b5f9a9 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -52,7 +52,11 @@ module Gitlab create_sub_relations(relation, @tree_hash) if relation.is_a?(Hash) relation_key = relation.is_a?(Hash) ? relation.keys.first : relation - relation_hash = create_relation(relation_key, @tree_hash[relation_key.to_s]) + relation_hash_list = @tree_hash[relation_key.to_s] + + next unless relation_hash_list + + relation_hash = create_relation(relation_key, relation_hash_list) saved << restored_project.append_or_update_attribute(relation_key, relation_hash) end saved.all? diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 2ba12f5f924..4a54e7ef2e7 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -10,6 +10,7 @@ module Gitlab hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', push_access_levels: 'ProtectedBranch::PushAccessLevel', + create_access_levels: 'ProtectedTag::CreateAccessLevel', labels: :project_labels, priorities: :label_priorities, label: :project_label }.freeze @@ -185,7 +186,7 @@ module Gitlab end def admin_user? - @user.is_admin? + @user.admin? end def parsed_relation_hash diff --git a/lib/gitlab/user_access.rb b/lib/gitlab/user_access.rb index f260c0c535f..54728e5ff0e 100644 --- a/lib/gitlab/user_access.rb +++ b/lib/gitlab/user_access.rb @@ -28,14 +28,23 @@ module Gitlab true end + def can_create_tag?(ref) + return false unless can_access_git? + + if ProtectedTag.protected?(project, ref) + project.protected_tags.protected_ref_accessible_to?(ref, user, action: :create) + else + user.can?(:push_code, project) + end + end + def can_push_to_branch?(ref) return false unless can_access_git? - if project.protected_branch?(ref) + if ProtectedBranch.protected?(project, ref) return true if project.empty_repo? && project.user_can_push_to_empty_repo?(user) - access_levels = project.protected_branches.matching(ref).map(&:push_access_levels).flatten - has_access = access_levels.any? { |access_level| access_level.check_access(user) } + has_access = project.protected_branches.protected_ref_accessible_to?(ref, user, action: :push) has_access || !project.repository.branch_exists?(ref) && can_merge_to_branch?(ref) else @@ -46,9 +55,8 @@ module Gitlab def can_merge_to_branch?(ref) return false unless can_access_git? - if project.protected_branch?(ref) - access_levels = project.protected_branches.matching(ref).map(&:merge_access_levels).flatten - access_levels.any? { |access_level| access_level.check_access(user) } + if ProtectedBranch.protected?(project, ref) + project.protected_branches.protected_ref_accessible_to?(ref, user, action: :merge) else user.can?(:push_code, project) end diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb index 8f1d1fdc02e..2e31f4462f9 100644 --- a/lib/gitlab/visibility_level.rb +++ b/lib/gitlab/visibility_level.rb @@ -63,7 +63,7 @@ module Gitlab end def allowed_for?(user, level) - user.is_admin? || allowed_level?(level.to_i) + user.admin? || allowed_level?(level.to_i) end # Return true if the specified level is allowed for the current user. diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index a8a7bf9bc12..e6e40f6945d 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -24,14 +24,8 @@ module Gitlab } if Gitlab.config.gitaly.enabled - storage = repository.project.repository_storage - address = Gitlab::GitalyClient.get_address(storage) - # TODO: use GitalyClient code to assemble the Repository message - params[:Repository] = Gitaly::Repository.new( - path: repo_path, - storage_name: storage, - relative_path: Gitlab::RepoPath.strip_storage_path(repo_path), - ).to_h + address = Gitlab::GitalyClient.get_address(repository.project.repository_storage) + params[:Repository] = repository.gitaly_repository.to_h feature_enabled = case action.to_s when 'git_receive_pack' diff --git a/lib/tasks/gitlab/gitaly.rake b/lib/tasks/gitlab/gitaly.rake index c288e17ac8d..9f6cfe3957c 100644 --- a/lib/tasks/gitlab/gitaly.rake +++ b/lib/tasks/gitlab/gitaly.rake @@ -19,5 +19,19 @@ namespace :gitlab do run_command!([command]) end end + + desc "GitLab | Print storage configuration in TOML format" + task storage_config: :environment do + require 'toml' + + puts "# Gitaly storage configuration generated from #{Gitlab.config.source} on #{Time.current.to_s(:long)}" + puts "# This is in TOML format suitable for use in Gitaly's config.toml file." + + config = Gitlab.config.repositories.storages.map do |key, val| + { name: key, path: val['path'] } + end + + puts TOML.dump(storage: config) + end end end diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake index 350afeb5c0b..15131fbf755 100644 --- a/lib/tasks/import.rake +++ b/lib/tasks/import.rake @@ -48,9 +48,16 @@ class NewImporter < ::Gitlab::GithubImport::Importer begin raise 'Blocked import URL.' if Gitlab::UrlBlocker.blocked_url?(project.import_url) - gitlab_shell.import_repository(project.repository_storage_path, project.path_with_namespace, project.import_url) + project.create_repository + project.repository.add_remote(project.import_type, project.import_url) + project.repository.set_remote_as_mirror(project.import_type) + project.repository.fetch_remote(project.import_type, forced: true) + project.repository.remove_remote(project.import_type) rescue => e - project.repository.before_import if project.repository_exists? + # Expire cache to prevent scenarios such as: + # 1. First import failed, but the repo was imported successfully, so +exists?+ returns true + # 2. Retried import, repo is broken or not imported but +exists?+ still returns true + project.repository.expire_content_cache if project.repository_exists? raise "Error importing repository #{project.import_url} into #{project.path_with_namespace} - #{e.message}" end diff --git a/package.json b/package.json index 312e38f7407..6d4f99e33b3 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "visibilityjs": "^1.2.4", "vue": "^2.2.4", "vue-resource": "^0.9.3", - "webpack": "^2.2.1", + "webpack": "^2.3.3", "webpack-bundle-analyzer": "^2.3.0" }, "devDependencies": { @@ -65,6 +65,6 @@ "karma-phantomjs-launcher": "^1.0.2", "karma-sourcemap-loader": "^0.3.7", "karma-webpack": "^2.0.2", - "webpack-dev-server": "^2.3.0" + "webpack-dev-server": "^2.4.2" } } diff --git a/public/404.html b/public/404.html index b3b3a0fa3f3..03e98e81862 100644 --- a/public/404.html +++ b/public/404.html @@ -57,6 +57,11 @@ .container { margin: auto 20px; } + + .go-back { + display: none; + } + </style> </head> @@ -71,7 +76,16 @@ <hr /> <p>Make sure the address is correct and that the page hasn't moved.</p> <p>Please contact your GitLab administrator if you think this is a mistake.</p> - <a href="javascript:history.back()">Go back</a> + <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> </div> + <script> + (function () { + var goBack = document.querySelector('.js-go-back'); + + if (history.length > 1) { + goBack.style.display = 'inline'; + } + })(); + </script> </body> </html> diff --git a/public/422.html b/public/422.html index 119e54ad8bd..49ebbe40f39 100644 --- a/public/422.html +++ b/public/422.html @@ -57,6 +57,11 @@ .container { margin: auto 20px; } + + .go-back { + display: none; + } + </style> </head> @@ -71,7 +76,17 @@ <hr /> <p>Make sure you have access to the thing you tried to change.</p> <p>Please contact your GitLab administrator if you think this is a mistake.</p> - <a href="javascript:history.back()">Go back</a> + <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> </div> + <script> + (function () { + var goBack = document.querySelector('.js-go-back'); + + if (history.length > 1) { + goBack.style.display = 'inline'; + } + })(); + + </script> </body> </html> diff --git a/public/500.html b/public/500.html index 226ef3c40ea..516920f7471 100644 --- a/public/500.html +++ b/public/500.html @@ -57,6 +57,11 @@ .container { margin: auto 20px; } + + .go-back { + display: none; + } + </style> </head> @@ -71,7 +76,16 @@ <hr /> <p>Try refreshing the page, or going back and attempting the action again.</p> <p>Please contact your GitLab administrator if this problem persists.</p> - <a href="javascript:history.back()">Go back</a> + <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> </div> + <script> + (function () { + var goBack = document.querySelector('.js-go-back'); + + if (history.length > 1) { + goBack.style.display = 'inline'; + } + })(); + </script> </body> </html> diff --git a/public/502.html b/public/502.html index f037b81bace..189458c9816 100644 --- a/public/502.html +++ b/public/502.html @@ -57,6 +57,11 @@ .container { margin: auto 20px; } + + .go-back { + display: none; + } + </style> </head> @@ -71,7 +76,16 @@ <hr /> <p>Try refreshing the page, or going back and attempting the action again.</p> <p>Please contact your GitLab administrator if this problem persists.</p> - <a href="javascript:history.back()">Go back</a> + <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> </div> + <script> + (function () { + var goBack = document.querySelector('.js-go-back'); + + if (history.length > 1) { + goBack.style.display = 'inline'; + } + })(); + </script> </body> </html> diff --git a/public/503.html b/public/503.html index f946a087871..b09b0e2a67e 100644 --- a/public/503.html +++ b/public/503.html @@ -57,6 +57,11 @@ .container { margin: auto 20px; } + + .go-back { + display: none; + } + </style> </head> @@ -71,7 +76,16 @@ <hr /> <p>Try refreshing the page, or going back and attempting the action again.</p> <p>Please contact your GitLab administrator if this problem persists.</p> - <a href="javascript:history.back()">Go back</a> + <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> </div> + <script> + (function () { + var goBack = document.querySelector('.js-go-back'); + + if (history.length > 1) { + goBack.style.display = 'inline'; + } + })(); + </script> </body> </html> diff --git a/qa/qa/page/main/groups.rb b/qa/qa/page/main/groups.rb index 84597719a84..169c5ebc967 100644 --- a/qa/qa/page/main/groups.rb +++ b/qa/qa/page/main/groups.rb @@ -5,7 +5,7 @@ module QA def prepare_test_namespace return if page.has_content?(Runtime::Namespace.name) - click_on 'New Group' + click_on 'New group' fill_in 'group_path', with: Runtime::Namespace.name fill_in 'group_description', diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb new file mode 100644 index 00000000000..b8b6e0c3a88 --- /dev/null +++ b/spec/controllers/health_controller_spec.rb @@ -0,0 +1,96 @@ +require 'spec_helper' + +describe HealthController do + include StubENV + + let(:token) { current_application_settings.health_check_access_token } + let(:json_response) { JSON.parse(response.body) } + + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + end + + describe '#readiness' do + context 'authorization token provided' do + before do + request.headers['TOKEN'] = token + end + + it 'returns proper response' do + get :readiness + expect(json_response['db_check']['status']).to eq('ok') + expect(json_response['redis_check']['status']).to eq('ok') + expect(json_response['fs_shards_check']['status']).to eq('ok') + expect(json_response['fs_shards_check']['labels']['shard']).to eq('default') + end + end + + context 'without authorization token' do + it 'returns proper response' do + get :readiness + expect(response.status).to eq(404) + end + end + end + + describe '#liveness' do + context 'authorization token provided' do + before do + request.headers['TOKEN'] = token + end + + it 'returns proper response' do + get :liveness + expect(json_response['db_check']['status']).to eq('ok') + expect(json_response['redis_check']['status']).to eq('ok') + expect(json_response['fs_shards_check']['status']).to eq('ok') + end + end + + context 'without authorization token' do + it 'returns proper response' do + get :liveness + expect(response.status).to eq(404) + end + end + end + + describe '#metrics' do + context 'authorization token provided' do + before do + request.headers['TOKEN'] = token + end + + it 'returns DB ping metrics' do + get :metrics + expect(response.body).to match(/^db_ping_timeout 0$/) + expect(response.body).to match(/^db_ping_success 1$/) + expect(response.body).to match(/^db_ping_latency [0-9\.]+$/) + end + + it 'returns Redis ping metrics' do + get :metrics + expect(response.body).to match(/^redis_ping_timeout 0$/) + expect(response.body).to match(/^redis_ping_success 1$/) + expect(response.body).to match(/^redis_ping_latency [0-9\.]+$/) + end + + it 'returns file system check metrics' do + get :metrics + expect(response.body).to match(/^filesystem_access_latency{shard="default"} [0-9\.]+$/) + expect(response.body).to match(/^filesystem_accessible{shard="default"} 1$/) + expect(response.body).to match(/^filesystem_write_latency{shard="default"} [0-9\.]+$/) + expect(response.body).to match(/^filesystem_writable{shard="default"} 1$/) + expect(response.body).to match(/^filesystem_read_latency{shard="default"} [0-9\.]+$/) + expect(response.body).to match(/^filesystem_readable{shard="default"} 1$/) + end + end + + context 'without authorization token' do + it 'returns proper response' do + get :metrics + expect(response.status).to eq(404) + end + end + end +end diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/builds_controller_spec.rb index 683667129e5..13208d21918 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/builds_controller_spec.rb @@ -10,6 +10,39 @@ describe Projects::BuildsController do sign_in(user) end + describe 'GET index' do + context 'number of queries' do + before do + Ci::Build::AVAILABLE_STATUSES.each do |status| + create_build(status, status) + end + + RequestStore.begin! + end + + after do + RequestStore.end! + RequestStore.clear! + end + + def render + get :index, namespace_id: project.namespace, + project_id: project + end + + it "verifies number of queries" do + recorded = ActiveRecord::QueryRecorder.new { render } + expect(recorded.count).to be_within(5).of(8) + end + + def create_build(name, status) + pipeline = create(:ci_pipeline, project: project) + create(:ci_build, :tags, :triggered, :artifacts, + pipeline: pipeline, name: name, status: status) + end + end + end + describe 'GET status.json' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index b223a22ae60..69e4706dc71 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -266,8 +266,8 @@ describe Projects::CommitController do diff_for_path(id: commit2.id, old_path: existing_path, new_path: existing_path) expect(assigns(:diff_notes_disabled)).to be_falsey - expect(assigns(:comments_target)).to eq(noteable_type: 'Commit', - commit_id: commit2.id) + expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'Commit', + commit_id: commit2.id) end it 'only renders the diffs for the path given' do diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb index 79ab364a6f3..fe62898fa9b 100644 --- a/spec/controllers/projects/discussions_controller_spec.rb +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -4,7 +4,7 @@ describe Projects::DiscussionsController do let(:user) { create(:user) } let(:merge_request) { create(:merge_request) } let(:project) { merge_request.source_project } - let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } let(:discussion) { note.discussion } let(:request_params) do diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index d5f1d46bf7f..79034b8d24d 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -519,7 +519,7 @@ describe Projects::IssuesController do end context 'resolving discussions in MergeRequest' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 99d5583e683..1739d40ab88 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -586,8 +586,8 @@ describe Projects::MergeRequestsController do diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path) expect(assigns(:diff_notes_disabled)).to be_falsey - expect(assigns(:comments_target)).to eq(noteable_type: 'MergeRequest', - noteable_id: merge_request.id) + expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest', + noteable_id: merge_request.id) end it 'only renders the diffs for the path given' do diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index d80780b1d90..f140eaef5d5 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -14,6 +14,109 @@ describe Projects::NotesController do } end + describe 'GET index' do + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + target_type: 'issue', + target_id: issue.id, + format: 'json' + } + end + + let(:parsed_response) { JSON.parse(response.body).with_indifferent_access } + let(:note_json) { parsed_response[:notes].first } + + before do + sign_in(user) + project.team << [user, :developer] + end + + it 'passes last_fetched_at from headers to NotesFinder' do + last_fetched_at = 3.hours.ago.to_i + + request.headers['X-Last-Fetched-At'] = last_fetched_at + + expect(NotesFinder).to receive(:new) + .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) + .and_call_original + + get :index, request_params + end + + context 'for a discussion note' do + let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) } + + it 'responds with the expected attributes' do + get :index, request_params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).not_to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + + context 'for a diff discussion note' do + let(:project) { create(:project, :repository) } + let!(:note) { create(:diff_note_on_merge_request, project: project) } + + let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) } + + it 'responds with the expected attributes' do + get :index, params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).not_to be_nil + expect(note_json[:diff_discussion_html]).not_to be_nil + end + end + + context 'for a commit note' do + let(:project) { create(:project, :repository) } + let!(:note) { create(:note_on_commit, project: project) } + + context 'when displayed on a merge request' do + let(:merge_request) { create(:merge_request, source_project: project) } + + let(:params) { request_params.merge(target_type: 'merge_request', target_id: merge_request.id) } + + it 'responds with the expected attributes' do + get :index, params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).not_to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + + context 'when displayed on the commit' do + let(:params) { request_params.merge(target_type: 'commit', target_id: note.commit_id) } + + it 'responds with the expected attributes' do + get :index, params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:discussion_html]).to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + end + + context 'for a regular note' do + let!(:note) { create(:note, noteable: issue, project: project) } + + it 'responds with the expected attributes' do + get :index, request_params + + expect(note_json[:id]).to eq(note.id) + expect(note_json[:html]).not_to be_nil + expect(note_json[:discussion_html]).to be_nil + expect(note_json[:diff_discussion_html]).to be_nil + end + end + end + describe 'POST create' do let(:merge_request) { create(:merge_request) } let(:project) { merge_request.source_project } @@ -49,7 +152,8 @@ describe Projects::NotesController do note: 'some note', noteable_id: merge_request.id.to_s, noteable_type: 'MergeRequest', - merge_request_diff_head_sha: 'sha' + merge_request_diff_head_sha: 'sha', + in_reply_to_discussion_id: nil } expect(Notes::CreateService).to receive(:new).with(project, user, service_params).and_return(double(execute: true)) @@ -200,31 +304,4 @@ describe Projects::NotesController do end end end - - describe 'GET index' do - let(:last_fetched_at) { '1487756246' } - let(:request_params) do - { - namespace_id: project.namespace, - project_id: project, - target_type: 'issue', - target_id: issue.id - } - end - - before do - sign_in(user) - project.team << [user, :developer] - end - - it 'passes last_fetched_at from headers to NotesFinder' do - request.headers['X-Last-Fetched-At'] = last_fetched_at - - expect(NotesFinder).to receive(:new) - .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) - .and_call_original - - get :index, request_params - end - end end diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb index e378b5714fe..80be135b5d8 100644 --- a/spec/controllers/projects/protected_branches_controller_spec.rb +++ b/spec/controllers/projects/protected_branches_controller_spec.rb @@ -3,6 +3,7 @@ require('spec_helper') describe Projects::ProtectedBranchesController do describe "GET #index" do let(:project) { create(:project_empty_repo, :public) } + it "redirects empty repo to projects page" do get(:index, namespace_id: project.namespace.to_param, project_id: project) end diff --git a/spec/controllers/projects/protected_tags_controller_spec.rb b/spec/controllers/projects/protected_tags_controller_spec.rb new file mode 100644 index 00000000000..64658988b3f --- /dev/null +++ b/spec/controllers/projects/protected_tags_controller_spec.rb @@ -0,0 +1,11 @@ +require('spec_helper') + +describe Projects::ProtectedTagsController do + describe "GET #index" do + let(:project) { create(:project_empty_repo, :public) } + + it "redirects empty repo to projects page" do + get(:index, namespace_id: project.namespace.to_param, project_id: project) + end + end +end diff --git a/spec/factories/ci/trigger_schedules.rb b/spec/factories/ci/trigger_schedules.rb index 315bce16995..2390706fa41 100644 --- a/spec/factories/ci/trigger_schedules.rb +++ b/spec/factories/ci/trigger_schedules.rb @@ -3,9 +3,11 @@ FactoryGirl.define do trigger factory: :ci_trigger_for_trigger_schedule cron '0 1 * * *' cron_timezone Gitlab::Ci::CronParser::VALID_SYNTAX_SAMPLE_TIME_ZONE + ref 'master' + active true after(:build) do |trigger_schedule, evaluator| - trigger_schedule.update!(project: trigger_schedule.trigger.project) + trigger_schedule.project ||= trigger_schedule.trigger.project end trait :nightly do diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index e36fe326e1c..361f9dac191 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -44,6 +44,10 @@ FactoryGirl.define do state :reopened end + trait :locked do + state :locked + end + trait :simple do source_branch "feature" target_branch "master" diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index fe19a404e16..90c35e2c7f8 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -16,10 +16,21 @@ FactoryGirl.define do factory :note_on_personal_snippet, traits: [:on_personal_snippet] factory :system_note, traits: [:system] - factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote do + factory :discussion_note_on_merge_request, traits: [:on_merge_request], class: DiscussionNote do association :project, :repository + + trait :resolved do + resolved_at { Time.now } + resolved_by { create(:user) } + end end + factory :discussion_note_on_issue, traits: [:on_issue], class: DiscussionNote + + factory :discussion_note_on_commit, traits: [:on_commit], class: DiscussionNote + + factory :legacy_diff_note_on_commit, traits: [:on_commit, :legacy_diff_note], class: LegacyDiffNote + factory :legacy_diff_note_on_merge_request, traits: [:on_merge_request, :legacy_diff_note], class: LegacyDiffNote do association :project, :repository end @@ -37,7 +48,7 @@ FactoryGirl.define do new_path: "files/ruby/popen.rb", old_line: nil, new_line: line_number, - diff_refs: noteable.diff_refs + diff_refs: noteable.try(:diff_refs) ) end @@ -108,5 +119,18 @@ FactoryGirl.define do trait :with_svg_attachment do attachment { fixture_file_upload(Rails.root + "spec/fixtures/unsanitized.svg", "image/svg+xml") } end + + transient do + in_reply_to nil + end + + before(:create) do |note, evaluator| + discussion = evaluator.in_reply_to + next unless discussion + discussion = discussion.to_discussion if discussion.is_a?(Note) + next unless discussion + + note.assign_attributes(discussion.reply_attributes.merge(project: discussion.project)) + end end end diff --git a/spec/factories/protected_tags.rb b/spec/factories/protected_tags.rb new file mode 100644 index 00000000000..d8e90ae1ee1 --- /dev/null +++ b/spec/factories/protected_tags.rb @@ -0,0 +1,22 @@ +FactoryGirl.define do + factory :protected_tag do + name + project + + after(:build) do |protected_tag| + protected_tag.create_access_levels.new(access_level: Gitlab::Access::MASTER) + end + + trait :developers_can_create do + after(:create) do |protected_tag| + protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::DEVELOPER) + end + end + + trait :no_one_can_create do + after(:create) do |protected_tag| + protected_tag.create_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS) + end + end + end +end diff --git a/spec/factories/sent_notifications.rb b/spec/factories/sent_notifications.rb index 6287c40afe9..99253be5a22 100644 --- a/spec/factories/sent_notifications.rb +++ b/spec/factories/sent_notifications.rb @@ -2,7 +2,7 @@ FactoryGirl.define do factory :sent_notification do project factory: :empty_project recipient factory: :user - noteable factory: :issue - reply_key "0123456789abcdef" * 2 + noteable { create(:issue, project: project) } + reply_key { SentNotification.reply_key } end end diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb index 56d5c7c327e..d5f595894d6 100644 --- a/spec/features/admin/admin_groups_spec.rb +++ b/spec/features/admin/admin_groups_spec.rb @@ -25,13 +25,22 @@ feature 'Admin Groups', feature: true do visit admin_groups_path click_link "New group" - fill_in 'group_path', with: 'gitlab' - fill_in 'group_description', with: 'Group description' + path_component = 'gitlab' + group_name = 'GitLab group name' + group_description = 'Description of group for GitLab' + fill_in 'group_path', with: path_component + fill_in 'group_name', with: group_name + fill_in 'group_description', with: group_description click_button "Create group" - expect(current_path).to eq admin_group_path(Group.find_by(path: 'gitlab')) - expect(page).to have_content('Group: gitlab') - expect(page).to have_content('Group description') + expect(current_path).to eq admin_group_path(Group.find_by(path: path_component)) + content = page.find('div#content-body') + h3_texts = content.all('h3').collect(&:text).join("\n") + expect(h3_texts).to match group_name + li_texts = content.all('li').collect(&:text).join("\n") + expect(li_texts).to match group_name + expect(li_texts).to match path_component + expect(li_texts).to match group_description end scenario 'shows the visibility level radio populated with the default value' do @@ -39,6 +48,15 @@ feature 'Admin Groups', feature: true do expect_selected_visibility(internal) end + + scenario 'when entered in group path, it auto filled the group name', js: true do + visit admin_groups_path + click_link "New group" + group_path = 'gitlab' + fill_in 'group_path', with: group_path + name_field = find('input#group_name') + expect(name_field.value).to eq group_path + end end describe 'show a group' do @@ -59,6 +77,17 @@ feature 'Admin Groups', feature: true do expect_selected_visibility(group.visibility_level) end + + scenario 'edit group path does not change group name', js: true do + group = create(:group, :private) + + visit admin_group_edit_path(group) + name_field = find('input#group_name') + original_name = name_field.value + fill_in 'group_path', with: 'this-new-path' + + expect(name_field.value).to eq original_name + end end describe 'add user into a group', js: true do diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index c0807b8c507..f6c3bc6a58d 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -223,7 +223,7 @@ describe "Admin::Users", feature: true do it "changes user entry" do user.reload expect(user.name).to eq('Big Bang') - expect(user.is_admin?).to be_truthy + expect(user.admin?).to be_truthy expect(user.password_expires_at).to be <= Time.now end end diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb index 19f5d1b0f30..1d4b86ed4b4 100644 --- a/spec/features/dashboard/group_spec.rb +++ b/spec/features/dashboard/group_spec.rb @@ -5,16 +5,18 @@ RSpec.describe 'Dashboard Group', feature: true do login_as(:user) end - it 'creates new grpup' do + it 'creates new group', js: true do visit dashboard_groups_path click_link 'New group' + new_path = 'Samurai' + new_description = 'Tokugawa Shogunate' - fill_in 'group_path', with: 'Samurai' - fill_in 'group_description', with: 'Tokugawa Shogunate' + fill_in 'group_path', with: new_path + fill_in 'group_description', with: new_description click_button 'Create group' - expect(current_path).to eq group_path(Group.find_by(name: 'Samurai')) - expect(page).to have_content('Samurai') - expect(page).to have_content('Tokugawa Shogunate') + expect(current_path).to eq group_path(Group.find_by(name: new_path)) + expect(page).to have_content(new_path) + expect(page).to have_content(new_description) end end diff --git a/spec/features/dashboard/issuables_counter_spec.rb b/spec/features/dashboard/issuables_counter_spec.rb index a1718912fc6..4fca7577e74 100644 --- a/spec/features/dashboard/issuables_counter_spec.rb +++ b/spec/features/dashboard/issuables_counter_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Navigation bar counter', feature: true, js: true, caching: true do +describe 'Navigation bar counter', feature: true, caching: true do let(:user) { create(:user) } let(:project) { create(:empty_project, namespace: user.namespace) } let(:issue) { create(:issue, project: project) } @@ -13,33 +13,48 @@ describe 'Navigation bar counter', feature: true, js: true, caching: true do end it 'reflects dashboard issues count' do - visit issues_dashboard_path + visit issues_path expect_counters('issues', '1') issue.update(assignee: nil) - visit issues_dashboard_path - expect_counters('issues', '1') + Timecop.travel(3.minutes.from_now) do + visit issues_path + + expect_counters('issues', '0') + end end it 'reflects dashboard merge requests count' do - visit merge_requests_dashboard_path + visit merge_requests_path expect_counters('merge_requests', '1') merge_request.update(assignee: nil) - visit merge_requests_dashboard_path - expect_counters('merge_requests', '1') + Timecop.travel(3.minutes.from_now) do + visit merge_requests_path + + expect_counters('merge_requests', '0') + end + end + + def issues_path + issues_dashboard_path(assignee_id: user.id) + end + + def merge_requests_path + merge_requests_dashboard_path(assignee_id: user.id) end def expect_counters(issuable_type, count) - dashboard_count = find('li.active') - find('.global-dropdown-toggle').click + dashboard_count = find('.nav-links li.active') nav_count = find(".dashboard-shortcuts-#{issuable_type}") + header_count = find(".header-content .#{issuable_type.tr('_', '-')}-count") - expect(nav_count).to have_content(count) expect(dashboard_count).to have_content(count) + expect(nav_count).to have_content(count) + expect(header_count).to have_content(count) end end diff --git a/spec/features/dashboard/project_member_activity_index_spec.rb b/spec/features/dashboard/project_member_activity_index_spec.rb index d62839a09ef..49d93db58a9 100644 --- a/spec/features/dashboard/project_member_activity_index_spec.rb +++ b/spec/features/dashboard/project_member_activity_index_spec.rb @@ -21,20 +21,20 @@ feature 'Project member activity', feature: true, js: true do context 'when a user joins the project' do before { visit_activities_and_wait_with_event(Event::JOINED) } - it { is_expected.to eq("joined project") } + it { is_expected.to eq("#{user.name} joined project") } end context 'when a user leaves the project' do before { visit_activities_and_wait_with_event(Event::LEFT) } - it { is_expected.to eq("left project") } + it { is_expected.to eq("#{user.name} left project") } end context 'when a users membership expires for the project' do before { visit_activities_and_wait_with_event(Event::EXPIRED) } it "presents the correct message" do - message = "removed due to membership expiration from project" + message = "#{user.name} removed due to membership expiration from project" is_expected.to eq(message) end end diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index c4e58d14f75..f1789fc9d43 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -7,7 +7,6 @@ RSpec.describe 'Dashboard Projects', feature: true do before do project.team << [user, :developer] login_as user - visit dashboard_projects_path end it 'shows the project the user in a member of in the list' do @@ -15,15 +14,19 @@ RSpec.describe 'Dashboard Projects', feature: true do expect(page).to have_content('awesome stuff') end - describe "with a pipeline" do - let(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) } + describe "with a pipeline", redis: true do + let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha) } before do - pipeline + # Since the cache isn't updated when a new pipeline is created + # we need the pipeline to advance in the pipeline since the cache was created + # by visiting the login page. + pipeline.succeed end it 'shows that the last pipeline passed' do visit dashboard_projects_path + expect(page).to have_xpath("//a[@href='#{pipelines_namespace_project_commit_path(project.namespace, project, project.commit)}']") end end diff --git a/spec/features/discussion_comments_spec.rb b/spec/features/discussion_comments_spec.rb new file mode 100644 index 00000000000..ae778118c5c --- /dev/null +++ b/spec/features/discussion_comments_spec.rb @@ -0,0 +1,295 @@ +require 'spec_helper' + +shared_examples 'discussion comments' do |resource_name| + let(:form_selector) { '.js-main-target-form' } + let(:dropdown_selector) { "#{form_selector} .comment-type-dropdown" } + let(:toggle_selector) { "#{dropdown_selector} .dropdown-toggle" } + let(:menu_selector) { "#{dropdown_selector} .dropdown-menu" } + let(:submit_selector) { "#{form_selector} .js-comment-submit-button" } + let(:close_selector) { "#{form_selector} .btn-comment-and-close" } + let(:comments_selector) { '.timeline > .note.timeline-entry' } + + it 'should show a comment type toggle' do + expect(page).to have_selector toggle_selector + end + + it 'clicking "Comment" will post a comment' do + find("#{form_selector} .note-textarea").send_keys('a') + + find(submit_selector).click + + find(comments_selector, match: :first) + new_comment = all(comments_selector).last + + expect(new_comment).to have_content 'a' + expect(new_comment).not_to have_selector '.discussion' + end + + if resource_name == 'issue' + it "clicking 'Comment & close #{resource_name}' will post a comment and close the #{resource_name}" do + find("#{form_selector} .note-textarea").send_keys('a') + + find(close_selector).click + + find(comments_selector, match: :first) + find("#{comments_selector}.system-note") + entries = all(comments_selector) + close_note = entries.last + new_comment = entries[-2] + + expect(close_note).to have_content 'closed' + expect(new_comment).not_to have_selector '.discussion' + end + end + + describe 'when the toggle is clicked' do + before do + find("#{form_selector} .note-textarea").send_keys('a') + + find(toggle_selector).click + end + + it 'opens a comment type dropdown with "Comment" and "Start discussion"' do + expect(page).to have_selector menu_selector + end + + it 'has a "Comment" item' do + menu = find(menu_selector) + + expect(menu).to have_content 'Comment' + expect(menu).to have_content "Add a general comment to this #{resource_name}." + end + + it 'has a "Start discussion" item' do + menu = find(menu_selector) + + expect(menu).to have_content 'Start discussion' + expect(menu).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}." + end + + it 'has the "Comment" item selected by default' do + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).to have_selector '.fa-check' + expect(items.first['class']).to match 'droplab-item-selected' + + expect(items.last).to have_content 'Start discussion' + expect(items.last).not_to have_selector '.fa-check' + expect(items.last['class']).not_to match 'droplab-item-selected' + end + + it 'closes the menu when clicking the toggle' do + find(toggle_selector).click + + expect(page).not_to have_selector menu_selector + end + + it 'closes the menu when clicking the body' do + find('body').click + + expect(page).not_to have_selector menu_selector + end + + it 'clicking the ul padding should not change the text' do + find(menu_selector).trigger 'click' + + expect(find(dropdown_selector)).to have_content 'Comment' + end + + describe 'when selecting "Start discussion"' do + before do + find("#{menu_selector} li", match: :first) + all("#{menu_selector} li").last.click + end + + it 'updates the note_type input to "DiscussionNote"' do + expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote') + end + + it 'updates the submit button text' do + expect(find(dropdown_selector)).to have_content 'Start discussion' + end + + if resource_name =~ /(issue|merge request)/ + it 'updates the close button text' do + expect(find(close_selector)).to have_content "Start discussion & close #{resource_name}" + end + + it 'typing does not change the close button text' do + find("#{form_selector} .note-textarea").send_keys('b') + + expect(find(close_selector)).to have_content "Start discussion & close #{resource_name}" + end + end + + it 'closes the dropdown' do + expect(page).not_to have_selector menu_selector + end + + it 'clicking "Start discussion" will post a discussion' do + find(submit_selector).click + + find(comments_selector, match: :first) + new_comment = all(comments_selector).last + + expect(new_comment).to have_content 'a' + expect(new_comment).to have_selector '.discussion' + end + + if resource_name == 'issue' + it "clicking 'Start discussion & close #{resource_name}' will post a discussion and close the #{resource_name}" do + find(close_selector).click + + find(comments_selector, match: :first) + find("#{comments_selector}.system-note") + entries = all(comments_selector) + close_note = entries.last + new_discussion = entries[-2] + + expect(close_note).to have_content 'closed' + expect(new_discussion).to have_selector '.discussion' + end + end + + describe 'when opening the menu' do + before do + find(toggle_selector).click + end + + it 'should have "Start discussion" selected' do + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).not_to have_selector '.fa-check' + expect(items.first['class']).not_to match 'droplab-item-selected' + + expect(items.last).to have_content 'Start discussion' + expect(items.last).to have_selector '.fa-check' + expect(items.last['class']).to match 'droplab-item-selected' + end + + describe 'when selecting "Comment"' do + before do + find("#{menu_selector} li", match: :first).click + end + + it 'clears the note_type input"' do + expect(find("#{form_selector} #note_type", visible: false).value).to eq('') + end + + it 'updates the submit button text' do + expect(find(dropdown_selector)).to have_content 'Comment' + end + + if resource_name =~ /(issue|merge request)/ + it 'updates the close button text' do + expect(find(close_selector)).to have_content "Comment & close #{resource_name}" + end + + it 'typing does not change the close button text' do + find("#{form_selector} .note-textarea").send_keys('b') + + expect(find(close_selector)).to have_content "Comment & close #{resource_name}" + end + end + + it 'closes the dropdown' do + expect(page).not_to have_selector menu_selector + end + + it 'should have "Comment" selected when opening the menu' do + find(toggle_selector).click + + find("#{menu_selector} li", match: :first) + items = all("#{menu_selector} li") + + expect(items.first).to have_content 'Comment' + expect(items.first).to have_selector '.fa-check' + expect(items.first['class']).to match 'droplab-item-selected' + + expect(items.last).to have_content 'Start discussion' + expect(items.last).not_to have_selector '.fa-check' + expect(items.last['class']).not_to match 'droplab-item-selected' + end + end + end + end + end + + if resource_name =~ /(issue|merge request)/ + describe "on a closed #{resource_name}" do + before do + find("#{form_selector} .js-note-target-close").click + + find("#{form_selector} .note-textarea").send_keys('a') + end + + it "should show a 'Comment & reopen #{resource_name}' button" do + expect(find("#{form_selector} .js-note-target-reopen")).to have_content "Comment & reopen #{resource_name}" + end + + it "should show a 'Start discussion & reopen #{resource_name}' button when 'Start discussion' is selected" do + find(toggle_selector).click + + find("#{menu_selector} li", match: :first) + all("#{menu_selector} li").last.click + + expect(find("#{form_selector} .js-note-target-reopen")).to have_content "Start discussion & reopen #{resource_name}" + end + end + end +end + +describe 'Discussion Comments', :feature, :js do + include RepoHelpers + + let(:user) { create(:user) } + let(:project) { create(:project) } + + before do + project.team << [user, :developer] + + login_as(user) + end + + describe 'on a merge request' do + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it_behaves_like 'discussion comments', 'merge request' + end + + describe 'on an issue' do + let(:issue) { create(:issue, project: project) } + + before do + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it_behaves_like 'discussion comments', 'issue' + end + + describe 'on an snippet' do + let(:snippet) { create(:project_snippet, :private, project: project, author: user) } + + before do + visit namespace_project_snippet_path(project.namespace, project, snippet) + end + + it_behaves_like 'discussion comments', 'snippet' + end + + describe 'on a commit' do + before do + visit namespace_project_commit_path(project.namespace, project, sample_commit.id) + end + + it_behaves_like 'discussion comments', 'commit' + end +end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 7670c4caea4..3d32c47bf09 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -83,7 +83,7 @@ feature 'Group', feature: true do end end - describe 'create a nested group' do + describe 'create a nested group', js: true do let(:group) { create(:group, path: 'foo') } context 'as admin' do diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 572bca3de21..58f897cba3e 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -4,7 +4,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu let(:user) { create(:user) } let(:project) { create(:project) } let(:merge_request) { create(:merge_request, source_project: project) } - let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } + let!(:discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion } describe 'as a user with access to the project' do before do diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb index bc8cbe30e66..cae01f37b6b 100644 --- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe 'Dropdown hint', js: true, feature: true do +describe 'Dropdown hint', :js, :feature do include FilteredSearchHelpers include WaitForAjax @@ -9,10 +9,6 @@ describe 'Dropdown hint', js: true, feature: true do let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_hint) { '#js-dropdown-hint' } - def dropdown_hint_size - page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size - end - def click_hint(text) find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: text).click end @@ -46,14 +42,16 @@ describe 'Dropdown hint', js: true, feature: true do it 'does not filter `Press Enter or click to search`' do filtered_search.set('randomtext') - expect(page).to have_css(js_dropdown_hint, text: 'Press Enter or click to search', visible: false) - expect(dropdown_hint_size).to eq(0) + hint_dropdown = find(js_dropdown_hint) + + expect(hint_dropdown).to have_content('Press Enter or click to search') + expect(hint_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 0) end it 'filters with text' do filtered_search.set('a') - expect(dropdown_hint_size).to eq(3) + expect(find(js_dropdown_hint)).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3) end end diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index c8645e08c4b..abe5d61e38c 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -28,10 +28,6 @@ describe 'Dropdown label', js: true, feature: true do filter_dropdown.find('.filter-dropdown-item', text: text).click end - def dropdown_label_size - filter_dropdown.all('.filter-dropdown-item').size - end - def clear_search_field find('.filtered-search-box .clear-search').click end @@ -81,7 +77,7 @@ describe 'Dropdown label', js: true, feature: true do filtered_search.set('label:') expect(filter_dropdown).to have_content(bug_label.title) - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) end end @@ -97,7 +93,8 @@ describe 'Dropdown label', js: true, feature: true do expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible - expect(dropdown_label_size).to eq(2) + + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 2) clear_search_field init_label_search @@ -106,14 +103,14 @@ describe 'Dropdown label', js: true, feature: true do expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible - expect(dropdown_label_size).to eq(2) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 2) end it 'filters by multiple words with or without symbol' do filtered_search.send_keys('Hig') expect(filter_dropdown.find('.filter-dropdown-item', text: two_words_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) clear_search_field init_label_search @@ -121,14 +118,14 @@ describe 'Dropdown label', js: true, feature: true do filtered_search.send_keys('~Hig') expect(filter_dropdown.find('.filter-dropdown-item', text: two_words_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) end it 'filters by multiple words containing single quotes with or without symbol' do filtered_search.send_keys('won\'t') expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_single_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) clear_search_field init_label_search @@ -136,14 +133,14 @@ describe 'Dropdown label', js: true, feature: true do filtered_search.send_keys('~won\'t') expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_single_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) end it 'filters by multiple words containing double quotes with or without symbol' do filtered_search.send_keys('won"t') expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) clear_search_field init_label_search @@ -151,14 +148,14 @@ describe 'Dropdown label', js: true, feature: true do filtered_search.send_keys('~won"t') expect(filter_dropdown.find('.filter-dropdown-item', text: wont_fix_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) end it 'filters by special characters with or without symbol' do filtered_search.send_keys('^+') expect(filter_dropdown.find('.filter-dropdown-item', text: special_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) clear_search_field init_label_search @@ -166,7 +163,7 @@ describe 'Dropdown label', js: true, feature: true do filtered_search.send_keys('~^+') expect(filter_dropdown.find('.filter-dropdown-item', text: special_label.title)).to be_visible - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) end end @@ -280,13 +277,13 @@ describe 'Dropdown label', js: true, feature: true do create(:label, project: project, title: 'bug-label') init_label_search - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) create(:label, project: project) clear_search_field init_label_search - expect(dropdown_label_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown-item', count: 1) end end end diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index 0a525bc68c9..448259057b0 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -65,7 +65,7 @@ describe 'Dropdown milestone', :feature, :js do it 'should load all the milestones when opened' do filtered_search.set('milestone:') - expect(dropdown_milestone_size).to be > 0 + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 6) end end @@ -84,37 +84,37 @@ describe 'Dropdown milestone', :feature, :js do it 'filters by name' do filtered_search.send_keys('v1') - expect(dropdown_milestone_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end it 'filters by case insensitive name' do filtered_search.send_keys('V1') - expect(dropdown_milestone_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end it 'filters by name with symbol' do filtered_search.send_keys('%v1') - expect(dropdown_milestone_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end it 'filters by case insensitive name with symbol' do filtered_search.send_keys('%V1') - expect(dropdown_milestone_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end it 'filters by special characters' do filtered_search.send_keys('(+') - expect(dropdown_milestone_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end it 'filters by special characters with symbol' do filtered_search.send_keys('%(+') - expect(dropdown_milestone_size).to eq(1) + expect(filter_dropdown).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end end diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index 48e7af67616..28137f11b92 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -26,7 +26,7 @@ describe 'Search bar', js: true, feature: true do filtered_search.native.send_keys(:down) page.within '#js-dropdown-hint' do - expect(page).to have_selector('.dropdown-active') + expect(page).to have_selector('.droplab-item-active') end end @@ -79,28 +79,30 @@ describe 'Search bar', js: true, feature: true do filtered_search.set('author') - expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1) + expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) find('.filtered-search-box .clear-search').click filtered_search.click - expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size) + expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: original_size) end it 'resets the dropdown filters' do + filtered_search.click + + hint_offset = get_left_style(find('#js-dropdown-hint')['style']) + filtered_search.set('a') - hint_style = page.find('#js-dropdown-hint')['style'] - hint_offset = get_left_style(hint_style) filtered_search.set('author:') - expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0) + find('#js-dropdown-hint', visible: false) find('.filtered-search-box .clear-search').click filtered_search.click - expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0 - expect(get_left_style(page.find('#js-dropdown-hint')['style'])).to eq(hint_offset) + expect(find('#js-dropdown-hint')).to have_selector('.filter-dropdown .filter-dropdown-item', count: 4) + expect(get_left_style(find('#js-dropdown-hint')['style'])).to eq(hint_offset) end end end diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb index a6c72b0b3ac..218d95a88b8 100644 --- a/spec/features/merge_requests/diff_notes_avatars_spec.rb +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -164,9 +164,7 @@ feature 'Diff note avatars', feature: true, js: true do context 'multiple comments' do before do - create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) - create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) - create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + create_list(:diff_note_on_merge_request, 3, project: project, noteable: merge_request, in_reply_to: note) visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view) diff --git a/spec/features/merge_requests/diff_notes_resolve_spec.rb b/spec/features/merge_requests/diff_notes_resolve_spec.rb index 69164aabdb2..88d28b649a4 100644 --- a/spec/features/merge_requests/diff_notes_resolve_spec.rb +++ b/spec/features/merge_requests/diff_notes_resolve_spec.rb @@ -191,7 +191,7 @@ feature 'Diff notes resolve', feature: true, js: true do context 'multiple notes' do before do - create(:diff_note_on_merge_request, project: project, noteable: merge_request) + create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: note) visit_merge_request end diff --git a/spec/features/notes_on_merge_requests_spec.rb b/spec/features/notes_on_merge_requests_spec.rb index fab2d532e06..783f2e93909 100644 --- a/spec/features/notes_on_merge_requests_spec.rb +++ b/spec/features/notes_on_merge_requests_spec.rb @@ -25,7 +25,7 @@ describe 'Comments', feature: true do describe 'the note form' do it 'is valid' do is_expected.to have_css('.js-main-target-form', visible: true, count: 1) - expect(find('.js-main-target-form input[type=submit]').value). + expect(find('.js-main-target-form .js-comment-button').value). to eq('Comment') page.within('.js-main-target-form') do expect(page).not_to have_link('Cancel') diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb index 76cb240ea98..035c57eaa47 100644 --- a/spec/features/projects/settings/pipelines_settings_spec.rb +++ b/spec/features/projects/settings/pipelines_settings_spec.rb @@ -32,5 +32,16 @@ feature "Pipelines settings", feature: true do expect(page).to have_button('Save changes', disabled: false) expect(page).to have_field('Test coverage parsing', with: 'coverage_regex') end + + scenario 'updates auto_cancel_pending_pipelines' do + page.check('Auto-cancel redundant, pending pipelines') + click_on 'Save changes' + + expect(page.status_code).to eq(200) + expect(page).to have_button('Save changes', disabled: false) + + checkbox = find_field('project_auto_cancel_pending_pipelines') + expect(checkbox).to be_checked + end end end diff --git a/spec/features/protected_branches/access_control_ce_spec.rb b/spec/features/protected_branches/access_control_ce_spec.rb index e4aca25a339..eb3cea775da 100644 --- a/spec/features/protected_branches/access_control_ce_spec.rb +++ b/spec/features/protected_branches/access_control_ce_spec.rb @@ -2,7 +2,9 @@ RSpec.shared_examples "protected branches > access control > CE" do ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| it "allows creating protected branches that #{access_type_name} can push to" do visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + within('.new_protected_branch') do allowed_to_push_button = find(".js-allowed-to-push") @@ -11,6 +13,7 @@ RSpec.shared_examples "protected branches > access control > CE" do within(".dropdown.open .dropdown-menu") { click_on access_type_name } end end + click_on "Protect" expect(ProtectedBranch.count).to eq(1) @@ -19,7 +22,9 @@ RSpec.shared_examples "protected branches > access control > CE" do it "allows updating protected branches so that #{access_type_name} can push to them" do visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + click_on "Protect" expect(ProtectedBranch.count).to eq(1) @@ -34,6 +39,7 @@ RSpec.shared_examples "protected branches > access control > CE" do end wait_for_ajax + expect(ProtectedBranch.last.push_access_levels.map(&:access_level)).to include(access_type_id) end end @@ -41,7 +47,9 @@ RSpec.shared_examples "protected branches > access control > CE" do ProtectedBranch::MergeAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| it "allows creating protected branches that #{access_type_name} can merge to" do visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + within('.new_protected_branch') do allowed_to_merge_button = find(".js-allowed-to-merge") @@ -50,6 +58,7 @@ RSpec.shared_examples "protected branches > access control > CE" do within(".dropdown.open .dropdown-menu") { click_on access_type_name } end end + click_on "Protect" expect(ProtectedBranch.count).to eq(1) @@ -58,7 +67,9 @@ RSpec.shared_examples "protected branches > access control > CE" do it "allows updating protected branches so that #{access_type_name} can merge to them" do visit namespace_project_protected_branches_path(project.namespace, project) + set_protected_branch_name('master') + click_on "Protect" expect(ProtectedBranch.count).to eq(1) @@ -73,6 +84,7 @@ RSpec.shared_examples "protected branches > access control > CE" do end wait_for_ajax + expect(ProtectedBranch.last.merge_access_levels.map(&:access_level)).to include(access_type_id) end end diff --git a/spec/features/protected_tags/access_control_ce_spec.rb b/spec/features/protected_tags/access_control_ce_spec.rb new file mode 100644 index 00000000000..5b2baf8616c --- /dev/null +++ b/spec/features/protected_tags/access_control_ce_spec.rb @@ -0,0 +1,46 @@ +RSpec.shared_examples "protected tags > access control > CE" do + ProtectedTag::CreateAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| + it "allows creating protected tags that #{access_type_name} can create" do + visit namespace_project_protected_tags_path(project.namespace, project) + + set_protected_tag_name('master') + + within('.js-new-protected-tag') do + allowed_to_create_button = find(".js-allowed-to-create") + + unless allowed_to_create_button.text == access_type_name + allowed_to_create_button.click + within(".dropdown.open .dropdown-menu") { click_on access_type_name } + end + end + + click_on "Protect" + + expect(ProtectedTag.count).to eq(1) + expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to eq([access_type_id]) + end + + it "allows updating protected tags so that #{access_type_name} can create them" do + visit namespace_project_protected_tags_path(project.namespace, project) + + set_protected_tag_name('master') + + click_on "Protect" + + expect(ProtectedTag.count).to eq(1) + + within(".protected-tags-list") do + find(".js-allowed-to-create").click + + within('.js-allowed-to-create-container') do + expect(first("li")).to have_content("Roles") + click_on access_type_name + end + end + + wait_for_ajax + + expect(ProtectedTag.last.create_access_levels.map(&:access_level)).to include(access_type_id) + end + end +end diff --git a/spec/features/protected_tags_spec.rb b/spec/features/protected_tags_spec.rb new file mode 100644 index 00000000000..09e8c850de3 --- /dev/null +++ b/spec/features/protected_tags_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' +Dir["./spec/features/protected_tags/*.rb"].sort.each { |f| require f } + +feature 'Projected Tags', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user, :admin) } + let(:project) { create(:project) } + + before { login_as(user) } + + def set_protected_tag_name(tag_name) + find(".js-protected-tag-select").click + find(".dropdown-input-field").set(tag_name) + click_on("Create wildcard #{tag_name}") + end + + describe "explicit protected tags" do + it "allows creating explicit protected tags" do + visit namespace_project_protected_tags_path(project.namespace, project) + set_protected_tag_name('some-tag') + click_on "Protect" + + within(".protected-tags-list") { expect(page).to have_content('some-tag') } + expect(ProtectedTag.count).to eq(1) + expect(ProtectedTag.last.name).to eq('some-tag') + end + + it "displays the last commit on the matching tag if it exists" do + commit = create(:commit, project: project) + project.repository.add_tag(user, 'some-tag', commit.id) + + visit namespace_project_protected_tags_path(project.namespace, project) + set_protected_tag_name('some-tag') + click_on "Protect" + + within(".protected-tags-list") { expect(page).to have_content(commit.id[0..7]) } + end + + it "displays an error message if the named tag does not exist" do + visit namespace_project_protected_tags_path(project.namespace, project) + set_protected_tag_name('some-tag') + click_on "Protect" + + within(".protected-tags-list") { expect(page).to have_content('tag was removed') } + end + end + + describe "wildcard protected tags" do + it "allows creating protected tags with a wildcard" do + visit namespace_project_protected_tags_path(project.namespace, project) + set_protected_tag_name('*-stable') + click_on "Protect" + + within(".protected-tags-list") { expect(page).to have_content('*-stable') } + expect(ProtectedTag.count).to eq(1) + expect(ProtectedTag.last.name).to eq('*-stable') + end + + it "displays the number of matching tags" do + project.repository.add_tag(user, 'production-stable', 'master') + project.repository.add_tag(user, 'staging-stable', 'master') + + visit namespace_project_protected_tags_path(project.namespace, project) + set_protected_tag_name('*-stable') + click_on "Protect" + + within(".protected-tags-list") { expect(page).to have_content("2 matching tags") } + end + + it "displays all the tags matching the wildcard" do + project.repository.add_tag(user, 'production-stable', 'master') + project.repository.add_tag(user, 'staging-stable', 'master') + project.repository.add_tag(user, 'development', 'master') + + visit namespace_project_protected_tags_path(project.namespace, project) + set_protected_tag_name('*-stable') + click_on "Protect" + + visit namespace_project_protected_tags_path(project.namespace, project) + click_on "2 matching tags" + + within(".protected-tags-list") do + expect(page).to have_content("production-stable") + expect(page).to have_content("staging-stable") + expect(page).not_to have_content("development") + end + end + end + + describe "access control" do + include_examples "protected tags > access control > CE" + end +end diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index c1ae6db00c6..81fa2de1cc3 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -77,6 +77,59 @@ feature 'Triggers', feature: true, js: true do expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' expect(page.find('.triggers-list')).to have_content new_trigger_title end + + context 'scheduled triggers' do + let!(:trigger) do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + end + + context 'enabling schedule' do + before do + visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger) + end + + scenario 'do fill form with valid data and save' do + find('#trigger_trigger_schedule_attributes_active').click + fill_in 'trigger_trigger_schedule_attributes_cron', with: '1 * * * *' + fill_in 'trigger_trigger_schedule_attributes_cron_timezone', with: 'UTC' + fill_in 'trigger_trigger_schedule_attributes_ref', with: 'master' + click_button 'Save trigger' + + expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' + end + + scenario 'do not fill form with valid data and save' do + find('#trigger_trigger_schedule_attributes_active').click + click_button 'Save trigger' + + expect(page).to have_content 'The form contains the following errors' + end + end + + context 'disabling schedule' do + before do + trigger.create_trigger_schedule( + project: trigger.project, + active: true, + ref: 'master', + cron: '1 * * * *', + cron_timezone: 'UTC') + + visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger) + end + + scenario 'disable and save form' do + find('#trigger_trigger_schedule_attributes_active').click + click_button 'Save trigger' + expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' + + visit edit_namespace_project_trigger_path(@project.namespace, @project, trigger) + checkbox = find_field('trigger_trigger_schedule_attributes_active') + + expect(checkbox).not_to be_checked + end + end + end end describe 'trigger "Take ownership" workflow' do diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 77a04507be1..765bf44d863 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -202,4 +202,45 @@ describe NotesFinder do end end end + + describe '#target' do + subject { described_class.new(project, user, params) } + + context 'for a issue target' do + let(:issue) { create(:issue, project: project) } + let(:params) { { target_type: 'issue', target_id: issue.id } } + + it 'returns the issue' do + expect(subject.target).to eq(issue) + end + end + + context 'for a merge request target' do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:params) { { target_type: 'merge_request', target_id: merge_request.id } } + + it 'returns the merge_request' do + expect(subject.target).to eq(merge_request) + end + end + + context 'for a snippet target' do + let(:snippet) { create(:project_snippet, project: project) } + let(:params) { { target_type: 'snippet', target_id: snippet.id } } + + it 'returns the snippet' do + expect(subject.target).to eq(snippet) + end + end + + context 'for a commit target' do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit } + let(:params) { { target_type: 'commit', target_id: commit.id } } + + it 'returns the commit' do + expect(subject.target).to eq(commit) + end + end + end end diff --git a/spec/helpers/ci_status_helper_spec.rb b/spec/helpers/ci_status_helper_spec.rb index 174cc84a97b..c795fe5a2a3 100644 --- a/spec/helpers/ci_status_helper_spec.rb +++ b/spec/helpers/ci_status_helper_spec.rb @@ -19,7 +19,11 @@ describe CiStatusHelper do describe "#pipeline_status_cache_key" do it "builds a cache key for pipeline status" do - pipeline_status = Ci::PipelineStatus.new(build(:project), sha: "123abc", status: "success") + pipeline_status = Gitlab::Cache::Ci::ProjectPipelineStatus.new( + build(:project), + sha: "123abc", + status: "success" + ) expect(helper.pipeline_status_cache_key(pipeline_status)).to eq("pipeline-status/123abc-success") end end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index f0554cc068d..540cb0ab1e0 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -150,7 +150,7 @@ describe IssuesHelper do describe "when passing a discussion" do let(:diff_note) { create(:diff_note_on_merge_request) } let(:merge_request) { diff_note.noteable } - let(:discussion) { Discussion.new([diff_note]) } + let(:discussion) { diff_note.to_discussion } it "links to the merge request with first note if a single discussion was passed" do expected_path = Gitlab::UrlBuilder.build(diff_note) diff --git a/spec/helpers/notes_helper_spec.rb b/spec/helpers/notes_helper_spec.rb index 9c577501f00..a427de32c4c 100644 --- a/spec/helpers/notes_helper_spec.rb +++ b/spec/helpers/notes_helper_spec.rb @@ -36,21 +36,4 @@ describe NotesHelper do expect(helper.note_max_access_for_user(other_note)).to eq('Reporter') end end - - describe '#preload_max_access_for_authors' do - before do - # This method reads cache from RequestStore, so make sure it's clean. - RequestStore.clear! - end - - it 'loads multiple users' do - expected_access = { - owner.id => Gitlab::Access::OWNER, - master.id => Gitlab::Access::MASTER, - reporter.id => Gitlab::Access::REPORTER - } - - expect(helper.preload_max_access_for_authors(notes, project)).to eq(expected_access) - end - end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 44312ada438..40efab6e4f7 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -63,7 +63,7 @@ describe ProjectsHelper do end end - describe "#project_list_cache_key" do + describe "#project_list_cache_key", redis: true do let(:project) { create(:project) } it "includes the namespace" do diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js index cb48f14fd9a..edd4b3c1440 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -64,58 +64,33 @@ describe('Build', () => { }); }); - describe('initial build trace', () => { - beforeEach(() => { - new Build(); - }); - - it('displays the initial build trace', () => { - expect($.ajax.calls.count()).toBe(1); - const [{ url, dataType, success, context }] = $.ajax.calls.argsFor(0); - expect(url).toBe( - `${BUILD_URL}/trace.json`, - ); - expect(dataType).toBe('json'); - expect(success).toEqual(jasmine.any(Function)); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - - success.call(context, { html: '<span>Example</span>', status: 'running' }); - - expect($('#build-trace .js-build-output').text()).toMatch(/Example/); - }); - - it('removes the spinner', () => { - const [{ success, context }] = $.ajax.calls.argsFor(0); - spyOn(gl.utils, 'setCiStatusFavicon').and.callFake(() => {}); - success.call(context, { trace_html: '<span>Example</span>', status: 'success' }); - - expect($('.js-build-refresh').length).toBe(0); - }); - }); - describe('running build', () => { beforeEach(function () { - $('.js-build-options').data('buildStatus', 'running'); this.build = new Build(); - spyOn(this.build, 'location').and.returnValue(BUILD_URL); }); it('updates the build trace on an interval', function () { + spyOn(gl.utils, 'visitUrl'); + jasmine.clock().tick(4001); - expect($.ajax.calls.count()).toBe(2); - let [{ url, dataType, success, context }] = $.ajax.calls.argsFor(1); - expect(url).toBe( - `${BUILD_URL}/trace.json?state=`, - ); - expect(dataType).toBe('json'); - expect(success).toEqual(jasmine.any(Function)); - - success.call(context, { + expect($.ajax.calls.count()).toBe(1); + + // We have to do it this way to prevent Webpack to fail to compile + // when destructuring assignments and reusing + // the same variables names inside the same scope + let args = $.ajax.calls.argsFor(0)[0]; + + expect(args.url).toBe(`${BUILD_URL}/trace.json`); + expect(args.dataType).toBe('json'); + expect(args.success).toEqual(jasmine.any(Function)); + + args.success.call($, { html: '<span>Update<span>', status: 'running', state: 'newstate', append: true, + complete: false, }); expect($('#build-trace .js-build-output').text()).toMatch(/Update/); @@ -123,17 +98,20 @@ describe('Build', () => { jasmine.clock().tick(4001); - expect($.ajax.calls.count()).toBe(3); - [{ url, dataType, success, context }] = $.ajax.calls.argsFor(2); - expect(url).toBe(`${BUILD_URL}/trace.json?state=newstate`); - expect(dataType).toBe('json'); - expect(success).toEqual(jasmine.any(Function)); + expect($.ajax.calls.count()).toBe(2); + + args = $.ajax.calls.argsFor(1)[0]; + expect(args.url).toBe(`${BUILD_URL}/trace.json`); + expect(args.dataType).toBe('json'); + expect(args.data.state).toBe('newstate'); + expect(args.success).toEqual(jasmine.any(Function)); - success.call(context, { + args.success.call($, { html: '<span>More</span>', status: 'running', state: 'finalstate', append: true, + complete: true, }); expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/); @@ -141,19 +119,22 @@ describe('Build', () => { }); it('replaces the entire build trace', () => { + spyOn(gl.utils, 'visitUrl'); + jasmine.clock().tick(4001); - let [{ success, context }] = $.ajax.calls.argsFor(1); - success.call(context, { + let args = $.ajax.calls.argsFor(0)[0]; + args.success.call($, { html: '<span>Update</span>', status: 'running', - append: true, + append: false, + complete: false, }); expect($('#build-trace .js-build-output').text()).toMatch(/Update/); jasmine.clock().tick(4001); - [{ success, context }] = $.ajax.calls.argsFor(2); - success.call(context, { + args = $.ajax.calls.argsFor(1)[0]; + args.success.call($, { html: '<span>Different</span>', status: 'running', append: false, @@ -163,15 +144,34 @@ describe('Build', () => { expect($('#build-trace .js-build-output').text()).toMatch(/Different/); }); + it('shows information about truncated log', () => { + jasmine.clock().tick(4001); + const [{ success }] = $.ajax.calls.argsFor(0); + + success.call($, { + html: '<span>Update</span>', + status: 'success', + append: false, + truncated: true, + size: '50', + }); + + expect( + $('#build-trace .js-truncated-info').text().trim(), + ).toContain('Showing last 50 KiB of log'); + expect($('#build-trace .js-truncated-info-size').text()).toMatch('50'); + }); + it('reloads the page when the build is done', () => { spyOn(gl.utils, 'visitUrl'); jasmine.clock().tick(4001); - const [{ success, context }] = $.ajax.calls.argsFor(1); - success.call(context, { + const [{ success }] = $.ajax.calls.argsFor(0); + success.call($, { html: '<span>Final</span>', status: 'passed', append: true, + complete: true, }); expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL); diff --git a/spec/javascripts/comment_type_toggle_spec.js b/spec/javascripts/comment_type_toggle_spec.js new file mode 100644 index 00000000000..dfd0810d52e --- /dev/null +++ b/spec/javascripts/comment_type_toggle_spec.js @@ -0,0 +1,157 @@ +import CommentTypeToggle from '~/comment_type_toggle'; +import * as dropLabSrc from '~/droplab/drop_lab'; +import InputSetter from '~/droplab/plugins/input_setter'; + +describe('CommentTypeToggle', function () { + describe('class constructor', function () { + beforeEach(function () { + this.dropdownTrigger = {}; + this.dropdownList = {}; + this.noteTypeInput = {}; + this.submitButton = {}; + this.closeButton = {}; + + this.commentTypeToggle = new CommentTypeToggle({ + dropdownTrigger: this.dropdownTrigger, + dropdownList: this.dropdownList, + noteTypeInput: this.noteTypeInput, + submitButton: this.submitButton, + closeButton: this.closeButton, + }); + }); + + it('should set .dropdownTrigger', function () { + expect(this.commentTypeToggle.dropdownTrigger).toBe(this.dropdownTrigger); + }); + + it('should set .dropdownList', function () { + expect(this.commentTypeToggle.dropdownList).toBe(this.dropdownList); + }); + + it('should set .noteTypeInput', function () { + expect(this.commentTypeToggle.noteTypeInput).toBe(this.noteTypeInput); + }); + + it('should set .submitButton', function () { + expect(this.commentTypeToggle.submitButton).toBe(this.submitButton); + }); + + it('should set .closeButton', function () { + expect(this.commentTypeToggle.closeButton).toBe(this.closeButton); + }); + + it('should set .reopenButton', function () { + expect(this.commentTypeToggle.reopenButton).toBe(this.reopenButton); + }); + }); + + describe('initDroplab', function () { + beforeEach(function () { + this.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + closeButton: {}, + setConfig: () => {}, + }; + this.config = {}; + + this.droplab = jasmine.createSpyObj('droplab', ['init']); + + spyOn(dropLabSrc, 'default').and.returnValue(this.droplab); + spyOn(this.commentTypeToggle, 'setConfig').and.returnValue(this.config); + + CommentTypeToggle.prototype.initDroplab.call(this.commentTypeToggle); + }); + + it('should instantiate a DropLab instance', function () { + expect(dropLabSrc.default).toHaveBeenCalled(); + }); + + it('should set .droplab', function () { + expect(this.commentTypeToggle.droplab).toBe(this.droplab); + }); + + it('should call .setConfig', function () { + expect(this.commentTypeToggle.setConfig).toHaveBeenCalled(); + }); + + it('should call DropLab.prototype.init', function () { + expect(this.droplab.init).toHaveBeenCalledWith( + this.commentTypeToggle.dropdownTrigger, + this.commentTypeToggle.dropdownList, + [InputSetter], + this.config, + ); + }); + }); + + describe('setConfig', function () { + describe('if no .closeButton is provided', function () { + beforeEach(function () { + this.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + reopenButton: {}, + }; + + this.setConfig = CommentTypeToggle.prototype.setConfig.call(this.commentTypeToggle); + }); + + it('should not add .closeButton related InputSetter config', function () { + expect(this.setConfig).toEqual({ + InputSetter: [{ + input: this.commentTypeToggle.noteTypeInput, + valueAttribute: 'data-value', + }, { + input: this.commentTypeToggle.submitButton, + valueAttribute: 'data-submit-text', + }, { + input: this.commentTypeToggle.reopenButton, + valueAttribute: 'data-reopen-text', + }, { + input: this.commentTypeToggle.reopenButton, + valueAttribute: 'data-reopen-text', + inputAttribute: 'data-alternative-text', + }], + }); + }); + }); + + describe('if no .reopenButton is provided', function () { + beforeEach(function () { + this.commentTypeToggle = { + dropdownTrigger: {}, + dropdownList: {}, + noteTypeInput: {}, + submitButton: {}, + closeButton: {}, + }; + + this.setConfig = CommentTypeToggle.prototype.setConfig.call(this.commentTypeToggle); + }); + + it('should not add .reopenButton related InputSetter config', function () { + expect(this.setConfig).toEqual({ + InputSetter: [{ + input: this.commentTypeToggle.noteTypeInput, + valueAttribute: 'data-value', + }, { + input: this.commentTypeToggle.submitButton, + valueAttribute: 'data-submit-text', + }, { + input: this.commentTypeToggle.closeButton, + valueAttribute: 'data-close-text', + }, { + input: this.commentTypeToggle.closeButton, + valueAttribute: 'data-close-text', + inputAttribute: 'data-alternative-text', + }], + }); + }); + }); + }); +}); diff --git a/spec/javascripts/droplab/constants_spec.js b/spec/javascripts/droplab/constants_spec.js new file mode 100644 index 00000000000..35239e4fb8e --- /dev/null +++ b/spec/javascripts/droplab/constants_spec.js @@ -0,0 +1,29 @@ +/* eslint-disable */ + +import * as constants from '~/droplab/constants'; + +describe('constants', function () { + describe('DATA_TRIGGER', function () { + it('should be `data-dropdown-trigger`', function() { + expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger'); + }); + }); + + describe('DATA_DROPDOWN', function () { + it('should be `data-dropdown`', function() { + expect(constants.DATA_DROPDOWN).toBe('data-dropdown'); + }); + }); + + describe('SELECTED_CLASS', function () { + it('should be `droplab-item-selected`', function() { + expect(constants.SELECTED_CLASS).toBe('droplab-item-selected'); + }); + }); + + describe('ACTIVE_CLASS', function () { + it('should be `droplab-item-active`', function() { + expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); + }); + }); +}); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js new file mode 100644 index 00000000000..802e2435672 --- /dev/null +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -0,0 +1,593 @@ +/* eslint-disable */ + +import DropDown from '~/droplab/drop_down'; +import utils from '~/droplab/utils'; +import { SELECTED_CLASS } from '~/droplab/constants'; + +describe('DropDown', function () { + describe('class constructor', function () { + beforeEach(function () { + spyOn(DropDown.prototype, 'getItems'); + spyOn(DropDown.prototype, 'initTemplateString'); + spyOn(DropDown.prototype, 'addEvents'); + + this.list = { innerHTML: 'innerHTML' }; + this.dropdown = new DropDown(this.list); + }); + + it('sets the .hidden property to true', function () { + expect(this.dropdown.hidden).toBe(true); + }) + + it('sets the .list property', function () { + expect(this.dropdown.list).toBe(this.list); + }); + + it('calls .getItems', function () { + expect(DropDown.prototype.getItems).toHaveBeenCalled(); + }); + + it('calls .initTemplateString', function () { + expect(DropDown.prototype.initTemplateString).toHaveBeenCalled(); + }); + + it('calls .addEvents', function () { + expect(DropDown.prototype.addEvents).toHaveBeenCalled(); + }); + + it('sets the .initialState property to the .list.innerHTML', function () { + expect(this.dropdown.initialState).toBe(this.list.innerHTML); + }); + + describe('if the list argument is a string', function () { + beforeEach(function () { + this.element = {}; + this.selector = '.selector'; + + spyOn(Document.prototype, 'querySelector').and.returnValue(this.element); + + this.dropdown = new DropDown(this.selector); + }); + + it('calls .querySelector with the selector string', function () { + expect(Document.prototype.querySelector).toHaveBeenCalledWith(this.selector); + }); + + it('sets the .list property element', function () { + expect(this.dropdown.list).toBe(this.element); + }); + }); + }); + + describe('getItems', function () { + beforeEach(function () { + this.list = { querySelectorAll: () => {} }; + this.dropdown = { list: this.list }; + this.nodeList = []; + + spyOn(this.list, 'querySelectorAll').and.returnValue(this.nodeList); + + this.getItems = DropDown.prototype.getItems.call(this.dropdown); + }); + + it('calls .querySelectorAll with a list item query', function () { + expect(this.list.querySelectorAll).toHaveBeenCalledWith('li'); + }); + + it('sets the .items property to the returned list items', function () { + expect(this.dropdown.items).toEqual(jasmine.any(Array)); + }); + + it('returns the .items', function () { + expect(this.getItems).toEqual(jasmine.any(Array)); + }); + }); + + describe('initTemplateString', function () { + beforeEach(function () { + this.items = [{ outerHTML: '<a></a>' }, { outerHTML: '<img>' }]; + this.dropdown = { items: this.items }; + + DropDown.prototype.initTemplateString.call(this.dropdown); + }); + + it('should set .templateString to the last items .outerHTML', function () { + expect(this.dropdown.templateString).toBe(this.items[1].outerHTML); + }); + + it('should not set .templateString to a non-last items .outerHTML', function () { + expect(this.dropdown.templateString).not.toBe(this.items[0].outerHTML); + }); + + describe('if .items is not set', function () { + beforeEach(function () { + this.dropdown = { getItems: () => {} }; + + spyOn(this.dropdown, 'getItems').and.returnValue([]); + + DropDown.prototype.initTemplateString.call(this.dropdown); + }); + + it('should call .getItems', function () { + expect(this.dropdown.getItems).toHaveBeenCalled(); + }); + }); + + describe('if items array is empty', function () { + beforeEach(function () { + this.dropdown = { items: [] }; + + DropDown.prototype.initTemplateString.call(this.dropdown); + }); + + it('should set .templateString to an empty string', function () { + expect(this.dropdown.templateString).toBe(''); + }); + }); + }); + + describe('clickEvent', function () { + beforeEach(function () { + this.list = { dispatchEvent: () => {} }; + this.dropdown = { hide: () => {}, list: this.list, addSelectedClass: () => {} }; + this.event = { preventDefault: () => {}, target: {} }; + this.customEvent = {}; + this.closestElement = {}; + + spyOn(this.dropdown, 'hide'); + spyOn(this.dropdown, 'addSelectedClass'); + spyOn(this.list, 'dispatchEvent'); + spyOn(this.event, 'preventDefault'); + spyOn(window, 'CustomEvent').and.returnValue(this.customEvent); + spyOn(utils, 'closest').and.returnValues(this.closestElement, undefined); + + DropDown.prototype.clickEvent.call(this.dropdown, this.event); + }); + + it('should call utils.closest', function () { + expect(utils.closest).toHaveBeenCalledWith(this.event.target, 'LI'); + }); + + it('should call addSelectedClass', function () { + expect(this.dropdown.addSelectedClass).toHaveBeenCalledWith(this.closestElement); + }) + + it('should call .preventDefault', function () { + expect(this.event.preventDefault).toHaveBeenCalled(); + }); + + it('should call .hide', function () { + expect(this.dropdown.hide).toHaveBeenCalled(); + }); + + it('should construct CustomEvent', function () { + expect(window.CustomEvent).toHaveBeenCalledWith('click.dl', jasmine.any(Object)); + }); + + it('should call .dispatchEvent with the customEvent', function () { + expect(this.list.dispatchEvent).toHaveBeenCalledWith(this.customEvent); + }); + + describe('if the target is a UL element', function () { + beforeEach(function () { + this.event = { preventDefault: () => {}, target: { tagName: 'UL' } }; + + spyOn(this.event, 'preventDefault'); + utils.closest.calls.reset(); + + DropDown.prototype.clickEvent.call(this.dropdown, this.event); + }); + + it('should return immediately', function () { + expect(utils.closest).not.toHaveBeenCalled(); + }); + }); + + describe('if no selected element exists', function () { + beforeEach(function () { + this.event.preventDefault.calls.reset(); + this.clickEvent = DropDown.prototype.clickEvent.call(this.dropdown, this.event); + }); + + it('should return undefined', function () { + expect(this.clickEvent).toBe(undefined); + }); + + it('should return before .preventDefault is called', function () { + expect(this.event.preventDefault).not.toHaveBeenCalled(); + }); + }); + }); + + describe('addSelectedClass', function () { + beforeEach(function () { + this.items = Array(4).forEach((item, i) => { + this.items[i] = { classList: { add: () => {} } }; + spyOn(this.items[i].classList, 'add'); + }); + this.selected = { classList: { add: () => {} } }; + this.dropdown = { removeSelectedClasses: () => {} }; + + spyOn(this.dropdown, 'removeSelectedClasses'); + spyOn(this.selected.classList, 'add'); + + DropDown.prototype.addSelectedClass.call(this.dropdown, this.selected); + }); + + it('should call .removeSelectedClasses', function () { + expect(this.dropdown.removeSelectedClasses).toHaveBeenCalled(); + }); + + it('should call .classList.add', function () { + expect(this.selected.classList.add).toHaveBeenCalledWith(SELECTED_CLASS); + }); + }); + + describe('removeSelectedClasses', function () { + beforeEach(function () { + this.items = Array(4); + this.items.forEach((item, i) => { + this.items[i] = { classList: { add: () => {} } }; + spyOn(this.items[i].classList, 'add'); + }); + this.dropdown = { items: this.items }; + + DropDown.prototype.removeSelectedClasses.call(this.dropdown); + }); + + it('should call .classList.remove for all items', function () { + this.items.forEach((item, i) => { + expect(this.items[i].classList.add).toHaveBeenCalledWith(SELECTED_CLASS); + }); + }); + + describe('if .items is not set', function () { + beforeEach(function () { + this.dropdown = { getItems: () => {} }; + + spyOn(this.dropdown, 'getItems').and.returnValue([]); + + DropDown.prototype.removeSelectedClasses.call(this.dropdown); + }); + + it('should call .getItems', function () { + expect(this.dropdown.getItems).toHaveBeenCalled(); + }); + }); + }); + + describe('addEvents', function () { + beforeEach(function () { + this.list = { addEventListener: () => {} }; + this.dropdown = { list: this.list, clickEvent: () => {}, eventWrapper: {} }; + + spyOn(this.list, 'addEventListener'); + + DropDown.prototype.addEvents.call(this.dropdown); + }); + + it('should call .addEventListener', function () { + expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function)); + }); + }); + + describe('toggle', function () { + beforeEach(function () { + this.dropdown = { hidden: true, show: () => {}, hide: () => {} }; + + spyOn(this.dropdown, 'show'); + spyOn(this.dropdown, 'hide'); + + DropDown.prototype.toggle.call(this.dropdown); + }); + + it('should call .show if hidden is true', function () { + expect(this.dropdown.show).toHaveBeenCalled(); + }); + + describe('if hidden is false', function () { + beforeEach(function () { + this.dropdown = { hidden: false, show: () => {}, hide: () => {} }; + + spyOn(this.dropdown, 'show'); + spyOn(this.dropdown, 'hide'); + + DropDown.prototype.toggle.call(this.dropdown); + }); + + it('should call .show if hidden is true', function () { + expect(this.dropdown.hide).toHaveBeenCalled(); + }); + }); + }); + + describe('setData', function () { + beforeEach(function () { + this.dropdown = { render: () => {} }; + this.data = ['data']; + + spyOn(this.dropdown, 'render'); + + DropDown.prototype.setData.call(this.dropdown, this.data); + }); + + it('should set .data', function () { + expect(this.dropdown.data).toBe(this.data); + }); + + it('should call .render with the .data', function () { + expect(this.dropdown.render).toHaveBeenCalledWith(this.data); + }); + }); + + describe('addData', function () { + beforeEach(function () { + this.dropdown = { render: () => {}, data: ['data1'] }; + this.data = ['data2']; + + spyOn(this.dropdown, 'render'); + spyOn(Array.prototype, 'concat').and.callThrough(); + + DropDown.prototype.addData.call(this.dropdown, this.data); + }); + + it('should call .concat with data', function () { + expect(Array.prototype.concat).toHaveBeenCalledWith(this.data); + }); + + it('should set .data with concatination', function () { + expect(this.dropdown.data).toEqual(['data1', 'data2']); + }); + + it('should call .render with the .data', function () { + expect(this.dropdown.render).toHaveBeenCalledWith(['data1', 'data2']); + }); + + describe('if .data is undefined', function () { + beforeEach(function () { + this.dropdown = { render: () => {}, data: undefined }; + this.data = ['data2']; + + spyOn(this.dropdown, 'render'); + + DropDown.prototype.addData.call(this.dropdown, this.data); + }); + + it('should set .data with concatination', function () { + expect(this.dropdown.data).toEqual(['data2']); + }); + }); + }); + + describe('render', function () { + beforeEach(function () { + this.list = { querySelector: () => {} }; + this.dropdown = { renderChildren: () => {}, list: this.list }; + this.renderableList = {}; + this.data = [0, 1]; + + spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); + spyOn(this.list, 'querySelector').and.returnValue(this.renderableList); + spyOn(this.data, 'map').and.callThrough(); + + DropDown.prototype.render.call(this.dropdown, this.data); + }); + + it('should call .map', function () { + expect(this.data.map).toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it('should call .renderChildren for each data item', function() { + expect(this.dropdown.renderChildren.calls.count()).toBe(this.data.length); + }); + + it('sets the renderableList .innerHTML', function () { + expect(this.renderableList.innerHTML).toBe('01'); + }); + + describe('if no data argument is passed' , function () { + beforeEach(function () { + this.data.map.calls.reset(); + this.dropdown.renderChildren.calls.reset(); + + DropDown.prototype.render.call(this.dropdown, undefined); + }); + + it('should not call .map', function () { + expect(this.data.map).not.toHaveBeenCalled(); + }); + + it('should not call .renderChildren', function () { + expect(this.dropdown.renderChildren).not.toHaveBeenCalled(); + }); + }); + + describe('if no dynamic list is present', function () { + beforeEach(function () { + this.list = { querySelector: () => {} }; + this.dropdown = { renderChildren: () => {}, list: this.list }; + this.data = [0, 1]; + + spyOn(this.dropdown, 'renderChildren').and.callFake(data => data); + spyOn(this.list, 'querySelector'); + spyOn(this.data, 'map').and.callThrough(); + + DropDown.prototype.render.call(this.dropdown, this.data); + }); + + it('sets the .list .innerHTML', function () { + expect(this.list.innerHTML).toBe('01'); + }); + }); + }); + + describe('renderChildren', function () { + beforeEach(function () { + this.templateString = 'templateString'; + this.dropdown = { setImagesSrc: () => {}, templateString: this.templateString }; + this.data = { droplab_hidden: true }; + this.html = 'html'; + this.template = { firstChild: { outerHTML: 'outerHTML', style: {} } }; + + spyOn(utils, 't').and.returnValue(this.html); + spyOn(document, 'createElement').and.returnValue(this.template); + spyOn(this.dropdown, 'setImagesSrc'); + + this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data); + }); + + it('should call utils.t with .templateString and data', function () { + expect(utils.t).toHaveBeenCalledWith(this.templateString, this.data); + }); + + it('should call document.createElement', function () { + expect(document.createElement).toHaveBeenCalledWith('div'); + }); + + it('should set the templates .innerHTML to the HTML', function () { + expect(this.template.innerHTML).toBe(this.html); + }); + + it('should call .setImagesSrc with the template', function () { + expect(this.dropdown.setImagesSrc).toHaveBeenCalledWith(this.template); + }); + + it('should set the template display to none', function () { + expect(this.template.firstChild.style.display).toBe('none'); + }); + + it('should return the templates .firstChild.outerHTML', function () { + expect(this.renderChildren).toBe(this.template.firstChild.outerHTML); + }); + + describe('if droplab_hidden is false', function () { + beforeEach(function () { + this.data = { droplab_hidden: false }; + this.renderChildren = DropDown.prototype.renderChildren.call(this.dropdown, this.data); + }); + + it('should set the template display to block', function () { + expect(this.template.firstChild.style.display).toBe('block'); + }); + }); + }); + + describe('setImagesSrc', function () { + beforeEach(function () { + this.dropdown = {}; + this.template = { querySelectorAll: () => {} }; + + spyOn(this.template, 'querySelectorAll').and.returnValue([]); + + DropDown.prototype.setImagesSrc.call(this.dropdown, this.template); + }); + + it('should call .querySelectorAll', function () { + expect(this.template.querySelectorAll).toHaveBeenCalledWith('img[data-src]'); + }); + }); + + describe('show', function () { + beforeEach(function () { + this.list = { style: {} }; + this.dropdown = { list: this.list, hidden: true }; + + DropDown.prototype.show.call(this.dropdown); + }); + + it('it should set .list display to block', function () { + expect(this.list.style.display).toBe('block'); + }); + + it('it should set .hidden to false', function () { + expect(this.dropdown.hidden).toBe(false); + }); + + describe('if .hidden is false', function () { + beforeEach(function () { + this.list = { style: {} }; + this.dropdown = { list: this.list, hidden: false }; + + this.show = DropDown.prototype.show.call(this.dropdown); + }); + + it('should return undefined', function () { + expect(this.show).toEqual(undefined); + }); + + it('should not set .list display to block', function () { + expect(this.list.style.display).not.toEqual('block'); + }); + }); + }); + + describe('hide', function () { + beforeEach(function () { + this.list = { style: {} }; + this.dropdown = { list: this.list }; + + DropDown.prototype.hide.call(this.dropdown); + }); + + it('it should set .list display to none', function () { + expect(this.list.style.display).toBe('none'); + }); + + it('it should set .hidden to true', function () { + expect(this.dropdown.hidden).toBe(true); + }); + }); + + describe('toggle', function () { + beforeEach(function () { + this.hidden = true + this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; + + spyOn(this.dropdown, 'show'); + spyOn(this.dropdown, 'hide'); + + DropDown.prototype.toggle.call(this.dropdown); + }); + + it('should call .show', function () { + expect(this.dropdown.show).toHaveBeenCalled(); + }); + + describe('if .hidden is false', function () { + beforeEach(function () { + this.hidden = false + this.dropdown = { hidden: this.hidden, show: () => {}, hide: () => {} }; + + spyOn(this.dropdown, 'show'); + spyOn(this.dropdown, 'hide'); + + DropDown.prototype.toggle.call(this.dropdown); + }); + + it('should call .hide', function () { + expect(this.dropdown.hide).toHaveBeenCalled(); + }); + }); + }); + + describe('destroy', function () { + beforeEach(function () { + this.list = { removeEventListener: () => {} }; + this.eventWrapper = { clickEvent: 'clickEvent' }; + this.dropdown = { list: this.list, hide: () => {}, eventWrapper: this.eventWrapper }; + + spyOn(this.list, 'removeEventListener'); + spyOn(this.dropdown, 'hide'); + + DropDown.prototype.destroy.call(this.dropdown); + }); + + it('it should call .hide', function () { + expect(this.dropdown.hide).toHaveBeenCalled(); + }); + + it('it should call .removeEventListener', function () { + expect(this.list.removeEventListener).toHaveBeenCalledWith('click', this.eventWrapper.clickEvent); + }); + }); +}); diff --git a/spec/javascripts/droplab/hook_spec.js b/spec/javascripts/droplab/hook_spec.js new file mode 100644 index 00000000000..8ebdcdd1404 --- /dev/null +++ b/spec/javascripts/droplab/hook_spec.js @@ -0,0 +1,82 @@ +/* eslint-disable */ + +import Hook from '~/droplab/hook'; +import * as dropdownSrc from '~/droplab/drop_down'; + +describe('Hook', function () { + describe('class constructor', function () { + beforeEach(function () { + this.trigger = { id: 'id' }; + this.list = {}; + this.plugins = {}; + this.config = {}; + this.dropdown = {}; + + spyOn(dropdownSrc, 'default').and.returnValue(this.dropdown); + + this.hook = new Hook(this.trigger, this.list, this.plugins, this.config); + }); + + it('should set .trigger', function () { + expect(this.hook.trigger).toBe(this.trigger); + }); + + it('should set .list', function () { + expect(this.hook.list).toBe(this.dropdown); + }); + + it('should call DropDown constructor', function () { + expect(dropdownSrc.default).toHaveBeenCalledWith(this.list); + }); + + it('should set .type', function () { + expect(this.hook.type).toBe('Hook'); + }); + + it('should set .event', function () { + expect(this.hook.event).toBe('click'); + }); + + it('should set .plugins', function () { + expect(this.hook.plugins).toBe(this.plugins); + }); + + it('should set .config', function () { + expect(this.hook.config).toBe(this.config); + }); + + it('should set .id', function () { + expect(this.hook.id).toBe(this.trigger.id); + }); + + describe('if config argument is undefined', function () { + beforeEach(function () { + this.config = undefined; + + this.hook = new Hook(this.trigger, this.list, this.plugins, this.config); + }); + + it('should set .config to an empty object', function () { + expect(this.hook.config).toEqual({}); + }); + }); + + describe('if plugins argument is undefined', function () { + beforeEach(function () { + this.plugins = undefined; + + this.hook = new Hook(this.trigger, this.list, this.plugins, this.config); + }); + + it('should set .plugins to an empty array', function () { + expect(this.hook.plugins).toEqual([]); + }); + }); + }); + + describe('addEvents', function () { + it('should exist', function () { + expect(Hook.prototype.hasOwnProperty('addEvents')).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/droplab/plugins/input_setter_spec.js b/spec/javascripts/droplab/plugins/input_setter_spec.js new file mode 100644 index 00000000000..bd625f4ae80 --- /dev/null +++ b/spec/javascripts/droplab/plugins/input_setter_spec.js @@ -0,0 +1,212 @@ +/* eslint-disable */ + +import InputSetter from '~/droplab/plugins/input_setter'; + +describe('InputSetter', function () { + describe('init', function () { + beforeEach(function () { + this.config = { InputSetter: {} }; + this.hook = { config: this.config }; + this.inputSetter = jasmine.createSpyObj('inputSetter', ['addEvents']); + + InputSetter.init.call(this.inputSetter, this.hook); + }); + + it('should set .hook', function () { + expect(this.inputSetter.hook).toBe(this.hook); + }); + + it('should set .config', function () { + expect(this.inputSetter.config).toBe(this.config.InputSetter); + }); + + it('should set .eventWrapper', function () { + expect(this.inputSetter.eventWrapper).toEqual({}); + }); + + it('should call .addEvents', function () { + expect(this.inputSetter.addEvents).toHaveBeenCalled(); + }); + + describe('if config.InputSetter is not set', function () { + beforeEach(function () { + this.config = { InputSetter: undefined }; + this.hook = { config: this.config }; + + InputSetter.init.call(this.inputSetter, this.hook); + }); + + it('should set .config to an empty object', function () { + expect(this.inputSetter.config).toEqual({}); + }); + + it('should set hook.config to an empty object', function () { + expect(this.hook.config.InputSetter).toEqual({}); + }); + }) + }); + + describe('addEvents', function () { + beforeEach(function () { + this.hook = { list: { list: jasmine.createSpyObj('list', ['addEventListener']) } }; + this.inputSetter = { eventWrapper: {}, hook: this.hook, setInputs: () => {} }; + + InputSetter.addEvents.call(this.inputSetter); + }); + + it('should set .eventWrapper.setInputs', function () { + expect(this.inputSetter.eventWrapper.setInputs).toEqual(jasmine.any(Function)); + }); + + it('should call .addEventListener', function () { + expect(this.hook.list.list.addEventListener) + .toHaveBeenCalledWith('click.dl', this.inputSetter.eventWrapper.setInputs); + }); + }); + + describe('removeEvents', function () { + beforeEach(function () { + this.hook = { list: { list: jasmine.createSpyObj('list', ['removeEventListener']) } }; + this.eventWrapper = jasmine.createSpyObj('eventWrapper', ['setInputs']); + this.inputSetter = { eventWrapper: this.eventWrapper, hook: this.hook }; + + InputSetter.removeEvents.call(this.inputSetter); + }); + + it('should call .removeEventListener', function () { + expect(this.hook.list.list.removeEventListener) + .toHaveBeenCalledWith('click.dl', this.eventWrapper.setInputs); + }); + }); + + describe('setInputs', function () { + beforeEach(function () { + this.event = { detail: { selected: {} } }; + this.config = [0, 1]; + this.inputSetter = { config: this.config, setInput: () => {} }; + + spyOn(this.inputSetter, 'setInput'); + + InputSetter.setInputs.call(this.inputSetter, this.event); + }); + + it('should call .setInput for each config element', function () { + const allArgs = this.inputSetter.setInput.calls.allArgs(); + + expect(allArgs.length).toEqual(2); + + allArgs.forEach((args, i) => { + expect(args[0]).toBe(this.config[i]); + expect(args[1]).toBe(this.event.detail.selected); + }); + }); + + describe('if config isnt an array', function () { + beforeEach(function () { + this.inputSetter = { config: {}, setInput: () => {} }; + + InputSetter.setInputs.call(this.inputSetter, this.event); + }); + + it('should set .config to an array with .config as the first element', function () { + expect(this.inputSetter.config).toEqual([{}]); + }); + }); + }); + + describe('setInput', function () { + beforeEach(function () { + this.selectedItem = { getAttribute: () => {} }; + this.input = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} }; + this.config = { valueAttribute: {}, input: this.input }; + this.inputSetter = { hook: { trigger: {} } }; + this.newValue = 'newValue'; + + spyOn(this.selectedItem, 'getAttribute').and.returnValue(this.newValue); + spyOn(this.input, 'hasAttribute').and.returnValue(false); + + InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem); + }); + + it('should call .getAttribute', function () { + expect(this.selectedItem.getAttribute).toHaveBeenCalledWith(this.config.valueAttribute); + }); + + it('should call .hasAttribute', function () { + expect(this.input.hasAttribute).toHaveBeenCalledWith(undefined); + }); + + it('should set the value of the input', function () { + expect(this.input.value).toBe(this.newValue); + }); + + describe('if no config.input is provided', function () { + beforeEach(function () { + this.config = { valueAttribute: {} }; + this.trigger = { value: 'oldValue', tagName: 'INPUT', hasAttribute: () => {} }; + this.inputSetter = { hook: { trigger: this.trigger } }; + + InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem); + }); + + it('should set the value of the hook.trigger', function () { + expect(this.trigger.value).toBe(this.newValue); + }); + }); + + describe('if the input tag is not INPUT', function () { + beforeEach(function () { + this.input = { textContent: 'oldValue', tagName: 'SPAN', hasAttribute: () => {} }; + this.config = { valueAttribute: {}, input: this.input }; + + InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem); + }); + + it('should set the textContent of the input', function () { + expect(this.input.textContent).toBe(this.newValue); + }); + }); + + describe('if there is an inputAttribute', function () { + beforeEach(function () { + this.selectedItem = { getAttribute: () => {} }; + this.input = { id: 'oldValue', hasAttribute: () => {}, setAttribute: () => {} }; + this.inputSetter = { hook: { trigger: {} } }; + this.newValue = 'newValue'; + this.inputAttribute = 'id'; + this.config = { + valueAttribute: {}, + input: this.input, + inputAttribute: this.inputAttribute, + }; + + spyOn(this.selectedItem, 'getAttribute').and.returnValue(this.newValue); + spyOn(this.input, 'hasAttribute').and.returnValue(true); + spyOn(this.input, 'setAttribute'); + + InputSetter.setInput.call(this.inputSetter, this.config, this.selectedItem); + }); + + it('should call setAttribute', function () { + expect(this.input.setAttribute).toHaveBeenCalledWith(this.inputAttribute, this.newValue); + }); + + it('should not set the value or textContent of the input', function () { + expect(this.input.value).not.toBe('newValue'); + expect(this.input.textContent).not.toBe('newValue'); + }); + }); + }); + + describe('destroy', function () { + beforeEach(function () { + this.inputSetter = jasmine.createSpyObj('inputSetter', ['removeEvents']); + + InputSetter.destroy.call(this.inputSetter); + }); + + it('should call .removeEvents', function () { + expect(this.inputSetter.removeEvents).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js index c16f77c53a2..2b1fe5e3eef 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js +++ b/spec/javascripts/filtered_search/dropdown_user_spec.js @@ -33,7 +33,7 @@ require('~/filtered_search/dropdown_user'); }); }); - describe('config droplabAjaxFilter\'s endpoint', () => { + describe('config AjaxFilter\'s endpoint', () => { beforeEach(() => { spyOn(gl.DropdownUser.prototype, 'bindEvents').and.callFake(() => {}); spyOn(gl.DropdownUser.prototype, 'getProjectId').and.callFake(() => {}); @@ -45,13 +45,13 @@ require('~/filtered_search/dropdown_user'); }; const dropdown = new gl.DropdownUser(); - expect(dropdown.config.droplabAjaxFilter.endpoint).toBe('/autocomplete/users.json'); + expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json'); }); it('should return endpoint when relative_url_root is undefined', () => { const dropdown = new gl.DropdownUser(); - expect(dropdown.config.droplabAjaxFilter.endpoint).toBe('/autocomplete/users.json'); + expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json'); }); it('should return endpoint with relative url when available', () => { @@ -60,7 +60,7 @@ require('~/filtered_search/dropdown_user'); }; const dropdown = new gl.DropdownUser(); - expect(dropdown.config.droplabAjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json'); + expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json'); }); afterEach(() => { diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index 5354e536edc..e437333d522 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -1,6 +1,7 @@ /* eslint-disable no-var, comma-dangle, object-shorthand */ require('~/merge_request_tabs'); +require('~/commit/pipelines/pipelines_bundle.js'); require('~/breakpoints'); require('~/lib/utils/common_utils'); require('~/diff'); @@ -222,7 +223,9 @@ require('vendor/jquery.scrollTo'); describe('#tabShown', () => { beforeEach(function () { - spyOn($, 'ajax').and.callFake(function () {}); + spyOn($, 'ajax').and.callFake(function (options) { + options.success({ html: '' }); + }); loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); }); @@ -230,9 +233,6 @@ require('vendor/jquery.scrollTo'); beforeEach(function () { this.class.diffViewType = () => 'parallel'; gl.Diff.prototype.diffViewType = () => 'parallel'; - spyOn($, 'ajax').and.callFake(function (options) { - options.success({ html: '' }); - }); }); it('maintains `container-limited` for pipelines tab', function (done) { diff --git a/spec/lib/banzai/filter/issuable_state_filter_spec.rb b/spec/lib/banzai/filter/issuable_state_filter_spec.rb new file mode 100644 index 00000000000..603b79a323c --- /dev/null +++ b/spec/lib/banzai/filter/issuable_state_filter_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +describe Banzai::Filter::IssuableStateFilter, lib: true do + include ActionView::Helpers::UrlHelper + include FilterSpecHelper + + let(:user) { create(:user) } + + def create_link(data) + link_to('text', '', class: 'gfm has-tooltip', data: data) + end + + it 'ignores non-GFM links' do + html = %(See <a href="https://google.com/">Google</a>) + doc = filter(html, current_user: user) + + expect(doc.css('a').last.text).to eq('Google') + end + + it 'ignores non-issuable links' do + project = create(:empty_project, :public) + link = create_link(project: project, reference_type: 'issue') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text') + end + + context 'for issue references' do + it 'ignores open issue references' do + issue = create(:issue) + link = create_link(issue: issue.id, reference_type: 'issue') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text') + end + + it 'ignores reopened issue references' do + reopened_issue = create(:issue, :reopened) + link = create_link(issue: reopened_issue.id, reference_type: 'issue') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text') + end + + it 'appends [closed] to closed issue references' do + closed_issue = create(:issue, :closed) + link = create_link(issue: closed_issue.id, reference_type: 'issue') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text [closed]') + end + end + + context 'for merge request references' do + it 'ignores open merge request references' do + mr = create(:merge_request) + link = create_link(merge_request: mr.id, reference_type: 'merge_request') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text') + end + + it 'ignores reopened merge request references' do + mr = create(:merge_request, :reopened) + link = create_link(merge_request: mr.id, reference_type: 'merge_request') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text') + end + + it 'ignores locked merge request references' do + mr = create(:merge_request, :locked) + link = create_link(merge_request: mr.id, reference_type: 'merge_request') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text') + end + + it 'appends [closed] to closed merge request references' do + mr = create(:merge_request, :closed) + link = create_link(merge_request: mr.id, reference_type: 'merge_request') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text [closed]') + end + + it 'appends [merged] to merged merge request references' do + mr = create(:merge_request, :merged) + link = create_link(merge_request: mr.id, reference_type: 'merge_request') + doc = filter(link, current_user: user) + + expect(doc.css('a').last.text).to eq('text [merged]') + end + end +end diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index 0140a91c7ba..8a6fe1ad6a3 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -15,6 +15,16 @@ describe Banzai::Filter::RedactorFilter, lib: true do link_to('text', '', class: 'gfm', data: data) end + it 'skips when the skip_redaction flag is set' do + user = create(:user) + project = create(:empty_project) + + link = reference_link(project: project.id, reference_type: 'test') + doc = filter(link, current_user: user, skip_redaction: true) + + expect(doc.css('a').length).to eq 1 + end + context 'with data-project' do let(:parser_class) do Class.new(Banzai::ReferenceParser::BaseParser) do diff --git a/spec/lib/banzai/issuable_extractor_spec.rb b/spec/lib/banzai/issuable_extractor_spec.rb new file mode 100644 index 00000000000..e5d332efb08 --- /dev/null +++ b/spec/lib/banzai/issuable_extractor_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Banzai::IssuableExtractor, lib: true do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:extractor) { described_class.new(project, user) } + let(:issue) { create(:issue, project: project) } + let(:merge_request) { create(:merge_request, source_project: project) } + let(:issue_link) do + html_to_node( + "<a href='' data-issue='#{issue.id}' data-reference-type='issue' class='gfm'>text</a>" + ) + end + let(:merge_request_link) do + html_to_node( + "<a href='' data-merge-request='#{merge_request.id}' data-reference-type='merge_request' class='gfm'>text</a>" + ) + end + + def html_to_node(html) + Nokogiri::HTML.fragment( + html + ).children[0] + end + + it 'returns instances of issuables for nodes with references' do + result = extractor.extract([issue_link, merge_request_link]) + + expect(result).to eq(issue_link => issue, merge_request_link => merge_request) + end + + describe 'caching' do + before do + RequestStore.begin! + end + + after do + RequestStore.end! + RequestStore.clear! + end + + it 'saves records to cache' do + extractor.extract([issue_link, merge_request_link]) + + second_call_queries = ActiveRecord::QueryRecorder.new do + extractor.extract([issue_link, merge_request_link]) + end.count + + expect(second_call_queries).to eq 0 + end + end +end diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb index 6bcda87c999..4817fcd031a 100644 --- a/spec/lib/banzai/object_renderer_spec.rb +++ b/spec/lib/banzai/object_renderer_spec.rb @@ -3,128 +3,51 @@ require 'spec_helper' describe Banzai::ObjectRenderer do let(:project) { create(:empty_project) } let(:user) { project.owner } - - def fake_object(attrs = {}) - object = double(attrs.merge("new_record?" => true, "destroyed?" => true)) - allow(object).to receive(:markdown_cache_field_for).with(:note).and_return(:note_html) - allow(object).to receive(:banzai_render_context).with(:note).and_return(project: nil, author: nil) - allow(object).to receive(:update_column).with(:note_html, anything).and_return(true) - object - end + let(:renderer) { described_class.new(project, user, custom_value: 'value') } + let(:object) { Note.new(note: 'hello', note_html: '<p>hello</p>') } describe '#render' do it 'renders and redacts an Array of objects' do - renderer = described_class.new(project, user) - object = fake_object(note: 'hello', note_html: nil) - - expect(renderer).to receive(:render_objects).with([object], :note). - and_call_original - - expect(renderer).to receive(:redact_documents). - with(an_instance_of(Array)). - and_call_original - - expect(object).to receive(:redacted_note_html=).with('<p dir="auto">hello</p>') - expect(object).to receive(:user_visible_reference_count=).with(0) - renderer.render([object], :note) - end - end - - describe '#render_objects' do - it 'renders an Array of objects' do - object = fake_object(note: 'hello', note_html: nil) - - renderer = described_class.new(project, user) - expect(renderer).to receive(:render_attributes).with([object], :note). - and_call_original - - rendered = renderer.render_objects([object], :note) - - expect(rendered).to be_an_instance_of(Array) - expect(rendered[0]).to be_an_instance_of(Nokogiri::HTML::DocumentFragment) - end - end - - describe '#redact_documents' do - it 'redacts a set of documents and returns them as an Array of Hashes' do - doc = Nokogiri::HTML.fragment('<p>hello</p>') - renderer = described_class.new(project, user) - - expect_any_instance_of(Banzai::Redactor).to receive(:redact). - with([doc]). - and_call_original - - redacted = renderer.redact_documents([doc]) - - expect(redacted.count).to eq(1) - expect(redacted.first[:visible_reference_count]).to eq(0) - expect(redacted.first[:document].to_html).to eq('<p>hello</p>') + expect(object.redacted_note_html).to eq '<p>hello</p>' + expect(object.user_visible_reference_count).to eq 0 end - end - describe '#context_for' do - let(:object) { fake_object(note: 'hello') } - let(:renderer) { described_class.new(project, user) } + it 'calls Banzai::Redactor to perform redaction' do + expect_any_instance_of(Banzai::Redactor).to receive(:redact).and_call_original - it 'returns a Hash' do - expect(renderer.context_for(object, :note)).to be_an_instance_of(Hash) - end - - it 'includes the banzai render context for the object' do - expect(object).to receive(:banzai_render_context).with(:note).and_return(foo: :bar) - context = renderer.context_for(object, :note) - expect(context).to have_key(:foo) - expect(context[:foo]).to eq(:bar) - end - end - - describe '#render_attributes' do - it 'renders the attribute of a list of objects' do - objects = [fake_object(note: 'hello', note_html: nil), fake_object(note: 'bye', note_html: nil)] - renderer = described_class.new(project, user) - - objects.each do |object| - expect(Banzai).to receive(:render_field).with(object, :note).and_call_original - end - - docs = renderer.render_attributes(objects, :note) - - expect(docs[0]).to be_an_instance_of(Nokogiri::HTML::DocumentFragment) - expect(docs[0].to_html).to eq('<p dir="auto">hello</p>') - - expect(docs[1]).to be_an_instance_of(Nokogiri::HTML::DocumentFragment) - expect(docs[1].to_html).to eq('<p dir="auto">bye</p>') - end - - it 'returns when no objects to render' do - objects = [] - renderer = described_class.new(project, user, pipeline: :note) - - expect(renderer.render_attributes(objects, :note)).to eq([]) + renderer.render([object], :note) end - end - describe '#base_context' do - let(:context) do - described_class.new(project, user, foo: :bar).base_context - end + it 'retrieves field content using Banzai.render_field' do + expect(Banzai).to receive(:render_field).with(object, :note).and_call_original - it 'returns a Hash' do - expect(context).to be_an_instance_of(Hash) - end - - it 'includes the custom attributes' do - expect(context[:foo]).to eq(:bar) + renderer.render([object], :note) end - it 'includes the current user' do - expect(context[:current_user]).to eq(user) - end + it 'passes context to PostProcessPipeline' do + another_user = create(:user) + another_project = create(:empty_project) + object = Note.new( + note: 'hello', + note_html: 'hello', + author: another_user, + project: another_project + ) + + expect(Banzai::Pipeline::PostProcessPipeline).to receive(:to_document).with( + anything, + hash_including( + skip_redaction: true, + current_user: user, + project: another_project, + author: another_user, + custom_value: 'value' + ) + ).and_call_original - it 'includes the current project' do - expect(context[:project]).to eq(project) + renderer.render([object], :note) end end end diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb index aa127f0179d..a3141894c74 100644 --- a/spec/lib/banzai/reference_parser/base_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb @@ -92,16 +92,26 @@ describe Banzai::ReferenceParser::BaseParser, lib: true do end describe '#grouped_objects_for_nodes' do - it 'returns a Hash grouping objects per ID' do - nodes = [double(:node)] + it 'returns a Hash grouping objects per node' do + link = double(:link) + + expect(link).to receive(:has_attribute?). + with('data-user'). + and_return(true) + + expect(link).to receive(:attr). + with('data-user'). + and_return(user.id.to_s) + + nodes = [link] expect(subject).to receive(:unique_attribute_values). with(nodes, 'data-user'). - and_return([user.id]) + and_return([user.id.to_s]) hash = subject.grouped_objects_for_nodes(nodes, User, 'data-user') - expect(hash).to eq({ user.id => user }) + expect(hash).to eq({ link => user }) end it 'returns an empty Hash when the list of nodes is empty' do diff --git a/spec/lib/banzai/reference_parser/issue_parser_spec.rb b/spec/lib/banzai/reference_parser/issue_parser_spec.rb index 6873b7b85f9..7031c47231c 100644 --- a/spec/lib/banzai/reference_parser/issue_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/issue_parser_spec.rb @@ -67,6 +67,16 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do expect(subject.referenced_by([])).to eq([]) end end + + context 'when issue with given ID does not exist' do + before do + link['data-issue'] = '-1' + end + + it 'returns an empty Array' do + expect(subject.referenced_by([link])).to eq([]) + end + end end end @@ -75,7 +85,7 @@ describe Banzai::ReferenceParser::IssueParser, lib: true do link['data-issue'] = issue.id.to_s nodes = [link] - expect(subject.issues_for_nodes(nodes)).to eq({ issue.id => issue }) + expect(subject.issues_for_nodes(nodes)).to eq({ link => issue }) end end end diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb index 31ca9d27b0b..4ec998efe53 100644 --- a/spec/lib/banzai/reference_parser/user_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb @@ -180,6 +180,15 @@ describe Banzai::ReferenceParser::UserParser, lib: true do expect(subject.nodes_user_can_reference(user, [link])).to eq([]) end + + it 'returns the nodes if the project attribute value equals the current project ID' do + other_user = create(:user) + + link['data-project'] = project.id.to_s + link['data-author'] = other_user.id.to_s + + expect(subject.nodes_user_can_reference(user, [link])).to eq([link]) + end end context 'when the link does not have a data-author attribute' do diff --git a/spec/models/ci/pipeline_status_spec.rb b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb index bc5b71666c2..fced253dd01 100644 --- a/spec/models/ci/pipeline_status_spec.rb +++ b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Ci::PipelineStatus do +describe Gitlab::Cache::Ci::ProjectPipelineStatus do let(:project) { create(:project) } let(:pipeline_status) { described_class.new(project) } @@ -12,6 +12,20 @@ describe Ci::PipelineStatus do end end + describe '.update_for_pipeline' do + it 'refreshes the cache if nescessary' do + pipeline = build_stubbed(:ci_pipeline, sha: '123456', status: 'success') + fake_status = double + expect(described_class).to receive(:new). + with(pipeline.project, sha: '123456', status: 'success', ref: 'master'). + and_return(fake_status) + + expect(fake_status).to receive(:store_in_cache_if_needed) + + described_class.update_for_pipeline(pipeline) + end + end + describe '#has_status?' do it "is false when the status wasn't loaded yet" do expect(pipeline_status.has_status?).to be_falsy @@ -41,14 +55,14 @@ describe Ci::PipelineStatus do it 'loads the status from the project commit when there is no cache' do allow(pipeline_status).to receive(:has_cache?).and_return(false) - expect(pipeline_status).to receive(:load_from_commit) + expect(pipeline_status).to receive(:load_from_project) pipeline_status.load_status end it 'stores the status in the cache when it loading it from the project' do allow(pipeline_status).to receive(:has_cache?).and_return(false) - allow(pipeline_status).to receive(:load_from_commit) + allow(pipeline_status).to receive(:load_from_project) expect(pipeline_status).to receive(:store_in_cache) @@ -70,14 +84,15 @@ describe Ci::PipelineStatus do end end - describe "#load_from_commit" do + describe "#load_from_project" do let!(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) } it 'reads the status from the pipeline for the commit' do - pipeline_status.load_from_commit + pipeline_status.load_from_project expect(pipeline_status.status).to eq('success') expect(pipeline_status.sha).to eq(project.commit.sha) + expect(pipeline_status.ref).to eq(project.default_branch) end it "doesn't fail for an empty project" do @@ -108,10 +123,11 @@ describe Ci::PipelineStatus do build_status = described_class.load_for_project(project) build_status.store_in_cache_if_needed - sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status) } + sha, status, ref = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status, :ref) } expect(sha).not_to be_nil expect(status).not_to be_nil + expect(ref).not_to be_nil end it "doesn't store the status in redis when the sha is not the head of the project" do @@ -126,14 +142,15 @@ describe Ci::PipelineStatus do it "deletes the cache if the repository doesn't have a head commit" do empty_project = create(:empty_project) - Gitlab::Redis.with { |redis| redis.mapped_hmset("projects/#{empty_project.id}/build_status", { sha: "sha", status: "pending" }) } + Gitlab::Redis.with { |redis| redis.mapped_hmset("projects/#{empty_project.id}/build_status", { sha: "sha", status: "pending", ref: 'master' }) } other_status = described_class.new(empty_project, sha: "123456", status: "failed") other_status.store_in_cache_if_needed - sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{empty_project.id}/build_status", :sha, :status) } + sha, status, ref = Gitlab::Redis.with { |redis| redis.hmget("projects/#{empty_project.id}/build_status", :sha, :status, :ref) } expect(sha).to be_nil expect(status).to be_nil + expect(ref).to be_nil end end diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index e22f88b7a32..959ae02c222 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -5,13 +5,10 @@ describe Gitlab::Checks::ChangeAccess, lib: true do let(:user) { create(:user) } let(:project) { create(:project, :repository) } let(:user_access) { Gitlab::UserAccess.new(user, project: project) } - let(:changes) do - { - oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', - newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51', - ref: 'refs/heads/master' - } - end + let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + let(:ref) { 'refs/heads/master' } + let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } } let(:protocol) { 'ssh' } subject do @@ -23,7 +20,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do ).exec end - before { allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) } + before { project.add_developer(user) } context 'without failed checks' do it "doesn't return any error" do @@ -41,25 +38,67 @@ describe Gitlab::Checks::ChangeAccess, lib: true do end context 'tags check' do - let(:changes) do - { - oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', - newrev: '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51', - ref: 'refs/tags/v1.0.0' - } - end + let(:ref) { 'refs/tags/v1.0.0' } it 'returns an error if the user is not allowed to update tags' do + allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) expect(subject.status).to be(false) expect(subject.message).to eq('You are not allowed to change existing tags on this project.') end + + context 'with protected tag' do + let!(:protected_tag) { create(:protected_tag, project: project, name: 'v*') } + + context 'as master' do + before { project.add_master(user) } + + context 'deletion' do + let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } + let(:newrev) { '0000000000000000000000000000000000000000' } + + it 'is prevented' do + expect(subject.status).to be(false) + expect(subject.message).to include('cannot be deleted') + end + end + + context 'update' do + let(:oldrev) { 'be93687618e4b132087f430a4d8fc3a609c9b77c' } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + + it 'is prevented' do + expect(subject.status).to be(false) + expect(subject.message).to include('cannot be updated') + end + end + end + + context 'creation' do + let(:oldrev) { '0000000000000000000000000000000000000000' } + let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + let(:ref) { 'refs/tags/v9.1.0' } + + it 'prevents creation below access level' do + expect(subject.status).to be(false) + expect(subject.message).to include('allowed to create this tag as it is protected') + end + + context 'when user has access' do + let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') } + + it 'allows tag creation' do + expect(subject.status).to be(true) + end + end + end + end end context 'protected branches check' do before do - allow(project).to receive(:protected_branch?).with('master').and_return(true) + allow(ProtectedBranch).to receive(:protected?).with(project, 'master').and_return(true) end it 'returns an error if the user is not allowed to do forced pushes to protected branches' do @@ -86,13 +125,7 @@ describe Gitlab::Checks::ChangeAccess, lib: true do end context 'branch deletion' do - let(:changes) do - { - oldrev: 'be93687618e4b132087f430a4d8fc3a609c9b77c', - newrev: '0000000000000000000000000000000000000000', - ref: 'refs/heads/master' - } - end + let(:newrev) { '0000000000000000000000000000000000000000' } it 'returns an error if the user is not allowed to delete protected branches' do expect(subject.status).to be(false) diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index b300feaabe1..3f79eaf7afb 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -143,6 +143,7 @@ describe Gitlab::Email::Handler::CreateNoteHandler, lib: true do expect(new_note.author).to eq(sent_notification.recipient) expect(new_note.position).to eq(note.position) expect(new_note.note).to include("I could not disagree more.") + expect(new_note.in_reply_to?(note)).to be_truthy end it "adds all attachments" do diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb index 4684b1d1ac0..58f11ff8906 100644 --- a/spec/lib/gitlab/gitaly_client/commit_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::GitalyClient::Commit do describe '.diff_from_parent' do let(:diff_stub) { double('Gitaly::Diff::Stub') } let(:project) { create(:project, :repository) } - let(:repository_message) { Gitaly::Repository.new(path: project.repository.path) } + let(:repository_message) { project.repository.gitaly_repository } let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } before do diff --git a/spec/lib/gitlab/gitaly_client/notifications_spec.rb b/spec/lib/gitlab/gitaly_client/notifications_spec.rb index 39c2048fef8..b87dacb175b 100644 --- a/spec/lib/gitlab/gitaly_client/notifications_spec.rb +++ b/spec/lib/gitlab/gitaly_client/notifications_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::GitalyClient::Notifications do describe '#post_receive' do let(:project) { create(:empty_project) } let(:repo_path) { project.repository.path_to_repo } - subject { described_class.new(project.repository_storage, project.full_path + '.git') } + subject { described_class.new(project.repository) } it 'sends a post_receive message' do expect_any_instance_of(Gitaly::Notifications::Stub). diff --git a/spec/lib/gitlab/gitaly_client/ref_spec.rb b/spec/lib/gitlab/gitaly_client/ref_spec.rb index 79c9ca993e4..5405eafd281 100644 --- a/spec/lib/gitlab/gitaly_client/ref_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::GitalyClient::Ref do let(:project) { create(:empty_project) } let(:repo_path) { project.repository.path_to_repo } - let(:client) { Gitlab::GitalyClient::Ref.new(project.repository_storage, project.full_path + '.git') } + let(:client) { Gitlab::GitalyClient::Ref.new(project.repository) } before do allow(Gitlab.config.gitaly).to receive(:enabled).and_return(true) diff --git a/spec/lib/gitlab/healthchecks/db_check_spec.rb b/spec/lib/gitlab/healthchecks/db_check_spec.rb new file mode 100644 index 00000000000..33c6c24449c --- /dev/null +++ b/spec/lib/gitlab/healthchecks/db_check_spec.rb @@ -0,0 +1,6 @@ +require 'spec_helper' +require_relative './simple_check_shared' + +describe Gitlab::HealthChecks::DbCheck do + include_examples 'simple_check', 'db_ping', 'Db', '1' +end diff --git a/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb b/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb new file mode 100644 index 00000000000..4cd8cf313a5 --- /dev/null +++ b/spec/lib/gitlab/healthchecks/fs_shards_check_spec.rb @@ -0,0 +1,127 @@ +require 'spec_helper' + +describe Gitlab::HealthChecks::FsShardsCheck do + let(:metric_class) { Gitlab::HealthChecks::Metric } + let(:result_class) { Gitlab::HealthChecks::Result } + let(:repository_storages) { [:default] } + let(:tmp_dir) { Dir.mktmpdir } + + let(:storages_paths) do + { + default: { path: tmp_dir } + }.with_indifferent_access + end + + before do + allow(described_class).to receive(:repository_storages) { repository_storages } + allow(described_class).to receive(:storages_paths) { storages_paths } + end + + after do + FileUtils.remove_entry_secure(tmp_dir) if Dir.exist?(tmp_dir) + end + + shared_examples 'filesystem checks' do + describe '#readiness' do + subject { described_class.readiness } + + context 'storage points to not existing folder' do + let(:storages_paths) do + { + default: { path: 'tmp/this/path/doesnt/exist' } + }.with_indifferent_access + end + + it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: :default)) } + end + + context 'storage points to directory that has both read and write rights' do + before do + FileUtils.chmod_R(0755, tmp_dir) + end + + it { is_expected.to include(result_class.new(true, nil, shard: :default)) } + + it 'cleans up files used for testing' do + expect(described_class).to receive(:storage_write_test).with(any_args).and_call_original + + subject + + expect(Dir.entries(tmp_dir).count).to eq(2) + end + + context 'read test fails' do + before do + allow(described_class).to receive(:storage_read_test).with(any_args).and_return(false) + end + + it { is_expected.to include(result_class.new(false, 'cannot read from storage', shard: :default)) } + end + + context 'write test fails' do + before do + allow(described_class).to receive(:storage_write_test).with(any_args).and_return(false) + end + + it { is_expected.to include(result_class.new(false, 'cannot write to storage', shard: :default)) } + end + end + end + + describe '#metrics' do + subject { described_class.metrics } + + context 'storage points to not existing folder' do + let(:storages_paths) do + { + default: { path: 'tmp/this/path/doesnt/exist' } + }.with_indifferent_access + end + + it { is_expected.to include(metric_class.new(:filesystem_accessible, 0, shard: :default)) } + it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) } + it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) } + + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + end + + context 'storage points to directory that has both read and write rights' do + before do + FileUtils.chmod_R(0755, tmp_dir) + end + + it { is_expected.to include(metric_class.new(:filesystem_accessible, 1, shard: :default)) } + it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) } + it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) } + + it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be > 0, labels: { shard: :default })) } + it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be > 0, labels: { shard: :default })) } + end + end + end + + context 'when popen always finds required binaries' do + before do + allow(Gitlab::Popen).to receive(:popen).and_wrap_original do |method, *args, &block| + begin + method.call(*args, &block) + rescue RuntimeError + raise 'expected not to happen' + end + end + end + + it_behaves_like 'filesystem checks' + end + + context 'when popen never finds required binaries' do + before do + allow(Gitlab::Popen).to receive(:popen).and_raise(Errno::ENOENT) + end + + it_behaves_like 'filesystem checks' + end +end diff --git a/spec/lib/gitlab/healthchecks/redis_check_spec.rb b/spec/lib/gitlab/healthchecks/redis_check_spec.rb new file mode 100644 index 00000000000..734cdcb893e --- /dev/null +++ b/spec/lib/gitlab/healthchecks/redis_check_spec.rb @@ -0,0 +1,6 @@ +require 'spec_helper' +require_relative './simple_check_shared' + +describe Gitlab::HealthChecks::RedisCheck do + include_examples 'simple_check', 'redis_ping', 'Redis', 'PONG' +end diff --git a/spec/lib/gitlab/healthchecks/simple_check_shared.rb b/spec/lib/gitlab/healthchecks/simple_check_shared.rb new file mode 100644 index 00000000000..1fa6d0faef9 --- /dev/null +++ b/spec/lib/gitlab/healthchecks/simple_check_shared.rb @@ -0,0 +1,66 @@ +shared_context 'simple_check' do |metrics_prefix, check_name, success_result| + describe '#metrics' do + subject { described_class.metrics } + context 'Check is passing' do + before do + allow(described_class).to receive(:check).and_return success_result + end + + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 1)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + end + + context 'Check is misbehaving' do + before do + allow(described_class).to receive(:check).and_return 'error!' + end + + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + end + + context 'Check is timeouting' do + before do + allow(described_class).to receive(:check).and_return Timeout::Error.new + end + + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_success", value: 0)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_timeout", value: 1)) } + it { is_expected.to include(have_attributes(name: "#{metrics_prefix}_latency", value: be > 0)) } + end + end + + describe '#readiness' do + subject { described_class.readiness } + context 'Check returns ok' do + before do + allow(described_class).to receive(:check).and_return success_result + end + + it { is_expected.to have_attributes(success: true) } + end + + context 'Check is misbehaving' do + before do + allow(described_class).to receive(:check).and_return 'error!' + end + + it { is_expected.to have_attributes(success: false, message: "unexpected #{check_name} check result: error!") } + end + + context 'Check is timeouting' do + before do + allow(described_class).to receive(:check ).and_return Timeout::Error.new + end + + it { is_expected.to have_attributes(success: false, message: "#{check_name} check timed out") } + end + end + + describe '#liveness' do + subject { described_class.readiness } + it { is_expected.to eq(Gitlab::HealthChecks::Result.new(true)) } + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 259c97f936a..0abf89d060c 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -89,6 +89,9 @@ pipelines: - statuses - builds - trigger_requests +- auto_canceled_by +- auto_canceled_pipelines +- auto_canceled_jobs - pending_builds - retryable_builds - cancelable_statuses @@ -98,6 +101,7 @@ statuses: - project - pipeline - user +- auto_canceled_by variables: - project triggers: @@ -120,10 +124,15 @@ protected_branches: - project - merge_access_levels - push_access_levels +protected_tags: +- project +- create_access_levels merge_access_levels: - protected_branch push_access_levels: - protected_branch +create_access_levels: +- protected_tag container_repositories: - project - name @@ -184,6 +193,7 @@ project: - snippets - hooks - protected_branches +- protected_tags - project_members - users - requesters diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index d9b67426818..7a0b0b06d4b 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -7455,6 +7455,24 @@ ] } ], + "protected_tags": [ + { + "id": 1, + "project_id": 9, + "name": "v*", + "created_at": "2017-04-04T13:48:13.426Z", + "updated_at": "2017-04-04T13:48:13.426Z", + "create_access_levels": [ + { + "id": 1, + "protected_tag_id": 1, + "access_level": 40, + "created_at": "2017-04-04T13:48:13.458Z", + "updated_at": "2017-04-04T13:48:13.458Z" + } + ] + } + ], "project_feature": { "builds_access_level": 0, "created_at": "2014-12-26T09:26:45.000Z", diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index af9c25acb02..0e9607c5bd3 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -64,6 +64,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect(ProtectedBranch.first.push_access_levels).not_to be_empty end + it 'contains the create access levels on a protected tag' do + expect(ProtectedTag.first.create_access_levels).not_to be_empty + end + context 'event at forth level of the tree' do let(:event) { Event.where(title: 'test levels').first } diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0c43c5662e8..0372e3f7dbf 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -183,6 +183,7 @@ Ci::Pipeline: - duration - user_id - lock_version +- auto_canceled_by_id CommitStatus: - id - project_id @@ -223,6 +224,7 @@ CommitStatus: - token - lock_version - coverage_regex +- auto_canceled_by_id Ci::Variable: - id - project_id @@ -251,6 +253,8 @@ Ci::TriggerSchedule: - cron - cron_timezone - next_run_at +- ref +- active DeployKey: - id - user_id @@ -311,6 +315,12 @@ ProtectedBranch: - name - created_at - updated_at +ProtectedTag: +- id +- project_id +- name +- created_at +- updated_at Project: - description - issues_enabled @@ -344,6 +354,14 @@ ProtectedBranch::PushAccessLevel: - access_level - created_at - updated_at +ProtectedTag::CreateAccessLevel: +- id +- protected_tag_id +- access_level +- created_at +- updated_at +- user_id +- group_id AwardEmoji: - id - user_id diff --git a/spec/lib/gitlab/user_access_spec.rb b/spec/lib/gitlab/user_access_spec.rb index 369e55f61f1..611cdbbc865 100644 --- a/spec/lib/gitlab/user_access_spec.rb +++ b/spec/lib/gitlab/user_access_spec.rb @@ -142,4 +142,73 @@ describe Gitlab::UserAccess, lib: true do end end end + + describe 'can_create_tag?' do + describe 'push to none protected tag' do + it 'returns true if user is a master' do + project.add_user(user, :master) + + expect(access.can_create_tag?('random_tag')).to be_truthy + end + + it 'returns true if user is a developer' do + project.add_user(user, :developer) + + expect(access.can_create_tag?('random_tag')).to be_truthy + end + + it 'returns false if user is a reporter' do + project.add_user(user, :reporter) + + expect(access.can_create_tag?('random_tag')).to be_falsey + end + end + + describe 'push to protected tag' do + let(:tag) { create(:protected_tag, project: project, name: "test") } + let(:not_existing_tag) { create :protected_tag, project: project } + + it 'returns true if user is a master' do + project.add_user(user, :master) + + expect(access.can_create_tag?(tag.name)).to be_truthy + end + + it 'returns false if user is a developer' do + project.add_user(user, :developer) + + expect(access.can_create_tag?(tag.name)).to be_falsey + end + + it 'returns false if user is a reporter' do + project.add_user(user, :reporter) + + expect(access.can_create_tag?(tag.name)).to be_falsey + end + end + + describe 'push to protected tag if allowed for developers' do + before do + @tag = create(:protected_tag, :developers_can_create, project: project) + end + + it 'returns true if user is a master' do + project.add_user(user, :master) + + expect(access.can_create_tag?(@tag.name)).to be_truthy + end + + it 'returns true if user is a developer' do + project.add_user(user, :developer) + + expect(access.can_create_tag?(@tag.name)).to be_truthy + end + + it 'returns false if user is a reporter' do + project.add_user(user, :reporter) + + expect(access.can_create_tag?(@tag.name)).to be_falsey + end + end + end end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 6a89b007f96..e6f0a3b5920 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -63,7 +63,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_html_escaped_body_text(issue.author_name) - is_expected.to have_body_text 'wrote:' + is_expected.to have_body_text 'created an issue:' end end end @@ -215,7 +215,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_html_escaped_body_text merge_request.author_name - is_expected.to have_body_text 'wrote:' + is_expected.to have_body_text 'created a merge request:' end end end @@ -554,7 +554,7 @@ describe Notify do end it 'does not contain note author' do - is_expected.not_to have_body_text 'wrote:' + is_expected.not_to have_body_text note.author_name end context 'when enabled email_author_in_body' do @@ -564,7 +564,6 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_html_escaped_body_text note.author_name - is_expected.to have_body_text 'wrote:' end end end @@ -637,7 +636,7 @@ describe Notify do end end - context 'items that are noteable, emails for a note on a diff' do + context 'items that are noteable, the email for a discussion note' do let(:project) { create(:project, :repository) } let(:note_author) { create(:user, name: 'author_name') } @@ -645,8 +644,118 @@ describe Notify do allow(Note).to receive(:find).with(note.id).and_return(note) end - shared_examples 'a note email on a diff' do |model| - let(:note) { create(model, project: project, author: note_author) } + shared_examples 'a discussion note email' do |model| + it_behaves_like 'it should have Gmail Actions links' + + it 'is sent to the given recipient as the author' do + sender = subject.header[:from].addrs[0] + + aggregate_failures do + expect(sender.display_name).to eq(note_author.name) + expect(sender.address).to eq(gitlab_sender) + expect(subject).to deliver_to(recipient.notification_email) + end + end + + it 'contains the message from the note' do + is_expected.to have_body_text note.note + end + + it 'contains an introduction' do + is_expected.to have_body_text 'started a new discussion' + end + + context 'when a comment on an existing discussion' do + let!(:second_note) { create(model, author: note_author, noteable: nil, in_reply_to: note) } + + it 'contains an introduction' do + is_expected.to have_body_text 'commented on a' + end + end + end + + describe 'on a commit' do + let(:commit) { project.commit } + let(:note) { create(:discussion_note_on_commit, commit_id: commit.id, project: project, author: note_author) } + + before(:each) { allow(note).to receive(:noteable).and_return(commit) } + + subject { Notify.note_commit_email(recipient.id, note.id) } + + it_behaves_like 'a discussion note email', :discussion_note_on_commit + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { commit } + end + it_behaves_like 'it should show Gmail Actions View Commit link' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'has the correct subject' do + is_expected.to have_subject "Re: #{project.name} | #{commit.title.strip} (#{commit.short_id})" + end + + it 'contains a link to the commit' do + is_expected.to have_body_text commit.short_id + end + end + + describe 'on a merge request' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note_author) } + let(:note_on_merge_request_path) { namespace_project_merge_request_path(project.namespace, project, merge_request, anchor: "note_#{note.id}") } + before(:each) { allow(note).to receive(:noteable).and_return(merge_request) } + + subject { Notify.note_merge_request_email(recipient.id, note.id) } + + it_behaves_like 'a discussion note email', :discussion_note_on_merge_request + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { merge_request } + end + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'an unsubscribeable thread' + + it 'has the correct subject' do + is_expected.to have_referable_subject(merge_request, reply: true) + end + + it 'contains a link to the merge request note' do + is_expected.to have_body_text note_on_merge_request_path + end + end + + describe 'on an issue' do + let(:issue) { create(:issue, project: project) } + let(:note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: note_author) } + let(:note_on_issue_path) { namespace_project_issue_path(project.namespace, project, issue, anchor: "note_#{note.id}") } + before(:each) { allow(note).to receive(:noteable).and_return(issue) } + + subject { Notify.note_issue_email(recipient.id, note.id) } + + it_behaves_like 'a discussion note email', :discussion_note_on_issue + it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do + let(:model) { issue } + end + it_behaves_like 'it should show Gmail Actions View Issue link' + it_behaves_like 'an unsubscribeable thread' + + it 'has the correct subject' do + is_expected.to have_referable_subject(issue, reply: true) + end + + it 'contains a link to the issue note' do + is_expected.to have_body_text note_on_issue_path + end + end + end + + context 'items that are noteable, the email for a diff discussion note' do + let(:note_author) { create(:user, name: 'author_name') } + + before :each do + allow(Note).to receive(:find).with(note.id).and_return(note) + end + + shared_examples 'an email for a note on a diff discussion' do |model| + let(:note) { create(model, author: note_author) } it "includes diffs with character-level highlighting" do is_expected.to have_body_text '<span class="p">}</span></span>' @@ -672,18 +781,15 @@ describe Notify do is_expected.to have_html_escaped_body_text note.note end - it 'does not contain note author' do - is_expected.not_to have_body_text 'wrote:' + it 'contains an introduction' do + is_expected.to have_body_text 'started a new discussion on' end - context 'when enabled email_author_in_body' do - before do - stub_application_setting(email_author_in_body: true) - end + context 'when a comment on an existing discussion' do + let!(:second_note) { create(model, author: note_author, noteable: nil, in_reply_to: note) } - it 'contains a link to note author' do - is_expected.to have_html_escaped_body_text note.author_name - is_expected.to have_body_text 'wrote:' + it 'contains an introduction' do + is_expected.to have_body_text 'commented on a discussion on' end end end @@ -694,7 +800,7 @@ describe Notify do subject { Notify.note_commit_email(recipient.id, note.id) } - it_behaves_like 'a note email on a diff', :diff_note_on_commit + it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_commit it_behaves_like 'it should show Gmail Actions View Commit link' it_behaves_like 'a user cannot unsubscribe through footer link' end @@ -705,7 +811,7 @@ describe Notify do subject { Notify.note_merge_request_email(recipient.id, note.id) } - it_behaves_like 'a note email on a diff', :diff_note_on_merge_request + it_behaves_like 'an email for a note on a diff discussion', :diff_note_on_merge_request it_behaves_like 'it should show Gmail Actions View Merge request link' it_behaves_like 'an unsubscribeable thread' end diff --git a/spec/mailers/previews/notify_preview.rb b/spec/mailers/previews/notify_preview.rb index 0e1ccb5b847..580f0d56a92 100644 --- a/spec/mailers/previews/notify_preview.rb +++ b/spec/mailers/previews/notify_preview.rb @@ -1,4 +1,100 @@ class NotifyPreview < ActionMailer::Preview + def note_merge_request_email_for_individual_note + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is an individual note on a merge request :smiley: + + In this notification email, we expect to see: + + - The note contents (that's what you're looking at) + - A link to view this note on Gitlab + - An explanation for why the user is receiving this notification + MD + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, note: note) + end + end + + def note_merge_request_email_for_discussion + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is a new discussion on a merge request :smiley: + + In this notification email, we expect to see: + + - A line saying who started this discussion + - The note contents (that's what you're looking at) + - A link to view this discussion on Gitlab + - An explanation for why the user is receiving this notification + MD + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiscussionNote', note: note) + end + end + + def note_merge_request_email_for_diff_discussion + note_email(:note_merge_request_email) do + note = <<-MD.strip_heredoc + This is a new discussion on a merge request :smiley: + + In this notification email, we expect to see: + + - A line saying who started this discussion and on what file + - The diff + - The note contents (that's what you're looking at) + - A link to view this discussion on Gitlab + - An explanation for why the user is receiving this notification + MD + + position = Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + + create_note(noteable_type: 'merge_request', noteable_id: merge_request.id, type: 'DiffNote', position: position, note: note) + end + end + + private + + def project + @project ||= Project.find_by_full_path('gitlab-org/gitlab-test') + end + + def merge_request + @merge_request ||= project.merge_requests.find_by(source_branch: 'master', target_branch: 'feature') + end + + def user + @user ||= User.last + end + + def create_note(params) + Notes::CreateService.new(project, user, params).execute + end + + def note_email(method) + cleanup do + note = yield + + Notify.public_send(method, user.id, note) + end + end + + def cleanup + email = nil + + ActiveRecord::Base.transaction do + email = yield + raise ActiveRecord::Rollback + end + + email + end + def pipeline_success_email pipeline = Ci::Pipeline.last Notify.pipeline_success_email(pipeline, pipeline.user.try(:email)) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 02c47b3efc4..d7d6a75d38d 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -12,10 +12,13 @@ describe Ci::Pipeline, models: true do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:auto_canceled_by) } it { is_expected.to have_many(:statuses) } it { is_expected.to have_many(:trigger_requests) } it { is_expected.to have_many(:builds) } + it { is_expected.to have_many(:auto_canceled_pipelines) } + it { is_expected.to have_many(:auto_canceled_jobs) } it { is_expected.to validate_presence_of :sha } it { is_expected.to validate_presence_of :status } @@ -134,6 +137,43 @@ describe Ci::Pipeline, models: true do end end + describe '#auto_canceled?' do + subject { pipeline.auto_canceled? } + + context 'when it is canceled' do + before do + pipeline.cancel + end + + context 'when there is auto_canceled_by' do + before do + pipeline.update(auto_canceled_by: create(:ci_empty_pipeline)) + end + + it 'is auto canceled' do + is_expected.to be_truthy + end + end + + context 'when there is no auto_canceled_by' do + it 'is not auto canceled' do + is_expected.to be_falsey + end + end + + context 'when it is retried and canceled manually' do + before do + pipeline.enqueue + pipeline.cancel + end + + it 'is not auto canceled' do + is_expected.to be_falsey + end + end + end + end + describe 'pipeline stages' do before do create(:commit_status, pipeline: pipeline, @@ -335,7 +375,7 @@ describe Ci::Pipeline, models: true do end end - describe 'pipeline ETag caching' do + describe 'pipeline caching' do it 'executes ExpirePipelinesCacheService' do expect_any_instance_of(Ci::ExpirePipelineCacheService).to receive(:execute).with(pipeline) @@ -1039,19 +1079,6 @@ describe Ci::Pipeline, models: true do end end - describe '#update_status' do - let(:pipeline) { create(:ci_pipeline, sha: '123456') } - - it 'updates the cached status' do - fake_status = double - # after updating the status, the status is set to `skipped` for this pipeline's builds - expect(Ci::PipelineStatus).to receive(:new).with(pipeline.project, sha: '123456', status: 'skipped').and_return(fake_status) - expect(fake_status).to receive(:store_in_cache_if_needed) - - pipeline.update_status - end - end - describe 'notifications when pipeline success or failed' do let(:project) { create(:project, :repository) } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 7343b735a74..0ee85489574 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -16,6 +16,7 @@ describe CommitStatus, :models do it { is_expected.to belong_to(:pipeline) } it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:auto_canceled_by) } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_inclusion_of(:status).in_array(%w(pending running failed success canceled)) } @@ -101,6 +102,32 @@ describe CommitStatus, :models do end end + describe '#auto_canceled?' do + subject { commit_status.auto_canceled? } + + context 'when it is canceled' do + before do + commit_status.update(status: 'canceled') + end + + context 'when there is auto_canceled_by' do + before do + commit_status.update(auto_canceled_by: create(:ci_empty_pipeline)) + end + + it 'is auto canceled' do + is_expected.to be_truthy + end + end + + context 'when there is no auto_canceled_by' do + it 'is not auto canceled' do + is_expected.to be_falsey + end + end + end + end + describe '#duration' do subject { commit_status.duration } diff --git a/spec/models/concerns/discussion_on_diff_spec.rb b/spec/models/concerns/discussion_on_diff_spec.rb new file mode 100644 index 00000000000..0002a00770f --- /dev/null +++ b/spec/models/concerns/discussion_on_diff_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe DiffDiscussion, DiscussionOnDiff, model: true do + subject { create(:diff_note_on_merge_request).to_discussion } + + describe "#truncated_diff_lines" do + let(:truncated_lines) { subject.truncated_diff_lines } + + context "when diff is greater than allowed number of truncated diff lines " do + it "returns fewer lines" do + expect(subject.diff_lines.count).to be > described_class::NUMBER_OF_TRUNCATED_DIFF_LINES + + expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES + end + end + + context "when some diff lines are meta" do + it "returns no meta lines" do + expect(subject.diff_lines).to include(be_meta) + expect(truncated_lines).not_to include(be_meta) + end + end + end +end diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb index 82abad0e2f6..67dae7cf4c0 100644 --- a/spec/models/concerns/has_status_spec.rb +++ b/spec/models/concerns/has_status_spec.rb @@ -231,6 +231,18 @@ describe HasStatus do end end + describe '.created_or_pending' do + subject { CommitStatus.created_or_pending } + + %i[created pending].each do |status| + it_behaves_like 'containing the job', status + end + + %i[running failed success].each do |status| + it_behaves_like 'not containing the job', status + end + end + describe '.finished' do subject { CommitStatus.finished } diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb new file mode 100644 index 00000000000..92cc8859a8c --- /dev/null +++ b/spec/models/concerns/noteable_spec.rb @@ -0,0 +1,261 @@ +require 'spec_helper' + +describe MergeRequest, Noteable, model: true do + let!(:active_diff_note1) { create(:diff_note_on_merge_request) } + let(:project) { active_diff_note1.project } + subject { active_diff_note1.noteable } + let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: subject, in_reply_to: active_diff_note1) } + let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: subject, position: active_position2) } + let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: subject, position: outdated_position) } + let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: subject, in_reply_to: outdated_diff_note1) } + let!(:discussion_note1) { create(:discussion_note_on_merge_request, project: project, noteable: subject) } + let!(:discussion_note2) { create(:discussion_note_on_merge_request, in_reply_to: discussion_note1) } + let!(:commit_diff_note1) { create(:diff_note_on_commit, project: project) } + let!(:commit_diff_note2) { create(:diff_note_on_commit, project: project, in_reply_to: commit_diff_note1) } + let!(:commit_note1) { create(:note_on_commit, project: project) } + let!(:commit_note2) { create(:note_on_commit, project: project) } + let!(:commit_discussion_note1) { create(:discussion_note_on_commit, project: project) } + let!(:commit_discussion_note2) { create(:discussion_note_on_commit, in_reply_to: commit_discussion_note1) } + let!(:commit_discussion_note3) { create(:discussion_note_on_commit, project: project) } + let!(:note1) { create(:note, project: project, noteable: subject) } + let!(:note2) { create(:note, project: project, noteable: subject) } + + let(:active_position2) 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: subject.diff_refs + ) + end + + let(:outdated_position) do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 9, + diff_refs: project.commit("874797c3a73b60d2187ed6e2fcabd289ff75171e").diff_refs + ) + end + + describe '#discussions' do + let(:discussions) { subject.discussions } + + it 'includes discussions for diff notes, commit diff notes, commit notes, and regular notes' do + expect(discussions).to eq([ + DiffDiscussion.new([active_diff_note1, active_diff_note2], subject), + DiffDiscussion.new([active_diff_note3], subject), + DiffDiscussion.new([outdated_diff_note1, outdated_diff_note2], subject), + Discussion.new([discussion_note1, discussion_note2], subject), + DiffDiscussion.new([commit_diff_note1, commit_diff_note2], subject), + OutOfContextDiscussion.new([commit_note1, commit_note2], subject), + Discussion.new([commit_discussion_note1, commit_discussion_note2], subject), + Discussion.new([commit_discussion_note3], subject), + IndividualNoteDiscussion.new([note1], subject), + IndividualNoteDiscussion.new([note2], subject) + ]) + end + end + + describe '#grouped_diff_discussions' do + let(:grouped_diff_discussions) { subject.grouped_diff_discussions } + + it "includes active discussions" do + discussions = grouped_diff_discussions.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.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(grouped_diff_discussions.values.flatten.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + end + + it "groups the discussions by line code" do + expect(grouped_diff_discussions[active_diff_note1.line_code].first.id).to eq(active_diff_note1.discussion_id) + expect(grouped_diff_discussions[active_diff_note3.line_code].first.id).to eq(active_diff_note3.discussion_id) + end + end + + context "discussion status" do + let(:first_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } + let(:second_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } + let(:third_discussion) { build_stubbed(:discussion_note_on_merge_request, noteable: subject, project: project).to_discussion } + + before do + allow(subject).to receive(:resolvable_discussions).and_return([first_discussion, second_discussion, third_discussion]) + end + + describe "#discussions_resolvable?" do + context "when all discussions are unresolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(false) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolvable?).to be false + end + end + + context "when some discussions are unresolvable and some discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + + context "when all discussions are resolvable" do + before do + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(true) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolvable?).to be true + end + end + end + + describe "#discussions_resolved?" do + context "when discussions are not resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + + context "when discussions are resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(true) + + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable discussions are resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.discussions_resolved?).to be true + end + end + + context "when some resolvable discussions are not resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_resolved?).to be false + end + end + end + end + + describe "#discussions_to_be_resolved?" do + context "when discussions are not resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.discussions_to_be_resolved?).to be false + end + end + + context "when discussions are resolvable" do + before do + allow(subject).to receive(:discussions_resolvable?).and_return(true) + + allow(first_discussion).to receive(:resolvable?).and_return(true) + allow(second_discussion).to receive(:resolvable?).and_return(false) + allow(third_discussion).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable discussions are resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.discussions_to_be_resolved?).to be false + end + end + + context "when some resolvable discussions are not resolved" do + before do + allow(first_discussion).to receive(:resolved?).and_return(true) + allow(third_discussion).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.discussions_to_be_resolved?).to be true + end + end + end + end + + describe "#discussions_to_be_resolved" do + before do + allow(first_discussion).to receive(:to_be_resolved?).and_return(true) + allow(second_discussion).to receive(:to_be_resolved?).and_return(false) + allow(third_discussion).to receive(:to_be_resolved?).and_return(false) + end + + it 'includes only discussions that need to be resolved' do + expect(subject.discussions_to_be_resolved).to eq([first_discussion]) + end + end + + describe '#discussions_can_be_resolved_by?' do + let(:user) { build(:user) } + + context 'all discussions can be resolved by the user' do + before do + allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(third_discussion).to receive(:can_resolve?).with(user).and_return(true) + end + + it 'allows a user to resolve the discussions' do + expect(subject.discussions_can_be_resolved_by?(user)).to be(true) + end + end + + context 'one discussion cannot be resolved by the user' do + before do + allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) + allow(third_discussion).to receive(:can_resolve?).with(user).and_return(false) + end + + it 'allows a user to resolve the discussions' do + expect(subject.discussions_can_be_resolved_by?(user)).to be(false) + end + end + end + end +end diff --git a/spec/models/concerns/resolvable_discussion_spec.rb b/spec/models/concerns/resolvable_discussion_spec.rb new file mode 100644 index 00000000000..18327fe262d --- /dev/null +++ b/spec/models/concerns/resolvable_discussion_spec.rb @@ -0,0 +1,548 @@ +require 'spec_helper' + +describe Discussion, ResolvableDiscussion, models: true do + subject { described_class.new([first_note, second_note, third_note]) } + + let(:first_note) { create(:discussion_note_on_merge_request) } + let(:merge_request) { first_note.noteable } + let(:project) { first_note.project } + let(:second_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, in_reply_to: first_note) } + let(:third_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + describe "#resolvable?" do + context "when potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(true) + end + + context "when all notes are unresolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(false) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when some notes are unresolvable and some notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + + context "when all notes are resolvable" do + before do + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(true) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + + context "when not potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + end + + describe "#resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns true" do + expect(subject.resolved?).to be true + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns false" do + expect(subject.resolved?).to be false + end + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when some resolvable notes are not resolved" do + before do + allow(first_note).to receive(:resolved?).and_return(true) + allow(third_note).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#can_resolve?" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when not signed in" do + let(:current_user) { nil } + + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + + context "when signed in" do + context "when the signed in user is the noteable author" do + before do + subject.noteable.author = current_user + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user can push to the project" do + before do + subject.project.team << [current_user, :master] + end + + it "returns true" do + expect(subject.can_resolve?(current_user)).to be true + end + end + + context "when the signed in user is a random user" do + it "returns false" do + expect(subject.can_resolve?(current_user)).to be false + end + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + let(:user) { create(:user) } + let(:second_note) { create(:diff_note_on_commit) } # unresolvable + + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + + first_note.reload + third_note.reload + end + + it "doesn't change resolved_at on the resolved notes" do + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at } + end + + it "doesn't change resolved_by on the resolved notes" do + expect(first_note.resolved_by).to eq(user) + expect(third_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved notes" do + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } + expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? } + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved state" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "doesn't change resolved_at on the resolved note" do + expect(first_note.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload.resolved_at } + end + + it "doesn't change resolved_by on the resolved note" do + expect(first_note.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload && first_note.resolved_by } + end + + it "doesn't change the resolved state on the resolved note" do + expect(first_note.resolved?).to be true + + expect { subject.resolve!(current_user) }. + not_to change { first_note.reload && first_note.resolved? } + end + + it "sets resolved_at on the unresolved note" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved note" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved note as resolved" do + subject.resolve!(current_user) + third_note.reload + + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + + context "when no resolvable notes are resolved" do + it "sets resolved_at on the unresolved notes" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved_at).not_to be_nil + expect(third_note.resolved_at).not_to be_nil + end + + it "sets resolved_by on the unresolved notes" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved_by).to eq(current_user) + expect(third_note.resolved_by).to eq(current_user) + end + + it "marks the unresolved notes as resolved" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(first_note.resolved?).to be true + expect(third_note.resolved?).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + first_note.reload + third_note.reload + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + let(:user) { create(:user) } + + before do + allow(subject).to receive(:resolvable?).and_return(true) + + allow(first_note).to receive(:resolvable?).and_return(true) + allow(second_note).to receive(:resolvable?).and_return(false) + allow(third_note).to receive(:resolvable?).and_return(true) + end + + context "when all resolvable notes are resolved" do + before do + first_note.resolve!(user) + third_note.resolve!(user) + end + + it "unsets resolved_at on the resolved notes" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved_at).to be_nil + expect(third_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved notes" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved_by).to be_nil + expect(third_note.resolved_by).to be_nil + end + + it "unmarks the resolved notes as resolved" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(first_note.resolved?).to be false + expect(third_note.resolved?).to be false + end + + it "unsets resolved_at" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + first_note.reload + third_note.reload + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when some resolvable notes are resolved" do + before do + first_note.resolve!(user) + end + + it "unsets resolved_at on the resolved note" do + subject.unresolve! + + expect(subject.first_note.resolved_at).to be_nil + end + + it "unsets resolved_by on the resolved note" do + subject.unresolve! + + expect(subject.first_note.resolved_by).to be_nil + end + + it "unmarks the resolved note as resolved" do + subject.unresolve! + + expect(subject.first_note.resolved?).to be false + end + end + end + end + + describe "#first_note_to_resolve" do + it "returns the first note that still needs to be resolved" do + allow(first_note).to receive(:to_be_resolved?).and_return(false) + allow(second_note).to receive(:to_be_resolved?).and_return(true) + + expect(subject.first_note_to_resolve).to eq(second_note) + end + end + + describe "#last_resolved_note" do + let(:current_user) { create(:user) } + + before do + first_note.resolve!(current_user) + third_note.resolve!(current_user) + second_note.resolve!(current_user) + end + + it "returns the last note that was resolved" do + expect(subject.last_resolved_note).to eq(second_note) + end + end +end diff --git a/spec/models/concerns/resolvable_note_spec.rb b/spec/models/concerns/resolvable_note_spec.rb new file mode 100644 index 00000000000..1503ccdff11 --- /dev/null +++ b/spec/models/concerns/resolvable_note_spec.rb @@ -0,0 +1,329 @@ +require 'spec_helper' + +describe Note, ResolvableNote, models: true do + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, source_project: project) } + subject { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + context 'resolvability scopes' do + let!(:note1) { create(:note, project: project) } + let!(:note2) { create(:diff_note_on_commit, project: project) } + let!(:note3) { create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + let!(:note4) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + let!(:note5) { create(:discussion_note_on_issue, project: project) } + let!(:note6) { create(:discussion_note_on_merge_request, :system, noteable: merge_request, project: project) } + + describe '.potentially_resolvable' do + it 'includes diff and discussion notes on merge requests' do + expect(Note.potentially_resolvable).to match_array([note3, note4, note6]) + end + end + + describe '.resolvable' do + it 'includes non-system diff and discussion notes on merge requests' do + expect(Note.resolvable).to match_array([note3, note4]) + end + end + + describe '.resolved' do + it 'includes resolved non-system diff and discussion notes on merge requests' do + expect(Note.resolved).to match_array([note3]) + end + end + + describe '.unresolved' do + it 'includes non-resolved non-system diff and discussion notes on merge requests' do + expect(Note.unresolved).to match_array([note4]) + end + end + end + + describe ".resolve!" do + let(:current_user) { create(:user) } + let!(:commit_note) { create(:diff_note_on_commit, project: project) } + let!(:resolved_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + let!(:unresolved_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) } + + before do + described_class.resolve!(current_user) + + commit_note.reload + resolved_note.reload + unresolved_note.reload + end + + it 'resolves only the resolvable, not yet resolved notes' do + expect(commit_note.resolved_at).to be_nil + expect(resolved_note.resolved_by).not_to eq(current_user) + expect(unresolved_note.resolved_at).not_to be_nil + expect(unresolved_note.resolved_by).to eq(current_user) + end + end + + describe ".unresolve!" do + let!(:resolved_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project) } + + before do + described_class.unresolve! + + resolved_note.reload + end + + it 'unresolves the resolved notes' do + expect(resolved_note.resolved_by).to be_nil + expect(resolved_note.resolved_at).to be_nil + end + end + + describe '#resolvable?' do + context "when potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(true) + end + + context "when a system note" do + before do + subject.system = true + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + + context "when a regular note" do + it "returns true" do + expect(subject.resolvable?).to be true + end + end + end + + context "when not potentially resolvable" do + before do + allow(subject).to receive(:potentially_resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.resolvable?).to be false + end + end + end + + describe "#to_be_resolved?" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + before do + allow(subject).to receive(:resolved?).and_return(true) + end + + it "returns false" do + expect(subject.to_be_resolved?).to be false + end + end + + context "when not resolved" do + before do + allow(subject).to receive(:resolved?).and_return(false) + end + + it "returns true" do + expect(subject.to_be_resolved?).to be true + end + end + end + end + + describe "#resolved?" do + let(:current_user) { create(:user) } + + context 'when not resolvable' do + before do + subject.resolve!(current_user) + + allow(subject).to receive(:resolvable?).and_return(false) + end + + it 'returns false' do + expect(subject.resolved?).to be_falsey + end + end + + context 'when resolvable' do + context 'when the note has been resolved' do + before do + subject.resolve!(current_user) + end + + it 'returns true' do + expect(subject.resolved?).to be_truthy + end + end + + context 'when the note has not been resolved' do + it 'returns false' do + expect(subject.resolved?).to be_falsey + end + end + end + end + + describe "#resolve!" do + let(:current_user) { create(:user) } + + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't set resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).to be_nil + end + + it "doesn't set resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to be_nil + end + + it "doesn't mark as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be false + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when already resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns nil" do + expect(subject.resolve!(current_user)).to be_nil + end + + it "doesn't change resolved_at" do + expect(subject.resolved_at).not_to be_nil + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } + end + + it "doesn't change resolved_by" do + expect(subject.resolved_by).to eq(user) + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } + end + + it "doesn't change resolved status" do + expect(subject.resolved?).to be true + + expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } + end + end + + context "when not yet resolved" do + it "returns true" do + expect(subject.resolve!(current_user)).to be true + end + + it "sets resolved_at" do + subject.resolve!(current_user) + + expect(subject.resolved_at).not_to be_nil + end + + it "sets resolved_by" do + subject.resolve!(current_user) + + expect(subject.resolved_by).to eq(current_user) + end + + it "marks as resolved" do + subject.resolve!(current_user) + + expect(subject.resolved?).to be true + end + end + end + end + + describe "#unresolve!" do + context "when not resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(false) + end + + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + + context "when resolvable" do + before do + allow(subject).to receive(:resolvable?).and_return(true) + end + + context "when resolved" do + let(:user) { create(:user) } + + before do + subject.resolve!(user) + end + + it "returns true" do + expect(subject.unresolve!).to be true + end + + it "unsets resolved_at" do + subject.unresolve! + + expect(subject.resolved_at).to be_nil + end + + it "unsets resolved_by" do + subject.unresolve! + + expect(subject.resolved_by).to be_nil + end + + it "unmarks as resolved" do + subject.unresolve! + + expect(subject.resolved?).to be false + end + end + + context "when not resolved" do + it "returns nil" do + expect(subject.unresolve!).to be_nil + end + end + end + end +end diff --git a/spec/models/diff_discussion_spec.rb b/spec/models/diff_discussion_spec.rb new file mode 100644 index 00000000000..48e7c0a822c --- /dev/null +++ b/spec/models/diff_discussion_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe DiffDiscussion, model: true do + subject { described_class.new([first_note, second_note, third_note]) } + + 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) } + + 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) + end + end +end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 9ea3a4b7020..fb80b74b226 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -31,43 +31,6 @@ describe DiffNote, models: true do subject { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } - describe ".resolve!" do - let(:current_user) { create(:user) } - let!(:commit_note) { create(:diff_note_on_commit) } - let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) } - let!(:unresolved_note) { create(:diff_note_on_merge_request) } - - before do - described_class.resolve!(current_user) - - commit_note.reload - resolved_note.reload - unresolved_note.reload - end - - it 'resolves only the resolvable, not yet resolved notes' do - expect(commit_note.resolved_at).to be_nil - expect(resolved_note.resolved_by).not_to eq(current_user) - expect(unresolved_note.resolved_at).not_to be_nil - expect(unresolved_note.resolved_by).to eq(current_user) - end - end - - describe ".unresolve!" do - let!(:resolved_note) { create(:diff_note_on_merge_request, :resolved) } - - before do - described_class.unresolve! - - resolved_note.reload - end - - it 'unresolves the resolved notes' do - expect(resolved_note.resolved_by).to be_nil - expect(resolved_note.resolved_at).to be_nil - end - end - describe "#position=" do context "when provided a string" do it "sets the position" do @@ -94,6 +57,32 @@ describe DiffNote, models: true do end end + describe "#original_position=" do + context "when provided a string" do + it "sets the original position" do + subject.original_position = new_position.to_json + + expect(subject.original_position).to eq(new_position) + end + end + + context "when provided a hash" do + it "sets the original position" do + subject.original_position = new_position.to_h + + expect(subject.original_position).to eq(new_position) + end + end + + context "when provided a position object" do + it "sets the original position" do + subject.original_position = new_position + + expect(subject.original_position).to eq(new_position) + end + end + end + describe "#diff_file" do it "returns the correct diff file" do diff_file = subject.diff_file @@ -226,252 +215,6 @@ describe DiffNote, models: true do end end - describe "#resolvable?" do - context "when noteable is a commit" do - subject { create(:diff_note_on_commit, project: project, position: position) } - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - - context "when noteable is a merge request" do - context "when a system note" do - before do - subject.system = true - end - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - - context "when a regular note" do - it "returns true" do - expect(subject.resolvable?).to be true - end - end - end - end - - describe "#to_be_resolved?" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when resolved" do - before do - allow(subject).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when not resolved" do - before do - allow(subject).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.to_be_resolved?).to be true - end - end - end - end - - describe "#resolve!" do - let(:current_user) { create(:user) } - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.resolve!(current_user)).to be_nil - end - - it "doesn't set resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).to be_nil - end - - it "doesn't set resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to be_nil - end - - it "doesn't mark as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when already resolved" do - let(:user) { create(:user) } - - before do - subject.resolve!(user) - end - - it "returns nil" do - expect(subject.resolve!(current_user)).to be_nil - end - - it "doesn't change resolved_at" do - expect(subject.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } - end - - it "doesn't change resolved_by" do - expect(subject.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } - end - - it "doesn't change resolved status" do - expect(subject.resolved?).to be true - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } - end - end - - context "when not yet resolved" do - it "returns true" do - expect(subject.resolve!(current_user)).to be true - end - - it "sets resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).not_to be_nil - end - - it "sets resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to eq(current_user) - end - - it "marks as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be true - end - end - end - end - - describe "#unresolve!" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.unresolve!).to be_nil - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when resolved" do - let(:user) { create(:user) } - - before do - subject.resolve!(user) - end - - it "returns true" do - expect(subject.unresolve!).to be true - end - - it "unsets resolved_at" do - subject.unresolve! - - expect(subject.resolved_at).to be_nil - end - - it "unsets resolved_by" do - subject.unresolve! - - expect(subject.resolved_by).to be_nil - end - - it "unmarks as resolved" do - subject.unresolve! - - expect(subject.resolved?).to be false - end - end - - context "when not resolved" do - it "returns nil" do - expect(subject.unresolve!).to be_nil - end - end - end - end - - describe "#discussion" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.discussion).to be_nil - end - end - - context "when resolvable" do - let!(:diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: subject.position) } - let!(:diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } - - let(:active_position2) 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 - ) - end - - it "returns the discussion this note is in" do - discussion = subject.discussion - - expect(discussion.id).to eq(subject.discussion_id) - expect(discussion.notes).to eq([subject, diff_note2]) - end - end - end - describe "#discussion_id" do let(:note) { create(:diff_note_on_merge_request) } @@ -496,29 +239,4 @@ describe DiffNote, models: true do end end end - - describe "#original_discussion_id" do - let(:note) { create(:diff_note_on_merge_request) } - - context "when it is newly created" do - it "has a discussion id" do - expect(note.original_discussion_id).not_to be_nil - expect(note.original_discussion_id).to match(/\A\h{40}\z/) - end - end - - context "when it didn't store a discussion id before" do - before do - note.update_column(:original_discussion_id, nil) - end - - it "has a discussion id" do - # The original_discussion_id is set in `after_initialize`, so `reload` won't work - reloaded_note = Note.find(note.id) - - expect(reloaded_note.original_discussion_id).not_to be_nil - expect(reloaded_note.original_discussion_id).to match(/\A\h{40}\z/) - end - end - end end diff --git a/spec/models/discussion_spec.rb b/spec/models/discussion_spec.rb index bc32fadd391..0221e23ced8 100644 --- a/spec/models/discussion_spec.rb +++ b/spec/models/discussion_spec.rb @@ -4,618 +4,27 @@ describe Discussion, model: true do subject { described_class.new([first_note, second_note, third_note]) } let(:first_note) { create(:diff_note_on_merge_request) } - let(:second_note) { create(:diff_note_on_merge_request) } + let(:merge_request) { first_note.noteable } + let(:second_note) { create(:diff_note_on_merge_request, in_reply_to: first_note) } let(:third_note) { create(:diff_note_on_merge_request) } - describe "#resolvable?" do - context "when a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(true) - end - - context "when all notes are unresolvable" do - before do - allow(first_note).to receive(:resolvable?).and_return(false) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - - context "when some notes are unresolvable and some notes are resolvable" do - before do - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.resolvable?).to be true - end - end - - context "when all notes are resolvable" do - before do - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(true) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.resolvable?).to be true - end - end - end - - context "when not a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(false) - end - - it "returns false" do - expect(subject.resolvable?).to be false - end - end - end - - describe "#resolved?" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(true) - end - - it "returns true" do - expect(subject.resolved?).to be true - end - end - - context "when some resolvable notes are not resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(false) - end - - it "returns false" do - expect(subject.resolved?).to be false - end - end - end - end - - describe "#to_be_resolved?" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.to_be_resolved?).to be false - end - end - - context "when some resolvable notes are not resolved" do - before do - allow(first_note).to receive(:resolved?).and_return(true) - allow(third_note).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.to_be_resolved?).to be true - end - end - end - end - - describe "#can_resolve?" do - let(:current_user) { create(:user) } - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.can_resolve?(current_user)).to be false - end - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when not signed in" do - let(:current_user) { nil } - - it "returns false" do - expect(subject.can_resolve?(current_user)).to be false - end - end - - context "when signed in" do - context "when the signed in user is the noteable author" do - before do - subject.noteable.author = current_user - end - - it "returns true" do - expect(subject.can_resolve?(current_user)).to be true - end - end - - context "when the signed in user can push to the project" do - before do - subject.project.team << [current_user, :master] - end - - it "returns true" do - expect(subject.can_resolve?(current_user)).to be true - end - end - - context "when the signed in user is a random user" do - it "returns false" do - expect(subject.can_resolve?(current_user)).to be false - end - end - end - end - end - - describe "#resolve!" do - let(:current_user) { create(:user) } - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.resolve!(current_user)).to be_nil - end - - it "doesn't set resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).to be_nil - end - - it "doesn't set resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to be_nil - end - - it "doesn't mark as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be false - end - end - - context "when resolvable" do - let(:user) { create(:user) } - let(:second_note) { create(:diff_note_on_commit) } # unresolvable - - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - first_note.resolve!(user) - third_note.resolve!(user) - - first_note.reload - third_note.reload - end - - it "doesn't change resolved_at on the resolved notes" do - expect(first_note.resolved_at).not_to be_nil - expect(third_note.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_at } - expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_at } - end - - it "doesn't change resolved_by on the resolved notes" do - expect(first_note.resolved_by).to eq(user) - expect(third_note.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }.not_to change { first_note.resolved_by } - expect { subject.resolve!(current_user) }.not_to change { third_note.resolved_by } - end - - it "doesn't change the resolved state on the resolved notes" do - expect(first_note.resolved?).to be true - expect(third_note.resolved?).to be true - - expect { subject.resolve!(current_user) }.not_to change { first_note.resolved? } - expect { subject.resolve!(current_user) }.not_to change { third_note.resolved? } - end - - it "doesn't change resolved_at" do - expect(subject.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_at } - end - - it "doesn't change resolved_by" do - expect(subject.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved_by } - end - - it "doesn't change resolved state" do - expect(subject.resolved?).to be true - - expect { subject.resolve!(current_user) }.not_to change { subject.resolved? } - end - end - - context "when some resolvable notes are resolved" do - before do - first_note.resolve!(user) - end - - it "doesn't change resolved_at on the resolved note" do - expect(first_note.resolved_at).not_to be_nil - - expect { subject.resolve!(current_user) }. - not_to change { first_note.reload.resolved_at } - end - - it "doesn't change resolved_by on the resolved note" do - expect(first_note.resolved_by).to eq(user) - - expect { subject.resolve!(current_user) }. - not_to change { first_note.reload && first_note.resolved_by } - end - - it "doesn't change the resolved state on the resolved note" do - expect(first_note.resolved?).to be true - - expect { subject.resolve!(current_user) }. - not_to change { first_note.reload && first_note.resolved? } - end - - it "sets resolved_at on the unresolved note" do - subject.resolve!(current_user) - third_note.reload - - expect(third_note.resolved_at).not_to be_nil - end - - it "sets resolved_by on the unresolved note" do - subject.resolve!(current_user) - third_note.reload - - expect(third_note.resolved_by).to eq(current_user) - end - - it "marks the unresolved note as resolved" do - subject.resolve!(current_user) - third_note.reload - - expect(third_note.resolved?).to be true - end - - it "sets resolved_at" do - subject.resolve!(current_user) - - expect(subject.resolved_at).not_to be_nil - end - - it "sets resolved_by" do - subject.resolve!(current_user) - - expect(subject.resolved_by).to eq(current_user) - end - - it "marks as resolved" do - subject.resolve!(current_user) - - expect(subject.resolved?).to be true - end - end - - context "when no resolvable notes are resolved" do - it "sets resolved_at on the unresolved notes" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(first_note.resolved_at).not_to be_nil - expect(third_note.resolved_at).not_to be_nil - end - - it "sets resolved_by on the unresolved notes" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(first_note.resolved_by).to eq(current_user) - expect(third_note.resolved_by).to eq(current_user) - end - - it "marks the unresolved notes as resolved" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(first_note.resolved?).to be true - expect(third_note.resolved?).to be true - end - - it "sets resolved_at" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(subject.resolved_at).not_to be_nil - end - - it "sets resolved_by" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(subject.resolved_by).to eq(current_user) - end - - it "marks as resolved" do - subject.resolve!(current_user) - first_note.reload - third_note.reload - - expect(subject.resolved?).to be true - end - end - end - end - - describe "#unresolve!" do - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - it "returns nil" do - expect(subject.unresolve!).to be_nil - end - end - - context "when resolvable" do - let(:user) { create(:user) } - - before do - allow(subject).to receive(:resolvable?).and_return(true) - - allow(first_note).to receive(:resolvable?).and_return(true) - allow(second_note).to receive(:resolvable?).and_return(false) - allow(third_note).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable notes are resolved" do - before do - first_note.resolve!(user) - third_note.resolve!(user) - end - - it "unsets resolved_at on the resolved notes" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(first_note.resolved_at).to be_nil - expect(third_note.resolved_at).to be_nil - end - - it "unsets resolved_by on the resolved notes" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(first_note.resolved_by).to be_nil - expect(third_note.resolved_by).to be_nil - end - - it "unmarks the resolved notes as resolved" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(first_note.resolved?).to be false - expect(third_note.resolved?).to be false - end - - it "unsets resolved_at" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(subject.resolved_at).to be_nil - end - - it "unsets resolved_by" do - subject.unresolve! - first_note.reload - third_note.reload - - expect(subject.resolved_by).to be_nil - end - - it "unmarks as resolved" do - subject.unresolve! - - expect(subject.resolved?).to be false - end - end - - context "when some resolvable notes are resolved" do - before do - first_note.resolve!(user) - end - - it "unsets resolved_at on the resolved note" do - subject.unresolve! - - expect(subject.first_note.resolved_at).to be_nil - end - - it "unsets resolved_by on the resolved note" do - subject.unresolve! - - expect(subject.first_note.resolved_by).to be_nil - end - - it "unmarks the resolved note as resolved" do - subject.unresolve! - - expect(subject.first_note.resolved?).to be false - end - end + describe '.build' do + it 'returns a discussion of the right type' do + discussion = described_class.build([first_note, second_note], merge_request) + expect(discussion).to be_a(DiffDiscussion) + expect(discussion.notes.count).to be(2) + expect(discussion.first_note).to be(first_note) + expect(discussion.noteable).to be(merge_request) end end - describe "#first_note_to_resolve" do - it "returns the first not that still needs to be resolved" do - allow(first_note).to receive(:to_be_resolved?).and_return(false) - allow(second_note).to receive(:to_be_resolved?).and_return(true) - - expect(subject.first_note_to_resolve).to eq(second_note) - end - end - - describe "#collapsed?" do - context "when a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(true) - end - - context "when resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(true) - end - - context "when resolved" do - before do - allow(subject).to receive(:resolved?).and_return(true) - end - - it "returns true" do - expect(subject.collapsed?).to be true - end - end - - context "when not resolved" do - before do - allow(subject).to receive(:resolved?).and_return(false) - end - - it "returns false" do - expect(subject.collapsed?).to be false - end - end - end - - context "when not resolvable" do - before do - allow(subject).to receive(:resolvable?).and_return(false) - end - - context "when active" do - before do - allow(subject).to receive(:active?).and_return(true) - end - - it "returns false" do - expect(subject.collapsed?).to be false - end - end - - context "when outdated" do - before do - allow(subject).to receive(:active?).and_return(false) - end - - it "returns true" do - expect(subject.collapsed?).to be true - end - end - end - end - - context "when not a diff discussion" do - before do - allow(subject).to receive(:diff_discussion?).and_return(false) - end - - it "returns false" do - expect(subject.collapsed?).to be false - end - end - end - - describe "#truncated_diff_lines" do - let(:truncated_lines) { subject.truncated_diff_lines } - - context "when diff is greater than allowed number of truncated diff lines " do - it "returns fewer lines" do - expect(subject.diff_lines.count).to be > described_class::NUMBER_OF_TRUNCATED_DIFF_LINES - - expect(truncated_lines.count).to be <= described_class::NUMBER_OF_TRUNCATED_DIFF_LINES - end - end - - context "when some diff lines are meta" do - it "returns no meta lines" do - expect(subject.diff_lines).to include(be_meta) - expect(truncated_lines).not_to include(be_meta) - end + describe '.build_collection' do + it 'returns an array of discussions of the right type' do + discussions = described_class.build_collection([first_note, second_note, third_note], merge_request) + expect(discussions).to eq([ + DiffDiscussion.new([first_note, second_note], merge_request), + DiffDiscussion.new([third_note], merge_request) + ]) end end end diff --git a/spec/models/legacy_diff_discussion_spec.rb b/spec/models/legacy_diff_discussion_spec.rb new file mode 100644 index 00000000000..153e757a0ef --- /dev/null +++ b/spec/models/legacy_diff_discussion_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe LegacyDiffDiscussion, models: true do + subject { create(:legacy_diff_note_on_merge_request).to_discussion } + + describe '#reply_attributes' do + it 'includes line_code' do + expect(subject.reply_attributes[:line_code]).to eq(subject.line_code) + end + end +end diff --git a/spec/models/legacy_diff_note_spec.rb b/spec/models/legacy_diff_note_spec.rb deleted file mode 100644 index 81517a18b74..00000000000 --- a/spec/models/legacy_diff_note_spec.rb +++ /dev/null @@ -1,101 +0,0 @@ -require 'spec_helper' - -describe LegacyDiffNote, models: true do - describe "Commit diff line notes" do - let!(:note) { create(:legacy_diff_note_on_commit, note: "+1 from me") } - let!(:commit) { note.noteable } - - it "saves a valid note" do - expect(note.commit_id).to eq(commit.id) - expect(note.noteable.id).to eq(commit.id) - end - - it "is recognized by #legacy_diff_note?" do - expect(note).to be_legacy_diff_note - end - end - - describe '#active?' do - it 'is always true when the note has no associated diff line' do - note = build(:legacy_diff_note_on_merge_request) - - expect(note).to receive(:diff_line).and_return(nil) - - expect(note).to be_active - end - - it 'is never true when the note has no noteable associated' do - note = build(:legacy_diff_note_on_merge_request) - - expect(note).to receive(:diff_line).and_return(double) - expect(note).to receive(:noteable).and_return(nil) - - expect(note).not_to be_active - end - - it 'returns the memoized value if defined' do - note = build(:legacy_diff_note_on_merge_request) - - note.instance_variable_set(:@active, 'foo') - expect(note).not_to receive(:find_noteable_diff) - - expect(note.active?).to eq 'foo' - end - - context 'for a merge request noteable' do - it 'is false when noteable has no matching diff' do - merge = build_stubbed(:merge_request, :simple) - note = build(:legacy_diff_note_on_merge_request, noteable: merge) - - allow(note).to receive(:diff_line).and_return(double) - expect(note).to receive(:find_noteable_diff).and_return(nil) - - expect(note).not_to be_active - end - - it 'is true when noteable has a matching diff' do - merge = create(:merge_request, :simple) - - # Generate a real line_code value so we know it will match. We use a - # random line from a random diff just for funsies. - diff = merge.raw_diffs.to_a.sample - line = Gitlab::Diff::Parser.new.parse(diff.diff.each_line).to_a.sample - code = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos) - - # We're persisting in order to trigger the set_diff callback - note = create(:legacy_diff_note_on_merge_request, noteable: merge, - line_code: code, - project: merge.source_project) - - # Make sure we don't get a false positive from a guard clause - expect(note).to receive(:find_noteable_diff).and_call_original - expect(note).to be_active - end - end - end - - describe "#discussion_id" do - let(:note) { create(:note) } - - context "when it is newly created" do - it "has a discussion id" do - expect(note.discussion_id).not_to be_nil - expect(note.discussion_id).to match(/\A\h{40}\z/) - end - end - - context "when it didn't store a discussion id before" do - before do - note.update_column(:discussion_id, nil) - end - - it "has a discussion id" do - # The discussion_id is set in `after_initialize`, so `reload` won't work - reloaded_note = Note.find(note.id) - - expect(reloaded_note.discussion_id).not_to be_nil - expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) - end - end - end -end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 24e7c1b17d9..90b3a2ba42d 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -441,7 +441,7 @@ describe MergeRequest, models: true do end it "can't be removed when its a protected branch" do - allow(subject.source_project).to receive(:protected_branch?).and_return(true) + allow(ProtectedBranch).to receive(:protected?).and_return(true) expect(subject.can_remove_source_branch?(user)).to be_falsey end @@ -1224,182 +1224,6 @@ describe MergeRequest, models: true do end end - context "discussion status" do - let(:first_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } - let(:second_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } - let(:third_discussion) { Discussion.new([create(:diff_note_on_merge_request)]) } - - before do - allow(subject).to receive(:diff_discussions).and_return([first_discussion, second_discussion, third_discussion]) - end - - describe '#resolvable_discussions' do - before do - allow(first_discussion).to receive(:to_be_resolved?).and_return(true) - allow(second_discussion).to receive(:to_be_resolved?).and_return(false) - allow(third_discussion).to receive(:to_be_resolved?).and_return(false) - end - - it 'includes only discussions that need to be resolved' do - expect(subject.resolvable_discussions).to eq([first_discussion]) - end - end - - describe '#discussions_can_be_resolved_by? user' do - let(:user) { build(:user) } - - context 'all discussions can be resolved by the user' do - before do - allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(third_discussion).to receive(:can_resolve?).with(user).and_return(true) - end - - it 'allows a user to resolve the discussions' do - expect(subject.discussions_can_be_resolved_by?(user)).to be(true) - end - end - - context 'one discussion cannot be resolved by the user' do - before do - allow(first_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(second_discussion).to receive(:can_resolve?).with(user).and_return(true) - allow(third_discussion).to receive(:can_resolve?).with(user).and_return(false) - end - - it 'allows a user to resolve the discussions' do - expect(subject.discussions_can_be_resolved_by?(user)).to be(false) - end - end - end - - describe "#discussions_resolvable?" do - context "when all discussions are unresolvable" do - before do - allow(first_discussion).to receive(:resolvable?).and_return(false) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_resolvable?).to be false - end - end - - context "when some discussions are unresolvable and some discussions are resolvable" do - before do - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.discussions_resolvable?).to be true - end - end - - context "when all discussions are resolvable" do - before do - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(true) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - it "returns true" do - expect(subject.discussions_resolvable?).to be true - end - end - end - - describe "#discussions_resolved?" do - context "when discussions are not resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_resolved?).to be false - end - end - - context "when discussions are resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(true) - - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable discussions are resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(true) - end - - it "returns true" do - expect(subject.discussions_resolved?).to be true - end - end - - context "when some resolvable discussions are not resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_resolved?).to be false - end - end - end - end - - describe "#discussions_to_be_resolved?" do - context "when discussions are not resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(false) - end - - it "returns false" do - expect(subject.discussions_to_be_resolved?).to be false - end - end - - context "when discussions are resolvable" do - before do - allow(subject).to receive(:discussions_resolvable?).and_return(true) - - allow(first_discussion).to receive(:resolvable?).and_return(true) - allow(second_discussion).to receive(:resolvable?).and_return(false) - allow(third_discussion).to receive(:resolvable?).and_return(true) - end - - context "when all resolvable discussions are resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(true) - end - - it "returns false" do - expect(subject.discussions_to_be_resolved?).to be false - end - end - - context "when some resolvable discussions are not resolved" do - before do - allow(first_discussion).to receive(:resolved?).and_return(true) - allow(third_discussion).to receive(:resolved?).and_return(false) - end - - it "returns true" do - expect(subject.discussions_to_be_resolved?).to be true - end - end - end - end - end - describe '#conflicts_can_be_resolved_in_ui?' do def create_merge_request(source_branch) create(:merge_request, source_branch: source_branch, target_branch: 'conflict-start') do |mr| diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index f3f48f951a8..e3e8e6d571c 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -109,18 +109,6 @@ describe Milestone, models: true do it { expect(milestone.percent_complete(user)).to eq(75) } end - describe '#is_empty?' do - before do - milestone.issues << create(:issue, project: project) - milestone.issues << create(:closed_issue, project: project) - milestone.merge_requests << create(:merge_request) - end - - it { expect(milestone.closed_items_count(user)).to eq(1) } - it { expect(milestone.total_items_count(user)).to eq(3) } - it { expect(milestone.is_empty?(user)).to be_falsey } - end - describe '#can_be_closed?' do it { expect(milestone.can_be_closed?).to be_truthy } end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 33536487c41..3c4bf3f4ddb 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -245,14 +245,28 @@ describe Note, models: true do end end + describe '.find_discussion' do + let!(:note) { create(:discussion_note_on_merge_request) } + let!(:note2) { create(:discussion_note_on_merge_request, in_reply_to: note) } + let(:merge_request) { note.noteable } + + it 'returns a discussion with multiple notes' do + discussion = merge_request.notes.find_discussion(note.discussion_id) + + expect(discussion).not_to be_nil + expect(discussion.notes).to match_array([note, note2]) + expect(discussion.first_note.discussion_id).to eq(note.discussion_id) + end + end + describe ".grouped_diff_discussions" do let!(:merge_request) { create(:merge_request) } let(:project) { merge_request.project } let!(:active_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } - let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let!(:active_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: active_diff_note1) } let!(:active_diff_note3) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: active_position2) } let!(:outdated_diff_note1) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } - let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: outdated_position) } + let!(:outdated_diff_note2) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, in_reply_to: outdated_diff_note1) } let(:active_position2) do Gitlab::Diff::Position.new( @@ -277,7 +291,7 @@ describe Note, models: true do subject { merge_request.notes.grouped_diff_discussions } it "includes active discussions" do - discussions = subject.values + 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]) @@ -288,37 +302,12 @@ describe Note, models: true do end it "doesn't include outdated discussions" do - expect(subject.values.map(&:id)).not_to include(outdated_diff_note1.discussion_id) + 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].id).to eq(active_diff_note1.discussion_id) - expect(subject[active_diff_note3.line_code].id).to eq(active_diff_note3.discussion_id) - end - end - - describe "#discussion_id" do - let(:note) { create(:note) } - - context "when it is newly created" do - it "has a discussion id" do - expect(note.discussion_id).not_to be_nil - expect(note.discussion_id).to match(/\A\h{40}\z/) - end - end - - context "when it didn't store a discussion id before" do - before do - note.update_column(:discussion_id, nil) - end - - it "has a discussion id" do - # The discussion_id is set in `after_initialize`, so `reload` won't work - reloaded_note = Note.find(note.id) - - expect(reloaded_note.discussion_id).not_to be_nil - expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) - end + 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 @@ -388,6 +377,248 @@ describe Note, models: true do end end + describe '#can_be_discussion_note?' do + context 'for a note on a merge request' do + it 'returns true' do + note = build(:note_on_merge_request) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a note on an issue' do + it 'returns true' do + note = build(:note_on_issue) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a note on a commit' do + it 'returns true' do + note = build(:note_on_commit) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a note on a snippet' do + it 'returns true' do + note = build(:note_on_project_snippet) + + expect(note.can_be_discussion_note?).to be_truthy + end + end + + context 'for a diff note on merge request' do + it 'returns false' do + note = build(:diff_note_on_merge_request) + + expect(note.can_be_discussion_note?).to be_falsey + end + end + + context 'for a diff note on commit' do + it 'returns false' do + note = build(:diff_note_on_commit) + + expect(note.can_be_discussion_note?).to be_falsey + end + end + + context 'for a discussion note' do + it 'returns false' do + note = build(:discussion_note_on_merge_request) + + expect(note.can_be_discussion_note?).to be_falsey + end + end + end + + describe '#discussion_class' do + let(:note) { build(:note_on_commit) } + let(:merge_request) { create(:merge_request) } + + context 'when the note is displayed out of context' do + it 'returns OutOfContextDiscussion' do + expect(note.discussion_class(merge_request)).to be(OutOfContextDiscussion) + end + end + + context 'when the note is displayed in the original context' do + it 'returns IndividualNoteDiscussion' do + expect(note.discussion_class(note.noteable)).to be(IndividualNoteDiscussion) + end + end + end + + describe "#discussion_id" do + let(:note) { create(:note_on_commit) } + + context "when it is newly created" do + it "has a discussion id" do + expect(note.discussion_id).not_to be_nil + expect(note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context "when it didn't store a discussion id before" do + before do + note.update_column(:discussion_id, nil) + end + + it "has a discussion id" do + # The discussion_id is set in `after_initialize`, so `reload` won't work + reloaded_note = Note.find(note.id) + + expect(reloaded_note.discussion_id).not_to be_nil + expect(reloaded_note.discussion_id).to match(/\A\h{40}\z/) + end + end + + context 'when the note is displayed out of context' do + let(:merge_request) { create(:merge_request) } + + it 'overrides the discussion id' do + expect(note.discussion_id(merge_request)).not_to eq(note.discussion_id) + end + end + end + + describe '#to_discussion' do + subject { create(:discussion_note_on_merge_request) } + let!(:note2) { create(:discussion_note_on_merge_request, project: subject.project, noteable: subject.noteable, in_reply_to: subject) } + + it "returns a discussion with just this note" do + discussion = subject.to_discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([subject]) + end + end + + describe "#discussion" do + let!(:note1) { create(:discussion_note_on_merge_request) } + let!(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) } + + context 'when the note is part of a discussion' do + subject { create(:discussion_note_on_merge_request, project: note1.project, noteable: note1.noteable, in_reply_to: note1) } + + it "returns the discussion this note is in" do + discussion = subject.discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([note1, subject]) + end + end + + context 'when the note is not part of a discussion' do + subject { create(:note) } + + it "returns a discussion with just this note" do + discussion = subject.discussion + + expect(discussion.id).to eq(subject.discussion_id) + expect(discussion.notes).to eq([subject]) + end + end + end + + describe "#part_of_discussion?" do + context 'for a regular note' do + let(:note) { build(:note) } + + it 'returns false' do + expect(note.part_of_discussion?).to be_falsey + end + end + + context 'for a diff note' do + let(:note) { build(:diff_note_on_commit) } + + it 'returns true' do + expect(note.part_of_discussion?).to be_truthy + end + end + + context 'for a discussion note' do + let(:note) { build(:discussion_note_on_merge_request) } + + it 'returns true' do + expect(note.part_of_discussion?).to be_truthy + end + end + end + + describe '#in_reply_to?' do + context 'for a note' do + context 'when part of a discussion' do + subject { create(:discussion_note_on_issue) } + let(:note) { create(:discussion_note_on_issue, in_reply_to: subject) } + + it 'checks if the note is in reply to the other discussion' do + expect(subject).to receive(:in_reply_to?).with(note).and_call_original + expect(subject).to receive(:in_reply_to?).with(note.noteable).and_call_original + expect(subject).to receive(:in_reply_to?).with(note.to_discussion).and_call_original + + subject.in_reply_to?(note) + end + end + + context 'when not part of a discussion' do + subject { create(:note) } + let(:note) { create(:note, in_reply_to: subject) } + + it 'checks if the note is in reply to the other noteable' do + expect(subject).to receive(:in_reply_to?).with(note).and_call_original + expect(subject).to receive(:in_reply_to?).with(note.noteable).and_call_original + + subject.in_reply_to?(note) + end + end + end + + context 'for a discussion' do + context 'when part of the same discussion' do + subject { create(:diff_note_on_merge_request) } + let(:note) { create(:diff_note_on_merge_request, in_reply_to: subject) } + + it 'returns true' do + expect(subject.in_reply_to?(note.to_discussion)).to be_truthy + end + end + + context 'when not part of the same discussion' do + subject { create(:diff_note_on_merge_request) } + let(:note) { create(:diff_note_on_merge_request) } + + it 'returns false' do + expect(subject.in_reply_to?(note.to_discussion)).to be_falsey + end + end + end + + context 'for a noteable' do + context 'when a comment on the same noteable' do + subject { create(:note) } + let(:note) { create(:note, in_reply_to: subject) } + + it 'returns true' do + expect(subject.in_reply_to?(note.noteable)).to be_truthy + end + end + + context 'when not a comment on the same noteable' do + subject { create(:note) } + let(:note) { create(:note) } + + it 'returns false' do + expect(subject.in_reply_to?(note.noteable)).to be_falsey + end + end + end + end + describe 'expiring ETag cache' do let(:note) { build(:note_on_issue) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 6d4ef78f8ec..92d420337f9 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -704,25 +704,6 @@ describe Project, models: true do end end - describe '#open_branches' do - let(:project) { create(:project, :repository) } - - before do - project.protected_branches.create(name: 'master') - end - - it { expect(project.open_branches.map(&:name)).to include('feature') } - it { expect(project.open_branches.map(&:name)).not_to include('master') } - - it "includes branches matching a protected branch wildcard" do - expect(project.open_branches.map(&:name)).to include('feature') - - create(:protected_branch, name: 'feat*', project: project) - - expect(Project.find(project.id).open_branches.map(&:name)).to include('feature') - end - end - describe '#star_count' do it 'counts stars from multiple users' do user1 = create :user @@ -1297,62 +1278,6 @@ describe Project, models: true do end end - describe '#protected_branch?' do - context 'existing project' do - let(:project) { create(:project, :repository) } - - it 'returns true when the branch matches a protected branch via direct match' do - create(:protected_branch, project: project, name: "foo") - - expect(project.protected_branch?('foo')).to eq(true) - end - - it 'returns true when the branch matches a protected branch via wildcard match' do - create(:protected_branch, project: project, name: "production/*") - - expect(project.protected_branch?('production/some-branch')).to eq(true) - end - - it 'returns false when the branch does not match a protected branch via direct match' do - expect(project.protected_branch?('foo')).to eq(false) - end - - it 'returns false when the branch does not match a protected branch via wildcard match' do - create(:protected_branch, project: project, name: "production/*") - - expect(project.protected_branch?('staging/some-branch')).to eq(false) - end - end - - context "new project" do - let(:project) { create(:empty_project) } - - it 'returns false when default_protected_branch is unprotected' do - stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) - - expect(project.protected_branch?('master')).to be false - end - - it 'returns false when default_protected_branch lets developers push' do - stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) - - expect(project.protected_branch?('master')).to be false - end - - it 'returns true when default_branch_protection does not let developers push but let developer merge branches' do - stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) - - expect(project.protected_branch?('master')).to be true - end - - it 'returns true when default_branch_protection is in full protection' do - stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) - - expect(project.protected_branch?('master')).to be true - end - end - end - describe '#user_can_push_to_empty_repo?' do let(:project) { create(:empty_project) } let(:user) { create(:user) } @@ -1949,7 +1874,7 @@ describe Project, models: true do describe '#pipeline_status' do let(:project) { create(:project) } it 'builds a pipeline status' do - expect(project.pipeline_status).to be_a(Ci::PipelineStatus) + expect(project.pipeline_status).to be_a(Gitlab::Cache::Ci::ProjectPipelineStatus) end it 'hase a loaded pipeline status' do diff --git a/spec/models/protectable_dropdown_spec.rb b/spec/models/protectable_dropdown_spec.rb new file mode 100644 index 00000000000..4c9bade592b --- /dev/null +++ b/spec/models/protectable_dropdown_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe ProtectableDropdown, models: true do + let(:project) { create(:project, :repository) } + let(:subject) { described_class.new(project, :branches) } + + describe '#protectable_ref_names' do + before do + project.protected_branches.create(name: 'master') + end + + it { expect(subject.protectable_ref_names).to include('feature') } + it { expect(subject.protectable_ref_names).not_to include('master') } + + it "includes branches matching a protected branch wildcard" do + expect(subject.protectable_ref_names).to include('feature') + + create(:protected_branch, name: 'feat*', project: project) + + subject = described_class.new(project.reload, :branches) + + expect(subject.protectable_ref_names).to include('feature') + end + end +end diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index 8bf0d24a128..179a443c43d 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -113,8 +113,8 @@ describe ProtectedBranch, models: true do staging = build(:protected_branch, name: "staging") expect(ProtectedBranch.matching("production")).to be_empty - expect(ProtectedBranch.matching("production", protected_branches: [production, staging])).to include(production) - expect(ProtectedBranch.matching("production", protected_branches: [production, staging])).not_to include(staging) + expect(ProtectedBranch.matching("production", protected_refs: [production, staging])).to include(production) + expect(ProtectedBranch.matching("production", protected_refs: [production, staging])).not_to include(staging) end end @@ -132,8 +132,64 @@ describe ProtectedBranch, models: true do staging = build(:protected_branch, name: "staging/*") expect(ProtectedBranch.matching("production/some-branch")).to be_empty - expect(ProtectedBranch.matching("production/some-branch", protected_branches: [production, staging])).to include(production) - expect(ProtectedBranch.matching("production/some-branch", protected_branches: [production, staging])).not_to include(staging) + expect(ProtectedBranch.matching("production/some-branch", protected_refs: [production, staging])).to include(production) + expect(ProtectedBranch.matching("production/some-branch", protected_refs: [production, staging])).not_to include(staging) + end + end + end + + describe '#protected?' do + context 'existing project' do + let(:project) { create(:project, :repository) } + + it 'returns true when the branch matches a protected branch via direct match' do + create(:protected_branch, project: project, name: "foo") + + expect(ProtectedBranch.protected?(project, 'foo')).to eq(true) + end + + it 'returns true when the branch matches a protected branch via wildcard match' do + create(:protected_branch, project: project, name: "production/*") + + expect(ProtectedBranch.protected?(project, 'production/some-branch')).to eq(true) + end + + it 'returns false when the branch does not match a protected branch via direct match' do + expect(ProtectedBranch.protected?(project, 'foo')).to eq(false) + end + + it 'returns false when the branch does not match a protected branch via wildcard match' do + create(:protected_branch, project: project, name: "production/*") + + expect(ProtectedBranch.protected?(project, 'staging/some-branch')).to eq(false) + end + end + + context "new project" do + let(:project) { create(:empty_project) } + + it 'returns false when default_protected_branch is unprotected' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_NONE) + + expect(ProtectedBranch.protected?(project, 'master')).to be false + end + + it 'returns false when default_protected_branch lets developers push' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_PUSH) + + expect(ProtectedBranch.protected?(project, 'master')).to be false + end + + it 'returns true when default_branch_protection does not let developers push but let developer merge branches' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_DEV_CAN_MERGE) + + expect(ProtectedBranch.protected?(project, 'master')).to be true + end + + it 'returns true when default_branch_protection is in full protection' do + stub_application_setting(default_branch_protection: Gitlab::Access::PROTECTION_FULL) + + expect(ProtectedBranch.protected?(project, 'master')).to be true end end end diff --git a/spec/models/protected_tag_spec.rb b/spec/models/protected_tag_spec.rb new file mode 100644 index 00000000000..51353852a93 --- /dev/null +++ b/spec/models/protected_tag_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe ProtectedTag, models: true do + describe 'Associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'Validation' do + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:name) } + end +end diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb new file mode 100644 index 00000000000..6b7eef388be --- /dev/null +++ b/spec/models/sent_notification_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' + +describe SentNotification, model: true do + describe 'validation' do + describe 'note validity' do + context "when the project doesn't match the noteable's project" do + subject { build(:sent_notification, noteable: create(:issue)) } + + it "is invalid" do + expect(subject).not_to be_valid + end + end + + context "when the project doesn't match the discussion project" do + let(:discussion_id) { create(:note).discussion_id } + subject { build(:sent_notification, in_reply_to_discussion_id: discussion_id) } + + it "is invalid" do + expect(subject).not_to be_valid + end + end + + context "when the noteable project and discussion project match" do + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project) } + let(:discussion_id) { create(:note, project: project, noteable: issue).discussion_id } + subject { build(:sent_notification, project: project, noteable: issue, in_reply_to_discussion_id: discussion_id) } + + it "is valid" do + expect(subject).to be_valid + end + end + end + end + + describe '.record' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + + it 'creates a new SentNotification' do + expect { described_class.record(issue, user.id) }.to change { SentNotification.count }.by(1) + end + end + + describe '.record_note' do + let(:user) { create(:user) } + let(:note) { create(:diff_note_on_merge_request) } + + it 'creates a new SentNotification' do + expect { described_class.record_note(note, user.id) }.to change { SentNotification.count }.by(1) + end + end + + describe '#create_reply' do + context 'for issue' do + let(:issue) { create(:issue) } + subject { described_class.record(issue, issue.author.id) } + + it 'creates a comment on the issue' do + note = subject.create_reply('Test') + expect(note.in_reply_to?(issue)).to be_truthy + end + end + + context 'for issue comment' do + let(:note) { create(:note_on_issue) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a comment on the issue' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for issue discussion' do + let(:note) { create(:discussion_note_on_issue) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for merge request' do + let(:merge_request) { create(:merge_request) } + subject { described_class.record(merge_request, merge_request.author.id) } + + it 'creates a comment on the merge_request' do + note = subject.create_reply('Test') + expect(note.in_reply_to?(merge_request)).to be_truthy + end + end + + context 'for merge request comment' do + let(:note) { create(:note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a comment on the merge request' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for merge request diff discussion' do + let(:note) { create(:diff_note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for merge request non-diff discussion' do + let(:note) { create(:discussion_note_on_merge_request) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for commit' do + let(:project) { create(:project) } + let(:commit) { project.commit } + subject { described_class.record(commit, project.creator.id) } + + it 'creates a comment on the commit' do + note = subject.create_reply('Test') + expect(note.in_reply_to?(commit)).to be_truthy + end + end + + context 'for commit comment' do + let(:note) { create(:note_on_commit) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a comment on the commit' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for commit diff discussion' do + let(:note) { create(:diff_note_on_commit) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'for commit non-diff discussion' do + let(:note) { create(:discussion_note_on_commit) } + subject { described_class.record_note(note, note.author.id) } + + it 'creates a reply on the discussion' do + new_note = subject.create_reply('Test') + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 6f7b9c2388a..9de16c41e94 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -315,7 +315,7 @@ describe User, models: true do end describe "Respond to" do - it { is_expected.to respond_to(:is_admin?) } + it { is_expected.to respond_to(:admin?) } it { is_expected.to respond_to(:name) } it { is_expected.to respond_to(:private_token) } it { is_expected.to respond_to(:external?) } @@ -586,7 +586,7 @@ describe User, models: true do describe 'normal user' do let(:user) { create(:user, name: 'John Smith') } - it { expect(user.is_admin?).to be_falsey } + it { expect(user.admin?).to be_falsey } it { expect(user.require_ssh_key?).to be_truthy } it { expect(user.can_create_group?).to be_truthy } it { expect(user.can_create_project?).to be_truthy } diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb index 7a35da38b2b..2190ab0e82e 100644 --- a/spec/presenters/ci/build_presenter_spec.rb +++ b/spec/presenters/ci/build_presenter_spec.rb @@ -57,6 +57,32 @@ describe Ci::BuildPresenter do end end + describe '#status_title' do + context 'when build is auto-canceled' do + before do + expect(build).to receive(:auto_canceled?).and_return(true) + expect(build).to receive(:auto_canceled_by_id).and_return(1) + end + + it 'shows that the build is auto-canceled' do + status_title = presenter.status_title + + expect(status_title).to include('auto-canceled') + expect(status_title).to include('Pipeline #1') + end + end + + context 'when build is not auto-canceled' do + before do + expect(build).to receive(:auto_canceled?).and_return(false) + end + + it 'does not have a status title' do + expect(presenter.status_title).to be_nil + end + end + end + describe 'quack like a Ci::Build permission-wise' do context 'user is not allowed' do let(:project) { build_stubbed(:empty_project, public_builds: false) } diff --git a/spec/presenters/ci/pipeline_presenter_spec.rb b/spec/presenters/ci/pipeline_presenter_spec.rb new file mode 100644 index 00000000000..9134d1cc31c --- /dev/null +++ b/spec/presenters/ci/pipeline_presenter_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Ci::PipelinePresenter do + let(:project) { create(:empty_project) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + subject(:presenter) do + described_class.new(pipeline) + end + + it 'inherits from Gitlab::View::Presenter::Delegated' do + expect(described_class.superclass).to eq(Gitlab::View::Presenter::Delegated) + end + + describe '#initialize' do + it 'takes a pipeline and optional params' do + expect { presenter }.not_to raise_error + end + + it 'exposes pipeline' do + expect(presenter.pipeline).to eq(pipeline) + end + + it 'forwards missing methods to pipeline' do + expect(presenter.ref).to eq(pipeline.ref) + end + end + + describe '#status_title' do + context 'when pipeline is auto-canceled' do + before do + expect(pipeline).to receive(:auto_canceled?).and_return(true) + expect(pipeline).to receive(:auto_canceled_by_id).and_return(1) + end + + it 'shows that the pipeline is auto-canceled' do + status_title = presenter.status_title + + expect(status_title).to include('auto-canceled') + expect(status_title).to include('Pipeline #1') + end + end + + context 'when pipeline is not auto-canceled' do + before do + expect(pipeline).to receive(:auto_canceled?).and_return(false) + end + + it 'does not have a status title' do + expect(presenter.status_title).to be_nil + end + end + end +end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 2b27ce6390a..551aae7d701 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -840,7 +840,7 @@ describe API::Issues, api: true do end context 'resolving discussions' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb index 28fab2011a5..393bf076616 100644 --- a/spec/requests/api/session_spec.rb +++ b/spec/requests/api/session_spec.rb @@ -13,7 +13,7 @@ describe API::Session, api: true do expect(json_response['email']).to eq(user.email) expect(json_response['private_token']).to eq(user.private_token) - expect(json_response['is_admin']).to eq(user.is_admin?) + expect(json_response['is_admin']).to eq(user.admin?) expect(json_response['can_create_project']).to eq(user.can_create_project?) expect(json_response['can_create_group']).to eq(user.can_create_group?) end @@ -37,7 +37,7 @@ describe API::Session, api: true do expect(json_response['email']).to eq user.email expect(json_response['private_token']).to eq user.private_token - expect(json_response['is_admin']).to eq user.is_admin? + expect(json_response['is_admin']).to eq user.admin? expect(json_response['can_create_project']).to eq user.can_create_project? expect(json_response['can_create_group']).to eq user.can_create_group? end @@ -50,7 +50,7 @@ describe API::Session, api: true do expect(json_response['email']).to eq user.email expect(json_response['private_token']).to eq user.private_token - expect(json_response['is_admin']).to eq user.is_admin? + expect(json_response['is_admin']).to eq user.admin? expect(json_response['can_create_project']).to eq user.can_create_project? expect(json_response['can_create_group']).to eq user.can_create_group? end diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index b1b398a897e..91d9057075f 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -824,7 +824,7 @@ describe API::V3::Issues, api: true do end context 'resolving issues in a merge request' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } before do diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index d2f0337c260..fa5014cee07 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -9,72 +9,140 @@ describe Ci::CreatePipelineService, services: true do end describe '#execute' do - def execute(params) + def execute_service(after: project.commit.id, message: 'Message', ref: 'refs/heads/master') + params = { ref: ref, + before: '00000000', + after: after, + commits: [{ message: message }] } + described_class.new(project, user, params).execute end context 'valid params' do - let(:pipeline) do - execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: "Message" }]) + let(:pipeline) { execute_service } + + let(:pipeline_on_previous_commit) do + execute_service( + after: previous_commit_sha_from_ref('master') + ) end it { expect(pipeline).to be_kind_of(Ci::Pipeline) } it { expect(pipeline).to be_valid } - it { expect(pipeline).to be_persisted } it { expect(pipeline).to eq(project.pipelines.last) } it { expect(pipeline).to have_attributes(user: user) } + it { expect(pipeline).to have_attributes(status: 'pending') } it { expect(pipeline.builds.first).to be_kind_of(Ci::Build) } + + context 'auto-cancel enabled' do + before do + project.update(auto_cancel_pending_pipelines: 'enabled') + end + + it 'does not cancel HEAD pipeline' do + pipeline + pipeline_on_previous_commit + + expect(pipeline.reload).to have_attributes(status: 'pending', auto_canceled_by_id: nil) + end + + it 'auto cancel pending non-HEAD pipelines' do + pipeline_on_previous_commit + pipeline + + expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: pipeline.id) + end + + it 'does not cancel running outdated pipelines' do + pipeline_on_previous_commit.run + execute_service + + expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'running', auto_canceled_by_id: nil) + end + + it 'cancel created outdated pipelines' do + pipeline_on_previous_commit.update(status: 'created') + pipeline + + expect(pipeline_on_previous_commit.reload).to have_attributes(status: 'canceled', auto_canceled_by_id: pipeline.id) + end + + it 'does not cancel pipelines from the other branches' do + pending_pipeline = execute_service( + ref: 'refs/heads/feature', + after: previous_commit_sha_from_ref('feature') + ) + pipeline + + expect(pending_pipeline.reload).to have_attributes(status: 'pending', auto_canceled_by_id: nil) + end + end + + context 'auto-cancel disabled' do + before do + project.update(auto_cancel_pending_pipelines: 'disabled') + end + + it 'does not auto cancel pending non-HEAD pipelines' do + pipeline_on_previous_commit + pipeline + + expect(pipeline_on_previous_commit.reload) + .to have_attributes(status: 'pending', auto_canceled_by_id: nil) + end + end + + def previous_commit_sha_from_ref(ref) + project.commit(ref).parent.sha + end end context "skip tag if there is no build for it" do it "creates commit if there is appropriate job" do - result = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: "Message" }]) - expect(result).to be_persisted + expect(execute_service).to be_persisted end it "creates commit if there is no appropriate job but deploy job has right ref setting" do config = YAML.dump({ deploy: { script: "ls", only: ["master"] } }) stub_ci_pipeline_yaml_file(config) - result = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: "Message" }]) - expect(result).to be_persisted + expect(execute_service).to be_persisted end end it 'skips creating pipeline for refs without .gitlab-ci.yml' do stub_ci_pipeline_yaml_file(nil) - result = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: 'Message' }]) - expect(result).not_to be_persisted + expect(execute_service).not_to be_persisted expect(Ci::Pipeline.count).to eq(0) end - it 'fails commits if yaml is invalid' do - message = 'message' - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } - stub_ci_pipeline_yaml_file('invalid: file: file') - commits = [{ message: message }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq('failed') - expect(pipeline.yaml_errors).not_to be_nil + shared_examples 'a failed pipeline' do + it 'creates failed pipeline' do + stub_ci_pipeline_yaml_file(ci_yaml) + + pipeline = execute_service(message: message) + + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq('failed') + expect(pipeline.yaml_errors).not_to be_nil + end + end + + context 'when yaml is invalid' do + let(:ci_yaml) { 'invalid: file: fiile' } + let(:message) { 'Message' } + + it_behaves_like 'a failed pipeline' + + context 'when receive git commit' do + before do + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } + end + + it_behaves_like 'a failed pipeline' + end end context 'when commit contains a [ci skip] directive' do @@ -97,11 +165,7 @@ describe Ci::CreatePipelineService, services: true do ci_messages.each do |ci_message| it "skips builds creation if the commit message is #{ci_message}" do - commits = [{ message: ci_message }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) + pipeline = execute_service(message: ci_message) expect(pipeline).to be_persisted expect(pipeline.builds.any?).to be false @@ -109,58 +173,34 @@ describe Ci::CreatePipelineService, services: true do end end - it "does not skips builds creation if there is no [ci skip] or [skip ci] tag in commit message" do - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { "some message" } + shared_examples 'creating a pipeline' do + it 'does not skip pipeline creation' do + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { commit_message } - commits = [{ message: "some message" }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) + pipeline = execute_service(message: commit_message) - expect(pipeline).to be_persisted - expect(pipeline.builds.first.name).to eq("rspec") + expect(pipeline).to be_persisted + expect(pipeline.builds.first.name).to eq("rspec") + end end - it "does not skip builds creation if the commit message is nil" do - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { nil } - - commits = [{ message: nil }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) + context 'when commit message does not contain [ci skip] nor [skip ci]' do + let(:commit_message) { 'some message' } - expect(pipeline).to be_persisted - expect(pipeline.builds.first.name).to eq("rspec") + it_behaves_like 'creating a pipeline' end - it "fails builds creation if there is [ci skip] tag in commit message and yaml is invalid" do - stub_ci_pipeline_yaml_file('invalid: file: fiile') - commits = [{ message: message }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) + context 'when commit message is nil' do + let(:commit_message) { nil } - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("failed") - expect(pipeline.yaml_errors).not_to be_nil + it_behaves_like 'creating a pipeline' end - end - it "creates commit with failed status if yaml is invalid" do - stub_ci_pipeline_yaml_file('invalid: file') - commits = [{ message: "some message" }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) - - expect(pipeline).to be_persisted - expect(pipeline.status).to eq("failed") - expect(pipeline.builds.any?).to be false + context 'when there is [ci skip] tag in commit message and yaml is invalid' do + let(:ci_yaml) { 'invalid: file: fiile' } + + it_behaves_like 'a failed pipeline' + end end context 'when there are no jobs for this pipeline' do @@ -170,10 +210,7 @@ describe Ci::CreatePipelineService, services: true do end it 'does not create a new pipeline' do - result = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: 'some msg' }]) + result = execute_service expect(result).not_to be_persisted expect(Ci::Build.all).to be_empty @@ -188,10 +225,7 @@ describe Ci::CreatePipelineService, services: true do end it 'does not create a new pipeline' do - result = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: 'some msg' }]) + result = execute_service expect(result).to be_persisted expect(result.manual_actions).not_to be_empty @@ -205,10 +239,7 @@ describe Ci::CreatePipelineService, services: true do end it 'creates the environment' do - result = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: [{ message: 'some msg' }]) + result = execute_service expect(result).to be_persisted expect(Environment.find_by(name: "review/master")).not_to be_nil diff --git a/spec/services/ci/expire_pipeline_cache_service_spec.rb b/spec/services/ci/expire_pipeline_cache_service_spec.rb index 3c735872c30..166c6dfc93e 100644 --- a/spec/services/ci/expire_pipeline_cache_service_spec.rb +++ b/spec/services/ci/expire_pipeline_cache_service_spec.rb @@ -16,5 +16,12 @@ describe Ci::ExpirePipelineCacheService, services: true do subject.execute(pipeline) end + + it 'updates the cached status for a project' do + expect(Gitlab::Cache::Ci::ProjectPipelineStatus).to receive(:update_for_pipeline). + with(pipeline) + + subject.execute(pipeline) + end end end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 8567817147b..b2d37657770 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -16,20 +16,21 @@ describe Ci::RetryBuildService, :services do %i[id status user token coverage trace runner artifacts_expire_at artifacts_file artifacts_metadata artifacts_size created_at updated_at started_at finished_at queued_at erased_by - erased_at].freeze + erased_at auto_canceled_by].freeze IGNORE_ACCESSORS = %i[type lock_version target_url base_tags commit_id deployments erased_by_id last_deployment project_id runner_id tag_taggings taggings tags trigger_request_id - user_id].freeze + user_id auto_canceled_by_id].freeze shared_examples 'build duplication' do let(:build) do create(:ci_build, :failed, :artifacts_expired, :erased, :queued, :coverage, :tags, :allowed_to_fail, :on_tag, :teardown_environment, :triggered, :trace, - description: 'some build', pipeline: pipeline) + description: 'some build', pipeline: pipeline, + auto_canceled_by: create(:ci_empty_pipeline)) end describe 'clone accessors' do diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb index 12c3cdf28c6..ab8df7b74cd 100644 --- a/spec/services/discussions/resolve_service_spec.rb +++ b/spec/services/discussions/resolve_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Discussions::ResolveService do describe '#execute' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:project) { merge_request.project } let(:merge_request) { discussion.noteable } let(:user) { create(:user) } @@ -41,7 +41,7 @@ describe Discussions::ResolveService do end it 'can resolve multiple discussions at once' do - other_discussion = Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: discussion.noteable, project: discussion.noteable.source_project)]).first + other_discussion = create(:diff_note_on_merge_request, noteable: discussion.noteable, project: discussion.noteable.source_project).to_discussion service.execute([discussion, other_discussion]) diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 17990f41b3b..55d635235b0 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -11,7 +11,7 @@ describe Issues::BuildService, services: true do context 'for a single discussion' do describe '#execute' do let(:merge_request) { create(:merge_request, title: "Hello world", source_project: project) } - let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done")]) } + let(:discussion) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done").to_discussion } let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id) } it 'references the noteable title in the issue title' do @@ -47,7 +47,7 @@ describe Issues::BuildService, services: true do let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) } it 'mentions the author of the note' do - discussion = Discussion.new([create(:diff_note_on_merge_request, author: create(:user, username: 'author'))]) + discussion = create(:diff_note_on_merge_request, author: create(:user, username: 'author')).to_discussion expect(service.item_for_discussion(discussion)).to include('@author') end @@ -60,7 +60,7 @@ describe Issues::BuildService, services: true do note_result = " > This is a string\n"\ " > > with a blockquote\n"\ " > > > That has a quote\n" - discussion = Discussion.new([create(:diff_note_on_merge_request, note: note_text)]) + discussion = create(:diff_note_on_merge_request, note: note_text).to_discussion expect(service.item_for_discussion(discussion)).to include(note_result) end end @@ -91,25 +91,23 @@ describe Issues::BuildService, services: true do end describe 'with multiple discussions' do - before do - create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) - end + let!(:diff_note) { create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) } it 'mentions all the authors in the description' do - authors = merge_request.diff_discussions.map(&:author) + authors = merge_request.resolvable_discussions.map(&:author) expect(issue.description).to include(*authors.map(&:to_reference)) end it 'has a link for each unresolved discussion in the description' do - notes = merge_request.diff_discussions.map(&:first_note) + notes = merge_request.resolvable_discussions.map(&:first_note) links = notes.map { |note| Gitlab::UrlBuilder.build(note) } expect(issue.description).to include(*links) end it 'mentions additional notes' do - create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, line_number: 15) + create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, in_reply_to: diff_note) expect(issue.description).to include('(+2 comments)') end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 776cbc4296b..80bfb731550 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -141,7 +141,7 @@ describe Issues::CreateService, services: true do it_behaves_like 'new issuable record that supports slash commands' context 'resolving discussions' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index 3a72f92383c..4a4929daefc 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -18,7 +18,7 @@ describe DummyService, services: true do end describe "for resolving discussions" do - let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) } + let(:discussion) { create(:diff_note_on_merge_request, project: project, note: "Almost done").to_discussion } let(:merge_request) { discussion.noteable } let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb new file mode 100644 index 00000000000..f9dd5541b10 --- /dev/null +++ b/spec/services/notes/build_service_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Notes::BuildService, services: true do + let(:note) { create(:discussion_note_on_issue) } + let(:project) { note.project } + let(:author) { note.author } + + describe '#execute' do + context 'when in_reply_to_discussion_id is specified' do + context 'when a note with that original discussion ID exists' do + it 'sets the note up to be in reply to that note' do + new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'when a note with that discussion ID exists' do + it 'sets the note up to be in reply to that note' do + new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: note.discussion_id).execute + expect(new_note).to be_valid + expect(new_note.in_reply_to?(note)).to be_truthy + end + end + + context 'when no note with that discussion ID exists' do + it 'sets an error' do + new_note = described_class.new(project, author, note: 'Test', in_reply_to_discussion_id: 'foo').execute + expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found') + end + end + end + + it 'builds a note without saving it' do + new_note = described_class.new(project, author, noteable_type: note.noteable_type, noteable_id: note.noteable_id, note: 'Test').execute + expect(new_note).to be_valid + expect(new_note).not_to be_persisted + end + end +end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index e3146a56495..617c8eaf3d5 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -439,7 +439,7 @@ describe NotificationService, services: true do notification.new_note(note) - expect(SentNotification.last.position).to eq(note.position) + expect(SentNotification.last.in_reply_to_discussion_id).to eq(note.discussion_id) end end end diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb new file mode 100644 index 00000000000..62bdd49a4d7 --- /dev/null +++ b/spec/services/protected_branches/update_service_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe ProtectedBranches::UpdateService, services: true do + let(:protected_branch) { create(:protected_branch) } + let(:project) { protected_branch.project } + let(:user) { project.owner } + let(:params) { { name: 'new protected branch name' } } + + describe '#execute' do + subject(:service) { described_class.new(project, user, params) } + + it 'updates a protected branch' do + result = service.execute(protected_branch) + + expect(result.reload.name).to eq(params[:name]) + end + + context 'without admin_project permissions' do + let(:user) { create(:user) } + + it "raises error" do + expect{ service.execute(protected_branch) }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + end +end diff --git a/spec/services/protected_tags/create_service_spec.rb b/spec/services/protected_tags/create_service_spec.rb new file mode 100644 index 00000000000..d91a58e8de5 --- /dev/null +++ b/spec/services/protected_tags/create_service_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe ProtectedTags::CreateService, services: true do + let(:project) { create(:empty_project) } + let(:user) { project.owner } + let(:params) do + { + name: 'master', + create_access_levels_attributes: [{ access_level: Gitlab::Access::MASTER }] + } + end + + describe '#execute' do + subject(:service) { described_class.new(project, user, params) } + + it 'creates a new protected tag' do + expect { service.execute }.to change(ProtectedTag, :count).by(1) + expect(project.protected_tags.last.create_access_levels.map(&:access_level)).to eq([Gitlab::Access::MASTER]) + end + end +end diff --git a/spec/services/protected_tags/update_service_spec.rb b/spec/services/protected_tags/update_service_spec.rb new file mode 100644 index 00000000000..e78fde4c48d --- /dev/null +++ b/spec/services/protected_tags/update_service_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe ProtectedTags::UpdateService, services: true do + let(:protected_tag) { create(:protected_tag) } + let(:project) { protected_tag.project } + let(:user) { project.owner } + let(:params) { { name: 'new protected tag name' } } + + describe '#execute' do + subject(:service) { described_class.new(project, user, params) } + + it 'updates a protected tag' do + result = service.execute(protected_tag) + + expect(result.reload.name).to eq(params[:name]) + end + + context 'without admin_project permissions' do + let(:user) { create(:user) } + + it "raises error" do + expect{ service.execute(protected_tag) }.to raise_error(Gitlab::Access::AccessDeniedError) + end + end + end +end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5ec1ed8237b..42d63a9f9ba 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -796,7 +796,7 @@ describe SystemNoteService, services: true do end describe '.discussion_continued_in_issue' do - let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } + let(:discussion) { create(:diff_note_on_merge_request).to_discussion } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } let(:issue) { create(:issue, project: project) } diff --git a/spec/support/slash_commands_helpers.rb b/spec/support/slash_commands_helpers.rb index 0d91fe5fd5d..4bfe481115f 100644 --- a/spec/support/slash_commands_helpers.rb +++ b/spec/support/slash_commands_helpers.rb @@ -3,7 +3,7 @@ module SlashCommandsHelpers Sidekiq::Testing.fake! do page.within('.js-main-target-form') do fill_in 'note[note]', with: text - find('.comment-btn').trigger('click') + find('.js-comment-submit-button').trigger('click') end end end diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb index 52f4fabdc47..01bc80f957e 100644 --- a/spec/support/time_tracking_shared_examples.rb +++ b/spec/support/time_tracking_shared_examples.rb @@ -77,6 +77,6 @@ end def submit_time(slash_command) fill_in 'note[note]', with: slash_command - find('.comment-btn').trigger('click') + find('.js-comment-submit-button').trigger('click') wait_for_ajax end diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb index d95baddf546..b369dcbb305 100644 --- a/spec/tasks/gitlab/gitaly_rake_spec.rb +++ b/spec/tasks/gitlab/gitaly_rake_spec.rb @@ -75,4 +75,36 @@ describe 'gitlab:gitaly namespace rake task' do end end end + + describe 'storage_config' do + it 'prints storage configuration in a TOML format' do + config = { + 'default' => { 'path' => '/path/to/default' }, + 'nfs_01' => { 'path' => '/path/to/nfs_01' }, + } + allow(Gitlab.config.repositories).to receive(:storages).and_return(config) + + orig_stdout = $stdout + $stdout = StringIO.new + + header = '' + Timecop.freeze do + header = <<~TOML + # Gitaly storage configuration generated from #{Gitlab.config.source} on #{Time.current.to_s(:long)} + # This is in TOML format suitable for use in Gitaly's config.toml file. + TOML + run_rake_task('gitlab:gitaly:storage_config') + end + + output = $stdout.string + $stdout = orig_stdout + + expect(output).to include(header) + + parsed_output = TOML.parse(output) + config.each do |name, params| + expect(parsed_output['storage']).to include({ 'name' => name, 'path' => params['path'] }) + end + end + end end diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb index 55b64808fb3..0f39df0f250 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/builds/show.html.haml_spec.rb @@ -9,7 +9,7 @@ describe 'projects/builds/show', :view do end before do - assign(:build, build) + assign(:build, build.present) assign(:project, project) allow(view).to receive(:can?).and_return(true) diff --git a/spec/views/projects/notes/_form.html.haml_spec.rb b/spec/views/projects/notes/_form.html.haml_spec.rb index b61f016967f..a364f9bce92 100644 --- a/spec/views/projects/notes/_form.html.haml_spec.rb +++ b/spec/views/projects/notes/_form.html.haml_spec.rb @@ -4,7 +4,7 @@ describe 'projects/notes/_form' do include Devise::Test::ControllerHelpers let(:user) { create(:user) } - let(:project) { create(:empty_project) } + let(:project) { create(:project, :repository) } before do project.team << [user, :master] @@ -20,7 +20,7 @@ describe 'projects/notes/_form' do context "with a note on #{noteable}" do let(:note) { build(:"note_on_#{noteable}", project: project) } - it 'says that only markdown is supported, not slash commands' do + it 'says that markdown and slash commands are supported' do expect(rendered).to have_content('Markdown and slash commands are supported') end end diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb index dca78dec6df..bb39ec8efbf 100644 --- a/spec/views/projects/pipelines/show.html.haml_spec.rb +++ b/spec/views/projects/pipelines/show.html.haml_spec.rb @@ -5,7 +5,13 @@ describe 'projects/pipelines/show' do let(:user) { create(:user) } let(:project) { create(:project, :repository) } - let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, user: user) } + + let(:pipeline) do + create(:ci_empty_pipeline, + project: project, + sha: project.commit.id, + user: user) + end before do controller.prepend_view_path('app/views/projects') @@ -21,7 +27,7 @@ describe 'projects/pipelines/show' do create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3) assign(:project, project) - assign(:pipeline, pipeline) + assign(:pipeline, pipeline.present(current_user: user)) assign(:commit, project.commit) allow(view).to receive(:can?).and_return(true) diff --git a/spec/workers/trigger_schedule_worker_spec.rb b/spec/workers/trigger_schedule_worker_spec.rb index 151e1c2f7b9..861bed4442e 100644 --- a/spec/workers/trigger_schedule_worker_spec.rb +++ b/spec/workers/trigger_schedule_worker_spec.rb @@ -8,24 +8,39 @@ describe TriggerScheduleWorker do end context 'when there is a scheduled trigger within next_run_at' do + let(:next_run_at) { 2.days.ago } + + let!(:trigger_schedule) do + create(:ci_trigger_schedule, :nightly) + end + before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - time_future = Time.now + 10.days - allow(Time).to receive(:now).and_return(time_future) - @next_time = Gitlab::Ci::CronParser.new(trigger_schedule.cron, trigger_schedule.cron_timezone).next_time_from(time_future) + trigger_schedule.update_column(:next_run_at, next_run_at) end it 'creates a new trigger request' do - expect { worker.perform }.to change { Ci::TriggerRequest.count }.by(1) + expect { worker.perform }.to change { Ci::TriggerRequest.count } end it 'creates a new pipeline' do - expect { worker.perform }.to change { Ci::Pipeline.count }.by(1) + expect { worker.perform }.to change { Ci::Pipeline.count } expect(Ci::Pipeline.last).to be_pending end it 'updates next_run_at' do - expect { worker.perform }.to change { Ci::TriggerSchedule.last.next_run_at }.to(@next_time) + worker.perform + + expect(trigger_schedule.reload.next_run_at).not_to eq(next_run_at) + end + + context 'inactive schedule' do + before do + trigger_schedule.update(active: false) + end + + it 'does not create a new trigger' do + expect { worker.perform }.not_to change { Ci::TriggerRequest.count } + end end end @@ -43,8 +58,8 @@ describe TriggerScheduleWorker do context 'when next_run_at is nil' do before do - trigger_schedule = create(:ci_trigger_schedule, :nightly) - trigger_schedule.update_attribute(:next_run_at, nil) + schedule = create(:ci_trigger_schedule, :nightly) + schedule.update_column(:next_run_at, nil) end it 'does not create a new pipeline' do diff --git a/yarn.lock b/yarn.lock index 9f2b8fe3d6e..2434b3a8a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,7 +25,7 @@ acorn-jsx@^3.0.0: dependencies: acorn "^3.0.4" -acorn@4.0.4, acorn@^4.0.4: +acorn@4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.4.tgz#17a8d6a7a6c4ef538b814ec9abac2779293bf30a" @@ -33,7 +33,7 @@ acorn@^3.0.4: version "3.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" -acorn@^4.0.11, acorn@^4.0.3: +acorn@^4.0.11, acorn@^4.0.3, acorn@^4.0.4: version "4.0.11" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0" @@ -184,7 +184,7 @@ async-each@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" -async@0.2.x, async@~0.2.6: +async@0.2.x: version "0.2.10" resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" @@ -1809,7 +1809,7 @@ events@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" -eventsource@~0.1.6: +eventsource@0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-0.1.6.tgz#0acede849ed7dd1ccc32c811bb11b944d4f29232" dependencies: @@ -2309,9 +2309,9 @@ http-errors@~1.5.0, http-errors@~1.5.1: setprototypeof "1.0.2" statuses ">= 1.3.1 < 2" -http-proxy-middleware@~0.17.1: - version "0.17.3" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.3.tgz#940382147149b856084f5534752d5b5a8168cd1d" +http-proxy-middleware@~0.17.4: + version "0.17.4" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833" dependencies: http-proxy "^1.16.2" is-glob "^3.1.0" @@ -3690,7 +3690,7 @@ read-pkg@^1.0.0: normalize-package-data "^2.3.2" path-type "^1.0.0" -"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.1.0, readable-stream@^2.2.2: +"readable-stream@^2.0.0 || ^1.1.13", readable-stream@^2.0.2, readable-stream@^2.1.0, readable-stream@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.2.tgz#a9e6fec3c7dda85f8bb1b3ba7028604556fc825e" dependencies: @@ -3702,16 +3702,7 @@ read-pkg@^1.0.0: string_decoder "~0.10.x" util-deprecate "~1.0.1" -readable-stream@~1.0.2: - version "1.0.34" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.1" - isarray "0.0.1" - string_decoder "~0.10.x" - -readable-stream@~2.0.0, readable-stream@~2.0.6: +readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@~2.0.0, readable-stream@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" dependencies: @@ -3722,6 +3713,15 @@ readable-stream@~2.0.0, readable-stream@~2.0.6: string_decoder "~0.10.x" util-deprecate "~1.0.1" +readable-stream@~1.0.2: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + readable-stream@~2.1.4: version "2.1.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0" @@ -4062,12 +4062,12 @@ socket.io@1.7.2: socket.io-client "1.7.2" socket.io-parser "2.3.1" -sockjs-client@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.1.tgz#284843e9a9784d7c474b1571b3240fca9dda4bb0" +sockjs-client@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.1.2.tgz#f0212a8550e4c9468c8cceaeefd2e3493c033ad5" dependencies: debug "^2.2.0" - eventsource "~0.1.6" + eventsource "0.1.6" faye-websocket "~0.11.0" inherits "^2.0.1" json3 "^3.3.2" @@ -4080,6 +4080,10 @@ sockjs@0.3.18: faye-websocket "^0.10.0" uuid "^2.0.2" +source-list-map@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-1.1.1.tgz#1a33ac210ca144d1e561f906ebccab5669ff4cb4" + source-list-map@~0.1.7: version "0.1.8" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106" @@ -4406,14 +4410,14 @@ typedarray@^0.0.6, typedarray@~0.0.5: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -uglify-js@^2.6, uglify-js@^2.7.5: - version "2.7.5" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.5.tgz#4612c0c7baaee2ba7c487de4904ae122079f2ca8" +uglify-js@^2.6, uglify-js@^2.8.5: + version "2.8.21" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.21.tgz#1733f669ae6f82fc90c7b25ec0f5c783ee375314" dependencies: - async "~0.2.6" source-map "~0.5.1" - uglify-to-browserify "~1.0.0" yargs "~3.10.0" + optionalDependencies: + uglify-to-browserify "~1.0.0" uglify-to-browserify@~1.0.0: version "1.0.2" @@ -4534,9 +4538,9 @@ vue@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.4.tgz#d0a3a050a80a12356d7950ae5a7b3131048209cc" -watchpack@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.2.1.tgz#01efa80c5c29e5c56ba55d6f5470a35b6402f0b2" +watchpack@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.3.1.tgz#7d8693907b28ce6013e7f3610aa2a1acf07dad87" dependencies: async "^2.1.2" chokidar "^1.4.3" @@ -4572,9 +4576,9 @@ webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.9.0: path-is-absolute "^1.0.0" range-parser "^1.0.3" -webpack-dev-server@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.3.0.tgz#0437704bbd4d941a6e4c061eb3cc232ed7d06101" +webpack-dev-server@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.4.2.tgz#cf595d6b40878452b6d2ad7229056b686f8a16be" dependencies: ansi-html "0.0.7" chokidar "^1.6.0" @@ -4582,28 +4586,35 @@ webpack-dev-server@^2.3.0: connect-history-api-fallback "^1.3.0" express "^4.13.3" html-entities "^1.2.0" - http-proxy-middleware "~0.17.1" + http-proxy-middleware "~0.17.4" opn "4.0.2" portfinder "^1.0.9" serve-index "^1.7.2" sockjs "0.3.18" - sockjs-client "1.1.1" + sockjs-client "1.1.2" spdy "^3.4.1" strip-ansi "^3.0.0" supports-color "^3.1.1" webpack-dev-middleware "^1.9.0" yargs "^6.0.0" -webpack-sources@^0.1.0, webpack-sources@^0.1.4: +webpack-sources@^0.1.0: version "0.1.4" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.1.4.tgz#ccc2c817e08e5fa393239412690bb481821393cd" dependencies: source-list-map "~0.1.7" source-map "~0.5.3" -webpack@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.2.1.tgz#7bb1d72ae2087dd1a4af526afec15eed17dda475" +webpack-sources@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.2.3.tgz#17c62bfaf13c707f9d02c479e0dcdde8380697fb" + dependencies: + source-list-map "^1.1.1" + source-map "~0.5.3" + +webpack@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.3.3.tgz#eecc083c18fb7bf958ea4f40b57a6640c5a0cc78" dependencies: acorn "^4.0.4" acorn-dynamic-import "^2.0.0" @@ -4621,9 +4632,9 @@ webpack@^2.2.1: source-map "^0.5.3" supports-color "^3.1.0" tapable "~0.2.5" - uglify-js "^2.7.5" - watchpack "^1.2.0" - webpack-sources "^0.1.4" + uglify-js "^2.8.5" + watchpack "^1.3.1" + webpack-sources "^0.2.3" yargs "^6.0.0" websocket-driver@>=0.5.1: |