diff options
author | Regis <boudinot.regis@yahoo.com> | 2017-01-15 09:48:40 -0500 |
---|---|---|
committer | Regis <boudinot.regis@yahoo.com> | 2017-01-15 09:48:40 -0500 |
commit | 88c3c8a8150ff0a1acdaf676f07171a50906c2c9 (patch) | |
tree | 2e3cba200e45e5b5dc0833a1566ed51189bc1034 | |
parent | 18774f65a19bdd1be08c3d36916bbca36dfcd2ae (diff) | |
parent | b56e57ec3b131e48030574251175afe311bcf51b (diff) | |
download | gitlab-ce-88c3c8a8150ff0a1acdaf676f07171a50906c2c9.tar.gz |
Merge branch 'master' into pipeline_index_mini_graph_fix
245 files changed, 8355 insertions, 2922 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e0e780e1e6b..68690ff33da 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -233,7 +233,13 @@ spinach 9 10 ruby21: *spinach-knapsack-ruby21 script: - bundle exec $CI_BUILD_NAME -rubocop: *exec +rubocop: + <<: *ruby-static-analysis + <<: *dedicated-runner + stage: test + script: + - bundle exec "rubocop --require rubocop-rspec" + rake haml_lint: *exec rake scss_lint: *exec rake brakeman: *exec diff --git a/.rubocop.yml b/.rubocop.yml index 80eb4a5c19e..bf2b2d8afc2 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -343,10 +343,6 @@ Style/ParenthesesAroundCondition: Style/RedundantParentheses: Enabled: true -# Don't use return where it's not required. -Style/RedundantReturn: - Enabled: true - # Don't use semicolons to terminate expressions. Style/Semicolon: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 11b34fafa2a..6d4d7170fe8 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,55 +1,70 @@ # This configuration was generated by # `rubocop --auto-gen-config --exclude-limit 0` -# on 2016-10-04 13:16:20 +0200 using RuboCop version 0.43.0. +# on 2017-01-11 09:38:25 +0000 using RuboCop version 0.46.0. # 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: 160 +# Offense count: 27 +# Configuration parameters: Include. +# Include: **/Gemfile, **/gems.rb +Bundler/OrderedGems: + Enabled: false + +# Offense count: 175 Lint/AmbiguousRegexpLiteral: Enabled: false -# Offense count: 40 +# Offense count: 53 # Configuration parameters: AllowSafeAssignment. Lint/AssignmentInCondition: Enabled: false -# Offense count: 18 +# Offense count: 1 +Lint/EmptyWhen: + Enabled: false + +# Offense count: 20 Lint/HandleExceptions: Enabled: false -# Offense count: 2 +# Offense count: 1 Lint/Loop: Enabled: false -# Offense count: 19 +# Offense count: 27 Lint/ShadowingOuterLocalVariable: Enabled: false -# Offense count: 9 +# Offense count: 10 # Cop supports --auto-correct. Lint/UnifiedInteger: Enabled: false -# Offense count: 13 +# Offense count: 21 # Cop supports --auto-correct. Lint/UnneededSplatExpansion: Enabled: false -# Offense count: 69 +# Offense count: 82 # Cop supports --auto-correct. # Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments. Lint/UnusedBlockArgument: Enabled: false -# Offense count: 144 +# Offense count: 173 # Cop supports --auto-correct. # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods. Lint/UnusedMethodArgument: Enabled: false -# Offense count: 2 +# Offense count: 93 +# Configuration parameters: CountComments. +Metrics/BlockLength: + Max: 288 + +# Offense count: 3 # Cop supports --auto-correct. Performance/RedundantBlockCall: Enabled: false @@ -59,7 +74,7 @@ Performance/RedundantBlockCall: Performance/RedundantMatch: Enabled: false -# Offense count: 26 +# Offense count: 32 # Cop supports --auto-correct. # Configuration parameters: MaxKeyValuePairs. Performance/RedundantMerge: @@ -69,61 +84,89 @@ Performance/RedundantMerge: RSpec/BeEql: Enabled: false -# Offense count: 20 +# Offense count: 15 # Configuration parameters: CustomIncludeMethods. RSpec/EmptyExampleGroup: Enabled: false -# Offense count: 16 +# Offense count: 24 RSpec/ExpectActual: Enabled: false -# Offense count: 34 +# Offense count: 58 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: implicit, each, example RSpec/HookArgument: Enabled: false -# Offense count: 168 +# Offense count: 12 +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: is_expected, should +RSpec/ImplicitExpect: + Enabled: false + +# Offense count: 237 RSpec/LeadingSubject: Enabled: false -# Offense count: 162 +# Offense count: 253 RSpec/LetSetup: Enabled: false -# Offense count: 10 +# Offense count: 13 RSpec/MessageChain: Enabled: false -# Offense count: 714 +# Offense count: 479 # Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: allow, expect -RSpec/MessageExpectation: +# SupportedStyles: have_received, receive +RSpec/MessageSpies: Enabled: false -# Offense count: 2423 +# Offense count: 3036 RSpec/MultipleExpectations: - Max: 36 + Max: 37 -# Offense count: 1504 +# Offense count: 2133 RSpec/NamedSubject: Enabled: false -# Offense count: 1335 +# Offense count: 1974 # Configuration parameters: MaxNesting. RSpec/NestedGroups: Enabled: false -# Offense count: 99 +# Offense count: 32 +RSpec/RepeatedDescription: + Enabled: false + +# Offense count: 1 +RSpec/SingleArgumentMessageChain: + Enabled: false + +# Offense count: 133 RSpec/SubjectStub: Enabled: false -# Offense count: 64 +# Offense count: 104 +# Cop supports --auto-correct. +# Configuration parameters: Whitelist. +# Whitelist: find_by_sql +Rails/DynamicFindBy: + Enabled: false + +# Offense count: 932 +# Cop supports --auto-correct. +# Configuration parameters: Include. +# Include: spec/**/*, test/**/* +Rails/HttpPositionalArguments: + Enabled: false + +# Offense count: 55 Rails/OutputSafety: Enabled: false -# Offense count: 151 +# Offense count: 182 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: strict, flexible Rails/TimeZone: @@ -136,33 +179,34 @@ Rails/TimeZone: Rails/Validation: Enabled: false -# Offense count: 2 +# Offense count: 8 # Cop supports --auto-correct. +# Configuration parameters: AutoCorrect. Security/JSONLoad: Enabled: false -# Offense count: 284 +# Offense count: 346 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: with_first_parameter, with_fixed_indentation Style/AlignParameters: Enabled: false -# Offense count: 28 +# Offense count: 27 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: always, conditionals Style/AndOr: Enabled: false -# Offense count: 52 +# Offense count: 54 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: percent_q, bare_percent Style/BarePercentLiterals: Enabled: false -# Offense count: 291 +# Offense count: 358 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: braces, no_braces, context_dependent @@ -173,66 +217,73 @@ Style/BracesAroundHashParameters: Style/CaseEquality: Enabled: false -# Offense count: 26 +# Offense count: 37 # Cop supports --auto-correct. Style/ColonMethodCall: Enabled: false -# Offense count: 2 +# Offense count: 4 # Cop supports --auto-correct. # Configuration parameters: Keywords. # Keywords: TODO, FIXME, OPTIMIZE, HACK, REVIEW Style/CommentAnnotation: Enabled: false -# Offense count: 30 +# Offense count: 29 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, SingleLineConditionsOnly. # SupportedStyles: assign_to_condition, assign_inside_condition Style/ConditionalAssignment: Enabled: false -# Offense count: 957 +# Offense count: 1210 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: leading, trailing Style/DotPosition: Enabled: false -# Offense count: 13 +# Offense count: 18 Style/DoubleNegation: Enabled: false -# Offense count: 6 +# Offense count: 7 # Cop supports --auto-correct. Style/EachWithObject: Enabled: false -# Offense count: 26 +# Offense count: 24 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: empty, nil, both Style/EmptyElse: Enabled: false -# Offense count: 3 +# Offense count: 4 # Cop supports --auto-correct. Style/EmptyLiteral: Enabled: false -# Offense count: 140 +# Offense count: 57 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: compact, expanded +Style/EmptyMethod: + Enabled: false + +# Offense count: 147 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment, ForceEqualSignAlignment. Style/ExtraSpacing: Enabled: false -# Offense count: 6 +# Offense count: 8 # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: format, sprintf, percent Style/FormatString: Enabled: false -# Offense count: 201 +# Offense count: 238 # Configuration parameters: MinBodyLength. Style/GuardClause: Enabled: false @@ -241,27 +292,27 @@ Style/GuardClause: Style/IfInsideElse: Enabled: false -# Offense count: 174 +# Offense count: 173 # Cop supports --auto-correct. # Configuration parameters: MaxLineLength. Style/IfUnlessModifier: Enabled: false -# Offense count: 53 +# Offense count: 55 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_brackets Style/IndentArray: Enabled: false -# Offense count: 95 +# Offense count: 101 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, IndentationWidth. # SupportedStyles: special_inside_parentheses, consistent, align_braces Style/IndentHash: Enabled: false -# Offense count: 29 +# Offense count: 41 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: line_count_dependent, lambda, literal @@ -273,16 +324,21 @@ Style/Lambda: Style/LineEndConcatenation: Enabled: false -# Offense count: 15 +# Offense count: 19 # Cop supports --auto-correct. Style/MethodCallParentheses: Enabled: false -# Offense count: 8 +# Offense count: 9 Style/MethodMissing: Enabled: false -# Offense count: 95 +# Offense count: 3 +# Cop supports --auto-correct. +Style/MultilineIfModifier: + Enabled: false + +# Offense count: 179 # Cop supports --auto-correct. Style/MutableConstant: Enabled: false @@ -299,32 +355,32 @@ Style/NestedParenthesizedCalls: Style/Next: Enabled: false -# Offense count: 12 +# Offense count: 19 # Cop supports --auto-correct. # Configuration parameters: EnforcedOctalStyle, SupportedOctalStyles. # SupportedOctalStyles: zero_with_o, zero_only Style/NumericLiteralPrefix: Enabled: false -# Offense count: 53 +# Offense count: 19 # Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. +# Configuration parameters: AutoCorrect, EnforcedStyle, SupportedStyles. # SupportedStyles: predicate, comparison Style/NumericPredicate: Enabled: false -# Offense count: 29 +# Offense count: 34 # Cop supports --auto-correct. Style/ParallelAssignment: Enabled: false -# Offense count: 294 +# Offense count: 417 # Cop supports --auto-correct. # Configuration parameters: PreferredDelimiters. Style/PercentLiteralDelimiters: Enabled: false -# Offense count: 11 +# Offense count: 10 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: lower_case_q, upper_case_q @@ -336,7 +392,7 @@ Style/PercentQLiterals: Style/PerlBackrefs: Enabled: false -# Offense count: 38 +# Offense count: 64 # Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. # NamePrefix: is_, has_, have_ # NamePrefixBlacklist: is_, has_, have_ @@ -344,17 +400,19 @@ Style/PerlBackrefs: Style/PredicateName: Enabled: false -# Offense count: 26 +# Offense count: 33 # Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: short, verbose Style/PreferredHashMethods: Enabled: false -# Offense count: 6 +# Offense count: 8 # Cop supports --auto-correct. Style/Proc: Enabled: false -# Offense count: 22 +# Offense count: 50 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: compact, exploded @@ -371,33 +429,34 @@ Style/RedundantBegin: Style/RedundantException: Enabled: false -# Offense count: 24 +# Offense count: 29 # Cop supports --auto-correct. Style/RedundantFreeze: Enabled: false -# Offense count: 427 +# Offense count: 11 +# Cop supports --auto-correct. +# Configuration parameters: AllowMultipleReturnValues. +Style/RedundantReturn: + Enabled: false + +# Offense count: 359 # Cop supports --auto-correct. Style/RedundantSelf: Enabled: false -# Offense count: 97 +# Offense count: 105 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, AllowInnerSlashes. # SupportedStyles: slashes, percent_r, mixed Style/RegexpLiteral: Enabled: false -# Offense count: 18 +# Offense count: 19 # Cop supports --auto-correct. Style/RescueModifier: Enabled: false -# Offense count: 114 -# Cop supports --auto-correct. -Style/SafeNavigation: - Enabled: false - # Offense count: 7 # Cop supports --auto-correct. Style/SelfAssignment: @@ -405,7 +464,7 @@ Style/SelfAssignment: # Offense count: 2 # Configuration parameters: Methods. -# Methods: {"reduce"=>["a", "e"]}, {"inject"=>["a", "e"]} +# Methods: {"reduce"=>["acc", "elem"]}, {"inject"=>["acc", "elem"]} Style/SingleLineBlockParams: Enabled: false @@ -415,56 +474,63 @@ Style/SingleLineBlockParams: Style/SingleLineMethods: Enabled: false -# Offense count: 125 +# Offense count: 138 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: space, no_space Style/SpaceBeforeBlockBraces: Enabled: false -# Offense count: 10 +# Offense count: 8 # Cop supports --auto-correct. # Configuration parameters: AllowForAlignment. Style/SpaceBeforeFirstArg: Enabled: false -# Offense count: 145 +# Offense count: 37 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: require_no_space, require_space +Style/SpaceInLambdaLiteral: + Enabled: false + +# Offense count: 174 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, EnforcedStyleForEmptyBraces, SpaceBeforeBlockParameters. # SupportedStyles: space, no_space Style/SpaceInsideBlockBraces: Enabled: false -# Offense count: 99 +# Offense count: 115 # Cop supports --auto-correct. Style/SpaceInsideBrackets: Enabled: false -# Offense count: 65 +# Offense count: 77 # Cop supports --auto-correct. Style/SpaceInsideParens: Enabled: false -# Offense count: 7 +# Offense count: 4 # Cop supports --auto-correct. Style/SpaceInsidePercentLiteralDelimiters: Enabled: false -# Offense count: 41 +# Offense count: 53 # Cop supports --auto-correct. # Configuration parameters: SupportedStyles. # SupportedStyles: use_perl_names, use_english_names Style/SpecialGlobalVars: EnforcedStyle: use_perl_names -# Offense count: 31 +# Offense count: 25 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. # SupportedStyles: single_quotes, double_quotes Style/StringLiteralsInInterpolation: Enabled: false -# Offense count: 33 +# Offense count: 54 # Cop supports --auto-correct. # Configuration parameters: IgnoredMethods. # IgnoredMethods: respond_to, define_method @@ -474,18 +540,18 @@ Style/SymbolProc: # Offense count: 5 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles, AllowSafeAssignment. -# SupportedStyles: require_parentheses, require_no_parentheses +# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex Style/TernaryParentheses: Enabled: false -# Offense count: 29 +# Offense count: 36 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyleForMultiline, SupportedStyles. # SupportedStyles: comma, consistent_comma, no_comma Style/TrailingCommaInArguments: Enabled: false -# Offense count: 102 +# Offense count: 150 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyleForMultiline, SupportedStyles. # SupportedStyles: comma, consistent_comma, no_comma @@ -498,12 +564,12 @@ Style/TrailingCommaInLiteral: Style/TrailingUnderscoreVariable: Enabled: false -# Offense count: 76 +# Offense count: 67 # Cop supports --auto-correct. Style/TrailingWhitespace: Enabled: false -# Offense count: 2 +# Offense count: 3 # Cop supports --auto-correct. # Configuration parameters: ExactNameMatch, AllowPredicates, AllowDSLWriters, IgnoreClassMethods, Whitelist. # Whitelist: to_ary, to_a, to_c, to_enum, to_h, to_hash, to_i, to_int, to_io, to_open, to_path, to_proc, to_r, to_regexp, to_str, to_s, to_sym @@ -515,7 +581,7 @@ Style/TrivialAccessors: Style/UnlessElse: Enabled: false -# Offense count: 14 +# Offense count: 17 # Cop supports --auto-correct. Style/UnneededInterpolation: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7cf9f0ce0..cabfef84b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,19 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 8.15.4 (2017-01-09) + +- Make successful pipeline emails off for watchers. !8176 +- Speed up group milestone index by passing group_id to IssuesFinder. !8363 +- Don't instrument 405 Grape calls. !8445 +- Update the gitlab-markup gem to the version 1.5.1. !8509 +- Updated Turbolinks to mitigate potential XSS attacks. +- Re-order update steps in the 8.14 -> 8.15 upgrade guide. +- Re-add Google Cloud Storage as a backup strategy. + ## 8.15.3 (2017-01-06) -- Rename wiki_events to wiki_page_events in project hooks API to avoid errors. !0 (8425) +- Rename wiki_events to wiki_page_events in project hooks API to avoid errors. !8425 - Rename projects wth reserved names. !8234 - Cache project authorizations even when user has access to zero projects. !8327 - Fix a minor grammar error in merge request widget. !8337 @@ -255,6 +265,11 @@ entry. - Whitelist next project names: help, ci, admin, search. !8227 - Adds back CSS for progress-bars. !8237 +## 8.14.6 (2017-01-10) + +- Update the gitlab-markup gem to the version 1.5.1. !8509 +- Updated Turbolinks to mitigate potential XSS attacks. + ## 8.14.5 (2016-12-14) - Moved Leave Project and Leave Group buttons to access_request_buttons from the settings dropdown. !7600 @@ -532,6 +547,11 @@ entry. - Fix "Without projects" filter. !6611 (Ben Bodenmiller) - Fix 404 when visit /projects page +## 8.13.11 (2017-01-10) + +- Update the gitlab-markup gem to the version 1.5.1. !8509 +- Updated Turbolinks to mitigate potential XSS attacks. + ## 8.13.10 (2016-12-14) - API: Memoize the current_user so that sudo can work properly. !8017 @@ -99,18 +99,19 @@ gem 'unf', '~> 0.1.4' gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing -gem 'html-pipeline', '~> 1.11.0' -gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' -gem 'gitlab-markup', '~> 1.5.1' -gem 'redcarpet', '~> 3.3.3' -gem 'RedCloth', '~> 4.3.2' -gem 'rdoc', '~> 4.2' -gem 'org-ruby', '~> 0.9.12' -gem 'creole', '~> 0.5.0' -gem 'wikicloth', '0.8.1' -gem 'asciidoctor', '~> 1.5.2' -gem 'rouge', '~> 2.0' -gem 'truncato', '~> 0.7.8' +gem 'html-pipeline', '~> 1.11.0' +gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' +gem 'gitlab-markup', '~> 1.5.1' +gem 'redcarpet', '~> 3.3.3' +gem 'RedCloth', '~> 4.3.2' +gem 'rdoc', '~> 4.2' +gem 'org-ruby', '~> 0.9.12' +gem 'creole', '~> 0.5.0' +gem 'wikicloth', '0.8.1' +gem 'asciidoctor', '~> 1.5.2' +gem 'asciidoctor-plantuml', '0.0.6' +gem 'rouge', '~> 2.0' +gem 'truncato', '~> 0.7.8' # See https://groups.google.com/forum/#!topic/ruby-security-ann/aSbgDiwb24s # and https://groups.google.com/forum/#!topic/ruby-security-ann/Dy7YiKb_pMM @@ -221,8 +222,7 @@ gem 'chronic_duration', '~> 0.10.6' gem 'sass-rails', '~> 5.0.6' gem 'coffee-rails', '~> 4.1.0' gem 'uglifier', '~> 2.7.2' -gem 'turbolinks', '~> 2.5.0' -gem 'jquery-turbolinks', '~> 2.1.0' +gem 'gitlab-turbolinks-classic', '~> 2.5', '>= 2.5.6' gem 'addressable', '~> 2.3.8' gem 'bootstrap-sass', '~> 3.3.0' @@ -300,8 +300,8 @@ group :development, :test do gem 'spring-commands-spinach', '~> 1.1.0' gem 'spring-commands-teaspoon', '~> 0.0.2' - gem 'rubocop', '~> 0.43.0', require: false - gem 'rubocop-rspec', '~> 1.5.0', require: false + gem 'rubocop', '~> 0.46.0', require: false + gem 'rubocop-rspec', '~> 1.9.1', require: false gem 'scss_lint', '~> 0.47.0', require: false gem 'haml_lint', '~> 0.18.2', require: false gem 'simplecov', '0.12.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 38c52e47299..e2d7f94e571 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -54,6 +54,8 @@ GEM faraday_middleware-multi_json (~> 0.0) oauth2 (~> 1.0) asciidoctor (1.5.3) + asciidoctor-plantuml (0.0.6) + asciidoctor (~> 1.5) ast (2.3.0) attr_encrypted (3.0.3) encryptor (~> 3.0.0) @@ -264,6 +266,8 @@ GEM mime-types (>= 1.16, < 3) posix-spawn (~> 0.3) gitlab-markup (1.5.1) + gitlab-turbolinks-classic (2.5.6) + coffee-rails gitlab_omniauth-ldap (1.2.1) net-ldap (~> 0.9) omniauth (~> 1.0) @@ -370,9 +374,6 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - jquery-turbolinks (2.1.0) - railties (>= 3.1.0) - turbolinks jquery-ui-rails (5.0.5) railties (>= 3.2.16) json (1.8.3) @@ -649,14 +650,14 @@ GEM rspec-retry (0.4.5) rspec-core rspec-support (3.5.0) - rubocop (0.43.0) + rubocop (0.46.0) parser (>= 2.3.1.1, < 3.0) powerpack (~> 0.1) rainbow (>= 1.99.1, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) - rubocop-rspec (1.5.0) - rubocop (>= 0.40.0) + rubocop-rspec (1.9.1) + rubocop (>= 0.42.0) ruby-fogbugz (0.2.1) crack (~> 0.4) ruby-prof (0.16.2) @@ -782,8 +783,6 @@ GEM truncato (0.7.8) htmlentities (~> 4.3.1) nokogiri (~> 1.6.1) - turbolinks (2.5.3) - coffee-rails tzinfo (1.2.2) thread_safe (~> 0.1) u2f (0.2.1) @@ -844,6 +843,7 @@ DEPENDENCIES allocations (~> 1.0) asana (~> 0.4.0) asciidoctor (~> 1.5.2) + asciidoctor-plantuml (= 0.0.6) attr_encrypted (~> 3.0.0) awesome_print (~> 1.2.0) babosa (~> 1.0.2) @@ -893,6 +893,7 @@ DEPENDENCIES github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) + gitlab-turbolinks-classic (~> 2.5, >= 2.5.6) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.2) gollum-rugged_adapter (~> 0.4.2) @@ -911,7 +912,6 @@ DEPENDENCIES jira-ruby (~> 1.1.2) jquery-atwho-rails (~> 1.3.2) jquery-rails (~> 4.1.0) - jquery-turbolinks (~> 2.1.0) jquery-ui-rails (~> 5.0.0) json-schema (~> 2.6.2) jwt @@ -974,8 +974,8 @@ DEPENDENCIES rqrcode-rails3 (~> 0.1.7) rspec-rails (~> 3.5.0) rspec-retry (~> 0.4.5) - rubocop (~> 0.43.0) - rubocop-rspec (~> 1.5.0) + rubocop (~> 0.46.0) + rubocop-rspec (~> 1.9.1) ruby-fogbugz (~> 0.2.1) ruby-prof (~> 0.16.2) rugged (~> 0.24.0) @@ -1010,7 +1010,6 @@ DEPENDENCIES thin (~> 1.7.0) timecop (~> 0.8.0) truncato (~> 0.7.8) - turbolinks (~> 2.5.0) u2f (~> 0.2.1) uglifier (~> 2.7.2) underscore-rails (~> 1.8.0) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index e43afbb4cc9..f0615481ed2 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -58,6 +58,7 @@ /*= require_directory ./extensions */ /*= require_directory ./lib/utils */ /*= require_directory ./u2f */ +/*= require_directory ./droplab */ /*= require_directory . */ /*= require fuzzaldrin-plus */ /*= require es6-promise.auto */ diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index bc13c46443a..fca47002870 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -5,6 +5,7 @@ (function() { var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; var AUTO_SCROLL_OFFSET = 75; + var DOWN_BUILD_TRACE = '#down-build-trace'; this.Build = (function() { Build.interval = null; @@ -26,7 +27,7 @@ this.$autoScrollStatus = $('#autoscroll-status'); this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); this.$upBuildTrace = $('#up-build-trace'); - this.$downBuildTrace = $('#down-build-trace'); + this.$downBuildTrace = $(DOWN_BUILD_TRACE); this.$scrollTopBtn = $('#scroll-top'); this.$scrollBottomBtn = $('#scroll-bottom'); this.$buildRefreshAnimation = $('.js-build-refresh'); @@ -91,6 +92,9 @@ dataType: 'json', success: function(buildData) { $('.js-build-output').html(buildData.trace_html); + if (window.location.hash === DOWN_BUILD_TRACE) { + $("html,body").scrollTop(this.$buildTrace.height()); + } if (removeRefreshStatuses.indexOf(buildData.status) >= 0) { this.$buildRefreshAnimation.remove(); return this.initScrollMonitor(); @@ -105,6 +109,8 @@ dataType: "json", success: (function(_this) { return function(log) { + var pageUrl; + if (log.state) { _this.state = log.state; } @@ -116,7 +122,12 @@ } return _this.checkAutoscroll(); } else if (log.status !== _this.buildStatus) { - return Turbolinks.visit(_this.pageUrl); + pageUrl = _this.pageUrl; + if (_this.$autoScrollStatus.data('state') === 'enabled') { + pageUrl += DOWN_BUILD_TRACE; + } + + return Turbolinks.visit(pageUrl); } }; })(this) diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index 54f13e328bd..99a34651639 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -84,6 +84,9 @@ break; case 'projects:merge_requests:index': case 'projects:issues:index': + if (gl.FilteredSearchManager) { + new gl.FilteredSearchManager(); + } Issuable.init(); new gl.IssuableBulkActions({ prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js new file mode 100644 index 00000000000..ed545ec8748 --- /dev/null +++ b/app/assets/javascripts/droplab/droplab.js @@ -0,0 +1,701 @@ +/* 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.hidden = true; + this.list = list; + this.items = []; + this.getItems(); + this.addEvents(); + this.initialState = list.innerHTML; +}; + +Object.assign(DropDown.prototype, { + getItems: function() { + this.items = [].slice.call(this.list.querySelectorAll('li')); + return this.items; + }, + + 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(data); + }, + + // call render manually on data; + render: function(data){ + // debugger + // empty the list first + var sampleItem; + var newChildren = []; + var toAppend; + + for(var i = 0; i < this.items.length; i++) { + var item = this.items[i]; + sampleItem = item; + if(item.parentNode && item.parentNode.dataset.hasOwnProperty('dynamic')) { + item.parentNode.removeChild(item); + } + } + + newChildren = this.data.map(function(dat){ + var html = utils.t(sampleItem.outerHTML, dat); + var template = document.createElement('div'); + template.innerHTML = html; + // console.log(template.content) + + // 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() { + // debugger + this.list.style.display = 'block'; + this.hidden = false; + }, + + hide: function() { + // debugger + this.list.style.display = 'none'; + this.hidden = true; + }, + + destroy: function() { + if (!this.hidden) { + 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); + w.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) { + 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) { + var inputEvent = new CustomEvent('input.dl', { + detail: { + hook: self, + text: e.target.value, + }, + bubbles: true, + cancelable: true + }); + e.target.dispatchEvent(inputEvent); + self.list.show(); + } + + this.keyup = function keyup(e) { + keyEvent(e, 'keyup.dl'); + } + + this.keydown = function keydown(e) { + keyEvent(e, 'keydown.dl'); + } + + function keyEvent(e, keyEventName){ + 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); + self.list.show(); + } + + 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.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 currentIndex = 0; + var isUpArrow = false; + var isDownArrow = false; + var removeHighlight = function removeHighlight(list) { + var listItems = list.list.querySelectorAll('li'); + for(var i = 0; i < listItems.length; i++) { + listItems[i].classList.remove('dropdown-active'); + } + return listItems; + }; + + var setMenuForArrows = function setMenuForArrows(list) { + var listItems = removeHighlight(list); + if(currentIndex>0){ + if(!listItems[currentIndex-1]){ + currentIndex = currentIndex-1; + } + listItems[currentIndex-1].classList.add('dropdown-active'); + } + }; + + var mousedown = function mousedown(e) { + var list = e.detail.hook.list; + removeHighlight(list); + list.show(); + currentIndex = 0; + isUpArrow = false; + isDownArrow = false; + }; + var selectItem = function selectItem(list) { + var listItems = removeHighlight(list); + var currentItem = listItems[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; + 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; } + 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.tagName !== stopTag && thisTag.tagName !== 'HTML'){ + thisTag = thisTag.parentNode; + } + return thisTag; +}; + +var isDropDownParts = function(target) { + if(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 new file mode 100644 index 00000000000..f20610b3811 --- /dev/null +++ b/app/assets/javascripts/droplab/droplab_ajax.js @@ -0,0 +1,79 @@ +/* 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) { + 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); + return resolve(data); + } else { + return reject([xhr.responseText, xhr.status]); + } + } + }; + xhr.send(); + }); + }, + + init: function init(hook) { + var self = this; + var config = hook.config.droplabAjax; + + 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; + } + + this._loadUrlData(config.endpoint) + .then(function(d) { + if (config.loadingTemplate) { + var dataLoadingTemplate = hook.list.list.querySelector('[data-loading-template]'); + + if (dataLoadingTemplate) { + dataLoadingTemplate.outerHTML = self.listTemplate; + } + } + hook.list[config.method].call(hook.list, d); + }).catch(function(e) { + throw new droplabAjaxException(e.message || e); + }); + }, + + destroy: function() { + } + }; +}); +},{"../window":2}],2:[function(require,module,exports){ +module.exports = function(callback) { + return (function() { + callback(this); + }).call(null); +}; + +},{}]},{},[1])(1) +});
\ No newline at end of file diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js new file mode 100644 index 00000000000..af163f76851 --- /dev/null +++ b/app/assets/javascripts/droplab/droplab_ajax_filter.js @@ -0,0 +1,145 @@ +/* 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; + this._loadUrlData(config.endpoint + this.buildParams(params)).then(function(data) { + 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(); + }); + }, + + _loadUrlData: function _loadUrlData(url) { + 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); + return resolve(data); + } else { + return reject([xhr.responseText, xhr.status]); + } + } + }; + xhr.send(); + }); + }, + + 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) +});
\ No newline at end of file diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js new file mode 100644 index 00000000000..41a220831f9 --- /dev/null +++ b/app/assets/javascripts/droplab/droplab_filter.js @@ -0,0 +1,60 @@ +/* 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 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; + }; + } + + matches = data.map(function(o) { + return filterFunction(o, value); + }); + list.render(matches); + }, + + 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); + }, + + destroy: function destroy(){ + this.hookInput.trigger.removeEventListener('keyup.dl', this.keydownWrapper); + } + }; +}); +},{"../window":2}],2:[function(require,module,exports){ +module.exports = function(callback) { + return (function() { + callback(this); + }).call(null); +}; + +},{}]},{},[1])(1) +});
\ No newline at end of file diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 b/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 new file mode 100644 index 00000000000..63c20f57520 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 @@ -0,0 +1,66 @@ +/*= require filtered_search/filtered_search_dropdown */ + +/* global droplabFilter */ + +(() => { + class DropdownHint extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + super(droplab, dropdown, input, filter); + this.config = { + droplabFilter: { + template: 'hint', + filterFunction: gl.DropdownUtils.filterHint, + }, + }; + } + + itemClicked(e) { + const { selected } = e.detail; + + if (selected.tagName === 'LI') { + if (selected.hasAttribute('data-value')) { + this.dismissDropdown(); + } else { + const token = selected.querySelector('.js-filter-hint').innerText.trim(); + const tag = selected.querySelector('.js-filter-tag').innerText.trim(); + + if (tag.length) { + gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', '')); + } + this.dismissDropdown(); + this.dispatchInputEvent(); + } + } + } + + renderContent() { + const dropdownData = [{ + icon: 'fa-pencil', + hint: 'author:', + tag: '<@author>', + }, { + icon: 'fa-user', + hint: 'assignee:', + tag: '<@assignee>', + }, { + icon: 'fa-clock-o', + hint: 'milestone:', + tag: '<%milestone>', + }, { + icon: 'fa-tag', + hint: 'label:', + tag: '<~label>', + }]; + + this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); + this.droplab.setData(this.hookId, dropdownData); + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownHint = DropdownHint; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 new file mode 100644 index 00000000000..f06c3fc9c6f --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 @@ -0,0 +1,44 @@ +/*= require filtered_search/filtered_search_dropdown */ + +/* global droplabAjax */ +/* global droplabFilter */ + +(() => { + class DropdownNonUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter, endpoint, symbol) { + super(droplab, dropdown, input, filter); + this.symbol = symbol; + this.config = { + droplabAjax: { + endpoint, + method: 'setData', + loadingTemplate: this.loadingTemplate, + }, + droplabFilter: { + filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol), + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, (selected) => { + const title = selected.querySelector('.js-data-value').innerText.trim(); + return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`; + }); + } + + renderContent(forceShowList = false) { + this.droplab + .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config); + super.renderContent(forceShowList); + } + + init() { + this.droplab + .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownNonUser = DropdownNonUser; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 new file mode 100644 index 00000000000..e80d266ae89 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 @@ -0,0 +1,53 @@ +/*= require filtered_search/filtered_search_dropdown */ + +/* global droplabAjaxFilter */ + +(() => { + class DropdownUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + super(droplab, dropdown, input, filter); + this.config = { + droplabAjaxFilter: { + endpoint: '/autocomplete/users.json', + searchKey: 'search', + params: { + per_page: 20, + active: true, + project_id: this.getProjectId(), + current_user: true, + }, + searchValueFunction: this.getSearchInput.bind(this), + loadingTemplate: this.loadingTemplate, + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, + selected => selected.querySelector('.dropdown-light-content').innerText.trim()); + } + + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config); + super.renderContent(forceShowList); + } + + getProjectId() { + return this.input.getAttribute('data-project-id'); + } + + getSearchInput() { + const query = this.input.value.trim(); + const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + + return lastToken.value || ''; + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownUser = DropdownUser; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 new file mode 100644 index 00000000000..c27ef3042d1 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 @@ -0,0 +1,79 @@ +(() => { + class DropdownUtils { + static getEscapedText(text) { + let escapedText = text; + const hasSpace = text.indexOf(' ') !== -1; + const hasDoubleQuote = text.indexOf('"') !== -1; + + // Encapsulate value with quotes if it has spaces + // Known side effect: values's with both single and double quotes + // won't escape properly + if (hasSpace) { + if (hasDoubleQuote) { + escapedText = `'${text}'`; + } else { + // Encapsulate singleQuotes or if it hasSpace + escapedText = `"${text}"`; + } + } + + return escapedText; + } + + static filterWithSymbol(filterSymbol, item, query) { + const updatedItem = item; + const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(query); + + if (lastToken !== searchToken) { + const title = updatedItem.title.toLowerCase(); + let value = lastToken.value.toLowerCase(); + + if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) { + value = value.slice(1); + } + + // Eg. filterSymbol = ~ for labels + const matchWithoutSymbol = lastToken.symbol === filterSymbol && title.indexOf(value) !== -1; + const match = title.indexOf(`${lastToken.symbol}${value}`) !== -1; + + updatedItem.droplab_hidden = !match && !matchWithoutSymbol; + } else { + updatedItem.droplab_hidden = false; + } + + return updatedItem; + } + + static filterHint(item, query) { + const updatedItem = item; + let { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + lastToken = lastToken.key || lastToken || ''; + + if (!lastToken || query.split('').last() === ' ') { + updatedItem.droplab_hidden = false; + } else if (lastToken) { + const split = lastToken.split(':'); + const tokenName = split[0].split(' ').last(); + + const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + updatedItem.droplab_hidden = tokenName ? match : false; + } + + return updatedItem; + } + + static setDataValueIfSelected(filter, selected) { + const dataValue = selected.getAttribute('data-value'); + + if (dataValue) { + gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue); + } + + // Return boolean based on whether it was set + return dataValue !== null; + } + } + + window.gl = window.gl || {}; + gl.DropdownUtils = DropdownUtils; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js new file mode 100644 index 00000000000..d188718c5f3 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -0,0 +1,7 @@ + // This is a manifest file that'll be compiled into including all the files listed below. + // Add new JavaScript code in separate files in this directory and they'll automatically + // be included in the compiled file accessible from http://example.com/assets/application.js + // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the + // the compiled file. + // + /*= require_tree . */ diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 new file mode 100644 index 00000000000..886d8113f4a --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 @@ -0,0 +1,102 @@ +(() => { + const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; + + class FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + this.droplab = droplab; + this.hookId = input.getAttribute('data-id'); + this.input = input; + this.filter = filter; + this.dropdown = dropdown; + this.loadingTemplate = `<div class="filter-dropdown-loading"> + <i class="fa fa-spinner fa-spin"></i> + </div>`; + this.bindEvents(); + } + + bindEvents() { + this.itemClickedWrapper = this.itemClicked.bind(this); + this.dropdown.addEventListener('click.dl', this.itemClickedWrapper); + } + + unbindEvents() { + this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper); + } + + getCurrentHook() { + return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null; + } + + itemClicked(e, getValueFunction) { + const { selected } = e.detail; + + if (selected.tagName === 'LI' && selected.innerHTML) { + const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected); + + if (!dataValueSet) { + const value = getValueFunction(selected); + gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value); + } + + this.dismissDropdown(); + } + } + + setAsDropdown() { + this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`); + } + + setOffset(offset = 0) { + this.dropdown.style.left = `${offset}px`; + } + + renderContent(forceShowList = false) { + if (forceShowList && this.getCurrentHook().list.hidden) { + this.getCurrentHook().list.show(); + } + } + + render(forceRenderContent = false, forceShowList = false) { + this.setAsDropdown(); + + const currentHook = this.getCurrentHook(); + const firstTimeInitialized = currentHook === null; + + if (firstTimeInitialized || forceRenderContent) { + this.renderContent(forceShowList); + } else if (currentHook.list.list.id !== this.dropdown.id) { + this.renderContent(forceShowList); + } + } + + dismissDropdown() { + // Focusing on the input will dismiss dropdown + // (default droplab functionality) + this.input.focus(); + } + + dispatchInputEvent() { + // Propogate input change to FilteredSearchDropdownManager + // so that it can determine which dropdowns to open + this.input.dispatchEvent(new Event('input')); + } + + hideDropdown() { + this.getCurrentHook().list.hide(); + } + + resetFilters() { + const hook = this.getCurrentHook(); + const data = hook.list.data; + const results = data.map((o) => { + const updated = o; + updated.droplab_hidden = false; + return updated; + }); + hook.list.render(results); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchDropdown = FilteredSearchDropdown; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 new file mode 100644 index 00000000000..1cd0483877a --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 @@ -0,0 +1,193 @@ +/* global DropLab */ + +(() => { + class FilteredSearchDropdownManager { + constructor() { + this.tokenizer = gl.FilteredSearchTokenizer; + this.filteredSearchInput = document.querySelector('.filtered-search'); + + this.setupMapping(); + + this.cleanupWrapper = this.cleanup.bind(this); + document.addEventListener('page:fetch', this.cleanupWrapper); + } + + cleanup() { + if (this.droplab) { + this.droplab.destroy(); + this.droplab = null; + } + + this.setupMapping(); + + document.removeEventListener('page:fetch', this.cleanupWrapper); + } + + setupMapping() { + this.mapping = { + author: { + reference: null, + gl: 'DropdownUser', + element: document.querySelector('#js-dropdown-author'), + }, + assignee: { + reference: null, + gl: 'DropdownUser', + element: document.querySelector('#js-dropdown-assignee'), + }, + milestone: { + reference: null, + gl: 'DropdownNonUser', + extraArguments: ['milestones.json', '%'], + element: document.querySelector('#js-dropdown-milestone'), + }, + label: { + reference: null, + gl: 'DropdownNonUser', + extraArguments: ['labels.json', '~'], + element: document.querySelector('#js-dropdown-label'), + }, + hint: { + reference: null, + gl: 'DropdownHint', + element: document.querySelector('#js-dropdown-hint'), + }, + }; + } + + static addWordToInput(tokenName, tokenValue = '') { + const input = document.querySelector('.filtered-search'); + const word = `${tokenName}:${tokenValue}`; + + const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(input.value); + const lastSearchToken = searchToken.split(' ').last(); + const lastInputCharacter = input.value[input.value.length - 1]; + const lastInputTrimmedCharacter = input.value.trim()[input.value.trim().length - 1]; + + // Remove the typed tokenName + if (word.indexOf(lastSearchToken) === 0 && searchToken !== '') { + // Remove spaces after the colon + if (lastInputCharacter === ' ' && lastInputTrimmedCharacter === ':') { + input.value = input.value.trim(); + } + + input.value = input.value.slice(0, -1 * lastSearchToken.length); + } else if (lastInputCharacter !== ' ' || (lastToken && lastToken.value[lastToken.value.length - 1] === ' ')) { + // Remove the existing tokenValue + const lastTokenString = `${lastToken.key}:${lastToken.symbol}${lastToken.value}`; + input.value = input.value.slice(0, -1 * lastTokenString.length); + } + + input.value += word; + } + + updateCurrentDropdownOffset() { + this.updateDropdownOffset(this.currentDropdown); + } + + updateDropdownOffset(key) { + if (!this.font) { + this.font = window.getComputedStyle(this.filteredSearchInput).font; + } + + const filterIconPadding = 27; + const offset = gl.text + .getTextWidth(this.filteredSearchInput.value, this.font) + filterIconPadding; + + this.mapping[key].reference.setOffset(offset); + } + + load(key, firstLoad = false) { + const mappingKey = this.mapping[key]; + const glClass = mappingKey.gl; + const element = mappingKey.element; + let forceShowList = false; + + if (!mappingKey.reference) { + const dl = this.droplab; + const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; + const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); + + // Passing glArguments to `new gl[glClass](<arguments>)` + mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))(); + } + + if (firstLoad) { + mappingKey.reference.init(); + } + + if (this.currentDropdown === 'hint') { + // Force the dropdown to show if it was clicked from the hint dropdown + forceShowList = true; + } + + this.updateDropdownOffset(key); + mappingKey.reference.render(firstLoad, forceShowList); + + this.currentDropdown = key; + } + + loadDropdown(dropdownName = '') { + let firstLoad = false; + + if (!this.droplab) { + firstLoad = true; + this.droplab = new DropLab(); + } + + const match = gl.FilteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); + const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key + && this.mapping[match.key]; + const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; + + if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { + const key = match && match.key ? match.key : 'hint'; + this.load(key, firstLoad); + } + } + + setDropdown() { + const { lastToken, searchToken } = this.tokenizer + .processTokens(this.filteredSearchInput.value); + + if (this.filteredSearchInput.value.split('').last() === ' ') { + this.updateCurrentDropdownOffset(); + } + + if (lastToken === searchToken && lastToken !== null) { + // Token is not fully initialized yet because it has no value + // Eg. token = 'label:' + + const split = lastToken.split(':'); + const dropdownName = split[0].split(' ').last(); + this.loadDropdown(split.length > 1 ? dropdownName : ''); + } else if (lastToken) { + // Token has been initialized into an object because it has a value + this.loadDropdown(lastToken.key); + } else { + this.loadDropdown('hint'); + } + } + + resetDropdowns() { + // Force current dropdown to hide + this.mapping[this.currentDropdown].reference.hideDropdown(); + + // Re-Load dropdown + this.setDropdown(); + + // Reset filters for current dropdown + this.mapping[this.currentDropdown].reference.resetFilters(); + + // Reposition dropdown so that it is aligned with cursor + this.updateDropdownOffset(this.currentDropdown); + } + + destroyDroplab() { + this.droplab.destroy(); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 new file mode 100644 index 00000000000..ffd0d7e9cba --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 @@ -0,0 +1,171 @@ +/* global Turbolinks */ + +(() => { + class FilteredSearchManager { + constructor() { + this.filteredSearchInput = document.querySelector('.filtered-search'); + this.clearSearchButton = document.querySelector('.clear-search'); + + if (this.filteredSearchInput) { + this.tokenizer = gl.FilteredSearchTokenizer; + this.dropdownManager = new gl.FilteredSearchDropdownManager(); + + this.bindEvents(); + this.loadSearchParamsFromURL(); + this.dropdownManager.setDropdown(); + + this.cleanupWrapper = this.cleanup.bind(this); + document.addEventListener('page:fetch', this.cleanupWrapper); + } + } + + cleanup() { + this.unbindEvents(); + document.removeEventListener('page:fetch', this.cleanupWrapper); + } + + bindEvents() { + this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); + this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this); + this.checkForEnterWrapper = this.checkForEnter.bind(this); + this.clearSearchWrapper = this.clearSearch.bind(this); + this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); + + this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); + this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); + this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); + this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); + } + + unbindEvents() { + this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); + this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); + this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); + } + + checkForBackspace(e) { + // 8 = Backspace Key + // 46 = Delete Key + if (e.keyCode === 8 || e.keyCode === 46) { + // Reposition dropdown so that it is aligned with cursor + this.dropdownManager.updateCurrentDropdownOffset(); + } + } + + checkForEnter(e) { + if (e.keyCode === 13) { + e.preventDefault(); + + // Prevent droplab from opening dropdown + this.dropdownManager.destroyDroplab(); + + this.search(); + } + } + + toggleClearSearchButton(e) { + if (e.target.value) { + this.clearSearchButton.classList.remove('hidden'); + } else { + this.clearSearchButton.classList.add('hidden'); + } + } + + clearSearch(e) { + e.preventDefault(); + + this.filteredSearchInput.value = ''; + this.clearSearchButton.classList.add('hidden'); + + this.dropdownManager.resetDropdowns(); + } + + loadSearchParamsFromURL() { + const params = gl.utils.getUrlParamsArray(); + const inputValues = []; + + params.forEach((p) => { + const split = p.split('='); + const keyParam = decodeURIComponent(split[0]); + const value = split[1]; + + // Check if it matches edge conditions listed in gl.FilteredSearchTokenKeys + const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(p); + + if (condition) { + inputValues.push(`${condition.tokenKey}:${condition.value}`); + } else { + // Sanitize value since URL converts spaces into + + // Replace before decode so that we know what was originally + versus the encoded + + const sanitizedValue = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : value; + const match = gl.FilteredSearchTokenKeys.searchByKeyParam(keyParam); + + if (match) { + const indexOf = keyParam.indexOf('_'); + const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; + const symbol = match.symbol; + let quotationsToUse = ''; + + if (sanitizedValue.indexOf(' ') !== -1) { + // Prefer ", but use ' if required + quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\''; + } + + inputValues.push(`${sanitizedKey}:${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); + } else if (!match && keyParam === 'search') { + inputValues.push(sanitizedValue); + } + } + }); + + // Trim the last space value + this.filteredSearchInput.value = inputValues.join(' '); + + if (inputValues.length > 0) { + this.clearSearchButton.classList.remove('hidden'); + } + } + + search() { + const paths = []; + const { tokens, searchToken } = this.tokenizer.processTokens(this.filteredSearchInput.value); + const currentState = gl.utils.getParameterByName('state') || 'opened'; + paths.push(`state=${currentState}`); + + tokens.forEach((token) => { + const condition = gl.FilteredSearchTokenKeys + .searchByConditionKeyValue(token.key, token.value.toLowerCase()); + const { param } = gl.FilteredSearchTokenKeys.searchByKey(token.key); + const keyParam = param ? `${token.key}_${param}` : token.key; + let tokenPath = ''; + + if (condition) { + tokenPath = condition.url; + } else { + let tokenValue = token.value; + + if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || + (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { + tokenValue = tokenValue.slice(1, tokenValue.length - 1); + } + + tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; + } + + paths.push(tokenPath); + }); + + if (searchToken) { + paths.push(`search=${encodeURIComponent(searchToken)}`); + } + + Turbolinks.visit(`?scope=all&utf8=✓&${paths.join('&')}`); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchManager = FilteredSearchManager; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 new file mode 100644 index 00000000000..e46373024b6 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 @@ -0,0 +1,83 @@ +(() => { + const tokenKeys = [{ + key: 'author', + type: 'string', + param: 'username', + symbol: '@', + }, { + key: 'assignee', + type: 'string', + param: 'username', + symbol: '@', + }, { + key: 'milestone', + type: 'string', + param: 'title', + symbol: '%', + }, { + key: 'label', + type: 'array', + param: 'name[]', + symbol: '~', + }]; + + const conditions = [{ + url: 'assignee_id=0', + tokenKey: 'assignee', + value: 'none', + }, { + url: 'milestone_title=No+Milestone', + tokenKey: 'milestone', + value: 'none', + }, { + url: 'milestone_title=%23upcoming', + tokenKey: 'milestone', + value: 'upcoming', + }, { + url: 'label_name[]=No+Label', + tokenKey: 'label', + value: 'none', + }]; + + class FilteredSearchTokenKeys { + static get() { + return tokenKeys; + } + + static getConditions() { + return conditions; + } + + static searchByKey(key) { + return tokenKeys.find(tokenKey => tokenKey.key === key) || null; + } + + static searchBySymbol(symbol) { + return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; + } + + static searchByKeyParam(keyParam) { + return tokenKeys.find((tokenKey) => { + let tokenKeyParam = tokenKey.key; + + if (tokenKey.param) { + tokenKeyParam += `_${tokenKey.param}`; + } + + return keyParam === tokenKeyParam; + }) || null; + } + + static searchByConditionUrl(url) { + return conditions.find(condition => condition.url === url) || null; + } + + static searchByConditionKeyValue(key, value) { + return conditions + .find(condition => condition.tokenKey === key && condition.value === value) || null; + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 new file mode 100644 index 00000000000..cf53845a48b --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 @@ -0,0 +1,45 @@ +(() => { + class FilteredSearchTokenizer { + static processTokens(input) { + // Regex extracts `(token):(symbol)(value)` + // Values that start with a double quote must end in a double quote (same for single) + const tokenRegex = /(\w+):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\S+))/g; + const tokens = []; + let lastToken = null; + const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { + let tokenValue = v1 || v2 || v3; + let tokenSymbol = symbol; + + if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { + tokenSymbol = tokenValue; + tokenValue = ''; + } + + tokens.push({ + key, + value: tokenValue || '', + symbol: tokenSymbol || '', + }); + return ''; + }).replace(/\s{2,}/g, ' ').trim() || ''; + + if (tokens.length > 0) { + const last = tokens[tokens.length - 1]; + const lastString = `${last.key}:${last.symbol}${last.value}`; + lastToken = input.lastIndexOf(lastString) === + input.length - lastString.length ? last : searchToken; + } else { + lastToken = searchToken; + } + + return { + tokens, + lastToken, + searchToken, + }; + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchTokenizer = FilteredSearchTokenizer; +})(); diff --git a/app/assets/javascripts/lib/utils/common_utils.js.es6 b/app/assets/javascripts/lib/utils/common_utils.js.es6 index b8d637a9827..0c6a3cc3170 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js.es6 +++ b/app/assets/javascripts/lib/utils/common_utils.js.es6 @@ -124,6 +124,12 @@ return parsedUrl.pathname.charAt(0) === '/' ? parsedUrl.pathname : '/' + parsedUrl.pathname; }; + gl.utils.getUrlParamsArray = function () { + // We can trust that each param has one & since values containing & will be encoded + // Remove the first character of search as it is always ? + return window.location.search.slice(1).split('&'); + }; + gl.utils.isMetaKey = function(e) { return e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; }; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 5066e3282d7..c856a26ae40 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -17,6 +17,21 @@ gl.text.replaceRange = function(s, start, end, substitute) { return s.substring(0, start) + substitute + s.substring(end); }; + gl.text.getTextWidth = function(text, font) { + /** + * Uses canvas.measureText to compute and return the width of the given text of given font in pixels. + * + * @param {String} text The text to be rendered. + * @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana"). + * + * @see http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393 + */ + // re-use canvas object for better performance + var canvas = gl.text.getTextWidth.canvas || (gl.text.getTextWidth.canvas = document.createElement('canvas')); + var context = canvas.getContext('2d'); + context.font = font; + return context.measureText(text).width; + }; gl.text.selectedText = function(text, textarea) { return text.substring(textarea.selectionStart, textarea.selectionEnd); }; diff --git a/app/assets/javascripts/search_autocomplete.js.es6 b/app/assets/javascripts/search_autocomplete.js.es6 index 437f5dbbf7d..cec8856d4e7 100644 --- a/app/assets/javascripts/search_autocomplete.js.es6 +++ b/app/assets/javascripts/search_autocomplete.js.es6 @@ -142,8 +142,9 @@ } getCategoryContents() { - var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, utils; + var dashboardOptions, groupOptions, issuesPath, items, mrPath, name, options, projectOptions, userId, userName, utils; userId = gon.current_user_id; + userName = gon.current_username; utils = gl.utils, projectOptions = gl.projectOptions, groupOptions = gl.groupOptions, dashboardOptions = gl.dashboardOptions; if (utils.isInGroupsPage() && groupOptions) { options = groupOptions[utils.getGroupSlug()]; @@ -158,10 +159,10 @@ header: "" + name }, { text: 'Issues assigned to me', - url: issuesPath + "/?assignee_id=" + userId + url: issuesPath + "/?assignee_username=" + userName }, { text: "Issues I've created", - url: issuesPath + "/?author_id=" + userId + url: issuesPath + "/?author_username=" + userName }, 'separator', { text: 'Merge requests assigned to me', url: mrPath + "/?assignee_id=" + userId diff --git a/app/assets/javascripts/vue_pipelines_index/index.js.es6 b/app/assets/javascripts/vue_pipelines_index/index.js.es6 index 9dfbedd73ab..edd01f17a97 100644 --- a/app/assets/javascripts/vue_pipelines_index/index.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/index.js.es6 @@ -1,5 +1,6 @@ /* global Vue, VueResource, gl */ /*= require vue_common_component/commit */ +/*= require vue_pagination/index */ /*= require vue-resource /*= require boards/vue_resource_interceptor */ /*= require ./status.js.es6 */ diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 index 73627e9ba50..b2ed05503c9 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 @@ -73,12 +73,12 @@ <table class="table ci-table"> <thead> <tr> - <th>Status</th> - <th>Pipeline</th> - <th>Commit</th> - <th>Stages</th> - <th></th> - <th class="hidden-xs"></th> + <th class="pipeline-status">Status</th> + <th class="pipeline-info">Pipeline</th> + <th class="pipeline-commit">Commit</th> + <th class="pipeline-stages">Stages</th> + <th class="pipeline-date"></th> + <th class="pipeline-actions hidden-xs"></th> </tr> </thead> <tbody> diff --git a/app/assets/javascripts/vue_pipelines_index/store.js.es6 b/app/assets/javascripts/vue_pipelines_index/store.js.es6 index 6b34839b030..1982142853a 100644 --- a/app/assets/javascripts/vue_pipelines_index/store.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/store.js.es6 @@ -3,14 +3,24 @@ /*= require vue_realtime_listener/index.js */ ((gl) => { - const pageValues = headers => ({ - perPage: +headers['X-Per-Page'], - page: +headers['X-Page'], - total: +headers['X-Total'], - totalPages: +headers['X-Total-Pages'], - nextPage: +headers['X-Next-Page'], - previousPage: +headers['X-Prev-Page'], - }); + const pageValues = (headers) => { + const normalizedHeaders = {}; + + Object.keys(headers).forEach((e) => { + normalizedHeaders[e.toUpperCase()] = headers[e]; + }); + + const paginationInfo = { + perPage: +normalizedHeaders['X-PER-PAGE'], + page: +normalizedHeaders['X-PAGE'], + total: +normalizedHeaders['X-TOTAL'], + totalPages: +normalizedHeaders['X-TOTAL-PAGES'], + nextPage: +normalizedHeaders['X-NEXT-PAGE'], + previousPage: +normalizedHeaders['X-PREV-PAGE'], + }; + + return paginationInfo; + }; gl.PipelineStore = class { fetchDataLoop(Vue, pageNum, url, apiScope) { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 19827943385..fee38b05023 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -23,3 +23,118 @@ } } +.filtered-search-container { + display: -webkit-flex; + display: flex; +} + +.filtered-search-input-container { + display: -webkit-flex; + display: flex; + position: relative; + width: 100%; + + .form-control { + padding-left: 25px; + padding-right: 25px; + + &:focus ~ .fa-filter { + color: $common-gray-dark; + } + } + + .fa-filter { + position: absolute; + top: 10px; + left: 10px; + color: $gray-darkest; + } + + .fa-times { + right: 10px; + color: $gray-darkest; + } + + .clear-search { + width: 35px; + background-color: transparent; + border: none; + position: absolute; + right: 0; + height: 100%; + outline: none; + + &:hover .fa-times { + color: $common-gray-dark; + } + } +} + +.dropdown-menu .filter-dropdown-item { + padding: 0; +} + +.filter-dropdown { + max-height: 215px; + overflow-x: scroll; +} + +.filter-dropdown-item { + .btn { + border: none; + width: 100%; + text-align: left; + padding: 8px 16px; + text-overflow: ellipsis; + overflow-y: hidden; + border-radius: 0; + + .fa { + width: 15px; + } + + .dropdown-label-box { + border-color: $white-light; + border-style: solid; + border-width: 1px; + width: 17px; + height: 17px; + } + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + text-decoration: none; + + .avatar { + border-color: $white-light; + } + } + } + + .dropdown-light-content { + font-size: 14px; + font-weight: 400; + } + + .dropdown-user { + display: -webkit-flex; + display: flex; + } + + .dropdown-user-details { + display: -webkit-flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + } +} + +.hint-dropdown { + width: 250px; +} + +.filter-dropdown-loading { + padding: 8px 16px; +} diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 5365b62e456..29d55c44699 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -41,6 +41,21 @@ body { } } + .alert-link-group { + float: right; + } + + /* Center alert text and alert action links on smaller screens */ + @media (max-width: $screen-sm-max) { + .alert { + text-align: center; + } + + .alert-link-group { + float: none; + } + } + /* Stripe the background colors so that adjacent alert-warnings are distinct from one another */ .alert-warning { transition: background-color 0.15s, border-color 0.15s; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 7eb9962ba33..8e2c56a8488 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -23,21 +23,21 @@ margin-right: 0; } - .issues-details-filters, + .issues-details-filters:not(.filtered-search-block), .dash-projects-filters, .check-all-holder { display: none; } - .rss-btn { + .issues-holder .issue-check { display: none; } - .project-home-links { + .rss-btn { display: none; } - .project-avatar { + .project-home-links { display: none; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index a8641e83154..838f5442fff 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -183,7 +183,9 @@ &.right-sidebar-expanded { .line-resolve-all-container { - display: none; + @media (min-width: $sidebar-breakpoint) { + display: none; + } } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 3e52c482ece..cf9424ea5dd 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -264,6 +264,11 @@ $dropdown-toggle-active-border-color: darken($border-color, 14%); /* +* Filtered Search +*/ +$dropdown-hover-color: #3b86ff; + +/* * Buttons */ $btn-active-gray: #ececec; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0a8c037c402..3272a862b85 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -1,3 +1,52 @@ +// Limit MR description for side-by-side diff view +.fixed-width-container { + max-width: $limited-layout-width - ($gl-padding * 2); + margin-left: auto; + margin-right: auto; +} + +.limit-container-width { + .detail-page-header { + @extend .fixed-width-container; + } + + .issuable-details { + .detail-page-description, + .mr-source-target, + .mr-state-widget, + .merge-manually { + @extend .fixed-width-container; + } + + .merge-request-tabs-holder { + &.affix { + border-bottom: 1px solid $border-color; + + .nav-links { + border: 0; + } + } + + .container-fluid { + @extend .fixed-width-container; + } + } + } + + .merge-request-details { + .emoji-list-container { + @extend .fixed-width-container; + } + } + + .diffs { + .mr-version-controls, + .files-changed { + @extend .fixed-width-container; + } + } +} + .issuable-details { section { .issuable-discussion { diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index e284b7269ce..686b64cdd24 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -109,6 +109,10 @@ .avatar { float: none; } + + > a:not(:last-of-type) { + margin-right: 5px; + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index ad4c31ca29e..e2a0253da38 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -526,8 +526,9 @@ ul.notes { } .line-resolve-all { + vertical-align: middle; display: inline-block; - padding: 5px 10px; + padding: 6px 10px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; @@ -535,18 +536,14 @@ ul.notes { &.has-next-btn { border-top-right-radius: 0; border-bottom-right-radius: 0; + border-right: 0; } .line-resolve-btn { - vertical-align: middle; margin-right: 5px; } } -.line-resolve-text { - vertical-align: middle; -} - .line-resolve-btn { display: inline-block; position: relative; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index e30d73886e1..9455ba3b98a 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -587,11 +587,21 @@ pre.light-well { .project-full-name { @include str-truncated; + + @media (max-width: $screen-xs-max) { + max-width: 50%; + } } .controls { line-height: $list-text-height; + .badge { + @media (max-width: $screen-xs-max) { + display: none; + } + } + a:hover { text-decoration: none; } @@ -605,6 +615,12 @@ pre.light-well { top: 2px; } } + + .description p { + @media (max-width: $screen-xs-max) { + max-width: 50%; + } + } } .bottom { diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index c2bb8464824..1b4987dd738 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -67,69 +67,78 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController params.delete(:domain_blacklist_raw) if params[:domain_blacklist_file] params.require(:application_setting).permit( - :default_projects_limit, - :default_branch_protection, - :signup_enabled, - :signin_enabled, - :require_two_factor_authentication, - :two_factor_grace_period, - :gravatar_enabled, - :sign_in_text, - :after_sign_up_text, - :help_page_text, - :home_page_url, + application_setting_params_ce + ) + end + + def application_setting_params_ce + [ + :admin_notification_email, :after_sign_out_path, - :max_attachment_size, - :session_expire_delay, + :after_sign_up_text, + :akismet_api_key, + :akismet_enabled, + :container_registry_token_expire_delay, + :default_branch_protection, + :default_group_visibility, :default_project_visibility, + :default_projects_limit, :default_snippet_visibility, - :default_group_visibility, - :domain_whitelist_raw, :domain_blacklist_enabled, - :domain_blacklist_raw, :domain_blacklist_file, - :version_check_enabled, - :admin_notification_email, - :user_oauth_applications, - :user_default_external, - :shared_runners_enabled, - :shared_runners_text, + :domain_blacklist_raw, + :domain_whitelist_raw, + :email_author_in_body, + :enabled_git_access_protocol, + :gravatar_enabled, + :help_page_text, + :home_page_url, + :housekeeping_bitmaps_enabled, + :housekeeping_enabled, + :housekeeping_full_repack_period, + :housekeeping_gc_period, + :housekeeping_incremental_repack_period, + :html_emails_enabled, + :koding_enabled, + :koding_url, + :plantuml_enabled, + :plantuml_url, :max_artifacts_size, + :max_attachment_size, :metrics_enabled, :metrics_host, - :metrics_port, - :metrics_pool_size, - :metrics_timeout, :metrics_method_call_threshold, + :metrics_packet_size, + :metrics_pool_size, + :metrics_port, :metrics_sample_interval, + :metrics_timeout, :recaptcha_enabled, - :recaptcha_site_key, :recaptcha_private_key, - :sentry_enabled, - :sentry_dsn, - :akismet_enabled, - :akismet_api_key, - :koding_enabled, - :koding_url, - :email_author_in_body, - :html_emails_enabled, + :recaptcha_site_key, :repository_checks_enabled, - :metrics_packet_size, + :require_two_factor_authentication, + :session_expire_delay, + :sign_in_text, + :signin_enabled, + :signup_enabled, + :sentry_dsn, + :sentry_enabled, :send_user_confirmation_email, - :container_registry_token_expire_delay, - :enabled_git_access_protocol, + :shared_runners_enabled, + :shared_runners_text, :sidekiq_throttling_enabled, :sidekiq_throttling_factor, - :housekeeping_enabled, - :housekeeping_bitmaps_enabled, - :housekeeping_incremental_repack_period, - :housekeeping_full_repack_period, - :housekeeping_gc_period, + :two_factor_grace_period, + :user_default_external, + :user_oauth_applications, + :version_check_enabled, + + disabled_oauth_sign_in_sources: [], + import_sources: [], repository_storages: [], restricted_visibility_levels: [], - import_sources: [], - disabled_oauth_sign_in_sources: [], sidekiq_throttling_queues: [] - ) + ] end end diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index add1c819adf..b7722a1d15d 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -61,7 +61,11 @@ class Admin::GroupsController < Admin::ApplicationController end def group_params - params.require(:group).permit( + params.require(:group).permit(group_params_ce) + end + + def group_params_ce + [ :avatar, :description, :lfs_enabled, @@ -69,6 +73,6 @@ class Admin::GroupsController < Admin::ApplicationController :path, :request_access_enabled, :visibility_level - ) + ] end end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index df9039b16b2..aa0f8d434dc 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -161,15 +161,6 @@ class Admin::UsersController < Admin::ApplicationController @user ||= User.find_by!(username: params[:id]) end - def user_params - params.require(:user).permit( - :email, :remember_me, :bio, :name, :username, - :skype, :linkedin, :twitter, :website_url, :color_scheme_id, :theme_id, :force_random_password, - :extern_uid, :provider, :password_expires_at, :avatar, :hide_no_ssh_key, :hide_no_password, - :projects_limit, :can_create_group, :admin, :key_id, :external - ) - end - def redirect_back_or_admin_user(options = {}) redirect_back_or_default(default: default_route, options: options) end @@ -177,4 +168,36 @@ class Admin::UsersController < Admin::ApplicationController def default_route [:admin, @user] end + + def user_params + params.require(:user).permit(user_params_ce) + end + + def user_params_ce + [ + :admin, + :avatar, + :bio, + :can_create_group, + :color_scheme_id, + :email, + :extern_uid, + :external, + :force_random_password, + :hide_no_password, + :hide_no_ssh_key, + :key_id, + :linkedin, + :name, + :password_expires_at, + :projects_limit, + :provider, + :remember_me, + :skype, + :theme_id, + :twitter, + :username, + :website_url + ] + end end diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb index 549a8526715..d7f5a4e4682 100644 --- a/app/controllers/concerns/service_params.rb +++ b/app/controllers/concerns/service_params.rb @@ -1,31 +1,72 @@ module ServiceParams extend ActiveSupport::Concern - ALLOWED_PARAMS = [:title, :token, :type, :active, :api_key, :api_url, :api_version, :subdomain, - :room, :recipients, :project_url, :webhook, - :user_key, :device, :priority, :sound, :bamboo_url, :username, :password, - :build_key, :server, :teamcity_url, :drone_url, :build_type, - :description, :issues_url, :new_issue_url, :restrict_to_branch, :channel, - :colorize_messages, :channels, - # We're using `issues_events` and `merge_requests_events` - # in the view so we still need to explicitly state them - # here. `Service#event_names` would only give - # `issue_events` and `merge_request_events` (singular!) - # See app/helpers/services_helper.rb for how we - # make those event names plural as special case. - :issues_events, :confidential_issues_events, :merge_requests_events, - :notify_only_broken_builds, :notify_only_broken_pipelines, - :add_pusher, :send_from_committer_email, :disable_diffs, - :external_wiki_url, :notify, :color, - :server_host, :server_port, :default_irc_uri, :enable_ssl_verification, - :jira_issue_transition_id, :url, :project_key, :ca_pem, :namespace] + ALLOWED_PARAMS_CE = [ + :active, + :add_pusher, + :api_key, + :api_url, + :api_version, + :bamboo_url, + :build_key, + :build_type, + :ca_pem, + :channel, + :channels, + :color, + :colorize_messages, + :confidential_issues_events, + :default_irc_uri, + :description, + :device, + :disable_diffs, + :drone_url, + :enable_ssl_verification, + :external_wiki_url, + # We're using `issues_events` and `merge_requests_events` + # in the view so we still need to explicitly state them + # here. `Service#event_names` would only give + # `issue_events` and `merge_request_events` (singular!) + # See app/helpers/services_helper.rb for how we + # make those event names plural as special case. + :issues_events, + :issues_url, + :jira_issue_transition_id, + :merge_requests_events, + :namespace, + :new_issue_url, + :notify, + :notify_only_broken_builds, + :notify_only_broken_pipelines, + :password, + :priority, + :project_key, + :project_url, + :recipients, + :restrict_to_branch, + :room, + :send_from_committer_email, + :server, + :server_host, + :server_port, + :sound, + :subdomain, + :teamcity_url, + :title, + :token, + :type, + :url, + :user_key, + :username, + :webhook + ] # Parameters to ignore if no value is specified FILTER_BLANK_PARAMS = [:password] def service_params dynamic_params = @service.event_channel_names + @service.event_names - service_params = params.permit(:id, service: ALLOWED_PARAMS + dynamic_params) + service_params = params.permit(:id, service: ALLOWED_PARAMS_CE + dynamic_params) if service_params[:service].is_a?(Hash) FILTER_BLANK_PARAMS.each do |param| diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index b61f4e9a2db..f81237db991 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -125,7 +125,11 @@ class GroupsController < Groups::ApplicationController end def group_params - params.require(:group).permit( + params.require(:group).permit(group_params_ce) + end + + def group_params_ce + [ :avatar, :description, :lfs_enabled, @@ -135,7 +139,7 @@ class GroupsController < Groups::ApplicationController :request_access_enabled, :share_with_group_lock, :visibility_level - ) + ] end def load_events diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6004e7d7115..aaebd4efa00 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -409,10 +409,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController else ci_service = @merge_request.source_project.try(:ci_service) status = ci_service.commit_status(merge_request.diff_head_sha, merge_request.source_branch) if ci_service - - if ci_service.respond_to?(:commit_coverage) - coverage = ci_service.commit_coverage(merge_request.diff_head_sha, merge_request.source_branch) - end end response = { diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index b4c14d05eaf..1576fc80a6b 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -165,31 +165,53 @@ class IssuableFinder end end - def assignee? - params[:assignee_id].present? + def assignee_id? + params[:assignee_id].present? && params[:assignee_id] != NONE + end + + def assignee_username? + params[:assignee_username].present? && params[:assignee_username] != NONE + end + + def no_assignee? + # Assignee_id takes precedence over assignee_username + params[:assignee_id] == NONE || params[:assignee_username] == NONE end def assignee return @assignee if defined?(@assignee) @assignee = - if assignee? && params[:assignee_id] != NONE - User.find(params[:assignee_id]) + if assignee_id? + User.find_by(id: params[:assignee_id]) + elsif assignee_username? + User.find_by(username: params[:assignee_username]) else nil end end - def author? - params[:author_id].present? + def author_id? + params[:author_id].present? && params[:author_id] != NONE + end + + def author_username? + params[:author_username].present? && params[:author_username] != NONE + end + + def no_author? + # author_id takes precedence over author_username + params[:author_id] == NONE || params[:author_username] == NONE end def author return @author if defined?(@author) @author = - if author? && params[:author_id] != NONE - User.find(params[:author_id]) + if author_id? + User.find_by(id: params[:author_id]) + elsif author_username? + User.find_by(username: params[:author_username]) else nil end @@ -263,16 +285,24 @@ class IssuableFinder end def by_assignee(items) - if assignee? - items = items.where(assignee_id: assignee.try(:id)) + if assignee + items = items.where(assignee_id: assignee.id) + elsif no_assignee? + items = items.where(assignee_id: nil) + elsif assignee_id? || assignee_username? # assignee not found + items = items.none end items end def by_author(items) - if author? - items = items.where(author_id: author.try(:id)) + if author + items = items.where(author_id: author.id) + elsif no_author? + items = items.where(author_id: nil) + elsif author_id? || author_username? # author not found + items = items.none end items diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c816b616631..a112928c6de 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -244,7 +244,9 @@ module ApplicationHelper scope: params[:scope], milestone_title: params[:milestone_title], assignee_id: params[:assignee_id], + assignee_username: params[:assignee_username], author_id: params[:author_id], + author_username: params[:author_username], search: params[:search], label_name: params[:label_name] } diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index bf463a3b6bb..8fab77cda0a 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -68,6 +68,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, if: :koding_enabled + validates :plantuml_url, + presence: true, + if: :plantuml_enabled + validates :max_attachment_size, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -184,6 +188,8 @@ class ApplicationSetting < ActiveRecord::Base akismet_enabled: false, koding_enabled: false, koding_url: nil, + plantuml_enabled: false, + plantuml_url: nil, repository_checks_enabled: true, disabled_oauth_sign_in_sources: [], send_user_confirmation_email: false, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 31cd381dcd2..9547c57b2ae 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -137,4 +137,10 @@ class CommitStatus < ActiveRecord::Base .new(self, current_user) .fabricate! end + + def sortable_name + name.split(/(\d+)/).map do |v| + v =~ /\d+/ ? v.to_i : v + end + end end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 6d88951c713..60734bc6660 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -32,6 +32,6 @@ module ProjectFeaturesCompatibility build_project_feature unless project_feature access_level = Gitlab::Utils.to_boolean(value) ? ProjectFeature::ENABLED : ProjectFeature::DISABLED - project_feature.update_attribute(field, access_level) + project_feature.send(:write_attribute, field, access_level) end end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 944519a3070..2589215ad19 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -55,30 +55,30 @@ module ReactiveCaching self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes - def calculate_reactive_cache + def calculate_reactive_cache(*args) raise NotImplementedError end - def with_reactive_cache(&blk) - within_reactive_cache_lifetime do - data = Rails.cache.read(full_reactive_cache_key) + def with_reactive_cache(*args, &blk) + within_reactive_cache_lifetime(*args) do + data = Rails.cache.read(full_reactive_cache_key(*args)) yield data if data.present? end ensure - Rails.cache.write(full_reactive_cache_key('alive'), true, expires_in: self.class.reactive_cache_lifetime) - ReactiveCachingWorker.perform_async(self.class, id) + Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime) + ReactiveCachingWorker.perform_async(self.class, id, *args) end - def clear_reactive_cache! - Rails.cache.delete(full_reactive_cache_key) + def clear_reactive_cache!(*args) + Rails.cache.delete(full_reactive_cache_key(*args)) end - def exclusively_update_reactive_cache! - locking_reactive_cache do - within_reactive_cache_lifetime do - enqueuing_update do - value = calculate_reactive_cache - Rails.cache.write(full_reactive_cache_key, value) + def exclusively_update_reactive_cache!(*args) + locking_reactive_cache(*args) do + within_reactive_cache_lifetime(*args) do + enqueuing_update(*args) do + value = calculate_reactive_cache(*args) + Rails.cache.write(full_reactive_cache_key(*args), value) end end end @@ -93,22 +93,26 @@ module ReactiveCaching ([prefix].flatten + qualifiers).join(':') end - def locking_reactive_cache - lease = Gitlab::ExclusiveLease.new(full_reactive_cache_key, timeout: reactive_cache_lease_timeout) + def alive_reactive_cache_key(*qualifiers) + full_reactive_cache_key(*(qualifiers + ['alive'])) + end + + def locking_reactive_cache(*args) + lease = Gitlab::ExclusiveLease.new(full_reactive_cache_key(*args), timeout: reactive_cache_lease_timeout) uuid = lease.try_obtain yield if uuid ensure - Gitlab::ExclusiveLease.cancel(full_reactive_cache_key, uuid) + Gitlab::ExclusiveLease.cancel(full_reactive_cache_key(*args), uuid) end - def within_reactive_cache_lifetime - yield if Rails.cache.read(full_reactive_cache_key('alive')) + def within_reactive_cache_lifetime(*args) + yield if Rails.cache.read(alive_reactive_cache_key(*args)) end - def enqueuing_update + def enqueuing_update(*args) yield ensure - ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id) + ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args) end end end diff --git a/app/models/concerns/reactive_service.rb b/app/models/concerns/reactive_service.rb new file mode 100644 index 00000000000..e1f868a299b --- /dev/null +++ b/app/models/concerns/reactive_service.rb @@ -0,0 +1,10 @@ +module ReactiveService + extend ActiveSupport::Concern + + included do + include ReactiveCaching + + # Default cache key: class name + project_id + self.reactive_cache_key = ->(service) { [ service.class.model_name.singular, service.project_id ] } + end +end diff --git a/app/models/concerns/valid_attribute.rb b/app/models/concerns/valid_attribute.rb new file mode 100644 index 00000000000..8c35cea8d58 --- /dev/null +++ b/app/models/concerns/valid_attribute.rb @@ -0,0 +1,10 @@ +module ValidAttribute + extend ActiveSupport::Concern + + # Checks whether an attribute has failed validation or not + # + # +attribute+ The symbolised name of the attribute i.e :name + def valid_attribute?(attribute) + self.errors.empty? || self.errors.messages[attribute].nil? + end +end diff --git a/app/models/cycle_analytics/summary.rb b/app/models/cycle_analytics/summary.rb index 82f53d17ddd..c9910d8cd09 100644 --- a/app/models/cycle_analytics/summary.rb +++ b/app/models/cycle_analytics/summary.rb @@ -31,7 +31,7 @@ class CycleAnalytics repository = @project.repository.raw_repository sha = @project.repository.commit(ref).sha - cmd = %W(git --git-dir=#{repository.path} log) + cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{repository.path} log) cmd << '--format=%H' cmd << "--after=#{@from.iso8601}" cmd << sha diff --git a/app/models/environment.rb b/app/models/environment.rb index 5cde94b3509..652abf18a8a 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -87,7 +87,7 @@ class Environment < ActiveRecord::Base end def update_merge_request_metrics? - self.name == "production" + (environment_type || name) == "production" end def first_deployment_for(commit) diff --git a/app/models/forked_project_link.rb b/app/models/forked_project_link.rb index 9803bae0bee..36cf7ad6a28 100644 --- a/app/models/forked_project_link.rb +++ b/app/models/forked_project_link.rb @@ -1,4 +1,4 @@ class ForkedProjectLink < ActiveRecord::Base - belongs_to :forked_to_project, class_name: Project - belongs_to :forked_from_project, class_name: Project + belongs_to :forked_to_project, class_name: 'Project' + belongs_to :forked_from_project, class_name: 'Project' end diff --git a/app/models/project.rb b/app/models/project.rb index 94a6f3ba799..e85d3d3bc6c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -12,6 +12,7 @@ class Project < ActiveRecord::Base include AfterCommitQueue include CaseSensitivity include TokenAuthenticatable + include ValidAttribute include ProjectFeaturesCompatibility include SelectForProjectAuthorization include Routable @@ -65,6 +66,8 @@ class Project < ActiveRecord::Base end end + after_validation :check_pending_delete + ActsAsTaggableOn.strict_case_match = true acts_as_taggable_on :tags @@ -119,7 +122,7 @@ class Project < ActiveRecord::Base # Merge Requests for target project should be removed with it has_many :merge_requests, dependent: :destroy, foreign_key: 'target_project_id' # Merge requests from source project should be kept when source project was removed - has_many :fork_merge_requests, foreign_key: 'source_project_id', class_name: MergeRequest + has_many :fork_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues, dependent: :destroy has_many :labels, dependent: :destroy, class_name: 'ProjectLabel' has_many :services, dependent: :destroy @@ -1320,4 +1323,21 @@ class Project < ActiveRecord::Base stats = statistics || build_statistics stats.update(namespace_id: namespace_id) end + + def check_pending_delete + return if valid_attribute?(:name) && valid_attribute?(:path) + return unless pending_delete_twin + + %i[route route.path name path].each do |error| + errors.delete(error) + end + + errors.add(:base, "The project is still being deleted. Please try again later.") + end + + def pending_delete_twin + return false unless path + + Project.unscoped.where(pending_delete: true).find_with_namespace(path_with_namespace) + end end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index b5c76e4d4fe..4819bdbef8c 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -1,4 +1,6 @@ class BambooService < CiService + include ReactiveService + prop_accessor :bamboo_url, :build_key, :username, :password validates :bamboo_url, presence: true, url: true, if: :activated? @@ -58,31 +60,46 @@ class BambooService < CiService %w(push) end - def build_info(sha) - @response = get_path("rest/api/latest/result?label=#{sha}") + def build_page(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:build_page] } end - def build_page(sha, ref) - build_info(sha) if @response.nil? || !@response.code + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end - if @response.code != 200 || @response['results']['results']['size'] == '0' + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + get_path("updateAndBuild.action?buildKey=#{build_key}") + end + + def calculate_reactive_cache(sha, ref) + response = get_path("rest/api/latest/result?label=#{sha}") + + { build_page: read_build_page(response), commit_status: read_commit_status(response) } + end + + private + + def read_build_page(response) + if response.code != 200 || response['results']['results']['size'] == '0' # If actual build link can't be determined, send user to build summary page. URI.join("#{bamboo_url}/", "browse/#{build_key}").to_s else # If actual build link is available, go to build result page. - result_key = @response['results']['results']['result']['planResultKey']['key'] + result_key = response['results']['results']['result']['planResultKey']['key'] URI.join("#{bamboo_url}/", "browse/#{result_key}").to_s end end - def commit_status(sha, ref) - build_info(sha) if @response.nil? || !@response.code - return :error unless @response.code == 200 || @response.code == 404 + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 - status = if @response.code == 404 || @response['results']['results']['size'] == '0' + status = if response.code == 404 || response['results']['results']['size'] == '0' 'Pending' else - @response['results']['results']['result']['buildState'] + response['results']['results']['result']['buildState'] end if status.include?('Success') @@ -96,14 +113,6 @@ class BambooService < CiService end end - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - get_path("updateAndBuild.action?buildKey=#{build_key}") - end - - private - def build_url(path) URI.join("#{bamboo_url}/", path).to_s end diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index fe6d7aabb22..e77942d8f3c 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -1,6 +1,8 @@ require "addressable/uri" class BuildkiteService < CiService + include ReactiveService + ENDPOINT = "https://buildkite.com" prop_accessor :project_url, :token @@ -33,13 +35,7 @@ class BuildkiteService < CiService end def commit_status(sha, ref) - response = HTTParty.get(commit_status_path(sha), verify: false) - - if response.code == 200 && response['status'] - response['status'] - else - :error - end + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } end def commit_status_path(sha) @@ -78,6 +74,19 @@ class BuildkiteService < CiService ] end + def calculate_reactive_cache(sha, ref) + response = HTTParty.get(commit_status_path(sha), verify: false) + + status = + if response.code == 200 && response['status'] + response['status'] + else + :error + end + + { commit_status: status } + end + private def webhook_token diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb index 596c00705ad..4de0106707e 100644 --- a/app/models/project_services/ci_service.rb +++ b/app/models/project_services/ci_service.rb @@ -12,15 +12,7 @@ class CiService < Service %w(push) end - def merge_request_page(iid, sha, ref) - commit_page(sha, ref) - end - - def commit_page(sha, ref) - build_page(sha, ref) - end - - # Return complete url to merge_request page + # Return complete url to build page # # Ex. # http://jenkins.example.com:8888/job/test1/scm/bySHA1/12d65c @@ -35,23 +27,6 @@ class CiService < Service # # # Ex. - # @service.merge_request_status(9, '13be4ac', 'dev') - # # => 'success' - # - # @service.merge_request_status(10, '2abe4ac', 'dev) - # # => 'running' - # - # - def merge_request_status(iid, sha, ref) - commit_status(sha, ref) - end - - # Return string with build status or :error symbol - # - # Allowed states: 'success', 'failed', 'running', 'pending', 'skipped' - # - # - # Ex. # @service.commit_status('13be4ac', 'master') # # => 'success' # diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index adc78a427ee..4bbbebf54cb 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -1,4 +1,6 @@ class DroneCiService < CiService + include ReactiveService + prop_accessor :drone_url, :token boolean_accessor :enable_ssl_verification @@ -34,14 +36,6 @@ class DroneCiService < CiService %w(push merge_request tag_push) end - def merge_request_status_path(iid, sha = nil, ref = nil) - url = [drone_url, - "gitlab/#{project.namespace.path}/#{project.path}/pulls/#{iid}", - "?access_token=#{token}"] - - URI.join(*url).to_s - end - def commit_status_path(sha, ref) url = [drone_url, "gitlab/#{project.namespace.path}/#{project.path}/commits/#{sha}", @@ -50,54 +44,34 @@ class DroneCiService < CiService URI.join(*url).to_s end - def merge_request_status(iid, sha, ref) - response = HTTParty.get(merge_request_status_path(iid), verify: enable_ssl_verification) - - if response.code == 200 and response['status'] - case response['status'] - when 'killed' - :canceled - when 'failure', 'error' - # Because drone return error if some test env failed - :failed - else - response["status"] - end - else - :error - end - rescue Errno::ECONNREFUSED - :error + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } end - def commit_status(sha, ref) + def calculate_reactive_cache(sha, ref) response = HTTParty.get(commit_status_path(sha, ref), verify: enable_ssl_verification) - if response.code == 200 and response['status'] - case response['status'] - when 'killed' - :canceled - when 'failure', 'error' - # Because drone return error if some test env failed - :failed + status = + if response.code == 200 and response['status'] + case response['status'] + when 'killed' + :canceled + when 'failure', 'error' + # Because drone return error if some test env failed + :failed + else + response["status"] + end else - response["status"] + :error end - else - :error - end - rescue Errno::ECONNREFUSED - :error - end - def merge_request_page(iid, sha, ref) - url = [drone_url, - "gitlab/#{project.namespace.path}/#{project.path}/redirect/pulls/#{iid}"] - - URI.join(*url).to_s + { commit_status: status } + rescue Errno::ECONNREFUSED + { commit_status: :error } end - def commit_page(sha, ref) + def build_page(sha, ref) url = [drone_url, "gitlab/#{project.namespace.path}/#{project.path}/redirect/commits/#{sha}", "?branch=#{URI::encode(ref.to_s)}"] @@ -105,14 +79,6 @@ class DroneCiService < CiService URI.join(*url).to_s end - def commit_coverage(sha, ref) - nil - end - - def build_page(sha, ref) - commit_page(sha, ref) - end - def title 'Drone CI' end diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index a4a967c9bc9..6726082048f 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -1,4 +1,6 @@ class TeamcityService < CiService + include ReactiveService + prop_accessor :teamcity_url, :build_type, :username, :password validates :teamcity_url, presence: true, url: true, if: :activated? @@ -61,43 +63,18 @@ class TeamcityService < CiService ] end - def build_info(sha) - @response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}") - end - def build_page(sha, ref) - build_info(sha) if @response.nil? || !@response.code - - if @response.code != 200 - # If actual build link can't be determined, - # send user to build summary page. - build_url("viewLog.html?buildTypeId=#{build_type}") - else - # If actual build link is available, go to build result page. - built_id = @response['build']['id'] - build_url("viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}") - end + with_reactive_cache(sha, ref) {|cached| cached[:build_page] } end def commit_status(sha, ref) - build_info(sha) if @response.nil? || !@response.code - return :error unless @response.code == 200 || @response.code == 404 + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end - status = if @response.code == 404 - 'Pending' - else - @response['build']['status'] - end + def calculate_reactive_cache(sha, ref) + response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}") - if status.include?('SUCCESS') - 'success' - elsif status.include?('FAILURE') - 'failed' - elsif status.include?('Pending') - 'pending' - else - :error - end + { build_page: read_build_page(response), commit_status: read_commit_status(response) } end def execute(data) @@ -122,6 +99,40 @@ class TeamcityService < CiService private + def read_build_page(response) + if response.code != 200 + # If actual build link can't be determined, + # send user to build summary page. + build_url("viewLog.html?buildTypeId=#{build_type}") + else + # If actual build link is available, go to build result page. + built_id = response['build']['id'] + build_url("viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}") + end + end + + def read_commit_status(response) + return :error unless response.code == 200 || response.code == 404 + + status = if response.code == 404 + 'Pending' + else + response['build']['status'] + end + + return :error unless status.present? + + if status.include?('SUCCESS') + 'success' + elsif status.include?('FAILURE') + 'failed' + elsif status.include?('Pending') + 'pending' + else + :error + end + end + def build_url(path) URI.join("#{teamcity_url}/", path).to_s end diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb index 49f4db36295..31763955f97 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -8,16 +8,16 @@ class CommitEntity < API::Entities::RepoCommit end expose :commit_url do |commit| - namespace_project_tree_url( + namespace_project_commit_url( request.project.namespace, request.project, - id: commit.id) + commit) end expose :commit_path do |commit| - namespace_project_tree_path( + namespace_project_commit_path( request.project.namespace, request.project, - id: commit.id) + commit) end end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 4612a7a058a..558bbe07b16 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -421,6 +421,23 @@ = link_to "Koding administration documentation", help_page_path("administration/integration/koding") %fieldset + %legend PlantUML + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :plantuml_enabled do + = f.check_box :plantuml_enabled + Enable PlantUML + .form-group + = f.label :plantuml_url, 'PlantUML URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + .help-block + Allow rendering of + = link_to "PlantUML", "http://plantuml.com" + diagrams in Asciidoc documents using an external PlantUML service. + + %fieldset %legend Usage statistics .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index bb4effeeeb1..60a561c9f9c 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -19,7 +19,7 @@ Your New Personal Access Token .form-group = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block" - = clipboard_button(clipboard_text: flash[:personal_access_token]) + = clipboard_button(clipboard_text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left") %span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again. %hr diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index ecd812312c0..5f8f56150f9 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -5,7 +5,8 @@ %div{ class: container_class } .top-area.adjust .nav-text - Protected branches can be managed in project settings + Protected branches can be managed in + = link_to 'project settings', namespace_project_protected_branches_path(@project.namespace, @project) .nav-controls = form_tag(filter_branches_path, method: :get) do diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 3525a07a687..f5769a629a0 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -52,7 +52,7 @@ git push -u origin master %fieldset - %h5 Existing folder or Git repository + %h5 Existing folder %pre.light-well :preserve cd existing_folder @@ -62,6 +62,15 @@ git commit git push -u origin master + %fieldset + %h5 Existing Git repository + %pre.light-well + :preserve + cd existing_repo + git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'clone')} + git push -u origin --all + git push -u origin --tags + - if can? current_user, :remove_project, @project .prepend-top-20 = link_to 'Remove project', [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-remove pull-right" diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 26f3f0ac292..18e8372ecab 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -6,6 +6,9 @@ = content_for :sub_nav do = render "projects/issues/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('filtered_search/filtered_search_bundle.js') + = content_for :meta_tags do - if current_user = auto_discovery_link_tag(:atom, url_for(params.merge(format: :atom, private_token: current_user.private_token)), title: "#{@project.name} issues") @@ -20,7 +23,6 @@ = icon('rss') %span.icon-label Subscribe - = render 'shared/issuable/search_form', path: namespace_project_issues_path(@project.namespace, @project) - if can? current_user, :create_issue, @project = link_to new_namespace_project_issue_path(@project.namespace, @project, @@ -30,7 +32,7 @@ title: "New Issue", id: "new_issue_link" do New Issue - = render 'shared/issuable/filter', type: :issues + = render 'shared/issuable/search_bar', type: :issues .issues-holder = render 'issues' diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 981bf640a6b..43141971231 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -1,4 +1,4 @@ -- @content_class = "limit-container-width" +- @content_class = "limit-container-width" unless fluid_layout - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml index 24e86b8497f..a80f9aa4c4a 100644 --- a/app/views/projects/mattermosts/_team_selection.html.haml +++ b/app/views/projects/mattermosts/_team_selection.html.haml @@ -7,20 +7,21 @@ %p = @teams.one? ? 'The team' : 'Select the team' where the slash commands will be used in - - selected_id = @teams.keys.first if @teams.one? + - selected_id = @teams.one? ? @teams.keys.first : 0 - options = mattermost_teams_options(@teams) - options = options_for_select(options, selected_id) - = f.select(:team_id, options, {}, { class: 'form-control', selected: "#{selected_id}" }) + = f.select(:team_id, options, {}, { class: 'form-control', disabled: @teams.one?, selected: selected_id }) + = f.hidden_field(:team_id, value: selected_id) if @teams.one? .help-block - if @teams.one? - This is the only team where you are an administrator. + This is the only available team. - else - The list shows teams where you are administrator - To create a team, ask your Mattermost system administrator. + The list shows all available teams. To create a team, = link_to "#{Gitlab.config.mattermost.host}/create_team" do use Mattermost's interface = icon('external-link') + or ask your Mattermost system administrator. %hr %h4 Command trigger word %p Choose the word that will trigger commands diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 1f63803c24e..110dd11d1ce 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -1,4 +1,4 @@ -- @content_class = "limit-container-width" +- @content_class = "limit-container-width" unless fluid_layout - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests" - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes @@ -47,7 +47,7 @@ = succeed '.' do = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" - .content-block.content-block-small + .content-block.content-block-small.emoji-list-container = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml index ec76c6a5417..93ed4b68e0e 100644 --- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/show/_how_to_merge.html.haml @@ -8,7 +8,7 @@ %p %strong Step 1. Fetch and check out the branch for this merge request - = clipboard_button(clipboard_target: "pre#merge-info-1") + = clipboard_button(clipboard_target: "pre#merge-info-1", title: "Copy commands to clipboard") %pre.dark#merge-info-1 - if @merge_request.for_fork? :preserve @@ -25,7 +25,7 @@ %p %strong Step 3. Merge the branch and fix any conflicts that come up - = clipboard_button(clipboard_target: "pre#merge-info-3") + = clipboard_button(clipboard_target: "pre#merge-info-3", title: "Copy commands to clipboard") %pre.dark#merge-info-3 - if @merge_request.for_fork? :preserve @@ -38,7 +38,7 @@ %p %strong Step 4. Push the result of the merge to GitLab - = clipboard_button(clipboard_target: "pre#merge-info-4") + = clipboard_button(clipboard_target: "pre#merge-info-4", title: "Copy commands to clipboard") %pre.dark#merge-info-4 :preserve git push origin #{h @merge_request.target_branch} diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index abea6932567..df36279ed75 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -64,5 +64,4 @@ .vue-pipelines-index -= page_specific_javascript_tag('vue_pagination/index.js') = page_specific_javascript_tag('vue_pipelines_index/index.js') diff --git a/app/views/projects/stage/_graph.html.haml b/app/views/projects/stage/_graph.html.haml index d9d392fa02f..4ee30b023ac 100644 --- a/app/views/projects/stage/_graph.html.haml +++ b/app/views/projects/stage/_graph.html.haml @@ -1,6 +1,6 @@ - stage = local_assigns.fetch(:stage) - statuses = stage.statuses.latest -- status_groups = statuses.sort_by(&:name).group_by(&:group_name) +- status_groups = statuses.sort_by(&:sortable_name).group_by(&:group_name) %li.stage-column .stage-name %a{ name: stage.name } diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 96b75440309..03684389742 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -19,7 +19,7 @@ = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true .input-group-btn - = clipboard_button(clipboard_target: '#project_clone') + = clipboard_button(clipboard_target: '#project_clone', title: "Copy URL to clipboard") :javascript $('ul.clone-options-dropdown a').on('click',function(e){ diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index a43bf33751a..ed6fc76c61e 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -1,8 +1,8 @@ - if cookies[:hide_no_password_message].blank? && !current_user.hide_no_password && current_user.require_password? - .no-password-message.alert.alert-warning.hidden-xs + .no-password-message.alert.alert-warning You won't be able to pull or push project code via #{gitlab_config.protocol.upcase} until you #{link_to 'set a password', edit_profile_password_path} on your account - .pull-right + .alert-link-group = link_to "Don't show again", profile_path(user: {hide_no_password: true}), method: :put | = link_to 'Remind later', '#', class: 'hide-no-password-message' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index bb5fff2d3bb..d663fa13d10 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -1,8 +1,8 @@ - if cookies[:hide_no_ssh_message].blank? && !current_user.hide_no_ssh_key && current_user.require_ssh_key? - .no-ssh-key-message.alert.alert-warning.hidden-xs + .no-ssh-key-message.alert.alert-warning You won't be able to pull or push project code via SSH until you #{link_to 'add an SSH key', profile_keys_path, class: 'alert-link'} to your profile - .pull-right + .alert-link-group = link_to "Don't show again", profile_path(user: {hide_no_ssh_key: true}), method: :put, class: 'alert-link' | = link_to 'Remind later', '#', class: 'hide-no-ssh-message alert-link' diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml new file mode 100644 index 00000000000..8d7b1d616f4 --- /dev/null +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -0,0 +1,127 @@ +- type = local_assigns.fetch(:type) + +.issues-filters + .issues-details-filters.row-content-block.second-block.filtered-search-block + = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do + - if params[:search].present? + = hidden_field_tag :search, params[:search] + - if @bulk_edit + .check-all-holder + = check_box_tag "check_all_issues", nil, false, + class: "check_all_issues left" + .issues-other-filters.filtered-search-container + .filtered-search-input-container + %input.form-control.filtered-search{ placeholder: 'Search or filter results...', 'data-id' => 'filtered-search', 'data-project-id' => @project.id } + = icon('filter') + %button.clear-search.hidden{ type: 'button' } + = icon('times') + #js-dropdown-hint.dropdown-menu.hint-dropdown + %ul{ 'data-dropdown' => true } + %li.filter-dropdown-item{ 'data-value' => '' } + %button.btn.btn-link + = icon('search') + %span + Keep typing and press Enter + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %i.fa{ class: "#{'{{icon}}'}" } + %span.js-filter-hint + {{hint}} + %span.js-filter-tag.dropdown-light-content + {{tag}} + #js-dropdown-author.dropdown-menu + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar.avatar-inline{ 'data-src' => '{{avatar_url}}', alt: '{{name}}\'s avatar', width: '30' } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-assignee.dropdown-menu + %ul{ 'data-dropdown' => true } + %li.filter-dropdown-item{ 'data-value' => 'none' } + %button.btn.btn-link + No Assignee + %li.divider + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar.avatar-inline{ 'data-src' => '{{avatar_url}}', alt: '{{name}}\'s avatar', width: '30' } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-milestone.dropdown-menu{ 'data-dropdown' => true } + %ul{ 'data-dropdown' => true } + %li.filter-dropdown-item{ 'data-value' => 'none' } + %button.btn.btn-link + No Milestone + %li.filter-dropdown-item{ 'data-value' => 'upcoming' } + %button.btn.btn-link + Upcoming + %li.divider + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value + {{title}} + #js-dropdown-label.dropdown-menu{ 'data-dropdown' => true } + %ul{ 'data-dropdown' => true } + %li.filter-dropdown-item{ 'data-value' => 'none' } + %button.btn.btn-link + No Label + %li.divider + %ul.filter-dropdown{ 'data-dynamic' => true, 'data-dropdown' => true } + %li.filter-dropdown-item + %button.btn.btn-link + %span.dropdown-label-box{ style: 'background: {{color}}' } + %span.label-title.js-data-value + {{title}} + .pull-right + = render 'shared/sort_dropdown' + + - if @bulk_edit + .issues_bulk_update.hide + = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do + .filter-item.inline + = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]" } } ) do + %ul + %li + %a{ href: "#", data: { id: "reopen" } } Open + %li + %a{ href: "#", data: { id: "close" } } Closed + .filter-item.inline + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) + .filter-item.inline + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + .filter-item.inline.labels-filter + = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } + .filter-item.inline + = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do + %ul + %li + %a{ href: "#", data: { id: "subscribe" } } Subscribe + %li + %a{ href: "#", data: { id: "unsubscribe" } } Unsubscribe + + = hidden_field_tag 'update[issuable_ids]', [] + = hidden_field_tag :state_event, params[:state_event] + .filter-item.inline + = button_tag "Update #{type.to_s.humanize(capitalize: false)}", class: "btn update_selected_issues btn-save" + +:javascript + new UsersSelect(); + new LabelsSelect(); + new MilestoneSelect(); + new IssueStatusSelect(); + new SubscriptionSelect(); + $('form.filter-form').on('submit', function (event) { + event.preventDefault(); + Turbolinks.visit(this.action + '&' + $(this).serialize()); + }); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 5f199301364..a02b815e3cd 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -153,13 +153,13 @@ - project_ref = cross_project_reference(@project, issuable) .block.project-reference .sidebar-collapsed-icon.dont-change-state - = clipboard_button(clipboard_text: project_ref) + = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") .cross-project-reference.hide-collapsed %span Reference: %cite{ title: project_ref } = project_ref - = clipboard_button(clipboard_text: project_ref) + = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}'); diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index 9af9dae04f0..18b8daf4e1e 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -2,7 +2,7 @@ class ReactiveCachingWorker include Sidekiq::Worker include DedicatedSidekiqQueue - def perform(class_name, id) + def perform(class_name, id, *args) klass = begin Kernel.const_get(class_name) rescue NameError @@ -10,6 +10,6 @@ class ReactiveCachingWorker end return unless klass - klass.find_by(id: id).try(:exclusively_update_reactive_cache!) + klass.find_by(id: id).try(:exclusively_update_reactive_cache!, *args) end end diff --git a/changelogs/unreleased/24139-production-wildcard-for-cycle-analytics.yml b/changelogs/unreleased/24139-production-wildcard-for-cycle-analytics.yml new file mode 100644 index 00000000000..83cf3670ec0 --- /dev/null +++ b/changelogs/unreleased/24139-production-wildcard-for-cycle-analytics.yml @@ -0,0 +1,4 @@ +--- +title: Treat environments matching `production/*` as Production +merge_request: 8500 +author: diff --git a/changelogs/unreleased/24185-legacy-ci-status-reactive-cache.yml b/changelogs/unreleased/24185-legacy-ci-status-reactive-cache.yml new file mode 100644 index 00000000000..09ff63a44fb --- /dev/null +++ b/changelogs/unreleased/24185-legacy-ci-status-reactive-cache.yml @@ -0,0 +1,4 @@ +--- +title: Query external CI statuses in the background +merge_request: +author: diff --git a/changelogs/unreleased/25776-alerts-should-be-responsive.yml b/changelogs/unreleased/25776-alerts-should-be-responsive.yml new file mode 100644 index 00000000000..15006523d3e --- /dev/null +++ b/changelogs/unreleased/25776-alerts-should-be-responsive.yml @@ -0,0 +1,4 @@ +--- +title: Changed alerts to be responsive, centered text on smaller viewports +merge_request: 8424 +author: Connor Smallman diff --git a/changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml b/changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml new file mode 100644 index 00000000000..cde0d114d7c --- /dev/null +++ b/changelogs/unreleased/26109-preserve-scroll-position-on-autoreload.yml @@ -0,0 +1,4 @@ +--- +title: Scroll to bottom on build completion if autoscroll was active +merge_request: 8391 +author: diff --git a/changelogs/unreleased/26129-add-link-to-branches-page.yml b/changelogs/unreleased/26129-add-link-to-branches-page.yml new file mode 100644 index 00000000000..aceb92dbb9c --- /dev/null +++ b/changelogs/unreleased/26129-add-link-to-branches-page.yml @@ -0,0 +1,4 @@ +--- +title: Convert project setting text into protected branch path link +merge_request: 8377 +author: Ken Ding diff --git a/changelogs/unreleased/26435-show-project-avatars-on-mobile.yml b/changelogs/unreleased/26435-show-project-avatars-on-mobile.yml new file mode 100644 index 00000000000..43afdf45013 --- /dev/null +++ b/changelogs/unreleased/26435-show-project-avatars-on-mobile.yml @@ -0,0 +1,4 @@ +--- +title: Display project avatars on Admin Area and Projects pages for mobile views +merge_request: +author: Ryan Harris diff --git a/changelogs/unreleased/26504-mr-discussion-btn.yml b/changelogs/unreleased/26504-mr-discussion-btn.yml new file mode 100644 index 00000000000..dec74ec61b1 --- /dev/null +++ b/changelogs/unreleased/26504-mr-discussion-btn.yml @@ -0,0 +1,4 @@ +--- +title: 26504 Fix styling of MR jump to discussion button +merge_request: +author: diff --git a/changelogs/unreleased/26587-metrics-middleware-endpoint-is-nil.yml b/changelogs/unreleased/26587-metrics-middleware-endpoint-is-nil.yml new file mode 100644 index 00000000000..5891a5ef6e8 --- /dev/null +++ b/changelogs/unreleased/26587-metrics-middleware-endpoint-is-nil.yml @@ -0,0 +1,4 @@ +--- +title: Check for env[Grape::Env::GRAPE_ROUTING_ARGS] instead of endpoint.route +merge_request: 8544 +author: diff --git a/changelogs/unreleased/26615-pipeline-status-cell.yml b/changelogs/unreleased/26615-pipeline-status-cell.yml new file mode 100644 index 00000000000..9a19b041e63 --- /dev/null +++ b/changelogs/unreleased/26615-pipeline-status-cell.yml @@ -0,0 +1,4 @@ +--- +title: Fixes pipeline status cell is too wide by adding missing classes in table head cells +merge_request: 8549 +author: diff --git a/changelogs/unreleased/add-changelog-search-bar-first-iteration.yml b/changelogs/unreleased/add-changelog-search-bar-first-iteration.yml new file mode 100644 index 00000000000..4d83d744be7 --- /dev/null +++ b/changelogs/unreleased/add-changelog-search-bar-first-iteration.yml @@ -0,0 +1,4 @@ +--- +title: Search bar redesign first iteration +merge_request: 7345 +author: diff --git a/changelogs/unreleased/allow_plus_sign_for_snippets.yml b/changelogs/unreleased/allow_plus_sign_for_snippets.yml new file mode 100644 index 00000000000..62d9dd74d07 --- /dev/null +++ b/changelogs/unreleased/allow_plus_sign_for_snippets.yml @@ -0,0 +1,4 @@ +--- +title: Allow to use + symbol in filenames +merge_request: 6644 +author: blackst0ne diff --git a/changelogs/unreleased/asciidoctor-plantuml.yml b/changelogs/unreleased/asciidoctor-plantuml.yml new file mode 100644 index 00000000000..ba6ef7c0800 --- /dev/null +++ b/changelogs/unreleased/asciidoctor-plantuml.yml @@ -0,0 +1,4 @@ +--- +title: Add support for PlantUML diagrams in AsciiDoc documents. +merge_request: 7810 +author: Horacio Sanson diff --git a/changelogs/unreleased/bug-project-feature-compatibility.yml b/changelogs/unreleased/bug-project-feature-compatibility.yml new file mode 100644 index 00000000000..2124ee085e0 --- /dev/null +++ b/changelogs/unreleased/bug-project-feature-compatibility.yml @@ -0,0 +1,5 @@ +--- +title: Mutate the attribute instead of issuing a write operation to the DB in `ProjectFeaturesCompatibility` + concern. +merge_request: 8552 +author: diff --git a/changelogs/unreleased/clipboard-button-text.yml b/changelogs/unreleased/clipboard-button-text.yml new file mode 100644 index 00000000000..dc93da60426 --- /dev/null +++ b/changelogs/unreleased/clipboard-button-text.yml @@ -0,0 +1,3 @@ +--- +title: 'Copy <some text> to clipboard' +merge_request: 8535 diff --git a/changelogs/unreleased/env-var-in-redis-config.yml b/changelogs/unreleased/env-var-in-redis-config.yml new file mode 100644 index 00000000000..561ea7f514e --- /dev/null +++ b/changelogs/unreleased/env-var-in-redis-config.yml @@ -0,0 +1,4 @@ +--- +title: Allow to use ENV variables in redis config +merge_request: 8073 +author: Semyon Pupkov diff --git a/changelogs/unreleased/fill-authorized-projects.yml b/changelogs/unreleased/fill-authorized-projects.yml new file mode 100644 index 00000000000..e8e33011a15 --- /dev/null +++ b/changelogs/unreleased/fill-authorized-projects.yml @@ -0,0 +1,4 @@ +--- +title: Fill missing authorized projects rows +merge_request: +author: diff --git a/changelogs/unreleased/fix-build-sort-order.yml b/changelogs/unreleased/fix-build-sort-order.yml new file mode 100644 index 00000000000..a6d6371f69a --- /dev/null +++ b/changelogs/unreleased/fix-build-sort-order.yml @@ -0,0 +1,4 @@ +--- +title: Sort numbers in build names more intelligently +merge_request: 8277 +author: diff --git a/changelogs/unreleased/fix-more-orphans-remove-undeleted-groups.yml b/changelogs/unreleased/fix-more-orphans-remove-undeleted-groups.yml new file mode 100644 index 00000000000..bc2068b8177 --- /dev/null +++ b/changelogs/unreleased/fix-more-orphans-remove-undeleted-groups.yml @@ -0,0 +1,4 @@ +--- +title: Remove extra orphaned rows when removing stray namespaces +merge_request: 7841 +author: diff --git a/changelogs/unreleased/fix-no-milestone-option-for-projects-endpoint-23194.yml b/changelogs/unreleased/fix-no-milestone-option-for-projects-endpoint-23194.yml new file mode 100644 index 00000000000..98066537723 --- /dev/null +++ b/changelogs/unreleased/fix-no-milestone-option-for-projects-endpoint-23194.yml @@ -0,0 +1,4 @@ +--- +title: 'API: fix query response for `/projects/:id/issues?milestone="No%20Milestone"`' +merge_request: 8457 +author: Panagiotis Atmatzidis, David Eisner diff --git a/changelogs/unreleased/fix-project-delete-tooltip.yml b/changelogs/unreleased/fix-project-delete-tooltip.yml new file mode 100644 index 00000000000..42fd9c32519 --- /dev/null +++ b/changelogs/unreleased/fix-project-delete-tooltip.yml @@ -0,0 +1,4 @@ +--- +title: Fix project queued for deletion re-creation tooltip +merge_request: +author: diff --git a/changelogs/unreleased/fix-serialized-commit-path.yml b/changelogs/unreleased/fix-serialized-commit-path.yml new file mode 100644 index 00000000000..4e4df503874 --- /dev/null +++ b/changelogs/unreleased/fix-serialized-commit-path.yml @@ -0,0 +1,4 @@ +--- +title: Fix links to commits pages on pipelines list page +merge_request: 8558 +author: diff --git a/changelogs/unreleased/fix-user-api-confirm-param.yml b/changelogs/unreleased/fix-user-api-confirm-param.yml new file mode 100644 index 00000000000..42642576634 --- /dev/null +++ b/changelogs/unreleased/fix-user-api-confirm-param.yml @@ -0,0 +1,4 @@ +--- +title: Fix 500 error when POSTing to Users API with optional confirm param +merge_request: +author: diff --git a/changelogs/unreleased/restore-backup-when-env-variable-is-passed.yml b/changelogs/unreleased/restore-backup-when-env-variable-is-passed.yml new file mode 100644 index 00000000000..8ec3cfdbb08 --- /dev/null +++ b/changelogs/unreleased/restore-backup-when-env-variable-is-passed.yml @@ -0,0 +1,4 @@ +--- +title: Restore backup correctly when "BACKUP" environment variable is passed +merge_request: 8477 +author: diff --git a/changelogs/unreleased/speed-up-group-milestone-index.yml b/changelogs/unreleased/speed-up-group-milestone-index.yml deleted file mode 100644 index b5181fa66da..00000000000 --- a/changelogs/unreleased/speed-up-group-milestone-index.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Speed up group milestone index by passing group_id to IssuesFinder -merge_request: 8363 -author: diff --git a/changelogs/unreleased/zj-unadressable-url-variables.yml b/changelogs/unreleased/zj-unadressable-url-variables.yml new file mode 100644 index 00000000000..6c412bd0540 --- /dev/null +++ b/changelogs/unreleased/zj-unadressable-url-variables.yml @@ -0,0 +1,4 @@ +--- +title: Don't validate environment urls on .gitlab-ci.yml +merge_request: +author: diff --git a/config/application.rb b/config/application.rb index 1de7fb7bdb8..8ce549cebf6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -106,11 +106,11 @@ module Gitlab config.assets.precompile << "blob_edit/blob_edit_bundle.js" config.assets.precompile << "snippet/snippet_bundle.js" config.assets.precompile << "terminal/terminal_bundle.js" + config.assets.precompile << "filtered_search/filtered_search_bundle.js" config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/*.js" config.assets.precompile << "u2f.js" config.assets.precompile << "vue_pipelines_index/index.js" - config.assets.precompile << "vue_pagination/index.js" config.assets.precompile << "vendor/assets/fonts/*" # Version of your assets, change this if you want to expire all your assets diff --git a/db/migrate/20161117114805_remove_undeleted_groups.rb b/db/migrate/20161117114805_remove_undeleted_groups.rb index 696914f8e4d..29040583aa2 100644 --- a/db/migrate/20161117114805_remove_undeleted_groups.rb +++ b/db/migrate/20161117114805_remove_undeleted_groups.rb @@ -5,47 +5,87 @@ class RemoveUndeletedGroups < ActiveRecord::Migration DOWNTIME = false def up + is_ee = defined?(Gitlab::License) + + if is_ee + execute <<-EOF.strip_heredoc + DELETE FROM path_locks + WHERE project_id IN ( + SELECT project_id + FROM projects + WHERE namespace_id IN (#{namespaces_pending_removal}) + ); + EOF + + execute <<-EOF.strip_heredoc + DELETE FROM remote_mirrors + WHERE project_id IN ( + SELECT project_id + FROM projects + WHERE namespace_id IN (#{namespaces_pending_removal}) + ); + EOF + end + execute <<-EOF.strip_heredoc - DELETE FROM projects - WHERE namespace_id IN ( - SELECT id FROM ( - SELECT id - FROM namespaces - WHERE deleted_at IS NOT NULL - ) namespace_ids + DELETE FROM lists + WHERE label_id IN ( + SELECT id + FROM labels + WHERE group_id IN (#{namespaces_pending_removal}) + ); + EOF + + execute <<-EOF.strip_heredoc + DELETE FROM lists + WHERE board_id IN ( + SELECT id + FROM boards + WHERE project_id IN ( + SELECT project_id + FROM projects + WHERE namespace_id IN (#{namespaces_pending_removal}) + ) ); EOF - if defined?(Gitlab::License) + execute <<-EOF.strip_heredoc + DELETE FROM labels + WHERE group_id IN (#{namespaces_pending_removal}); + EOF + + execute <<-EOF.strip_heredoc + DELETE FROM boards + WHERE project_id IN ( + SELECT project_id + FROM projects + WHERE namespace_id IN (#{namespaces_pending_removal}) + ) + EOF + + execute <<-EOF.strip_heredoc + DELETE FROM projects + WHERE namespace_id IN (#{namespaces_pending_removal}); + EOF + + if is_ee # EE adds these columns but we have to make sure this data is cleaned up # here before we run the DELETE below. An alternative would be patching # this migration in EE but this will only result in a mess and confusing # migrations. execute <<-EOF.strip_heredoc DELETE FROM protected_branch_push_access_levels - WHERE group_id IN ( - SELECT id FROM ( - SELECT id - FROM namespaces - WHERE deleted_at IS NOT NULL - ) namespace_ids - ); + WHERE group_id IN (#{namespaces_pending_removal}); EOF execute <<-EOF.strip_heredoc DELETE FROM protected_branch_merge_access_levels - WHERE group_id IN ( - SELECT id FROM ( - SELECT id - FROM namespaces - WHERE deleted_at IS NOT NULL - ) namespace_ids - ); + WHERE group_id IN (#{namespaces_pending_removal}); EOF end - # This removes namespaces that were supposed to be soft deleted but still - # reside in the database. + # This removes namespaces that were supposed to be deleted but still reside + # in the database. execute "DELETE FROM namespaces WHERE deleted_at IS NOT NULL;" end @@ -54,4 +94,12 @@ class RemoveUndeletedGroups < ActiveRecord::Migration # If someone is trying to rollback for other reasons, we should not throw an Exception. # raise ActiveRecord::IrreversibleMigration end + + def namespaces_pending_removal + "SELECT id FROM ( + SELECT id + FROM namespaces + WHERE deleted_at IS NOT NULL + ) namespace_ids" + end end diff --git a/db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb b/db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb new file mode 100644 index 00000000000..b8d8742ae40 --- /dev/null +++ b/db/migrate/20161201001911_add_plant_uml_url_to_application_settings.rb @@ -0,0 +1,12 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPlantUmlUrlToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :plantuml_url, :string + end +end diff --git a/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb b/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb new file mode 100644 index 00000000000..3677f978cc2 --- /dev/null +++ b/db/migrate/20161206003819_add_plant_uml_enabled_to_application_settings.rb @@ -0,0 +1,12 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPlantUmlEnabledToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :plantuml_enabled, :boolean + end +end diff --git a/db/migrate/20161226122833_remove_dot_git_from_usernames.rb b/db/migrate/20161226122833_remove_dot_git_from_usernames.rb index 809b09feb84..7d97339581f 100644 --- a/db/migrate/20161226122833_remove_dot_git_from_usernames.rb +++ b/db/migrate/20161226122833_remove_dot_git_from_usernames.rb @@ -14,9 +14,8 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration namespace_id = user['namespace_id'] path_was = user['username'] path_was_wildcard = quote_string("#{path_was}/%") - path = quote_string(rename_path(path_was)) - move_namespace(namespace_id, path_was, path) + path = move_namespace(namespace_id, path_was, path) execute "UPDATE routes SET path = '#{path}' WHERE source_type = 'Namespace' AND source_id = #{namespace_id}" execute "UPDATE namespaces SET path = '#{path}' WHERE id = #{namespace_id}" @@ -45,9 +44,13 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration select_all("SELECT id, path FROM routes WHERE path = '#{quote_string(path)}'").present? end + def path_exists?(repository_storage_path, path) + gitlab_shell.exists?(repository_storage_path, path) + end + # Accepts invalid path like test.git and returns test_git or # test_git1 if test_git already taken - def rename_path(path) + def rename_path(repository_storage_path, path) # To stay closer with original name and reduce risk of duplicates # we rename suffix instead of removing it path = path.sub(/\.git\z/, '_git') @@ -55,7 +58,7 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration counter = 0 base = path - while route_exists?(path) + while route_exists?(path) || path_exists?(repository_storage_path, path) counter += 1 path = "#{base}#{counter}" end @@ -73,6 +76,8 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration # Ensure old directory exists before moving it gitlab_shell.add_namespace(repository_storage_path, path_was) + path = quote_string(rename_path(repository_storage_path, path_was)) + unless gitlab_shell.mv_namespace(repository_storage_path, path_was, path) Rails.logger.error "Exception moving path #{repository_storage_path} from #{path_was} to #{path}" @@ -83,5 +88,7 @@ class RemoveDotGitFromUsernames < ActiveRecord::Migration end Gitlab::UploadsTransfer.new.rename_namespace(path_was, path) + + path end end diff --git a/db/post_migrate/20170106142508_fill_authorized_projects.rb b/db/post_migrate/20170106142508_fill_authorized_projects.rb new file mode 100644 index 00000000000..314c8440c8b --- /dev/null +++ b/db/post_migrate/20170106142508_fill_authorized_projects.rb @@ -0,0 +1,30 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class FillAuthorizedProjects < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + class User < ActiveRecord::Base + self.table_name = 'users' + end + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # We're not inserting any data so we don't need to start a transaction. + disable_ddl_transaction! + + def up + relation = User.select(:id). + where('authorized_projects_populated IS NOT TRUE') + + relation.find_in_batches(batch_size: 1_000) do |rows| + args = rows.map { |row| [row.id] } + + Sidekiq::Client.push_bulk('class' => 'AuthorizedProjectsWorker', 'args' => args) + end + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index f3bf7ced393..c58a886b0fa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -107,6 +107,8 @@ ActiveRecord::Schema.define(version: 20170106172224) do t.integer "housekeeping_full_repack_period", default: 50, null: false t.integer "housekeeping_gc_period", default: 200, null: false t.boolean "html_emails_enabled", default: true + t.string "plantuml_url" + t.boolean "plantuml_enabled" end create_table "audit_events", force: :cascade do |t| diff --git a/doc/administration/img/integration/plantuml-example.png b/doc/administration/img/integration/plantuml-example.png Binary files differnew file mode 100644 index 00000000000..cb64eca1a8a --- /dev/null +++ b/doc/administration/img/integration/plantuml-example.png diff --git a/doc/administration/integration/plantuml.md b/doc/administration/integration/plantuml.md new file mode 100644 index 00000000000..e5cf592e0a6 --- /dev/null +++ b/doc/administration/integration/plantuml.md @@ -0,0 +1,87 @@ +# PlantUML & GitLab + +> [Introduced][ce-7810] in GitLab 8.16. + +When [PlantUML](http://plantuml.com) integration is enabled and configured in +GitLab we are able to create simple diagrams in AsciiDoc documents created in +snippets, wikis, and repos. + +## PlantUML Server + +Before you can enable PlantUML in GitLab; you need to set up your own PlantUML +server that will generate the diagrams. Installing and configuring your +own PlantUML server is easy in Debian/Ubuntu distributions using Tomcat. + +First you need to create a `plantuml.war` file from the source code: + +``` +sudo apt-get install graphviz openjdk-7-jdk git-core maven +git clone https://github.com/plantuml/plantuml-server.git +cd plantuml-server +mvn package +``` + +The above sequence of commands will generate a WAR file that can be deployed +using Tomcat: + +``` +sudo apt-get install tomcat7 +sudo cp target/plantuml.war /var/lib/tomcat7/webapps/plantuml.war +sudo chown tomcat7:tomcat7 /var/lib/tomcat7/webapps/plantuml.war +sudo service restart tomcat7 +``` + +Once the Tomcat service restarts the PlantUML service will be ready and +listening for requests on port 8080: + +``` +http://localhost:8080/plantuml +``` + +you can change these defaults by editing the `/etc/tomcat7/server.xml` file. + + +## GitLab + +You need to enable PlantUML integration from Settings under Admin Area. To do +that, login with an Admin account and do following: + + - in GitLab go to **Admin Area** and then **Settings** + - scroll to bottom of the page until PlantUML section + - check **Enable PlantUML** checkbox + - set the PlantUML instance as **PlantUML URL** + +## Creating Diagrams + +With PlantUML integration enabled and configured, we can start adding diagrams to +our AsciiDoc snippets, wikis and repos using blocks: + +``` +[plantuml, format="png", id="myDiagram", width="200px"] +-- +Bob->Alice : hello +Alice -> Bob : Go Away +-- +``` + +The above block will be converted to an HTML img tag with source pointing to the +PlantUML instance. If the PlantUML server is correctly configured, this should +render a nice diagram instead of the block: + +![PlantUML Integration](../img/integration/plantuml-example.png) + +Inside the block you can add any of the supported diagrams by PlantUML such as +[Sequence](http://plantuml.com/sequence-diagram), [Use Case](http://plantuml.com/use-case-diagram), +[Class](http://plantuml.com/class-diagram), [Activity](http://plantuml.com/activity-diagram-legacy), +[Component](http://plantuml.com/component-diagram), [State](http://plantuml.com/state-diagram), +and [Object](http://plantuml.com/object-diagram) diagrams. You do not need to use the PlantUML +diagram delimiters `@startuml`/`@enduml` as these are replaced by the AsciiDoc `plantuml` block. + +Some parameters can be added to the block definition: + + - *format*: Can be either `png` or `svg`. Note that `svg` is not supported by + all browsers so use with care. The default is `png`. + - *id*: A CSS id added to the diagram HTML tag. + - *width*: Width attribute added to the img tag. + - *height*: Height attribute added to the img tag. + diff --git a/doc/api/issues.md b/doc/api/issues.md index 119125bcd3d..dd84afd7c73 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -23,12 +23,15 @@ GET /issues?state=closed GET /issues?labels=foo GET /issues?labels=foo,bar GET /issues?labels=foo,bar&state=opened +GET /issues?milestone=1.0.0 +GET /issues?milestone=1.0.0&state=opened ``` | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `state` | string | no | Return all issues or just those that are `opened` or `closed`| | `labels` | string | no | Comma-separated list of label names, issues with any of the labels will be returned | +| `milestone` | string| no | The milestone title | | `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | diff --git a/doc/api/settings.md b/doc/api/settings.md index 0bd38a6e664..f86c7cc2f94 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -44,7 +44,9 @@ Example response: "repository_storage": "default", "repository_storages": ["default"], "koding_enabled": false, - "koding_url": null + "koding_url": null, + "plantuml_enabled": false, + "plantuml_url": null } ``` @@ -80,6 +82,8 @@ PUT /application/settings | `koding_enabled` | boolean | no | Enable Koding integration. Default is `false`. | | `koding_url` | string | yes (if `koding_enabled` is `true`) | The Koding instance URL for integration. | | `disabled_oauth_sign_in_sources` | Array of strings | no | Disabled OAuth sign-in sources | +| `plantuml_enabled` | boolean | no | Enable PlantUML integration. Default is `false`. | +| `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. | ```bash curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/application/settings?signup_enabled=false&default_project_visibility=1 @@ -112,6 +116,8 @@ Example response: "container_registry_token_expire_delay": 5, "repository_storage": "default", "koding_enabled": false, - "koding_url": null + "koding_url": null, + "plantuml_enabled": false, + "plantuml_url": null } ``` diff --git a/doc/install/installation.md b/doc/install/installation.md index 9dba03b1924..9cebed34b7e 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -601,6 +601,12 @@ If you want to connect the Redis server via socket, then use the "unix:" URL sch production: url: unix:/path/to/redis/socket +Also you can use environment variables in the `config/resque.yml` file: + + # example + production: + url: <%= ENV.fetch('GITLAB_REDIS_URL') %> + ### Custom SSH Connection If you are running SSH on a non-standard port, you must change the GitLab user's SSH config. diff --git a/doc/integration/README.md b/doc/integration/README.md index ed843c0bfa9..e97430feb57 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -16,6 +16,7 @@ See the documentation below for details on how to configure these services. - [reCAPTCHA](recaptcha.md) Configure GitLab to use Google reCAPTCHA for new users - [Akismet](akismet.md) Configure Akismet to stop spam - [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration +- [PlantUML](../administration/integration/plantuml.md) Configure PlantUML to use diagrams in AsciiDoc documents. GitLab Enterprise Edition contains [advanced Jenkins support][jenkins]. diff --git a/doc/project_services/slack_slash_commands.md b/doc/project_services/slack_slash_commands.md index b6b5c741d90..d9ff573d185 100644 --- a/doc/project_services/slack_slash_commands.md +++ b/doc/project_services/slack_slash_commands.md @@ -6,7 +6,7 @@ Slack commands give users an extra interface to perform common operations from the chat environment. This allows one to, for example, create an issue as soon as the idea was discussed in chat. For all available commands try the help subcommand, for example: `/gitlab help`, -all review the [full list of commands](../integrations/chat_commands.md). +all review the [full list of commands](../integration/chat_commands.md). ## Prerequisites diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 43ddc419054..f6b4db71b44 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -9,6 +9,9 @@ This archive will be saved in `backup_path`, which is specified in the The filename will be `[TIMESTAMP]_gitlab_backup.tar`, where `TIMESTAMP` identifies the time at which each backup was created. +> In GitLab 8.15 we changed the timestamp format from `EPOCH` (`1393513186`) +> to `EPOCH_YYYY_MM_DD` (`1393513186_2014_02_27`) + You can only restore a backup to exactly the same version of GitLab on which it was created. The best way to migrate your repositories from one server to another is through backup restore. @@ -91,7 +94,7 @@ In the example below we use Amazon S3 for storage, but Fog also lets you use for AWS, Google, OpenStack Swift and Rackspace as well. A local driver is [also available](#uploading-to-locally-mounted-shares). -For omnibus packages: +For omnibus packages, add the following to `/etc/gitlab/gitlab.rb`: ```ruby gitlab_rails['backup_upload_connection'] = { @@ -106,6 +109,8 @@ gitlab_rails['backup_upload_connection'] = { gitlab_rails['backup_upload_remote_directory'] = 'my.s3.bucket' ``` +Make sure to run `sudo gitlab-ctl reconfigure` after editing `/etc/gitlab/gitlab.rb` to reflect the changes. + For installations from source: ```yaml @@ -223,7 +228,8 @@ For installations from source: ## Backup archive permissions -The backup archives created by GitLab (123456_gitlab_backup.tar) will have owner/group git:git and 0600 permissions by default. +The backup archives created by GitLab (`1393513186_2014_02_27_gitlab_backup.tar`) +will have owner/group git:git and 0600 permissions by default. This is meant to avoid other system users reading GitLab's data. If you need the backup archives to have different permissions you can use the 'archive_permissions' setting. @@ -335,7 +341,7 @@ First make sure your backup tar file is in the backup directory described in the `/var/opt/gitlab/backups`. ```shell -sudo cp 1393513186_gitlab_backup.tar /var/opt/gitlab/backups/ +sudo cp 1393513186_2014_02_27_gitlab_backup.tar /var/opt/gitlab/backups/ ``` Stop the processes that are connected to the database. Leave the rest of GitLab diff --git a/doc/user/project/cycle_analytics.md b/doc/user/project/cycle_analytics.md index 86fe52ef4ff..62afd8cf247 100644 --- a/doc/user/project/cycle_analytics.md +++ b/doc/user/project/cycle_analytics.md @@ -50,7 +50,7 @@ exception of the staging and production stages, where only data deployed to production are measured. Specifically, if your CI is not set up and you have not defined a `production` -[environment], then you will not have any data for those stages. +or `production/*` [environment], then you will not have any data for those stages. Below you can see in more detail what the various stages of Cycle Analytics mean. @@ -61,7 +61,7 @@ Below you can see in more detail what the various stages of Cycle Analytics mean | Code | Measures the median time between pushing a first commit (previous stage) and creating a merge request (MR) related to that commit. The key to keep the process tracked is to include the [issue closing pattern] to the description of the merge request (for example, `Closes #xxx`, where `xxx` is the number of the issue related to this merge request). If the issue closing pattern is not present in the merge request description, the MR is not considered to the measurement time of the stage. | | Test | Measures the median time to run the entire pipeline for that project. It's related to the time GitLab CI takes to run every job for the commits pushed to that merge request defined in the previous stage. It is basically the start->finish time for all pipelines. `master` is not excluded. It does not attempt to track time for any particular stages. | | Review | Measures the median time taken to review the merge request, between its creation and until it's merged. | -| Staging | Measures the median time between merging the merge request until the very first deployment to production. It's tracked by the [environment] set to `production` (case-sensitive, `Production` won't work) in your GitLab CI configuration. If there isn't a `production` environment, this is not tracked. | +| Staging | Measures the median time between merging the merge request until the very first deployment to production. It's tracked by the [environment] set to `production` or matching `production/*` (case-sensitive, `Production` won't work) in your GitLab CI configuration. If there isn't a production environment, this is not tracked. | | Production| The sum of all time (medians) taken to run the entire process, from issue creation to deploying the code to production. | --- @@ -79,10 +79,13 @@ Here's a little explanation of how this works behind the scenes: etc. To sum up, anything that doesn't follow the [GitLab flow] won't be tracked at all. -So, if a merge request doesn't close an issue or an issue is not labeled with a -label present in the Issue Board or assigned a milestone or a project has no -`production` environment (for staging and production stages), the Cycle Analytics -dashboard won't present any data at all. +So, the Cycle Analytics dashboard won't present any data: +- For merge requests that do not close an issue. +- For issues not labeled with a label present in the Issue Board. +- For issues not assigned a milestone. +- For staging and production stages, if the project has no `production` or `production/*` + environment. + ## Example workflow diff --git a/doc/workflow/importing/import_projects_from_gitlab_com.md b/doc/workflow/importing/import_projects_from_gitlab_com.md index dcc00074b75..b27125a44de 100644 --- a/doc/workflow/importing/import_projects_from_gitlab_com.md +++ b/doc/workflow/importing/import_projects_from_gitlab_com.md @@ -5,6 +5,9 @@ GitLab support is enabled on your GitLab instance. You can read more about GitLab support [here](http://docs.gitlab.com/ce/integration/gitlab.html) To get to the importer page you need to go to "New project" page. +>**Note:** +If you are interested in importing Wiki and Merge Request data to your new instance, you'll need to follow the instructions for [project export](../../user/project/settings/import_export.md) + ![New project page](gitlab_importer/new_project_page.png) Click on the "Import projects from GitLab.com" link and you will be redirected to GitLab.com diff --git a/features/admin/users.feature b/features/admin/users.feature deleted file mode 100644 index 6755645778a..00000000000 --- a/features/admin/users.feature +++ /dev/null @@ -1,65 +0,0 @@ -@admin -Feature: Admin Users - Background: - Given I sign in as an admin - And system has users - - Scenario: On Admin Users - Given I visit admin users page - Then I should see all users - - Scenario: Edit user and change username to non ascii char - When I visit admin users page - And Click edit - And Input non ascii char in username - And Click save - Then See username error message - And Not changed form action url - - Scenario: Show user attributes - Given user "Mike" with groups and projects - Given I visit admin users page - And click on "Mike" link - Then I should see user "Mike" details - - Scenario: Edit my user attributes - Given I visit admin users page - And click edit on my user - When I submit modified user - Then I see user attributes changed - - @javascript - Scenario: Remove users secondary email - Given I visit admin users page - And I view the user with secondary email - And I see the secondary email - When I click remove secondary email - Then I should not see secondary email anymore - - Scenario: Show user keys - Given user "Pete" with ssh keys - And I visit admin users page - And click on user "Pete" - And click on ssh keys tab - Then I should see key list - And I click on the key title - Then I should see key details - And I click on remove key - Then I should see the key removed - - Scenario: Show user identities - Given user "Pete" with twitter account - And I visit "Pete" identities page in admin - Then I should see twitter details - - Scenario: Update user identities - Given user "Pete" with twitter account - And I visit "Pete" identities page in admin - And I modify twitter identity - Then I should see twitter details updated - - Scenario: Remove user identities - Given user "Pete" with twitter account - And I visit "Pete" identities page in admin - And I remove twitter identity - Then I should not see twitter details diff --git a/features/dashboard/active_tab.feature b/features/dashboard/active_tab.feature deleted file mode 100644 index bd883a0ebfa..00000000000 --- a/features/dashboard/active_tab.feature +++ /dev/null @@ -1,24 +0,0 @@ -@dashboard -Feature: Dashboard Active Tab - Background: - Given I sign in as a user - - Scenario: On Dashboard Home - Given I visit dashboard page - Then the active main tab should be Home - And no other main tabs should be active - - Scenario: On Dashboard Issues - Given I visit dashboard issues page - Then the active main tab should be Issues - And no other main tabs should be active - - Scenario: On Dashboard Merge Requests - Given I visit dashboard merge requests page - Then the active main tab should be Merge Requests - And no other main tabs should be active - - Scenario: On Dashboard Groups - Given I visit dashboard groups page - Then the active main tab should be Groups - And no other main tabs should be active diff --git a/features/dashboard/archived_projects.feature b/features/dashboard/archived_projects.feature deleted file mode 100644 index bed9282f1c6..00000000000 --- a/features/dashboard/archived_projects.feature +++ /dev/null @@ -1,17 +0,0 @@ -@dashboard -Feature: Dashboard Archived Projects - Background: - Given I sign in as a user - And I own project "Shop" - And I own project "Forum" - And project "Forum" is archived - And I visit dashboard page - - Scenario: I should see non-archived projects on dashboard - Then I should see "Shop" project link - And I should not see "Forum" project link - - Scenario: I toggle show of archived projects on dashboard - When I click "Show archived projects" link - Then I should see "Shop" project link - And I should see "Forum" project link diff --git a/features/dashboard/group.feature b/features/dashboard/group.feature deleted file mode 100644 index 3ae2c679dc1..00000000000 --- a/features/dashboard/group.feature +++ /dev/null @@ -1,13 +0,0 @@ -@dashboard -Feature: Dashboard Group - Background: - Given I sign in as "John Doe" - And "John Doe" is owner of group "Owned" - And "John Doe" is guest of group "Guest" - - Scenario: Create a group from dasboard - And I visit dashboard groups page - And I click new group link - And submit form with new group "Samurai" info - Then I should be redirected to group "Samurai" page - And I should see newly created group "Samurai" diff --git a/features/dashboard/help.feature b/features/dashboard/help.feature deleted file mode 100644 index bca2772897b..00000000000 --- a/features/dashboard/help.feature +++ /dev/null @@ -1,9 +0,0 @@ -@dashboard -Feature: Dashboard Help - Background: - Given I sign in as a user - And I visit the "Rake Tasks" help page - - Scenario: The markdown should be rendered correctly - Then I should see "Rake Tasks" page markdown rendered - And Header "Rebuild project satellites" should have correct ids and links diff --git a/features/project/issues/filter_labels.feature b/features/project/issues/filter_labels.feature deleted file mode 100644 index 49d7a3b9af2..00000000000 --- a/features/project/issues/filter_labels.feature +++ /dev/null @@ -1,28 +0,0 @@ -@project_issues -Feature: Project Issues Filter Labels - Background: - Given I sign in as a user - And I own project "Shop" - And project "Shop" has labels: "bug", "feature", "enhancement" - And project "Shop" has issue "Bugfix1" with labels: "bug", "feature" - And project "Shop" has issue "Bugfix2" with labels: "bug", "enhancement" - And project "Shop" has issue "Feature1" with labels: "feature" - Given I visit project "Shop" issues page - - @javascript - Scenario: I filter by one label - Given I click link "bug" - And I click "dropdown close button" - Then I should see "Bugfix1" in issues list - And I should see "Bugfix2" in issues list - And I should not see "Feature1" in issues list - - # TODO: make labels filter works according to this scanario - # right now it looks for label 1 OR label 2. Old behaviour (this test) was - # all issues that have both label 1 AND label 2 - #Scenario: I filter by two labels - #Given I click link "bug" - #And I click link "feature" - #Then I should see "Bugfix1" in issues list - #And I should not see "Bugfix2" in issues list - #And I should not see "Feature1" in issues list diff --git a/features/project/issues/issues.feature b/features/project/issues/issues.feature index 80670063ea0..b2b4fe72220 100644 --- a/features/project/issues/issues.feature +++ b/features/project/issues/issues.feature @@ -26,12 +26,6 @@ Feature: Project Issues Given I click link "Release 0.4" Then I should see issue "Release 0.4" - @javascript - Scenario: I filter by author - Given I add a user to project "Shop" - And I click "author" dropdown - Then I see current user as the first user - Scenario: I submit new unassigned issue Given I click link "New Issue" And I submit new issue "500 error on profile" @@ -84,56 +78,6 @@ Feature: Project Issues And I sort the list by "Least popular" Then The list should be sorted by "Least popular" - @javascript - Scenario: I search issue - Given I fill in issue search with "Re" - Then I should see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: I search issue that not exist - Given I fill in issue search with "Bu" - Then I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - - @javascript - Scenario: I search all issues - Given I click link "All" - And I fill in issue search with ".3" - Then I should see "Release 0.3" in issues - And I should not see "Release 0.4" in issues - - @javascript - Scenario: Search issues when search string exactly matches issue description - Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1' - And I fill in issue search with 'Description for issue1' - Then I should see 'Bugfix1' in issues - And I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: Search issues when search string partially matches issue description - Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1' - And project 'Shop' has issue 'Feature1' with description: 'Feature submitted for issue1' - And I fill in issue search with 'issue1' - Then I should see 'Feature1' in issues - Then I should see 'Bugfix1' in issues - And I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - @javascript - Scenario: Search issues when search string matches no issue description - Given project 'Shop' has issue 'Bugfix1' with description: 'Description for issue1' - And I fill in issue search with 'Rock and roll' - Then I should not see 'Bugfix1' in issues - And I should not see "Release 0.4" in issues - And I should not see "Release 0.3" in issues - And I should not see "Tweet control" in issues - - # Markdown Scenario: Headers inside the description should have ids generated for them. diff --git a/features/steps/admin/users.rb b/features/steps/admin/users.rb deleted file mode 100644 index 8fb8a86d58b..00000000000 --- a/features/steps/admin/users.rb +++ /dev/null @@ -1,167 +0,0 @@ -class Spinach::Features::AdminUsers < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedAdmin - - before do - allow(Gitlab::OAuth::Provider).to receive(:providers).and_return([:twitter, :twitter_updated]) - allow_any_instance_of(ApplicationHelper).to receive(:user_omniauth_authorize_path).and_return(root_path) - end - - after do - allow(Gitlab::OAuth::Provider).to receive(:providers).and_call_original - allow_any_instance_of(ApplicationHelper).to receive(:user_omniauth_authorize_path).and_call_original - end - - step 'I should see all users' do - User.all.each do |user| - expect(page).to have_content user.name - end - end - - step 'Click edit' do - @user = User.first - find("#edit_user_#{@user.id}").click - end - - step 'Input non ascii char in username' do - fill_in 'user_username', with: "\u3042\u3044" - end - - step 'Click save' do - click_button("Save") - end - - step 'See username error message' do - page.within "#error_explanation" do - expect(page).to have_content "Username" - end - end - - step 'Not changed form action url' do - expect(page).to have_selector %(form[action="/admin/users/#{@user.username}"]) - end - - step 'I submit modified user' do - check :user_can_create_group - click_button 'Save' - end - - step 'I see user attributes changed' do - expect(page).to have_content 'Can create groups: Yes' - end - - step 'click edit on my user' do - find("#edit_user_#{current_user.id}").click - end - - step 'I view the user with secondary email' do - @user_with_secondary_email = User.last - @user_with_secondary_email.emails.new(email: "secondary@example.com") - @user_with_secondary_email.save - visit "/admin/users/#{@user_with_secondary_email.username}" - end - - step 'I see the secondary email' do - expect(page).to have_content "Secondary email: #{@user_with_secondary_email.emails.last.email}" - end - - step 'I click remove secondary email' do - find("#remove_email_#{@user_with_secondary_email.emails.last.id}").click - end - - step 'I should not see secondary email anymore' do - expect(page).not_to have_content "Secondary email:" - end - - step 'user "Mike" with groups and projects' do - user = create(:user, name: 'Mike') - - project = create(:empty_project) - project.team << [user, :developer] - - group = create(:group) - group.add_developer(user) - end - - step 'click on "Mike" link' do - click_link "Mike" - end - - step 'I should see user "Mike" details' do - expect(page).to have_content 'Account' - expect(page).to have_content 'Personal projects limit' - end - - step 'user "Pete" with ssh keys' do - user = create(:user, name: 'Pete') - create(:key, user: user, title: "ssh-rsa Key1", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC4FIEBXGi4bPU8kzxMefudPIJ08/gNprdNTaO9BR/ndy3+58s2HCTw2xCHcsuBmq+TsAqgEidVq4skpqoTMB+Uot5Uzp9z4764rc48dZiI661izoREoKnuRQSsRqUTHg5wrLzwxlQbl1MVfRWQpqiz/5KjBC7yLEb9AbusjnWBk8wvC1bQPQ1uLAauEA7d836tgaIsym9BrLsMVnR4P1boWD3Xp1B1T/ImJwAGHvRmP/ycIqmKdSpMdJXwxcb40efWVj0Ibbe7ii9eeoLdHACqevUZi6fwfbymdow+FeqlkPoHyGg3Cu4vD/D8+8cRc7mE/zGCWcQ15Var83Tczour Key1") - create(:key, user: user, title: "ssh-rsa Key2", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQSTWXhJAX/He+nG78MiRRRn7m0Pb0XbcgTxE0etArgoFoh9WtvDf36HG6tOSg/0UUNcp0dICsNAmhBKdncp6cIyPaXJTURPRAGvhI0/VDk4bi27bRnccGbJ/hDaUxZMLhhrzY0r22mjVf8PF6dvv5QUIQVm1/LeaWYsHHvLgiIjwrXirUZPnFrZw6VLREoBKG8uWvfSXw1L5eapmstqfsME8099oi+vWLR8MgEysZQmD28M73fgW4zek6LDQzKQyJx9nB+hJkKUDvcuziZjGmRFlNgSA2mguERwL1OXonD8WYUrBDGKroIvBT39zS5d9tQDnidEJZ9Y8gv5ViYP7x Key2") - end - - step 'click on user "Pete"' do - click_link 'Pete' - end - - step 'I should see key list' do - expect(page).to have_content 'ssh-rsa Key2' - expect(page).to have_content 'ssh-rsa Key1' - end - - step 'I click on the key title' do - click_link 'ssh-rsa Key2' - end - - step 'I should see key details' do - expect(page).to have_content 'ssh-rsa Key2' - expect(page).to have_content 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQSTWXhJAX/He+nG78MiRRRn7m0Pb0XbcgTxE0etArgoFoh9WtvDf36HG6tOSg/0UUNcp0dICsNAmhBKdncp6cIyPaXJTURPRAGvhI0/VDk4bi27bRnccGbJ/hDaUxZMLhhrzY0r22mjVf8PF6dvv5QUIQVm1/LeaWYsHHvLgiIjwrXirUZPnFrZw6VLREoBKG8uWvfSXw1L5eapmstqfsME8099oi+vWLR8MgEysZQmD28M73fgW4zek6LDQzKQyJx9nB+hJkKUDvcuziZjGmRFlNgSA2mguERwL1OXonD8WYUrBDGKroIvBT39zS5d9tQDnidEJZ9Y8gv5ViYP7x Key2' - end - - step 'I click on remove key' do - click_link 'Remove' - end - - step 'I should see the key removed' do - expect(page).not_to have_content 'ssh-rsa Key2' - end - - step 'user "Pete" with twitter account' do - @user = create(:user, name: 'Pete') - @user.identities.create!(extern_uid: '123456', provider: 'twitter') - end - - step 'I visit "Pete" identities page in admin' do - visit admin_user_identities_path(@user) - end - - step 'I should see twitter details' do - expect(page).to have_content 'Pete' - expect(page).to have_content 'twitter' - end - - step 'I modify twitter identity' do - find('.table').find(:link, 'Edit').click - fill_in 'identity_extern_uid', with: '654321' - select 'twitter_updated', from: 'identity_provider' - click_button 'Save changes' - end - - step 'I should see twitter details updated' do - expect(page).to have_content 'Pete' - expect(page).to have_content 'twitter_updated' - expect(page).to have_content '654321' - end - - step 'I remove twitter identity' do - click_link 'Delete' - end - - step 'I should not see twitter details' do - expect(page).to have_content 'Pete' - expect(page).not_to have_content 'twitter' - end - - step 'click on ssh keys tab' do - click_link 'SSH keys' - end -end diff --git a/features/steps/dashboard/active_tab.rb b/features/steps/dashboard/active_tab.rb deleted file mode 100644 index 04fe96cef22..00000000000 --- a/features/steps/dashboard/active_tab.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Spinach::Features::DashboardActiveTab < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedSidebarActiveTab -end diff --git a/features/steps/dashboard/archived_projects.rb b/features/steps/dashboard/archived_projects.rb deleted file mode 100644 index 6510f8d9b32..00000000000 --- a/features/steps/dashboard/archived_projects.rb +++ /dev/null @@ -1,26 +0,0 @@ -class Spinach::Features::DashboardArchivedProjects < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedProject - - When 'project "Forum" is archived' do - project = Project.find_by(name: "Forum") - project.update_attribute(:archived, true) - end - - step 'I should see "Shop" project link' do - expect(page).to have_link "Shop" - end - - step 'I should not see "Forum" project link' do - expect(page).not_to have_link "Forum" - end - - step 'I should see "Forum" project link' do - expect(page).to have_link "Forum" - end - - step 'I click "Show archived projects" link' do - click_link "Show archived projects" - end -end diff --git a/features/steps/dashboard/group.rb b/features/steps/dashboard/group.rb deleted file mode 100644 index cf679fea530..00000000000 --- a/features/steps/dashboard/group.rb +++ /dev/null @@ -1,25 +0,0 @@ -class Spinach::Features::DashboardGroup < Spinach::FeatureSteps - include SharedAuthentication - include SharedGroup - include SharedPaths - include SharedUser - - step 'I click new group link' do - click_link "New Group" - end - - step 'submit form with new group "Samurai" info' do - fill_in 'group_path', with: 'Samurai' - fill_in 'group_description', with: 'Tokugawa Shogunate' - click_button "Create group" - end - - step 'I should be redirected to group "Samurai" page' do - expect(current_path).to eq group_path(Group.find_by(name: 'Samurai')) - end - - step 'I should see newly created group "Samurai"' do - expect(page).to have_content "Samurai" - expect(page).to have_content "Tokugawa Shogunate" - end -end diff --git a/features/steps/dashboard/help.rb b/features/steps/dashboard/help.rb deleted file mode 100644 index 3c5bf44c538..00000000000 --- a/features/steps/dashboard/help.rb +++ /dev/null @@ -1,21 +0,0 @@ -class Spinach::Features::DashboardHelp < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedMarkdown - - step 'I visit the help page' do - visit help_path - end - - step 'I visit the "Rake Tasks" help page' do - visit help_page_path("administration/raketasks/maintenance") - end - - step 'I should see "Rake Tasks" page markdown rendered' do - expect(page).to have_content "Gather information about GitLab and the system it runs on" - end - - step 'Header "Rebuild project satellites" should have correct ids and links' do - header_should_have_correct_id_and_link(2, 'Check GitLab configuration', 'check-gitlab-configuration', '.documentation') - end -end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index d2fadf6a3d0..885ce7d44bc 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -565,6 +565,8 @@ module API expose :repository_storages expose :koding_enabled expose :koding_url + expose :plantuml_enabled + expose :plantuml_url end class Release < Grape::Entity diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 54b97402426..161269cbd41 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -5,13 +5,31 @@ module API before { authenticate! } helpers do - # TODO: Remove in 9.0 and switch to IssueFinder-based label filtering - def filter_issues_labels(issues, labels) - issues.includes(:labels).where('labels.title' => labels.split(',')) + def find_issues(args = {}) + args = params.merge(args) + + args.delete(:id) + args[:milestone_title] = args.delete(:milestone) + + match_all_labels = args.delete(:match_all_labels) + labels = args.delete(:labels) + args[:label_name] = labels if match_all_labels + + args[:search] = "#{Issue.reference_prefix}#{args.delete(:iid)}" if args.key?(:iid) + + issues = IssuesFinder.new(current_user, args).execute.inc_notes_with_associations + + # TODO: Remove in 9.0 pass `label_name: args.delete(:labels)` to IssuesFinder + if !match_all_labels && labels.present? + issues = issues.includes(:labels).where('labels.title' => labels.split(',')) + end + + issues.reorder(args[:order_by] => args[:sort]) end params :issues_params do optional :labels, type: String, desc: 'Comma-separated list of label names' + optional :milestone, type: String, desc: 'Milestone title' optional :order_by, type: String, values: %w[created_at updated_at], default: 'created_at', desc: 'Return issues ordered by `created_at` or `updated_at` fields.' optional :sort, type: String, values: %w[asc desc], default: 'desc', @@ -40,9 +58,7 @@ module API use :issues_params end get do - issues = IssuesFinder.new(current_user, scope: 'all', author_id: current_user.id, state: params[:state]).execute.inc_notes_with_associations - issues = filter_issues_labels(issues, params[:labels]) unless params[:labels].nil? - issues = issues.reorder(params[:order_by] => params[:sort]) + issues = find_issues(scope: 'authored') present paginate(issues), with: Entities::Issue, current_user: current_user end @@ -61,15 +77,10 @@ module API use :issues_params end get ":id/issues" do - group = find_group!(params.delete(:id)) + group = find_group!(params[:id]) - params[:group_id] = group.id - params[:milestone_title] = params.delete(:milestone) - params[:label_name] = params.delete(:labels) + issues = find_issues(group_id: group.id, state: params[:state] || 'opened', match_all_labels: true) - issues = IssuesFinder.new(current_user, params).execute - - issues = issues.reorder(params[:order_by] => params[:sort]) present paginate(issues), with: Entities::Issue, current_user: current_user end end @@ -84,17 +95,13 @@ module API params do optional :state, type: String, values: %w[opened closed all], default: 'all', desc: 'Return opened, closed, or all issues' - optional :iid, type: Integer, desc: 'The IID of the issue' + optional :iid, type: Integer, desc: 'Return the issue having the given `iid`' use :issues_params end get ":id/issues" do - issues = IssuesFinder.new(current_user, - project_id: user_project.id, - state: params[:state], - milestone_title: params[:milestone]).execute.inc_notes_with_associations - issues = filter_issues_labels(issues, params[:labels]) unless params[:labels].nil? - issues = filter_by_iid(issues, params[:iid]) unless params[:iid].nil? - issues = issues.reorder(params[:order_by] => params[:sort]) + project = find_project(params[:id]) + + issues = find_issues(project_id: project.id) present paginate(issues), with: Entities::Issue, current_user: current_user, project: user_project end diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 9eb9a105bde..c5eff16a5de 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -93,6 +93,10 @@ module API given koding_enabled: ->(val) { val } do requires :koding_url, type: String, desc: 'The Koding team URL' end + optional :plantuml_enabled, type: Boolean, desc: 'Enable PlantUML' + given plantuml_enabled: ->(val) { val } do + requires :plantuml_url, type: String, desc: 'The PlantUML server URL' + end optional :version_check_enabled, type: Boolean, desc: 'Let GitLab inform you when an update is available.' optional :email_author_in_body, type: Boolean, desc: 'Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.' optional :html_emails_enabled, type: Boolean, desc: 'By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format.' @@ -114,7 +118,7 @@ module API :shared_runners_enabled, :max_artifacts_size, :container_registry_token_expire_delay, :metrics_enabled, :sidekiq_throttling_enabled, :recaptcha_enabled, :akismet_enabled, :admin_notification_email, :sentry_enabled, - :repository_storage, :repository_checks_enabled, :koding_enabled, + :repository_storage, :repository_checks_enabled, :koding_enabled, :plantuml_enabled, :version_check_enabled, :email_author_in_body, :html_emails_enabled, :housekeeping_enabled end diff --git a/lib/api/users.rb b/lib/api/users.rb index 0db76ec7877..11a7368b4c0 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -93,7 +93,7 @@ module API # Filter out params which are used later user_params = declared_params(include_missing: false) identity_attrs = user_params.slice(:provider, :extern_uid) - confirm = params.delete(:confirm) + confirm = user_params.delete(:confirm) user = User.new(user_params.except(:extern_uid, :provider)) user.skip_confirmation! unless confirm diff --git a/lib/backup/manager.rb b/lib/backup/manager.rb index 7e6537e3d9e..cefbfdce3bb 100644 --- a/lib/backup/manager.rb +++ b/lib/backup/manager.rb @@ -2,6 +2,7 @@ module Backup class Manager ARCHIVES_TO_BACKUP = %w[uploads builds artifacts lfs registry] FOLDERS_TO_BACKUP = %w[repositories db] + FILE_NAME_SUFFIX = '_gitlab_backup.tar' def pack # Make sure there is a connection @@ -14,7 +15,7 @@ module Backup s[:gitlab_version] = Gitlab::VERSION s[:tar_version] = tar_version s[:skipped] = ENV["SKIP"] - tar_file = s[:backup_created_at].strftime('%s_%Y_%m_%d') + '_gitlab_backup.tar' + tar_file = "#{s[:backup_created_at].strftime('%s_%Y_%m_%d')}#{FILE_NAME_SUFFIX}" Dir.chdir(Gitlab.config.backup.path) do File.open("#{Gitlab.config.backup.path}/backup_information.yml", @@ -82,7 +83,7 @@ module Backup removed = 0 Dir.chdir(Gitlab.config.backup.path) do - Dir.glob('*_gitlab_backup.tar').each do |file| + Dir.glob("*#{FILE_NAME_SUFFIX}").each do |file| next unless file =~ /(\d+)(?:_\d{4}_\d{2}_\d{2})?_gitlab_backup\.tar/ timestamp = $1.to_i @@ -108,41 +109,50 @@ module Backup Dir.chdir(Gitlab.config.backup.path) # check for existing backups in the backup dir - file_list = Dir.glob("*_gitlab_backup.tar") - puts "no backups found" if file_list.count == 0 + file_list = Dir.glob("*#{FILE_NAME_SUFFIX}") + + if file_list.count == 0 + $progress.puts "No backups found in #{Gitlab.config.backup.path}" + $progress.puts "Please make sure that file name ends with #{FILE_NAME_SUFFIX}" + exit 1 + end if file_list.count > 1 && ENV["BACKUP"].nil? - puts "Found more than one backup, please specify which one you want to restore:" - puts "rake gitlab:backup:restore BACKUP=timestamp_of_backup" + $progress.puts 'Found more than one backup, please specify which one you want to restore:' + $progress.puts 'rake gitlab:backup:restore BACKUP=timestamp_of_backup' exit 1 end - tar_file = ENV["BACKUP"].nil? ? file_list.first : file_list.grep(ENV['BACKUP']).first + if ENV['BACKUP'].present? + tar_file = "#{ENV['BACKUP']}#{FILE_NAME_SUFFIX}" + else + tar_file = file_list.first + end unless File.exist?(tar_file) - puts "The specified backup doesn't exist!" + $progress.puts "The backup file #{tar_file} does not exist!" exit 1 end - $progress.print "Unpacking backup ... " + $progress.print 'Unpacking backup ... ' unless Kernel.system(*%W(tar -xf #{tar_file})) - puts "unpacking backup failed".color(:red) + $progress.puts 'unpacking backup failed'.color(:red) exit 1 else - $progress.puts "done".color(:green) + $progress.puts 'done'.color(:green) end ENV["VERSION"] = "#{settings[:db_version]}" if settings[:db_version].to_i > 0 # restoring mismatching backups can lead to unexpected problems if settings[:gitlab_version] != Gitlab::VERSION - puts "GitLab version mismatch:".color(:red) - puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red) - puts " Please switch to the following version and try again:".color(:red) - puts " version: #{settings[:gitlab_version]}".color(:red) - puts - puts "Hint: git checkout v#{settings[:gitlab_version]}" + $progress.puts 'GitLab version mismatch:'.color(:red) + $progress.puts " Your current GitLab version (#{Gitlab::VERSION}) differs from the GitLab version in the backup!".color(:red) + $progress.puts ' Please switch to the following version and try again:'.color(:red) + $progress.puts " version: #{settings[:gitlab_version]}".color(:red) + $progress.puts + $progress.puts "Hint: git checkout v#{settings[:gitlab_version]}" exit 1 end end diff --git a/lib/gitlab/asciidoc.rb b/lib/gitlab/asciidoc.rb index fa234284361..0618107e2c3 100644 --- a/lib/gitlab/asciidoc.rb +++ b/lib/gitlab/asciidoc.rb @@ -1,5 +1,6 @@ require 'asciidoctor' require 'asciidoctor/converter/html5' +require "asciidoctor-plantuml" module Gitlab # Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters @@ -29,6 +30,8 @@ module Gitlab ) asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS) + plantuml_setup + html = ::Asciidoctor.convert(input, asciidoc_opts) html = Banzai.post_process(html, context) @@ -36,6 +39,15 @@ module Gitlab html.html_safe end + def self.plantuml_setup + Asciidoctor::PlantUml.configure do |conf| + conf.url = ApplicationSetting.current.plantuml_url + conf.svg_enable = ApplicationSetting.current.plantuml_enabled + conf.png_enable = ApplicationSetting.current.plantuml_enabled + conf.txt_enable = false + end + end + class Html5Converter < Asciidoctor::Converter::Html5Converter extend Asciidoctor::Converter::Config diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index 9c391fa92a3..273118135a9 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -30,9 +30,9 @@ module Gitlab return unless @branch_name return unless project.protected_branch?(@branch_name) - if forced_push? && user_access.cannot_do_action?(:force_push_code_to_protected_branches) + 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) && user_access.cannot_do_action?(:remove_protected_branches) + elsif Gitlab::Git.blank_ref?(@newrev) return "You are not allowed to delete protected branches from this project." end diff --git a/lib/gitlab/ci/config/entry/environment.rb b/lib/gitlab/ci/config/entry/environment.rb index b7b4b91eb51..f7c530c7d9f 100644 --- a/lib/gitlab/ci/config/entry/environment.rb +++ b/lib/gitlab/ci/config/entry/environment.rb @@ -33,7 +33,6 @@ module Gitlab validates :url, length: { maximum: 255 }, - addressable_url: true, allow_nil: true validates :action, diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index 9d142f1b82e..2ff27e46d64 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -35,6 +35,7 @@ module Gitlab signin_enabled: Settings.gitlab['signin_enabled'], gravatar_enabled: Settings.gravatar['enabled'], koding_enabled: false, + plantuml_enabled: false, sign_in_text: nil, after_sign_up_text: nil, help_page_text: nil, diff --git a/lib/gitlab/git/blame.rb b/lib/gitlab/git/blame.rb index 46f3969b6e1..2913230e979 100644 --- a/lib/gitlab/git/blame.rb +++ b/lib/gitlab/git/blame.rb @@ -27,7 +27,7 @@ module Gitlab private def load_blame - cmd = %W(git --git-dir=#{@repo.path} blame -p #{@sha} -- #{@path}) + cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{@repo.path} blame -p #{@sha} -- #{@path}) # Read in binary mode to ensure ASCII-8BIT raw_output = IO.popen(cmd, 'rb') {|io| io.read } output = encode_utf8(raw_output) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 963b326a730..79b23d59b3a 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -332,7 +332,7 @@ module Gitlab end def log_by_shell(sha, options) - cmd = %W(git --git-dir=#{path} log) + cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} log) cmd += %W(-n #{options[:limit].to_i}) cmd += %w(--format=%H) cmd += %W(--skip=#{options[:offset].to_i}) @@ -913,7 +913,7 @@ module Gitlab return [] end - cmd = %W(git --git-dir=#{path} ls-tree) + cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} ls-tree) cmd += %w(-r) cmd += %w(--full-tree) cmd += %w(--full-name) @@ -1108,7 +1108,7 @@ module Gitlab end def archive_to_file(treeish = 'master', filename = 'archive.tar.gz', format = nil, compress_cmd = %w(gzip -n)) - git_archive_cmd = %W(git --git-dir=#{path} archive) + git_archive_cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{path} archive) # Put files into a directory before archiving prefix = "#{archive_name(treeish)}/" diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 4d4e04e9e35..b8a5ac907a4 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -13,6 +13,7 @@ module Gitlab if current_user gon.current_user_id = current_user.id + gon.current_username = current_user.username end end end diff --git a/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb index 7ed01bf56ca..54a5b1d31cd 100644 --- a/lib/gitlab/ldap/access.rb +++ b/lib/gitlab/ldap/access.rb @@ -69,7 +69,7 @@ module Gitlab user.ldap_block Gitlab::AppLogger.info( - "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " + + "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \ "blocking Gitlab user \"#{user.name}\" (#{user.email})" ) end @@ -78,7 +78,7 @@ module Gitlab user.activate Gitlab::AppLogger.info( - "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " + + "LDAP account \"#{user.ldap_identity.extern_uid}\" #{reason}, " \ "unblocking Gitlab user \"#{user.name}\" (#{user.email})" ) end diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index d01d47a6a7a..47f88727fc8 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -71,10 +71,17 @@ module Gitlab def tag_endpoint(trans, env) endpoint = env[ENDPOINT_KEY] - # endpoint.route is nil in the case of a 405 response - if endpoint.route - path = endpoint_paths_cache[endpoint.route.request_method][endpoint.route.path] - trans.action = "Grape##{endpoint.route.request_method} #{path}" + begin + route = endpoint.route + rescue + # endpoint.route is calling env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info] + # but env[Grape::Env::GRAPE_ROUTING_ARGS] is nil in the case of a 405 response + # so we're rescuing exceptions and bailing out + end + + if route + path = endpoint_paths_cache[route.request_method][route.path] + trans.action = "Grape##{route.request_method} #{path}" end end diff --git a/lib/gitlab/redis.rb b/lib/gitlab/redis.rb index 9226da2d6b1..9384102acec 100644 --- a/lib/gitlab/redis.rb +++ b/lib/gitlab/redis.rb @@ -42,7 +42,7 @@ module Gitlab return @_raw_config if defined?(@_raw_config) begin - @_raw_config = File.read(CONFIG_FILE).freeze + @_raw_config = ERB.new(File.read(CONFIG_FILE)).result.freeze rescue Errno::ENOENT @_raw_config = false end diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 9e0b0e5ea98..a3fa7c1331a 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -61,11 +61,11 @@ module Gitlab end def file_name_regex - @file_name_regex ||= /\A[[[:alnum:]]_\-\.\@]*\z/.freeze + @file_name_regex ||= /\A[[[:alnum:]]_\-\.\@\+]*\z/.freeze end def file_name_regex_message - "can contain only letters, digits, '_', '-', '@' and '.'." + "can contain only letters, digits, '_', '-', '@', '+' and '.'." end def file_path_regex diff --git a/lib/tasks/gitlab/git.rake b/lib/tasks/gitlab/git.rake index f9834a4dae8..a67c1fe1f27 100644 --- a/lib/tasks/gitlab/git.rake +++ b/lib/tasks/gitlab/git.rake @@ -3,7 +3,7 @@ namespace :gitlab do desc "GitLab | Git | Repack" task repack: :environment do - failures = perform_git_cmd(%W(git repack -a --quiet), "Repacking repo") + failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} repack -a --quiet), "Repacking repo") if failures.empty? puts "Done".color(:green) else @@ -13,17 +13,17 @@ namespace :gitlab do desc "GitLab | Git | Run garbage collection on all repos" task gc: :environment do - failures = perform_git_cmd(%W(git gc --auto --quiet), "Garbage Collecting") + failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} gc --auto --quiet), "Garbage Collecting") if failures.empty? puts "Done".color(:green) else output_failures(failures) end end - + desc "GitLab | Git | Prune all repos" task prune: :environment do - failures = perform_git_cmd(%W(git prune), "Git Prune") + failures = perform_git_cmd(%W(#{Gitlab.config.git.bin_path} prune), "Git Prune") if failures.empty? puts "Done".color(:green) else diff --git a/lib/tasks/gitlab/update_templates.rake b/lib/tasks/gitlab/update_templates.rake index 4f76dad7286..b77a5bb62d1 100644 --- a/lib/tasks/gitlab/update_templates.rake +++ b/lib/tasks/gitlab/update_templates.rake @@ -44,7 +44,7 @@ namespace :gitlab do ), Template.new( "https://gitlab.com/gitlab-org/gitlab-ci-yml.git", - /(\.{1,2}|LICENSE|Pages|\.gitlab-ci.yml)\z/ + /(\.{1,2}|LICENSE|Pages|autodeploy|\.gitlab-ci.yml)\z/ ) ] diff --git a/scripts/notify_slack.sh b/scripts/notify_slack.sh index 0a4239e132c..6b3bc563c7a 100755 --- a/scripts/notify_slack.sh +++ b/scripts/notify_slack.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh # Sends Slack notification ERROR_MSG to CHANNEL # An env. variable CI_SLACK_WEBHOOK_URL needs to be set. diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index 288984cfba9..19fbc2f7748 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -12,7 +12,7 @@ describe Dashboard::TodosController do end context 'when using pagination' do - let(:last_page) { user.todos.page().total_pages } + let(:last_page) { user.todos.page.total_pages } let!(:issues) { create_list(:issue, 2, project: project, assignee: user) } before do diff --git a/spec/db/production/settings.rb b/spec/db/production/settings.rb index a7c5283df94..007b35bbb77 100644 --- a/spec/db/production/settings.rb +++ b/spec/db/production/settings.rb @@ -2,10 +2,11 @@ require 'spec_helper' require 'rainbow/ext/string' describe 'seed production settings', lib: true do + include StubENV + context 'GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN is set in the environment' do before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN').and_return('013456789') + stub_env('GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN', '013456789') end it 'writes the token to the database' do diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index f7fa834d7a2..1cdbe4fc9a5 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -24,6 +24,10 @@ FactoryGirl.define do visibility_level Gitlab::VisibilityLevel::PRIVATE end + trait :archived do + archived true + end + trait :access_requestable do request_access_enabled true end diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 55ffc6761f8..a586f8d3184 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -1,9 +1,13 @@ require 'spec_helper' -describe "Admin::Users", feature: true do +describe "Admin::Users", feature: true do include WaitForAjax - before { login_as :admin } + let!(:user) do + create(:omniauth_user, provider: 'twitter', extern_uid: '123456') + end + + let!(:current_user) { login_as :admin } describe "GET /admin/users" do before do @@ -15,8 +19,10 @@ describe "Admin::Users", feature: true do end it "has users list" do - expect(page).to have_content(@user.email) - expect(page).to have_content(@user.name) + expect(page).to have_content(current_user.email) + expect(page).to have_content(current_user.name) + expect(page).to have_content(user.email) + expect(page).to have_content(user.name) end describe 'Two-factor Authentication filters' do @@ -40,8 +46,6 @@ describe "Admin::Users", feature: true do end it 'counts users who have not enabled 2FA' do - create(:user) - visit admin_users_path page.within('.filter-two-factor-disabled small') do @@ -50,8 +54,6 @@ describe "Admin::Users", feature: true do end it 'filters by users who have not enabled 2FA' do - user = create(:user) - visit admin_users_path click_link '2FA Disabled' @@ -110,10 +112,10 @@ describe "Admin::Users", feature: true do describe "GET /admin/users/:id" do it "has user info" do visit admin_users_path - click_link @user.name + click_link user.name - expect(page).to have_content(@user.email) - expect(page).to have_content(@user.name) + expect(page).to have_content(user.email) + expect(page).to have_content(user.name) end describe 'Impersonation' do @@ -126,7 +128,7 @@ describe "Admin::Users", feature: true do end it 'does not show impersonate button for admin itself' do - visit admin_user_path(@user) + visit admin_user_path(current_user) expect(page).not_to have_content('Impersonate') end @@ -158,7 +160,7 @@ describe "Admin::Users", feature: true do it 'logs out of impersonated user back to original user' do find(:css, 'li.impersonation a').click - expect(page.find(:css, '.header-user .profile-link')['data-user']).to eql(@user.username) + expect(page.find(:css, '.header-user .profile-link')['data-user']).to eql(current_user.username) end it 'is redirected back to the impersonated users page in the admin after stopping' do @@ -171,15 +173,15 @@ describe "Admin::Users", feature: true do describe 'Two-factor Authentication status' do it 'shows when enabled' do - @user.update_attribute(:otp_required_for_login, true) + user.update_attribute(:otp_required_for_login, true) - visit admin_user_path(@user) + visit admin_user_path(user) expect_two_factor_status('Enabled') end it 'shows when disabled' do - visit admin_user_path(@user) + visit admin_user_path(user) expect_two_factor_status('Disabled') end @@ -194,9 +196,8 @@ describe "Admin::Users", feature: true do describe "GET /admin/users/:id/edit" do before do - @simple_user = create(:user) visit admin_users_path - click_link "edit_user_#{@simple_user.id}" + click_link "edit_user_#{user.id}" end it "has user edit page" do @@ -214,45 +215,58 @@ describe "Admin::Users", feature: true do click_button "Save changes" end - it "shows page with new data" do + it "shows page with new data" do expect(page).to have_content('bigbang@mail.com') expect(page).to have_content('Big Bang') end it "changes user entry" do - @simple_user.reload - expect(@simple_user.name).to eq('Big Bang') - expect(@simple_user.is_admin?).to be_truthy - expect(@simple_user.password_expires_at).to be <= Time.now + user.reload + expect(user.name).to eq('Big Bang') + expect(user.is_admin?).to be_truthy + expect(user.password_expires_at).to be <= Time.now + end + end + + describe 'update username to non ascii char' do + it do + fill_in 'user_username', with: '\u3042\u3044' + click_button('Save') + + page.within '#error_explanation' do + expect(page).to have_content('Username') + end + + expect(page).to have_selector(%(form[action="/admin/users/#{user.username}"])) end end end describe "GET /admin/users/:id/projects" do + let(:group) { create(:group) } + let!(:project) { create(:project, group: group) } + before do - @group = create(:group) - @project = create(:project, group: @group) - @simple_user = create(:user) - @group.add_developer(@simple_user) + group.add_developer(user) - visit projects_admin_user_path(@simple_user) + visit projects_admin_user_path(user) end it "lists group projects" do within(:css, '.append-bottom-default + .panel') do expect(page).to have_content 'Group projects' - expect(page).to have_link @group.name, admin_group_path(@group) + expect(page).to have_link group.name, admin_group_path(group) end end it 'allows navigation to the group details' do within(:css, '.append-bottom-default + .panel') do - click_link @group.name + click_link group.name end within(:css, 'h3.page-title') do - expect(page).to have_content "Group: #{@group.name}" + expect(page).to have_content "Group: #{group.name}" end - expect(page).to have_content @project.name + expect(page).to have_content project.name end it 'shows the group access level' do @@ -270,4 +284,99 @@ describe "Admin::Users", feature: true do expect(page).not_to have_selector('.group_member') end end + + describe 'show user attributes' do + it do + visit admin_users_path + + click_link user.name + + expect(page).to have_content 'Account' + expect(page).to have_content 'Personal projects limit' + end + end + + describe 'remove users secondary email', js: true do + let!(:secondary_email) do + create :email, email: 'secondary@example.com', user: user + end + + it do + visit admin_user_path(user.username) + + expect(page).to have_content("Secondary email: #{secondary_email.email}") + + find("#remove_email_#{secondary_email.id}").click + + expect(page).not_to have_content(secondary_email.email) + end + end + + describe 'show user keys' do + let!(:key1) do + create(:key, user: user, title: "ssh-rsa Key1", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC4FIEBXGi4bPU8kzxMefudPIJ08/gNprdNTaO9BR/ndy3+58s2HCTw2xCHcsuBmq+TsAqgEidVq4skpqoTMB+Uot5Uzp9z4764rc48dZiI661izoREoKnuRQSsRqUTHg5wrLzwxlQbl1MVfRWQpqiz/5KjBC7yLEb9AbusjnWBk8wvC1bQPQ1uLAauEA7d836tgaIsym9BrLsMVnR4P1boWD3Xp1B1T/ImJwAGHvRmP/ycIqmKdSpMdJXwxcb40efWVj0Ibbe7ii9eeoLdHACqevUZi6fwfbymdow+FeqlkPoHyGg3Cu4vD/D8+8cRc7mE/zGCWcQ15Var83Tczour Key1") + end + + let!(:key2) do + create(:key, user: user, title: "ssh-rsa Key2", key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQSTWXhJAX/He+nG78MiRRRn7m0Pb0XbcgTxE0etArgoFoh9WtvDf36HG6tOSg/0UUNcp0dICsNAmhBKdncp6cIyPaXJTURPRAGvhI0/VDk4bi27bRnccGbJ/hDaUxZMLhhrzY0r22mjVf8PF6dvv5QUIQVm1/LeaWYsHHvLgiIjwrXirUZPnFrZw6VLREoBKG8uWvfSXw1L5eapmstqfsME8099oi+vWLR8MgEysZQmD28M73fgW4zek6LDQzKQyJx9nB+hJkKUDvcuziZjGmRFlNgSA2mguERwL1OXonD8WYUrBDGKroIvBT39zS5d9tQDnidEJZ9Y8gv5ViYP7x Key2") + end + + it do + visit admin_users_path + + click_link user.name + click_link 'SSH keys' + + expect(page).to have_content(key1.title) + expect(page).to have_content(key2.title) + + click_link key2.title + + expect(page).to have_content(key2.title) + expect(page).to have_content(key2.key) + + click_link 'Remove' + + expect(page).not_to have_content(key2.title) + end + end + + describe 'show user identities' do + it 'shows user identities' do + visit admin_user_identities_path(user) + + expect(page).to have_content(user.name) + expect(page).to have_content('twitter') + end + end + + describe 'update user identities' do + before do + allow(Gitlab::OAuth::Provider).to receive(:providers).and_return([:twitter, :twitter_updated]) + end + + it 'modifies twitter identity' do + visit admin_user_identities_path(user) + + find('.table').find(:link, 'Edit').click + fill_in 'identity_extern_uid', with: '654321' + select 'twitter_updated', from: 'identity_provider' + click_button 'Save changes' + + expect(page).to have_content(user.name) + expect(page).to have_content('twitter_updated') + expect(page).to have_content('654321') + end + end + + describe 'remove user with identities' do + it 'removes user with twitter identity' do + visit admin_user_identities_path(user) + + click_link 'Delete' + + expect(page).to have_content(user.name) + expect(page).not_to have_content('twitter') + end + end end diff --git a/spec/features/cycle_analytics_spec.rb b/spec/features/cycle_analytics_spec.rb index e48a2b0c92e..0648c89a5c7 100644 --- a/spec/features/cycle_analytics_spec.rb +++ b/spec/features/cycle_analytics_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' feature 'Cycle Analytics', feature: true, js: true do include WaitForAjax - let(:project) { create(:project) } let(:user) { create(:user) } let(:guest) { create(:user) } let(:project) { create(:project) } diff --git a/spec/features/dashboard/active_tab_spec.rb b/spec/features/dashboard/active_tab_spec.rb new file mode 100644 index 00000000000..7d59fcac517 --- /dev/null +++ b/spec/features/dashboard/active_tab_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +RSpec.describe 'Dashboard Active Tab', feature: true do + before do + login_as :user + end + + shared_examples 'page has active tab' do |title| + it "#{title} tab" do + expect(page).to have_selector('.nav-sidebar li.active', count: 1) + expect(find('.nav-sidebar li.active')).to have_content(title) + end + end + + context 'on dashboard projects' do + before do + visit dashboard_projects_path + end + + it_behaves_like 'page has active tab', 'Projects' + end + + context 'on dashboard issues' do + before do + visit issues_dashboard_path + end + + it_behaves_like 'page has active tab', 'Issues' + end + + context 'on dashboard merge requests' do + before do + visit merge_requests_dashboard_path + end + + it_behaves_like 'page has active tab', 'Merge Requests' + end + + context 'on dashboard groups' do + before do + visit dashboard_groups_path + end + + it_behaves_like 'page has active tab', 'Groups' + end +end diff --git a/spec/features/dashboard/archived_projects_spec.rb b/spec/features/dashboard/archived_projects_spec.rb new file mode 100644 index 00000000000..038c1641be9 --- /dev/null +++ b/spec/features/dashboard/archived_projects_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +RSpec.describe 'Dashboard Archived Project', feature: true do + let(:user) { create :user } + let(:project) { create :project} + let(:archived_project) { create(:project, :archived) } + + before do + project.team << [user, :master] + archived_project.team << [user, :master] + + login_as(user) + + visit dashboard_projects_path + end + + it 'renders non archived projects' do + expect(page).to have_link(project.name) + expect(page).not_to have_link(archived_project.name) + end + + it 'renders all projects' do + click_link 'Show archived projects' + + expect(page).to have_link(project.name) + expect(page).to have_link(archived_project.name) + end +end diff --git a/spec/features/dashboard/group_spec.rb b/spec/features/dashboard/group_spec.rb new file mode 100644 index 00000000000..d5f8470fab0 --- /dev/null +++ b/spec/features/dashboard/group_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +RSpec.describe 'Dashboard Group', feature: true do + before do + login_as(:user) + end + + it 'creates new grpup' do + visit dashboard_groups_path + click_link 'New Group' + + fill_in 'group_path', with: 'Samurai' + fill_in 'group_description', with: 'Tokugawa Shogunate' + 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') + end +end diff --git a/spec/features/dashboard/help_spec.rb b/spec/features/dashboard/help_spec.rb new file mode 100644 index 00000000000..2803f7ec62b --- /dev/null +++ b/spec/features/dashboard/help_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +RSpec.describe 'Dashboard Help', feature: true do + before do + login_as(:user) + end + + it 'renders correctly markdown' do + visit help_page_path("administration/raketasks/maintenance") + + expect(page).to have_content('Gather information about GitLab and the system it runs on') + + node = find('.documentation h2 a#user-content-check-gitlab-configuration') + expect(node[:href]).to eq '#check-gitlab-configuration' + expect(find(:xpath, "#{node.path}/..").text).to eq 'Check GitLab configuration' + end +end diff --git a/spec/features/issues/filter_by_milestone_spec.rb b/spec/features/issues/filter_by_milestone_spec.rb deleted file mode 100644 index 9dfa5d1de19..00000000000 --- a/spec/features/issues/filter_by_milestone_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -require 'rails_helper' - -feature 'Issue filtering by Milestone', feature: true do - let(:project) { create(:project, :public) } - let(:milestone) { create(:milestone, project: project) } - - scenario 'filters by no Milestone', js: true do - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::None.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'No Milestone') - expect(page).to have_css('.issue', count: 1) - end - - context 'filters by upcoming milestone', js: true do - it 'does not show issues with no expiry' do - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::Upcoming.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming') - expect(page).to have_css('.issue', count: 0) - end - - it 'shows issues in future' do - milestone = create(:milestone, project: project, due_date: Date.tomorrow) - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::Upcoming.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming') - expect(page).to have_css('.issue', count: 1) - end - - it 'does not show issues in past' do - milestone = create(:milestone, project: project, due_date: Date.yesterday) - create(:issue, project: project) - create(:issue, project: project, milestone: milestone) - - visit_issues(project) - filter_by_milestone(Milestone::Upcoming.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: 'Upcoming') - expect(page).to have_css('.issue', count: 0) - end - end - - scenario 'filters by a specific Milestone', js: true do - create(:issue, project: project, milestone: milestone) - create(:issue, project: project) - - visit_issues(project) - filter_by_milestone(milestone.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: milestone.title) - expect(page).to have_css('.issue', count: 1) - end - - context 'when milestone has single quotes in title' do - background do - milestone.update(name: "rock 'n' roll") - end - - scenario 'filters by a specific Milestone', js: true do - create(:issue, project: project, milestone: milestone) - create(:issue, project: project) - - visit_issues(project) - filter_by_milestone(milestone.title) - - expect(page).to have_css('.milestone-filter .dropdown-toggle-text', text: milestone.title) - expect(page).to have_css('.issue', count: 1) - end - end - - def visit_issues(project) - visit namespace_project_issues_path(project.namespace, project) - end - - def filter_by_milestone(title) - find(".js-milestone-select").click - find(".milestone-filter .dropdown-content a", text: title).click - end -end diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb new file mode 100644 index 00000000000..6f6a2532c04 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' + +describe 'Dropdown assignee', js: true, feature: true do + include WaitForAjax + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user, name: 'administrator', username: 'root') } + let!(:user_john) { create(:user, name: 'John', username: 'th0mas') } + let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') } + let(:filtered_search) { find('.filtered-search') } + let(:js_dropdown_assignee) { '#js-dropdown-assignee' } + + def send_keys_to_filtered_search(input) + input.split("").each do |i| + filtered_search.send_keys(i) + sleep 5 + wait_for_ajax + end + end + + def dropdown_assignee_size + page.all('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item').size + end + + def click_assignee(text) + find('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item', text: text).click + end + + before do + project.team << [user, :master] + project.team << [user_john, :master] + project.team << [user_jacob, :master] + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'behavior' do + it 'opens when the search bar has assignee:' do + filtered_search.set('assignee:') + + expect(page).to have_css(js_dropdown_assignee, visible: true) + end + + it 'closes when the search bar is unfocused' do + find('body').click() + + expect(page).to have_css(js_dropdown_assignee, visible: false) + end + + it 'should show loading indicator when opened' do + filtered_search.set('assignee:') + + expect(page).to have_css('#js-dropdown-assignee .filter-dropdown-loading', visible: true) + end + + it 'should hide loading indicator when loaded' do + send_keys_to_filtered_search('assignee:') + + expect(page).not_to have_css('#js-dropdown-assignee .filter-dropdown-loading') + end + + it 'should load all the assignees when opened' do + send_keys_to_filtered_search('assignee:') + + expect(dropdown_assignee_size).to eq(3) + end + end + + describe 'filtering' do + before do + send_keys_to_filtered_search('assignee:') + end + + it 'filters by name' do + send_keys_to_filtered_search('j') + + expect(dropdown_assignee_size).to eq(2) + end + + it 'filters by case insensitive name' do + send_keys_to_filtered_search('J') + + expect(dropdown_assignee_size).to eq(2) + end + + it 'filters by username with symbol' do + send_keys_to_filtered_search('@ot') + + expect(dropdown_assignee_size).to eq(2) + end + + it 'filters by case insensitive username with symbol' do + send_keys_to_filtered_search('@OT') + + expect(dropdown_assignee_size).to eq(2) + end + + it 'filters by username without symbol' do + send_keys_to_filtered_search('ot') + + expect(dropdown_assignee_size).to eq(2) + end + + it 'filters by case insensitive username without symbol' do + send_keys_to_filtered_search('OT') + + expect(dropdown_assignee_size).to eq(2) + end + end + + describe 'selecting from dropdown' do + before do + filtered_search.set('assignee:') + end + + it 'fills in the assignee username when the assignee has not been filtered' do + click_assignee(user_jacob.name) + + expect(page).to have_css(js_dropdown_assignee, visible: false) + expect(filtered_search.value).to eq("assignee:@#{user_jacob.username}") + end + + it 'fills in the assignee username when the assignee has been filtered' do + send_keys_to_filtered_search('roo') + click_assignee(user.name) + + expect(page).to have_css(js_dropdown_assignee, visible: false) + expect(filtered_search.value).to eq("assignee:@#{user.username}") + end + + it 'selects `no assignee`' do + find('#js-dropdown-assignee .filter-dropdown-item', text: 'No Assignee').click + + expect(page).to have_css(js_dropdown_assignee, visible: false) + expect(filtered_search.value).to eq("assignee:none") + end + end + + describe 'input has existing content' do + it 'opens assignee dropdown with existing search term' do + filtered_search.set('searchTerm assignee:') + + expect(page).to have_css(js_dropdown_assignee, visible: true) + end + + it 'opens assignee dropdown with existing author' do + filtered_search.set('author:@user assignee:') + + expect(page).to have_css(js_dropdown_assignee, visible: true) + end + + it 'opens assignee dropdown with existing label' do + filtered_search.set('label:~bug assignee:') + + expect(page).to have_css(js_dropdown_assignee, visible: true) + end + + it 'opens assignee dropdown with existing milestone' do + filtered_search.set('milestone:%v1.0 assignee:') + + expect(page).to have_css(js_dropdown_assignee, visible: true) + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb new file mode 100644 index 00000000000..60a86cc93d4 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -0,0 +1,154 @@ +require 'rails_helper' + +describe 'Dropdown author', js: true, feature: true do + include WaitForAjax + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user, name: 'administrator', username: 'root') } + let!(:user_john) { create(:user, name: 'John', username: 'th0mas') } + let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') } + let(:filtered_search) { find('.filtered-search') } + let(:js_dropdown_author) { '#js-dropdown-author' } + + def send_keys_to_filtered_search(input) + input.split("").each do |i| + filtered_search.send_keys(i) + sleep 5 + wait_for_ajax + end + end + + def dropdown_author_size + page.all('#js-dropdown-author .filter-dropdown .filter-dropdown-item').size + end + + def click_author(text) + find('#js-dropdown-author .filter-dropdown .filter-dropdown-item', text: text).click + end + + before do + project.team << [user, :master] + project.team << [user_john, :master] + project.team << [user_jacob, :master] + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'behavior' do + it 'opens when the search bar has author:' do + filtered_search.set('author:') + + expect(page).to have_css(js_dropdown_author, visible: true) + end + + it 'closes when the search bar is unfocused' do + find('body').click() + + expect(page).to have_css(js_dropdown_author, visible: false) + end + + it 'should show loading indicator when opened' do + filtered_search.set('author:') + + expect(page).to have_css('#js-dropdown-author .filter-dropdown-loading', visible: true) + end + + it 'should hide loading indicator when loaded' do + send_keys_to_filtered_search('author:') + + expect(page).not_to have_css('#js-dropdown-author .filter-dropdown-loading') + end + + it 'should load all the authors when opened' do + send_keys_to_filtered_search('author:') + + expect(dropdown_author_size).to eq(3) + end + end + + describe 'filtering' do + before do + filtered_search.set('author') + send_keys_to_filtered_search(':') + end + + it 'filters by name' do + send_keys_to_filtered_search('ja') + + expect(dropdown_author_size).to eq(1) + end + + it 'filters by case insensitive name' do + send_keys_to_filtered_search('Ja') + + expect(dropdown_author_size).to eq(1) + end + + it 'filters by username with symbol' do + send_keys_to_filtered_search('@ot') + + expect(dropdown_author_size).to eq(2) + end + + it 'filters by username without symbol' do + send_keys_to_filtered_search('ot') + + expect(dropdown_author_size).to eq(2) + end + + it 'filters by case insensitive username without symbol' do + send_keys_to_filtered_search('OT') + + expect(dropdown_author_size).to eq(2) + end + end + + describe 'selecting from dropdown' do + before do + filtered_search.set('author') + send_keys_to_filtered_search(':') + end + + it 'fills in the author username when the author has not been filtered' do + click_author(user_jacob.name) + + expect(page).to have_css(js_dropdown_author, visible: false) + expect(filtered_search.value).to eq("author:@#{user_jacob.username}") + end + + it 'fills in the author username when the author has been filtered' do + click_author(user.name) + + expect(page).to have_css(js_dropdown_author, visible: false) + expect(filtered_search.value).to eq("author:@#{user.username}") + end + end + + describe 'input has existing content' do + it 'opens author dropdown with existing search term' do + filtered_search.set('searchTerm author:') + + expect(page).to have_css(js_dropdown_author, visible: true) + end + + it 'opens author dropdown with existing assignee' do + filtered_search.set('assignee:@user author:') + + expect(page).to have_css(js_dropdown_author, visible: true) + end + + it 'opens author dropdown with existing label' do + filtered_search.set('label:~bug author:') + + expect(page).to have_css(js_dropdown_author, visible: true) + end + + it 'opens author dropdown with existing milestone' do + filtered_search.set('milestone:%v1.0 author:') + + expect(page).to have_css(js_dropdown_author, visible: true) + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb new file mode 100644 index 00000000000..04dd54ab459 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -0,0 +1,134 @@ +require 'rails_helper' + +describe 'Dropdown hint', js: true, feature: true do + include WaitForAjax + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user) } + 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 + + before do + project.team << [user, :master] + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'behavior' do + before do + expect(page).to have_css(js_dropdown_hint, visible: false) + filtered_search.click + end + + it 'opens when the search bar is first focused' do + expect(page).to have_css(js_dropdown_hint, visible: true) + end + + it 'closes when the search bar is unfocused' do + find('body').click + + expect(page).to have_css(js_dropdown_hint, visible: false) + end + end + + describe 'filtering' do + it 'does not filter `Keep typing and press Enter`' do + filtered_search.set('randomtext') + + expect(page).to have_css(js_dropdown_hint, text: 'Keep typing and press Enter', visible: false) + expect(dropdown_hint_size).to eq(0) + end + + it 'filters with text' do + filtered_search.set('a') + + expect(dropdown_hint_size).to eq(3) + end + end + + describe 'selecting from dropdown with no input' do + before do + filtered_search.click + end + + it 'opens the author dropdown when you click on author' do + click_hint('author') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-author', visible: true) + expect(filtered_search.value).to eq('author:') + end + + it 'opens the assignee dropdown when you click on assignee' do + click_hint('assignee') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-assignee', visible: true) + expect(filtered_search.value).to eq('assignee:') + end + + it 'opens the milestone dropdown when you click on milestone' do + click_hint('milestone') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-milestone', visible: true) + expect(filtered_search.value).to eq('milestone:') + end + + it 'opens the label dropdown when you click on label' do + click_hint('label') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-label', visible: true) + expect(filtered_search.value).to eq('label:') + end + end + + describe 'selecting from dropdown with some input' do + it 'opens the author dropdown when you click on author' do + filtered_search.set('auth') + click_hint('author') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-author', visible: true) + expect(filtered_search.value).to eq('author:') + end + + it 'opens the assignee dropdown when you click on assignee' do + filtered_search.set('assign') + click_hint('assignee') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-assignee', visible: true) + expect(filtered_search.value).to eq('assignee:') + end + + it 'opens the milestone dropdown when you click on milestone' do + filtered_search.set('mile') + click_hint('milestone') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-milestone', visible: true) + expect(filtered_search.value).to eq('milestone:') + end + + it 'opens the label dropdown when you click on label' do + filtered_search.set('lab') + click_hint('label') + + expect(page).to have_css(js_dropdown_hint, visible: false) + expect(page).to have_css('#js-dropdown-label', visible: true) + expect(filtered_search.value).to eq('label:') + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb new file mode 100644 index 00000000000..89c144141c9 --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -0,0 +1,242 @@ +require 'rails_helper' + +describe 'Dropdown label', js: true, feature: true do + include WaitForAjax + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user) } + let!(:bug_label) { create(:label, project: project, title: 'bug') } + let!(:uppercase_label) { create(:label, project: project, title: 'BUG') } + let!(:two_words_label) { create(:label, project: project, title: 'High Priority') } + let!(:wont_fix_label) { create(:label, project: project, title: 'Won"t Fix') } + let!(:wont_fix_single_label) { create(:label, project: project, title: 'Won\'t Fix') } + let!(:special_label) { create(:label, project: project, title: '!@#$%^+&*()')} + let!(:long_label) { create(:label, project: project, title: 'this is a very long title this is a very long title this is a very long title this is a very long title this is a very long title')} + let(:filtered_search) { find('.filtered-search') } + let(:js_dropdown_label) { '#js-dropdown-label' } + + def send_keys_to_filtered_search(input) + input.split("").each do |i| + filtered_search.send_keys(i) + sleep 3 + wait_for_ajax + sleep 3 + end + end + + def dropdown_label_size + page.all('#js-dropdown-label .filter-dropdown .filter-dropdown-item').size + end + + def click_label(text) + find('#js-dropdown-label .filter-dropdown .filter-dropdown-item', text: text).click + end + + before do + project.team << [user, :master] + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'behavior' do + it 'opens when the search bar has label:' do + filtered_search.set('label:') + + expect(page).to have_css(js_dropdown_label, visible: true) + end + + it 'closes when the search bar is unfocused' do + find('body').click() + + expect(page).to have_css(js_dropdown_label, visible: false) + end + + it 'should show loading indicator when opened' do + filtered_search.set('label:') + + expect(page).to have_css('#js-dropdown-label .filter-dropdown-loading', visible: true) + end + + it 'should hide loading indicator when loaded' do + send_keys_to_filtered_search('label:') + + expect(page).not_to have_css('#js-dropdown-label .filter-dropdown-loading') + end + + it 'should load all the labels when opened' do + send_keys_to_filtered_search('label:') + + expect(dropdown_label_size).to be > 0 + end + end + + describe 'filtering' do + before do + filtered_search.set('label') + end + + it 'filters by name' do + send_keys_to_filtered_search(':b') + + expect(dropdown_label_size).to eq(2) + end + + it 'filters by case insensitive name' do + send_keys_to_filtered_search(':B') + + expect(dropdown_label_size).to eq(2) + end + + it 'filters by name with symbol' do + send_keys_to_filtered_search(':~bu') + + expect(dropdown_label_size).to eq(2) + end + + it 'filters by case insensitive name with symbol' do + send_keys_to_filtered_search(':~BU') + + expect(dropdown_label_size).to eq(2) + end + + it 'filters by multiple words' do + send_keys_to_filtered_search(':Hig') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by multiple words with symbol' do + send_keys_to_filtered_search(':~Hig') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by multiple words containing single quotes' do + send_keys_to_filtered_search(':won\'t') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by multiple words containing single quotes with symbol' do + send_keys_to_filtered_search(':~won\'t') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by multiple words containing double quotes' do + send_keys_to_filtered_search(':won"t') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by multiple words containing double quotes with symbol' do + send_keys_to_filtered_search(':~won"t') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by special characters' do + send_keys_to_filtered_search(':^+') + + expect(dropdown_label_size).to eq(1) + end + + it 'filters by special characters with symbol' do + send_keys_to_filtered_search(':~^+') + + expect(dropdown_label_size).to eq(1) + end + end + + describe 'selecting from dropdown' do + before do + filtered_search.set('label:') + end + + it 'fills in the label name when the label has not been filled' do + click_label(bug_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~#{bug_label.title}") + end + + it 'fills in the label name when the label is partially filled' do + send_keys_to_filtered_search('bu') + click_label(bug_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~#{bug_label.title}") + end + + it 'fills in the label name that contains multiple words' do + click_label(two_words_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~\"#{two_words_label.title}\"") + end + + it 'fills in the label name that contains multiple words and is very long' do + click_label(long_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~\"#{long_label.title}\"") + end + + it 'fills in the label name that contains double quotes' do + click_label(wont_fix_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~'#{wont_fix_label.title}'") + end + + it 'fills in the label name with the correct capitalization' do + click_label(uppercase_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~#{uppercase_label.title}") + end + + it 'fills in the label name with special characters' do + click_label(special_label.title) + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:~#{special_label.title}") + end + + it 'selects `no label`' do + find('#js-dropdown-label .filter-dropdown-item', text: 'No Label').click + + expect(page).to have_css(js_dropdown_label, visible: false) + expect(filtered_search.value).to eq("label:none") + end + end + + describe 'input has existing content' do + it 'opens label dropdown with existing search term' do + filtered_search.set('searchTerm label:') + expect(page).to have_css(js_dropdown_label, visible: true) + end + + it 'opens label dropdown with existing author' do + filtered_search.set('author:@person label:') + expect(page).to have_css(js_dropdown_label, visible: true) + end + + it 'opens label dropdown with existing assignee' do + filtered_search.set('assignee:@person label:') + expect(page).to have_css(js_dropdown_label, visible: true) + end + + it 'opens label dropdown with existing label' do + filtered_search.set('label:~urgent label:') + expect(page).to have_css(js_dropdown_label, visible: true) + end + + it 'opens label dropdown with existing milestone' do + filtered_search.set('milestone:%v2.0 label:') + expect(page).to have_css(js_dropdown_label, visible: true) + 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 new file mode 100644 index 00000000000..e5a271b663f --- /dev/null +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -0,0 +1,222 @@ +require 'rails_helper' + +describe 'Dropdown milestone', js: true, feature: true do + include WaitForAjax + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user) } + let!(:milestone) { create(:milestone, title: 'v1.0', project: project) } + let!(:uppercase_milestone) { create(:milestone, title: 'CAP_MILESTONE', project: project) } + let!(:two_words_milestone) { create(:milestone, title: 'Future Plan', project: project) } + let!(:wont_fix_milestone) { create(:milestone, title: 'Won"t Fix', project: project) } + let!(:special_milestone) { create(:milestone, title: '!@#$%^&*(+)', project: project) } + let!(:long_milestone) { create(:milestone, title: 'this is a very long title this is a very long title this is a very long title this is a very long title this is a very long title', project: project) } + + let(:filtered_search) { find('.filtered-search') } + let(:js_dropdown_milestone) { '#js-dropdown-milestone' } + + def send_keys_to_filtered_search(input) + input.split("").each do |i| + filtered_search.send_keys(i) + sleep 3 + wait_for_ajax + sleep 3 + end + end + + def dropdown_milestone_size + page.all('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item').size + end + + def click_milestone(text) + find('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item', text: text).click + end + + def click_static_milestone(text) + find('#js-dropdown-milestone .filter-dropdown-item', text: text).click + end + + before do + project.team << [user, :master] + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'behavior' do + it 'opens when the search bar has milestone:' do + filtered_search.set('milestone:') + + expect(page).to have_css(js_dropdown_milestone, visible: true) + end + + it 'closes when the search bar is unfocused' do + find('body').click() + + expect(page).to have_css(js_dropdown_milestone, visible: false) + end + + it 'should show loading indicator when opened' do + filtered_search.set('milestone:') + + expect(page).to have_css('#js-dropdown-milestone .filter-dropdown-loading', visible: true) + end + + it 'should hide loading indicator when loaded' do + send_keys_to_filtered_search('milestone:') + + expect(page).not_to have_css('#js-dropdown-milestone .filter-dropdown-loading') + end + + it 'should load all the milestones when opened' do + send_keys_to_filtered_search('milestone:') + + expect(dropdown_milestone_size).to be > 0 + end + end + + describe 'filtering' do + before do + filtered_search.set('milestone') + end + + it 'filters by name' do + send_keys_to_filtered_search(':v1') + + expect(dropdown_milestone_size).to eq(1) + end + + it 'filters by case insensitive name' do + send_keys_to_filtered_search(':V1') + + expect(dropdown_milestone_size).to eq(1) + end + + it 'filters by name with symbol' do + send_keys_to_filtered_search(':%v1') + + expect(dropdown_milestone_size).to eq(1) + end + + it 'filters by case insensitive name with symbol' do + send_keys_to_filtered_search(':%V1') + + expect(dropdown_milestone_size).to eq(1) + end + + it 'filters by special characters' do + send_keys_to_filtered_search(':(+') + + expect(dropdown_milestone_size).to eq(1) + end + + it 'filters by special characters with symbol' do + send_keys_to_filtered_search(':%(+') + + expect(dropdown_milestone_size).to eq(1) + end + end + + describe 'selecting from dropdown' do + before do + filtered_search.set('milestone:') + end + + it 'fills in the milestone name when the milestone has not been filled' do + click_milestone(milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%#{milestone.title}") + end + + it 'fills in the milestone name when the milestone is partially filled' do + send_keys_to_filtered_search('v') + click_milestone(milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%#{milestone.title}") + end + + it 'fills in the milestone name that contains multiple words' do + click_milestone(two_words_milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%\"#{two_words_milestone.title}\"") + end + + it 'fills in the milestone name that contains multiple words and is very long' do + click_milestone(long_milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%\"#{long_milestone.title}\"") + end + + it 'fills in the milestone name that contains double quotes' do + click_milestone(wont_fix_milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%'#{wont_fix_milestone.title}'") + end + + it 'fills in the milestone name with the correct capitalization' do + click_milestone(uppercase_milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%#{uppercase_milestone.title}") + end + + it 'fills in the milestone name with special characters' do + click_milestone(special_milestone.title) + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:%#{special_milestone.title}") + end + + it 'selects `no milestone`' do + click_static_milestone('No Milestone') + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:none") + end + + it 'selects `upcoming milestone`' do + click_static_milestone('Upcoming') + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect(filtered_search.value).to eq("milestone:upcoming") + end + end + + describe 'input has existing content' do + it 'opens milestone dropdown with existing search term' do + filtered_search.set('searchTerm milestone:') + + expect(page).to have_css(js_dropdown_milestone, visible: true) + end + + it 'opens milestone dropdown with existing author' do + filtered_search.set('author:@john milestone:') + + expect(page).to have_css(js_dropdown_milestone, visible: true) + end + + it 'opens milestone dropdown with existing assignee' do + filtered_search.set('assignee:@john milestone:') + + expect(page).to have_css(js_dropdown_milestone, visible: true) + end + + it 'opens milestone dropdown with existing label' do + filtered_search.set('label:~important milestone:') + + expect(page).to have_css(js_dropdown_milestone, visible: true) + end + + it 'opens milestone dropdown with existing milestone' do + filtered_search.set('milestone:%100 milestone:') + + expect(page).to have_css(js_dropdown_milestone, visible: true) + end + end +end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb new file mode 100644 index 00000000000..ead43d6784a --- /dev/null +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -0,0 +1,759 @@ +require 'rails_helper' + +describe 'Filter issues', js: true, feature: true do + include WaitForAjax + + let!(:group) { create(:group) } + let!(:project) { create(:project, group: group) } + let!(:user) { create(:user) } + let!(:user2) { create(:user) } + let!(:milestone) { create(:milestone, project: project) } + let!(:label) { create(:label, project: project) } + let!(:wontfix) { create(:label, project: project, title: "Won't fix") } + + let!(:bug_label) { create(:label, project: project, title: 'bug') } + let!(:caps_sensitive_label) { create(:label, project: project, title: 'CAPS_sensitive') } + let!(:milestone) { create(:milestone, title: "8", project: project) } + let!(:multiple_words_label) { create(:label, project: project, title: "Two words") } + + let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) } + let(:filtered_search) { find('.filtered-search') } + + def input_filtered_search(search_term) + filtered_search.set(search_term) + filtered_search.send_keys(:enter) + end + + def expect_filtered_search_input(input) + expect(find('.filtered-search').value).to eq(input) + end + + def expect_no_issues_list + page.within '.issues-list' do + expect(page).not_to have_selector('.issue') + end + end + + def expect_issues_list_count(open_count, closed_count = 0) + all_count = open_count + closed_count + + expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count) + page.within '.issues-list' do + expect(page).to have_selector('.issue', count: open_count) + end + end + + before do + project.team << [user, :master] + project.team << [user2, :master] + group.add_developer(user) + group.add_developer(user2) + login_as(user) + create(:issue, project: project) + + create(:issue, title: "Bug report 1", project: project) + create(:issue, title: "Bug report 2", project: project) + create(:issue, title: "issue with 'single quotes'", project: project) + create(:issue, title: "issue with \"double quotes\"", project: project) + create(:issue, title: "issue with !@\#{$%^&*()-+", project: project) + create(:issue, title: "issue by assignee", project: project, milestone: milestone, author: user, assignee: user) + create(:issue, title: "issue by assignee with searchTerm", project: project, milestone: milestone, author: user, assignee: user) + + issue = create(:issue, + title: "Bug 2", + project: project, + milestone: milestone, + author: user, + assignee: user) + issue.labels << bug_label + + issue_with_caps_label = create(:issue, + title: "issue by assignee with searchTerm and label", + project: project, + milestone: milestone, + author: user, + assignee: user) + issue_with_caps_label.labels << caps_sensitive_label + + issue_with_everything = create(:issue, + title: "Bug report with everything you thought was possible", + project: project, + milestone: milestone, + author: user, + assignee: user) + issue_with_everything.labels << bug_label + issue_with_everything.labels << caps_sensitive_label + + multiple_words_label_issue = create(:issue, title: "Issue with multiple words label", project: project) + multiple_words_label_issue.labels << multiple_words_label + + future_milestone = create(:milestone, title: "future", project: project, due_date: Time.now + 1.month) + + create(:issue, + title: "Issue with future milestone", + milestone: future_milestone, + project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'filter issues by author' do + context 'only author' do + it 'filters issues by searched author' do + input_filtered_search("author:@#{user.username}") + + expect_issues_list_count(5) + end + + it 'filters issues by invalid author' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + + it 'filters issues by multiple authors' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + end + + context 'author with other filters' do + it 'filters issues by searched author and text' do + search = "author:@#{user.username} issue" + input_filtered_search(search) + + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by searched author, assignee and text' do + search = "author:@#{user.username} assignee:@#{user.username} issue" + input_filtered_search(search) + + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by searched author, assignee, label, and text' do + search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} issue" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched author, assignee, label, milestone and text' do + search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} issue" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + it 'sorting' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + end + + describe 'filter issues by assignee' do + context 'only assignee' do + it 'filters issues by searched assignee' do + search = "assignee:@#{user.username}" + input_filtered_search(search) + + expect_issues_list_count(5) + expect_filtered_search_input(search) + end + + it 'filters issues by no assignee' do + search = "assignee:none" + input_filtered_search(search) + + expect_issues_list_count(8, 1) + expect_filtered_search_input(search) + end + + it 'filters issues by invalid assignee' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + + it 'filters issues by multiple assignees' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + end + + context 'assignee with other filters' do + it 'filters issues by searched assignee and text' do + search = "assignee:@#{user.username} searchTerm" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched assignee, author and text' do + search = "assignee:@#{user.username} author:@#{user.username} searchTerm" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched assignee, author, label, text' do + search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} searchTerm" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched assignee, author, label, milestone and text' do + search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} searchTerm" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'sorting' do + it 'sorts' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + end + end + + describe 'filter issues by label' do + context 'only label' do + it 'filters issues by searched label' do + search = "label:~#{bug_label.title}" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by no label' do + search = "label:none" + input_filtered_search(search) + + expect_issues_list_count(9, 1) + expect_filtered_search_input(search) + end + + it 'filters issues by invalid label' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + + it 'filters issues by multiple labels' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title}" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by label containing special characters' do + special_label = create(:label, project: project, title: '!@#{$%^&*()-+[]<>?/:{}|\}') + special_issue = create(:issue, title: "Issue with special character label", project: project) + special_issue.labels << special_label + + search = "label:~#{special_label.title}" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'does not show issues' do + new_label = create(:label, project: project, title: "new_label") + + search = "label:~#{new_label.title}" + input_filtered_search(search) + + expect_no_issues_list() + expect_filtered_search_input(search) + end + end + + context 'label with multiple words' do + it 'special characters' do + special_multiple_label = create(:label, project: project, title: "Utmost |mp0rt@nce") + special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project) + special_multiple_issue.labels << special_multiple_label + + search = "label:~'#{special_multiple_label.title}'" + input_filtered_search(search) + + expect_issues_list_count(1) + + # filtered search defaults quotations to double quotes + expect_filtered_search_input("label:~\"#{special_multiple_label.title}\"") + end + + it 'single quotes' do + search = "label:~'#{multiple_words_label.title}'" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input("label:~\"#{multiple_words_label.title}\"") + end + + it 'double quotes' do + search = "label:~\"#{multiple_words_label.title}\"" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'single quotes containing double quotes' do + double_quotes_label = create(:label, project: project, title: 'won"t fix') + double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project) + double_quotes_label_issue.labels << double_quotes_label + + search = "label:~'#{double_quotes_label.title}'" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'double quotes containing single quotes' do + single_quotes_label = create(:label, project: project, title: "won't fix") + single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project) + single_quotes_label_issue.labels << single_quotes_label + + search = "label:~\"#{single_quotes_label.title}\"" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'label with other filters' do + it 'filters issues by searched label and text' do + search = "label:~#{caps_sensitive_label.title} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, author and text' do + search = "label:~#{caps_sensitive_label.title} author:@#{user.username} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, author, assignee and text' do + search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, author, assignee, milestone and text' do + search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'multiple labels with other filters' do + it 'filters issues by searched label, label2, and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, label2, author and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, label2, author, assignee and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched label, label2, author, assignee, milestone and text' do + search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + end + + context 'issue label clicked' do + before do + find('.issues-list .issue .issue-info a .label', text: multiple_words_label.title).click + sleep 1 + end + + it 'filters' do + expect_issues_list_count(1) + end + + it 'displays in search bar' do + expect(find('.filtered-search').value).to eq("label:~\"#{multiple_words_label.title}\"") + end + end + + context 'sorting' do + it 'sorts' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + end + end + + describe 'filter issues by milestone' do + context 'only milestone' do + it 'filters issues by searched milestone' do + input_filtered_search("milestone:%#{milestone.title}") + + expect_issues_list_count(5) + end + + it 'filters issues by no milestone' do + input_filtered_search("milestone:none") + + expect_issues_list_count(7, 1) + end + + it 'filters issues by upcoming milestones' do + input_filtered_search("milestone:upcoming") + + expect_issues_list_count(1) + end + + it 'filters issues by invalid milestones' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + + it 'filters issues by multiple milestones' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + + it 'filters issues by milestone containing special characters' do + special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) + create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone) + + search = "milestone:%#{special_milestone.title}" + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'does not show issues' do + new_milestone = create(:milestone, title: "new", project: project) + + search = "milestone:%#{new_milestone.title}" + input_filtered_search(search) + + expect_no_issues_list() + expect_filtered_search_input(search) + end + end + + context 'milestone with other filters' do + it 'filters issues by searched milestone and text' do + search = "milestone:%#{milestone.title} bug" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched milestone, author and text' do + search = "milestone:%#{milestone.title} author:@#{user.username} bug" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched milestone, author, assignee and text' do + search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} bug" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + + it 'filters issues by searched milestone, author, assignee, label and text' do + search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug" + input_filtered_search(search) + + expect_issues_list_count(2) + expect_filtered_search_input(search) + end + end + + context 'sorting' do + it 'sorts' do + pending('to be tested, issue #26546') + expect(true).to be(false) + end + end + end + + describe 'filter issues by text' do + context 'only text' do + it 'filters issues by searched text' do + search = 'Bug' + input_filtered_search(search) + + expect_issues_list_count(4, 1) + expect_filtered_search_input(search) + end + + it 'filters issues by multiple searched text' do + search = 'Bug report' + input_filtered_search(search) + + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by case insensitive searched text' do + search = 'bug report' + input_filtered_search(search) + + expect_issues_list_count(3) + expect_filtered_search_input(search) + end + + it 'filters issues by searched text containing single quotes' do + search = '\'single quotes\'' + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched text containing double quotes' do + search = '"double quotes"' + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'filters issues by searched text containing special characters' do + search = '!@#{$%^&*()-+' + input_filtered_search(search) + + expect_issues_list_count(1) + expect_filtered_search_input(search) + end + + it 'does not show any issues' do + search = 'testing' + input_filtered_search(search) + + expect_no_issues_list() + expect_filtered_search_input(search) + end + end + + context 'searched text with other filters' do + it 'filters issues by searched text and author' do + input_filtered_search("bug author:@#{user.username}") + + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} bug") + end + + it 'filters issues by searched text, author and more text' do + input_filtered_search("bug author:@#{user.username} report") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} bug report") + end + + it 'filters issues by searched text, author and assignee' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}") + + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug") + end + + it 'filters issues by searched text, author, more text and assignee' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report") + end + + it 'filters issues by searched text, author, more text, assignee and even more text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report with") + end + + it 'filters issues by searched text, author, assignee and label' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}") + + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug") + end + + it 'filters issues by searched text, author, text, assignee, text, label and text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug report with everything") + end + + it 'filters issues by searched text, author, assignee, label and milestone' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}") + + expect_issues_list_count(2) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug") + end + + it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything milestone:%#{milestone.title} you") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug report with everything you") + end + + it 'filters issues by searched text, author, assignee, multiple labels and milestone' do + input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug") + end + + it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do + input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything label:~#{caps_sensitive_label.title} you milestone:%#{milestone.title} thought") + + expect_issues_list_count(1) + expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug report with everything you thought") + end + end + + context 'sorting' do + it 'sorts by oldest updated' do + create(:issue, + title: '3 days ago', + project: project, + author: user, + created_at: 3.days.ago, + updated_at: 3.days.ago) + + old_issue = create(:issue, + title: '5 days ago', + project: project, + author: user, + created_at: 5.days.ago, + updated_at: 5.days.ago) + + input_filtered_search('days ago') + + expect_issues_list_count(2) + + sort_toggle = find('.filtered-search-container .dropdown-toggle') + sort_toggle.click + + find('.filtered-search-container .dropdown-menu li a', text: 'Oldest updated').click + wait_for_ajax + + expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) + end + end + end + + describe 'retains filter when switching issue states' do + before do + input_filtered_search('bug') + + # Wait for search results to load + sleep 2 + end + + it 'open state' do + find('.issues-state-filters a', text: 'Closed').click + wait_for_ajax + + find('.issues-state-filters a', text: 'Open').click + wait_for_ajax + + expect(page).to have_selector('.issues-list .issue', count: 4) + end + + it 'closed state' do + find('.issues-state-filters a', text: 'Closed').click + wait_for_ajax + + expect(page).to have_selector('.issues-list .issue', count: 1) + expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title) + end + + it 'all state' do + find('.issues-state-filters a', text: 'All').click + wait_for_ajax + + expect(page).to have_selector('.issues-list .issue', count: 5) + end + end + + describe 'RSS feeds' do + it 'updates atom feed link for project issues' do + visit namespace_project_issues_path(project.namespace, project, milestone_title: milestone.title, assignee_id: user.id) + link = find('.nav-controls a', text: 'Subscribe') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + + expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('milestone_title' => [milestone.title]) + expect(params).to include('assignee_id' => [user.id.to_s]) + expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) + expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) + end + + it 'updates atom feed link for group issues' do + visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) + link = find('.nav-controls a', text: 'Subscribe') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + + expect(params).to include('private_token' => [user.private_token]) + expect(params).to include('milestone_title' => [milestone.title]) + expect(params).to include('assignee_id' => [user.id.to_s]) + expect(auto_discovery_params).to include('private_token' => [user.private_token]) + expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) + expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) + end + end +end diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb new file mode 100644 index 00000000000..56b1d354eb0 --- /dev/null +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +describe 'Search bar', js: true, feature: true do + include WaitForAjax + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user) } + let(:filtered_search) { find('.filtered-search') } + + before do + project.team << [user, :master] + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + def get_left_style(style) + left_style = /left:\s\d*[.]\d*px/.match(style) + left_style.to_s.gsub('left: ', '').to_f + end + + describe 'clear search button' do + it 'clears text' do + search_text = 'search_text' + filtered_search.set(search_text) + + expect(filtered_search.value).to eq(search_text) + find('.filtered-search-input-container .clear-search').click + + expect(filtered_search.value).to eq('') + end + + it 'hides by default' do + expect(page).to have_css('.clear-search', visible: false) + end + + it 'hides after clicked' do + filtered_search.set('a') + find('.filtered-search-input-container .clear-search').click + + expect(page).to have_css('.clear-search', visible: false) + end + + it 'hides when there is no text' do + filtered_search.set('a') + filtered_search.set('') + + expect(page).to have_css('.clear-search', visible: false) + end + + it 'shows when there is text' do + filtered_search.set('a') + + expect(page).to have_css('.clear-search', visible: true) + end + + it 'resets the dropdown hint filter' do + filtered_search.click + original_size = page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size + + filtered_search.set('author') + + expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1) + + find('.filtered-search-input-container .clear-search').click + filtered_search.click + + expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size) + end + + it 'resets the dropdown filters' do + 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('.filtered-search-input-container .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) + end + end +end diff --git a/spec/features/issues/reset_filters_spec.rb b/spec/features/issues/reset_filters_spec.rb deleted file mode 100644 index c9a3ecf16ea..00000000000 --- a/spec/features/issues/reset_filters_spec.rb +++ /dev/null @@ -1,89 +0,0 @@ -require 'rails_helper' - -feature 'Issues filter reset button', feature: true, js: true do - include WaitForAjax - include IssueHelpers - - let!(:project) { create(:project, :public) } - let!(:user) { create(:user)} - let!(:milestone) { create(:milestone, project: project) } - let!(:bug) { create(:label, project: project, name: 'bug')} - let!(:issue1) { create(:issue, project: project, milestone: milestone, author: user, assignee: user, title: 'Feature')} - let!(:issue2) { create(:labeled_issue, project: project, labels: [bug], title: 'Bugfix1')} - - before do - project.team << [user, :developer] - end - - context 'when a milestone filter has been applied' do - it 'resets the milestone filter' do - visit_issues(project, milestone_title: milestone.title) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when a label filter has been applied' do - it 'resets the label filter' do - visit_issues(project, label_name: bug.name) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when a text search has been conducted' do - it 'resets the text search filter' do - visit_issues(project, search: 'Bug') - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when author filter has been applied' do - it 'resets the author filter' do - visit_issues(project, author_id: user.id) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when assignee filter has been applied' do - it 'resets the assignee filter' do - visit_issues(project, assignee_id: user.id) - expect(page).to have_css('.issue', count: 1) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when all filters have been applied' do - it 'resets all filters' do - visit_issues(project, assignee_id: user.id, author_id: user.id, milestone_title: milestone.title, label_name: bug.name, search: 'Bug') - expect(page).to have_css('.issue', count: 0) - - reset_filters - expect(page).to have_css('.issue', count: 2) - end - end - - context 'when no filters have been applied' do - it 'the reset link should not be visible' do - visit_issues(project) - expect(page).to have_css('.issue', count: 2) - expect(page).not_to have_css '.reset_filters' - end - end - - def reset_filters - find('.reset-filters').click - end -end diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/merge_requests/filter_by_labels_spec.rb index 0253629f753..4c60329865c 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/merge_requests/filter_by_labels_spec.rb @@ -7,25 +7,27 @@ feature 'Issue filtering by Labels', feature: true, js: true do let!(:user) { create(:user) } let!(:label) { create(:label, project: project) } - before do - bug = create(:label, project: project, title: 'bug') - feature = create(:label, project: project, title: 'feature') - enhancement = create(:label, project: project, title: 'enhancement') + let!(:bug) { create(:label, project: project, title: 'bug') } + let!(:feature) { create(:label, project: project, title: 'feature') } + let!(:enhancement) { create(:label, project: project, title: 'enhancement') } + + let!(:mr1) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "bugfix1") } + let!(:mr2) { create(:merge_request, title: "Bugfix2", source_project: project, target_project: project, source_branch: "bugfix2") } + let!(:mr3) { create(:merge_request, title: "Feature1", source_project: project, target_project: project, source_branch: "feature1") } - issue1 = create(:issue, title: "Bugfix1", project: project) - issue1.labels << bug + before do + mr1.labels << bug - issue2 = create(:issue, title: "Bugfix2", project: project) - issue2.labels << bug - issue2.labels << enhancement + mr2.labels << bug + mr2.labels << enhancement - issue3 = create(:issue, title: "Feature1", project: project) - issue3.labels << feature + mr3.title = "Feature1" + mr3.labels << feature project.team << [user, :master] login_as(user) - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) end context 'filter by label bug' do diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb index 0d19563d628..4642b5a530d 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/merge_requests/filter_merge_requests_spec.rb @@ -1,10 +1,10 @@ require 'rails_helper' -describe 'Filter issues', feature: true do +describe 'Filter merge requests', feature: true do include WaitForAjax + let!(:project) { create(:project) } let!(:group) { create(:group) } - let!(:project) { create(:project, group: group) } let!(:user) { create(:user)} let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } @@ -14,12 +14,12 @@ describe 'Filter issues', feature: true do project.team << [user, :master] group.add_developer(user) login_as(user) - create(:issue, project: project) + create(:merge_request, source_project: project, target_project: project) end - describe 'for assignee from issues#index' do + describe 'for assignee from mr#index' do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-assignee-search').click @@ -47,9 +47,9 @@ describe 'Filter issues', feature: true do end end - describe 'for milestone from issues#index' do + describe 'for milestone from mr#index' do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-milestone-select').click @@ -77,9 +77,9 @@ describe 'Filter issues', feature: true do end end - describe 'for label from issues#index', js: true do + describe 'for label from mr#index', js: true do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-label-select').click wait_for_ajax end @@ -127,7 +127,7 @@ describe 'Filter issues', feature: true do expect(page).to have_content wontfix.title end - find('.dropdown-menu-close-icon').click + find('body').click expect(find('.filtered-labels')).to have_content(wontfix.title) @@ -135,7 +135,7 @@ describe 'Filter issues', feature: true do wait_for_ajax find('.dropdown-menu-labels a', text: label.title).click - find('.dropdown-menu-close-icon').click + find('body').click expect(find('.filtered-labels')).to have_content(wontfix.title) expect(find('.filtered-labels')).to have_content(label.title) @@ -150,21 +150,21 @@ describe 'Filter issues', feature: true do it "selects and unselects `won't fix`" do find('.dropdown-menu-labels a', text: wontfix.title).click find('.dropdown-menu-labels a', text: wontfix.title).click - - find('.dropdown-menu-close-icon').click + # Close label dropdown to load + find('body').click expect(page).not_to have_css('.filtered-labels') end end describe 'for assignee and label from issues#index' do before do - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) find('.js-assignee-search').click find('.dropdown-menu-user-link', text: user.username).click - expect(page).not_to have_selector('.issues-list .issue') + expect(page).not_to have_selector('.mr-list .merge-request') find('.js-label-select').click @@ -196,38 +196,40 @@ describe 'Filter issues', feature: true do end end - describe 'filter issues by text' do + describe 'filter merge requests by text' do before do - create(:issue, title: "Bug", project: project) + create(:merge_request, title: "Bug", source_project: project, target_project: project, source_branch: "bug") bug_label = create(:label, project: project, title: 'bug') milestone = create(:milestone, title: "8", project: project) - issue = create(:issue, - title: "Bug 2", - project: project, + mr = create(:merge_request, + title: "Bug 2", + source_project: project, + target_project: project, + source_branch: "bug2", milestone: milestone, author: user, assignee: user) - issue.labels << bug_label + mr.labels << bug_label - visit namespace_project_issues_path(project.namespace, project) + visit namespace_project_merge_requests_path(project.namespace, project) end context 'only text', js: true do - it 'filters issues by searched text' do + it 'filters merge requests by searched text' do fill_in 'issuable_search', with: 'Bug' - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end end - it 'does not show any issues' do + it 'does not show any merge requests' do fill_in 'issuable_search', with: 'testing' - page.within '.issues-list' do - expect(page).not_to have_selector('.issue') + page.within '.mr-list' do + expect(page).not_to have_selector('.merge-request') end end end @@ -237,8 +239,8 @@ describe 'Filter issues', feature: true do fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Label' @@ -248,8 +250,8 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-close-icon').click expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end @@ -257,8 +259,8 @@ describe 'Filter issues', feature: true do fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Milestone' @@ -267,8 +269,8 @@ describe 'Filter issues', feature: true do end expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end @@ -276,8 +278,8 @@ describe 'Filter issues', feature: true do fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Assignee' @@ -286,8 +288,8 @@ describe 'Filter issues', feature: true do end expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end @@ -295,8 +297,8 @@ describe 'Filter issues', feature: true do fill_in 'issuable_search', with: 'Bug' expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Author' @@ -305,26 +307,27 @@ describe 'Filter issues', feature: true do end expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 1) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 1) end end end end - describe 'filter issues and sort', js: true do + describe 'filter merge requests and sort', js: true do before do bug_label = create(:label, project: project, title: 'bug') - bug_one = create(:issue, title: "Frontend", project: project) - bug_two = create(:issue, title: "Bug 2", project: project) - bug_one.labels << bug_label - bug_two.labels << bug_label + mr1 = create(:merge_request, title: "Frontend", source_project: project, target_project: project, source_branch: "Frontend") + mr2 = create(:merge_request, title: "Bug 2", source_project: project, target_project: project, source_branch: "bug2") - visit namespace_project_issues_path(project.namespace, project) + mr1.labels << bug_label + mr2.labels << bug_label + + visit namespace_project_merge_requests_path(project.namespace, project) end - it 'is able to filter and sort issues' do + it 'is able to filter and sort merge requests' do click_button 'Label' wait_for_ajax page.within '.labels-filter' do @@ -334,8 +337,8 @@ describe 'Filter issues', feature: true do wait_for_ajax expect(page).to have_issuable_counts(open: 2, closed: 0, all: 2) - page.within '.issues-list' do - expect(page).to have_selector('.issue', count: 2) + page.within '.mr-list' do + expect(page).to have_selector('.merge-request', count: 2) end click_button 'Last created' @@ -344,41 +347,9 @@ describe 'Filter issues', feature: true do end wait_for_ajax - page.within '.issues-list' do + page.within '.mr-list' do expect(page).to have_content('Frontend') end end end - - it 'updates atom feed link for project issues' do - visit namespace_project_issues_path(project.namespace, project, milestone_title: '', assignee_id: user.id) - - link = find('.nav-controls a', text: 'Subscribe') - params = CGI::parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('private_token' => [user.private_token]) - expect(params).to include('milestone_title' => ['']) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) - expect(auto_discovery_params).to include('milestone_title' => ['']) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) - end - - it 'updates atom feed link for group issues' do - visit issues_group_path(group, milestone_title: '', assignee_id: user.id) - - link = find('.nav-controls a', text: 'Subscribe') - params = CGI::parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('private_token' => [user.private_token]) - expect(params).to include('milestone_title' => ['']) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('private_token' => [user.private_token]) - expect(auto_discovery_params).to include('milestone_title' => ['']) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) - end end diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb new file mode 100644 index 00000000000..3a7ece7e1d6 --- /dev/null +++ b/spec/features/merge_requests/reset_filters_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +feature 'Issues filter reset button', feature: true, js: true do + include WaitForAjax + include IssueHelpers + + let!(:project) { create(:project, :public) } + let!(:user) { create(:user)} + let!(:milestone) { create(:milestone, project: project) } + let!(:bug) { create(:label, project: project, name: 'bug')} + let!(:mr1) { create(:merge_request, title: "Feature", source_project: project, target_project: project, source_branch: "Feature", milestone: milestone, author: user, assignee: user) } + let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") } + + let(:merge_request_css) { '.merge-request' } + + before do + mr2.labels << bug + project.team << [user, :developer] + end + + context 'when a milestone filter has been applied' do + it 'resets the milestone filter' do + visit_merge_requests(project, milestone_title: milestone.title) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when a label filter has been applied' do + it 'resets the label filter' do + visit_merge_requests(project, label_name: bug.name) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when a text search has been conducted' do + it 'resets the text search filter' do + visit_merge_requests(project, search: 'Bug') + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when author filter has been applied' do + it 'resets the author filter' do + visit_merge_requests(project, author_id: user.id) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when assignee filter has been applied' do + it 'resets the assignee filter' do + visit_merge_requests(project, assignee_id: user.id) + expect(page).to have_css(merge_request_css, count: 1) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when all filters have been applied' do + it 'resets all filters' do + visit_merge_requests(project, assignee_id: user.id, author_id: user.id, milestone_title: milestone.title, label_name: bug.name, search: 'Bug') + expect(page).to have_css(merge_request_css, count: 0) + + reset_filters + expect(page).to have_css(merge_request_css, count: 2) + end + end + + context 'when no filters have been applied' do + it 'the reset link should not be visible' do + visit_merge_requests(project) + expect(page).to have_css(merge_request_css, count: 2) + expect(page).not_to have_css '.reset_filters' + end + end + + def visit_merge_requests(project, opts = {}) + visit namespace_project_merge_requests_path project.namespace, project, opts + end + + def reset_filters + find('.reset-filters').click + end +end diff --git a/spec/features/projects/services/mattermost_slash_command_spec.rb b/spec/features/projects/services/mattermost_slash_command_spec.rb index 8de827447ff..86a07b2c679 100644 --- a/spec/features/projects/services/mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/mattermost_slash_command_spec.rb @@ -33,10 +33,89 @@ feature 'Setup Mattermost slash commands', feature: true do expect(value).to eq(token) end - describe 'mattermost service is enabled' do - it 'shows the add to mattermost button' do - expect(page).to have_link 'Add to Mattermost' + it 'shows the add to mattermost button' do + expect(page).to have_link('Add to Mattermost') + end + + it 'shows an explanation if user is a member of no teams' do + stub_teams(count: 0) + + click_link 'Add to Mattermost' + + expect(page).to have_content('You aren’t a member of any team on the Mattermost instance') + expect(page).to have_link('join a team', href: "#{Gitlab.config.mattermost.host}/select_team") + end + + it 'shows an explanation if user is a member of 1 team' do + stub_teams(count: 1) + + click_link 'Add to Mattermost' + + expect(page).to have_content('The team where the slash commands will be used in') + expect(page).to have_content('This is the only available team.') + end + + it 'shows a disabled prefilled select if user is a member of 1 team' do + teams = stub_teams(count: 1) + + click_link 'Add to Mattermost' + + team_name = teams.first[1]['display_name'] + select_element = find('select#mattermost_team_id') + selected_option = select_element.find('option[selected]') + + expect(select_element['disabled']).to be(true) + expect(selected_option).to have_content(team_name.to_s) + end + + it 'has a hidden input for the prefilled value if user is a member of 1 team' do + teams = stub_teams(count: 1) + + click_link 'Add to Mattermost' + + expect(find('input#mattermost_team_id', visible: false).value).to eq(teams.first[0].to_s) + end + + it 'shows an explanation user is a member of multiple teams' do + stub_teams(count: 2) + + click_link 'Add to Mattermost' + + expect(page).to have_content('Select the team where the slash commands will be used in') + expect(page).to have_content('The list shows all available teams.') + end + + it 'shows a select with team options user is a member of multiple teams' do + stub_teams(count: 2) + + click_link 'Add to Mattermost' + + select_element = find('select#mattermost_team_id') + selected_option = select_element.find('option[selected]') + + expect(select_element['disabled']).to be(false) + expect(selected_option).to have_content('Select team...') + # The 'Select team...' placeholder is item `0`. + expect(select_element.all('option').count).to eq(3) + end + + def stub_teams(count: 0) + teams = create_teams(count) + + allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { teams } + + teams + end + + def create_teams(count = 0) + teams = {} + + count.times do |i| + i += 1 + teams[i] = { id: i, display_name: i } end + + teams end describe 'mattermost service is not enabled' do diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index caecd027aaa..a05b83959fb 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -169,16 +169,16 @@ describe "Search", feature: true do find('.dropdown-menu').click_link 'Issues assigned to me' sleep 2 - expect(page).to have_selector('.issues-holder') - expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) + expect(page).to have_selector('.filtered-search') + expect(find('.filtered-search').value).to eq("assignee:@#{user.username}") end it 'takes user to her issues page when issues authored is clicked' do find('.dropdown-menu').click_link "Issues I've created" sleep 2 - expect(page).to have_selector('.issues-holder') - expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) + expect(page).to have_selector('.filtered-search') + expect(find('.filtered-search').value).to eq("author:@#{user.username}") end it 'takes user to her MR page when MR assigned is clicked' do diff --git a/spec/features/snippets/create_snippet_spec.rb b/spec/features/snippets/create_snippet_spec.rb index cb95e7828db..5470276bf06 100644 --- a/spec/features/snippets/create_snippet_spec.rb +++ b/spec/features/snippets/create_snippet_spec.rb @@ -17,4 +17,18 @@ feature 'Create Snippet', feature: true do expect(page).to have_content('My Snippet Title') expect(page).to have_content('Hello World!') end + + scenario 'Authenticated user creates a snippet with + in filename' do + fill_in 'personal_snippet_title', with: 'My Snippet Title' + page.within('.file-editor') do + find(:xpath, "//input[@id='personal_snippet_file_name']").set 'snippet+file+name' + find(:xpath, "//input[@id='personal_snippet_content']").set 'Hello World!' + end + + click_button 'Create snippet' + + expect(page).to have_content('My Snippet Title') + expect(page).to have_content('snippet+file+name') + expect(page).to have_content('Hello World!') + end end diff --git a/spec/fixtures/config/redis_config_with_env.yml b/spec/fixtures/config/redis_config_with_env.yml new file mode 100644 index 00000000000..f5860f37e47 --- /dev/null +++ b/spec/fixtures/config/redis_config_with_env.yml @@ -0,0 +1,2 @@ +test: + url: <%= ENV['TEST_GITLAB_REDIS_URL'] %> diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb index 837b0de9a4c..ad7f032d1e5 100644 --- a/spec/initializers/secret_token_spec.rb +++ b/spec/initializers/secret_token_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' require_relative '../../config/initializers/secret_token' describe 'create_tokens', lib: true do + include StubENV + let(:secrets) { ActiveSupport::OrderedOptions.new } before do - allow(ENV).to receive(:[]).and_call_original allow(File).to receive(:write) allow(File).to receive(:delete) allow(Rails).to receive_message_chain(:application, :secrets).and_return(secrets) @@ -17,7 +18,7 @@ describe 'create_tokens', lib: true do context 'setting secret_key_base and otp_key_base' do context 'when none of the secrets exist' do before do - allow(ENV).to receive(:[]).with('SECRET_KEY_BASE').and_return(nil) + stub_env('SECRET_KEY_BASE', nil) allow(File).to receive(:exist?).with('.secret').and_return(false) allow(File).to receive(:exist?).with('config/secrets.yml').and_return(false) allow(self).to receive(:warn_missing_secret) @@ -69,7 +70,7 @@ describe 'create_tokens', lib: true do context 'when secret_key_base exists in the environment and secrets.yml' do before do - allow(ENV).to receive(:[]).with('SECRET_KEY_BASE').and_return('env_key') + stub_env('SECRET_KEY_BASE', 'env_key') secrets.secret_key_base = 'secret_key_base' secrets.otp_key_base = 'otp_key_base' end diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 b/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 new file mode 100644 index 00000000000..ce61b73aa8a --- /dev/null +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 @@ -0,0 +1,107 @@ +//= require extensions/array +//= require filtered_search/dropdown_utils +//= require filtered_search/filtered_search_tokenizer +//= require filtered_search/filtered_search_dropdown_manager + +(() => { + describe('Dropdown Utils', () => { + describe('getEscapedText', () => { + it('should return same word when it has no space', () => { + const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace'); + expect(escaped).toBe('textWithoutSpace'); + }); + + it('should escape with double quotes', () => { + let escaped = gl.DropdownUtils.getEscapedText('text with space'); + expect(escaped).toBe('"text with space"'); + + escaped = gl.DropdownUtils.getEscapedText('won\'t fix'); + expect(escaped).toBe('"won\'t fix"'); + }); + + it('should escape with single quotes', () => { + const escaped = gl.DropdownUtils.getEscapedText('won"t fix'); + expect(escaped).toBe('\'won"t fix\''); + }); + + it('should escape with single quotes by default', () => { + const escaped = gl.DropdownUtils.getEscapedText('won"t\' fix'); + expect(escaped).toBe('\'won"t\' fix\''); + }); + }); + + describe('filterWithSymbol', () => { + const item = { + title: '@root', + }; + + it('should filter without symbol', () => { + const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':roo'); + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with symbol', () => { + const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':@roo'); + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should filter with colon', () => { + const updatedItem = gl.DropdownUtils.filterWithSymbol('@', item, ':'); + expect(updatedItem.droplab_hidden).toBe(false); + }); + }); + + describe('filterHint', () => { + it('should filter', () => { + let updatedItem = gl.DropdownUtils.filterHint({ + hint: 'label', + }, 'l'); + expect(updatedItem.droplab_hidden).toBe(false); + + updatedItem = gl.DropdownUtils.filterHint({ + hint: 'label', + }, 'o'); + expect(updatedItem.droplab_hidden).toBe(true); + }); + + it('should return droplab_hidden false when item has no hint', () => { + const updatedItem = gl.DropdownUtils.filterHint({}, ''); + expect(updatedItem.droplab_hidden).toBe(false); + }); + }); + + describe('setDataValueIfSelected', () => { + beforeEach(() => { + spyOn(gl.FilteredSearchDropdownManager, 'addWordToInput') + .and.callFake(() => {}); + }); + + it('calls addWordToInput when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + }; + + gl.DropdownUtils.setDataValueIfSelected(null, selected); + expect(gl.FilteredSearchDropdownManager.addWordToInput.calls.count()).toEqual(1); + }); + + it('returns true when dataValue exists', () => { + const selected = { + getAttribute: () => 'value', + }; + + const result = gl.DropdownUtils.setDataValueIfSelected(null, selected); + expect(result).toBe(true); + }); + + it('returns false when dataValue does not exist', () => { + const selected = { + getAttribute: () => null, + }; + + const result = gl.DropdownUtils.setDataValueIfSelected(null, selected); + expect(result).toBe(false); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 new file mode 100644 index 00000000000..d0d27ceb4a6 --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 @@ -0,0 +1,59 @@ +//= require extensions/array +//= require filtered_search/filtered_search_tokenizer +//= require filtered_search/filtered_search_dropdown_manager + +(() => { + describe('Filtered Search Dropdown Manager', () => { + describe('addWordToInput', () => { + function getInputValue() { + return document.querySelector('.filtered-search').value; + } + + function setInputValue(value) { + document.querySelector('.filtered-search').value = value; + } + + beforeEach(() => { + const input = document.createElement('input'); + input.classList.add('filtered-search'); + document.body.appendChild(input); + }); + + afterEach(() => { + document.querySelector('.filtered-search').outerHTML = ''; + }); + + describe('input has no existing value', () => { + it('should add just tokenName', () => { + gl.FilteredSearchDropdownManager.addWordToInput('milestone'); + expect(getInputValue()).toBe('milestone:'); + }); + + it('should add tokenName and tokenValue', () => { + gl.FilteredSearchDropdownManager.addWordToInput('label', 'none'); + expect(getInputValue()).toBe('label:none'); + }); + }); + + describe('input has existing value', () => { + it('should be able to just add tokenName', () => { + setInputValue('a'); + gl.FilteredSearchDropdownManager.addWordToInput('author'); + expect(getInputValue()).toBe('author:'); + }); + + it('should replace tokenValue', () => { + setInputValue('author:roo'); + gl.FilteredSearchDropdownManager.addWordToInput('author', '@root'); + expect(getInputValue()).toBe('author:@root'); + }); + + it('should add tokenValues containing spaces', () => { + setInputValue('label:~"test'); + gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); + expect(getInputValue()).toBe('label:~\'"test me"\''); + }); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 new file mode 100644 index 00000000000..6df7c0e44ef --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 @@ -0,0 +1,104 @@ +//= require extensions/array +//= require filtered_search/filtered_search_token_keys + +(() => { + describe('Filtered Search Token Keys', () => { + describe('get', () => { + let tokenKeys; + + beforeEach(() => { + tokenKeys = gl.FilteredSearchTokenKeys.get(); + }); + + it('should return tokenKeys', () => { + expect(tokenKeys !== null).toBe(true); + }); + + it('should return tokenKeys as an array', () => { + expect(tokenKeys instanceof Array).toBe(true); + }); + }); + + describe('getConditions', () => { + let conditions; + + beforeEach(() => { + conditions = gl.FilteredSearchTokenKeys.getConditions(); + }); + + it('should return conditions', () => { + expect(conditions !== null).toBe(true); + }); + + it('should return conditions as an array', () => { + expect(conditions instanceof Array).toBe(true); + }); + }); + + describe('searchByKey', () => { + it('should return null when key not found', () => { + const tokenKey = gl.FilteredSearchTokenKeys.searchByKey('notakey'); + expect(tokenKey === null).toBe(true); + }); + + it('should return tokenKey when found by key', () => { + const tokenKeys = gl.FilteredSearchTokenKeys.get(); + const result = gl.FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key); + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchBySymbol', () => { + it('should return null when symbol not found', () => { + const tokenKey = gl.FilteredSearchTokenKeys.searchBySymbol('notasymbol'); + expect(tokenKey === null).toBe(true); + }); + + it('should return tokenKey when found by symbol', () => { + const tokenKeys = gl.FilteredSearchTokenKeys.get(); + const result = gl.FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol); + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByKeyParam', () => { + it('should return null when key param not found', () => { + const tokenKey = gl.FilteredSearchTokenKeys.searchByKeyParam('notakeyparam'); + expect(tokenKey === null).toBe(true); + }); + + it('should return tokenKey when found by key param', () => { + const tokenKeys = gl.FilteredSearchTokenKeys.get(); + const result = gl.FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`); + expect(result).toEqual(tokenKeys[0]); + }); + }); + + describe('searchByConditionUrl', () => { + it('should return null when condition url not found', () => { + const condition = gl.FilteredSearchTokenKeys.searchByConditionUrl(null); + expect(condition === null).toBe(true); + }); + + it('should return condition when found by url', () => { + const conditions = gl.FilteredSearchTokenKeys.getConditions(); + const result = gl.FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); + expect(result).toBe(conditions[0]); + }); + }); + + describe('searchByConditionKeyValue', () => { + it('should return null when condition tokenKey and value not found', () => { + const condition = gl.FilteredSearchTokenKeys.searchByConditionKeyValue(null, null); + expect(condition === null).toBe(true); + }); + + it('should return condition when found by tokenKey and value', () => { + const conditions = gl.FilteredSearchTokenKeys.getConditions(); + const result = gl.FilteredSearchTokenKeys + .searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value); + expect(result).toEqual(conditions[0]); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 new file mode 100644 index 00000000000..ac7f8e9cbcd --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 @@ -0,0 +1,104 @@ +//= require extensions/array +//= require filtered_search/filtered_search_token_keys +//= require filtered_search/filtered_search_tokenizer + +(() => { + describe('Filtered Search Tokenizer', () => { + describe('processTokens', () => { + it('returns for input containing only search value', () => { + const results = gl.FilteredSearchTokenizer.processTokens('searchTerm'); + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(0); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing only tokens', () => { + const results = gl.FilteredSearchTokenizer + .processTokens('author:@root label:~"Very Important" milestone:%v1.0 assignee:none'); + expect(results.searchToken).toBe(''); + expect(results.tokens.length).toBe(4); + expect(results.tokens[3]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Very Important"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('v1.0'); + expect(results.tokens[2].symbol).toBe('%'); + + expect(results.tokens[3].key).toBe('assignee'); + expect(results.tokens[3].value).toBe('none'); + expect(results.tokens[3].symbol).toBe(''); + }); + + it('returns for input starting with search value and ending with tokens', () => { + const results = gl.FilteredSearchTokenizer + .processTokens('searchTerm anotherSearchTerm milestone:none'); + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0]).toBe(results.lastToken); + expect(results.tokens[0].key).toBe('milestone'); + expect(results.tokens[0].value).toBe('none'); + expect(results.tokens[0].symbol).toBe(''); + }); + + it('returns for input starting with tokens and ending with search value', () => { + const results = gl.FilteredSearchTokenizer + .processTokens('assignee:@user searchTerm'); + + expect(results.searchToken).toBe('searchTerm'); + expect(results.tokens.length).toBe(1); + expect(results.tokens[0].key).toBe('assignee'); + expect(results.tokens[0].value).toBe('user'); + expect(results.tokens[0].symbol).toBe('@'); + expect(results.lastToken).toBe(results.searchToken); + }); + + it('returns for input containing search value wrapped between tokens', () => { + const results = gl.FilteredSearchTokenizer + .processTokens('author:@root label:~"Won\'t fix" searchTerm anotherSearchTerm milestone:none'); + + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('label'); + expect(results.tokens[1].value).toBe('"Won\'t fix"'); + expect(results.tokens[1].symbol).toBe('~'); + + expect(results.tokens[2].key).toBe('milestone'); + expect(results.tokens[2].value).toBe('none'); + expect(results.tokens[2].symbol).toBe(''); + }); + + it('returns for input containing search value in between tokens', () => { + const results = gl.FilteredSearchTokenizer + .processTokens('author:@root searchTerm assignee:none anotherSearchTerm label:~Doing'); + expect(results.searchToken).toBe('searchTerm anotherSearchTerm'); + expect(results.tokens.length).toBe(3); + expect(results.tokens[2]).toBe(results.lastToken); + + expect(results.tokens[0].key).toBe('author'); + expect(results.tokens[0].value).toBe('root'); + expect(results.tokens[0].symbol).toBe('@'); + + expect(results.tokens[1].key).toBe('assignee'); + expect(results.tokens[1].value).toBe('none'); + expect(results.tokens[1].symbol).toBe(''); + + expect(results.tokens[2].key).toBe('label'); + expect(results.tokens[2].value).toBe('Doing'); + expect(results.tokens[2].symbol).toBe('~'); + }); + }); + }); +})(); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js.es6 b/spec/javascripts/lib/utils/common_utils_spec.js.es6 index ef75f600898..031f9ca03c9 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js.es6 +++ b/spec/javascripts/lib/utils/common_utils_spec.js.es6 @@ -15,6 +15,7 @@ expect(gl.utils.parseUrl('" test="asf"').pathname).toEqual('/teaspoon/%22%20test=%22asf%22'); }); }); + describe('gl.utils.parseUrlPathname', () => { beforeEach(() => { spyOn(gl.utils, 'parseUrl').and.callFake(url => ({ @@ -28,5 +29,28 @@ expect(gl.utils.parseUrlPathname('some/relative/url')).toEqual('/some/relative/url'); }); }); + + describe('gl.utils.getUrlParamsArray', () => { + it('should return params array', () => { + expect(gl.utils.getUrlParamsArray() instanceof Array).toBe(true); + }); + + it('should remove the question mark from the search params', () => { + const paramsArray = gl.utils.getUrlParamsArray(); + expect(paramsArray[0][0] !== '?').toBe(true); + }); + }); + + describe('gl.utils.getParameterByName', () => { + it('should return valid parameter', () => { + const value = gl.utils.getParameterByName('reporter'); + expect(value).toBe('Console'); + }); + + it('should return invalid parameter', () => { + const value = gl.utils.getParameterByName('fakeParameter'); + expect(value).toBe(null); + }); + }); }); })(); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js.es6 b/spec/javascripts/lib/utils/text_utility_spec.js.es6 new file mode 100644 index 00000000000..e97356b65d5 --- /dev/null +++ b/spec/javascripts/lib/utils/text_utility_spec.js.es6 @@ -0,0 +1,25 @@ +//= require lib/utils/text_utility + +(() => { + describe('text_utility', () => { + describe('gl.text.getTextWidth', () => { + it('returns zero width when no text is passed', () => { + expect(gl.text.getTextWidth('')).toBe(0); + }); + + it('returns zero width when no text is passed and font is passed', () => { + expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); + }); + + it('returns width when text is passed', () => { + expect(gl.text.getTextWidth('foo') > 0).toBe(true); + }); + + it('returns bigger width when font is larger', () => { + const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); + const regular = gl.text.getTextWidth('foo', '10px sans-serif'); + expect(largeFont > regular).toBe(true); + }); + }); + }); +})(); diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index e13c4ad772c..2d3f44e7980 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -11,6 +11,7 @@ (function() { var addBodyAttributes, assertLinks, dashboardIssuesPath, dashboardMRsPath, groupIssuesPath, groupMRsPath, groupName, mockDashboardOptions, mockGroupOptions, mockProjectOptions, projectIssuesPath, projectMRsPath, projectName, userId, widget; + var userName = 'root'; widget = null; @@ -19,6 +20,7 @@ window.gon || (window.gon = {}); window.gon.current_user_id = userId; + window.gon.current_username = userName; dashboardIssuesPath = '/dashboard/issues'; @@ -93,8 +95,8 @@ assertLinks = function(list, issuesPath, mrsPath) { var a1, a2, a3, a4, issuesAssignedToMeLink, issuesIHaveCreatedLink, mrsAssignedToMeLink, mrsIHaveCreatedLink; - issuesAssignedToMeLink = issuesPath + "/?assignee_id=" + userId; - issuesIHaveCreatedLink = issuesPath + "/?author_id=" + userId; + issuesAssignedToMeLink = issuesPath + "/?assignee_username=" + userName; + issuesIHaveCreatedLink = issuesPath + "/?author_username=" + userName; mrsAssignedToMeLink = mrsPath + "/?assignee_id=" + userId; mrsIHaveCreatedLink = mrsPath + "/?author_id=" + userId; a1 = "a[href='" + issuesAssignedToMeLink + "']"; diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 62d68721574..f824e2e1efe 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -769,6 +769,19 @@ module Ci expect(builds.first[:environment]).to eq(environment[:name]) expect(builds.first[:options]).to include(environment: environment) end + + context 'the url has a port as variable' do + let(:environment) do + { name: 'production', + url: 'http://production.gitlab.com:$PORT' } + end + + it 'allows a variable for the port' do + expect(builds.size).to eq(1) + expect(builds.first[:environment]).to eq(environment[:name]) + expect(builds.first[:options]).to include(environment: environment) + end + end end context 'when no environment is specified' do diff --git a/spec/lib/gitlab/asciidoc_spec.rb b/spec/lib/gitlab/asciidoc_spec.rb index f3843ca64ff..ba199917f5c 100644 --- a/spec/lib/gitlab/asciidoc_spec.rb +++ b/spec/lib/gitlab/asciidoc_spec.rb @@ -8,6 +8,10 @@ module Gitlab let(:html) { 'H<sub>2</sub>O' } context "without project" do + before do + allow_any_instance_of(ApplicationSetting).to receive(:current).and_return(::ApplicationSetting.create_from_defaults) + end + it "converts the input using Asciidoctor and default options" do expected_asciidoc_opts = { safe: :secure, diff --git a/spec/lib/gitlab/backup/manager_spec.rb b/spec/lib/gitlab/backup/manager_spec.rb index 1b749d1bd39..f84782ab440 100644 --- a/spec/lib/gitlab/backup/manager_spec.rb +++ b/spec/lib/gitlab/backup/manager_spec.rb @@ -1,9 +1,27 @@ require 'spec_helper' describe Backup::Manager, lib: true do - describe '#remove_old' do - let(:progress) { StringIO.new } + include StubENV + + let(:progress) { StringIO.new } + + before do + allow(progress).to receive(:puts) + allow(progress).to receive(:print) + + allow_any_instance_of(String).to receive(:color) do |string, _color| + string + end + + @old_progress = $progress # rubocop:disable Style/GlobalVars + $progress = progress # rubocop:disable Style/GlobalVars + end + + after do + $progress = @old_progress # rubocop:disable Style/GlobalVars + end + describe '#remove_old' do let(:files) do [ '1451606400_2016_01_01_gitlab_backup.tar', @@ -20,20 +38,6 @@ describe Backup::Manager, lib: true do allow(Dir).to receive(:glob).and_return(files) allow(FileUtils).to receive(:rm) allow(Time).to receive(:now).and_return(Time.utc(2016)) - - allow(progress).to receive(:puts) - allow(progress).to receive(:print) - - allow_any_instance_of(String).to receive(:color) do |string, _color| - string - end - - @old_progress = $progress # rubocop:disable Style/GlobalVars - $progress = progress # rubocop:disable Style/GlobalVars - end - - after do - $progress = @old_progress # rubocop:disable Style/GlobalVars end context 'when keep_time is zero' do @@ -124,4 +128,82 @@ describe Backup::Manager, lib: true do end end end + + describe '#unpack' do + before do + allow(Dir).to receive(:chdir) + end + + context 'when there are no backup files in the directory' do + before do + allow(Dir).to receive(:glob).and_return([]) + end + + it 'fails the operation and prints an error' do + expect { subject.unpack }.to raise_error SystemExit + expect(progress).to have_received(:puts) + .with(a_string_matching('No backups found')) + end + end + + context 'when there are two backup files in the directory and BACKUP variable is not set' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar', + '1451520000_2015_12_31_gitlab_backup.tar', + ] + ) + end + + it 'fails the operation and prints an error' do + expect { subject.unpack }.to raise_error SystemExit + expect(progress).to have_received(:puts) + .with(a_string_matching('Found more than one backup')) + end + end + + context 'when BACKUP variable is set to a non-existing file' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(false) + + stub_env('BACKUP', 'wrong') + end + + it 'fails the operation and prints an error' do + expect { subject.unpack }.to raise_error SystemExit + expect(File).to have_received(:exist?).with('wrong_gitlab_backup.tar') + expect(progress).to have_received(:puts) + .with(a_string_matching('The backup file wrong_gitlab_backup.tar does not exist')) + end + end + + context 'when BACKUP variable is set to a correct file' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(true) + allow(Kernel).to receive(:system).and_return(true) + allow(YAML).to receive(:load_file).and_return(gitlab_version: Gitlab::VERSION) + + stub_env('BACKUP', '1451606400_2016_01_01') + end + + it 'unpacks the file' do + subject.unpack + + expect(Kernel).to have_received(:system) + .with("tar", "-xf", "1451606400_2016_01_01_gitlab_backup.tar") + expect(progress).to have_received(:puts).with(a_string_matching('done')) + end + end + end end diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index 39069b49978..98effecdbbc 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -56,7 +56,6 @@ describe Gitlab::Checks::ChangeAccess, lib: true do it 'returns an error if the user is not allowed to do forced pushes to protected branches' do expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) - expect(user_access).to receive(:can_do_action?).with(:force_push_code_to_protected_branches).and_return(false) expect(subject.status).to be(false) expect(subject.message).to eq('You are not allowed to force push code to a protected branch on this project.') @@ -88,8 +87,6 @@ describe Gitlab::Checks::ChangeAccess, lib: true do end it 'returns an error if the user is not allowed to delete protected branches' do - expect(user_access).to receive(:can_do_action?).with(:remove_protected_branches).and_return(false) - expect(subject.status).to be(false) expect(subject.message).to eq('You are not allowed to delete protected branches from this project.') end diff --git a/spec/lib/gitlab/ci/config/entry/environment_spec.rb b/spec/lib/gitlab/ci/config/entry/environment_spec.rb index d97806295fb..2adbed2154f 100644 --- a/spec/lib/gitlab/ci/config/entry/environment_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/environment_spec.rb @@ -196,22 +196,5 @@ describe Gitlab::Ci::Config::Entry::Environment do end end end - - context 'when invalid URL is used' do - let(:config) { { name: 'test', url: 'invalid-example.gitlab.com' } } - - describe '#valid?' do - it 'is not valid' do - expect(entry).not_to be_valid - end - end - - describe '#errors?' do - it 'contains error about invalid URL' do - expect(entry.errors) - .to include "environment url must be a valid url" - end - end - end end end diff --git a/spec/lib/gitlab/ldap/access_spec.rb b/spec/lib/gitlab/ldap/access_spec.rb index 011c33e63a1..b9d12c3c24c 100644 --- a/spec/lib/gitlab/ldap/access_spec.rb +++ b/spec/lib/gitlab/ldap/access_spec.rb @@ -127,7 +127,7 @@ describe Gitlab::LDAP::Access, lib: true do it 'logs the reason' do expect(Gitlab::AppLogger).to have_received(:info).with( - "LDAP account \"123456\" reason, " + + "LDAP account \"123456\" reason, " \ "blocking Gitlab user \"#{user.name}\" (#{user.email})" ) end @@ -148,7 +148,7 @@ describe Gitlab::LDAP::Access, lib: true do it 'logs the reason' do Gitlab::AppLogger.info( - "LDAP account \"123456\" reason, " + + "LDAP account \"123456\" reason, " \ "unblocking Gitlab user \"#{user.name}\" (#{user.email})" ) end diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index 7371b578a48..fb470ea7568 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -126,5 +126,16 @@ describe Gitlab::Metrics::RackMiddleware do expect(transaction.action).to eq('Grape#GET /projects/:id/archive') end + + it 'does not tag a transaction if route infos are missing' do + endpoint = double(:endpoint) + allow(endpoint).to receive(:route).and_raise + + env['api.endpoint'] = endpoint + + middleware.tag_endpoint(transaction, env) + + expect(transaction.action).to be_nil + end end end diff --git a/spec/lib/gitlab/redis_spec.rb b/spec/lib/gitlab/redis_spec.rb index e5406fb2d33..917c5c46db1 100644 --- a/spec/lib/gitlab/redis_spec.rb +++ b/spec/lib/gitlab/redis_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::Redis do - let(:redis_config) { Rails.root.join('config', 'resque.yml').to_s } + include StubENV before(:each) { clear_raw_config } after(:each) { clear_raw_config } @@ -72,6 +72,20 @@ describe Gitlab::Redis do expect(url2).not_to end_with('foobar') end + + context 'when yml file with env variable' do + let(:redis_config) { Rails.root.join('spec/fixtures/config/redis_config_with_env.yml') } + + before do + stub_env('TEST_GITLAB_REDIS_URL', 'redis://redishost:6379') + end + + it 'reads redis url from env variable' do + stub_const("#{described_class}::CONFIG_FILE", redis_config) + + expect(described_class.url).to eq 'redis://redishost:6379' + end + end end describe '._raw_config' do diff --git a/spec/migrations/fill_authorized_projects_spec.rb b/spec/migrations/fill_authorized_projects_spec.rb new file mode 100644 index 00000000000..99dc4195818 --- /dev/null +++ b/spec/migrations/fill_authorized_projects_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170106142508_fill_authorized_projects.rb') + +describe FillAuthorizedProjects do + describe '#up' do + it 'schedules the jobs in batches' do + user1 = create(:user) + user2 = create(:user) + + expect(Sidekiq::Client).to receive(:push_bulk).with( + 'class' => 'AuthorizedProjectsWorker', + 'args' => [[user1.id], [user2.id]] + ) + + described_class.new.up + end + end +end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb deleted file mode 100644 index cd3b6d51545..00000000000 --- a/spec/models/build_spec.rb +++ /dev/null @@ -1,1338 +0,0 @@ -require 'spec_helper' - -describe Ci::Build, models: true do - let(:project) { create(:project) } - - let(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id, - ref: project.default_branch, - status: 'success') - end - - let(:build) { create(:ci_build, pipeline: pipeline) } - - it { is_expected.to validate_presence_of :ref } - - it { is_expected.to respond_to :trace_html } - - describe '#first_pending' do - let!(:first) { create(:ci_build, pipeline: pipeline, status: 'pending', created_at: Date.yesterday) } - let!(:second) { create(:ci_build, pipeline: pipeline, status: 'pending') } - subject { Ci::Build.first_pending } - - it { is_expected.to be_a(Ci::Build) } - it('returns with the first pending build') { is_expected.to eq(first) } - end - - describe '#create_from' do - before do - build.status = 'success' - build.save - end - let(:create_from_build) { Ci::Build.create_from build } - - it 'exists a pending task' do - expect(Ci::Build.pending.count(:all)).to eq 0 - create_from_build - expect(Ci::Build.pending.count(:all)).to be > 0 - end - end - - describe '#failed_but_allowed?' do - subject { build.failed_but_allowed? } - - context 'when build is not allowed to fail' do - before do - build.allow_failure = false - end - - context 'and build.status is success' do - before do - build.status = 'success' - end - - it { is_expected.to be_falsey } - end - - context 'and build.status is failed' do - before do - build.status = 'failed' - end - - it { is_expected.to be_falsey } - end - end - - context 'when build is allowed to fail' do - before do - build.allow_failure = true - end - - context 'and build.status is success' do - before do - build.status = 'success' - end - - it { is_expected.to be_falsey } - end - - context 'and build.status is failed' do - before do - build.status = 'failed' - end - - it { is_expected.to be_truthy } - end - end - end - - describe '#persisted_environment' do - before do - @environment = create(:environment, project: project, name: "foo-#{project.default_branch}") - end - - subject { build.persisted_environment } - - context 'referenced literally' do - let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") } - - it { is_expected.to eq(@environment) } - end - - context 'referenced with a variable' do - let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_BUILD_REF_NAME") } - - it { is_expected.to eq(@environment) } - end - end - - describe '#trace' do - it { expect(build.trace).to be_nil } - - context 'when build.trace contains text' do - let(:text) { 'example output' } - before do - build.trace = text - end - - it { expect(build.trace).to eq(text) } - end - - context 'when build.trace hides runners token' do - let(:token) { 'my_secret_token' } - - before do - build.update(trace: token) - build.project.update(runners_token: token) - end - - it { expect(build.trace).not_to include(token) } - it { expect(build.raw_trace).to include(token) } - end - - context 'when build.trace hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(trace: token) - build.update(token: token) - end - - it { expect(build.trace).not_to include(token) } - it { expect(build.raw_trace).to include(token) } - end - end - - describe '#raw_trace' do - subject { build.raw_trace } - - context 'when build.trace hides runners token' do - let(:token) { 'my_secret_token' } - - before do - build.project.update(runners_token: token) - build.update(trace: token) - end - - it { is_expected.not_to include(token) } - end - - context 'when build.trace hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(token: token) - build.update(trace: token) - end - - it { is_expected.not_to include(token) } - end - end - - context '#append_trace' do - subject { build.trace_html } - - context 'when build.trace hides runners token' do - let(:token) { 'my_secret_token' } - - before do - build.project.update(runners_token: token) - build.append_trace(token, 0) - end - - it { is_expected.not_to include(token) } - end - - context 'when build.trace hides build token' do - let(:token) { 'my_secret_token' } - - before do - build.update(token: token) - build.append_trace(token, 0) - end - - it { is_expected.not_to include(token) } - end - end - - # TODO: build timeout - # describe :timeout do - # subject { build.timeout } - # - # it { is_expected.to eq(pipeline.project.timeout) } - # end - - describe '#options' do - let(:options) do - { - image: "ruby:2.1", - services: [ - "postgres" - ] - } - end - - subject { build.options } - it { is_expected.to eq(options) } - end - - # TODO: allow_git_fetch - # describe :allow_git_fetch do - # subject { build.allow_git_fetch } - # - # it { is_expected.to eq(project.allow_git_fetch) } - # end - - describe '#project' do - subject { build.project } - - it { is_expected.to eq(pipeline.project) } - end - - describe '#project_id' do - subject { build.project_id } - - it { is_expected.to eq(pipeline.project_id) } - end - - describe '#project_name' do - subject { build.project_name } - - it { is_expected.to eq(project.name) } - end - - describe '#extract_coverage' do - context 'valid content & regex' do - subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', '\(\d+.\d+\%\) covered') } - - it { is_expected.to eq(98.29) } - end - - context 'valid content & bad regex' do - subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', 'very covered') } - - it { is_expected.to be_nil } - end - - context 'no coverage content & regex' do - subject { build.extract_coverage('No coverage for today :sad:', '\(\d+.\d+\%\) covered') } - - it { is_expected.to be_nil } - end - - context 'multiple results in content & regex' do - subject { build.extract_coverage(' (98.39%) covered. (98.29%) covered', '\(\d+.\d+\%\) covered') } - - it { is_expected.to eq(98.29) } - end - - context 'using a regex capture' do - subject { build.extract_coverage('TOTAL 9926 3489 65%', 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)') } - - it { is_expected.to eq(65) } - end - end - - describe '#ref_slug' do - { - 'master' => 'master', - '1-foo' => '1-foo', - 'fix/1-foo' => 'fix-1-foo', - 'fix-1-foo' => 'fix-1-foo', - 'a' * 63 => 'a' * 63, - 'a' * 64 => 'a' * 63, - 'FOO' => 'foo', - }.each do |ref, slug| - it "transforms #{ref} to #{slug}" do - build.ref = ref - - expect(build.ref_slug).to eq(slug) - end - end - end - - describe '#variables' do - let(:container_registry_enabled) { false } - let(:predefined_variables) do - [ - { key: 'CI', value: 'true', public: true }, - { key: 'GITLAB_CI', value: 'true', public: true }, - { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, - { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, - { key: 'CI_BUILD_REF', value: build.sha, public: true }, - { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, - { key: 'CI_BUILD_REF_NAME', value: 'master', public: true }, - { key: 'CI_BUILD_REF_SLUG', value: 'master', public: true }, - { key: 'CI_BUILD_NAME', value: 'test', public: true }, - { key: 'CI_BUILD_STAGE', value: 'test', public: true }, - { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, - { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, - { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, - { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, - { key: 'CI_PROJECT_NAME', value: project.path, public: true }, - { key: 'CI_PROJECT_PATH', value: project.path_with_namespace, public: true }, - { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.path, public: true }, - { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, - { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true } - ] - end - - before do - stub_container_registry_config(enabled: container_registry_enabled, host_port: 'registry.example.com') - end - - subject { build.variables } - - context 'returns variables' do - before do - build.yaml_variables = [] - end - - it { is_expected.to eq(predefined_variables) } - end - - context 'when build has user' do - let(:user) { create(:user, username: 'starter') } - let(:user_variables) do - [ - { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, - { key: 'GITLAB_USER_EMAIL', value: user.email, public: true } - ] - end - - before do - build.update_attributes(user: user) - end - - it { user_variables.each { |v| is_expected.to include(v) } } - end - - context 'when build has an environment' do - before do - build.update(environment: 'production') - create(:environment, project: build.project, name: 'production', slug: 'prod-slug') - end - - let(:environment_variables) do - [ - { key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true }, - { key: 'CI_ENVIRONMENT_SLUG', value: 'prod-slug', public: true } - ] - end - - it { environment_variables.each { |v| is_expected.to include(v) } } - end - - context 'when build started manually' do - before do - build.update_attributes(when: :manual) - end - - let(:manual_variable) do - { key: 'CI_BUILD_MANUAL', value: 'true', public: true } - end - - it { is_expected.to include(manual_variable) } - end - - context 'when build is for tag' do - let(:tag_variable) do - { key: 'CI_BUILD_TAG', value: 'master', public: true } - end - - before do - build.update_attributes(tag: true) - end - - it { is_expected.to include(tag_variable) } - end - - context 'when secure variable is defined' do - let(:secure_variable) do - { key: 'SECRET_KEY', value: 'secret_value', public: false } - end - - before do - build.project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') - end - - it { is_expected.to include(secure_variable) } - end - - context 'when build is for triggers' do - let(:trigger) { create(:ci_trigger, project: project) } - let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline, trigger: trigger) } - let(:user_trigger_variable) do - { key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1', public: false } - end - let(:predefined_trigger_variable) do - { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } - end - - before do - build.trigger_request = trigger_request - end - - it { is_expected.to include(user_trigger_variable) } - it { is_expected.to include(predefined_trigger_variable) } - end - - context 'when yaml_variables are undefined' do - before do - build.yaml_variables = nil - end - - context 'use from gitlab-ci.yml' do - before do - stub_ci_pipeline_yaml_file(config) - end - - context 'when config is not found' do - let(:config) { nil } - - it { is_expected.to eq(predefined_variables) } - end - - context 'when config does not have a questioned job' do - let(:config) do - YAML.dump({ - test_other: { - script: 'Hello World' - } - }) - end - - it { is_expected.to eq(predefined_variables) } - end - - context 'when config has variables' do - let(:config) do - YAML.dump({ - test: { - script: 'Hello World', - variables: { - KEY: 'value' - } - } - }) - end - let(:variables) do - [{ key: 'KEY', value: 'value', public: true }] - end - - it { is_expected.to eq(predefined_variables + variables) } - end - end - end - - context 'when container registry is enabled' do - let(:container_registry_enabled) { true } - let(:ci_registry) do - { key: 'CI_REGISTRY', value: 'registry.example.com', public: true } - end - let(:ci_registry_image) do - { key: 'CI_REGISTRY_IMAGE', value: project.container_registry_repository_url, public: true } - end - - context 'and is disabled for project' do - before do - project.update(container_registry_enabled: false) - end - - it { is_expected.to include(ci_registry) } - it { is_expected.not_to include(ci_registry_image) } - end - - context 'and is enabled for project' do - before do - project.update(container_registry_enabled: true) - end - - it { is_expected.to include(ci_registry) } - it { is_expected.to include(ci_registry_image) } - end - end - - context 'when runner is assigned to build' do - let(:runner) { create(:ci_runner, description: 'description', tag_list: ['docker', 'linux']) } - - before do - build.update(runner: runner) - end - - it { is_expected.to include({ key: 'CI_RUNNER_ID', value: runner.id.to_s, public: true }) } - it { is_expected.to include({ key: 'CI_RUNNER_DESCRIPTION', value: 'description', public: true }) } - it { is_expected.to include({ key: 'CI_RUNNER_TAGS', value: 'docker, linux', public: true }) } - end - - context 'when build is for a deployment' do - let(:deployment_variable) { { key: 'KUBERNETES_TOKEN', value: 'TOKEN', public: false } } - - before do - build.environment = 'production' - allow(project).to receive(:deployment_variables).and_return([deployment_variable]) - end - - it { is_expected.to include(deployment_variable) } - end - - context 'returns variables in valid order' do - before do - allow(build).to receive(:predefined_variables) { ['predefined'] } - allow(project).to receive(:predefined_variables) { ['project'] } - allow(pipeline).to receive(:predefined_variables) { ['pipeline'] } - allow(build).to receive(:yaml_variables) { ['yaml'] } - allow(project).to receive(:secret_variables) { ['secret'] } - end - - it { is_expected.to eq(%w[predefined project pipeline yaml secret]) } - end - end - - describe '#has_tags?' do - context 'when build has tags' do - subject { create(:ci_build, tag_list: ['tag']) } - it { is_expected.to have_tags } - end - - context 'when build does not have tags' do - subject { create(:ci_build, tag_list: []) } - it { is_expected.not_to have_tags } - end - end - - describe '#any_runners_online?' do - subject { build.any_runners_online? } - - context 'when no runners' do - it { is_expected.to be_falsey } - end - - context 'when there are runners' do - let(:runner) { create(:ci_runner) } - - before do - build.project.runners << runner - runner.update_attributes(contacted_at: 1.second.ago) - end - - it { is_expected.to be_truthy } - - it 'that is inactive' do - runner.update_attributes(active: false) - is_expected.to be_falsey - end - - it 'that is not online' do - runner.update_attributes(contacted_at: nil) - is_expected.to be_falsey - end - - it 'that cannot handle build' do - expect_any_instance_of(Ci::Runner).to receive(:can_pick?).and_return(false) - is_expected.to be_falsey - end - end - end - - describe '#stuck?' do - subject { build.stuck? } - - context "when commit_status.status is pending" do - before do - build.status = 'pending' - end - - it { is_expected.to be_truthy } - - context "and there are specific runner" do - let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } - - before do - build.project.runners << runner - runner.save - end - - it { is_expected.to be_falsey } - end - end - - %w[success failed canceled running].each do |state| - context "when commit_status.status is #{state}" do - before do - build.status = state - end - - it { is_expected.to be_falsey } - end - end - end - - describe '#artifacts?' do - subject { build.artifacts? } - - context 'artifacts archive does not exist' do - before do - build.update_attributes(artifacts_file: nil) - end - - it { is_expected.to be_falsy } - end - - context 'artifacts archive exists' do - let(:build) { create(:ci_build, :artifacts) } - it { is_expected.to be_truthy } - - context 'is expired' do - before { build.update(artifacts_expire_at: Time.now - 7.days) } - it { is_expected.to be_falsy } - end - - context 'is not expired' do - before { build.update(artifacts_expire_at: Time.now + 7.days) } - it { is_expected.to be_truthy } - end - end - end - - describe '#artifacts_expired?' do - subject { build.artifacts_expired? } - - context 'is expired' do - before { build.update(artifacts_expire_at: Time.now - 7.days) } - - it { is_expected.to be_truthy } - end - - context 'is not expired' do - before { build.update(artifacts_expire_at: Time.now + 7.days) } - - it { is_expected.to be_falsey } - end - end - - describe '#artifacts_metadata?' do - subject { build.artifacts_metadata? } - context 'artifacts metadata does not exist' do - it { is_expected.to be_falsy } - end - - context 'artifacts archive is a zip file and metadata exists' do - let(:build) { create(:ci_build, :artifacts) } - it { is_expected.to be_truthy } - end - end - describe '#repo_url' do - let(:build) { create(:ci_build) } - let(:project) { build.project } - - subject { build.repo_url } - - it { is_expected.to be_a(String) } - it { is_expected.to end_with(".git") } - it { is_expected.to start_with(project.web_url[0..6]) } - it { is_expected.to include(build.token) } - it { is_expected.to include('gitlab-ci-token') } - it { is_expected.to include(project.web_url[7..-1]) } - end - - describe '#artifacts_expire_in' do - subject { build.artifacts_expire_in } - it { is_expected.to be_nil } - - context 'when artifacts_expire_at is specified' do - let(:expire_at) { Time.now + 7.days } - - before { build.artifacts_expire_at = expire_at } - - it { is_expected.to be_within(5).of(expire_at - Time.now) } - end - end - - describe '#artifacts_expire_in=' do - subject { build.artifacts_expire_in } - - it 'when assigning valid duration' do - build.artifacts_expire_in = '7 days' - - is_expected.to be_within(10).of(7.days.to_i) - end - - it 'when assigning invalid duration' do - expect { build.artifacts_expire_in = '7 elephants' }.to raise_error(ChronicDuration::DurationParseError) - is_expected.to be_nil - end - - it 'when resseting value' do - build.artifacts_expire_in = nil - - is_expected.to be_nil - end - end - - describe '#keep_artifacts!' do - let(:build) { create(:ci_build, artifacts_expire_at: Time.now + 7.days) } - - it 'to reset expire_at' do - build.keep_artifacts! - - expect(build.artifacts_expire_at).to be_nil - end - end - - describe '#depends_on_builds' do - let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') } - let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } - let!(:rubocop_test) { create(:ci_build, pipeline: pipeline, name: 'rubocop', stage_idx: 1, stage: 'test') } - let!(:staging) { create(:ci_build, pipeline: pipeline, name: 'staging', stage_idx: 2, stage: 'deploy') } - - it 'expects to have no dependents if this is first build' do - expect(build.depends_on_builds).to be_empty - end - - it 'expects to have one dependent if this is test' do - expect(rspec_test.depends_on_builds.map(&:id)).to contain_exactly(build.id) - end - - it 'expects to have all builds from build and test stage if this is last' do - expect(staging.depends_on_builds.map(&:id)).to contain_exactly(build.id, rspec_test.id, rubocop_test.id) - end - - it 'expects to have retried builds instead the original ones' do - retried_rspec = Ci::Build.retry(rspec_test) - expect(staging.depends_on_builds.map(&:id)).to contain_exactly(build.id, retried_rspec.id, rubocop_test.id) - end - end - - def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) - create(factory, source_project_id: pipeline.gl_project_id, - target_project_id: pipeline.gl_project_id, - source_branch: build.ref, - created_at: created_at) - end - - describe '#merge_request' do - context 'when a MR has a reference to the pipeline' do - before do - @merge_request = create_mr(build, pipeline, factory: :merge_request) - - commits = [double(id: pipeline.sha)] - allow(@merge_request).to receive(:commits).and_return(commits) - allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request]) - end - - it 'returns the single associated MR' do - expect(build.merge_request.id).to eq(@merge_request.id) - end - end - - context 'when there is not a MR referencing the pipeline' do - it 'returns nil' do - expect(build.merge_request).to be_nil - end - end - - context 'when more than one MR have a reference to the pipeline' do - before do - @merge_request = create_mr(build, pipeline, factory: :merge_request) - @merge_request.close! - @merge_request2 = create_mr(build, pipeline, factory: :merge_request) - - commits = [double(id: pipeline.sha)] - allow(@merge_request).to receive(:commits).and_return(commits) - allow(@merge_request2).to receive(:commits).and_return(commits) - allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request, @merge_request2]) - end - - it 'returns the first MR' do - expect(build.merge_request.id).to eq(@merge_request.id) - end - end - - context 'when a Build is created after the MR' do - before do - @merge_request = create_mr(build, pipeline, factory: :merge_request_with_diffs) - pipeline2 = create(:ci_pipeline, project: project) - @build2 = create(:ci_build, pipeline: pipeline2) - - allow(@merge_request).to receive(:commits_sha). - and_return([pipeline.sha, pipeline2.sha]) - allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request]) - end - - it 'returns the current MR' do - expect(@build2.merge_request.id).to eq(@merge_request.id) - end - end - end - - describe 'build erasable' do - shared_examples 'erasable' do - it 'removes artifact file' do - expect(build.artifacts_file.exists?).to be_falsy - end - - it 'removes artifact metadata file' do - expect(build.artifacts_metadata.exists?).to be_falsy - end - - it 'erases build trace in trace file' do - expect(build.trace).to be_empty - end - - it 'sets erased to true' do - expect(build.erased?).to be true - end - - it 'sets erase date' do - expect(build.erased_at).not_to be_falsy - end - end - - context 'build is not erasable' do - let!(:build) { create(:ci_build) } - - describe '#erase' do - subject { build.erase } - - it { is_expected.to be false } - end - - describe '#erasable?' do - subject { build.erasable? } - it { is_expected.to eq false } - end - end - - context 'build is erasable' do - let!(:build) { create(:ci_build, :trace, :success, :artifacts) } - - describe '#erase' do - before do - build.erase(erased_by: user) - end - - context 'erased by user' do - let!(:user) { create(:user, username: 'eraser') } - - include_examples 'erasable' - - it 'records user who erased a build' do - expect(build.erased_by).to eq user - end - end - - context 'erased by system' do - let(:user) { nil } - - include_examples 'erasable' - - it 'does not set user who erased a build' do - expect(build.erased_by).to be_nil - end - end - end - - describe '#erasable?' do - subject { build.erasable? } - it { is_expected.to be_truthy } - end - - describe '#erased?' do - let!(:build) { create(:ci_build, :trace, :success, :artifacts) } - subject { build.erased? } - - context 'build has not been erased' do - it { is_expected.to be_falsey } - end - - context 'build has been erased' do - before do - build.erase - end - - it { is_expected.to be_truthy } - end - end - - context 'metadata and build trace are not available' do - let!(:build) { create(:ci_build, :success, :artifacts) } - - before do - build.remove_artifacts_metadata! - end - - describe '#erase' do - it 'does not raise error' do - expect { build.erase }.not_to raise_error - end - end - end - end - end - - describe '#commit' do - it 'returns commit pipeline has been created for' do - expect(build.commit).to eq project.commit - end - end - - describe '#when' do - subject { build.when } - - context 'when `when` is undefined' do - before do - build.when = nil - end - - context 'use from gitlab-ci.yml' do - before do - stub_ci_pipeline_yaml_file(config) - end - - context 'when config is not found' do - let(:config) { nil } - - it { is_expected.to eq('on_success') } - end - - context 'when config does not have a questioned job' do - let(:config) do - YAML.dump({ - test_other: { - script: 'Hello World' - } - }) - end - - it { is_expected.to eq('on_success') } - end - - context 'when config has `when`' do - let(:config) do - YAML.dump({ - test: { - script: 'Hello World', - when: 'always' - } - }) - end - - it { is_expected.to eq('always') } - end - end - end - end - - describe '#cancelable?' do - subject { build } - - context 'when build is cancelable' do - context 'when build is pending' do - it { is_expected.to be_cancelable } - end - - context 'when build is running' do - before do - build.run! - end - - it { is_expected.to be_cancelable } - end - end - - context 'when build is not cancelable' do - context 'when build is successful' do - before do - build.success! - end - - it { is_expected.not_to be_cancelable } - end - - context 'when build is failed' do - before do - build.drop! - end - - it { is_expected.not_to be_cancelable } - end - end - end - - describe '#retryable?' do - subject { build } - - context 'when build is retryable' do - context 'when build is successful' do - before do - build.success! - end - - it { is_expected.to be_retryable } - end - - context 'when build is failed' do - before do - build.drop! - end - - it { is_expected.to be_retryable } - end - - context 'when build is canceled' do - before do - build.cancel! - end - - it { is_expected.to be_retryable } - end - end - - context 'when build is not retryable' do - context 'when build is running' do - before do - build.run! - end - - it { is_expected.not_to be_retryable } - end - - context 'when build is skipped' do - before do - build.skip! - end - - it { is_expected.not_to be_retryable } - end - end - end - - describe '#manual?' do - before do - build.update(when: value) - end - - subject { build.manual? } - - context 'when is set to manual' do - let(:value) { 'manual' } - - it { is_expected.to be_truthy } - end - - context 'when set to something else' do - let(:value) { 'something else' } - - it { is_expected.to be_falsey } - end - end - - describe '#other_actions' do - let(:build) { create(:ci_build, :manual, pipeline: pipeline) } - let!(:other_build) { create(:ci_build, :manual, pipeline: pipeline, name: 'other action') } - - subject { build.other_actions } - - it 'returns other actions' do - is_expected.to contain_exactly(other_build) - end - - context 'when build is retried' do - let!(:new_build) { Ci::Build.retry(build) } - - it 'does not return any of them' do - is_expected.not_to include(build, new_build) - end - end - - context 'when other build is retried' do - let!(:retried_build) { Ci::Build.retry(other_build) } - - it 'returns a retried build' do - is_expected.to contain_exactly(retried_build) - end - end - end - - describe '#play' do - let(:build) { create(:ci_build, :manual, pipeline: pipeline) } - - subject { build.play } - - it 'enqueues a build' do - is_expected.to be_pending - is_expected.to eq(build) - end - - context 'for successful build' do - before do - build.update(status: 'success') - end - - it 'creates a new build' do - is_expected.to be_pending - is_expected.not_to eq(build) - end - end - end - - describe '#when' do - subject { build.when } - - context 'when `when` is undefined' do - before do - build.when = nil - end - - context 'use from gitlab-ci.yml' do - before do - stub_ci_pipeline_yaml_file(config) - end - - context 'when config is not found' do - let(:config) { nil } - - it { is_expected.to eq('on_success') } - end - - context 'when config does not have a questioned job' do - let(:config) do - YAML.dump({ - test_other: { - script: 'Hello World' - } - }) - end - - it { is_expected.to eq('on_success') } - end - - context 'when config has when' do - let(:config) do - YAML.dump({ - test: { - script: 'Hello World', - when: 'always' - } - }) - end - - it { is_expected.to eq('always') } - end - end - end - end - - describe '#retryable?' do - context 'when build is running' do - before { build.run! } - - it 'returns false' do - expect(build).not_to be_retryable - end - end - - context 'when build is finished' do - before do - build.success! - end - - it 'returns true' do - expect(build).to be_retryable - end - end - end - - describe '#has_environment?' do - subject { build.has_environment? } - - context 'when environment is defined' do - before do - build.update(environment: 'review') - end - - it { is_expected.to be_truthy } - end - - context 'when environment is not defined' do - before do - build.update(environment: nil) - end - - it { is_expected.to be_falsey } - end - end - - describe '#starts_environment?' do - subject { build.starts_environment? } - - context 'when environment is defined' do - before do - build.update(environment: 'review') - end - - context 'no action is defined' do - it { is_expected.to be_truthy } - end - - context 'and start action is defined' do - before do - build.update(options: { environment: { action: 'start' } } ) - end - - it { is_expected.to be_truthy } - end - end - - context 'when environment is not defined' do - before do - build.update(environment: nil) - end - - it { is_expected.to be_falsey } - end - end - - describe '#stops_environment?' do - subject { build.stops_environment? } - - context 'when environment is defined' do - before do - build.update(environment: 'review') - end - - context 'no action is defined' do - it { is_expected.to be_falsey } - end - - context 'and stop action is defined' do - before do - build.update(options: { environment: { action: 'stop' } } ) - end - - it { is_expected.to be_truthy } - end - end - - context 'when environment is not defined' do - before do - build.update(environment: nil) - end - - it { is_expected.to be_falsey } - end - end - - describe '#last_deployment' do - subject { build.last_deployment } - - context 'when multiple deployments are created' do - let!(:deployment1) { create(:deployment, deployable: build) } - let!(:deployment2) { create(:deployment, deployable: build) } - - it 'returns the latest one' do - is_expected.to eq(deployment2) - end - end - end - - describe '#outdated_deployment?' do - subject { build.outdated_deployment? } - - context 'when build succeeded' do - let(:build) { create(:ci_build, :success) } - let!(:deployment) { create(:deployment, deployable: build) } - - context 'current deployment is latest' do - it { is_expected.to be_falsey } - end - - context 'current deployment is not latest on environment' do - let!(:deployment2) { create(:deployment, environment: deployment.environment) } - - it { is_expected.to be_truthy } - end - end - - context 'when build failed' do - let(:build) { create(:ci_build, :failed) } - - it { is_expected.to be_falsey } - end - end - - describe '#expanded_environment_name' do - subject { build.expanded_environment_name } - - context 'when environment uses $CI_BUILD_REF_NAME' do - let(:build) do - create(:ci_build, - ref: 'master', - environment: 'review/$CI_BUILD_REF_NAME') - end - - it { is_expected.to eq('review/master') } - end - - context 'when environment uses yaml_variables containing symbol keys' do - let(:build) do - create(:ci_build, - yaml_variables: [{ key: :APP_HOST, value: 'host' }], - environment: 'review/$APP_HOST') - end - - it { is_expected.to eq('review/host') } - end - end - - describe '#detailed_status' do - let(:user) { create(:user) } - - it 'returns a detailed status' do - expect(build.detailed_status(user)) - .to be_a Gitlab::Ci::Status::Build::Cancelable - end - end -end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 7e1d1126b97..af0f6a31eda 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1,14 +1,962 @@ require 'spec_helper' -describe Ci::Build, models: true do - let(:build) { create(:ci_build) } +describe Ci::Build, :models do + let(:project) { create(:project) } + let(:build) { create(:ci_build, pipeline: pipeline) } let(:test_trace) { 'This is a test' } + let(:pipeline) do + create(:ci_pipeline, project: project, + sha: project.commit.id, + ref: project.default_branch, + status: 'success') + end + it { is_expected.to belong_to(:runner) } it { is_expected.to belong_to(:trigger_request) } it { is_expected.to belong_to(:erased_by) } - it { is_expected.to have_many(:deployments) } + it { is_expected.to validate_presence_of :ref } + it { is_expected.to respond_to :trace_html } + + describe '#any_runners_online?' do + subject { build.any_runners_online? } + + context 'when no runners' do + it { is_expected.to be_falsey } + end + + context 'when there are runners' do + let(:runner) { create(:ci_runner) } + + before do + build.project.runners << runner + runner.update_attributes(contacted_at: 1.second.ago) + end + + it { is_expected.to be_truthy } + + it 'that is inactive' do + runner.update_attributes(active: false) + is_expected.to be_falsey + end + + it 'that is not online' do + runner.update_attributes(contacted_at: nil) + is_expected.to be_falsey + end + + it 'that cannot handle build' do + expect_any_instance_of(Ci::Runner).to receive(:can_pick?).and_return(false) + is_expected.to be_falsey + end + end + end + + describe '#append_trace' do + subject { build.trace_html } + + context 'when build.trace hides runners token' do + let(:token) { 'my_secret_token' } + + before do + build.project.update(runners_token: token) + build.append_trace(token, 0) + end + + it { is_expected.not_to include(token) } + end + + context 'when build.trace hides build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(token: token) + build.append_trace(token, 0) + end + + it { is_expected.not_to include(token) } + end + end + + describe '#artifacts?' do + subject { build.artifacts? } + + context 'artifacts archive does not exist' do + before do + build.update_attributes(artifacts_file: nil) + end + + it { is_expected.to be_falsy } + end + + context 'artifacts archive exists' do + let(:build) { create(:ci_build, :artifacts) } + it { is_expected.to be_truthy } + + context 'is expired' do + before { build.update(artifacts_expire_at: Time.now - 7.days) } + it { is_expected.to be_falsy } + end + + context 'is not expired' do + before { build.update(artifacts_expire_at: Time.now + 7.days) } + it { is_expected.to be_truthy } + end + end + end + + describe '#artifacts_expired?' do + subject { build.artifacts_expired? } + + context 'is expired' do + before { build.update(artifacts_expire_at: Time.now - 7.days) } + + it { is_expected.to be_truthy } + end + + context 'is not expired' do + before { build.update(artifacts_expire_at: Time.now + 7.days) } + + it { is_expected.to be_falsey } + end + end + + describe '#artifacts_metadata?' do + subject { build.artifacts_metadata? } + context 'artifacts metadata does not exist' do + it { is_expected.to be_falsy } + end + + context 'artifacts archive is a zip file and metadata exists' do + let(:build) { create(:ci_build, :artifacts) } + it { is_expected.to be_truthy } + end + end + + describe '#artifacts_expire_in' do + subject { build.artifacts_expire_in } + it { is_expected.to be_nil } + + context 'when artifacts_expire_at is specified' do + let(:expire_at) { Time.now + 7.days } + + before { build.artifacts_expire_at = expire_at } + + it { is_expected.to be_within(5).of(expire_at - Time.now) } + end + end + + describe '#artifacts_expire_in=' do + subject { build.artifacts_expire_in } + + it 'when assigning valid duration' do + build.artifacts_expire_in = '7 days' + + is_expected.to be_within(10).of(7.days.to_i) + end + + it 'when assigning invalid duration' do + expect { build.artifacts_expire_in = '7 elephants' }.to raise_error(ChronicDuration::DurationParseError) + is_expected.to be_nil + end + + it 'when resseting value' do + build.artifacts_expire_in = nil + + is_expected.to be_nil + end + end + + describe '#commit' do + it 'returns commit pipeline has been created for' do + expect(build.commit).to eq project.commit + end + end + + describe '#create_from' do + before do + build.status = 'success' + build.save + end + let(:create_from_build) { Ci::Build.create_from build } + + it 'exists a pending task' do + expect(Ci::Build.pending.count(:all)).to eq 0 + create_from_build + expect(Ci::Build.pending.count(:all)).to be > 0 + end + end + + describe '#depends_on_builds' do + let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') } + let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } + let!(:rubocop_test) { create(:ci_build, pipeline: pipeline, name: 'rubocop', stage_idx: 1, stage: 'test') } + let!(:staging) { create(:ci_build, pipeline: pipeline, name: 'staging', stage_idx: 2, stage: 'deploy') } + + it 'expects to have no dependents if this is first build' do + expect(build.depends_on_builds).to be_empty + end + + it 'expects to have one dependent if this is test' do + expect(rspec_test.depends_on_builds.map(&:id)).to contain_exactly(build.id) + end + + it 'expects to have all builds from build and test stage if this is last' do + expect(staging.depends_on_builds.map(&:id)).to contain_exactly(build.id, rspec_test.id, rubocop_test.id) + end + + it 'expects to have retried builds instead the original ones' do + retried_rspec = Ci::Build.retry(rspec_test) + expect(staging.depends_on_builds.map(&:id)).to contain_exactly(build.id, retried_rspec.id, rubocop_test.id) + end + end + + describe '#detailed_status' do + let(:user) { create(:user) } + + it 'returns a detailed status' do + expect(build.detailed_status(user)) + .to be_a Gitlab::Ci::Status::Build::Cancelable + end + end + + describe 'deployment' do + describe '#last_deployment' do + subject { build.last_deployment } + + context 'when multiple deployments are created' do + let!(:deployment1) { create(:deployment, deployable: build) } + let!(:deployment2) { create(:deployment, deployable: build) } + + it 'returns the latest one' do + is_expected.to eq(deployment2) + end + end + end + + describe '#outdated_deployment?' do + subject { build.outdated_deployment? } + + context 'when build succeeded' do + let(:build) { create(:ci_build, :success) } + let!(:deployment) { create(:deployment, deployable: build) } + + context 'current deployment is latest' do + it { is_expected.to be_falsey } + end + + context 'current deployment is not latest on environment' do + let!(:deployment2) { create(:deployment, environment: deployment.environment) } + + it { is_expected.to be_truthy } + end + end + + context 'when build failed' do + let(:build) { create(:ci_build, :failed) } + + it { is_expected.to be_falsey } + end + end + end + + describe 'environment' do + describe '#has_environment?' do + subject { build.has_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + it { is_expected.to be_truthy } + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#expanded_environment_name' do + subject { build.expanded_environment_name } + + context 'when environment uses $CI_BUILD_REF_NAME' do + let(:build) do + create(:ci_build, + ref: 'master', + environment: 'review/$CI_BUILD_REF_NAME') + end + + it { is_expected.to eq('review/master') } + end + + context 'when environment uses yaml_variables containing symbol keys' do + let(:build) do + create(:ci_build, + yaml_variables: [{ key: :APP_HOST, value: 'host' }], + environment: 'review/$APP_HOST') + end + + it { is_expected.to eq('review/host') } + end + end + + describe '#starts_environment?' do + subject { build.starts_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + context 'no action is defined' do + it { is_expected.to be_truthy } + end + + context 'and start action is defined' do + before do + build.update(options: { environment: { action: 'start' } } ) + end + + it { is_expected.to be_truthy } + end + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + + describe '#stops_environment?' do + subject { build.stops_environment? } + + context 'when environment is defined' do + before do + build.update(environment: 'review') + end + + context 'no action is defined' do + it { is_expected.to be_falsey } + end + + context 'and stop action is defined' do + before do + build.update(options: { environment: { action: 'stop' } } ) + end + + it { is_expected.to be_truthy } + end + end + + context 'when environment is not defined' do + before do + build.update(environment: nil) + end + + it { is_expected.to be_falsey } + end + end + end + + describe 'erasable build' do + shared_examples 'erasable' do + it 'removes artifact file' do + expect(build.artifacts_file.exists?).to be_falsy + end + + it 'removes artifact metadata file' do + expect(build.artifacts_metadata.exists?).to be_falsy + end + + it 'erases build trace in trace file' do + expect(build.trace).to be_empty + end + + it 'sets erased to true' do + expect(build.erased?).to be true + end + + it 'sets erase date' do + expect(build.erased_at).not_to be_falsy + end + end + + context 'build is not erasable' do + let!(:build) { create(:ci_build) } + + describe '#erase' do + subject { build.erase } + + it { is_expected.to be false } + end + + describe '#erasable?' do + subject { build.erasable? } + it { is_expected.to eq false } + end + end + + context 'build is erasable' do + let!(:build) { create(:ci_build, :trace, :success, :artifacts) } + + describe '#erase' do + before do + build.erase(erased_by: user) + end + + context 'erased by user' do + let!(:user) { create(:user, username: 'eraser') } + + include_examples 'erasable' + + it 'records user who erased a build' do + expect(build.erased_by).to eq user + end + end + + context 'erased by system' do + let(:user) { nil } + + include_examples 'erasable' + + it 'does not set user who erased a build' do + expect(build.erased_by).to be_nil + end + end + end + + describe '#erasable?' do + subject { build.erasable? } + it { is_expected.to be_truthy } + end + + describe '#erased?' do + let!(:build) { create(:ci_build, :trace, :success, :artifacts) } + subject { build.erased? } + + context 'build has not been erased' do + it { is_expected.to be_falsey } + end + + context 'build has been erased' do + before do + build.erase + end + + it { is_expected.to be_truthy } + end + end + + context 'metadata and build trace are not available' do + let!(:build) { create(:ci_build, :success, :artifacts) } + + before do + build.remove_artifacts_metadata! + end + + describe '#erase' do + it 'does not raise error' do + expect { build.erase }.not_to raise_error + end + end + end + end + end + + describe '#extract_coverage' do + context 'valid content & regex' do + subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', '\(\d+.\d+\%\) covered') } + + it { is_expected.to eq(98.29) } + end + + context 'valid content & bad regex' do + subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', 'very covered') } + + it { is_expected.to be_nil } + end + + context 'no coverage content & regex' do + subject { build.extract_coverage('No coverage for today :sad:', '\(\d+.\d+\%\) covered') } + + it { is_expected.to be_nil } + end + + context 'multiple results in content & regex' do + subject { build.extract_coverage(' (98.39%) covered. (98.29%) covered', '\(\d+.\d+\%\) covered') } + + it { is_expected.to eq(98.29) } + end + + context 'using a regex capture' do + subject { build.extract_coverage('TOTAL 9926 3489 65%', 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)') } + + it { is_expected.to eq(65) } + end + end + + describe '#first_pending' do + let!(:first) { create(:ci_build, pipeline: pipeline, status: 'pending', created_at: Date.yesterday) } + let!(:second) { create(:ci_build, pipeline: pipeline, status: 'pending') } + subject { Ci::Build.first_pending } + + it { is_expected.to be_a(Ci::Build) } + it('returns with the first pending build') { is_expected.to eq(first) } + end + + describe '#failed_but_allowed?' do + subject { build.failed_but_allowed? } + + context 'when build is not allowed to fail' do + before do + build.allow_failure = false + end + + context 'and build.status is success' do + before do + build.status = 'success' + end + + it { is_expected.to be_falsey } + end + + context 'and build.status is failed' do + before do + build.status = 'failed' + end + + it { is_expected.to be_falsey } + end + end + + context 'when build is allowed to fail' do + before do + build.allow_failure = true + end + + context 'and build.status is success' do + before do + build.status = 'success' + end + + it { is_expected.to be_falsey } + end + + context 'and build.status is failed' do + before do + build.status = 'failed' + end + + it { is_expected.to be_truthy } + end + end + end + + describe 'flags' do + describe '#cancelable?' do + subject { build } + + context 'when build is cancelable' do + context 'when build is pending' do + it { is_expected.to be_cancelable } + end + + context 'when build is running' do + before do + build.run! + end + + it { is_expected.to be_cancelable } + end + end + + context 'when build is not cancelable' do + context 'when build is successful' do + before do + build.success! + end + + it { is_expected.not_to be_cancelable } + end + + context 'when build is failed' do + before do + build.drop! + end + + it { is_expected.not_to be_cancelable } + end + end + end + + describe '#retryable?' do + subject { build } + + context 'when build is retryable' do + context 'when build is successful' do + before do + build.success! + end + + it { is_expected.to be_retryable } + end + + context 'when build is failed' do + before do + build.drop! + end + + it { is_expected.to be_retryable } + end + + context 'when build is canceled' do + before do + build.cancel! + end + + it { is_expected.to be_retryable } + end + end + + context 'when build is not retryable' do + context 'when build is running' do + before do + build.run! + end + + it { is_expected.not_to be_retryable } + end + + context 'when build is skipped' do + before do + build.skip! + end + + it { is_expected.not_to be_retryable } + end + end + end + + describe '#manual?' do + before do + build.update(when: value) + end + + subject { build.manual? } + + context 'when is set to manual' do + let(:value) { 'manual' } + + it { is_expected.to be_truthy } + end + + context 'when set to something else' do + let(:value) { 'something else' } + + it { is_expected.to be_falsey } + end + end + end + + describe '#has_tags?' do + context 'when build has tags' do + subject { create(:ci_build, tag_list: ['tag']) } + it { is_expected.to have_tags } + end + + context 'when build does not have tags' do + subject { create(:ci_build, tag_list: []) } + it { is_expected.not_to have_tags } + end + end + + describe '#keep_artifacts!' do + let(:build) { create(:ci_build, artifacts_expire_at: Time.now + 7.days) } + + it 'to reset expire_at' do + build.keep_artifacts! + + expect(build.artifacts_expire_at).to be_nil + end + end + + describe '#merge_request' do + def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) + create(factory, source_project_id: pipeline.gl_project_id, + target_project_id: pipeline.gl_project_id, + source_branch: build.ref, + created_at: created_at) + end + + context 'when a MR has a reference to the pipeline' do + before do + @merge_request = create_mr(build, pipeline, factory: :merge_request) + + commits = [double(id: pipeline.sha)] + allow(@merge_request).to receive(:commits).and_return(commits) + allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request]) + end + + it 'returns the single associated MR' do + expect(build.merge_request.id).to eq(@merge_request.id) + end + end + + context 'when there is not a MR referencing the pipeline' do + it 'returns nil' do + expect(build.merge_request).to be_nil + end + end + + context 'when more than one MR have a reference to the pipeline' do + before do + @merge_request = create_mr(build, pipeline, factory: :merge_request) + @merge_request.close! + @merge_request2 = create_mr(build, pipeline, factory: :merge_request) + + commits = [double(id: pipeline.sha)] + allow(@merge_request).to receive(:commits).and_return(commits) + allow(@merge_request2).to receive(:commits).and_return(commits) + allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request, @merge_request2]) + end + + it 'returns the first MR' do + expect(build.merge_request.id).to eq(@merge_request.id) + end + end + + context 'when a Build is created after the MR' do + before do + @merge_request = create_mr(build, pipeline, factory: :merge_request_with_diffs) + pipeline2 = create(:ci_pipeline, project: project) + @build2 = create(:ci_build, pipeline: pipeline2) + + allow(@merge_request).to receive(:commits_sha). + and_return([pipeline.sha, pipeline2.sha]) + allow(MergeRequest).to receive_message_chain(:includes, :where, :reorder).and_return([@merge_request]) + end + + it 'returns the current MR' do + expect(@build2.merge_request.id).to eq(@merge_request.id) + end + end + end + + describe '#options' do + let(:options) do + { + image: "ruby:2.1", + services: [ + "postgres" + ] + } + end + + it 'contains options' do + expect(build.options).to eq(options) + end + end + + describe '#other_actions' do + let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + let!(:other_build) { create(:ci_build, :manual, pipeline: pipeline, name: 'other action') } + + subject { build.other_actions } + + it 'returns other actions' do + is_expected.to contain_exactly(other_build) + end + + context 'when build is retried' do + let!(:new_build) { Ci::Build.retry(build) } + + it 'does not return any of them' do + is_expected.not_to include(build, new_build) + end + end + + context 'when other build is retried' do + let!(:retried_build) { Ci::Build.retry(other_build) } + + it 'returns a retried build' do + is_expected.to contain_exactly(retried_build) + end + end + end + + describe '#persisted_environment' do + before do + @environment = create(:environment, project: project, name: "foo-#{project.default_branch}") + end + + subject { build.persisted_environment } + + context 'referenced literally' do + let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") } + + it { is_expected.to eq(@environment) } + end + + context 'referenced with a variable' do + let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_BUILD_REF_NAME") } + + it { is_expected.to eq(@environment) } + end + end + + describe '#play' do + let(:build) { create(:ci_build, :manual, pipeline: pipeline) } + + subject { build.play } + + it 'enqueues a build' do + is_expected.to be_pending + is_expected.to eq(build) + end + + context 'for successful build' do + before do + build.update(status: 'success') + end + + it 'creates a new build' do + is_expected.to be_pending + is_expected.not_to eq(build) + end + end + end + + describe 'project settings' do + describe '#timeout' do + it 'returns project timeout configuration' do + expect(build.timeout).to eq(project.build_timeout) + end + end + + describe '#allow_git_fetch' do + it 'return project allow_git_fetch configuration' do + expect(build.allow_git_fetch).to eq(project.build_allow_git_fetch) + end + end + end + + describe '#project' do + subject { build.project } + + it { is_expected.to eq(pipeline.project) } + end + + describe '#project_id' do + subject { build.project_id } + + it { is_expected.to eq(pipeline.project_id) } + end + + describe '#project_name' do + subject { build.project_name } + + it { is_expected.to eq(project.name) } + end + + describe '#raw_trace' do + subject { build.raw_trace } + + context 'when build.trace hides runners token' do + let(:token) { 'my_secret_token' } + + before do + build.project.update(runners_token: token) + build.update(trace: token) + end + + it { is_expected.not_to include(token) } + end + + context 'when build.trace hides build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(token: token) + build.update(trace: token) + end + + it { is_expected.not_to include(token) } + end + end + + describe '#ref_slug' do + { + 'master' => 'master', + '1-foo' => '1-foo', + 'fix/1-foo' => 'fix-1-foo', + 'fix-1-foo' => 'fix-1-foo', + 'a' * 63 => 'a' * 63, + 'a' * 64 => 'a' * 63, + 'FOO' => 'foo', + }.each do |ref, slug| + it "transforms #{ref} to #{slug}" do + build.ref = ref + + expect(build.ref_slug).to eq(slug) + end + end + end + + describe '#repo_url' do + let(:build) { create(:ci_build) } + let(:project) { build.project } + + subject { build.repo_url } + + it { is_expected.to be_a(String) } + it { is_expected.to end_with(".git") } + it { is_expected.to start_with(project.web_url[0..6]) } + it { is_expected.to include(build.token) } + it { is_expected.to include('gitlab-ci-token') } + it { is_expected.to include(project.web_url[7..-1]) } + end + + describe '#stuck?' do + subject { build.stuck? } + + context "when commit_status.status is pending" do + before do + build.status = 'pending' + end + + it { is_expected.to be_truthy } + + context "and there are specific runner" do + let(:runner) { create(:ci_runner, contacted_at: 1.second.ago) } + + before do + build.project.runners << runner + runner.save + end + + it { is_expected.to be_falsey } + end + end + + %w[success failed canceled running].each do |state| + context "when commit_status.status is #{state}" do + before do + build.status = state + end + + it { is_expected.to be_falsey } + end + end + end describe '#trace' do it 'obfuscates project runners token' do @@ -24,6 +972,45 @@ describe Ci::Build, models: true do expect(build.trace).to eq(test_trace) end + + context 'when build does not have trace' do + it 'is is empty' do + expect(build.trace).to be_nil + end + end + + context 'when trace contains text' do + let(:text) { 'example output' } + before do + build.trace = text + end + + it { expect(build.trace).to eq(text) } + end + + context 'when trace hides runners token' do + let(:token) { 'my_secret_token' } + + before do + build.update(trace: token) + build.project.update(runners_token: token) + end + + it { expect(build.trace).not_to include(token) } + it { expect(build.raw_trace).to include(token) } + end + + context 'when build.trace hides build token' do + let(:token) { 'my_secret_token' } + + before do + build.update(trace: token) + build.update(token: token) + end + + it { expect(build.trace).not_to include(token) } + it { expect(build.raw_trace).to include(token) } + end end describe '#has_trace_file?' do @@ -111,4 +1098,289 @@ describe Ci::Build, models: true do build.destroy end end + + describe '#when' do + subject { build.when } + + context 'when `when` is undefined' do + before do + build.when = nil + end + + context 'use from gitlab-ci.yml' do + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'when config is not found' do + let(:config) { nil } + + it { is_expected.to eq('on_success') } + end + + context 'when config does not have a questioned job' do + let(:config) do + YAML.dump({ + test_other: { + script: 'Hello World' + } + }) + end + + it { is_expected.to eq('on_success') } + end + + context 'when config has `when`' do + let(:config) do + YAML.dump({ + test: { + script: 'Hello World', + when: 'always' + } + }) + end + + it { is_expected.to eq('always') } + end + end + end + end + + describe '#variables' do + let(:container_registry_enabled) { false } + let(:predefined_variables) do + [ + { key: 'CI', value: 'true', public: true }, + { key: 'GITLAB_CI', value: 'true', public: true }, + { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, + { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, + { key: 'CI_BUILD_REF', value: build.sha, public: true }, + { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, + { key: 'CI_BUILD_REF_NAME', value: 'master', public: true }, + { key: 'CI_BUILD_REF_SLUG', value: 'master', public: true }, + { key: 'CI_BUILD_NAME', value: 'test', public: true }, + { key: 'CI_BUILD_STAGE', value: 'test', public: true }, + { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, + { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, + { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, + { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, + { key: 'CI_PROJECT_NAME', value: project.path, public: true }, + { key: 'CI_PROJECT_PATH', value: project.path_with_namespace, public: true }, + { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.path, public: true }, + { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, + { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true } + ] + end + + before do + stub_container_registry_config(enabled: container_registry_enabled, host_port: 'registry.example.com') + end + + subject { build.variables } + + context 'returns variables' do + before do + build.yaml_variables = [] + end + + it { is_expected.to eq(predefined_variables) } + end + + context 'when build has user' do + let(:user) { create(:user, username: 'starter') } + let(:user_variables) do + [ + { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, + { key: 'GITLAB_USER_EMAIL', value: user.email, public: true } + ] + end + + before do + build.update_attributes(user: user) + end + + it { user_variables.each { |v| is_expected.to include(v) } } + end + + context 'when build has an environment' do + before do + build.update(environment: 'production') + create(:environment, project: build.project, name: 'production', slug: 'prod-slug') + end + + let(:environment_variables) do + [ + { key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true }, + { key: 'CI_ENVIRONMENT_SLUG', value: 'prod-slug', public: true } + ] + end + + it { environment_variables.each { |v| is_expected.to include(v) } } + end + + context 'when build started manually' do + before do + build.update_attributes(when: :manual) + end + + let(:manual_variable) do + { key: 'CI_BUILD_MANUAL', value: 'true', public: true } + end + + it { is_expected.to include(manual_variable) } + end + + context 'when build is for tag' do + let(:tag_variable) do + { key: 'CI_BUILD_TAG', value: 'master', public: true } + end + + before do + build.update_attributes(tag: true) + end + + it { is_expected.to include(tag_variable) } + end + + context 'when secure variable is defined' do + let(:secure_variable) do + { key: 'SECRET_KEY', value: 'secret_value', public: false } + end + + before do + build.project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') + end + + it { is_expected.to include(secure_variable) } + end + + context 'when build is for triggers' do + let(:trigger) { create(:ci_trigger, project: project) } + let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline, trigger: trigger) } + let(:user_trigger_variable) do + { key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1', public: false } + end + let(:predefined_trigger_variable) do + { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } + end + + before do + build.trigger_request = trigger_request + end + + it { is_expected.to include(user_trigger_variable) } + it { is_expected.to include(predefined_trigger_variable) } + end + + context 'when yaml_variables are undefined' do + before do + build.yaml_variables = nil + end + + context 'use from gitlab-ci.yml' do + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'when config is not found' do + let(:config) { nil } + + it { is_expected.to eq(predefined_variables) } + end + + context 'when config does not have a questioned job' do + let(:config) do + YAML.dump({ + test_other: { + script: 'Hello World' + } + }) + end + + it { is_expected.to eq(predefined_variables) } + end + + context 'when config has variables' do + let(:config) do + YAML.dump({ + test: { + script: 'Hello World', + variables: { + KEY: 'value' + } + } + }) + end + let(:variables) do + [{ key: 'KEY', value: 'value', public: true }] + end + + it { is_expected.to eq(predefined_variables + variables) } + end + end + end + + context 'when container registry is enabled' do + let(:container_registry_enabled) { true } + let(:ci_registry) do + { key: 'CI_REGISTRY', value: 'registry.example.com', public: true } + end + let(:ci_registry_image) do + { key: 'CI_REGISTRY_IMAGE', value: project.container_registry_repository_url, public: true } + end + + context 'and is disabled for project' do + before do + project.update(container_registry_enabled: false) + end + + it { is_expected.to include(ci_registry) } + it { is_expected.not_to include(ci_registry_image) } + end + + context 'and is enabled for project' do + before do + project.update(container_registry_enabled: true) + end + + it { is_expected.to include(ci_registry) } + it { is_expected.to include(ci_registry_image) } + end + end + + context 'when runner is assigned to build' do + let(:runner) { create(:ci_runner, description: 'description', tag_list: ['docker', 'linux']) } + + before do + build.update(runner: runner) + end + + it { is_expected.to include({ key: 'CI_RUNNER_ID', value: runner.id.to_s, public: true }) } + it { is_expected.to include({ key: 'CI_RUNNER_DESCRIPTION', value: 'description', public: true }) } + it { is_expected.to include({ key: 'CI_RUNNER_TAGS', value: 'docker, linux', public: true }) } + end + + context 'when build is for a deployment' do + let(:deployment_variable) { { key: 'KUBERNETES_TOKEN', value: 'TOKEN', public: false } } + + before do + build.environment = 'production' + allow(project).to receive(:deployment_variables).and_return([deployment_variable]) + end + + it { is_expected.to include(deployment_variable) } + end + + context 'returns variables in valid order' do + before do + allow(build).to receive(:predefined_variables) { ['predefined'] } + allow(project).to receive(:predefined_variables) { ['project'] } + allow(pipeline).to receive(:predefined_variables) { ['pipeline'] } + allow(build).to receive(:yaml_variables) { ['yaml'] } + allow(project).to receive(:secret_variables) { ['secret'] } + end + + it { is_expected.to eq(%w[predefined project pipeline yaml secret]) } + end + end end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 701f3323c0f..64ea607eb95 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -243,4 +243,23 @@ describe CommitStatus, models: true do .to be_a Gitlab::Ci::Status::Success end end + + describe '#sortable_name' do + tests = { + 'karma' => ['karma'], + 'karma 0 20' => ['karma ', 0, ' ', 20], + 'karma 10 20' => ['karma ', 10, ' ', 20], + 'karma 50:100' => ['karma ', 50, ':', 100], + 'karma 1.10' => ['karma ', 1, '.', 10], + 'karma 1.5.1' => ['karma ', 1, '.', 5, '.', 1], + 'karma 1 a' => ['karma ', 1, ' a'] + } + + tests.each do |name, sortable_name| + it "'#{name}' sorts as '#{sortable_name}'" do + commit_status.name = name + expect(commit_status.sortable_name).to eq(sortable_name) + end + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 93eb402e060..96efe1696c3 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -63,6 +63,23 @@ describe Environment, models: true do end end + describe '#update_merge_request_metrics?' do + { 'production' => true, + 'production/eu' => true, + 'production/www.gitlab.com' => true, + 'productioneu' => false, + 'Production' => false, + 'Production/eu' => false, + 'test-production' => false + }.each do |name, expected_value| + it "returns #{expected_value} for #{name}" do + env = create(:environment, name: name) + + expect(env.update_merge_request_metrics?).to eq(expected_value) + end + end + end + describe '#first_deployment_for' do let(:project) { create(:project) } let!(:deployment) { create(:deployment, environment: environment, ref: commit.parent.id) } diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb index d7e1a4e3b6c..497a626a418 100644 --- a/spec/models/project_services/bamboo_service_spec.rb +++ b/spec/models/project_services/bamboo_service_spec.rb @@ -1,14 +1,28 @@ require 'spec_helper' -describe BambooService, models: true do +describe BambooService, models: true, caching: true do + include ReactiveCachingHelpers + + let(:bamboo_url) { 'http://gitlab.com/bamboo' } + + subject(:service) do + described_class.create( + project: create(:empty_project), + properties: { + bamboo_url: bamboo_url, + username: 'mic', + password: 'password', + build_key: 'foo' + } + ) + end + describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } end describe 'Validations' do - subject { service } - context 'when service is active' do before { subject.active = true } @@ -103,90 +117,103 @@ describe BambooService, models: true do end describe '#build_page' do - it 'returns a specific URL when status is 500' do - stub_request(status: 500) + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { build_page: 'foo' }, 'sha', 'ref') - expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/foo') + expect(service.build_page('sha', 'ref')).to eq('foo') end + end - it 'returns a specific URL when response has no results' do - stub_request(body: %Q({"results":{"results":{"size":"0"}}})) + describe '#commit_status' do + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { commit_status: 'foo' }, 'sha', 'ref') - expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/foo') + expect(service.commit_status('sha', 'ref')).to eq('foo') end + end - it 'returns a build URL when bamboo_url has no trailing slash' do - stub_request(body: %Q({"results":{"results":{"result":{"planResultKey":{"key":"42"}}}}})) + describe '#calculate_reactive_cache' do + context '#build_page' do + subject { service.calculate_reactive_cache('123', 'unused')[:build_page] } - expect(service(bamboo_url: 'http://gitlab.com/bamboo').build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/42') - end + it 'returns a specific URL when status is 500' do + stub_request(status: 500) - it 'returns a build URL when bamboo_url has a trailing slash' do - stub_request(body: %Q({"results":{"results":{"result":{"planResultKey":{"key":"42"}}}}})) + is_expected.to eq('http://gitlab.com/bamboo/browse/foo') + end - expect(service(bamboo_url: 'http://gitlab.com/bamboo/').build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/42') - end - end + it 'returns a specific URL when response has no results' do + stub_request(body: bamboo_response(size: 0)) - describe '#commit_status' do - it 'sets commit status to :error when status is 500' do - stub_request(status: 500) + is_expected.to eq('http://gitlab.com/bamboo/browse/foo') + end - expect(service.commit_status('123', 'unused')).to eq(:error) - end + it 'returns a build URL when bamboo_url has no trailing slash' do + stub_request(body: bamboo_response) - it 'sets commit status to "pending" when status is 404' do - stub_request(status: 404) + is_expected.to eq('http://gitlab.com/bamboo/browse/42') + end - expect(service.commit_status('123', 'unused')).to eq('pending') - end + context 'bamboo_url has trailing slash' do + let(:bamboo_url) { 'http://gitlab.com/bamboo/' } - it 'sets commit status to "pending" when response has no results' do - stub_request(body: %Q({"results":{"results":{"size":"0"}}})) + it 'returns a build URL' do + stub_request(body: bamboo_response) - expect(service.commit_status('123', 'unused')).to eq('pending') + is_expected.to eq('http://gitlab.com/bamboo/browse/42') + end + end end - it 'sets commit status to "success" when build state contains Success' do - stub_request(build_state: 'YAY Success!') + context '#commit_status' do + subject { service.calculate_reactive_cache('123', 'unused')[:commit_status] } - expect(service.commit_status('123', 'unused')).to eq('success') - end + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) - it 'sets commit status to "failed" when build state contains Failed' do - stub_request(build_state: 'NO Failed!') + is_expected.to eq(:error) + end - expect(service.commit_status('123', 'unused')).to eq('failed') - end + it 'sets commit status to "pending" when status is 404' do + stub_request(status: 404) - it 'sets commit status to "pending" when build state contains Pending' do - stub_request(build_state: 'NO Pending!') + is_expected.to eq('pending') + end - expect(service.commit_status('123', 'unused')).to eq('pending') - end + it 'sets commit status to "pending" when response has no results' do + stub_request(body: %Q({"results":{"results":{"size":"0"}}})) - it 'sets commit status to :error when build state is unknown' do - stub_request(build_state: 'FOO BAR!') + is_expected.to eq('pending') + end - expect(service.commit_status('123', 'unused')).to eq(:error) - end - end + it 'sets commit status to "success" when build state contains Success' do + stub_request(body: bamboo_response(build_state: 'YAY Success!')) - def service(bamboo_url: 'http://gitlab.com/bamboo') - described_class.create( - project: create(:empty_project), - properties: { - bamboo_url: bamboo_url, - username: 'mic', - password: 'password', - build_key: 'foo' - } - ) + is_expected.to eq('success') + end + + it 'sets commit status to "failed" when build state contains Failed' do + stub_request(body: bamboo_response(build_state: 'NO Failed!')) + + is_expected.to eq('failed') + end + + it 'sets commit status to "pending" when build state contains Pending' do + stub_request(body: bamboo_response(build_state: 'NO Pending!')) + + is_expected.to eq('pending') + end + + it 'sets commit status to :error when build state is unknown' do + stub_request(body: bamboo_response(build_state: 'FOO BAR!')) + + is_expected.to eq(:error) + end + end end - def stub_request(status: 200, body: nil, build_state: 'success') + def stub_request(status: 200, body: nil) bamboo_full_url = 'http://mic:password@gitlab.com/bamboo/rest/api/latest/result?label=123&os_authType=basic' - body ||= %Q({"results":{"results":{"result":{"buildState":"#{build_state}"}}}}) WebMock.stub_request(:get, bamboo_full_url).to_return( status: status, @@ -194,4 +221,8 @@ describe BambooService, models: true do body: body ) end + + def bamboo_response(result_key: 42, build_state: 'success', size: 1) + %Q({"results":{"results":{"size":"#{size}","result":{"buildState":"#{build_state}","planResultKey":{"key":"#{result_key}"}}}}}) + end end diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb index 6f65beb79d0..dbd23ff5491 100644 --- a/spec/models/project_services/buildkite_service_spec.rb +++ b/spec/models/project_services/buildkite_service_spec.rb @@ -1,6 +1,21 @@ require 'spec_helper' -describe BuildkiteService, models: true do +describe BuildkiteService, models: true, caching: true do + include ReactiveCachingHelpers + + let(:project) { create(:empty_project) } + + subject(:service) do + described_class.create( + project: project, + properties: { + service_hook: true, + project_url: 'https://buildkite.com/account-name/example-project', + token: 'secret-sauce-webhook-token:secret-sauce-status-token' + } + ) + end + describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } @@ -25,21 +40,12 @@ describe BuildkiteService, models: true do describe 'commits methods' do before do - @project = Project.new - allow(@project).to receive(:default_branch).and_return('default-brancho') - - @service = BuildkiteService.new - allow(@service).to receive_messages( - project: @project, - service_hook: true, - project_url: 'https://buildkite.com/account-name/example-project', - token: 'secret-sauce-webhook-token:secret-sauce-status-token' - ) + allow(project).to receive(:default_branch).and_return('default-brancho') end describe '#webhook_url' do it 'returns the webhook url' do - expect(@service.webhook_url).to eq( + expect(service.webhook_url).to eq( 'https://webhook.buildkite.com/deliver/secret-sauce-webhook-token' ) end @@ -47,7 +53,7 @@ describe BuildkiteService, models: true do describe '#commit_status_path' do it 'returns the correct status page' do - expect(@service.commit_status_path('2ab7834c')).to eq( + expect(service.commit_status_path('2ab7834c')).to eq( 'https://gitlab.buildkite.com/status/secret-sauce-status-token.json?commit=2ab7834c' ) end @@ -55,10 +61,53 @@ describe BuildkiteService, models: true do describe '#build_page' do it 'returns the correct build page' do - expect(@service.build_page('2ab7834c', nil)).to eq( + expect(service.build_page('2ab7834c', nil)).to eq( 'https://buildkite.com/account-name/example-project/builds?commit=2ab7834c' ) end end + + describe '#commit_status' do + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { commit_status: 'foo' }, 'sha', 'ref') + + expect(service.commit_status('sha', 'ref')).to eq('foo') + end + end + + describe '#calculate_reactive_cache' do + context '#commit_status' do + subject { service.calculate_reactive_cache('123', 'unused')[:commit_status] } + + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) + + is_expected.to eq(:error) + end + + it 'sets commit status to :error when status is 404' do + stub_request(status: 404) + + is_expected.to eq(:error) + end + + it 'passes through build status untouched when status is 200' do + stub_request(body: %Q({"status":"Great Success"})) + + is_expected.to eq('Great Success') + end + end + end + end + + def stub_request(status: 200, body: nil) + body ||= %Q({"status":"success"}) + buildkite_full_url = 'https://gitlab.buildkite.com/status/secret-sauce-status-token.json?commit=123' + + WebMock.stub_request(:get, buildkite_full_url).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body + ) end end diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb index f13bb1e8adf..42c2ed668bc 100644 --- a/spec/models/project_services/drone_ci_service_spec.rb +++ b/spec/models/project_services/drone_ci_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' -describe DroneCiService, models: true do +describe DroneCiService, models: true, caching: true do + include ReactiveCachingHelpers + describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to have_one(:service_hook) } @@ -33,6 +35,10 @@ describe DroneCiService, models: true do let(:token) { 'secret' } let(:iid) { rand(1..9999) } + # URL's + let(:build_page) { "#{drone_url}/gitlab/#{path}/redirect/commits/#{sha}?branch=#{branch}" } + let(:commit_status_path) { "#{drone_url}/gitlab/#{path}/commits/#{sha}?branch=#{branch}&access_token=#{token}" } + before(:each) do allow(drone).to receive_messages( project_id: project.id, @@ -42,22 +48,66 @@ describe DroneCiService, models: true do token: token ) end + + def stub_request(status: 200, body: nil) + body ||= %Q({"status":"success"}) + + WebMock.stub_request(:get, commit_status_path).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body + ) + end end describe "service page/path methods" do include_context :drone_ci_service - # URL's - let(:commit_page) { "#{drone_url}/gitlab/#{path}/redirect/commits/#{sha}?branch=#{branch}" } - let(:merge_request_page) { "#{drone_url}/gitlab/#{path}/redirect/pulls/#{iid}" } - let(:commit_status_path) { "#{drone_url}/gitlab/#{path}/commits/#{sha}?branch=#{branch}&access_token=#{token}" } - let(:merge_request_status_path) { "#{drone_url}/gitlab/#{path}/pulls/#{iid}?access_token=#{token}" } - - it { expect(drone.build_page(sha, branch)).to eq(commit_page) } - it { expect(drone.commit_page(sha, branch)).to eq(commit_page) } - it { expect(drone.merge_request_page(iid, sha, branch)).to eq(merge_request_page) } + it { expect(drone.build_page(sha, branch)).to eq(build_page) } it { expect(drone.commit_status_path(sha, branch)).to eq(commit_status_path) } - it { expect(drone.merge_request_status_path(iid, sha, branch)).to eq(merge_request_status_path) } + end + + describe '#commit_status' do + include_context :drone_ci_service + + it 'returns the contents of the reactive cache' do + stub_reactive_cache(drone, { commit_status: 'foo' }, 'sha', 'ref') + + expect(drone.commit_status('sha', 'ref')).to eq('foo') + end + end + + describe '#calculate_reactive_cache' do + include_context :drone_ci_service + + context '#commit_status' do + subject { drone.calculate_reactive_cache(sha, branch)[:commit_status] } + + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) + + is_expected.to eq(:error) + end + + it 'sets commit status to :error when status is 404' do + stub_request(status: 404) + + is_expected.to eq(:error) + end + + { "killed" => :canceled, + "failure" => :failed, + "error" => :failed, + "success" => "success", + }.each do |drone_status, our_status| + + it "sets commit status to #{our_status.inspect} when returned status is #{drone_status.inspect}" do + stub_request(body: %Q({"status":"#{drone_status}"})) + + is_expected.to eq(our_status) + end + end + end end describe "execute" do diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb index f7e878844dc..a1edd083aa1 100644 --- a/spec/models/project_services/teamcity_service_spec.rb +++ b/spec/models/project_services/teamcity_service_spec.rb @@ -1,14 +1,28 @@ require 'spec_helper' -describe TeamcityService, models: true do +describe TeamcityService, models: true, caching: true do + include ReactiveCachingHelpers + + let(:teamcity_url) { 'http://gitlab.com/teamcity' } + + subject(:service) do + described_class.create( + project: create(:empty_project), + properties: { + teamcity_url: teamcity_url, + username: 'mic', + password: 'password', + build_type: 'foo' + } + ) + end + describe 'Associations' do it { is_expected.to belong_to :project } it { is_expected.to have_one :service_hook } end describe 'Validations' do - subject { service } - context 'when service is active' do before { subject.active = true } @@ -103,73 +117,87 @@ describe TeamcityService, models: true do end describe '#build_page' do - it 'returns a specific URL when status is 500' do - stub_request(status: 500) + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { build_page: 'foo' }, 'sha', 'ref') - expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/teamcity/viewLog.html?buildTypeId=foo') + expect(service.build_page('sha', 'ref')).to eq('foo') end + end - it 'returns a build URL when teamcity_url has no trailing slash' do - stub_request(body: %Q({"build":{"id":"666"}})) + describe '#commit_status' do + it 'returns the contents of the reactive cache' do + stub_reactive_cache(service, { commit_status: 'foo' }, 'sha', 'ref') - expect(service(teamcity_url: 'http://gitlab.com/teamcity').build_page('123', 'unused')).to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') + expect(service.commit_status('sha', 'ref')).to eq('foo') end + end - it 'returns a build URL when teamcity_url has a trailing slash' do - stub_request(body: %Q({"build":{"id":"666"}})) + describe '#calculate_reactive_cache' do + context 'build_page' do + subject { service.calculate_reactive_cache('123', 'unused')[:build_page] } - expect(service(teamcity_url: 'http://gitlab.com/teamcity/').build_page('123', 'unused')).to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') - end - end + it 'returns a specific URL when status is 500' do + stub_request(status: 500) - describe '#commit_status' do - it 'sets commit status to :error when status is 500' do - stub_request(status: 500) + is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildTypeId=foo') + end - expect(service.commit_status('123', 'unused')).to eq(:error) - end + it 'returns a build URL when teamcity_url has no trailing slash' do + stub_request(body: %Q({"build":{"id":"666"}})) - it 'sets commit status to "pending" when status is 404' do - stub_request(status: 404) + is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') + end - expect(service.commit_status('123', 'unused')).to eq('pending') - end + context 'teamcity_url has trailing slash' do + let(:teamcity_url) { 'http://gitlab.com/teamcity/' } - it 'sets commit status to "success" when build status contains SUCCESS' do - stub_request(build_status: 'YAY SUCCESS!') + it 'returns a build URL' do + stub_request(body: %Q({"build":{"id":"666"}})) - expect(service.commit_status('123', 'unused')).to eq('success') + is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') + end + end end - it 'sets commit status to "failed" when build status contains FAILURE' do - stub_request(build_status: 'NO FAILURE!') + context 'commit_status' do + subject { service.calculate_reactive_cache('123', 'unused')[:commit_status] } - expect(service.commit_status('123', 'unused')).to eq('failed') - end + it 'sets commit status to :error when status is 500' do + stub_request(status: 500) - it 'sets commit status to "pending" when build status contains Pending' do - stub_request(build_status: 'NO Pending!') + is_expected.to eq(:error) + end - expect(service.commit_status('123', 'unused')).to eq('pending') - end + it 'sets commit status to "pending" when status is 404' do + stub_request(status: 404) - it 'sets commit status to :error when build status is unknown' do - stub_request(build_status: 'FOO BAR!') + is_expected.to eq('pending') + end - expect(service.commit_status('123', 'unused')).to eq(:error) - end - end + it 'sets commit status to "success" when build status contains SUCCESS' do + stub_request(build_status: 'YAY SUCCESS!') - def service(teamcity_url: 'http://gitlab.com/teamcity') - described_class.create( - project: create(:empty_project), - properties: { - teamcity_url: teamcity_url, - username: 'mic', - password: 'password', - build_type: 'foo' - } - ) + is_expected.to eq('success') + end + + it 'sets commit status to "failed" when build status contains FAILURE' do + stub_request(build_status: 'NO FAILURE!') + + is_expected.to eq('failed') + end + + it 'sets commit status to "pending" when build status contains Pending' do + stub_request(build_status: 'NO Pending!') + + is_expected.to eq('pending') + end + + it 'sets commit status to :error when build status is unknown' do + stub_request(build_status: 'FOO BAR!') + + is_expected.to eq(:error) + end + end end def stub_request(status: 200, body: nil, build_status: 'success') diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3ec7bb46686..32779eb92ef 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -190,34 +190,54 @@ describe Project, models: true do end it 'does not allow an invalid URI as import_url' do - project2 = build(:project, import_url: 'invalid://') + project2 = build(:empty_project, import_url: 'invalid://') expect(project2).not_to be_valid end it 'does allow a valid URI as import_url' do - project2 = build(:project, import_url: 'ssh://test@gitlab.com/project.git') + project2 = build(:empty_project, import_url: 'ssh://test@gitlab.com/project.git') expect(project2).to be_valid end it 'allows an empty URI' do - project2 = build(:project, import_url: '') + project2 = build(:empty_project, import_url: '') expect(project2).to be_valid end it 'does not produce import data on an empty URI' do - project2 = build(:project, import_url: '') + project2 = build(:empty_project, import_url: '') expect(project2.import_data).to be_nil end it 'does not produce import data on an invalid URI' do - project2 = build(:project, import_url: 'test://') + project2 = build(:empty_project, import_url: 'test://') expect(project2.import_data).to be_nil end + + describe 'project pending deletion' do + let!(:project_pending_deletion) do + create(:empty_project, + pending_delete: true) + end + let(:new_project) do + build(:empty_project, + name: project_pending_deletion.name, + namespace: project_pending_deletion.namespace) + end + + before do + new_project.validate + end + + it 'contains errors related to the project being deleted' do + expect(new_project.errors.full_messages.first).to eq('The project is still being deleted. Please try again later.') + end + end end describe 'default_scope' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index a786dc9edb3..12dd4bd83f7 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -50,6 +50,8 @@ describe API::Issues, api: true do end let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } + let(:no_milestone_title) { URI.escape(Milestone::None.title) } + before do project.team << [user, :reporter] project.team << [guest, :guest] @@ -107,6 +109,7 @@ describe API::Issues, api: true do it 'returns an array of labeled issues when at least one label matches' do get api("/issues?labels=#{label.title},foo,bar", user) + expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(1) @@ -136,6 +139,51 @@ describe API::Issues, api: true do expect(json_response.length).to eq(0) end + it 'returns an empty array if no issue matches milestone' do + get api("/issues?milestone=#{empty_milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an empty array if milestone does not exist' do + get api("/issues?milestone=foo", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + + it 'returns an array of issues in given milestone' do + get api("/issues?milestone=#{milestone.title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['id']).to eq(issue.id) + expect(json_response.second['id']).to eq(closed_issue.id) + end + + it 'returns an array of issues matching state in milestone' do + get api("/issues?milestone=#{milestone.title}"\ + '&state=closed', user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(closed_issue.id) + end + + it 'returns an array of issues with no milestone' do + get api("/issues?milestone=#{no_milestone_title}", author) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(confidential_issue.id) + end + it 'sorts by created_at descending by default' do get api('/issues', user) response_dates = json_response.map { |issue| issue['created_at'] } @@ -318,6 +366,15 @@ describe API::Issues, api: true do expect(json_response.first['id']).to eq(group_closed_issue.id) end + it 'returns an array of issues with no milestone' do + get api("#{base_url}?milestone=#{no_milestone_title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(group_confidential_issue.id) + end + it 'sorts by created_at descending by default' do get api(base_url, user) response_dates = json_response.map { |issue| issue['created_at'] } @@ -357,7 +414,6 @@ describe API::Issues, api: true do describe "GET /projects/:id/issues" do let(:base_url) { "/projects/#{project.id}" } - let(:title) { milestone.title } it "returns 404 on private projects for other users" do private_project = create(:empty_project, :private) @@ -433,8 +489,9 @@ describe API::Issues, api: true do expect(json_response.first['labels']).to eq([label.title]) end - it 'returns an array of labeled project issues when at least one label matches' do + it 'returns an array of labeled project issues where all labels match' do get api("#{base_url}/issues?labels=#{label.title},foo,bar", user) + expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(1) @@ -463,7 +520,8 @@ describe API::Issues, api: true do end it 'returns an array of issues in given milestone' do - get api("#{base_url}/issues?milestone=#{title}", user) + get api("#{base_url}/issues?milestone=#{milestone.title}", user) + expect(response).to have_http_status(200) expect(json_response).to be_an Array expect(json_response.length).to eq(2) @@ -480,6 +538,15 @@ describe API::Issues, api: true do expect(json_response.first['id']).to eq(closed_issue.id) end + it 'returns an array of issues with no milestone' do + get api("#{base_url}/issues?milestone=#{no_milestone_title}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(confidential_issue.id) + end + it 'sorts by created_at descending by default' do get api("#{base_url}/issues", user) response_dates = json_response.map { |issue| issue['created_at'] } @@ -547,12 +614,21 @@ describe API::Issues, api: true do it 'returns a project issue by iid' do get api("/projects/#{project.id}/issues?iid=#{issue.iid}", user) + expect(response.status).to eq 200 + expect(json_response.length).to eq 1 expect(json_response.first['title']).to eq issue.title expect(json_response.first['id']).to eq issue.id expect(json_response.first['iid']).to eq issue.iid end + it 'returns an empty array for an unknown project issue iid' do + get api("/projects/#{project.id}/issues?iid=#{issue.iid + 10}", user) + + expect(response.status).to eq 200 + expect(json_response.length).to eq 0 + end + it "returns 404 if issue id not found" do get api("/projects/#{project.id}/issues/54321", user) expect(response).to have_http_status(404) diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index ad9d8a25af4..91e3c333a02 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -16,6 +16,8 @@ describe API::Settings, 'Settings', api: true do expect(json_response['repository_storage']).to eq('default') expect(json_response['koding_enabled']).to be_falsey expect(json_response['koding_url']).to be_nil + expect(json_response['plantuml_enabled']).to be_falsey + expect(json_response['plantuml_url']).to be_nil end end @@ -28,7 +30,8 @@ describe API::Settings, 'Settings', api: true do it "updates application settings" do put api("/application/settings", admin), - default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com' + default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com', + plantuml_enabled: true, plantuml_url: 'http://plantuml.example.com' expect(response).to have_http_status(200) expect(json_response['default_projects_limit']).to eq(3) expect(json_response['signin_enabled']).to be_falsey @@ -36,6 +39,8 @@ describe API::Settings, 'Settings', api: true do expect(json_response['repository_storages']).to eq(['custom']) expect(json_response['koding_enabled']).to be_truthy expect(json_response['koding_url']).to eq('http://koding.example.com') + expect(json_response['plantuml_enabled']).to be_truthy + expect(json_response['plantuml_url']).to eq('http://plantuml.example.com') end end @@ -47,5 +52,14 @@ describe API::Settings, 'Settings', api: true do expect(json_response['error']).to eq('koding_url is missing') end end + + context "missing plantuml_url value when plantuml_enabled is true" do + it "returns a blank parameter error message" do + put api("/application/settings", admin), plantuml_enabled: true + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('plantuml_url is missing') + end + end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 2c2e17eddb0..5bf5bf0739e 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -137,6 +137,15 @@ describe API::Users, api: true do expect(new_user.can_create_group).to eq(true) end + it "creates user with optional attributes" do + optional_attributes = { confirm: true } + attributes = attributes_for(:user).merge(optional_attributes) + + post api('/users', admin), attributes + + expect(response).to have_http_status(201) + end + it "creates non-admin user" do post api('/users', admin), attributes_for(:user, admin: false, can_create_group: false) expect(response).to have_http_status(201) diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb index a8662e81d20..0333d73b5b5 100644 --- a/spec/serializers/commit_entity_spec.rb +++ b/spec/serializers/commit_entity_spec.rb @@ -33,10 +33,12 @@ describe CommitEntity do it 'contains path to commit' do expect(subject).to include(:commit_path) + expect(subject[:commit_path]).to include "commit/#{commit.id}" end it 'contains URL to commit' do expect(subject).to include(:commit_url) + expect(subject[:commit_path]).to include "commit/#{commit.id}" end it 'needs to receive project in the request' do diff --git a/spec/support/reactive_caching_helpers.rb b/spec/support/reactive_caching_helpers.rb index 279db3c5748..98eb57f8b54 100644 --- a/spec/support/reactive_caching_helpers.rb +++ b/spec/support/reactive_caching_helpers.rb @@ -3,31 +3,35 @@ module ReactiveCachingHelpers ([subject.class.reactive_cache_key.call(subject)].flatten + qualifiers).join(':') end - def stub_reactive_cache(subject = nil, data = nil) + def alive_reactive_cache_key(subject, *qualifiers) + reactive_cache_key(subject, *(qualifiers + ['alive'])) + end + + def stub_reactive_cache(subject = nil, data = nil, *qualifiers) allow(ReactiveCachingWorker).to receive(:perform_async) allow(ReactiveCachingWorker).to receive(:perform_in) - write_reactive_cache(subject, data) if data + write_reactive_cache(subject, data, *qualifiers) if data end - def read_reactive_cache(subject) - Rails.cache.read(reactive_cache_key(subject)) + def read_reactive_cache(subject, *qualifiers) + Rails.cache.read(reactive_cache_key(subject, *qualifiers)) end - def write_reactive_cache(subject, data) - start_reactive_cache_lifetime(subject) - Rails.cache.write(reactive_cache_key(subject), data) + def write_reactive_cache(subject, data, *qualifiers) + start_reactive_cache_lifetime(subject, *qualifiers) + Rails.cache.write(reactive_cache_key(subject, *qualifiers), data) end - def reactive_cache_alive?(subject) - Rails.cache.read(reactive_cache_key(subject, 'alive')) + def reactive_cache_alive?(subject, *qualifiers) + Rails.cache.read(alive_reactive_cache_key(subject, *qualifiers)) end - def invalidate_reactive_cache(subject) - Rails.cache.delete(reactive_cache_key(subject, 'alive')) + def invalidate_reactive_cache(subject, *qualifiers) + Rails.cache.delete(alive_reactive_cache_key(subject, *qualifiers)) end - def start_reactive_cache_lifetime(subject) - Rails.cache.write(reactive_cache_key(subject, 'alive'), true) + def start_reactive_cache_lifetime(subject, *qualifiers) + Rails.cache.write(alive_reactive_cache_key(subject, *qualifiers), true) end def expect_reactive_cache_update_queued(subject) diff --git a/spec/support/seed_helper.rb b/spec/support/seed_helper.rb index 3f8398a31e3..03fa0a66b9a 100644 --- a/spec/support/seed_helper.rb +++ b/spec/support/seed_helper.rb @@ -25,32 +25,32 @@ module SeedHelper end def create_bare_seeds - system(git_env, *%W(git clone --bare #{GITLAB_URL}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --bare #{GITLAB_URL}), chdir: SEED_REPOSITORY_PATH, out: '/dev/null', err: '/dev/null') end def create_normal_seeds - system(git_env, *%W(git clone #{TEST_REPO_PATH} #{TEST_NORMAL_REPO_PATH}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} clone #{TEST_REPO_PATH} #{TEST_NORMAL_REPO_PATH}), out: '/dev/null', err: '/dev/null') end def create_mutable_seeds - system(git_env, *%W(git clone #{TEST_REPO_PATH} #{TEST_MUTABLE_REPO_PATH}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} clone #{TEST_REPO_PATH} #{TEST_MUTABLE_REPO_PATH}), out: '/dev/null', err: '/dev/null') system(git_env, *%w(git branch -t feature origin/feature), chdir: TEST_MUTABLE_REPO_PATH, out: '/dev/null', err: '/dev/null') - system(git_env, *%W(git remote add expendable #{GITLAB_URL}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} remote add expendable #{GITLAB_URL}), chdir: TEST_MUTABLE_REPO_PATH, out: '/dev/null', err: '/dev/null') end def create_broken_seeds - system(git_env, *%W(git clone --bare #{TEST_REPO_PATH} #{TEST_BROKEN_REPO_PATH}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --bare #{TEST_REPO_PATH} #{TEST_BROKEN_REPO_PATH}), out: '/dev/null', err: '/dev/null') diff --git a/spec/support/stub_env.rb b/spec/support/stub_env.rb new file mode 100644 index 00000000000..18597b5c71f --- /dev/null +++ b/spec/support/stub_env.rb @@ -0,0 +1,7 @@ +module StubENV + def stub_env(key, value) + allow(ENV).to receive(:[]).and_call_original unless @env_already_stubbed + @env_already_stubbed ||= true + allow(ENV).to receive(:[]).with(key).and_return(value) + end +end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index a9fea5f1e81..bc751d20ce1 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -41,7 +41,7 @@ describe 'gitlab:app namespace rake task' do context 'gitlab version' do before do - allow(Dir).to receive(:glob).and_return([]) + allow(Dir).to receive(:glob).and_return(['1_gitlab_backup.tar']) allow(Dir).to receive(:chdir) allow(File).to receive(:exist?).and_return(true) allow(Kernel).to receive(:system).and_return(true) diff --git a/vendor/assets/javascripts/jquery.turbolinks.js b/vendor/assets/javascripts/jquery.turbolinks.js new file mode 100644 index 00000000000..fd6e95e75d5 --- /dev/null +++ b/vendor/assets/javascripts/jquery.turbolinks.js @@ -0,0 +1,49 @@ +// Generated by CoffeeScript 1.7.1 + +/* +jQuery.Turbolinks ~ https://github.com/kossnocorp/jquery.turbolinks +jQuery plugin for drop-in fix binded events problem caused by Turbolinks + +The MIT License +Copyright (c) 2012-2013 Sasha Koss & Rico Sta. Cruz + */ + +(function() { + var $, $document; + + $ = window.jQuery || (typeof require === "function" ? require('jquery') : void 0); + + $document = $(document); + + $.turbo = { + version: '2.1.0', + isReady: false, + use: function(load, fetch) { + return $document.off('.turbo').on("" + load + ".turbo", this.onLoad).on("" + fetch + ".turbo", this.onFetch); + }, + addCallback: function(callback) { + if ($.turbo.isReady) { + callback($); + } + return $document.on('turbo:ready', function() { + return callback($); + }); + }, + onLoad: function() { + $.turbo.isReady = true; + return $document.trigger('turbo:ready'); + }, + onFetch: function() { + return $.turbo.isReady = false; + }, + register: function() { + $(this.onLoad); + return $.fn.ready = this.addCallback; + } + }; + + $.turbo.register(); + + $.turbo.use('page:load', 'page:fetch'); + +}).call(this); diff --git a/vendor/gitignore/Android.gitignore b/vendor/gitignore/Android.gitignore index 935ceef0680..d028d1251ad 100644 --- a/vendor/gitignore/Android.gitignore +++ b/vendor/gitignore/Android.gitignore @@ -35,6 +35,8 @@ captures/ # Intellij *.iml .idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml .idea/libraries # Keystore files diff --git a/vendor/gitignore/Autotools.gitignore b/vendor/gitignore/Autotools.gitignore index 1e9158e2a85..e3923f96fce 100644 --- a/vendor/gitignore/Autotools.gitignore +++ b/vendor/gitignore/Autotools.gitignore @@ -1,6 +1,11 @@ # http://www.gnu.org/software/automake Makefile.in +/ar-lib +/mdate-sh +/py-compile +/test-driver +/ylwrap # http://www.gnu.org/software/autoconf @@ -9,10 +14,20 @@ Makefile.in /autoscan-*.log /aclocal.m4 /compile +/config.guess /config.h.in +/config.sub /configure /configure.scan /depcomp /install-sh /missing /stamp-h1 + +# https://www.gnu.org/software/libtool/ + +/ltmain.sh + +# http://www.gnu.org/software/texinfo + +/texinfo.tex diff --git a/vendor/gitignore/CMake.gitignore b/vendor/gitignore/CMake.gitignore index 0cc7e4b5275..27ada0591ec 100644 --- a/vendor/gitignore/CMake.gitignore +++ b/vendor/gitignore/CMake.gitignore @@ -4,4 +4,5 @@ CMakeScripts Makefile cmake_install.cmake install_manifest.txt +compile_commands.json CTestTestfile.cmake diff --git a/vendor/gitignore/Clojure.gitignore b/vendor/gitignore/Clojure.gitignore index a9fe6fba80d..7657a270c45 100644..120000 --- a/vendor/gitignore/Clojure.gitignore +++ b/vendor/gitignore/Clojure.gitignore @@ -1,13 +1 @@ -pom.xml -pom.xml.asc -*.jar -*.class -/lib/ -/classes/ -/target/ -/checkouts/ -.lein-deps-sum -.lein-repl-history -.lein-plugins/ -.lein-failures -.nrepl-port +Leiningen.gitignore
\ No newline at end of file diff --git a/vendor/gitignore/CodeIgniter.gitignore b/vendor/gitignore/CodeIgniter.gitignore index 0f77d9e1d17..60571a0c383 100644 --- a/vendor/gitignore/CodeIgniter.gitignore +++ b/vendor/gitignore/CodeIgniter.gitignore @@ -4,3 +4,8 @@ */cache/* !*/cache/index.html !*/cache/.htaccess + +user_guide_src/build/* +user_guide_src/cilexer/build/* +user_guide_src/cilexer/dist/* +user_guide_src/cilexer/pycilexer.egg-info/* diff --git a/vendor/gitignore/CommonLisp.gitignore b/vendor/gitignore/CommonLisp.gitignore index 4806e580b60..e7de127b014 100644 --- a/vendor/gitignore/CommonLisp.gitignore +++ b/vendor/gitignore/CommonLisp.gitignore @@ -1,3 +1,17 @@ *.FASL *.fasl *.lisp-temp +*.dfsl +*.pfsl +*.d64fsl +*.p64fsl +*.lx64fsl +*.lx32fsl +*.dx64fsl +*.dx32fsl +*.fx64fsl +*.fx32fsl +*.sx64fsl +*.sx32fsl +*.wx64fsl +*.wx32fsl diff --git a/vendor/gitignore/Coq.gitignore b/vendor/gitignore/Coq.gitignore index d3083b3a605..f25a61d9964 100644 --- a/vendor/gitignore/Coq.gitignore +++ b/vendor/gitignore/Coq.gitignore @@ -1,3 +1,30 @@ -*.vo +.*.aux +*.a +*.cma +*.cmi +*.cmo +*.cmx +*.cmxa +*.cmxs *.glob +*.ml.d +*.ml4.d +*.mli.d +*.mllib.d +*.mlpack.d +*.native +*.o *.v.d +*.vio +*.vo +.coq-native/ +.csdp.cache +.lia.cache +.nia.cache +.nlia.cache +.nra.cache +csdp.cache +lia.cache +nia.cache +nlia.cache +nra.cache diff --git a/vendor/gitignore/Dart.gitignore b/vendor/gitignore/Dart.gitignore index 7c280441649..4b366585ddc 100644 --- a/vendor/gitignore/Dart.gitignore +++ b/vendor/gitignore/Dart.gitignore @@ -1,13 +1,19 @@ # See https://www.dartlang.org/tools/private-files.html # Files and directories created by pub -.buildlog + +# SDK 1.20 and later (no longer creates packages directories) .packages -.project .pub/ build/ + +# Older SDK versions +# (Include if the minimum SDK version specified in pubsepc.yaml is earlier than 1.20) +.project +.buildlog **/packages/ + # Files created by dart2js # (Most Dart developers will use pub build to compile Dart, use/modify these # rules if you intend to use dart2js directly diff --git a/vendor/gitignore/Elisp.gitignore b/vendor/gitignore/Elisp.gitignore index 9b4291b7fe8..206569dc661 100644 --- a/vendor/gitignore/Elisp.gitignore +++ b/vendor/gitignore/Elisp.gitignore @@ -3,3 +3,9 @@ # Packaging .cask + +# Backup files +*~ + +# Undo-tree save-files +*.~undo-tree diff --git a/vendor/gitignore/Elixir.gitignore b/vendor/gitignore/Elixir.gitignore index 755b605549d..ac67aaf3243 100644 --- a/vendor/gitignore/Elixir.gitignore +++ b/vendor/gitignore/Elixir.gitignore @@ -3,3 +3,4 @@ /deps erl_crash.dump *.ez +*.beam diff --git a/vendor/gitignore/Global/Emacs.gitignore b/vendor/gitignore/Global/Emacs.gitignore index 0c96c9ad060..3ac7904dcd2 100644 --- a/vendor/gitignore/Global/Emacs.gitignore +++ b/vendor/gitignore/Global/Emacs.gitignore @@ -39,4 +39,7 @@ flycheck_*.el /server/ # projectiles files -.projectile
\ No newline at end of file +.projectile + +# directory configuration +.dir-locals.el diff --git a/vendor/gitignore/Global/IPythonNotebook.gitignore b/vendor/gitignore/Global/IPythonNotebook.gitignore deleted file mode 100644 index 27c13510bf5..00000000000 --- a/vendor/gitignore/Global/IPythonNotebook.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Temporary data -.ipynb_checkpoints/ diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore index 0a254147875..e375c744b6d 100644 --- a/vendor/gitignore/Global/JetBrains.gitignore +++ b/vendor/gitignore/Global/JetBrains.gitignore @@ -6,6 +6,7 @@ .idea/tasks.xml # Sensitive or high-churn files: +.idea/dataSources/ .idea/dataSources.ids .idea/dataSources.xml .idea/dataSources.local.xml diff --git a/vendor/gitignore/Global/SublimeText.gitignore b/vendor/gitignore/Global/SublimeText.gitignore index 69c8c2b29ce..95ff2244c99 100644 --- a/vendor/gitignore/Global/SublimeText.gitignore +++ b/vendor/gitignore/Global/SublimeText.gitignore @@ -20,6 +20,9 @@ Package Control.ca-bundle Package Control.system-ca-bundle Package Control.cache/ Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt bh_unicode_properties.cache # Sublime-github package stores a github token in this file diff --git a/vendor/gitignore/Global/Vim.gitignore b/vendor/gitignore/Global/Vim.gitignore index bdc04a0b529..42e7afc1005 100644 --- a/vendor/gitignore/Global/Vim.gitignore +++ b/vendor/gitignore/Global/Vim.gitignore @@ -1,6 +1,8 @@ # swap -[._]*.s[a-w][a-z] -[._]s[a-w][a-z] +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] # session Session.vim # temporary diff --git a/vendor/gitignore/Global/VisualStudioCode.gitignore b/vendor/gitignore/Global/VisualStudioCode.gitignore index d9960081c98..0511e2b51f0 100644 --- a/vendor/gitignore/Global/VisualStudioCode.gitignore +++ b/vendor/gitignore/Global/VisualStudioCode.gitignore @@ -2,3 +2,4 @@ !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json +!.vscode/extensions.json diff --git a/vendor/gitignore/Global/Windows.gitignore b/vendor/gitignore/Global/Windows.gitignore index a0d31452b0e..ba26afd9653 100644 --- a/vendor/gitignore/Global/Windows.gitignore +++ b/vendor/gitignore/Global/Windows.gitignore @@ -1,6 +1,7 @@ -# Windows image file caches +# Windows thumbnail cache files Thumbs.db ehthumbs.db +ehthumbs_vista.db # Folder config file Desktop.ini diff --git a/vendor/gitignore/Go.gitignore b/vendor/gitignore/Go.gitignore index 397a0ed4acb..5e1047c9d78 100644 --- a/vendor/gitignore/Go.gitignore +++ b/vendor/gitignore/Go.gitignore @@ -26,5 +26,5 @@ _testmain.go # Output of the go coverage tool, specifically when used with LiteIDE *.out -# external packages folder +# External packages folder vendor/ diff --git a/vendor/gitignore/Java.gitignore b/vendor/gitignore/Java.gitignore index 32858aad3c3..e44e0860405 100644 --- a/vendor/gitignore/Java.gitignore +++ b/vendor/gitignore/Java.gitignore @@ -1,5 +1,8 @@ *.class +# BlueJ files +*.ctxt + # Mobile Tools for Java (J2ME) .mtj.tmp/ diff --git a/vendor/gitignore/Laravel.gitignore b/vendor/gitignore/Laravel.gitignore index e7c594fa3e2..a2d1564060b 100644 --- a/vendor/gitignore/Laravel.gitignore +++ b/vendor/gitignore/Laravel.gitignore @@ -6,8 +6,8 @@ bootstrap/compiled.php app/storage/ # Laravel 5 & Lumen specific -bootstrap/cache/ public/storage +storage/*.key .env.*.php .env.php .env diff --git a/vendor/gitignore/Maven.gitignore b/vendor/gitignore/Maven.gitignore index 1cdc9f7fd45..9af45b175ae 100644 --- a/vendor/gitignore/Maven.gitignore +++ b/vendor/gitignore/Maven.gitignore @@ -7,3 +7,6 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties + +# Exclude maven wrapper +!/.mvn/wrapper/maven-wrapper.jar diff --git a/vendor/gitignore/Node.gitignore b/vendor/gitignore/Node.gitignore index bc7fc55724c..9a439fcd988 100644 --- a/vendor/gitignore/Node.gitignore +++ b/vendor/gitignore/Node.gitignore @@ -42,3 +42,7 @@ jspm_packages # Output of 'npm pack' *.tgz + +# Yarn Integrity file +.yarn-integrity + diff --git a/vendor/gitignore/Perl.gitignore b/vendor/gitignore/Perl.gitignore index ae2ad536abb..d41364ab18e 100644 --- a/vendor/gitignore/Perl.gitignore +++ b/vendor/gitignore/Perl.gitignore @@ -1,20 +1,34 @@ -/blib/ -/.build/ -_build/ -cover_db/ -inc/ -Build !Build/ -Build.bat .last_cover_stats -/Makefile -/Makefile.old -/MANIFEST.bak /META.yml /META.json /MYMETA.* -nytprof.out -/pm_to_blib *.o *.bs + +# Devel::Cover +cover_db/ + +# Devel::NYTProf +nytprof.out + +# Dizt::Zilla +/.build/ + +# Module::Build +_build/ +Build +Build.bat + +# Module::Install +inc/ + +# ExtUitls::MakeMaker +/blib/ /_eumm/ +/*.gz +/Makefile +/Makefile.old +/MANIFEST.bak +/pm_to_blib +/*.zip diff --git a/vendor/gitignore/Python.gitignore b/vendor/gitignore/Python.gitignore index 6a2bf47ade9..9a05e2debe5 100644 --- a/vendor/gitignore/Python.gitignore +++ b/vendor/gitignore/Python.gitignore @@ -20,6 +20,7 @@ lib64/ parts/ sdist/ var/ +wheels/ *.egg-info/ .installed.cfg *.egg diff --git a/vendor/gitignore/Symfony.gitignore b/vendor/gitignore/Symfony.gitignore index 7d56f982f81..ed4d3c6c28d 100644 --- a/vendor/gitignore/Symfony.gitignore +++ b/vendor/gitignore/Symfony.gitignore @@ -31,9 +31,6 @@ /web/bundles/ /web/uploads/ -# Assets managed by Bower -/web/assets/vendor/ - # PHPUnit /app/phpunit.xml /phpunit.xml @@ -45,4 +42,4 @@ /composer.phar # Backup entities generated with doctrine:generate:entities command -*/Entity/*~ +**/Entity/*~ diff --git a/vendor/gitignore/TeX.gitignore b/vendor/gitignore/TeX.gitignore index 1afbaf197f4..69bfb1eec3e 100644 --- a/vendor/gitignore/TeX.gitignore +++ b/vendor/gitignore/TeX.gitignore @@ -52,12 +52,22 @@ acs-*.bib # beamer *.nav +*.pre *.snm *.vrb +# changes +*.soc + # cprotect *.cpt +# elsarticle (documentclass of Elsevier journals) +*.spl + +# endnotes +*.ent + # fixme *.lox @@ -123,9 +133,7 @@ acs-*.bib *.maf *.mlf *.mlt -*.mtc -*.mtc[0-9] -*.mtc[1-9][0-9] +*.mtc[0-9]* # minted _minted* @@ -140,6 +148,9 @@ _minted* # nomencl *.nlo +# pax +*.pax + # sagetex *.sagetex.sage *.sagetex.py @@ -202,5 +213,8 @@ TSWLatexianTemp* # KBibTeX *~[0-9]* -# auto folder when using emacs and auctex +# auto folder when using emacs and auctex /auto/* + +# expex forward references with \gathertags +*-tags.tex diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore index 09e407344ca..d9e876cfcdd 100644 --- a/vendor/gitignore/VisualStudio.gitignore +++ b/vendor/gitignore/VisualStudio.gitignore @@ -8,7 +8,6 @@ *.user *.userosscache *.sln.docstates -*.vcxproj.filters # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -43,11 +42,11 @@ TestResult.xml [Rr]eleasePS/ dlldata.c -# DNX +# .NET Core project.lock.json project.fragment.lock.json artifacts/ -Properties/launchSettings.json +**/Properties/launchSettings.json *_i.c *_p.c diff --git a/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml b/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml index f3fa3949656..8c590579934 100644 --- a/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Docker.gitlab-ci.yml @@ -7,6 +7,7 @@ services: build: stage: build script: + - export IMAGE_TAG=$(echo -en $CI_BUILD_REF_NAME | tr -c '[:alnum:]_.-' '-') - docker login -u "gitlab-ci-token" -p "$CI_BUILD_TOKEN" $CI_REGISTRY - - docker build --pull -t "$CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME" . - - docker push "$CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME" + - docker build --pull -t "$CI_REGISTRY_IMAGE:$IMAGE_TAG" . + - docker push "$CI_REGISTRY_IMAGE:$IMAGE_TAG" diff --git a/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml b/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml index 00f9541e89b..981a77497e2 100644 --- a/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Elixir.gitlab-ci.yml @@ -1,6 +1,4 @@ -# This template uses the non default language docker image -# The image already has Hex installed. You might want to consider to use `elixir:latest` -image: trenpixster/elixir:latest +image: elixir:latest # Pick zero or more services to be used on all builds. # Only needed when using a docker container to run your tests in. @@ -11,6 +9,8 @@ services: - postgres:latest before_script: + - mix local.rebar --force + - mix local.hex --force - mix deps.get mix: diff --git a/vendor/gitlab-ci-yml/Go.gitlab-ci.yml b/vendor/gitlab-ci-yml/Go.gitlab-ci.yml new file mode 100644 index 00000000000..e23b6e212f0 --- /dev/null +++ b/vendor/gitlab-ci-yml/Go.gitlab-ci.yml @@ -0,0 +1,37 @@ +image: golang:latest + +# The problem is that to be able to use go get, one needs to put +# the repository in the $GOPATH. So for example if your gitlab domain +# is mydomainperso.com, and that your repository is repos/projectname, and +# the default GOPATH being /go, then you'd need to have your +# repository in /go/src/mydomainperso.com/repos/projectname +# Thus, making a symbolic link corrects this. +before_script: + - ln -s /builds /go/src/mydomainperso.com + - cd /go/src/mydomainperso.com/repos/projectname + +stages: + - test + - build + +format: + stage: test + script: + # Add here all the dependencies, or use glide/govendor to get + # them automatically. + # - curl https://glide.sh/get | sh + - go get github.com/alecthomas/kingpin + - go tool vet -composites=false -shadow=true *.go + - go test -race $(go list ./... | grep -v /vendor/) + +compile: + stage: build + script: + # Add here all the dependencies, or use glide/govendor/... + # to get them automatically. + - go get github.com/alecthomas/kingpin + # Better put this in a Makefile + - go build -race -ldflags "-extldflags '-static'" -o mybinary + artifacts: + paths: + - mybinary diff --git a/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml b/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml index 263c4c19999..98d3039ad06 100644 --- a/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml @@ -31,4 +31,4 @@ build: test: stage: test script: - - ./gradlew -g /cache./gradle check + - ./gradlew -g /cache/.gradle check diff --git a/vendor/gitlab-ci-yml/Openshift.gitlab-ci.yml b/vendor/gitlab-ci-yml/Openshift.gitlab-ci.yml new file mode 100644 index 00000000000..2ba5cad9682 --- /dev/null +++ b/vendor/gitlab-ci-yml/Openshift.gitlab-ci.yml @@ -0,0 +1,92 @@ +# This file is a template, and might need editing before it works on your project. +image: ayufan/openshift-cli + +stages: + - test + - review + - staging + - production + +variables: + OPENSHIFT_SERVER: openshift.default.svc.cluster.local + # OPENSHIFT_DOMAIN: apps.example.com + # Configure this variable in Secure Variables: + # OPENSHIFT_TOKEN: my.openshift.token + +test1: + stage: test + before_script: [] + script: + - echo run tests + +test2: + stage: test + before_script: [] + script: + - echo run tests + +.deploy: &deploy + before_script: + - oc login "$OPENSHIFT_SERVER" --token="$OPENSHIFT_TOKEN" --insecure-skip-tls-verify + - oc project "$CI_PROJECT_NAME" 2> /dev/null || oc new-project "$CI_PROJECT_NAME" + script: + - "oc get services $APP 2> /dev/null || oc new-app . --name=$APP --strategy=docker" + - "oc start-build $APP --from-dir=. --follow || sleep 3s || oc start-build $APP --from-dir=. --follow" + - "oc get routes $APP 2> /dev/null || oc expose service $APP --hostname=$APP_HOST" + +review: + <<: *deploy + stage: review + variables: + APP: $CI_BUILD_REF_NAME + APP_HOST: $CI_PROJECT_NAME-$CI_BUILD_REF_NAME.$OPENSHIFT_DOMAIN + environment: + name: review/$CI_BUILD_REF_NAME + url: http://$CI_PROJECT_NAME-$CI_BUILD_REF_NAME.$OPENSHIFT_DOMAIN + on_stop: stop-review + only: + - branches + except: + - master + +stop-review: + <<: *deploy + stage: review + script: + - oc delete all -l "app=$APP" + when: manual + variables: + APP: $CI_BUILD_REF_NAME + GIT_STRATEGY: none + environment: + name: review/$CI_BUILD_REF_NAME + action: stop + only: + - branches + except: + - master + +staging: + <<: *deploy + stage: staging + variables: + APP: staging + APP_HOST: $CI_PROJECT_NAME-staging.$OPENSHIFT_DOMAIN + environment: + name: staging + url: http://$CI_PROJECT_NAME-staging.$OPENSHIFT_DOMAIN + only: + - master + +production: + <<: *deploy + stage: production + variables: + APP: production + APP_HOST: $CI_PROJECT_NAME.$OPENSHIFT_DOMAIN + when: manual + environment: + name: production + url: http://$CI_PROJECT_NAME.$OPENSHIFT_DOMAIN + only: + - master diff --git a/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml b/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml index e384b585ae0..249adbc9f4a 100644 --- a/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/autodeploy/OpenShift.gitlab-ci.yml @@ -1,3 +1,5 @@ +# Explaination on the scripts: +# https://gitlab.com/gitlab-examples/openshift-deploy/blob/master/README.md image: registry.gitlab.com/gitlab-examples/openshift-deploy variables: |